diff --git a/app/aws/s3.py b/app/aws/s3.py index 2aa7aac39..ed206b3a4 100644 --- a/app/aws/s3.py +++ b/app/aws/s3.py @@ -4,7 +4,12 @@ from flask import current_app FILE_LOCATION_STRUCTURE = 'service-{}-notify/{}.csv' -def get_s3_job_object(bucket_name, file_location): +def get_s3_file(bucket_name, file_location): + s3_file = get_s3_object(bucket_name, file_location) + return s3_file.get()['Body'].read().decode('utf-8') + + +def get_s3_object(bucket_name, file_location): s3 = resource('s3') return s3.Object(bucket_name, file_location) @@ -12,12 +17,12 @@ def get_s3_job_object(bucket_name, file_location): def get_job_from_s3(service_id, job_id): bucket_name = current_app.config['CSV_UPLOAD_BUCKET_NAME'] file_location = FILE_LOCATION_STRUCTURE.format(service_id, job_id) - obj = get_s3_job_object(bucket_name, file_location) + obj = get_s3_object(bucket_name, file_location) return obj.get()['Body'].read().decode('utf-8') def remove_job_from_s3(service_id, job_id): bucket_name = current_app.config['CSV_UPLOAD_BUCKET_NAME'] file_location = FILE_LOCATION_STRUCTURE.format(service_id, job_id) - obj = get_s3_job_object(bucket_name, file_location) + obj = get_s3_object(bucket_name, file_location) return obj.delete() diff --git a/app/celery/tasks.py b/app/celery/tasks.py index 464e3e1be..48923d228 100644 --- a/app/celery/tasks.py +++ b/app/celery/tasks.py @@ -1,6 +1,7 @@ import random from datetime import (datetime) +from collections import namedtuple from flask import current_app from notifications_utils.recipients import ( @@ -354,3 +355,27 @@ def get_template_class(template_type): # since we don't need rendering capabilities (we only need to extract placeholders) both email and letter can # use the same base template return WithSubjectTemplate + + +@notify_celery.task(bind=True, name='update-letter-notifications-statuses') +@statsd(namespace="tasks") +def update_letter_notifications_statuses(self, filename): + bucket_location = '{}-ftp'.format(current_app.config['NOTIFY_EMAIL_DOMAIN']) + response_file = s3.get_s3_file(bucket_location, filename) + + try: + NotificationUpdate = namedtuple('NotificationUpdate', ['reference', 'status', 'page_count', 'cost_threshold']) + notification_updates = [NotificationUpdate(*line.split('|')) for line in response_file.splitlines()] + + except TypeError: + current_app.logger.exception('DVLA response file: {} has an invalid format'.format(filename)) + raise + + else: + if notification_updates: + for update in notification_updates: + current_app.logger.info('DVLA update: {}'.format(str(update))) + # TODO: Update notifications with desired status + return notification_updates + else: + current_app.logger.exception('DVLA response file contained no updates') diff --git a/app/config.py b/app/config.py index 4b0f170fb..b87f4d031 100644 --- a/app/config.py +++ b/app/config.py @@ -89,6 +89,7 @@ class Config(object): PASSWORD_RESET_TEMPLATE_ID = '474e9242-823b-4f99-813d-ed392e7f1201' ALREADY_REGISTERED_EMAIL_TEMPLATE_ID = '0880fbb1-a0c6-46f0-9a8e-36c986381ceb' CHANGE_EMAIL_CONFIRMATION_TEMPLATE_ID = 'eb4d9930-87ab-4aef-9bce-786762687884' + SERVICE_NOW_LIVE_TEMPLATE_ID = '618185c6-3636-49cd-b7d2-6f6f5eb3bdde' BROKER_URL = 'sqs://' BROKER_TRANSPORT_OPTIONS = { diff --git a/app/dao/services_dao.py b/app/dao/services_dao.py index c5679289c..b0891c490 100644 --- a/app/dao/services_dao.py +++ b/app/dao/services_dao.py @@ -384,3 +384,12 @@ def dao_suspend_service(service_id): def dao_resume_service(service_id): service = Service.query.get(service_id) service.active = True + + +def dao_fetch_active_users_for_service(service_id): + query = User.query.filter( + User.user_to_service.any(id=service_id), + User.state == 'active' + ) + + return query.all() diff --git a/app/models.py b/app/models.py index f15507e01..d0f2dfeb7 100644 --- a/app/models.py +++ b/app/models.py @@ -31,6 +31,18 @@ from app import ( from app.history_meta import Versioned from app.utils import get_utc_time_in_bst +SMS_TYPE = 'sms' +EMAIL_TYPE = 'email' +LETTER_TYPE = 'letter' + +TEMPLATE_TYPES = [SMS_TYPE, EMAIL_TYPE, LETTER_TYPE] + +template_types = db.Enum(*TEMPLATE_TYPES, name='template_type') + +NORMAL = 'normal' +PRIORITY = 'priority' +TEMPLATE_PROCESS_TYPE = [NORMAL, PRIORITY] + def filter_null_value_fields(obj): return dict( @@ -183,6 +195,30 @@ class Service(db.Model, Versioned): ) +INTERNATIONAL_SMS_TYPE = 'international_sms' +INCOMING_SMS_TYPE = 'incoming_sms' + +SERVICE_PERMISSION_TYPES = [EMAIL_TYPE, SMS_TYPE, LETTER_TYPE, INTERNATIONAL_SMS_TYPE, INCOMING_SMS_TYPE] + + +class ServicePermissionTypes(db.Model): + __tablename__ = 'service_permission_types' + + name = db.Column(db.String(255), primary_key=True) + + +class ServicePermission(db.Model): + __tablename__ = "service_permissions" + + service_id = db.Column(UUID(as_uuid=True), db.ForeignKey('services.id'), + primary_key=True, index=True, nullable=False) + service = db.relationship('Service') + permission = db.Column(db.String(255), db.ForeignKey('service_permission_types.name'), + index=True, primary_key=True, nullable=False) + created_at = db.Column(db.DateTime, default=datetime.datetime.utcnow, nullable=False) + updated_at = db.Column(db.DateTime, nullable=True, onupdate=datetime.datetime.utcnow) + + MOBILE_TYPE = 'mobile' EMAIL_TYPE = 'email' @@ -293,19 +329,6 @@ class TemplateProcessTypes(db.Model): name = db.Column(db.String(255), primary_key=True) -SMS_TYPE = 'sms' -EMAIL_TYPE = 'email' -LETTER_TYPE = 'letter' - -TEMPLATE_TYPES = [SMS_TYPE, EMAIL_TYPE, LETTER_TYPE] - -template_types = db.Enum(*TEMPLATE_TYPES, name='template_type') - -NORMAL = 'normal' -PRIORITY = 'priority' -TEMPLATE_PROCESS_TYPE = [NORMAL, PRIORITY] - - class Template(db.Model): __tablename__ = 'templates' diff --git a/app/notifications/notifications_letter_callback.py b/app/notifications/notifications_letter_callback.py index f7c357055..2f758b7ac 100644 --- a/app/notifications/notifications_letter_callback.py +++ b/app/notifications/notifications_letter_callback.py @@ -1,39 +1,57 @@ -from datetime import datetime +from functools import wraps from flask import ( Blueprint, jsonify, request, - current_app, - json + current_app ) -from app import statsd_client -from app.clients.email.aws_ses import get_aws_responses -from app.dao import ( - notifications_dao -) +from app.celery.tasks import update_letter_notifications_statuses +from app.v2.errors import register_errors +from app.notifications.utils import autoconfirm_subscription +from app.schema_validation import validate -from app.notifications.process_client_response import validate_callback_data letter_callback_blueprint = Blueprint('notifications_letter_callback', __name__) - -from app.errors import ( - register_errors, - InvalidRequest -) - register_errors(letter_callback_blueprint) +dvla_sns_callback_schema = { + "$schema": "http://json-schema.org/draft-04/schema#", + "description": "sns callback received on s3 update", + "type": "object", + "title": "dvla internal sns callback", + "properties": { + "Type": {"enum": ["Notification", "SubscriptionConfirmation"]}, + "MessageId": {"type": "string"}, + "Message": {"type": ["string", "object"]} + }, + "required": ["Type", "MessageId", "Message"] +} + + +def validate_schema(schema): + def decorator(f): + @wraps(f) + def wrapper(*args, **kw): + validate(request.json, schema) + return f(*args, **kw) + return wrapper + return decorator + + @letter_callback_blueprint.route('/notifications/letter/dvla', methods=['POST']) +@validate_schema(dvla_sns_callback_schema) def process_letter_response(): - try: - dvla_request = json.loads(request.data) - current_app.logger.info(dvla_request) - return jsonify( - result="success", message="DVLA callback succeeded" - ), 200 - except ValueError: - error = "DVLA callback failed: invalid json" - raise InvalidRequest(error, status_code=400) + req_json = request.json + if not autoconfirm_subscription(req_json): + # The callback should have one record for an S3 Put Event. + filename = req_json['Message']['Records'][0]['s3']['object']['key'] + current_app.logger.info('Received file from DVLA: {}'.format(filename)) + current_app.logger.info('DVLA callback: Calling task to update letter notifications') + update_letter_notifications_statuses.apply_async([filename], queue='notify') + + return jsonify( + result="success", message="DVLA callback succeeded" + ), 200 diff --git a/app/notifications/notifications_ses_callback.py b/app/notifications/notifications_ses_callback.py index ce8dec9ba..e78fb9394 100644 --- a/app/notifications/notifications_ses_callback.py +++ b/app/notifications/notifications_ses_callback.py @@ -13,9 +13,8 @@ from app.clients.email.aws_ses import get_aws_responses from app.dao import ( notifications_dao ) - from app.notifications.process_client_response import validate_callback_data -from app.notifications.utils import confirm_subscription +from app.notifications.utils import autoconfirm_subscription ses_callback_blueprint = Blueprint('notifications_ses_callback', __name__) @@ -32,14 +31,12 @@ def process_ses_response(): try: ses_request = json.loads(request.data) - if ses_request.get('Type') == 'SubscriptionConfirmation': - current_app.logger.info("SNS subscription confirmation url: {}".format(ses_request['SubscribeURL'])) - subscribed_topic = confirm_subscription(ses_request) - if subscribed_topic: - current_app.logger.info("Automatically subscribed to topic: {}".format(subscribed_topic)) - return jsonify( - result="success", message="SES callback succeeded" - ), 200 + subscribed_topic = autoconfirm_subscription(ses_request) + if subscribed_topic: + current_app.logger.info("Automatically subscribed to topic: {}".format(subscribed_topic)) + return jsonify( + result="success", message="SES callback succeeded" + ), 200 errors = validate_callback_data(data=ses_request, fields=['Message'], client_name=client_name) if errors: diff --git a/app/notifications/utils.py b/app/notifications/utils.py index 80346c475..5f1443f09 100644 --- a/app/notifications/utils.py +++ b/app/notifications/utils.py @@ -16,3 +16,10 @@ def confirm_subscription(confirmation_request): raise e return confirmation_request['TopicArn'] + + +def autoconfirm_subscription(req_json): + if req_json.get('Type') == 'SubscriptionConfirmation': + current_app.logger.info("SNS subscription confirmation url: {}".format(req_json['SubscribeURL'])) + subscribed_topic = confirm_subscription(req_json) + return subscribed_topic diff --git a/app/service/rest.py b/app/service/rest.py index 4122db82a..db8c1aefa 100644 --- a/app/service/rest.py +++ b/app/service/rest.py @@ -46,6 +46,7 @@ from app.errors import ( InvalidRequest, register_errors) from app.service import statistics from app.service.utils import get_whitelist_objects +from app.service.sender import send_notification_to_service_users from app.schemas import ( service_schema, api_key_schema, @@ -117,10 +118,25 @@ def create_service(): @service_blueprint.route('/', methods=['POST']) def update_service(service_id): fetched_service = dao_fetch_service_by_id(service_id) + # Capture the status change here as Marshmallow changes this later + service_going_live = fetched_service.restricted and not request.get_json().get('restricted', True) + current_data = dict(service_schema.dump(fetched_service).data.items()) current_data.update(request.get_json()) update_dict = service_schema.load(current_data).data dao_update_service(update_dict) + + if service_going_live: + send_notification_to_service_users( + service_id=service_id, + template_id=current_app.config['SERVICE_NOW_LIVE_TEMPLATE_ID'], + personalisation={ + 'service_name': current_data['name'], + 'message_limit': current_data['message_limit'] + }, + include_user_fields=['name'] + ) + return jsonify(data=service_schema.dump(fetched_service).data), 200 diff --git a/app/service/sender.py b/app/service/sender.py new file mode 100644 index 000000000..3c6a6a03e --- /dev/null +++ b/app/service/sender.py @@ -0,0 +1,33 @@ +from flask import current_app + +from app.dao.services_dao import dao_fetch_service_by_id, dao_fetch_active_users_for_service +from app.dao.templates_dao import dao_get_template_by_id +from app.models import EMAIL_TYPE, KEY_TYPE_NORMAL +from app.notifications.process_notifications import persist_notification, send_notification_to_queue + + +def send_notification_to_service_users(service_id, template_id, personalisation={}, include_user_fields=[]): + template = dao_get_template_by_id(template_id) + service = dao_fetch_service_by_id(service_id) + active_users = dao_fetch_active_users_for_service(service.id) + notify_service = dao_fetch_service_by_id(current_app.config['NOTIFY_SERVICE_ID']) + + for user in active_users: + personalisation = _add_user_fields(user, personalisation, include_user_fields) + notification = persist_notification( + template_id=template.id, + template_version=template.version, + recipient=user.email_address if template.template_type == EMAIL_TYPE else user.mobile_number, + service=notify_service, + personalisation=personalisation, + notification_type=template.template_type, + api_key_id=None, + key_type=KEY_TYPE_NORMAL + ) + send_notification_to_queue(notification, False, queue='notify') + + +def _add_user_fields(user, personalisation, fields): + for field in fields: + personalisation[field] = getattr(user, field) + return personalisation diff --git a/migrations/versions/0083_add_perm_types_and_svc_perm.py b/migrations/versions/0083_add_perm_types_and_svc_perm.py new file mode 100644 index 000000000..485ddef5d --- /dev/null +++ b/migrations/versions/0083_add_perm_types_and_svc_perm.py @@ -0,0 +1,53 @@ +"""empty message + +Revision ID: 0083_add_perm_types_and_svc_perm +Revises: 0082_add_go_live_template +Create Date: 2017-05-12 11:29:32.664811 + +""" + +# revision identifiers, used by Alembic. +revision = '0083_add_perm_types_and_svc_perm' +down_revision = '0082_add_go_live_template' + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + service_permission_types = op.create_table('service_permission_types', + sa.Column('name', sa.String(length=255), nullable=False), + sa.PrimaryKeyConstraint('name')) + + op.bulk_insert(service_permission_types, + [ + {'name': x} for x in { + 'letter', + 'email', + 'sms', + 'international_sms', + 'incoming_sms' + } + ]) + + op.create_table('service_permissions', + sa.Column('service_id', postgresql.UUID(as_uuid=True), nullable=False), + sa.Column('permission', sa.String(length=255), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['permission'], ['service_permission_types.name'], ), + sa.ForeignKeyConstraint(['service_id'], ['services.id'], ), + sa.PrimaryKeyConstraint('service_id', 'permission')) + op.create_index(op.f('ix_service_permissions_permission'), 'service_permissions', ['permission'], unique=False) + op.create_index(op.f('ix_service_permissions_service_id'), 'service_permissions', ['service_id'], unique=False) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_service_permissions_service_id'), table_name='service_permissions') + op.drop_index(op.f('ix_service_permissions_permission'), table_name='service_permissions') + op.drop_table('service_permissions') + op.drop_table('service_permission_types') + # ### end Alembic commands ### diff --git a/tests/app/aws/test_s3.py b/tests/app/aws/test_s3.py new file mode 100644 index 000000000..38c9966dd --- /dev/null +++ b/tests/app/aws/test_s3.py @@ -0,0 +1,11 @@ +from app.aws.s3 import get_s3_file + + +def test_get_s3_file_makes_correct_call(sample_service, sample_job, mocker): + get_s3_mock = mocker.patch('app.aws.s3.get_s3_object') + get_s3_file('foo-bucket', 'bar-file.txt') + + get_s3_mock.assert_called_with( + 'foo-bucket', + 'bar-file.txt' + ) diff --git a/tests/app/celery/test_tasks.py b/tests/app/celery/test_tasks.py index 8b9631b4f..560f68c27 100644 --- a/tests/app/celery/test_tasks.py +++ b/tests/app/celery/test_tasks.py @@ -12,15 +12,19 @@ from celery.exceptions import Retry from app import (encryption, DATETIME_FORMAT) from app.celery import provider_tasks from app.celery import tasks -from app.celery.tasks import s3, build_dvla_file, create_dvla_file_contents, update_dvla_job_to_error from app.celery.tasks import ( + s3, + build_dvla_file, + create_dvla_file_contents, + update_dvla_job_to_error, process_job, process_row, send_sms, send_email, persist_letter, get_template_class, - update_job_to_sent_to_dvla + update_job_to_sent_to_dvla, + update_letter_notifications_statuses ) from app.dao import jobs_dao, services_dao from app.models import ( @@ -34,6 +38,7 @@ from app.models import ( Job) from tests.app import load_example_csv +from tests.conftest import set_config from tests.app.conftest import ( sample_service, sample_template, @@ -1071,3 +1076,37 @@ def test_update_dvla_job_to_error(sample_letter_template, sample_letter_job): assert not n.sent_by assert 'error' == Job.query.filter_by(id=sample_letter_job.id).one().job_status + + +def test_update_letter_notifications_statuses_raises_for_invalid_format(notify_api, mocker): + invalid_file = 'ref-foo|Sent|1|Unsorted\nref-bar|Sent|2' + mocker.patch('app.celery.tasks.s3.get_s3_file', return_value=invalid_file) + + with pytest.raises(TypeError): + update_letter_notifications_statuses(filename='foo.txt') + + +def test_update_letter_notifications_statuses_calls_with_correct_bucket_location(notify_api, mocker): + s3_mock = mocker.patch('app.celery.tasks.s3.get_s3_object') + + with set_config(notify_api, 'NOTIFY_EMAIL_DOMAIN', 'foo.bar'): + update_letter_notifications_statuses(filename='foo.txt') + s3_mock.assert_called_with('{}-ftp'.format(current_app.config['NOTIFY_EMAIL_DOMAIN']), 'foo.txt') + + +def test_update_letter_notifications_statuses_builds_updates_list(notify_api, mocker): + valid_file = 'ref-foo|Sent|1|Unsorted\nref-bar|Sent|2|Sorted' + mocker.patch('app.celery.tasks.s3.get_s3_file', return_value=valid_file) + updates = update_letter_notifications_statuses(filename='foo.txt') + + assert len(updates) == 2 + + assert updates[0].reference == 'ref-foo' + assert updates[0].status == 'Sent' + assert updates[0].page_count == '1' + assert updates[0].cost_threshold == 'Unsorted' + + assert updates[1].reference == 'ref-bar' + assert updates[1].status == 'Sent' + assert updates[1].page_count == '2' + assert updates[1].cost_threshold == 'Sorted' diff --git a/tests/app/dao/test_services_dao.py b/tests/app/dao/test_services_dao.py index d37f8341b..89b909431 100644 --- a/tests/app/dao/test_services_dao.py +++ b/tests/app/dao/test_services_dao.py @@ -24,7 +24,8 @@ from app.dao.services_dao import ( dao_fetch_todays_stats_for_all_services, fetch_stats_by_date_range_for_all_services, dao_suspend_service, - dao_resume_service + dao_resume_service, + dao_fetch_active_users_for_service ) from app.dao.users_dao import save_model_user from app.models import ( @@ -49,6 +50,7 @@ from app.models import ( KEY_TYPE_TEST ) +from tests.app.db import create_user, create_service from tests.app.conftest import ( sample_notification as create_notification, sample_notification_history as create_notification_history, @@ -783,3 +785,13 @@ def test_fetch_monthly_historical_template_stats_for_service_separates_templates assert len(result.get('2016-04').keys()) == 2 assert str(template_one.id) in result.get('2016-04').keys() assert str(template_two.id) in result.get('2016-04').keys() + + +def test_dao_fetch_active_users_for_service_returns_active_only(notify_db, notify_db_session): + active_user = create_user(email='active@foo.com', state='active') + pending_user = create_user(email='pending@foo.com', state='pending') + service = create_service(user=active_user) + dao_add_user_to_service(service, pending_user) + users = dao_fetch_active_users_for_service(service.id) + + assert len(users) == 1 diff --git a/tests/app/db.py b/tests/app/db.py index b5838d693..f1dba8ffa 100644 --- a/tests/app/db.py +++ b/tests/app/db.py @@ -9,13 +9,14 @@ from app.dao.templates_dao import dao_create_template from app.dao.services_dao import dao_create_service -def create_user(mobile_number="+447700900986", email="notify@digital.cabinet-office.gov.uk"): +def create_user(mobile_number="+447700900986", email="notify@digital.cabinet-office.gov.uk", state='active'): data = { + 'id': uuid.uuid4(), 'name': 'Test User', 'email_address': email, 'password': 'password', 'mobile_number': mobile_number, - 'state': 'active' + 'state': state } user = User.query.filter_by(email_address=email).first() if not user: @@ -24,11 +25,11 @@ def create_user(mobile_number="+447700900986", email="notify@digital.cabinet-off return user -def create_service(user=None, service_name="Sample service", service_id=None): +def create_service(user=None, service_name="Sample service", service_id=None, restricted=False): service = Service( name=service_name, message_limit=1000, - restricted=False, + restricted=restricted, email_from=service_name.lower().replace(' ', '.'), created_by=user or create_user() ) diff --git a/tests/app/notifications/rest/test_callbacks.py b/tests/app/notifications/rest/test_callbacks.py index 780076b34..a15a607df 100644 --- a/tests/app/notifications/rest/test_callbacks.py +++ b/tests/app/notifications/rest/test_callbacks.py @@ -10,17 +10,65 @@ from app.dao.notifications_dao import ( get_notification_by_id ) from app.models import NotificationStatistics +from tests.app.notifications.test_notifications_ses_callback import ses_confirmation_callback from tests.app.conftest import sample_notification as create_sample_notification -def test_dvla_callback_should_not_need_auth(client): - data = json.dumps({"somekey": "somevalue"}) +def test_dvla_callback_returns_400_with_invalid_request(client): + data = json.dumps({"foo": "bar"}) response = client.post( path='/notifications/letter/dvla', data=data, - headers=[('Content-Type', 'application/json')]) + headers=[('Content-Type', 'application/json')] + ) + + assert response.status_code == 400 + + +def test_dvla_callback_autoconfirms_subscription(client, mocker): + autoconfirm_mock = mocker.patch('app.notifications.notifications_letter_callback.autoconfirm_subscription') + + data = ses_confirmation_callback() + response = client.post( + path='/notifications/letter/dvla', + data=data, + headers=[('Content-Type', 'application/json')] + ) assert response.status_code == 200 + assert autoconfirm_mock.called + + +def test_dvla_callback_autoconfirm_does_not_call_update_letter_notifications_task(client, mocker): + autoconfirm_mock = mocker.patch('app.notifications.notifications_letter_callback.autoconfirm_subscription') + update_task = \ + mocker.patch('app.notifications.notifications_letter_callback.update_letter_notifications_statuses.apply_async') + + data = ses_confirmation_callback() + response = client.post( + path='/notifications/letter/dvla', + data=data, + headers=[('Content-Type', 'application/json')] + ) + + assert response.status_code == 200 + assert autoconfirm_mock.called + assert not update_task.called + + +def test_dvla_callback_calls_update_letter_notifications_task(client, mocker): + update_task = \ + mocker.patch('app.notifications.notifications_letter_callback.update_letter_notifications_statuses.apply_async') + data = _sample_sns_s3_callback() + response = client.post( + path='/notifications/letter/dvla', + data=data, + headers=[('Content-Type', 'application/json')] + ) + + assert response.status_code == 200 + assert update_task.called + update_task.assert_called_with(['bar.txt'], queue='notify') def test_firetext_callback_should_not_need_auth(client, mocker): @@ -458,3 +506,46 @@ def test_firetext_callback_should_record_statsd(client, notify_db, notify_db_ses def get_notification_stats(service_id): return NotificationStatistics.query.filter_by(service_id=service_id).one() + + +def _sample_sns_s3_callback(): + return json.dumps({ + "SigningCertURL": "foo.pem", + "UnsubscribeURL": "bar", + "Signature": "some-signature", + "Type": "Notification", + "Timestamp": "2016-05-03T08:35:12.884Z", + "SignatureVersion": "1", + "MessageId": "6adbfe0a-d610-509a-9c47-af894e90d32d", + "Subject": "Amazon S3 Notification", + "TopicArn": "sample-topic-arn", + "Message": { + "Records": [{ + "eventVersion": "2.0", + "eventSource": "aws:s3", + "awsRegion": "eu-west-1", + "eventTime": "2017-05-03T08:35:12.826Z", + "eventName": "ObjectCreated:Put", + "userIdentity": {"principalId": "some-p-id"}, + "requestParameters": {"sourceIPAddress": "8.8.8.8"}, + "responseElements": {"x-amz-request-id": "some-req-id", "x-amz-id-2": "some-amz-id"}, + "s3": { + "s3SchemaVersion": "1.0", + "configurationId": "some-config-id", + "bucket": { + "name": "some-bucket", + "ownerIdentity": {"principalId": "some-p-id"}, + "arn": "some-bucket-arn" + }, + "object": { + "key": "bar.txt", + "size": 200, + "eTag": "some-etag", + "versionId": "some-v-id", + "sequencer": "some-seq" + } + } + } + ] + } + }) diff --git a/tests/app/service/test_rest.py b/tests/app/service/test_rest.py index 91c928751..1681444b7 100644 --- a/tests/app/service/test_rest.py +++ b/tests/app/service/test_rest.py @@ -1658,3 +1658,66 @@ def test_search_for_notification_by_to_field_return_multiple_matches( assert str(notification1.id) in [n["id"] for n in result["notifications"]] assert str(notification2.id) in [n["id"] for n in result["notifications"]] assert str(notification3.id) in [n["id"] for n in result["notifications"]] + + +def test_update_service_calls_send_notification_as_service_becomes_live(notify_db, notify_db_session, client, mocker): + send_notification_mock = mocker.patch('app.service.rest.send_notification_to_service_users') + + restricted_service = create_service( + notify_db, + notify_db_session, + restricted=True + ) + + data = { + "restricted": False + } + + auth_header = create_authorization_header() + resp = client.post( + 'service/{}'.format(restricted_service.id), + data=json.dumps(data), + headers=[auth_header], + content_type='application/json' + ) + + assert resp.status_code == 200 + assert send_notification_mock.called + + +def test_update_service_does_not_call_send_notification_for_live_service(sample_service, client, mocker): + send_notification_mock = mocker.patch('app.service.rest.send_notification_to_service_users') + + data = { + "restricted": True + } + + auth_header = create_authorization_header() + resp = client.post( + 'service/{}'.format(sample_service.id), + data=json.dumps(data), + headers=[auth_header], + content_type='application/json' + ) + + assert resp.status_code == 200 + assert not send_notification_mock.called + + +def test_update_service_does_not_call_send_notification_when_restricted_not_changed(sample_service, client, mocker): + send_notification_mock = mocker.patch('app.service.rest.send_notification_to_service_users') + + data = { + "name": 'Name of service' + } + + auth_header = create_authorization_header() + resp = client.post( + 'service/{}'.format(sample_service.id), + data=json.dumps(data), + headers=[auth_header], + content_type='application/json' + ) + + assert resp.status_code == 200 + assert not send_notification_mock.called diff --git a/tests/app/service/test_sender.py b/tests/app/service/test_sender.py new file mode 100644 index 000000000..3223e9e71 --- /dev/null +++ b/tests/app/service/test_sender.py @@ -0,0 +1,113 @@ +import pytest + +from flask import current_app + +from app.dao.services_dao import dao_add_user_to_service +from app.models import Notification, EMAIL_TYPE, SMS_TYPE +from app.service.sender import send_notification_to_service_users + +from tests.app.conftest import ( + notify_service as create_notify_service, + sample_service as create_sample_service +) +from tests.app.db import create_template, create_user + + +@pytest.mark.parametrize('notification_type', [ + EMAIL_TYPE, + SMS_TYPE +]) +def test_send_notification_to_service_users_persists_notifications_correctly( + notify_db, + notify_db_session, + notification_type, + sample_user, + mocker +): + mocker.patch('app.service.sender.send_notification_to_queue') + + create_notify_service(notify_db, notify_db_session) + service = create_sample_service(notify_db, notify_db_session, user=sample_user) + template = create_template(service, template_type=notification_type) + send_notification_to_service_users(service_id=service.id, template_id=template.id) + to = sample_user.email_address if notification_type == EMAIL_TYPE else sample_user.mobile_number + + notification = Notification.query.one() + + assert Notification.query.count() == 1 + assert notification.to == to + assert str(notification.service_id) == current_app.config['NOTIFY_SERVICE_ID'] + assert notification.template.id == template.id + assert notification.template.template_type == notification_type + assert notification.notification_type == notification_type + + +def test_send_notification_to_service_users_sends_to_queue( + notify_db, + notify_db_session, + sample_user, + mocker +): + send_mock = mocker.patch('app.service.sender.send_notification_to_queue') + + create_notify_service(notify_db, notify_db_session) + service = create_sample_service(notify_db, notify_db_session, user=sample_user) + template = create_template(service, template_type=EMAIL_TYPE) + send_notification_to_service_users(service_id=service.id, template_id=template.id) + + assert send_mock.called + assert send_mock.call_count == 1 + + +def test_send_notification_to_service_users_includes_user_fields_in_personalisation( + notify_db, + notify_db_session, + sample_user, + mocker +): + persist_mock = mocker.patch('app.service.sender.persist_notification') + mocker.patch('app.service.sender.send_notification_to_queue') + + create_notify_service(notify_db, notify_db_session) + service = create_sample_service(notify_db, notify_db_session, user=sample_user) + template = create_template(service, template_type=EMAIL_TYPE) + send_notification_to_service_users( + service_id=service.id, + template_id=template.id, + include_user_fields=['name', 'email_address', 'state'] + ) + + persist_call = persist_mock.call_args_list[0][1] + + assert len(persist_mock.call_args_list) == 1 + assert persist_call['personalisation'] == { + 'name': sample_user.name, + 'email_address': sample_user.email_address, + 'state': sample_user.state, + } + + +def test_send_notification_to_service_users_sends_to_active_users_only( + notify_db, + notify_db_session, + mocker +): + mocker.patch('app.service.sender.send_notification_to_queue') + + create_notify_service(notify_db, notify_db_session) + + first_active_user = create_user(email='foo@bar.com', state='active') + second_active_user = create_user(email='foo1@bar.com', state='active') + pending_user = create_user(email='foo2@bar.com', state='pending') + service = create_sample_service(notify_db, notify_db_session, user=first_active_user) + dao_add_user_to_service(service, second_active_user) + dao_add_user_to_service(service, pending_user) + template = create_template(service, template_type=EMAIL_TYPE) + + send_notification_to_service_users(service_id=service.id, template_id=template.id) + notifications = Notification.query.all() + + assert Notification.query.count() == 2 + + assert notifications[0].to == first_active_user.email_address + assert notifications[1].to == second_active_user.email_address