import re from contextlib import contextmanager from requests import Response from unittest.mock import patch from odoo.addons.mail.tests.common import mail_new_test_user from odoo.addons.sms.models.sms_sms import SmsSms from odoo.addons.sms.tests.common import SMSCase from odoo.addons.sms_twilio.tools import sms_twilio as twilio_tools from odoo.addons.sms_twilio.tools.sms_api import SmsApiTwilio from odoo.tests.common import TransactionCase class MockSmsTwilioApi(SMSCase): @classmethod def setUpClass(cls): super().setUpClass() # some test data cls.twilio_valid_phone_number = "+12202154155" cls.twilio_invalid_phone_number = "+3212312312" # mock control cls.mock_error_type = False cls.mock_error_number_to_type = {} cls.mock_body = False cls.mock_company = cls.env.company cls.mock_number = False cls.mock_sms_uuid = 'NA' # find details of outgoing requests cls.twilio_request_re = re.compile(r"https://api.twilio.com/2010-04-01/Accounts/(AC[\d]{32})/(.*)") # typical / expected responses cls.webhook_ok_response = { 'AccountSid': 'ACfake', 'ApiVersion': '2010-04-01', 'From': '+12212341234', 'MessageSid': 'SMfake', 'MessageStatus': 'delivered', 'RawDlrDoneDate': '2504241615', 'SmsSid': 'SMfake', 'SmsStatus': 'delivered', 'To': '+32486321321', } cls.request_send_ok_json = { "account_sid": "AC12345678987654321234567898765432", "api_version": "2010-04-01", "date_created": "Mon, 14 Apr 2025 09:27:41 +0000", "date_sent": None, "date_updated": "Mon, 14 Apr 2025 09:27:41 +0000", "direction": "outbound-api", "error_code": None, "error_message": None, "from": "+12212341234", "messaging_service_sid": None, "num_media": "0", "num_segments": "1", "price": None, "price_unit": "USD", "sid": "SMfake", "status": "queued", "subresource_uris": { "media": "/2010-04-01/Accounts/ACfake/Messages/SMfake/Media.json" }, "uri": "/2010-04-01/Accounts/ACfake/Messages/SMfake.json", } cls.request_send_nok_json = { 'code': 21211, 'more_info': 'https://www.twilio.com/docs/errors/21211', 'status': 400, } @classmethod def _request_handler(cls, session, request, **kwargs): url = request.url matching = cls.twilio_request_re.match(url) if matching: _sid = matching.group(1) right_part = matching.group(2) response = Response() response.status_code = 200 if right_part == "IncomingPhoneNumbers.json": response.json = lambda: { 'incoming_phone_numbers': [ {'phone_number': '+32455998877'}, {'phone_number': '+32455665544'}, ], } return response elif right_part == "Messages.json": error_type = cls.mock_error_number_to_type.get(cls.mock_number) or cls.mock_error_type if not error_type and not cls.mock_number: error_type = "sms_number_missing" error_codes = { 'wrong_number_format': 21211, 'sms_number_missing': 21604, 'twilio_acc_unverified': 21608, 'twilio_callback': 21609, 'unknown': 1, 'other': 1, } if not error_type: request_send_ok_json = cls.request_send_ok_json.copy() request_send_ok_json['body'] = cls.mock_body or 'body' request_send_ok_json['sid'] = f'twilio_{cls.mock_company.name}_{cls.mock_sms_uuid}' if cls.mock_sms_uuid else 'SMFake' request_send_ok_json['to_number'] = cls.mock_number or 'to_number' response.json = lambda: request_send_ok_json else: if error_type not in error_codes: raise ValueError('Unsupported error code') error_code = error_codes.get(error_type) if error_type else False request_send_nok_json = cls.request_send_nok_json.copy() request_send_nok_json['body'] = cls.mock_body or 'body' request_send_nok_json['code'] = error_code request_send_nok_json['to_number'] = cls.mock_number or 'to_number' response.json = lambda: request_send_nok_json response.status_code = 400 return response return super()._request_handler(session, request, **kwargs) @classmethod def _setup_sms_twilio(cls, company): company.sudo().write({ "sms_provider": "twilio", "sms_twilio_account_sid": "AC12345678987654321234567898765432", "sms_twilio_auth_token": "grimgorironhide", "sms_twilio_number_ids": [ (5, 0), (0, 0, { "country_id": cls.env.ref("base.be").id, "number": "+32455998877", "sequence": 0, }), (0, 0, { "country_id": cls.env.ref("base.us").id, "number": "+15056998877", "sequence": 1, }), ], }) @classmethod def _update_mock(cls, error_type=None, error_number_to_type=None, body=None, number=False, sms_uuid=False, company=False): if error_type is not None: cls.mock_error_type = error_type if error_number_to_type is not None: cls.mock_error_number_to_type = error_number_to_type # various data, used notably to forge better simulated responses if body is not None: cls.mock_body = body if company is not False: cls.mock_company = company if number is not False: cls.mock_number = number if sms_uuid is not False: cls.mock_sms_uuid = sms_uuid @contextmanager def mock_sms_twilio_send(self, error_type=False, error_number_to_type=None): self._clear_sms_sent() self._update_mock( error_type=error_type, error_number_to_type=error_number_to_type, company=self.env.company, ) sms_twilio_send_request_origin = SmsApiTwilio._sms_twilio_send_request def _sms_api_twilio_sms_twilio_send_request(model, *args, **kwargs): (_session, to_number, body, uuid) = args self._update_mock( error_type=self.mock_error_type, error_number_to_type=self.mock_error_number_to_type, body=body, number=to_number, sms_uuid=uuid, company=model.company, ) res = sms_twilio_send_request_origin(model, *args, **kwargs) self._sms += [{ 'body': body, 'number': to_number, 'uuid': uuid, }] return res with patch.object(SmsApiTwilio, '_sms_twilio_send_request', autospec=True, side_effect=_sms_api_twilio_sms_twilio_send_request) as _sms_twilio_send_mock: self._sms_twilio_send_mock = _sms_twilio_send_mock yield @contextmanager def mock_sms_twilio_gateway(self, error_type=False, error_number_to_type=None): self._clear_sms_sent() sms_create_origin = SmsSms.create def _sms_sms_create(model, *args, **kwargs): res = sms_create_origin(model, *args, **kwargs) self._new_sms += res.sudo() return res with ( patch.object(SmsSms, 'create', autospec=True, wraps=SmsSms, side_effect=_sms_sms_create), self.mock_sms_twilio_send(error_type=error_type, error_number_to_type=error_number_to_type), ): yield def simulate_sms_twilio_status(self, sms_batch, company): """ Simulate callback webhook called by Twilio """ for sms in sms_batch: expected_signature = twilio_tools.generate_twilio_sms_callback_signature( self.user_admin.company_id, sms.uuid, self.webhook_ok_response, ) _response = self.url_open( f"/sms_twilio/status/{sms.uuid}", self.webhook_ok_response, headers={ "X-Twilio-Signature": expected_signature, }, ) class MockSmsTwilio(MockSmsTwilioApi, TransactionCase): @classmethod def setUpClass(cls): super().setUpClass() cls.user_admin = cls.env.ref('base.user_admin') cls.company_admin = cls.user_admin.company_id cls.basic_user = mail_new_test_user( cls.env, company_id=cls.company_admin.id, country_id=cls.env.ref('base.be').id, groups='base.group_user,base.group_partner_manager', login='employee', ) cls.valid_partner = cls.env['res.partner'].create({ 'name': 'ValidPartner', 'phone': cls.twilio_valid_phone_number, }) cls.invalid_partner = cls.env['res.partner'].create({ 'name': 'InvalidPartner', 'phone': cls.twilio_invalid_phone_number, })