This commit is contained in:
Ernad Husremovic 2025-08-29 17:40:39 +02:00
parent 12c29a983b
commit 95fcc8bd63
189 changed files with 170858 additions and 0 deletions

View file

@ -0,0 +1,9 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import common
from . import http_common
from . import test_flows
from . import test_multicompany_flows
from . import test_payment_provider
from . import test_payment_token
from . import test_payment_transaction

View file

@ -0,0 +1,243 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import logging
from unittest.mock import patch
from lxml import objectify
from odoo.fields import Command
from odoo.tests.common import TransactionCase
from odoo.tools.misc import hmac as hmac_tool
_logger = logging.getLogger(__name__)
class PaymentCommon(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.currency_euro = cls._prepare_currency('EUR')
cls.currency_usd = cls._prepare_currency('USD')
cls.country_belgium = cls.env.ref('base.be')
cls.country_france = cls.env.ref('base.fr')
cls.europe = cls.env.ref('base.europe')
cls.group_user = cls.env.ref('base.group_user')
cls.group_portal = cls.env.ref('base.group_portal')
cls.group_public = cls.env.ref('base.group_public')
cls.admin_user = cls.env.ref('base.user_admin')
cls.internal_user = cls.env['res.users'].create({
'name': 'Internal User (Test)',
'login': 'internal',
'password': 'internal',
'groups_id': [Command.link(cls.group_user.id)]
})
cls.portal_user = cls.env['res.users'].create({
'name': 'Portal User (Test)',
'login': 'payment_portal',
'password': 'payment_portal',
'groups_id': [Command.link(cls.group_portal.id)]
})
cls.public_user = cls.env.ref('base.public_user')
cls.admin_partner = cls.admin_user.partner_id
cls.internal_partner = cls.internal_user.partner_id
cls.portal_partner = cls.portal_user.partner_id
cls.default_partner = cls.env['res.partner'].create({
'name': 'Norbert Buyer',
'lang': 'en_US',
'email': 'norbert.buyer@example.com',
'street': 'Huge Street',
'street2': '2/543',
'phone': '0032 12 34 56 78',
'city': 'Sin City',
'zip': '1000',
'country_id': cls.country_belgium.id,
})
# Create a dummy provider to allow basic tests without any specific provider implementation
arch = """
<form action="dummy" method="post">
<input type="hidden" name="view_id" t-att-value="viewid"/>
<input type="hidden" name="user_id" t-att-value="user_id.id"/>
</form>
""" # We exploit the default values `viewid` and `user_id` from QWeb's rendering context
redirect_form = cls.env['ir.ui.view'].create({
'name': "Dummy Redirect Form",
'type': 'qweb',
'arch': arch,
})
cls.dummy_provider = cls.env['payment.provider'].create({
'name': "Dummy Provider",
'code': 'none',
'state': 'test',
'is_published': True,
'allow_tokenization': True,
'redirect_form_view_id': redirect_form.id,
})
cls.provider = cls.dummy_provider
cls.amount = 1111.11
cls.company = cls.env.company
cls.company_id = cls.company.id
cls.currency = cls.currency_euro
cls.partner = cls.default_partner
cls.reference = "Test Transaction"
account_payment_module = cls.env['ir.module.module']._get('account_payment')
cls.account_payment_installed = account_payment_module.state in ('installed', 'to upgrade')
cls.enable_reconcile_after_done_patcher = True
def setUp(self):
super().setUp()
if self.account_payment_installed and self.enable_reconcile_after_done_patcher:
# disable account payment generation if account_payment is installed
# because the accounting setup of providers is not managed in this common
self.reconcile_after_done_patcher = patch(
'odoo.addons.account_payment.models.payment_transaction.PaymentTransaction._reconcile_after_done',
)
self.startPatcher(self.reconcile_after_done_patcher)
#=== Utils ===#
@classmethod
def _prepare_currency(cls, currency_code):
currency = cls.env['res.currency'].with_context(active_test=False).search(
[('name', '=', currency_code.upper())]
)
currency.action_unarchive()
return currency
@classmethod
def _prepare_provider(cls, code='none', company=None, update_values=None):
""" Prepare and return the first provider matching the given provider and company.
If no provider is found in the given company, we duplicate the one from the base company.
All other providers belonging to the same company are disabled to avoid any interferences.
:param str code: The code of the provider to prepare
:param recordset company: The company of the provider to prepare, as a `res.company` record
:param dict update_values: The values used to update the provider
:return: The provider to prepare, if found
:rtype: recordset of `payment.provider`
"""
company = company or cls.env.company
update_values = update_values or {}
provider = cls.env['payment.provider'].sudo().search(
[('code', '=', code), ('company_id', '=', company.id)], limit=1
)
if not provider:
base_provider = cls.env['payment.provider'].sudo().search(
[('code', '=', code)], limit=1
)
if not base_provider:
_logger.error("no payment.provider found for code %s", code)
return cls.env['payment.provider']
else:
provider = base_provider.copy({'company_id': company.id})
update_values['state'] = 'test'
provider.write(update_values)
return provider
@classmethod
def _prepare_user(cls, user, group_xmlid):
user.groups_id = [Command.link(cls.env.ref(group_xmlid).id)]
# Flush and invalidate the cache to allow checking access rights.
user.flush_recordset()
user.invalidate_recordset()
return user
def _create_transaction(self, flow, sudo=True, **values):
default_values = {
'amount': self.amount,
'currency_id': self.currency.id,
'provider_id': self.provider.id,
'reference': self.reference,
'operation': f'online_{flow}',
'partner_id': self.partner.id,
}
return self.env['payment.transaction'].sudo(sudo).create(dict(default_values, **values))
def _create_token(self, sudo=True, **values):
default_values = {
'payment_details': "1234",
'provider_id': self.provider.id,
'partner_id': self.partner.id,
'provider_ref': "provider Ref (TEST)",
'active': True,
}
return self.env['payment.token'].sudo(sudo).create(dict(default_values, **values))
def _get_tx(self, reference):
return self.env['payment.transaction'].sudo().search([
('reference', '=', reference),
])
def _generate_test_access_token(self, *values):
""" Generate an access token based on the provided values for testing purposes.
This methods returns a token identical to that generated by
payment.utils.generate_access_token but uses the test class environment rather than the
environment of odoo.http.request.
See payment.utils.generate_access_token for additional details.
:param list values: The values to use for the generation of the token
:return: The generated access token
:rtype: str
"""
token_str = '|'.join(str(val) for val in values)
access_token = hmac_tool(self.env(su=True), 'generate_access_token', token_str)
return access_token
def _extract_values_from_html_form(self, html_form):
""" Extract the transaction rendering values from an HTML form.
:param str html_form: The HTML form
:return: The extracted information (action & inputs)
:rtype: dict[str:str]
"""
html_tree = objectify.fromstring(html_form)
if hasattr(html_tree, 'input'):
inputs = {input_.get('name'): input_.get('value') for input_ in html_tree.input}
else:
inputs = {}
return {
'action': html_tree.get('action'),
'method': html_tree.get('method'),
'inputs': inputs,
}
def _assert_does_not_raise(self, exception_class, func, *args, **kwargs):
""" Fail if an exception of the provided class is raised when calling the function.
If an exception of any other class is raised, it is caught and silently ignored.
This method cannot be used with functions that make requests. Any exception raised in the
scope of the new request will not be caught and will make the test fail.
:param class exception_class: The class of the exception to monitor.
:param function fun: The function to call when monitoring for exceptions.
:param list args: The positional arguments passed as-is to the called function.
:param dict kwargs: The keyword arguments passed as-is to the called function.
:return: None
"""
try:
func(*args, **kwargs)
except exception_class:
self.fail(f"{func.__name__} should not raise error of class {exception_class.__name__}")
except Exception:
pass # Any exception whose class is not monitored is caught and ignored.
def _skip_if_account_payment_is_not_installed(self):
""" Skip current test if `account_payment` module is not installed. """
if not self.account_payment_installed:
self.skipTest("account_payment module is not installed")

View file

@ -0,0 +1,240 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import json
from uuid import uuid4
from lxml import etree, objectify
from werkzeug import urls
from odoo.tests import HttpCase
from odoo.addons.payment.tests.common import PaymentCommon
class PaymentHttpCommon(PaymentCommon, HttpCase):
""" HttpCase common to build and simulate requests going through payment controllers.
Only use if you effectively want to test controllers.
If you only want to test 'models' code, the PaymentCommon should be sufficient.
"""
# Helpers #
###########
def _build_url(self, route):
return urls.url_join(self.base_url(), route)
def _make_http_get_request(self, url, params=None):
""" Make an HTTP GET request to the provided URL.
:param str url: The URL to make the request to
:param dict params: The parameters to be sent in the query string
:return: The response of the request
:rtype: :class:`requests.models.Response`
"""
formatted_params = self._format_http_request_payload(payload=params)
return self.opener.get(url, params=formatted_params)
def _make_http_post_request(self, url, data=None):
""" Make an HTTP POST request to the provided URL.
:param str url: The URL to make the request to
:param dict data: The data to be send in the request body
:return: The response of the request
:rtype: :class:`requests.models.Response`
"""
formatted_data = self._format_http_request_payload(payload=data)
return self.opener.post(url, data=formatted_data)
def _format_http_request_payload(self, payload=None):
""" Format a request payload to replace float values by their string representation.
:param dict payload: The payload to format
:return: The formatted payload
:rtype: dict
"""
formatted_payload = {}
if payload is not None:
for k, v in payload.items():
formatted_payload[k] = str(v) if isinstance(v, float) else v
return formatted_payload
def _make_json_request(self, url, data=None):
""" Make a JSON request to the provided URL.
:param str url: The URL to make the request to
:param dict data: The data to be send in the request body in JSON format
:return: The response of the request
:rtype: :class:`requests.models.Response`
"""
return self.opener.post(url, json=data)
def _make_json_rpc_request(self, url, data=None):
""" Make a JSON-RPC request to the provided URL.
:param str url: The URL to make the request to
:param dict data: The data to be send in the request body in JSON-RPC 2.0 format
:return: The response of the request
:rtype: :class:`requests.models.Response`
"""
return self.opener.post(url, json={
'jsonrpc': '2.0',
'method': 'call',
'id': str(uuid4()),
'params': data,
})
def _get_tx_context(self, response, form_name):
"""Extracts txContext & other form info (provider & token ids)
from a payment response (with manage/checkout html form)
:param response: http Response, with a payment form as text
:param str form_name: o_payment_manage / o_payment_checkout
:return: Transaction context (+ provider_ids & token_ids)
:rtype: dict
"""
# Need to specify an HTML parser as parser
# Otherwise void elements (<img>, <link> without a closing / tag)
# are considered wrong and trigger a lxml.etree.XMLSyntaxError
html_tree = objectify.fromstring(
response.text,
parser=etree.HTMLParser(),
)
checkout_form = html_tree.xpath(f"//form[@name='{form_name}']")[0]
values = {}
for key, val in checkout_form.items():
if key.startswith("data-"):
formatted_key = key[5:].replace('-', '_')
if formatted_key.endswith('_id'):
formatted_val = int(val)
elif formatted_key == 'amount':
formatted_val = float(val)
else:
formatted_val = val
values[formatted_key] = formatted_val
payment_options_inputs = html_tree.xpath("//input[@name='o_payment_radio']")
provider_ids = []
token_ids = []
for p_o_input in payment_options_inputs:
data = dict()
for key, val in p_o_input.items():
if key.startswith('data-'):
data[key[5:]] = val
if data['payment-option-type'] == 'provider':
provider_ids.append(int(data['payment-option-id']))
else:
token_ids.append(int(data['payment-option-id']))
values.update({
'provider_ids': provider_ids,
'token_ids': token_ids,
})
return values
# payment/pay #
###############
def _prepare_pay_values(self, amount=0.0, currency=None, reference='', partner=None):
"""Prepare basic payment/pay route values
NOTE: needs PaymentCommon to enable fallback values.
:rtype: dict
"""
amount = amount or self.amount
currency = currency or self.currency
reference = reference or self.reference
partner = partner or self.partner
return {
'amount': amount,
'currency_id': currency.id,
'reference': reference,
'partner_id': partner.id,
'access_token': self._generate_test_access_token(partner.id, amount, currency.id),
}
def _portal_pay(self, **route_kwargs):
"""/payment/pay txContext feedback
NOTE: must be authenticated before calling method.
Or an access_token should be specified in route_kwargs
"""
uri = '/payment/pay'
url = self._build_url(uri)
return self._make_http_get_request(url, route_kwargs)
def _get_tx_checkout_context(self, **route_kwargs):
response = self._portal_pay(**route_kwargs)
self.assertEqual(response.status_code, 200)
return self._get_tx_context(response, 'o_payment_checkout')
# /my/payment_method #
######################
def _portal_payment_method(self):
"""/my/payment_method txContext feedback
NOTE: must be authenticated before calling method
validation flow is restricted to logged users
"""
uri = '/my/payment_method'
url = self._build_url(uri)
return self._make_http_get_request(url, {})
def _get_tx_manage_context(self):
response = self._portal_payment_method()
self.assertEqual(response.status_code, 200)
return self._get_tx_context(response, 'o_payment_manage')
# payment/transaction #
#######################
def _prepare_transaction_values(self, payment_option_id, flow):
""" Prepare the basic payment/transaction route values.
:param int payment_option_id: The payment option handling the transaction, as a
`payment.provider` id or a `payment.token` id
:param str flow: The payment flow
:return: The route values
:rtype: dict
"""
return {
'amount': self.amount,
'currency_id': self.currency.id,
'partner_id': self.partner.id,
'access_token': self._generate_test_access_token(
self.partner.id, self.amount, self.currency.id
),
'payment_option_id': payment_option_id,
'reference_prefix': 'test',
'tokenization_requested': True,
'landing_route': 'Test',
'is_validation': False,
'flow': flow,
}
def _portal_transaction(self, **route_kwargs):
"""/payment/transaction feedback
:return: The response to the json request
"""
uri = '/payment/transaction'
url = self._build_url(uri)
response = self._make_json_rpc_request(url, route_kwargs)
self.assertEqual(response.status_code, 200) # Check the request went through.
return response
def _get_processing_values(self, **route_kwargs):
response = self._portal_transaction(**route_kwargs)
self.assertEqual(response.status_code, 200)
resp_content = json.loads(response.content)
return resp_content['result']

View file

@ -0,0 +1,394 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from urllib.parse import urlparse, parse_qs
from unittest.mock import patch
from freezegun import freeze_time
from odoo.tests import tagged
from odoo.tools import mute_logger
from odoo.addons.payment.controllers.portal import PaymentPortal
from odoo.addons.payment.tests.http_common import PaymentHttpCommon
@tagged('post_install', '-at_install')
class TestFlows(PaymentHttpCommon):
def _test_flow(self, flow):
""" Simulate the given online payment flow and tests the tx values at each step.
:param str flow: The online payment flow to test ('direct', 'redirect', or 'token')
:return: The transaction created by the payment flow
:rtype: recordset of `payment.transaction`
"""
self.reference = f"Test Transaction ({flow} - {self.partner.name})"
route_values = self._prepare_pay_values()
# /payment/pay
tx_context = self._get_tx_checkout_context(**route_values)
for key, val in tx_context.items():
if key in route_values:
self.assertEqual(val, route_values[key])
self.assertIn(self.provider.id, tx_context['provider_ids'])
# Route values are taken from tx_context result of /pay route to correctly simulate the flow
route_values = {
k: tx_context[k]
for k in [
'amount',
'currency_id',
'reference_prefix',
'partner_id',
'access_token',
'landing_route',
]
}
route_values.update({
'flow': flow,
'payment_option_id': self.provider.id,
'tokenization_requested': False,
})
if flow == 'token':
route_values['payment_option_id'] = self._create_token().id
with mute_logger('odoo.addons.payment.models.payment_transaction'):
processing_values = self._get_processing_values(**route_values)
tx_sudo = self._get_tx(processing_values['reference'])
# Tx values == given values
self.assertEqual(tx_sudo.provider_id.id, self.provider.id)
self.assertEqual(tx_sudo.amount, self.amount)
self.assertEqual(tx_sudo.currency_id.id, self.currency.id)
self.assertEqual(tx_sudo.partner_id.id, self.partner.id)
self.assertEqual(tx_sudo.reference, self.reference)
# processing_values == given values
self.assertEqual(processing_values['provider_id'], self.provider.id)
self.assertEqual(processing_values['amount'], self.amount)
self.assertEqual(processing_values['currency_id'], self.currency.id)
self.assertEqual(processing_values['partner_id'], self.partner.id)
self.assertEqual(processing_values['reference'], self.reference)
# Verify computed values not provided, but added during the flow
self.assertIn("tx_id=", tx_sudo.landing_route)
self.assertIn("access_token=", tx_sudo.landing_route)
if flow == 'redirect':
# In redirect flow, we verify the rendering of the dummy test form
redirect_form_info = self._extract_values_from_html_form(
processing_values['redirect_form_html'])
# Test content of rendered dummy redirect form
self.assertEqual(redirect_form_info['action'], 'dummy')
# Public user since we didn't authenticate with a specific user
self.assertEqual(
redirect_form_info['inputs']['user_id'],
str(self.user.id))
self.assertEqual(
redirect_form_info['inputs']['view_id'],
str(self.dummy_provider.redirect_form_view_id.id))
return tx_sudo
def test_10_direct_checkout_public(self):
# No authentication needed, automatic fallback on public user
self.user = self.public_user
# Make sure the company considered in payment/pay
# doesn't fall back on the public user main company (not the test one)
self.partner.company_id = self.env.company.id
self._test_flow('direct')
def test_11_direct_checkout_portal(self):
self.authenticate(self.portal_user.login, self.portal_user.login)
self.user = self.portal_user
self.partner = self.portal_partner
self._test_flow('direct')
def test_12_direct_checkout_internal(self):
self.authenticate(self.internal_user.login, self.internal_user.login)
self.user = self.internal_user
self.partner = self.internal_partner
self._test_flow('direct')
def test_20_redirect_checkout_public(self):
self.user = self.public_user
# Make sure the company considered in payment/pay
# doesn't fall back on the public user main company (not the test one)
self.partner.company_id = self.env.company.id
self._test_flow('redirect')
def test_21_redirect_checkout_portal(self):
self.authenticate(self.portal_user.login, self.portal_user.login)
self.user = self.portal_user
self.partner = self.portal_partner
self._test_flow('redirect')
def test_22_redirect_checkout_internal(self):
self.authenticate(self.internal_user.login, self.internal_user.login)
self.user = self.internal_user
self.partner = self.internal_partner
self._test_flow('redirect')
# Payment by token #
####################
# NOTE: not tested as public user because a public user cannot save payment details
def test_31_tokenize_portal(self):
self.authenticate(self.portal_user.login, self.portal_user.login)
self.partner = self.portal_partner
self.user = self.portal_user
self._test_flow('token')
def test_32_tokenize_internal(self):
self.authenticate(self.internal_user.login, self.internal_user.login)
self.partner = self.internal_partner
self.user = self.internal_user
self._test_flow('token')
# VALIDATION #
##############
# NOTE: not tested as public user because the validation flow is only available when logged in
# freeze time for consistent singularize_prefix behavior during the test
@freeze_time("2011-11-02 12:00:21")
def _test_validation(self, flow):
# Fixed with freezegun
expected_reference = 'V-20111102120021'
validation_amount = self.provider._get_validation_amount()
validation_currency = self.provider._get_validation_currency()
tx_context = self._get_tx_manage_context()
expected_values = {
'partner_id': self.partner.id,
'access_token': self._generate_test_access_token(self.partner.id, None, None),
'reference_prefix': expected_reference
}
for key, val in tx_context.items():
if key in expected_values:
self.assertEqual(val, expected_values[key])
transaction_values = {
'amount': None,
'currency_id': None,
'partner_id': tx_context['partner_id'],
'access_token': tx_context['access_token'],
'flow': flow,
'payment_option_id': self.provider.id,
'tokenization_requested': True,
'reference_prefix': tx_context['reference_prefix'],
'landing_route': tx_context['landing_route'],
'is_validation': True,
}
with mute_logger('odoo.addons.payment.models.payment_transaction'):
processing_values = self._get_processing_values(**transaction_values)
tx_sudo = self._get_tx(processing_values['reference'])
# Tx values == given values
self.assertEqual(tx_sudo.provider_id.id, self.provider.id)
self.assertEqual(tx_sudo.amount, validation_amount)
self.assertEqual(tx_sudo.currency_id.id, validation_currency.id)
self.assertEqual(tx_sudo.partner_id.id, self.partner.id)
self.assertEqual(tx_sudo.reference, expected_reference)
# processing_values == given values
self.assertEqual(processing_values['provider_id'], self.provider.id)
self.assertEqual(processing_values['amount'], validation_amount)
self.assertEqual(processing_values['currency_id'], validation_currency.id)
self.assertEqual(processing_values['partner_id'], self.partner.id)
self.assertEqual(processing_values['reference'], expected_reference)
def test_51_validation_direct_portal(self):
self.authenticate(self.portal_user.login, self.portal_user.login)
self.partner = self.portal_partner
self._test_validation(flow='direct')
def test_52_validation_direct_internal(self):
self.authenticate(self.internal_user.login, self.internal_user.login)
self.partner = self.internal_partner
self._test_validation(flow='direct')
def test_61_validation_redirect_portal(self):
self.authenticate(self.portal_user.login, self.portal_user.login)
self.partner = self.portal_partner
self._test_validation(flow='direct')
def test_62_validation_redirect_internal(self):
self.authenticate(self.internal_user.login, self.internal_user.login)
self.partner = self.internal_partner
self._test_validation(flow='direct')
# Specific flows #
##################
def test_pay_redirect_if_no_partner_exist(self):
route_values = self._prepare_pay_values()
route_values.pop('partner_id')
# Pay without a partner specified --> redirection to login page
response = self._portal_pay(**route_values)
url = urlparse(response.url)
self.assertEqual(url.path, '/web/login')
self.assertIn('redirect', parse_qs(url.query))
# Pay without a partner specified (but logged) --> pay with the partner of current user.
self.authenticate(self.portal_user.login, self.portal_user.login)
tx_context = self._get_tx_checkout_context(**route_values)
self.assertEqual(tx_context['partner_id'], self.portal_partner.id)
def test_pay_no_token(self):
route_values = self._prepare_pay_values()
route_values.pop('partner_id')
route_values.pop('access_token')
# Pay without a partner specified --> redirection to login page
response = self._portal_pay(**route_values)
url = urlparse(response.url)
self.assertEqual(url.path, '/web/login')
self.assertIn('redirect', parse_qs(url.query))
# Pay without a partner specified (but logged) --> pay with the partner of current user.
self.authenticate(self.portal_user.login, self.portal_user.login)
tx_context = self._get_tx_checkout_context(**route_values)
self.assertEqual(tx_context['partner_id'], self.portal_partner.id)
def test_pay_wrong_token(self):
route_values = self._prepare_pay_values()
route_values['access_token'] = "abcde"
# Pay with a wrong access token --> Not found (404)
response = self._portal_pay(**route_values)
self.assertEqual(response.status_code, 404)
def test_pay_wrong_currency(self):
# Pay with a wrong currency --> Not found (404)
self.currency = self.env['res.currency'].browse(self.env['res.currency'].search([], order='id desc', limit=1).id + 1000)
route_values = self._prepare_pay_values()
response = self._portal_pay(**route_values)
self.assertEqual(response.status_code, 404)
# Pay with an inactive currency --> Not found (404)
self.currency = self.env['res.currency'].search([('active', '=', False)], limit=1)
route_values = self._prepare_pay_values()
response = self._portal_pay(**route_values)
self.assertEqual(response.status_code, 404)
def test_transaction_wrong_flow(self):
transaction_values = self._prepare_pay_values()
transaction_values.update({
'flow': 'this flow does not exist',
'payment_option_id': self.provider.id,
'tokenization_requested': False,
'reference_prefix': 'whatever',
'landing_route': 'whatever',
})
# Transaction step with a wrong flow --> UserError
with mute_logger('odoo.http'):
response = self._portal_transaction(**transaction_values)
self.assertIn(
"odoo.exceptions.UserError: The payment should either be direct, with redirection, or made by a token.",
response.text)
def test_transaction_wrong_token(self):
route_values = self._prepare_pay_values()
route_values['access_token'] = "abcde"
# Transaction step with a wrong access token --> ValidationError
with mute_logger('odoo.http'):
response = self._portal_transaction(**route_values)
self.assertIn(
"odoo.exceptions.ValidationError: The access token is invalid.",
response.text)
def test_access_disabled_providers_tokens(self):
self.partner = self.portal_partner
# Log in as user from Company A
self.authenticate(self.portal_user.login, self.portal_user.login)
token = self._create_token()
provider_b = self.provider.copy()
provider_b.state = 'test'
token_b = self._create_token(provider_id=provider_b.id)
# User must see both enabled providers and tokens
manage_context = self._get_tx_manage_context()
self.assertEqual(manage_context['partner_id'], self.partner.id)
self.assertIn(self.provider.id, manage_context['provider_ids'])
self.assertIn(provider_b.id, manage_context['provider_ids'])
self.assertIn(token.id, manage_context['token_ids'])
self.assertIn(token_b.id, manage_context['token_ids'])
# Token of disabled provider(s) & disabled providers should not be shown
self.provider.state = 'disabled'
manage_context = self._get_tx_manage_context()
self.assertEqual(manage_context['partner_id'], self.partner.id)
self.assertEqual(manage_context['provider_ids'], [provider_b.id])
self.assertEqual(manage_context['token_ids'], [token_b.id])
# Archived tokens must be hidden from the user
token_b.active = False
manage_context = self._get_tx_manage_context()
self.assertEqual(manage_context['partner_id'], self.partner.id)
self.assertEqual(manage_context['provider_ids'], [provider_b.id])
self.assertEqual(manage_context['token_ids'], [])
@mute_logger('odoo.addons.payment.models.payment_transaction')
def test_direct_payment_triggers_no_payment_request(self):
self.authenticate(self.portal_user.login, self.portal_user.login)
self.partner = self.portal_partner
self.user = self.portal_user
with patch(
'odoo.addons.payment.models.payment_transaction.PaymentTransaction'
'._send_payment_request'
) as patched:
self._portal_transaction(
**self._prepare_transaction_values(self.provider.id, 'direct')
)
self.assertEqual(patched.call_count, 0)
@mute_logger('odoo.addons.payment.models.payment_transaction')
def test_payment_with_redirect_triggers_no_payment_request(self):
self.authenticate(self.portal_user.login, self.portal_user.login)
self.partner = self.portal_partner
self.user = self.portal_user
with patch(
'odoo.addons.payment.models.payment_transaction.PaymentTransaction'
'._send_payment_request'
) as patched:
self._portal_transaction(
**self._prepare_transaction_values(self.provider.id, 'redirect')
)
self.assertEqual(patched.call_count, 0)
@mute_logger('odoo.addons.payment.models.payment_transaction')
def test_payment_by_token_triggers_exactly_one_payment_request(self):
self.authenticate(self.portal_user.login, self.portal_user.login)
self.partner = self.portal_partner
self.user = self.portal_user
with patch(
'odoo.addons.payment.models.payment_transaction.PaymentTransaction'
'._send_payment_request'
) as patched:
self._portal_transaction(
**self._prepare_transaction_values(self._create_token().id, 'token')
)
self.assertEqual(patched.call_count, 1)
def test_tokenization_input_is_show_to_logged_in_users(self):
self.provider.allow_tokenization = True
show_tokenize_input = PaymentPortal._compute_show_tokenize_input_mapping(
self.provider, logged_in=True
)
self.assertDictEqual(show_tokenize_input, {self.provider.id: True})
def test_tokenization_input_is_hidden_for_logged_out_users(self):
self.provider.allow_tokenization = False
show_tokenize_input = PaymentPortal._compute_show_tokenize_input_mapping(
self.provider, logged_in=True
)
self.assertDictEqual(show_tokenize_input, {self.provider.id: False})

View file

@ -0,0 +1,133 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.fields import Command
from odoo.tests import tagged
from odoo.tools import mute_logger
from odoo.addons.payment.tests.http_common import PaymentHttpCommon
@tagged('post_install', '-at_install')
class TestMultiCompanyFlows(PaymentHttpCommon):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.company_a = cls.env.company # cls.company_data['company']
cls.company_b = cls.env.company.create({'name': "Payment Test Company"}) # cls.company_data_2['company']
cls.user_company_a = cls.internal_user
cls.user_company_b = cls.env['res.users'].create({
'name': f"{cls.company_b.name} User (TEST)",
'login': 'user_company_b',
'password': 'user_company_b',
'company_id': cls.company_b.id,
'company_ids': [Command.set(cls.company_b.ids)],
'groups_id': [Command.link(cls.group_user.id)],
})
cls.user_multi_company = cls.env['res.users'].create({
'name': "Multi Company User (TEST)",
'login': 'user_multi_company',
'password': 'user_multi_company',
'company_id': cls.company_a.id,
'company_ids': [Command.set([cls.company_a.id, cls.company_b.id])],
'groups_id': [Command.link(cls.group_user.id)],
})
cls.provider_company_b = cls._prepare_provider(company=cls.company_b)
def test_pay_logged_in_another_company(self):
"""User pays for an amount in another company."""
# for another res.partner than the user's one
route_values = self._prepare_pay_values(partner=self.user_company_b.partner_id)
# Log in as user from Company A
self.authenticate(self.user_company_a.login, self.user_company_a.login)
# Pay in company B
route_values['company_id'] = self.company_b.id
tx_context = self._get_tx_checkout_context(**route_values)
for key, val in tx_context.items():
if key in route_values:
if key == 'access_token':
continue # access_token was modified due to the change of partner.
elif key == 'partner_id':
# The partner is replaced by the partner of the user paying.
self.assertEqual(val, self.user_company_a.partner_id.id)
else:
self.assertEqual(val, route_values[key])
available_providers = self.env['payment.provider'].sudo().browse(tx_context['provider_ids'])
self.assertIn(self.provider_company_b, available_providers)
self.assertEqual(available_providers.company_id, self.company_b)
validation_values = {
k: tx_context[k]
for k in [
'amount',
'currency_id',
'reference_prefix',
'partner_id',
'access_token',
'landing_route',
]
}
validation_values.update({
'flow': 'direct',
'payment_option_id': self.provider_company_b.id,
'tokenization_requested': False,
})
with mute_logger('odoo.addons.payment.models.payment_transaction'):
processing_values = self._get_processing_values(**validation_values)
tx_sudo = self._get_tx(processing_values['reference'])
# Tx values == given values
self.assertEqual(tx_sudo.provider_id.id, self.provider_company_b.id)
self.assertEqual(tx_sudo.amount, self.amount)
self.assertEqual(tx_sudo.currency_id.id, self.currency.id)
self.assertEqual(tx_sudo.partner_id.id, self.user_company_a.partner_id.id)
self.assertEqual(tx_sudo.reference, self.reference)
self.assertEqual(tx_sudo.company_id, self.company_b)
# processing_values == given values
self.assertEqual(processing_values['provider_id'], self.provider_company_b.id)
self.assertEqual(processing_values['amount'], self.amount)
self.assertEqual(processing_values['currency_id'], self.currency.id)
self.assertEqual(processing_values['partner_id'], self.user_company_a.partner_id.id)
self.assertEqual(processing_values['reference'], self.reference)
def test_full_access_to_partner_tokens(self):
self.partner = self.portal_partner
# Log in as user from Company A
self.authenticate(self.portal_user.login, self.portal_user.login)
token = self._create_token()
token_company_b = self._create_token(provider_id=self.provider_company_b.id)
# A partner should see all his tokens on the /my/payment_method route,
# even if they are in other companies otherwise he won't ever see them.
manage_context = self._get_tx_manage_context()
self.assertEqual(manage_context['partner_id'], self.partner.id)
self.assertEqual(manage_context['provider_ids'], self.provider.ids)
self.assertIn(token.id, manage_context['token_ids'])
self.assertIn(token_company_b.id, manage_context['token_ids'])
def test_archive_token_logged_in_another_company(self):
"""User archives his token from another company."""
# get user's token from company A
token = self._create_token(partner_id=self.portal_partner.id)
# assign user to another company
company_b = self.env['res.company'].create({'name': 'Company B'})
self.portal_user.write({'company_ids': [company_b.id], 'company_id': company_b.id})
# Log in as portal user
self.authenticate(self.portal_user.login, self.portal_user.login)
# Archive token in company A
url = self._build_url('/payment/archive_token')
self._make_json_rpc_request(url, {'token_id': token.id})
self.assertFalse(token.active)

View file

@ -0,0 +1,69 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.tests import tagged
from odoo.addons.payment.tests.common import PaymentCommon
@tagged('-at_install', 'post_install')
class TestPaymentprovider(PaymentCommon):
def test_published_provider_compatible_with_all_users(self):
""" Test that a published provider is always available to all users. """
for user in (self.public_user, self.portal_user):
self.env = self.env(user=user)
compatible_providers = self.env['payment.provider'].sudo()._get_compatible_providers(
self.company.id, self.partner.id, self.amount
)
self.assertIn(self.provider, compatible_providers)
def test_unpublished_provider_compatible_with_internal_user(self):
""" Test that an unpublished provider is still available to internal users. """
self.provider.is_published = False
compatible_providers = self.env['payment.provider']._get_compatible_providers(
self.company.id, self.partner.id, self.amount
)
self.assertIn(self.provider, compatible_providers)
def test_unpublished_provider_not_compatible_with_non_internal_user(self):
""" Test that an unpublished provider is not available to non-internal users. """
self.provider.is_published = False
for user in (self.public_user, self.portal_user):
self.env = self.env(user=user)
compatible_providers = self.env['payment.provider'].sudo()._get_compatible_providers(
self.company.id, self.partner.id, self.amount
)
self.assertNotIn(self.provider, compatible_providers)
def test_provider_compatible_when_maximum_amount_is_zero(self):
""" Test that the maximum amount has no effect on the provider's compatibility when it is
set to 0. """
self.provider.maximum_amount = 0.
compatible_providers = self.env['payment.provider']._get_compatible_providers(
self.company.id, self.partner.id, self.amount, currency_id=self.env.company.currency_id.id,
)
self.assertIn(self.provider, compatible_providers)
def test_provider_compatible_when_payment_below_maximum_amount(self):
""" Test that a provider is compatible when the payment amount is less than the maximum
amount. """
self.provider.maximum_amount = self.amount + 10.0
compatible_providers = self.env['payment.provider']._get_compatible_providers(
self.company.id, self.partner.id, self.amount, currency_id=self.env.company.currency_id.id,
)
self.assertIn(self.provider, compatible_providers)
def test_provider_not_compatible_when_payment_above_maximum_amount(self):
""" Test that a provider is not compatible when the payment amount is more than the maximum
amount. """
self.provider.maximum_amount = self.amount - 10.0
compatible_providers = self.env['payment.provider']._get_compatible_providers(
self.company.id, self.partner.id, self.amount, currency_id=self.env.company.currency_id.id,
)
self.assertNotIn(self.provider, compatible_providers)

View file

@ -0,0 +1,49 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from datetime import date
from freezegun import freeze_time
from odoo.exceptions import UserError
from odoo.tests import tagged
from odoo.addons.payment.tests.common import PaymentCommon
@tagged('-at_install', 'post_install')
class TestPaymentToken(PaymentCommon):
def test_token_cannot_be_unarchived(self):
""" Test that unarchiving disabled tokens is forbidden. """
token = self._create_token(active=False)
with self.assertRaises(UserError):
token.active = True
def test_display_name_is_padded(self):
""" Test that the display name is built by padding the payment details. """
token = self._create_token()
self.assertEqual(token._build_display_name(), '•••• 1234')
@freeze_time('2024-1-31 10:00:00')
def test_display_name_for_empty_payment_details(self):
""" Test that the display name is still built for token without payment details. """
token = self._create_token(payment_details='')
self.env.cr.execute(
'UPDATE payment_token SET create_date = %s WHERE id = %s',
params=(date.today(), token.id),
)
token.invalidate_recordset(fnames=['create_date'])
self.assertEqual(
token._build_display_name(),
f"Payment details saved on {date.today().strftime('%Y/%m/%d')}",
)
def test_display_name_is_shortened_to_max_length(self):
""" Test that the display name is not fully padded when a `max_length` is passed. """
token = self._create_token()
self.assertEqual(token._build_display_name(max_length=6), '• 1234')
def test_display_name_is_not_padded(self):
""" Test that the display name is not padded when `should_pad` is `False`. """
token = self._create_token()
self.assertEqual(token._build_display_name(should_pad=False), '1234')

View file

@ -0,0 +1,128 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.exceptions import AccessError
from odoo.tests import tagged
from odoo.addons.payment.tests.common import PaymentCommon
@tagged('-at_install', 'post_install')
class TestPaymentTransaction(PaymentCommon):
def test_capture_allowed_for_authorized_users(self):
""" Test that users who have access to a transaction can capture it. """
if 'account' not in self.env["ir.module.module"]._installed():
self.skipTest("account module is not installed")
self.provider.support_manual_capture = True
tx = self._create_transaction('redirect', state='authorized')
user = self._prepare_user(self.internal_user, 'account.group_account_invoice')
self._assert_does_not_raise(AccessError, tx.with_user(user).action_capture)
def test_void_allowed_for_authorized_users(self):
""" Test that users who have access to a transaction can void it. """
if 'account' not in self.env["ir.module.module"]._installed():
self.skipTest("account module is not installed")
self.provider.support_manual_capture = True
tx = self._create_transaction('redirect', state='authorized')
user = self._prepare_user(self.internal_user, 'account.group_account_invoice')
self._assert_does_not_raise(AccessError, tx.with_user(user).action_void)
def test_refund_allowed_for_authorized_users(self):
""" Test that users who have access to a transaction can refund it. """
if 'account' not in self.env["ir.module.module"]._installed():
self.skipTest("account module is not installed")
self.provider.support_refund = 'full_only'
tx = self._create_transaction('redirect', state='done')
user = self._prepare_user(self.internal_user, 'account.group_account_invoice')
self._assert_does_not_raise(AccessError, tx.with_user(user).action_refund)
def test_capture_blocked_for_unauthorized_user(self):
""" Test that users who don't have access to a transaction cannot capture it. """
self.provider.support_manual_capture = True
tx = self._create_transaction('redirect', state='authorized')
self.assertRaises(AccessError, tx.with_user(self.internal_user).action_capture)
def test_void_blocked_for_unauthorized_user(self):
""" Test that users who don't have access to a transaction cannot void it. """
self.provider.support_manual_capture = True
tx = self._create_transaction('redirect', state='authorized')
self.assertRaises(AccessError, tx.with_user(self.internal_user).action_void)
def test_refund_blocked_for_unauthorized_user(self):
""" Test that users who don't have access to a transaction cannot refund it. """
self.provider.support_refund = 'full_only'
tx = self._create_transaction('redirect', state='done')
self.assertRaises(AccessError, tx.with_user(self.internal_user).action_refund)
def test_refunds_count(self):
self.provider.support_refund = 'full_only' # Should simply not be False
tx = self._create_transaction('redirect', state='done')
for reference_index, operation in enumerate(
('online_redirect', 'online_direct', 'online_token', 'validation', 'refund')
):
self._create_transaction(
'dummy',
reference=f'R-{tx.reference}-{reference_index + 1}',
state='done',
operation=operation, # Override the computed flow
source_transaction_id=tx.id,
)._reconcile_after_done()
self.assertEqual(
tx.refunds_count,
1,
msg="The refunds count should only consider transactions with operation 'refund'."
)
def test_refund_transaction_values(self):
self.provider.support_refund = 'partial'
tx = self._create_transaction('redirect', state='done')
# Test the default values of a full refund transaction
refund_tx = tx._create_refund_transaction()
self.assertEqual(
refund_tx.reference,
f'R-{tx.reference}',
msg="The reference of the refund transaction should be the prefixed reference of the "
"source transaction."
)
self.assertLess(
refund_tx.amount, 0, msg="The amount of a refund transaction should always be negative."
)
self.assertAlmostEqual(
refund_tx.amount,
-tx.amount,
places=2,
msg="The amount of the refund transaction should be taken from the amount of the "
"source transaction."
)
self.assertEqual(
refund_tx.currency_id,
tx.currency_id,
msg="The currency of the refund transaction should that of the source transaction."
)
self.assertEqual(
refund_tx.operation,
'refund',
msg="The operation of the refund transaction should be 'refund'."
)
self.assertEqual(
tx,
refund_tx.source_transaction_id,
msg="The refund transaction should be linked to the source transaction."
)
self.assertEqual(
refund_tx.partner_id,
tx.partner_id,
msg="The partner of the refund transaction should that of the source transaction."
)
# Test the values of a partial refund transaction with custom refund amount
partial_refund_tx = tx._create_refund_transaction(amount_to_refund=11.11)
self.assertAlmostEqual(
partial_refund_tx.amount,
-11.11,
places=2,
msg="The amount of the refund transaction should be the negative value of the amount "
"to refund."
)