From 015152bab2f6edb4d865116f5d96713d4e329c6d Mon Sep 17 00:00:00 2001 From: Ben Thorner Date: Fri, 25 Mar 2022 15:03:52 +0000 Subject: [PATCH] Add boilerplate for sending SMS via Reach This works in conjunction with the new SMS provider stub [^1]. Local testing: - Run the migrations to add Reach as an inactive provider. - Activate the Reach provider locally and deactivate the others. update provider_details set priority = 100, active = false where notification_type = 'sms'; update provider_details set active = true where identifier = 'reach'; - Tweak your local environment to point at the SMS stub. export REACH_URL="http://host.docker.internal:6300/reach" - Start / restart Celery to pick up the config change. - Send a SMS via the Admin app and see the stub log it. - Reset your environment so you can send normal SMS. update provider_details set active = true where notification_type = 'sms'; update provider_details set active = false where identifier = 'reach'; [^1]: https://github.com/alphagov/notifications-sms-provider-stub/pull/10 --- README.md | 2 + app/__init__.py | 8 ++- app/clients/sms/reach.py | 50 ++++++++++++-- app/config.py | 2 + migrations/versions/0367_add_reach.py | 54 +++++++++++++++ tests/app/clients/test_reach.py | 94 ++++++++++++++++++++++++++- 6 files changed, 203 insertions(+), 7 deletions(-) create mode 100644 migrations/versions/0367_add_reach.py diff --git a/README.md b/README.md index ea3636115..8edd6bd15 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ export NOTIFY_ENVIRONMENT='development' export MMG_API_KEY='MMG_API_KEY' export FIRETEXT_API_KEY='FIRETEXT_ACTUAL_KEY' +export REACH_API_KEY='REACH_API_KEY' export NOTIFICATION_QUEUE_PREFIX='YOUR_OWN_PREFIX' export FLASK_APP=application.py @@ -45,6 +46,7 @@ Things to change: ``` notify-pass credentials/firetext notify-pass credentials/mmg +notify-pass credentials/reach ``` ### Postgres diff --git a/app/__init__.py b/app/__init__.py index cc21ef59a..15f4f1e48 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -36,6 +36,7 @@ from app.clients.email.aws_ses import AwsSesClient from app.clients.email.aws_ses_stub import AwsSesStubClient from app.clients.sms.firetext import FiretextClient from app.clients.sms.mmg import MMGClient +from app.clients.sms.reach import ReachClient class SQLAlchemy(_SQLAlchemy): @@ -56,6 +57,7 @@ ma = Marshmallow() notify_celery = NotifyCelery() firetext_client = FiretextClient() mmg_client = MMGClient() +reach_client = ReachClient() aws_ses_client = AwsSesClient() aws_ses_stub_client = AwsSesStubClient() encryption = Encryption() @@ -98,6 +100,7 @@ def create_app(application): logging.init_app(application, statsd_client) firetext_client.init_app(application, statsd_client=statsd_client) mmg_client.init_app(application, statsd_client=statsd_client) + reach_client.init_app(application, statsd_client=statsd_client) aws_ses_client.init_app(application.config['AWS_REGION'], statsd_client=statsd_client) aws_ses_stub_client.init_app( @@ -107,7 +110,10 @@ def create_app(application): ) # If a stub url is provided for SES, then use the stub client rather than the real SES boto client email_clients = [aws_ses_stub_client] if application.config['SES_STUB_URL'] else [aws_ses_client] - notification_provider_clients.init_app(sms_clients=[firetext_client, mmg_client], email_clients=email_clients) + notification_provider_clients.init_app( + sms_clients=[firetext_client, mmg_client, reach_client], + email_clients=email_clients + ) notify_celery.init_app(application) encryption.init_app(application) diff --git a/app/clients/sms/reach.py b/app/clients/sms/reach.py index d9b016e2a..ad4eee17f 100644 --- a/app/clients/sms/reach.py +++ b/app/clients/sms/reach.py @@ -1,3 +1,7 @@ +import json + +from requests import RequestException, request + from app.clients.sms import SmsClient, SmsClientResponseException @@ -13,13 +17,49 @@ def get_reach_responses(status, detailed_status_code=None): class ReachClientResponseException(SmsClientResponseException): - pass # TODO (custom exception for errors) + def __init__(self, response, exception): + status_code = response.status_code if response is not None else 504 + text = response.text if response is not None else "Gateway Time-out" + + self.status_code = status_code + self.text = text + self.exception = exception + + def __str__(self): + return "Code {} text {} exception {}".format(self.status_code, self.text, str(self.exception)) class ReachClient(SmsClient): + def init_app(self, *args, **kwargs): + super().init_app(*args, **kwargs) + self.url = self.current_app.config.get('REACH_URL') - def get_name(self): - pass # TODO + @property + def name(self): + return 'reach' - def send_sms(self, to, content, reference, international, multi=True, sender=None): - pass # TODO + def try_send_sms(self, to, content, reference, international, sender): + data = { + # TODO + } + + try: + response = request( + "POST", + self.url, + data=json.dumps(data), + headers={ + 'Content-Type': 'application/json', + }, + timeout=60 + ) + + response.raise_for_status() + try: + json.loads(response.text) + except (ValueError, AttributeError) as e: + raise ReachClientResponseException(response=response, exception=e) + except RequestException as e: + raise ReachClientResponseException(response=e.response, exception=e) + + return response diff --git a/app/config.py b/app/config.py index a9e95f930..f36fdb7c5 100644 --- a/app/config.py +++ b/app/config.py @@ -380,6 +380,7 @@ class Config(object): # these environment vars aren't defined in the manifest so to set them on paas use `cf set-env` MMG_URL = os.environ.get("MMG_URL", "https://api.mmg.co.uk/jsonv2a/api.php") FIRETEXT_URL = os.environ.get("FIRETEXT_URL", "https://www.firetext.co.uk/api/sendsms/json") + REACH_URL = os.environ.get("REACH_URL", "TODO") SES_STUB_URL = os.environ.get("SES_STUB_URL") AWS_REGION = 'eu-west-1' @@ -481,6 +482,7 @@ class Test(Development): MMG_URL = 'https://example.com/mmg' FIRETEXT_URL = 'https://example.com/firetext' + REACH_URL = 'https://example.com/reach' CBC_PROXY_ENABLED = True DVLA_EMAIL_ADDRESSES = ['success@simulator.amazonses.com', 'success+2@simulator.amazonses.com'] diff --git a/migrations/versions/0367_add_reach.py b/migrations/versions/0367_add_reach.py new file mode 100644 index 000000000..92eef85fd --- /dev/null +++ b/migrations/versions/0367_add_reach.py @@ -0,0 +1,54 @@ +""" + +Revision ID: 0367_add_reach +Revises: 0366_letter_rates_2022 +Create Date: 2022-03-24 16:00:00 + +""" +import itertools +import uuid +from datetime import datetime + +from alembic import op +from sqlalchemy.sql import text + +from app.models import LetterRate + + +revision = '0367_add_reach' +down_revision = '0366_letter_rates_2022' + + +def upgrade(): + conn = op.get_bind() + conn.execute( + """ + INSERT INTO provider_details ( + id, + display_name, + identifier, + priority, + notification_type, + active, + version, + created_by_id + ) + VALUES ( + '{}', + 'Reach', + 'reach', + 0, + 'sms', + false, + 1, + null + ) + """.format( + str(uuid.uuid4()), + ) + ) + + +def downgrade(): + conn = op.get_bind() + conn.execute("DELETE FROM provider_details WHERE identifier = 'reach'") diff --git a/tests/app/clients/test_reach.py b/tests/app/clients/test_reach.py index 00cf4f034..7cc8b2bc3 100644 --- a/tests/app/clients/test_reach.py +++ b/tests/app/clients/test_reach.py @@ -1 +1,93 @@ -# TODO: all of the tests +import pytest +import requests_mock +from requests import HTTPError +from requests.exceptions import ConnectTimeout, ReadTimeout + +from app import reach_client +from app.clients.sms import SmsClientResponseException +from app.clients.sms.reach import ReachClientResponseException + +# TODO: tests for get_reach_responses + + +def test_try_send_sms_successful_returns_reach_response(notify_api, mocker): + to = content = reference = 'foo' + response_dict = {} # TODO + + with requests_mock.Mocker() as request_mock: + request_mock.post('https://example.com/reach', json=response_dict, status_code=200) + response = reach_client.try_send_sms(to, content, reference, False, 'sender') + + # response_json = response.json() + assert response.status_code == 200 + # TODO: assertions + + +def test_try_send_sms_calls_reach_correctly(notify_api, mocker): + to = '+447234567890' + content = 'my message' + reference = 'my reference' + response_dict = {} # TODO + + with requests_mock.Mocker() as request_mock: + request_mock.post('https://example.com/reach', json=response_dict, status_code=200) + reach_client.try_send_sms(to, content, reference, False, 'sender') + + assert request_mock.call_count == 1 + assert request_mock.request_history[0].url == 'https://example.com/reach' + assert request_mock.request_history[0].method == 'POST' + + # request_args = request_mock.request_history[0].json() + # TODO: assertions + + +def test_try_send_sms_raises_if_reach_rejects(notify_api, mocker): + to = content = reference = 'foo' + response_dict = { + 'Error': 206, + 'Description': 'Some kind of error' + } + + with pytest.raises(SmsClientResponseException) as exc, requests_mock.Mocker() as request_mock: + request_mock.post('https://example.com/reach', json=response_dict, status_code=400) + reach_client.try_send_sms(to, content, reference, False, 'sender') + + assert exc.value.status_code == 400 + assert '"Error": 206' in exc.value.text + assert '"Description": "Some kind of error"' in exc.value.text + assert type(exc.value.exception) == HTTPError + + +def test_try_send_sms_raises_if_reach_fails_to_return_json(notify_api, mocker): + to = content = reference = 'foo' + response_dict = 'NOT AT ALL VALID JSON {"key" : "value"}}' + + with pytest.raises(SmsClientResponseException) as exc, requests_mock.Mocker() as request_mock: + request_mock.post('https://example.com/reach', text=response_dict, status_code=200) + reach_client.try_send_sms(to, content, reference, False, 'sender') + + assert 'Code 200 text NOT AT ALL VALID JSON {"key" : "value"}} exception Expecting value: line 1 column 1 (char 0)' in str(exc.value) # noqa + assert exc.value.status_code == 200 + assert exc.value.text == 'NOT AT ALL VALID JSON {"key" : "value"}}' + + +def test_try_send_sms_raises_if_reach_rejects_with_connect_timeout(rmock): + to = content = reference = 'foo' + + with pytest.raises(ReachClientResponseException) as exc: + rmock.register_uri('POST', 'https://example.com/reach', exc=ConnectTimeout) + reach_client.try_send_sms(to, content, reference, False, 'sender') + + assert exc.value.status_code == 504 + assert exc.value.text == 'Gateway Time-out' + + +def test_try_send_sms_raises_if_reach_rejects_with_read_timeout(rmock): + to = content = reference = 'foo' + + with pytest.raises(ReachClientResponseException) as exc: + rmock.register_uri('POST', 'https://example.com/reach', exc=ReadTimeout) + reach_client.try_send_sms(to, content, reference, False, 'sender') + + assert exc.value.status_code == 504 + assert exc.value.text == 'Gateway Time-out'