19.0 vanilla

This commit is contained in:
Ernad Husremovic 2026-03-09 09:31:39 +01:00
parent 5df8c07b59
commit daa394e8b0
2114 changed files with 564841 additions and 299642 deletions

View file

@ -1,24 +1,42 @@
# -*- coding: utf-8 -*-
from contextlib import contextmanager
from freezegun import freeze_time
from unittest.mock import patch
from odoo import exceptions, tools
from odoo.tests import common
from odoo.addons.mail.tests.common import MailCommon
from odoo.addons.phone_validation.tools import phone_validation
from odoo.addons.sms.models.sms_api import SmsApi
from odoo.addons.sms.models.sms_sms import SmsSms
from odoo.addons.sms.tools.sms_api import SmsApi
from odoo.tests import common
class MockSMS(common.BaseCase):
class MockSMS(common.HttpCase):
def tearDown(self):
super(MockSMS, self).tearDown()
self._clear_sms_sent()
# ------------------------------------------------------------
# UTILITY MOCKS
# ------------------------------------------------------------
@contextmanager
def mockSMSGateway(self, sms_allow_unlink=False, sim_error=None, nbr_t_error=None):
def mock_datetime_and_now(self, mock_dt):
""" Used when synchronization date (using env.cr.now()) is important
in addition to standard datetime mocks. Used mainly to detect sync
issues. """
with freeze_time(mock_dt), \
patch.object(self.env.cr, 'now', lambda: mock_dt):
yield
# ------------------------------------------------------------
# GATEWAY MOCK
# ------------------------------------------------------------
@contextmanager
def mockSMSGateway(self, sms_allow_unlink=False, sim_error=None, nbr_t_error=None, moderated=False, force_delivered=False):
self._clear_sms_sent()
sms_create_origin = SmsSms.create
sms_send_origin = SmsSms._send
@ -35,27 +53,52 @@ class MockSMS(common.BaseCase):
if local_endpoint == '/iap/sms/2/send':
result = []
for to_send in params['messages']:
res = {'res_id': to_send['res_id'], 'state': 'success', 'credit': 1}
res = {'res_id': to_send['res_id'], 'state': 'delivered' if force_delivered else 'success', 'credit': 1}
error = sim_error or (nbr_t_error and nbr_t_error.get(to_send['number']))
if error and error == 'credit':
res.update(credit=0, state='insufficient_credit')
elif error and error == 'wrong_number_format':
res.update(state='wrong_number_format')
elif error and error == 'unregistered':
res.update(state='unregistered')
elif error and error == 'server_error':
res.update(state='server_error')
elif error and error in {'wrong_number_format', 'unregistered', 'server_error'}:
res.update(state=error)
elif error and error == 'jsonrpc_exception':
raise exceptions.AccessError(
'The url that this service requested returned an error. Please contact the author of the app. The url it tried to contact was ' + local_endpoint
)
result.append(res)
if res['state'] == 'success':
if res['state'] == 'success' or res['state'] == 'delivered':
self._sms.append({
'number': to_send['number'],
'body': to_send['content'],
})
return result
elif local_endpoint == '/api/sms/3/send':
result = []
for message in params['messages']:
for number in message["numbers"]:
error = sim_error or (nbr_t_error and nbr_t_error.get(number['number']))
if error == 'jsonrpc_exception':
raise exceptions.AccessError(
'The url that this service requested returned an error. '
'Please contact the author of the app. '
'The url it tried to contact was ' + local_endpoint
)
elif error == 'credit':
error = 'insufficient_credit'
res = {
'uuid': number['uuid'],
'state': error or ('delivered' if force_delivered else 'success' if not moderated else 'processing'),
'credit': 1,
}
if error:
# credit is only given if the amount is known
res.update(credit=0)
else:
self._sms.append({
'number': number['number'],
'body': message['content'],
'uuid': number['uuid'],
})
result.append(res)
return result
def _sms_sms_create(model, *args, **kwargs):
res = sms_create_origin(model, *args, **kwargs)
@ -65,23 +108,20 @@ class MockSMS(common.BaseCase):
def _sms_sms_send(records, unlink_failed=False, unlink_sent=True, raise_exception=False):
if sms_allow_unlink:
return sms_send_origin(records, unlink_failed=unlink_failed, unlink_sent=unlink_sent, raise_exception=raise_exception)
else:
return sms_send_origin(records, unlink_failed=False, unlink_sent=False, raise_exception=raise_exception)
return True
return sms_send_origin(records, unlink_failed=False, unlink_sent=False, raise_exception=raise_exception)
try:
with patch.object(SmsApi, '_contact_iap', side_effect=_contact_iap), \
patch.object(SmsSms, 'create', autospec=True, wraps=SmsSms, side_effect=_sms_sms_create), \
patch.object(SmsSms, '_send', autospec=True, wraps=SmsSms, side_effect=_sms_sms_send):
yield
finally:
pass
with patch.object(SmsApi, '_contact_iap', side_effect=_contact_iap) as _sms_api_contact_iap_mock, \
patch.object(SmsSms, 'create', autospec=True, wraps=SmsSms, side_effect=_sms_sms_create) as sms_create, \
patch.object(SmsSms, '_send', autospec=True, wraps=SmsSms, side_effect=_sms_sms_send):
self._sms_api_contact_iap_mock = _sms_api_contact_iap_mock
self._mock_sms_create = sms_create
yield
def _clear_sms_sent(self):
self._sms = []
self._new_sms = self.env['sms.sms'].sudo()
def _clear_outoing_sms(self):
def _clear_outgoing_sms(self):
""" As SMS gateway mock keeps SMS, we may need to remove them manually
if there are several tests in the same tx. """
self.env['sms.sms'].sudo().search([('state', '=', 'outgoing')]).unlink()
@ -91,17 +131,27 @@ class SMSCase(MockSMS):
""" Main test class to use when testing SMS integrations. Contains helpers and tools related
to notification sent by SMS. """
@classmethod
def setUpClass(cls):
super().setUpClass()
# This is called to make sure that an iap_account for sms already exists or if not is created.
cls.env['iap.account'].get('sms')
def _find_sms_sent(self, partner, number):
if number is None and partner:
number = partner.phone_get_sanitized_number()
number = partner._phone_format()
sent_sms = next((sms for sms in self._sms if sms['number'] == number), None)
if not sent_sms:
raise AssertionError('sent sms not found for %s (number: %s)' % (partner, number))
debug_info = '\n'.join(
f"To {sms['number']}"
for sms in self._sms
)
raise AssertionError(f'sent sms not found for {partner} (number: {number})\n{debug_info}')
return sent_sms
def _find_sms_sms(self, partner, number, status):
def _find_sms_sms(self, partner, number, status, content=None):
if number is None and partner:
number = partner.phone_get_sanitized_number()
number = partner._phone_format()
domain = [('id', 'in', self._new_sms.ids),
('partner_id', '=', partner.id),
('number', '=', number)]
@ -109,19 +159,27 @@ class SMSCase(MockSMS):
domain += [('state', '=', status)]
sms = self.env['sms.sms'].sudo().search(domain)
if len(sms) > 1 and content:
sms = sms.filtered(lambda s: content in (s.body or ""))
if not sms:
raise AssertionError('sms.sms not found for %s (number: %s / status %s)' % (partner, number, status))
debug_info = '\n'.join(
f"To {sms.number} ({sms.partner_id}) / state {sms.state}"
for sms in self._new_sms
)
raise AssertionError(
f'sms.sms not found for {partner} (number: {number} / status {status})\n--MOCKED DATA\n{debug_info}'
)
if len(sms) > 1:
raise NotImplementedError()
raise NotImplementedError(
f'Found {len(sms)} sms.sms for {partner} (number: {number} / status {status})'
)
return sms
def assertNoSMS(self):
""" Check no sms went through gateway during mock. """
self.assertTrue(len(self._new_sms) == 0)
def assertSMSIapSent(self, numbers, content=None):
""" Check sent SMS. Order is not checked. Each number should have received
the same content. Useful to check batch sending.
""" Check sent SMS (to IAP, but other providers like twilio should be
mocked to fill up 'self._sms', allowing tests to pass). Order is not
checked. Each number should have received the same content. Useful to
check batch sending.
:param numbers: list of numbers;
:param content: content to check for each number;
@ -132,10 +190,6 @@ class SMSCase(MockSMS):
if content is not None:
self.assertIn(content, sent_sms['body'])
def assertSMSSent(self, numbers, content=None):
""" Deprecated. Remove in 14.4 """
return self.assertSMSIapSent(numbers, content=content)
def assertSMS(self, partner, number, status, failure_type=None,
content=None, fields_values=None):
""" Find a ``sms.sms`` record, based on given partner, number and status.
@ -149,16 +203,16 @@ class SMSCase(MockSMS):
:param fields_values: optional values allowing to check directly some
values on ``sms.sms`` record;
"""
sms_sms = self._find_sms_sms(partner, number, status)
sms_sms = self._find_sms_sms(partner, number, status, content=content)
if failure_type:
self.assertEqual(sms_sms.failure_type, failure_type)
if content is not None:
self.assertIn(content, sms_sms.body)
self.assertIn(content, (sms_sms.body or ""))
for fname, fvalue in (fields_values or {}).items():
self.assertEqual(
sms_sms[fname], fvalue,
'SMS: expected %s for %s, got %s' % (fvalue, fname, sms_sms[fname]))
if status == 'sent':
if status == 'pending':
self.assertSMSIapSent([sms_sms.number], content=content)
def assertSMSCanceled(self, partner, number, failure_type, content=None, fields_values=None):
@ -190,7 +244,7 @@ class SMSCase(MockSMS):
:param recipients_info: list[{
'partner': res.partner record (may be empty),
'number': number used for notification (may be empty, computed based on partner),
'state': ready / sent / exception / canceled (sent by default),
'state': ready / pending / sent / exception / canceled (pending by default),
'failure_type': optional: sms_number_missing / sms_number_format / sms_credit / sms_server
}, { ... }]
:param content: SMS content
@ -213,31 +267,58 @@ class SMSCase(MockSMS):
self.assertEqual(notifications.mapped('res_partner_id'), partners)
for recipient_info in recipients_info:
# sanity check
extra_keys = recipient_info.keys() - {
# notification
'failure_reason',
'failure_type',
'state',
# sms
'sms_fields_values',
# recipient
'number',
'partner',
'recipient_check_sms',
}
if extra_keys:
raise ValueError(f'Unsupported values: {extra_keys}')
partner = recipient_info.get('partner', self.env['res.partner'])
number = recipient_info.get('number')
state = recipient_info.get('state', 'sent')
state = recipient_info.get('state', 'pending')
if number is None and partner:
number = partner.phone_get_sanitized_number()
number = partner._phone_format()
notif = notifications.filtered(lambda n: n.res_partner_id == partner and n.sms_number == number and n.notification_status == state)
self.assertTrue(notif, 'SMS: not found notification for %s (number: %s, state: %s)' % (partner, number, state))
debug_info = ''
if not notif:
debug_info = '\n'.join(
f'To: {notif.sms_number} ({notif.res_partner_id}) - (State: {notif.notification_status})'
for notif in notifications
)
self.assertTrue(notif, 'SMS: not found notification for %s (number: %s, state: %s)\n%s' % (partner, number, state, debug_info))
self.assertEqual(notif.author_id, notif.mail_message_id.author_id, 'SMS: Message and notification should have the same author')
for field_name, expected_value in (mail_message_values or {}).items():
self.assertEqual(notif.mail_message_id[field_name], expected_value)
if state not in ('sent', 'ready', 'canceled'):
if 'failure_reason' in recipient_info:
self.assertEqual(notif.failure_reason, recipient_info['failure_reason'])
if state not in {'process', 'sent', 'ready', 'canceled', 'pending'}:
self.assertEqual(notif.failure_type, recipient_info['failure_type'])
if check_sms:
if state == 'sent':
if recipient_info.get('recipient_check_sms', check_sms):
fields_values = recipient_info.get('sms_fields_values') or {}
if state in {'process', 'pending', 'sent'}:
if sent_unlink:
self.assertSMSIapSent([number], content=content)
else:
self.assertSMS(partner, number, 'sent', content=content)
self.assertSMS(partner, number, state, content=content, fields_values=fields_values)
elif state == 'ready':
self.assertSMS(partner, number, 'outgoing', content=content)
self.assertSMS(partner, number, 'outgoing', content=content, fields_values=fields_values)
elif state == 'exception':
self.assertSMS(partner, number, 'error', failure_type=recipient_info['failure_type'], content=content)
self.assertSMS(partner, number, 'error', failure_type=recipient_info['failure_type'], content=content, fields_values=fields_values)
elif state == 'canceled':
self.assertSMS(partner, number, 'canceled', failure_type=recipient_info['failure_type'], content=content)
self.assertSMS(partner, number, 'canceled', failure_type=recipient_info['failure_type'], content=content, fields_values=fields_values)
else:
raise NotImplementedError('Not implemented')
@ -260,11 +341,7 @@ class SMSCommon(MailCommon, SMSCase):
@classmethod
def setUpClass(cls):
super(SMSCommon, cls).setUpClass()
cls.user_employee.write({'login': 'employee'})
# update country to belgium in order to test sanitization of numbers
cls.user_employee.company_id.write({'country_id': cls.env.ref('base.be').id})
super().setUpClass()
# some numbers for testing
cls.random_numbers_str = '+32456998877, 0456665544'
@ -284,3 +361,6 @@ class SMSCommon(MailCommon, SMSCase):
'model_id': cls.env['ir.model']._get(model).id,
'body': body if body else 'Dear {{ object.display_name }} this is an SMS.'
})
def _make_webhook_jsonrpc_request(self, statuses):
return self.make_jsonrpc_request('/sms/status', {'message_statuses': statuses})