diff --git a/.talismanrc b/.talismanrc index 4d34aa1f18..bc3a11bc15 100644 --- a/.talismanrc +++ b/.talismanrc @@ -1,14 +1,23 @@ fileignoreconfig: -- filename: README.md - checksum: b2cbbb8508af49abccae8b35b317f7ce09215f3508430ed31440add78f450e5a -- filename: app/notifications/process_notifications.py - checksum: ae4e31c6eb56d91ec80ae09d13baf4558cf461c65f08893b93fee43f036a17a7 -- filename: lambda_functions/pinpoint_callback/pinpoint_callback_lambda.py - checksum: 7bd4900e14b1fa789bbb2568b8a8d7a400e3c8350ba32fb44cc0b5b66a2df037 -- filename: lambda_functions/ses_callback/ses_callback_lambda.py - checksum: b20c36921290a9609f158784e2a3278c36190887e6054ea548004a67675fd79b -- filename: poetry.lock - checksum: 34d12acdf749363555c31add4e7e7afa9e2a27afd792bd98c85f331b87bd7112 -- filename: scripts/trigger_task.py - checksum: 0e9d244dbe285de23fc84bb643407963dacf7d25a3358373f01f6272fb217778 + - filename: README.md + checksum: b2cbbb8508af49abccae8b35b317f7ce09215f3508430ed31440add78f450e5a + - filename: app/notifications/process_notifications.py + checksum: ae4e31c6eb56d91ec80ae09d13baf4558cf461c65f08893b93fee43f036a17a7 + - filename: tests/app/notifications/test_process_notifications_for_profile_v3.py + checksum: 4e15e63d349635131173ffdd7aebcd547621db08de877ef926d3a41fde72d065 + - filename: poetry.lock + checksum: 34d12acdf749363555c31add4e7e7afa9e2a27afd792bd98c85f331b87bd7112 + - filename: app/template/rest.py + checksum: 1e5bdac8bc694d50f8f656dec127dd036b7b1b5b6156e3282d3411956c71ba0b + - filename: app/service/rest.py + checksum: b42aefd1ae0e6ea76e75db4cf14d425facd0941943b17f7ba2e41f850ad1ec23 + - filename: app/celery/contact_information_tasks.py + checksum: 80d0acf88bafb1358583016b9e143f4523ef1160d6eacdc9754ca68859b90eae + - filename: lambda_functions/pinpoint_callback/pinpoint_callback_lambda.py + checksum: 7bd4900e14b1fa789bbb2568b8a8d7a400e3c8350ba32fb44cc0b5b66a2df037 + - filename: lambda_functions/ses_callback/ses_callback_lambda.py + checksum: b20c36921290a9609f158784e2a3278c36190887e6054ea548004a67675fd79b + - filename: scripts/trigger_task.py + checksum: 0e9d244dbe285de23fc84bb643407963dacf7d25a3358373f01f6272fb217778 + version: "1.0" diff --git a/app/celery/contact_information_tasks.py b/app/celery/contact_information_tasks.py index 2ae4a1dd55..cedcbbf6a1 100644 --- a/app/celery/contact_information_tasks.py +++ b/app/celery/contact_information_tasks.py @@ -1,16 +1,36 @@ +from celery import Task from flask import current_app +from notifications_utils.statsd_decorators import statsd +from requests import Timeout + + from app import notify_celery, va_profile_client from app.celery.common import can_retry, handle_max_retries_exceeded from app.celery.exceptions import AutoRetryException from app.celery.service_callback_tasks import check_and_queue_callback_task -from app.va.identifier import IdentifierType -from app.va.va_profile import VAProfileRetryableException, VAProfileNonRetryableException, NoContactInfoException -from app.dao.notifications_dao import get_notification_by_id, dao_update_notification, update_notification_status_by_id -from app.models import NOTIFICATION_PERMANENT_FAILURE, EMAIL_TYPE, SMS_TYPE +from app.dao.notifications_dao import ( + get_notification_by_id, + dao_update_notification, + update_notification_status_by_id, +) from app.exceptions import NotificationTechnicalFailureException, NotificationPermanentFailureException -from app.va.va_profile.exceptions import VAProfileIDNotFoundException -from notifications_utils.statsd_decorators import statsd -from requests import Timeout +from app.feature_flags import FeatureFlag, is_feature_enabled +from app.models import ( + NOTIFICATION_PERMANENT_FAILURE, + NOTIFICATION_PREFERENCES_DECLINED, + EMAIL_TYPE, + SMS_TYPE, + Notification, + RecipientIdentifier, +) +from app.va.identifier import IdentifierType +from app.va.va_profile import ( + VAProfileRetryableException, + VAProfileNonRetryableException, + NoContactInfoException, + VAProfileResult, +) +from app.va.va_profile.exceptions import VAProfileIDNotFoundException, CommunicationItemNotFoundException @notify_celery.task( @@ -24,55 +44,197 @@ ) @statsd(namespace='tasks') def lookup_contact_info( - self, - notification_id, + self: Task, + notification_id: str, ): + """ + Celery task to look up contact information (email/phone number) for a given notification. + If the feature flag, VA_PROFILE_V3_COMBINE_CONTACT_INFO_AND_PERMISSIONS_LOOKUP, is enabled, + also check for related communication permissions. + + Args: + self (Task): The Celery task instance. + notification_id (str): The ID of the notification for which to look up contact information. + + Raises: + AutoRetryException: If a retryable exception occurs during the lookup process. + NotificationTechnicalFailureException: If the maximum retries have been exceeded. + NotificationPermanentFailureException: If the exception indicates a permanent failure. + Exception: If an unhandled exception occurs. + + Returns: + None + """ current_app.logger.info('Looking up contact information for notification_id: %s.', notification_id) notification = get_notification_by_id(notification_id) recipient_identifier = notification.recipient_identifiers[IdentifierType.VA_PROFILE_ID.value] + should_send = notification.default_send try: - if EMAIL_TYPE == notification.notification_type: - recipient = va_profile_client.get_email(recipient_identifier) - elif SMS_TYPE == notification.notification_type: - recipient = va_profile_client.get_telephone(recipient_identifier) + if is_feature_enabled(FeatureFlag.VA_PROFILE_V3_COMBINE_CONTACT_INFO_AND_PERMISSIONS_LOOKUP): + result = get_profile_result(notification, recipient_identifier) + recipient = result.recipient + should_send = result.communication_allowed + permission_message = result.permission_message else: - raise NotImplementedError( - f'The task lookup_contact_info failed for notification {notification_id}. ' - f'{notification.notification_type} is not supported' + recipient = get_recipient( + notification.notification_type, + notification_id, + recipient_identifier, ) - except (Timeout, VAProfileRetryableException) as e: - if can_retry(self.request.retries, self.max_retries, notification_id): - current_app.logger.warning('Unable to get contact info for notification id: %s, retrying', notification_id) + except Exception as e: + handle_lookup_contact_info_exception(self, notification, recipient_identifier, e) + + notification.to = recipient + dao_update_notification(notification) + + if not should_send: + handle_communication_not_allowed(notification, recipient_identifier, permission_message) + + +def get_recipient( + notification_type: str, + notification_id: str, + recipient_identifier: RecipientIdentifier, +) -> str: + """ + Retrieve the recipient email or phone number. + + Args: + notification_type (str): The type of recipient info requested. + notification_id (str): The notification ID associated with this request. + recipient_identifier (RecipientIdentifier): The VA profile ID to retrieve the profile for. + + Returns: + str: The recipient email or phone number. + """ + if notification_type == EMAIL_TYPE: + return va_profile_client.get_email(recipient_identifier) + elif notification_type == SMS_TYPE: + return va_profile_client.get_telephone(recipient_identifier) + else: + raise NotImplementedError( + f'The task lookup_contact_info failed for notification {notification_id}. ' + f'{notification_type} is not supported' + ) + + +def get_profile_result( + notification: Notification, + recipient_identifier: RecipientIdentifier, +) -> VAProfileResult: + """ + Retrieve the result of looking up contact info from VA Profile. + + Args: + notification (Notification): The Notification object to get contact info and permissions for. + recipient_identifier (RecipientIdentifier): The VA profile ID to retrieve the profile for. + + Returns: + VAProfileResult: The contact info result from VA Profile. + """ + if notification.notification_type == EMAIL_TYPE: + return va_profile_client.get_email_with_permission(recipient_identifier, notification) + elif notification.notification_type == SMS_TYPE: + return va_profile_client.get_telephone_with_permission(recipient_identifier, notification) + else: + raise NotImplementedError( + f'The task lookup_contact_info failed for notification {notification.id}. ' + f'{notification.notification_type} is not supported' + ) + + +def handle_lookup_contact_info_exception( + lookup_task: Task, notification: Notification, recipient_identifier: RecipientIdentifier, e: Exception +): + """ + Handles exceptions that occur during the lookup of contact information. + + Args: + lookup_task (Task): The task object that is performing the lookup. + notification (Notification): The notification object associated with the lookup. + recipient_identifier (RecipientIdentifier): The identifier of the recipient. + e (Exception): The exception that was raised during the lookup. + + Raises: + AutoRetryException: If the exception is retryable and the task can be retried. + NotificationTechnicalFailureException: If the maximum retries have been exceeded. + NotificationPermanentFailureException: If the exception indicates a permanent failure. + Exception: If an unhandled exception occurs. + + Returns: + str or None: A message indicating the result of the exception handling, or None if no action is needed. + """ + if isinstance(e, (Timeout, VAProfileRetryableException)): + if can_retry(lookup_task.request.retries, lookup_task.max_retries, notification.id): + current_app.logger.warning('Unable to get contact info for notification id: %s, retrying', notification.id) raise AutoRetryException(f'Found {type(e).__name__}, autoretrying...', e, e.args) else: - msg = handle_max_retries_exceeded(notification_id, 'lookup_contact_info') + msg = handle_max_retries_exceeded(notification.id, 'lookup_contact_info') check_and_queue_callback_task(notification) raise NotificationTechnicalFailureException(msg) - except NoContactInfoException as e: + elif isinstance(e, NoContactInfoException): message = ( - f"Can't proceed after querying VA Profile for contact information for {notification_id}. " + f"Can't proceed after querying VA Profile for contact information for {notification.id}. " 'Stopping execution of following tasks. Notification has been updated to permanent-failure.' ) current_app.logger.warning('%s - %s: %s', e.__class__.__name__, str(e), message) update_notification_status_by_id( - notification_id, NOTIFICATION_PERMANENT_FAILURE, status_reason=e.failure_reason + notification.id, NOTIFICATION_PERMANENT_FAILURE, status_reason=e.failure_reason ) check_and_queue_callback_task(notification) raise NotificationPermanentFailureException(message) from e - except (VAProfileIDNotFoundException, VAProfileNonRetryableException) as e: + elif isinstance(e, (VAProfileIDNotFoundException, VAProfileNonRetryableException)): current_app.logger.exception(e) message = ( - f'The task lookup_contact_info failed for notification {notification_id}. ' + f'The task lookup_contact_info failed for notification {notification.id}. ' 'Notification has been updated to permanent-failure' ) update_notification_status_by_id( - notification_id, NOTIFICATION_PERMANENT_FAILURE, status_reason=e.failure_reason + notification.id, NOTIFICATION_PERMANENT_FAILURE, status_reason=e.failure_reason ) check_and_queue_callback_task(notification) raise NotificationPermanentFailureException(message) from e + elif isinstance(e, CommunicationItemNotFoundException): + current_app.logger.info( + 'Communication item for recipient %s not found on notification %s', + recipient_identifier.id_value, + notification.id, + ) - notification.to = recipient - dao_update_notification(notification) + return None if notification.default_send else 'No recipient opt-in found for explicit preference' + else: + current_app.logger.exception(f'Unhandled exception for notification {notification.id}: {e}') + raise e + + +def handle_communication_not_allowed( + notification: Notification, recipient_identifier: RecipientIdentifier, permission_message: str +): + """ + Handles the scenario where communication is not allowed for a given notification. + + Args: + notification (Notification): The notification object associated with the communication. + recipient_identifier (RecipientIdentifier): The identifier of the recipient. + permission_message (str): The message indicating the reason for permission denial. + + Raises: + NotificationPermanentFailureException: If the recipient has declined permission to receive notifications. + """ + if is_feature_enabled(FeatureFlag.VA_PROFILE_V3_COMBINE_CONTACT_INFO_AND_PERMISSIONS_LOOKUP): + current_app.logger.info( + 'Permission denied for recipient %s for notification %s', + recipient_identifier.id_value, + notification.id, + ) + reason = permission_message if permission_message is not None else 'Contact preferences set to false' + update_notification_status_by_id(notification.id, NOTIFICATION_PREFERENCES_DECLINED, status_reason=reason) + + message = f'The recipient for notification {notification.id} has declined permission to receive notifications.' + current_app.logger.info(message) + + check_and_queue_callback_task(notification) + raise NotificationPermanentFailureException(message) diff --git a/app/models.py b/app/models.py index 79822ae4d3..17d9bbea97 100644 --- a/app/models.py +++ b/app/models.py @@ -1,20 +1,17 @@ import datetime -import uuid import itertools -from typing import Dict, Any -from app import ( - DATETIME_FORMAT, - encryption, -) -from app.db import db -from app.encryption import ( - check_hash, - hashpw, -) -from app.history_meta import Versioned -from app.model import User, EMAIL_AUTH_TYPE -from app.va.identifier import IdentifierType -from flask import url_for, current_app +import uuid +from typing import Any, Dict, Optional + +from flask import current_app, url_for + +from sqlalchemy import CheckConstraint, Index, UniqueConstraint, and_, select +from sqlalchemy.dialects.postgresql import JSON, JSONB, UUID +from sqlalchemy.ext.associationproxy import association_proxy +from sqlalchemy.ext.declarative import declared_attr +from sqlalchemy.ext.hybrid import hybrid_property +from sqlalchemy.orm.collections import InstrumentedList, attribute_mapped_collection + from notifications_utils.columns import Columns from notifications_utils.letter_timings import get_letter_timings from notifications_utils.recipients import ( @@ -30,12 +27,13 @@ SMSMessageTemplate, ) from notifications_utils.timezones import convert_local_timezone_to_utc, convert_utc_to_local_timezone -from sqlalchemy import and_, CheckConstraint, Index, UniqueConstraint -from sqlalchemy.dialects.postgresql import JSON, JSONB, UUID -from sqlalchemy.ext.associationproxy import association_proxy -from sqlalchemy.ext.declarative import declared_attr -from sqlalchemy.ext.hybrid import hybrid_property -from sqlalchemy.orm.collections import attribute_mapped_collection, InstrumentedList + +from app import DATETIME_FORMAT, encryption +from app.db import db +from app.encryption import check_hash, hashpw +from app.history_meta import Versioned +from app.model import EMAIL_AUTH_TYPE, User +from app.va.identifier import IdentifierType EMAIL_TYPE = 'email' LETTER_TYPE = 'letter' @@ -1386,6 +1384,25 @@ class Notification(db.Model): {}, ) + @property + def communication_item(self) -> Optional['CommunicationItem']: + if self.template and self.template.communication_item_id: + communication_item = db.session.scalar( + select(CommunicationItem).where(CommunicationItem.id == self.template.communication_item_id) + ) + return communication_item + + @property + def va_profile_item_id(self): + if self.communication_item: + return self.communication_item.va_profile_item_id + + @property + def default_send(self): + if self.communication_item: + return self.communication_item.default_send_indicator + return True + @property def personalisation(self): if self._personalisation: diff --git a/app/notifications/process_notifications.py b/app/notifications/process_notifications.py index e6fde1c8f4..c464c670db 100644 --- a/app/notifications/process_notifications.py +++ b/app/notifications/process_notifications.py @@ -275,7 +275,10 @@ def send_to_queue_for_recipient_info_based_on_recipient_identifier( tasks.append(lookup_contact_info.si(notification.id).set(queue=QueueNames.LOOKUP_CONTACT_INFO)) - if communication_item_id: + if ( + not is_feature_enabled(FeatureFlag.VA_PROFILE_V3_COMBINE_CONTACT_INFO_AND_PERMISSIONS_LOOKUP) + and communication_item_id + ): tasks.append( lookup_recipient_communication_permissions.si(notification.id).set( queue=QueueNames.COMMUNICATION_ITEM_PERMISSIONS diff --git a/app/va/va_profile/__init__.py b/app/va/va_profile/__init__.py index 853592e4d0..fc0ad415aa 100644 --- a/app/va/va_profile/__init__.py +++ b/app/va/va_profile/__init__.py @@ -4,4 +4,7 @@ VAProfileNonRetryableException, NoContactInfoException, ) -from .va_profile_client import VAProfileClient # noqa: F401 +from .va_profile_client import ( # noqa: F401 + VAProfileClient, + VAProfileResult, +) diff --git a/app/va/va_profile/exceptions.py b/app/va/va_profile/exceptions.py index 0cbff0f846..329d8ef420 100644 --- a/app/va/va_profile/exceptions.py +++ b/app/va/va_profile/exceptions.py @@ -26,5 +26,5 @@ class ContactPreferencesException(VAProfileNonRetryableException): failure_reason = 'VA Profile contact preferences not allowing contact' -class CommunicationItemNotFoundException(Exception): +class CommunicationItemNotFoundException(VAProfileNonRetryableException): failure_reason = 'No communication bio found from VA Profile' diff --git a/app/va/va_profile/va_profile_client.py b/app/va/va_profile/va_profile_client.py index 4fc7962b26..46b6b0cb40 100644 --- a/app/va/va_profile/va_profile_client.py +++ b/app/va/va_profile/va_profile_client.py @@ -1,18 +1,23 @@ from __future__ import annotations +from dataclasses import dataclass from enum import Enum from http.client import responses -from typing import TYPE_CHECKING, Dict, List +from typing import TYPE_CHECKING + import iso8601 import requests + from app.va.identifier import OIDS, IdentifierType, transform_to_fhir_format from app.va.va_profile import NoContactInfoException, VAProfileNonRetryableException, VAProfileRetryableException -from app.va.va_profile.exceptions import CommunicationItemNotFoundException, VAProfileIDNotFoundException +from app.va.va_profile.exceptions import ( + CommunicationItemNotFoundException, + VAProfileIDNotFoundException, +) if TYPE_CHECKING: - from app.models import RecipientIdentifier - + from app.models import RecipientIdentifier, Notification from va_profile_types import CommunicationPermissions, ContactInformation, Profile, Telephone @@ -22,6 +27,17 @@ } +class CommunicationChannel(Enum): + EMAIL = ('Email', 2) + TEXT = ('Text', 1) + + def __new__(cls, value, id): + obj = object.__new__(cls) + obj._value_ = value + obj.id = id + return obj + + class PhoneNumberType(Enum): MOBILE = 'MOBILE' HOME = 'HOME' @@ -34,6 +50,13 @@ def valid_type_values() -> list[str]: return [PhoneNumberType.MOBILE.value, PhoneNumberType.HOME.value] +@dataclass +class VAProfileResult: + recipient: str + communication_allowed: bool + permission_message: str | None + + class VAProfileClient: SUCCESS_STATUS = 'COMPLETED_SUCCESS' EMAIL_BIO_TYPE = 'emails' @@ -77,7 +100,7 @@ def get_profile(self, va_profile_id: RecipientIdentifier) -> Profile: except (requests.HTTPError, requests.RequestException, requests.Timeout) as e: self._handle_exceptions(va_profile_id.id_value, e) - response_json: Dict = response.json() + response_json: dict = response.json() return response_json.get('profile', {}) def get_telephone(self, va_profile_id: RecipientIdentifier) -> str: @@ -93,9 +116,7 @@ def get_telephone(self, va_profile_id: RecipientIdentifier) -> str: contact_info: ContactInformation = self.get_profile(va_profile_id).get('contactInformation', {}) self.logger.debug('V3 Profile - Retrieved ContactInformation: %s', contact_info) - telephones: List[Telephone] = contact_info.get(self.PHONE_BIO_TYPE, []) - phone_numbers = ', '.join([tel['phoneNumber'] for tel in telephones]) - self.logger.debug('V3 Profile telephones: %s', phone_numbers) + telephones: list[Telephone] = contact_info.get(self.PHONE_BIO_TYPE, []) sorted_telephones = sorted( [phone for phone in telephones if phone['phoneType'] == PhoneNumberType.MOBILE.value], key=lambda phone: iso8601.parse_date(phone['createDate']), @@ -136,6 +157,100 @@ def get_email(self, va_profile_id: RecipientIdentifier) -> str: self.statsd_client.incr('clients.va-profile.get-email.failure') self._raise_no_contact_info_exception(self.EMAIL_BIO_TYPE, va_profile_id, contact_info.get(self.TX_AUDIT_ID)) + def get_telephone_with_permission( + self, + va_profile_id: RecipientIdentifier, + notification: Notification, + ) -> VAProfileResult: + """ + Retrieve the telephone number from the profile information for a given VA profile ID. + + Args: + va_profile_id (RecipientIdentifier): The VA profile ID to retrieve the telephone number for. + notification (Notification): Notification object which contains needed default_send and communication_item details + + Returns: + VAProfileResults: The result data. + Property recipient is the telephone number retrieved from the VA Profile service. + Property communication_allowed is true when VA Profile service indicates that the recipient has allowed communication. + Property permission_message may contain an error message if the permission check encountered an exception. + """ + profile = self.get_profile(va_profile_id) + communication_allowed = notification.default_send + permission_message = None + try: + communication_allowed = self.get_is_communication_allowed_from_profile( + profile, notification, CommunicationChannel.TEXT + ) + except CommunicationItemNotFoundException: + self.logger.info('Communication item for recipient %s not found', va_profile_id) + permission_message = f'V3 Profile - No recipient opt-in found for explicit preference, falling back to default send: {notification.default_send} (Recipient Identifier {va_profile_id.id_value})' + + contact_info: ContactInformation = profile.get('contactInformation', {}) + + telephones: list[Telephone] = contact_info.get(self.PHONE_BIO_TYPE, []) + sorted_telephones = sorted( + [phone for phone in telephones if phone['phoneType'] == PhoneNumberType.MOBILE.value], + key=lambda phone: iso8601.parse_date(phone['createDate']), + reverse=True, + ) + if sorted_telephones: + if ( + sorted_telephones[0].get('countryCode') + and sorted_telephones[0].get('areaCode') + and sorted_telephones[0].get('phoneNumber') + ): + self.statsd_client.incr('clients.va-profile.get-telephone.success') + telephone_result = f"+{sorted_telephones[0]['countryCode']}{sorted_telephones[0]['areaCode']}{sorted_telephones[0]['phoneNumber']}" + return VAProfileResult(telephone_result, communication_allowed, permission_message) + + self.statsd_client.incr('clients.va-profile.get-telephone.failure') + self._raise_no_contact_info_exception(self.PHONE_BIO_TYPE, va_profile_id, contact_info.get(self.TX_AUDIT_ID)) + + def get_email_with_permission( + self, + va_profile_id: RecipientIdentifier, + notification: Notification, + ) -> VAProfileResult: + """ + Retrieve the email address from the profile information for a given VA profile ID. + + Args: + va_profile_id (RecipientIdentifier): The VA profile ID to retrieve the email address for. + notification (Notification): Notification object which contains needed default_send and communication_item details + + Returns: + VAProfileResults: The result data. + Property recipient is the telephone number retrieved from the VA Profile service. + Property communication_allowed is true when VA Profile service indicates that the recipient has allowed communication. + Property permission_message may contain an error message if the permission check encountered an exception. + """ + profile = self.get_profile(va_profile_id) + communication_allowed = notification.default_send + permission_message = None + + try: + communication_allowed = self.get_is_communication_allowed_from_profile( + profile, notification, CommunicationChannel.EMAIL + ) + except CommunicationItemNotFoundException: + self.logger.info('Communication item for recipient %s not found', va_profile_id) + permission_message = f'V3 Profile - No recipient opt-in found for explicit preference, falling back to default send: {notification.default_send} (Recipient Identifier {va_profile_id.id_value})' + + contact_info: ContactInformation = profile.get('contactInformation', {}) + sorted_emails = sorted( + contact_info.get(self.EMAIL_BIO_TYPE, []), + key=lambda email: iso8601.parse_date(email['createDate']), + reverse=True, + ) + if sorted_emails: + self.statsd_client.incr('clients.va-profile.get-email.success') + email_result = sorted_emails[0].get('emailAddressText') + return VAProfileResult(email_result, communication_allowed, permission_message) + + self.statsd_client.incr('clients.va-profile.get-email.failure') + self._raise_no_contact_info_exception(self.EMAIL_BIO_TYPE, va_profile_id, contact_info.get(self.TX_AUDIT_ID)) + def get_is_communication_allowed( self, recipient_id: RecipientIdentifier, @@ -162,31 +277,11 @@ def get_is_communication_allowed( communication_permissions: CommunicationPermissions = self.get_profile(recipient_id).get( 'communicationPermissions', {} ) - self.logger.debug( - 'V3 Profile -- Retrieved Communication Permissions for recipient_id: %s, notification_id: \ - %s, notification_type: %s -- %s', - recipient_id.id_value, - notification_id, - notification_type, - communication_permissions, - ) for perm in communication_permissions: - self.logger.debug( - 'V3 Profile -- Found communication item id %s on recipient %s for notification %s', - communication_item_id, - recipient_id.id_value, - notification_id, - ) if ( perm['communicationChannelName'] == VA_NOTIFY_TO_VA_PROFILE_NOTIFICATION_TYPES[notification_type] and perm['communicationItemId'] == communication_item_id ): - self.logger.debug( - 'V3 Profile -- %s notification: Value of allowed is %s for notification %s', - perm['communicationChannelName'], - perm['allowed'], - notification_id, - ) self.statsd_client.incr('clients.va-profile.get-communication-item-permission.success') assert isinstance(perm['allowed'], bool) return perm['allowed'] @@ -203,6 +298,47 @@ def get_is_communication_allowed( self.statsd_client.incr('clients.va-profile.get-communication-item-permission.no-permissions') raise CommunicationItemNotFoundException + def get_is_communication_allowed_from_profile( + self, + profile: Profile, + notification: Notification, + communication_channel: CommunicationChannel, + ) -> bool: + """ + Determine if communication is allowed for a given recipient, communication item, and notification type. + + Argsj + profile (Profile): The recipient's profile. + notification (Notification): Notification object + communication_channel (CommunicationChannel): Communication channel to send the notification + + Returns: + bool: True if communication is allowed, False otherwise. + + Raises: + CommunicationItemNotFoundException: If no communication permissions are found for the given parameters. + """ + + communication_permissions: CommunicationPermissions = profile.get('communicationPermissions', {}) + for perm in communication_permissions: + if ( + perm['communicationChannelId'] == communication_channel.id + and perm['communicationItemId'] == notification.va_profile_item_id + ): + self.statsd_client.incr('clients.va-profile.get-communication-item-permission.success') + assert isinstance(perm['allowed'], bool) + return perm['allowed'] + + self.logger.debug( + 'V3 Profile -- did not have permission for communication item %s and channel %s', + notification.va_profile_item_id, + communication_channel.value, + ) + + # TODO 893 - use default communication item settings when that has been implemented + self.statsd_client.incr('clients.va-profile.get-communication-item-permission.no-permissions') + raise CommunicationItemNotFoundException + def _handle_exceptions(self, va_profile_id_value: str, error: Exception): """ Handle exceptions that occur during requests to the VA Profile service. diff --git a/cd/application-deployment/dev/vaec-api-task-definition.json b/cd/application-deployment/dev/vaec-api-task-definition.json index 348936941d..1582d3fc42 100644 --- a/cd/application-deployment/dev/vaec-api-task-definition.json +++ b/cd/application-deployment/dev/vaec-api-task-definition.json @@ -222,6 +222,10 @@ { "name": "COMP_AND_PEN_DYNAMODB_NAME", "value": "dev-bip-payment-notification-table" + }, + { + "name": "VA_PROFILE_V3_COMBINE_CONTACT_INFO_AND_PERMISSIONS_LOOKUP", + "value": "True" } ], "secrets": [ diff --git a/cd/application-deployment/dev/vaec-celery-task-definition.json b/cd/application-deployment/dev/vaec-celery-task-definition.json index 9e4b0a1763..7506d57220 100644 --- a/cd/application-deployment/dev/vaec-celery-task-definition.json +++ b/cd/application-deployment/dev/vaec-celery-task-definition.json @@ -228,6 +228,10 @@ { "name": "COMP_AND_PEN_DYNAMODB_NAME", "value": "dev-bip-payment-notification-table" + }, + { + "name": "VA_PROFILE_V3_COMBINE_CONTACT_INFO_AND_PERMISSIONS_LOOKUP", + "value": "True" } ], "secrets": [ diff --git a/cd/application-deployment/perf/vaec-api-task-definition.json b/cd/application-deployment/perf/vaec-api-task-definition.json index e6cb54ab46..30241827ae 100644 --- a/cd/application-deployment/perf/vaec-api-task-definition.json +++ b/cd/application-deployment/perf/vaec-api-task-definition.json @@ -198,6 +198,10 @@ { "name": "COMP_AND_PEN_DYNAMODB_NAME", "value": "perf-bip-payment-notification-table" + }, + { + "name": "VA_PROFILE_V3_COMBINE_CONTACT_INFO_AND_PERMISSIONS_LOOKUP", + "value": "True" } ], "secrets": [ diff --git a/cd/application-deployment/perf/vaec-celery-task-definition.json b/cd/application-deployment/perf/vaec-celery-task-definition.json index fd6dfbb77b..300d760d01 100644 --- a/cd/application-deployment/perf/vaec-celery-task-definition.json +++ b/cd/application-deployment/perf/vaec-celery-task-definition.json @@ -188,6 +188,10 @@ { "name": "COMP_AND_PEN_PERF_TO_NUMBER", "value": "+14254147755" + }, + { + "name": "VA_PROFILE_V3_COMBINE_CONTACT_INFO_AND_PERMISSIONS_LOOKUP", + "value": "True" } ], "secrets": [ diff --git a/cd/application-deployment/prod/vaec-api-task-definition.json b/cd/application-deployment/prod/vaec-api-task-definition.json index c2f5a5a87b..fe40b76e0b 100644 --- a/cd/application-deployment/prod/vaec-api-task-definition.json +++ b/cd/application-deployment/prod/vaec-api-task-definition.json @@ -202,6 +202,10 @@ { "name": "COMP_AND_PEN_DYNAMODB_NAME", "value": "prod-bip-payment-notification-table" + }, + { + "name": "VA_PROFILE_V3_COMBINE_CONTACT_INFO_AND_PERMISSIONS_LOOKUP", + "value": "False" } ], "secrets": [ diff --git a/cd/application-deployment/prod/vaec-celery-task-definition.json b/cd/application-deployment/prod/vaec-celery-task-definition.json index 62c55e1c71..9ab026c5c9 100644 --- a/cd/application-deployment/prod/vaec-celery-task-definition.json +++ b/cd/application-deployment/prod/vaec-celery-task-definition.json @@ -192,6 +192,10 @@ { "name": "COMP_AND_PEN_DYNAMODB_NAME", "value": "prod-bip-payment-notification-table" + }, + { + "name": "VA_PROFILE_V3_COMBINE_CONTACT_INFO_AND_PERMISSIONS_LOOKUP", + "value": "False" } ], "secrets": [ diff --git a/cd/application-deployment/staging/vaec-api-task-definition.json b/cd/application-deployment/staging/vaec-api-task-definition.json index eae765691c..22f9fc7e71 100644 --- a/cd/application-deployment/staging/vaec-api-task-definition.json +++ b/cd/application-deployment/staging/vaec-api-task-definition.json @@ -218,6 +218,10 @@ { "name": "COMP_AND_PEN_DYNAMODB_NAME", "value": "staging-bip-payment-notification-table" + }, + { + "name": "VA_PROFILE_V3_COMBINE_CONTACT_INFO_AND_PERMISSIONS_LOOKUP", + "value": "True" } ], "secrets": [ diff --git a/cd/application-deployment/staging/vaec-celery-task-definition.json b/cd/application-deployment/staging/vaec-celery-task-definition.json index b19f3bce7f..02aae8eb99 100644 --- a/cd/application-deployment/staging/vaec-celery-task-definition.json +++ b/cd/application-deployment/staging/vaec-celery-task-definition.json @@ -208,6 +208,10 @@ { "name": "COMP_AND_PEN_DYNAMODB_NAME", "value": "staging-bip-payment-notification-table" + }, + { + "name": "VA_PROFILE_V3_COMBINE_CONTACT_INFO_AND_PERMISSIONS_LOOKUP", + "value": "True" } ], "secrets": [ diff --git a/tests/app/celery/test_contact_information_tasks.py b/tests/app/celery/test_contact_information_tasks.py index da2a814ce9..9be73efa71 100644 --- a/tests/app/celery/test_contact_information_tasks.py +++ b/tests/app/celery/test_contact_information_tasks.py @@ -6,7 +6,6 @@ from app.exceptions import NotificationTechnicalFailureException, NotificationPermanentFailureException from app.models import ( EMAIL_TYPE, - Notification, NOTIFICATION_TECHNICAL_FAILURE, NOTIFICATION_PERMANENT_FAILURE, RecipientIdentifier, @@ -103,7 +102,7 @@ def test_should_not_retry_on_non_retryable_exception(client, mocker, sample_temp 'app.celery.contact_information_tasks.update_notification_status_by_id' ) - with pytest.raises(NotificationPermanentFailureException) as exc_info: + with pytest.raises(NotificationPermanentFailureException): lookup_contact_info(notification.id) mocked_va_profile_client.get_email.assert_called_with(mocker.ANY) @@ -130,7 +129,7 @@ def test_should_retry_on_retryable_exception(client, mocker, sample_template, sa mocked_va_profile_client.get_email = mocker.Mock(side_effect=exception_type('some error')) mocker.patch('app.celery.contact_information_tasks.va_profile_client', new=mocked_va_profile_client) - with pytest.raises(AutoRetryException) as exc_info: + with pytest.raises(AutoRetryException): lookup_contact_info(notification.id) mocked_va_profile_client.get_email.assert_called_with(mocker.ANY) @@ -196,7 +195,7 @@ def test_should_update_notification_to_technical_failure_on_max_retries( 'app.celery.contact_information_tasks.handle_max_retries_exceeded' ) - with pytest.raises(NotificationTechnicalFailureException) as exc_info: + with pytest.raises(NotificationTechnicalFailureException): lookup_contact_info(notification.id) mocked_va_profile_client.get_email.assert_called_with(mocker.ANY) @@ -230,7 +229,7 @@ def test_should_update_notification_to_permanent_failure_on_no_contact_info_exce 'app.celery.contact_information_tasks.update_notification_status_by_id' ) - with pytest.raises(NotificationPermanentFailureException) as exc_info: + with pytest.raises(NotificationPermanentFailureException): lookup_contact_info(notification.id) mocked_va_profile_client.get_email.assert_called_with(mocker.ANY) diff --git a/tests/app/celery/test_contact_information_tasks_for_profile_v3.py b/tests/app/celery/test_contact_information_tasks_for_profile_v3.py new file mode 100644 index 0000000000..c2c282813f --- /dev/null +++ b/tests/app/celery/test_contact_information_tasks_for_profile_v3.py @@ -0,0 +1,381 @@ +import pytest +import uuid +from app.celery.common import RETRIES_EXCEEDED +from app.celery.contact_information_tasks import lookup_contact_info +from app.celery.exceptions import AutoRetryException +from app.exceptions import NotificationTechnicalFailureException, NotificationPermanentFailureException +from app.feature_flags import FeatureFlag +from app.models import ( + EMAIL_TYPE, + NOTIFICATION_TECHNICAL_FAILURE, + NOTIFICATION_PERMANENT_FAILURE, + RecipientIdentifier, + SMS_TYPE, +) +from app.va.identifier import IdentifierType +from app.va.va_profile import ( + NoContactInfoException, + VAProfileClient, + VAProfileNonRetryableException, + VAProfileRetryableException, + VAProfileResult, +) +from requests import Timeout + +from tests.app.factories.feature_flag import mock_feature_flag + +EXAMPLE_VA_PROFILE_ID = '135' +notification_id = str(uuid.uuid4()) + + +def test_should_get_email_address_and_update_notification(client, mocker, sample_template, sample_notification): + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_COMBINE_CONTACT_INFO_AND_PERMISSIONS_LOOKUP, 'True') + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_IDENTIFY_MOBILE_TELEPHONE_NUMBERS, 'True') + + template = sample_template(template_type=EMAIL_TYPE) + notification = sample_notification( + template=template, + recipient_identifiers=[{'id_type': IdentifierType.VA_PROFILE_ID.value, 'id_value': EXAMPLE_VA_PROFILE_ID}], + ) + + mocked_get_notification_by_id = mocker.patch( + 'app.celery.contact_information_tasks.get_notification_by_id', return_value=notification + ) + + mocked_va_profile_client = mocker.Mock(VAProfileClient) + mocked_va_profile_client.get_email_with_permission = mocker.Mock( + return_value=VAProfileResult('test@test.org', True, None) + ) + mocker.patch('app.celery.contact_information_tasks.va_profile_client', new=mocked_va_profile_client) + + mocked_update_notification = mocker.patch('app.celery.contact_information_tasks.dao_update_notification') + + lookup_contact_info(notification.id) + + mocked_get_notification_by_id.assert_called() + mocked_va_profile_client.get_email_with_permission.assert_called_with(mocker.ANY, notification) + recipient_identifier = mocked_va_profile_client.get_email_with_permission.call_args[0][0] + assert isinstance(recipient_identifier, RecipientIdentifier) + assert recipient_identifier.id_value == EXAMPLE_VA_PROFILE_ID + mocked_update_notification.assert_called_with(notification) + assert notification.to == 'test@test.org' + + +def test_should_get_phone_number_and_update_notification(client, mocker, sample_notification): + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_COMBINE_CONTACT_INFO_AND_PERMISSIONS_LOOKUP, 'True') + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_IDENTIFY_MOBILE_TELEPHONE_NUMBERS, 'True') + + notification = sample_notification( + recipient_identifiers=[{'id_type': IdentifierType.VA_PROFILE_ID.value, 'id_value': EXAMPLE_VA_PROFILE_ID}] + ) + assert notification.notification_type == SMS_TYPE + mocked_get_notification_by_id = mocker.patch( + 'app.celery.contact_information_tasks.get_notification_by_id', return_value=notification + ) + + mocked_va_profile_client = mocker.Mock(VAProfileClient) + mocked_va_profile_client.get_telephone_with_permission = mocker.Mock( + return_value=VAProfileResult('+15555555555', True, None) + ) + mocker.patch('app.celery.contact_information_tasks.va_profile_client', new=mocked_va_profile_client) + + mocked_update_notification = mocker.patch('app.celery.contact_information_tasks.dao_update_notification') + + lookup_contact_info(notification.id) + + mocked_get_notification_by_id.assert_called() + mocked_va_profile_client.get_telephone_with_permission.assert_called_with(mocker.ANY, notification) + recipient_identifier = mocked_va_profile_client.get_telephone_with_permission.call_args[0][0] + assert isinstance(recipient_identifier, RecipientIdentifier) + assert recipient_identifier.id_value == EXAMPLE_VA_PROFILE_ID + mocked_update_notification.assert_called_with(notification) + assert notification.to == '+15555555555' + + +def test_should_get_phone_number_and_update_notification_with_no_communication_item( + client, mocker, sample_notification +): + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_COMBINE_CONTACT_INFO_AND_PERMISSIONS_LOOKUP, 'True') + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_IDENTIFY_MOBILE_TELEPHONE_NUMBERS, 'True') + + notification = sample_notification( + recipient_identifiers=[{'id_type': IdentifierType.VA_PROFILE_ID.value, 'id_value': EXAMPLE_VA_PROFILE_ID}] + ) + notification.template.communication_item_id = None + assert notification.notification_type == SMS_TYPE + mocked_get_notification_by_id = mocker.patch( + 'app.celery.contact_information_tasks.get_notification_by_id', return_value=notification + ) + + mocked_va_profile_client = mocker.Mock(VAProfileClient) + mocked_va_profile_client.get_telephone_with_permission = mocker.Mock( + return_value=VAProfileResult('+15555555555', True, None) + ) + mocker.patch('app.celery.contact_information_tasks.va_profile_client', new=mocked_va_profile_client) + + mocked_update_notification = mocker.patch('app.celery.contact_information_tasks.dao_update_notification') + + lookup_contact_info(notification.id) + + mocked_get_notification_by_id.assert_called() + mocked_va_profile_client.get_telephone_with_permission.assert_called_with(mocker.ANY, notification) + recipient_identifier = mocked_va_profile_client.get_telephone_with_permission.call_args[0][0] + assert isinstance(recipient_identifier, RecipientIdentifier) + assert recipient_identifier.id_value == EXAMPLE_VA_PROFILE_ID + mocked_update_notification.assert_called_with(notification) + assert notification.to == '+15555555555' + + +def test_should_not_retry_on_non_retryable_exception(client, mocker, sample_template, sample_notification): + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_COMBINE_CONTACT_INFO_AND_PERMISSIONS_LOOKUP, 'True') + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_IDENTIFY_MOBILE_TELEPHONE_NUMBERS, 'True') + + template = sample_template(template_type=EMAIL_TYPE) + notification = sample_notification( + template=template, + recipient_identifiers=[{'id_type': IdentifierType.VA_PROFILE_ID.value, 'id_value': EXAMPLE_VA_PROFILE_ID}], + ) + + mocker.patch('app.celery.contact_information_tasks.get_notification_by_id', return_value=notification) + + mocked_check_and_queue_callback_task = mocker.patch( + 'app.celery.contact_information_tasks.check_and_queue_callback_task', + ) + + mocked_va_profile_client = mocker.Mock(VAProfileClient) + + exception = VAProfileNonRetryableException + mocked_va_profile_client.get_email_with_permission = mocker.Mock(side_effect=exception) + mocker.patch('app.celery.contact_information_tasks.va_profile_client', new=mocked_va_profile_client) + + mocked_update_notification_status_by_id = mocker.patch( + 'app.celery.contact_information_tasks.update_notification_status_by_id' + ) + + with pytest.raises(NotificationPermanentFailureException): + lookup_contact_info(notification.id) + + mocked_va_profile_client.get_email_with_permission.assert_called_with(mocker.ANY, notification) + recipient_identifier = mocked_va_profile_client.get_email_with_permission.call_args[0][0] + assert isinstance(recipient_identifier, RecipientIdentifier) + assert recipient_identifier.id_value == EXAMPLE_VA_PROFILE_ID + + mocked_update_notification_status_by_id.assert_called_with( + notification.id, NOTIFICATION_PERMANENT_FAILURE, status_reason=exception.failure_reason + ) + mocked_check_and_queue_callback_task.assert_called_once_with(notification) + + +@pytest.mark.parametrize('exception_type', (Timeout, VAProfileRetryableException)) +def test_should_retry_on_retryable_exception(client, mocker, sample_template, sample_notification, exception_type): + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_COMBINE_CONTACT_INFO_AND_PERMISSIONS_LOOKUP, 'True') + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_IDENTIFY_MOBILE_TELEPHONE_NUMBERS, 'True') + + template = sample_template(template_type=EMAIL_TYPE) + notification = sample_notification( + template=template, + recipient_identifiers=[{'id_type': IdentifierType.VA_PROFILE_ID.value, 'id_value': EXAMPLE_VA_PROFILE_ID}], + ) + mocker.patch('app.celery.contact_information_tasks.get_notification_by_id', return_value=notification) + + mocked_va_profile_client = mocker.Mock(VAProfileClient) + mocked_va_profile_client.get_email_with_permission = mocker.Mock(side_effect=exception_type('some error')) + mocker.patch('app.celery.contact_information_tasks.va_profile_client', new=mocked_va_profile_client) + + with pytest.raises(AutoRetryException): + lookup_contact_info(notification.id) + + mocked_va_profile_client.get_email_with_permission.assert_called_with(mocker.ANY, notification) + recipient_identifier = mocked_va_profile_client.get_email_with_permission.call_args[0][0] + assert isinstance(recipient_identifier, RecipientIdentifier) + assert recipient_identifier.id_value == EXAMPLE_VA_PROFILE_ID + + +@pytest.mark.parametrize('notification_type', (SMS_TYPE, EMAIL_TYPE)) +def test_lookup_contact_info_should_retry_on_timeout( + client, mocker, sample_template, sample_notification, notification_type +): + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_COMBINE_CONTACT_INFO_AND_PERMISSIONS_LOOKUP, 'True') + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_IDENTIFY_MOBILE_TELEPHONE_NUMBERS, 'True') + + template = sample_template(template_type=notification_type) + notification = sample_notification( + template=template, + recipient_identifiers=[{'id_type': IdentifierType.VA_PROFILE_ID.value, 'id_value': EXAMPLE_VA_PROFILE_ID}], + ) + + mocker.patch('app.celery.contact_information_tasks.get_notification_by_id', return_value=notification) + + mocked_va_profile_client = mocker.Mock(VAProfileClient) + + if notification_type == SMS_TYPE: + mocked_va_profile_client.get_telephone_with_permission = mocker.Mock(side_effect=Timeout('Request timed out')) + else: + mocked_va_profile_client.get_email_with_permission = mocker.Mock(side_effect=Timeout('Request timed out')) + + mocker.patch('app.celery.contact_information_tasks.va_profile_client', new=mocked_va_profile_client) + + with pytest.raises(AutoRetryException) as exc_info: + lookup_contact_info(notification.id) + + assert exc_info.value.args[0] == 'Found Timeout, autoretrying...' + assert isinstance(exc_info.value.args[1], Timeout) + assert str(exc_info.value.args[1]) == 'Request timed out' + + if notification_type == SMS_TYPE: + mocked_va_profile_client.get_telephone_with_permission.assert_called_with(mocker.ANY, notification) + recipient_identifier = mocked_va_profile_client.get_telephone_with_permission.call_args[0][0] + else: + mocked_va_profile_client.get_email_with_permission.assert_called_with(mocker.ANY, notification) + recipient_identifier = mocked_va_profile_client.get_email_with_permission.call_args[0][0] + + assert isinstance(recipient_identifier, RecipientIdentifier) + assert recipient_identifier.id_value == EXAMPLE_VA_PROFILE_ID + + +def test_should_update_notification_to_technical_failure_on_max_retries( + client, mocker, sample_template, sample_notification +): + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_COMBINE_CONTACT_INFO_AND_PERMISSIONS_LOOKUP, 'True') + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_IDENTIFY_MOBILE_TELEPHONE_NUMBERS, 'True') + + template = sample_template(template_type=EMAIL_TYPE) + notification = sample_notification( + template=template, + recipient_identifiers=[{'id_type': IdentifierType.VA_PROFILE_ID.value, 'id_value': EXAMPLE_VA_PROFILE_ID}], + ) + mocker.patch('app.celery.contact_information_tasks.get_notification_by_id', return_value=notification) + + mocked_va_profile_client = mocker.Mock(VAProfileClient) + mocked_va_profile_client.get_email_with_permission = mocker.Mock(side_effect=VAProfileRetryableException) + mocker.patch('app.celery.contact_information_tasks.va_profile_client', new=mocked_va_profile_client) + mocker.patch('app.celery.contact_information_tasks.can_retry', return_value=False) + mocked_handle_max_retries_exceeded = mocker.patch( + 'app.celery.contact_information_tasks.handle_max_retries_exceeded' + ) + + with pytest.raises(NotificationTechnicalFailureException): + lookup_contact_info(notification.id) + + mocked_va_profile_client.get_email_with_permission.assert_called_with(mocker.ANY, notification) + recipient_identifier = mocked_va_profile_client.get_email_with_permission.call_args[0][0] + assert isinstance(recipient_identifier, RecipientIdentifier) + assert recipient_identifier.id_value == EXAMPLE_VA_PROFILE_ID + + mocked_handle_max_retries_exceeded.assert_called_once() + + +def test_should_update_notification_to_permanent_failure_on_no_contact_info_exception( + client, mocker, sample_template, sample_notification +): + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_COMBINE_CONTACT_INFO_AND_PERMISSIONS_LOOKUP, 'True') + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_IDENTIFY_MOBILE_TELEPHONE_NUMBERS, 'True') + + template = sample_template(template_type=EMAIL_TYPE) + notification = sample_notification( + template=template, + recipient_identifiers=[{'id_type': IdentifierType.VA_PROFILE_ID.value, 'id_value': EXAMPLE_VA_PROFILE_ID}], + ) + mocker.patch('app.celery.contact_information_tasks.get_notification_by_id', return_value=notification) + + mocked_va_profile_client = mocker.Mock(VAProfileClient) + exception = NoContactInfoException + mocked_va_profile_client.get_email_with_permission = mocker.Mock(side_effect=exception) + mocker.patch('app.celery.contact_information_tasks.va_profile_client', new=mocked_va_profile_client) + + mocked_check_and_queue_callback_task = mocker.patch( + 'app.celery.contact_information_tasks.check_and_queue_callback_task', + ) + + mocked_update_notification_status_by_id = mocker.patch( + 'app.celery.contact_information_tasks.update_notification_status_by_id' + ) + + with pytest.raises(NotificationPermanentFailureException): + lookup_contact_info(notification.id) + + mocked_va_profile_client.get_email_with_permission.assert_called_with(mocker.ANY, notification) + recipient_identifier = mocked_va_profile_client.get_email_with_permission.call_args[0][0] + assert isinstance(recipient_identifier, RecipientIdentifier) + assert recipient_identifier.id_value == EXAMPLE_VA_PROFILE_ID + + mocked_update_notification_status_by_id.assert_called_with( + notification.id, NOTIFICATION_PERMANENT_FAILURE, status_reason=exception.failure_reason + ) + + mocked_check_and_queue_callback_task.assert_called_once_with(notification) + + +@pytest.mark.parametrize( + 'exception, throws_additional_exception, notification_status, exception_reason', + [ + ( + VAProfileRetryableException, + NotificationTechnicalFailureException, + NOTIFICATION_TECHNICAL_FAILURE, + RETRIES_EXCEEDED, + ), + ( + NoContactInfoException, + NotificationPermanentFailureException, + NOTIFICATION_PERMANENT_FAILURE, + NoContactInfoException.failure_reason, + ), + ( + VAProfileNonRetryableException, + NotificationPermanentFailureException, + NOTIFICATION_PERMANENT_FAILURE, + VAProfileNonRetryableException.failure_reason, + ), + ], +) +def test_exception_sets_failure_reason_if_thrown( + client, + mocker, + sample_template, + sample_notification, + exception, + throws_additional_exception, + notification_status, + exception_reason, +): + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_COMBINE_CONTACT_INFO_AND_PERMISSIONS_LOOKUP, 'True') + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_IDENTIFY_MOBILE_TELEPHONE_NUMBERS, 'True') + + template = sample_template(template_type=EMAIL_TYPE) + notification = sample_notification( + template=template, + recipient_identifiers=[{'id_type': IdentifierType.VA_PROFILE_ID.value, 'id_value': EXAMPLE_VA_PROFILE_ID}], + ) + mocker.patch('app.celery.contact_information_tasks.get_notification_by_id', return_value=notification) + + mocked_va_profile_client = mocker.Mock(VAProfileClient) + mocked_va_profile_client.get_email_with_permission = mocker.Mock(side_effect=exception) + mocker.patch('app.celery.contact_information_tasks.va_profile_client', new=mocked_va_profile_client) + mocker.patch('app.celery.contact_information_tasks.can_retry', return_value=False) + + mocked_check_and_queue_callback_task = mocker.patch( + 'app.celery.contact_information_tasks.check_and_queue_callback_task', + ) + + if exception_reason == RETRIES_EXCEEDED: + mocker_handle_max_retries_exceeded = mocker.patch( + 'app.celery.contact_information_tasks.handle_max_retries_exceeded' + ) + with pytest.raises(throws_additional_exception): + lookup_contact_info(notification.id) + mocker_handle_max_retries_exceeded.assert_called_once() + else: + mocked_update_notification_status_by_id = mocker.patch( + 'app.celery.contact_information_tasks.update_notification_status_by_id' + ) + if throws_additional_exception: + with pytest.raises(throws_additional_exception): + lookup_contact_info(notification.id) + else: + lookup_contact_info(notification.id) + mocked_update_notification_status_by_id.assert_called_once_with( + notification.id, notification_status, status_reason=exception_reason + ) + + mocked_check_and_queue_callback_task.assert_called_once_with(notification) diff --git a/tests/app/celery/test_lookup_recipient_communication_permissions_task.py b/tests/app/celery/test_lookup_recipient_communication_permissions_task.py index dc2cf16af3..2f0a36b584 100644 --- a/tests/app/celery/test_lookup_recipient_communication_permissions_task.py +++ b/tests/app/celery/test_lookup_recipient_communication_permissions_task.py @@ -19,6 +19,8 @@ from app.va.va_profile.exceptions import CommunicationItemNotFoundException from app.va.va_profile.va_profile_client import VAProfileClient from app.va.identifier import IdentifierType +from app.feature_flags import FeatureFlag +from tests.app.factories.feature_flag import mock_feature_flag @pytest.fixture @@ -183,6 +185,7 @@ def test_recipient_has_given_permission_should_return_none_if_user_permissions_n def test_recipient_has_given_permission_with_default_send_indicator_and_no_preference_set( client, mocker, send_indicator: bool ): + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_COMBINE_CONTACT_INFO_AND_PERMISSIONS_LOOKUP, 'True') mocked_va_profile_client = mocker.Mock(VAProfileClient) mocked_va_profile_client.get_is_communication_allowed = mocker.Mock(side_effect=CommunicationItemNotFoundException) mocker.patch( @@ -217,6 +220,7 @@ def test_recipient_has_given_permission_max_retries_exceeded(client, mocker, fak id=uuid.uuid4(), va_profile_item_id=1, name='name', default_send_indicator=send_indicator ) + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_COMBINE_CONTACT_INFO_AND_PERMISSIONS_LOOKUP, 'True') mocker.patch( 'app.celery.lookup_recipient_communication_permissions_task.get_communication_item', return_value=test_communication_item, diff --git a/tests/app/conftest.py b/tests/app/conftest.py index 3a27172ef0..8f6f13db66 100644 --- a/tests/app/conftest.py +++ b/tests/app/conftest.py @@ -1497,12 +1497,6 @@ def _sample_notification(*args, gen_type: str = SMS_TYPE, **kwargs): notify_db_session.session.commit() -@pytest.fixture -def sample_email_notification(sample_template, sample_notification): - template = sample_template(template_type=EMAIL_TYPE) - return sample_notification(template=template) - - @pytest.fixture def sample_notification_history( notify_db_session, @@ -2167,16 +2161,20 @@ def sample_communication_item(notify_db_session): created_communication_item_ids = [] va_profile_ids = set([]) - def _sample_communication_item(): - va_profile_item_id = randint(1, 10000000) + def _sample_communication_item(default_send: bool = True): + va_profile_item_id = randint(111, 10000000) # This actually hit a duplicate during testing! while va_profile_item_id in va_profile_ids: va_profile_item_id = randint(1, 10000000) - communication_item = CommunicationItem(id=uuid4(), va_profile_item_id=va_profile_item_id, name=uuid4()) + communication_item = CommunicationItem( + id=uuid4(), + va_profile_item_id=va_profile_item_id, + name=uuid4(), + default_send_indicator=default_send, + ) notify_db_session.session.add(communication_item) notify_db_session.session.commit() - assert communication_item.default_send_indicator, 'Should be True by default.' created_communication_item_ids.append(communication_item.id) va_profile_ids.add(va_profile_item_id) diff --git a/tests/app/delivery/test_send_to_providers.py b/tests/app/delivery/test_send_to_providers.py index 2081b1df3d..4b19917e0b 100644 --- a/tests/app/delivery/test_send_to_providers.py +++ b/tests/app/delivery/test_send_to_providers.py @@ -889,12 +889,12 @@ def test_send_sms_to_provider_should_format_phone_number( def test_send_email_to_provider_should_format_email_address( - sample_email_notification, + sample_notification, mock_email_client, ): - sample_email_notification.to = 'test@example.com\t' + notification = sample_notification(to_field='test@example.com\t') - send_to_providers.send_email_to_provider(sample_email_notification) + send_to_providers.send_email_to_provider(notification) _, kwargs = mock_email_client.send_email.call_args assert kwargs['to_addresses'] == 'test@example.com' diff --git a/tests/app/notifications/test_process_notifications.py b/tests/app/notifications/test_process_notifications.py index 44a2e6d20b..bedb014179 100644 --- a/tests/app/notifications/test_process_notifications.py +++ b/tests/app/notifications/test_process_notifications.py @@ -512,7 +512,7 @@ def test_send_notification_to_queue_with_recipient_identifiers( sample_communication_item, sample_template, ): - mocker.patch('app.notifications.process_notifications.is_feature_enabled', return_value=True) + mock_feature_flag(mocker, FeatureFlag.SMS_SENDER_RATE_LIMIT_ENABLED, 'True') mocked_chain = mocker.patch('app.notifications.process_notifications.chain') template = sample_template( template_type=notification_type, @@ -579,7 +579,7 @@ def test_send_notification_to_queue_throws_exception_deletes_notification( mocker, ): notification = sample_notification(api_key=sample_api_key()) - mocker.patch('app.notifications.process_notifications.is_feature_enabled', return_value=False) + mock_feature_flag(mocker, FeatureFlag.SMS_SENDER_RATE_LIMIT_ENABLED, 'False') mocked_chain = mocker.patch('app.notifications.process_notifications.chain', side_effect=Boto3Error('EXPECTED')) mocker.patch('app.notifications.process_notifications.dao_get_service_sms_sender_by_service_id_and_number') with pytest.raises(Boto3Error): @@ -983,7 +983,7 @@ def test_send_notification_to_correct_queue_to_lookup_contact_info( expected_tasks, sample_template, ): - mocker.patch('app.notifications.process_notifications.is_feature_enabled', return_value=True) + mock_feature_flag(mocker, FeatureFlag.SMS_SENDER_RATE_LIMIT_ENABLED, 'True') mocked_chain = mocker.patch('app.notifications.process_notifications.chain') template = sample_template(template_type=notification_type) diff --git a/tests/app/notifications/test_process_notifications_for_profile_v3.py b/tests/app/notifications/test_process_notifications_for_profile_v3.py new file mode 100644 index 0000000000..4e1f9f4f76 --- /dev/null +++ b/tests/app/notifications/test_process_notifications_for_profile_v3.py @@ -0,0 +1,1116 @@ +import datetime +import uuid +from collections import namedtuple + +import pytest +from boto3.exceptions import Boto3Error +from freezegun import freeze_time +from sqlalchemy import delete, select +from sqlalchemy.exc import SQLAlchemyError + +from app.celery.contact_information_tasks import lookup_contact_info +from app.celery.lookup_recipient_communication_permissions_task import lookup_recipient_communication_permissions +from app.celery.lookup_va_profile_id_task import lookup_va_profile_id +from app.celery.onsite_notification_tasks import send_va_onsite_notification_task +from app.celery.provider_tasks import deliver_email, deliver_sms +from app.feature_flags import FeatureFlag +from app.models import ( + EMAIL_TYPE, + KEY_TYPE_TEST, + LETTER_TYPE, + NOTIFICATION_CREATED, + Notification, + RecipientIdentifier, + ScheduledNotification, + SMS_TYPE, + Template, +) +from app.notifications.process_notifications import ( + create_content_for_notification, + persist_notification, + persist_scheduled_notification, + send_notification_to_queue, + send_to_queue_for_recipient_info_based_on_recipient_identifier, + simulated_recipient, +) +from app.va.identifier import IdentifierType +from app.v2.errors import BadRequestError +from notifications_utils.recipients import validate_and_format_email_address, validate_and_format_phone_number + +from tests.app.factories.feature_flag import mock_feature_flag + + +def test_create_content_for_notification_passes(notify_db_session, sample_template, mocker): + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_COMBINE_CONTACT_INFO_AND_PERMISSIONS_LOOKUP, 'True') + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_IDENTIFY_MOBILE_TELEPHONE_NUMBERS, 'True') + + template = sample_template(template_type=EMAIL_TYPE) + db_template = notify_db_session.session.get(Template, template.id) + + content = create_content_for_notification(db_template, None) + assert str(content) == template.content + + +def test_create_content_for_notification_with_placeholders_passes(notify_db_session, sample_template, mocker): + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_COMBINE_CONTACT_INFO_AND_PERMISSIONS_LOOKUP, 'True') + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_IDENTIFY_MOBILE_TELEPHONE_NUMBERS, 'True') + + template = sample_template(content='Hello ((name))') + db_template = notify_db_session.session.get(Template, template.id) + + content = create_content_for_notification(db_template, {'name': 'Bobby'}) + assert content.content == template.content + assert 'Bobby' in str(content) + + +def test_create_content_for_notification_fails_with_missing_personalisation(notify_db_session, sample_template, mocker): + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_COMBINE_CONTACT_INFO_AND_PERMISSIONS_LOOKUP, 'True') + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_IDENTIFY_MOBILE_TELEPHONE_NUMBERS, 'True') + + template = sample_template(content='Hello ((name))\n((Additional placeholder))') + db_template = notify_db_session.session.get(Template, template.id) + + with pytest.raises(BadRequestError): + create_content_for_notification(db_template, None) + + +def test_create_content_for_notification_allows_additional_personalisation(notify_db_session, sample_template, mocker): + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_COMBINE_CONTACT_INFO_AND_PERMISSIONS_LOOKUP, 'True') + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_IDENTIFY_MOBILE_TELEPHONE_NUMBERS, 'True') + + template = sample_template(content='Hello ((name))\n((Additional placeholder))') + db_template = notify_db_session.session.get(Template, template.id) + + create_content_for_notification(db_template, {'name': 'Bobby', 'Additional placeholder': 'Data'}) + + +@pytest.mark.serial +@freeze_time('2016-01-01 11:09:00.061258') +def test_persist_notification_creates_and_save_to_db( + notify_db_session, + sample_api_key, + sample_template, + mocker, +): + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_COMBINE_CONTACT_INFO_AND_PERMISSIONS_LOOKUP, 'True') + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_IDENTIFY_MOBILE_TELEPHONE_NUMBERS, 'True') + + mocked_redis = mocker.patch('app.notifications.process_notifications.redis_store.get') + + template = sample_template() + api_key = sample_api_key(template.service) + + data = { + 'template_id': template.id, + 'notification_id': uuid.uuid4(), + 'created_at': datetime.datetime.utcnow(), + 'reference': str(uuid.uuid4()), + 'billing_code': str(uuid.uuid4()), + 'recipient': '+16502532222', + 'notification_type': SMS_TYPE, + 'api_key_id': api_key.id, + 'key_type': api_key.key_type, + 'reply_to_text': template.service.get_default_sms_sender(), + 'service_id': template.service.id, + 'template_version': template.version, + 'personalisation': {}, + } + + # Intermittently makes the status 'technical-failure' + # Cleaned by the template cleanup + persist_notification(**data) + + db_notification = notify_db_session.session.get(Notification, data['notification_id']) + + assert db_notification.id == data['notification_id'] + assert db_notification.template_id == data['template_id'] + assert db_notification.template_version == data['template_version'] + assert db_notification.api_key_id == data['api_key_id'] + assert db_notification.key_type == data['key_type'] + assert db_notification.notification_type == data['notification_type'] + assert db_notification.created_at == data['created_at'] + assert db_notification.reference == data['reference'] + assert db_notification.reply_to_text == data['reply_to_text'] + assert db_notification.billing_code == data['billing_code'] + assert db_notification.status == NOTIFICATION_CREATED + assert db_notification.billable_units == 0 + assert db_notification.updated_at is None + assert db_notification.created_by_id is None + assert db_notification.client_reference is None + assert not db_notification.sent_at + + mocked_redis.assert_called_once_with(str(template.service_id) + '-2016-01-01-count') + + +def test_persist_notification_throws_exception_when_missing_template(sample_api_key, mocker): + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_COMBINE_CONTACT_INFO_AND_PERMISSIONS_LOOKUP, 'True') + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_IDENTIFY_MOBILE_TELEPHONE_NUMBERS, 'True') + + api_key = sample_api_key() + notification = None + + with pytest.raises(SQLAlchemyError): + notification = persist_notification( + template_id=None, + template_version=None, + recipient='+16502532222', + service_id=api_key.service.id, + personalisation=None, + notification_type=SMS_TYPE, + api_key_id=api_key.id, + key_type=api_key.key_type, + ) + + assert notification is None + + +def test_cache_is_not_incremented_on_failure_to_persist_notification( + sample_api_key, + mocker, +): + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_COMBINE_CONTACT_INFO_AND_PERMISSIONS_LOOKUP, 'True') + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_IDENTIFY_MOBILE_TELEPHONE_NUMBERS, 'True') + + api_key = sample_api_key() + mocked_redis = mocker.patch('app.redis_store.get') + mock_service_template_cache = mocker.patch('app.redis_store.get_all_from_hash') + with pytest.raises(SQLAlchemyError): + persist_notification( + template_id=None, + template_version=None, + recipient='+16502532222', + service_id=api_key.service.id, + personalisation=None, + notification_type=SMS_TYPE, + api_key_id=api_key.id, + key_type=api_key.key_type, + ) + mocked_redis.assert_not_called() + mock_service_template_cache.assert_not_called() + + +def test_persist_notification_does_not_increment_cache_if_test_key( + notify_db_session, + sample_api_key, + sample_template, + mocker, +): + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_COMBINE_CONTACT_INFO_AND_PERMISSIONS_LOOKUP, 'True') + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_IDENTIFY_MOBILE_TELEPHONE_NUMBERS, 'True') + + template = sample_template() + api_key = sample_api_key(service=template.service, key_type=KEY_TYPE_TEST) + + mocker.patch('app.notifications.process_notifications.redis_store.get', return_value='cache') + mocker.patch('app.notifications.process_notifications.redis_store.get_all_from_hash', return_value='cache') + daily_limit_cache = mocker.patch('app.notifications.process_notifications.redis_store.incr') + template_usage_cache = mocker.patch('app.notifications.process_notifications.redis_store.increment_hash_value') + + notification_id = uuid.uuid4() + + # Cleaned by the template cleanup + persist_notification( + template_id=template.id, + template_version=template.version, + recipient='+16502532222', + service_id=template.service.id, + personalisation={}, + notification_type=SMS_TYPE, + api_key_id=api_key.id, + key_type=api_key.key_type, + reference=str(uuid.uuid4()), + notification_id=notification_id, + ) + + assert notify_db_session.session.get(Notification, notification_id) + assert not daily_limit_cache.called + assert not template_usage_cache.called + + +@freeze_time('2016-01-01 11:09:00.061258') +def test_persist_notification_with_optionals( + notify_db_session, + sample_api_key, + sample_template, + mocker, +): + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_COMBINE_CONTACT_INFO_AND_PERMISSIONS_LOOKUP, 'True') + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_IDENTIFY_MOBILE_TELEPHONE_NUMBERS, 'True') + + api_key = sample_api_key() + template = sample_template(service=api_key.service) + service = api_key.service + mocked_redis = mocker.patch('app.notifications.process_notifications.redis_store.get') + notification_id = uuid.uuid4() + created_at = datetime.datetime(2016, 11, 11, 16, 8, 18) + + # Cleaned by the template cleanup + persist_notification( + template_id=template.id, + template_version=template.version, + recipient='+16502532222', + service_id=service.id, + personalisation=None, + notification_type=SMS_TYPE, + api_key_id=api_key.id, + key_type=api_key.key_type, + created_at=created_at, + client_reference='ref from client', + notification_id=notification_id, + created_by_id=api_key.created_by_id, + ) + + persisted_notification = notify_db_session.session.get(Notification, notification_id) + + assert persisted_notification.id == notification_id + assert persisted_notification.created_at == created_at + mocked_redis.assert_called_once_with(str(service.id) + '-2016-01-01-count') + assert persisted_notification.client_reference == 'ref from client' + assert persisted_notification.reference is None + assert persisted_notification.international is False + assert persisted_notification.phone_prefix == '1' + assert persisted_notification.rate_multiplier == 1 + assert persisted_notification.created_by_id == api_key.created_by_id + assert not persisted_notification.reply_to_text + + +@freeze_time('2016-01-01 11:09:00.061258') +def test_persist_notification_doesnt_touch_cache_for_old_keys_that_dont_exist( + sample_api_key, + sample_template, + mocker, +): + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_COMBINE_CONTACT_INFO_AND_PERMISSIONS_LOOKUP, 'True') + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_IDENTIFY_MOBILE_TELEPHONE_NUMBERS, 'True') + + api_key = sample_api_key() + template = sample_template(service=api_key.service) + mock_incr = mocker.patch('app.notifications.process_notifications.redis_store.incr') + mocker.patch('app.notifications.process_notifications.redis_store.get', return_value=None) + mocker.patch('app.notifications.process_notifications.redis_store.get_all_from_hash', return_value=None) + + # Cleaned by the template cleanup + persist_notification( + template_id=template.id, + template_version=template.version, + recipient='+16502532222', + service_id=api_key.service.id, + personalisation={}, + notification_type=SMS_TYPE, + api_key_id=api_key.id, + key_type=api_key.key_type, + reference='ref', + ) + + mock_incr.assert_not_called() + + +@freeze_time('2016-01-01 11:09:00.061258') +def test_persist_notification_increments_cache_if_key_exists( + sample_api_key, + sample_template, + mocker, +): + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_COMBINE_CONTACT_INFO_AND_PERMISSIONS_LOOKUP, 'True') + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_IDENTIFY_MOBILE_TELEPHONE_NUMBERS, 'True') + + api_key = sample_api_key() + template = sample_template(service=api_key.service) + service = template.service + mock_incr = mocker.patch('app.notifications.process_notifications.redis_store.incr') + mocker.patch('app.notifications.process_notifications.redis_store.get', return_value=1) + mocker.patch('app.notifications.process_notifications.redis_store.get_all_from_hash', return_value={template.id, 1}) + + # Cleaned by the template cleanup + persist_notification( + template_id=template.id, + template_version=template.version, + recipient='+16502532222', + service_id=service.id, + personalisation={}, + notification_type=SMS_TYPE, + api_key_id=api_key.id, + key_type=api_key.key_type, + reference='ref2', + ) + + mock_incr.assert_called_once_with(str(service.id) + '-2016-01-01-count') + + +@pytest.mark.parametrize( + 'research_mode, requested_queue, notification_type, key_type, expected_queue, expected_tasks', + [ + (True, None, SMS_TYPE, 'normal', 'research-mode-tasks', [deliver_sms]), + (True, None, EMAIL_TYPE, 'normal', 'research-mode-tasks', [deliver_email]), + (True, None, EMAIL_TYPE, 'team', 'research-mode-tasks', [deliver_email]), + (False, None, SMS_TYPE, 'normal', 'send-sms-tasks', [deliver_sms]), + (False, None, EMAIL_TYPE, 'normal', 'send-email-tasks', [deliver_email]), + (False, None, SMS_TYPE, 'team', 'send-sms-tasks', [deliver_sms]), + (False, None, SMS_TYPE, 'test', 'research-mode-tasks', [deliver_sms]), + (True, 'notify-internal-tasks', EMAIL_TYPE, 'normal', 'research-mode-tasks', [deliver_email]), + (False, 'notify-internal-tasks', SMS_TYPE, 'normal', 'notify-internal-tasks', [deliver_sms]), + (False, 'notify-internal-tasks', EMAIL_TYPE, 'normal', 'notify-internal-tasks', [deliver_email]), + (False, 'notify-internal-tasks', SMS_TYPE, 'test', 'research-mode-tasks', [deliver_sms]), + ], +) +def test_send_notification_to_queue_with_no_recipient_identifiers( + research_mode, + requested_queue, + notification_type, + key_type, + expected_queue, + expected_tasks, + mocker, + sample_template, +): + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_COMBINE_CONTACT_INFO_AND_PERMISSIONS_LOOKUP, 'True') + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_IDENTIFY_MOBILE_TELEPHONE_NUMBERS, 'True') + + mocked_chain = mocker.patch('app.notifications.process_notifications.chain') + template = sample_template(template_type=notification_type) + MockService = namedtuple('Service', ['id']) + service = MockService(id=uuid.uuid4()) + + MockSmsSender = namedtuple('ServiceSmsSender', ['service_id', 'sms_sender', 'rate_limit']) + sms_sender = MockSmsSender(service_id=service.id, sms_sender='+18888888888', rate_limit=1) + + NotificationTuple = namedtuple( + 'Notification', ['id', 'key_type', 'notification_type', 'created_at', 'template', 'service_id', 'reply_to_text'] + ) + + mocker.patch( + 'app.notifications.process_notifications.dao_get_service_sms_sender_by_service_id_and_number', return_value=None + ) + + MockSmsSender = namedtuple('ServiceSmsSender', ['service_id', 'sms_sender', 'rate_limit']) + sms_sender = MockSmsSender(service_id=service.id, sms_sender='+18888888888', rate_limit=None) + + notification = NotificationTuple( + id=uuid.uuid4(), + key_type=key_type, + notification_type=notification_type, + created_at=datetime.datetime(2016, 11, 11, 16, 8, 18), + template=template, + service_id=service.id, + reply_to_text=sms_sender.sms_sender, + ) + + send_notification_to_queue(notification=notification, research_mode=research_mode, queue=requested_queue) + + args, _ = mocked_chain.call_args + for called_task, expected_task in zip(args, expected_tasks): + assert called_task.name == expected_task.name + called_task_notification_arg = args[0].args[0] + assert called_task_notification_arg == str(notification.id) + + +@pytest.mark.parametrize( + 'research_mode, requested_queue, notification_type, key_type, expected_queue, ' + 'request_recipient_id_type, request_recipient_id_value, expected_tasks', + [ + ( + True, + None, + SMS_TYPE, + 'normal', + 'research-mode-tasks', + IdentifierType.VA_PROFILE_ID.value, + 'some va profile id', + [lookup_recipient_communication_permissions, deliver_sms], + ), + ( + True, + None, + EMAIL_TYPE, + 'normal', + 'research-mode-tasks', + IdentifierType.PID.value, + 'some pid', + [lookup_va_profile_id, lookup_recipient_communication_permissions, deliver_email], + ), + ( + True, + None, + EMAIL_TYPE, + 'team', + 'research-mode-tasks', + IdentifierType.ICN.value, + 'some icn', + [lookup_va_profile_id, lookup_recipient_communication_permissions, deliver_email], + ), + ( + True, + 'notify-internal-tasks', + EMAIL_TYPE, + 'normal', + 'research-mode-tasks', + IdentifierType.VA_PROFILE_ID.value, + 'some va profile id', + [lookup_recipient_communication_permissions, deliver_email], + ), + ( + False, + None, + SMS_TYPE, + 'normal', + 'send-sms-tasks', + IdentifierType.PID.value, + 'some pid', + [lookup_va_profile_id, lookup_recipient_communication_permissions, deliver_sms], + ), + ( + False, + None, + EMAIL_TYPE, + 'normal', + 'send-email-tasks', + IdentifierType.ICN.value, + 'some icn', + [lookup_va_profile_id, lookup_recipient_communication_permissions, deliver_email], + ), + ( + False, + None, + SMS_TYPE, + 'team', + 'send-sms-tasks', + IdentifierType.VA_PROFILE_ID.value, + 'some va profile id', + [lookup_recipient_communication_permissions, deliver_sms], + ), + ( + False, + None, + SMS_TYPE, + 'test', + 'research-mode-tasks', + IdentifierType.PID.value, + 'some pid', + [lookup_va_profile_id, lookup_recipient_communication_permissions, deliver_sms], + ), + ( + False, + 'notify-internal-tasks', + SMS_TYPE, + 'normal', + 'notify-internal-tasks', + IdentifierType.ICN.value, + 'some icn', + [lookup_va_profile_id, lookup_recipient_communication_permissions, deliver_sms], + ), + ( + False, + 'notify-internal-tasks', + EMAIL_TYPE, + 'normal', + 'notify-internal-tasks', + IdentifierType.VA_PROFILE_ID.value, + 'some va profile id', + [lookup_recipient_communication_permissions, deliver_email], + ), + ( + False, + 'notify-internal-tasks', + SMS_TYPE, + 'test', + 'research-mode-tasks', + IdentifierType.PID.value, + 'some pid', + [lookup_va_profile_id, lookup_recipient_communication_permissions, deliver_sms], + ), + ], +) +def test_send_notification_to_queue_with_recipient_identifiers( + research_mode, + requested_queue, + notification_type, + key_type, + expected_queue, + request_recipient_id_type, + request_recipient_id_value, + expected_tasks, + mocker, + sample_communication_item, + sample_template, +): + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_COMBINE_CONTACT_INFO_AND_PERMISSIONS_LOOKUP, 'True') + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_IDENTIFY_MOBILE_TELEPHONE_NUMBERS, 'True') + + mock_feature_flag(mocker, FeatureFlag.SMS_SENDER_RATE_LIMIT_ENABLED, 'True') + mocked_chain = mocker.patch('app.notifications.process_notifications.chain') + template = sample_template( + template_type=notification_type, + content='Hello (( Name))\nHere is some HTML & entities' if notification_type == SMS_TYPE else None, + ) + template.service.prefix_sms = notification_type == SMS_TYPE # True only for SMS_TYPE + + MockService = namedtuple('Service', ['id']) + service = MockService(id=uuid.uuid4()) + MockSmsSender = namedtuple('ServiceSmsSender', ['service_id', 'sms_sender', 'rate_limit']) + sms_sender = MockSmsSender(service_id=service.id, sms_sender='+18888888888', rate_limit=None) + + mocker.patch( + 'app.notifications.process_notifications.dao_get_service_sms_sender_by_service_id_and_number', + return_value=sms_sender, + ) + + TestNotification = namedtuple( + 'Notification', + [ + 'id', + 'key_type', + 'notification_type', + 'created_at', + 'template', + 'recipient_identifiers', + 'service_id', + 'reply_to_text', + 'sms_sender', + ], + ) + notification_id = uuid.uuid4() + notification = TestNotification( + id=notification_id, + key_type=key_type, + notification_type=notification_type, + created_at=datetime.datetime(2016, 11, 11, 16, 8, 18), + template=template, + recipient_identifiers={ + f'{request_recipient_id_type}': RecipientIdentifier( + notification_id=notification_id, id_type=request_recipient_id_type, id_value=request_recipient_id_value + ) + }, + service_id=service.id, + reply_to_text=sms_sender.sms_sender, + sms_sender=sms_sender, + ) + + send_notification_to_queue( + notification=notification, + research_mode=research_mode, + queue=requested_queue, + recipient_id_type=request_recipient_id_type, + ) + + args, _ = mocked_chain.call_args + for called_task, expected_task in zip(args, expected_tasks): + assert called_task.name == expected_task.name + + +def test_send_notification_to_queue_throws_exception_deletes_notification( + sample_api_key, + sample_notification, + mocker, +): + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_COMBINE_CONTACT_INFO_AND_PERMISSIONS_LOOKUP, 'True') + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_IDENTIFY_MOBILE_TELEPHONE_NUMBERS, 'True') + + notification = sample_notification(api_key=sample_api_key()) + mock_feature_flag(mocker, FeatureFlag.SMS_SENDER_RATE_LIMIT_ENABLED, 'False') + mocked_chain = mocker.patch('app.notifications.process_notifications.chain', side_effect=Boto3Error('EXPECTED')) + mocker.patch('app.notifications.process_notifications.dao_get_service_sms_sender_by_service_id_and_number') + with pytest.raises(Boto3Error): + send_notification_to_queue(notification, False) + + args, _ = mocked_chain.call_args + for called_task, expected_task in zip(args, ['send-sms-tasks']): + assert called_task.args[0] == str(notification.id) + assert called_task.options['queue'] == expected_task + + +@pytest.mark.parametrize( + 'to_address, notification_type, expected', + [ + ('+16132532222', 'sms', True), + ('+16132532223', 'sms', True), + ('6132532222', 'sms', True), + ('simulate-delivered@notifications.va.gov', 'email', True), + ('simulate-delivered-2@notifications.va.gov', 'email', True), + ('simulate-delivered-3@notifications.va.gov', 'email', True), + ('6132532225', 'sms', False), + ('valid_email@test.com', 'email', False), + ], +) +def test_simulated_recipient(to_address, notification_type, expected, mocker): + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_COMBINE_CONTACT_INFO_AND_PERMISSIONS_LOOKUP, 'True') + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_IDENTIFY_MOBILE_TELEPHONE_NUMBERS, 'True') + + """ + The values where the expected = 'research-mode' are listed in the config['SIMULATED_EMAIL_ADDRESSES'] + and config['SIMULATED_SMS_NUMBERS']. These values should result in using the research mode queue. + SIMULATED_EMAIL_ADDRESSES = ( + 'simulate-delivered@notifications.va.gov', + 'simulate-delivered-2@notifications.va.gov', + 'simulate-delivered-2@notifications.va.gov' + ) + SIMULATED_SMS_NUMBERS = ('6132532222', '+16132532222', '+16132532223') + """ + formatted_address = None + + if notification_type == EMAIL_TYPE: + formatted_address = validate_and_format_email_address(to_address) + else: + formatted_address = validate_and_format_phone_number(to_address) + + is_simulated_address = simulated_recipient(formatted_address, notification_type) + + assert is_simulated_address == expected + + +@pytest.mark.parametrize( + 'recipient, expected_international, expected_prefix, expected_units', + [ + ('6502532222', False, '1', 1), # NA + ('+16502532222', False, '1', 1), # NA + ('+79587714230', True, '7', 1), # Russia + ('+360623400400', True, '36', 3), # Hungary + ], +) +def test_persist_notification_with_international_info_stores_correct_info( + notify_db_session, + sample_api_key, + sample_template, + mocker, + recipient, + expected_international, + expected_prefix, + expected_units, +): + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_COMBINE_CONTACT_INFO_AND_PERMISSIONS_LOOKUP, 'True') + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_IDENTIFY_MOBILE_TELEPHONE_NUMBERS, 'True') + + template = sample_template() + api_key = sample_api_key(service=template.service) + + notification_id = uuid.uuid4() + + # Cleaned by the template cleanup + persist_notification( + template_id=template.id, + template_version=template.version, + recipient=recipient, + service_id=template.service.id, + personalisation=None, + notification_type=SMS_TYPE, + api_key_id=api_key.id, + key_type=api_key.key_type, + client_reference='ref from client', + notification_id=notification_id, + ) + + persisted_notification = notify_db_session.session.get(Notification, notification_id) + + assert persisted_notification.international is expected_international + assert persisted_notification.phone_prefix == expected_prefix + assert persisted_notification.rate_multiplier == expected_units + + +def test_persist_notification_with_international_info_does_not_store_for_email( + notify_db_session, + sample_api_key, + sample_template, + mocker, +): + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_COMBINE_CONTACT_INFO_AND_PERMISSIONS_LOOKUP, 'True') + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_IDENTIFY_MOBILE_TELEPHONE_NUMBERS, 'True') + + template = sample_template() + api_key = sample_api_key(service=template.service) + + notification_id = uuid.uuid4() + + # Cleaned by the template cleanup + persist_notification( + template_id=template.id, + template_version=template.version, + recipient='foo@bar.com', + service_id=api_key.service.id, + personalisation=None, + notification_type=EMAIL_TYPE, + api_key_id=api_key.id, + key_type=api_key.key_type, + client_reference='ref from client', + notification_id=notification_id, + ) + + persisted_notification = notify_db_session.session.get(Notification, notification_id) + + assert persisted_notification.international is False + assert persisted_notification.phone_prefix is None + assert persisted_notification.rate_multiplier is None + + +# This test assumes the local timezone is EST +def test_persist_scheduled_notification(notify_db_session, sample_api_key, sample_notification, mocker): + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_COMBINE_CONTACT_INFO_AND_PERMISSIONS_LOOKUP, 'True') + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_IDENTIFY_MOBILE_TELEPHONE_NUMBERS, 'True') + + api_key = sample_api_key() + notification = sample_notification(api_key=api_key) + + # Cleaned by the template cleanup + persist_scheduled_notification(notification.id, '2017-05-12 14:15') + stmt = select(ScheduledNotification).where(ScheduledNotification.notification_id == notification.id) + scheduled_notification = notify_db_session.session.scalar(stmt) + + assert scheduled_notification.notification_id == notification.id + assert scheduled_notification.scheduled_for == datetime.datetime(2017, 5, 12, 18, 15) + + +@pytest.mark.parametrize( + 'recipient, expected_recipient_normalised', + [ + ('6502532222', '+16502532222'), + (' 6502532223', '+16502532223'), + ('6502532223', '+16502532223'), + ], +) +def test_persist_sms_notification_stores_normalised_number( + notify_db_session, + sample_api_key, + sample_template, + mocker, + recipient, + expected_recipient_normalised, +): + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_COMBINE_CONTACT_INFO_AND_PERMISSIONS_LOOKUP, 'True') + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_IDENTIFY_MOBILE_TELEPHONE_NUMBERS, 'True') + + template = sample_template() + api_key = sample_api_key(service=template.service) + + notification_id = uuid.uuid4() + + # Cleaned by the template cleanup + persist_notification( + template_id=template.id, + template_version=template.version, + recipient=recipient, + service_id=api_key.service.id, + personalisation=None, + notification_type=SMS_TYPE, + api_key_id=api_key.id, + key_type=api_key.key_type, + notification_id=notification_id, + ) + + persisted_notification = notify_db_session.session.get(Notification, notification_id) + + assert persisted_notification.to == recipient + assert persisted_notification.normalised_to == expected_recipient_normalised + + +@pytest.mark.parametrize( + 'recipient, expected_recipient_normalised', [('FOO@bar.com', 'foo@bar.com'), ('BAR@foo.com', 'bar@foo.com')] +) +def test_persist_email_notification_stores_normalised_email( + notify_db_session, + sample_api_key, + sample_template, + mocker, + recipient, + expected_recipient_normalised, +): + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_COMBINE_CONTACT_INFO_AND_PERMISSIONS_LOOKUP, 'True') + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_IDENTIFY_MOBILE_TELEPHONE_NUMBERS, 'True') + + template = sample_template() + api_key = sample_api_key(service=template.service) + + notification_id = uuid.uuid4() + + # Cleaned by the template cleanup + persist_notification( + template_id=template.id, + template_version=template.version, + recipient=recipient, + service_id=api_key.service.id, + personalisation=None, + notification_type=EMAIL_TYPE, + api_key_id=api_key.id, + key_type=api_key.key_type, + notification_id=notification_id, + ) + persisted_notification = notify_db_session.session.get(Notification, notification_id) + + assert persisted_notification.to == recipient + assert persisted_notification.normalised_to == expected_recipient_normalised + + +@pytest.mark.skip(reason='Mislabelled for route removal, fails when unskipped') +def test_persist_notification_with_billable_units_stores_correct_info( + notify_db_session, + mocker, + sample_service, + sample_template, +): + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_COMBINE_CONTACT_INFO_AND_PERMISSIONS_LOOKUP, 'True') + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_IDENTIFY_MOBILE_TELEPHONE_NUMBERS, 'True') + + service = sample_service(service_permissions=[LETTER_TYPE]) + template = sample_template(service=service, template_type=LETTER_TYPE) + mocker.patch('app.dao.templates_dao.dao_get_template_by_id', return_value=template) + + # Cleaned by the template cleanup + persist_notification( + template_id=template.id, + template_version=template.version, + recipient='123 Main Street', + service_id=template.service.id, + personalisation=None, + notification_type=template.template_type, + api_key_id=None, + key_type='normal', + billable_units=3, + template_postage=template.postage, + ) + + stmt = select(Notification) + persisted_notification = notify_db_session.session.scalars(stmt).all()[0] + + assert persisted_notification.billable_units == 3 + + +@pytest.mark.parametrize( + 'notification_type', + [ + EMAIL_TYPE, + SMS_TYPE, + ], +) +@pytest.mark.parametrize( + 'id_type, id_value', + [ + (IdentifierType.VA_PROFILE_ID.value, 'some va profile id'), + (IdentifierType.PID.value, 'some pid'), + (IdentifierType.ICN.value, 'some icn'), + ], +) +def test_persist_notification_persists_recipient_identifiers( + notify_db_session, + notification_type, + id_type, + id_value, + sample_api_key, + sample_template, + mocker, +): + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_COMBINE_CONTACT_INFO_AND_PERMISSIONS_LOOKUP, 'True') + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_IDENTIFY_MOBILE_TELEPHONE_NUMBERS, 'True') + + mocker.patch('app.notifications.process_notifications.accept_recipient_identifiers_enabled', return_value=True) + template = sample_template(template_type=notification_type) + api_key = sample_api_key() + recipient_identifier = {'id_type': id_type, 'id_value': id_value} + + notification_id = uuid.uuid4() + # Cleaned by the template cleanup + persist_notification( + template_id=template.id, + template_version=template.version, + service_id=api_key.service.id, + personalisation=None, + notification_type=notification_type, + api_key_id=api_key.id, + key_type=api_key.key_type, + recipient_identifier=recipient_identifier, + notification_id=notification_id, + ) + + recipient_identifier = notify_db_session.session.get(RecipientIdentifier, (notification_id, id_type, id_value)) + + try: + # Persisted correctly + assert recipient_identifier.notification_id == notification_id + assert recipient_identifier.id_type == id_type + assert recipient_identifier.id_value == id_value + finally: + # Teardown + stmt = delete(RecipientIdentifier).where(RecipientIdentifier.notification_id == notification_id) + notify_db_session.session.execute(stmt) + notify_db_session.session.commit() + + +@pytest.mark.parametrize( + 'recipient_identifiers_enabled, recipient_identifier', + [(True, None), (False, {'id_type': IdentifierType.VA_PROFILE_ID.value, 'id_value': 'foo'}), (False, None)], +) +def test_persist_notification_should_not_persist_recipient_identifier_if_none_present_or_toggle_off( + notify_db_session, + recipient_identifiers_enabled, + recipient_identifier, + sample_api_key, + sample_template, + mocker, +): + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_COMBINE_CONTACT_INFO_AND_PERMISSIONS_LOOKUP, 'True') + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_IDENTIFY_MOBILE_TELEPHONE_NUMBERS, 'True') + + mocker.patch( + 'app.notifications.process_notifications.accept_recipient_identifiers_enabled', + return_value=recipient_identifiers_enabled, + ) + + template = sample_template() + api_key = sample_api_key(template.service) + + # Cleaned by the template cleanup + notification = persist_notification( + template_id=template.id, + template_version=template.version, + service_id=api_key.service.id, + personalisation=None, + notification_type=EMAIL_TYPE, + api_key_id=api_key.id, + key_type=api_key.key_type, + recipient_identifier=recipient_identifier, + ) + + # Persisted correctly + assert notification.recipient_identifiers == {} + + # DB stored correctly + stmt = select(RecipientIdentifier).where(RecipientIdentifier.notification_id == notification.id) + assert notify_db_session.session.scalar(stmt) is None + + +@pytest.mark.parametrize( + 'id_type, notification_type, expected_tasks', + [ + ( + IdentifierType.VA_PROFILE_ID.value, + EMAIL_TYPE, + [ + send_va_onsite_notification_task, + lookup_contact_info, + deliver_email, + ], + ), + ( + IdentifierType.VA_PROFILE_ID.value, + SMS_TYPE, + [ + send_va_onsite_notification_task, + lookup_contact_info, + deliver_sms, + ], + ), + ( + IdentifierType.ICN.value, + EMAIL_TYPE, + [ + lookup_va_profile_id, + send_va_onsite_notification_task, + lookup_contact_info, + deliver_email, + ], + ), + ( + IdentifierType.ICN.value, + SMS_TYPE, + [ + lookup_va_profile_id, + send_va_onsite_notification_task, + lookup_contact_info, + deliver_sms, + ], + ), + ], +) +def test_send_notification_to_correct_queue_to_lookup_contact_info( + client, + mocker, + notification_type, + id_type, + expected_tasks, + sample_template, +): + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_COMBINE_CONTACT_INFO_AND_PERMISSIONS_LOOKUP, 'True') + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_IDENTIFY_MOBILE_TELEPHONE_NUMBERS, 'True') + + mock_feature_flag(mocker, FeatureFlag.SMS_SENDER_RATE_LIMIT_ENABLED, 'True') + mocked_chain = mocker.patch('app.notifications.process_notifications.chain') + + template = sample_template(template_type=notification_type) + notification_id = str(uuid.uuid4()) + notification = Notification(id=notification_id, notification_type=notification_type, template=template) + mock_template_id = uuid.uuid4() + + send_to_queue_for_recipient_info_based_on_recipient_identifier( + notification, id_type, 'some_id_value', mock_template_id + ) + + args, _ = mocked_chain.call_args + for called_task, expected_task in zip(args, expected_tasks): + assert called_task.name == expected_task.name + + +def test_send_notification_with_sms_sender_rate_limit_uses_rate_limit_delivery_task(client, mocker): + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_COMBINE_CONTACT_INFO_AND_PERMISSIONS_LOOKUP, 'True') + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_IDENTIFY_MOBILE_TELEPHONE_NUMBERS, 'True') + + mock_feature_flag(mocker, FeatureFlag.SMS_SENDER_RATE_LIMIT_ENABLED, 'True') + mocked_chain = mocker.patch('app.notifications.process_notifications.chain') + + MockService = namedtuple('Service', ['id']) + service = MockService(id='some service id') + + MockSmsSender = namedtuple('ServiceSmsSender', ['service_id', 'sms_sender', 'rate_limit']) + sms_sender = MockSmsSender(service_id=service.id, sms_sender='+18888888888', rate_limit=2) + + MockTemplate = namedtuple('MockTemplate', ['communication_item_id']) + template = MockTemplate(communication_item_id=1) + + mocker.patch( + 'app.notifications.process_notifications.dao_get_service_sms_sender_by_service_id_and_number', + return_value=sms_sender, + ) + + notification = Notification( + id=str(uuid.uuid4()), + notification_type=SMS_TYPE, + reply_to_text=sms_sender.sms_sender, + service_id=service.id, + template=template, + ) + + send_notification_to_queue(notification, False) + + assert mocked_chain.call_args[0][0].task == 'deliver_sms_with_rate_limiting' + + +def test_send_notification_without_sms_sender_rate_limit_uses_regular_delivery_task(client, mocker): + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_COMBINE_CONTACT_INFO_AND_PERMISSIONS_LOOKUP, 'True') + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_IDENTIFY_MOBILE_TELEPHONE_NUMBERS, 'True') + + mocked_chain = mocker.patch('app.notifications.process_notifications.chain') + deliver_sms_with_rate_limiting = mocker.patch( + 'app.celery.provider_tasks.deliver_sms_with_rate_limiting.apply_async' + ) + + MockService = namedtuple('Service', ['id']) + service = MockService(id='some service id') + + MockTemplate = namedtuple('MockTemplate', ['communication_item_id']) + template = MockTemplate(communication_item_id=1) + + MockSmsSender = namedtuple('ServiceSmsSender', ['service_id', 'sms_sender', 'rate_limit']) + sms_sender = MockSmsSender(service_id=service.id, sms_sender='+18888888888', rate_limit=None) + + mocker.patch( + 'app.notifications.process_notifications.dao_get_service_sms_sender_by_service_id_and_number', + return_value=sms_sender, + ) + + notification = Notification( + id=str(uuid.uuid4()), + notification_type=SMS_TYPE, + reply_to_text=sms_sender.sms_sender, + service_id=service.id, + template=template, + ) + + send_notification_to_queue(notification, False) + + assert mocked_chain.call_args[0][0].task == 'deliver_sms' + deliver_sms_with_rate_limiting.assert_not_called() diff --git a/tests/app/notifications/test_receive_notification.py b/tests/app/notifications/test_receive_notification.py index 57da6d9e61..ff8ae79044 100644 --- a/tests/app/notifications/test_receive_notification.py +++ b/tests/app/notifications/test_receive_notification.py @@ -101,7 +101,7 @@ def test_create_inbound_mmg_sms_object( service = sample_service() data = { 'Message': 'hello+there+%F0%9F%93%A9', - 'Number': '+15551234567', + 'Number': '+15551234566', 'MSISDN': '447700900001', 'DateReceived': '2017-01-02+03%3A04%3A05', 'ID': 'bar', @@ -117,7 +117,7 @@ def test_create_inbound_mmg_sms_object( ) assert inbound_sms.service_id == service.id - assert inbound_sms.notify_number == '+15551234567' + assert inbound_sms.notify_number == '+15551234566' assert inbound_sms.user_number == '447700900001' assert inbound_sms.provider_date == datetime(2017, 1, 2, 8, 4, 5) assert inbound_sms.provider_reference == 'bar' diff --git a/tests/app/va/va_profile/test_va_profile_client_for_profile_v3.py b/tests/app/va/va_profile/test_va_profile_client_for_profile_v3.py new file mode 100644 index 0000000000..ecb33c559d --- /dev/null +++ b/tests/app/va/va_profile/test_va_profile_client_for_profile_v3.py @@ -0,0 +1,480 @@ +import json +from urllib import parse +from unittest.mock import PropertyMock + +import pytest +import requests +import requests_mock + +from app.feature_flags import FeatureFlag +from app.models import EMAIL_TYPE, SMS_TYPE, RecipientIdentifier +from app.va.identifier import IdentifierType, OIDS, transform_to_fhir_format +from app.va.va_profile import VAProfileClient +from app.va.va_profile.exceptions import ( + NoContactInfoException, + VAProfileIDNotFoundException, + VAProfileNonRetryableException, + VAProfileRetryableException, +) +from app.va.va_profile.va_profile_client import CommunicationChannel + +from tests.app.factories.feature_flag import mock_feature_flag + +MOCK_VA_PROFILE_URL = 'http://mock.vaprofile.va.gov' + + +@pytest.fixture(scope='function') +def mock_va_profile_client(mocker, notify_api): + with notify_api.app_context(): + mock_logger = mocker.Mock() + mock_ssl_key_path = 'some_key.pem' + mock_ssl_cert_path = 'some_cert.pem' + mock_statsd_client = mocker.Mock() + mock_va_profile_token = mocker.Mock() + + client = VAProfileClient() + client.init_app( + logger=mock_logger, + va_profile_url=MOCK_VA_PROFILE_URL, + ssl_cert_path=mock_ssl_cert_path, + ssl_key_path=mock_ssl_key_path, + va_profile_token=mock_va_profile_token, + statsd_client=mock_statsd_client, + ) + + return client + + +@pytest.fixture(scope='function') +def mock_response(): + with open('tests/app/va/va_profile/mock_response.json', 'r') as f: + return json.load(f) + + +@pytest.fixture(scope='module') +def recipient_identifier(): + return RecipientIdentifier(notification_id='123456', id_type=IdentifierType.VA_PROFILE_ID, id_value='1234') + + +@pytest.fixture(scope='module') +def id_with_aaid(recipient_identifier): + return transform_to_fhir_format(recipient_identifier) + + +@pytest.fixture(scope='module') +def oid(recipient_identifier): + return OIDS.get(recipient_identifier.id_type) + + +@pytest.fixture(scope='module') +def url(oid, id_with_aaid): + return f'{MOCK_VA_PROFILE_URL}/profile-service/profile/v3/{oid}/{id_with_aaid}' + + +class TestVAProfileClient: + def test_ut_get_email_calls_endpoint_and_returns_email_address( + self, + rmock, + mock_va_profile_client, + mock_response, + recipient_identifier, + url, + mocker, + sample_notification, + ): + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_COMBINE_CONTACT_INFO_AND_PERMISSIONS_LOOKUP, 'True') + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_IDENTIFY_MOBILE_TELEPHONE_NUMBERS, 'True') + + rmock.post(url, json=mock_response, status_code=200) + + result = mock_va_profile_client.get_email_with_permission( + recipient_identifier, + sample_notification(gen_type=EMAIL_TYPE), + ) + email = result.recipient + + assert email == mock_response['profile']['contactInformation']['emails'][0]['emailAddressText'] + assert rmock.called + + def test_ut_get_email_raises_NoContactInfoException_if_no_emails_exist( + self, + rmock, + mock_va_profile_client, + mock_response, + recipient_identifier, + url, + mocker, + sample_notification, + ): + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_COMBINE_CONTACT_INFO_AND_PERMISSIONS_LOOKUP, 'True') + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_IDENTIFY_MOBILE_TELEPHONE_NUMBERS, 'True') + + mock_response['profile']['contactInformation']['emails'] = [] + rmock.post(url, json=mock_response, status_code=200) + + with pytest.raises(NoContactInfoException): + mock_va_profile_client.get_email_with_permission( + recipient_identifier, + sample_notification(gen_type=EMAIL_TYPE), + ) + + def test_ut_get_profile_calls_correct_url( + self, + rmock, + mock_va_profile_client, + mock_response, + recipient_identifier, + url, + id_with_aaid, + oid, + mocker, + sample_notification, + ): + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_COMBINE_CONTACT_INFO_AND_PERMISSIONS_LOOKUP, 'True') + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_IDENTIFY_MOBILE_TELEPHONE_NUMBERS, 'True') + + rmock.post(url, json=mock_response, status_code=200) + + mock_va_profile_client.get_email_with_permission(recipient_identifier, sample_notification()) + + assert rmock.called + + escaped_id = parse.quote(id_with_aaid, safe='') + expected_url = f'{MOCK_VA_PROFILE_URL}/profile-service/profile/v3/{oid}/{escaped_id}' + + assert rmock.request_history[0].url == expected_url + + def test_ut_get_email_raises_exception_when_failed_request( + self, + rmock, + mock_va_profile_client, + recipient_identifier, + url, + mocker, + sample_notification, + ): + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_COMBINE_CONTACT_INFO_AND_PERMISSIONS_LOOKUP, 'True') + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_IDENTIFY_MOBILE_TELEPHONE_NUMBERS, 'True') + + response = { + 'messages': [ + { + 'code': 'CORE103', + 'key': '_CUF_NOT_FOUND', + 'text': 'The ContactInformationBio for id/criteria 103 could not be found. Please correct your requ...', + 'severity': 'INFO', + } + ], + 'txAuditId': 'dca32cae-b410-46c5-b61b-9a382567843f', + 'status': 'COMPLETED_FAILURE', + } + rmock.post(url, json=response, status_code=200) + + with pytest.raises(VAProfileNonRetryableException): + mock_va_profile_client.get_email_with_permission( + recipient_identifier, sample_notification(gen_type=EMAIL_TYPE) + ) + + def test_ut_get_telephone_calls_endpoint_and_returns_phone_number( + self, + rmock, + mock_va_profile_client, + mock_response, + recipient_identifier, + url, + mocker, + sample_notification, + ): + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_COMBINE_CONTACT_INFO_AND_PERMISSIONS_LOOKUP, 'True') + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_IDENTIFY_MOBILE_TELEPHONE_NUMBERS, 'True') + + rmock.post(url, json=mock_response, status_code=200) + + result = mock_va_profile_client.get_telephone_with_permission(recipient_identifier, sample_notification()) + telephone = result.recipient + + assert telephone is not None + assert rmock.called + + +class TestVAProfileClientExceptionHandling: + def test_ut_get_telephone_raises_NoContactInfoException_if_no_telephones_exist( + self, + rmock, + mock_va_profile_client, + mock_response, + recipient_identifier, + url, + mocker, + sample_notification, + ): + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_COMBINE_CONTACT_INFO_AND_PERMISSIONS_LOOKUP, 'True') + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_IDENTIFY_MOBILE_TELEPHONE_NUMBERS, 'True') + + mock_response['profile']['contactInformation']['telephones'] = [] + rmock.post(url, json=mock_response, status_code=200) + + with pytest.raises(NoContactInfoException): + mock_va_profile_client.get_telephone_with_permission(recipient_identifier, sample_notification()) + + def test_ut_get_telephone_raises_NoContactInfoException_if_no_mobile_telephones_exist( + self, rmock, mock_va_profile_client, mock_response, recipient_identifier, url, mocker, sample_notification + ): + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_COMBINE_CONTACT_INFO_AND_PERMISSIONS_LOOKUP, 'True') + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_IDENTIFY_MOBILE_TELEPHONE_NUMBERS, 'True') + + telephones = mock_response['profile']['contactInformation']['telephones'] + mock_response['profile']['contactInformation']['telephones'] = [ + telephone for telephone in telephones if telephone['phoneType'] != 'MOBILE' + ] + rmock.post(url, json=mock_response, status_code=200) + + with pytest.raises(NoContactInfoException): + mock_va_profile_client.get_telephone_with_permission(recipient_identifier, sample_notification()) + + def test_ut_handle_exceptions_retryable_exception(self, mock_va_profile_client, mocker): + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_COMBINE_CONTACT_INFO_AND_PERMISSIONS_LOOKUP, 'True') + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_IDENTIFY_MOBILE_TELEPHONE_NUMBERS, 'True') + + # This test checks if VAProfileRetryableException is raised for a RequestException + with pytest.raises(VAProfileRetryableException): + mock_va_profile_client._handle_exceptions('some_va_profile_id', requests.RequestException()) + + def test_ut_handle_exceptions_id_not_found_exception(self, mock_va_profile_client, mocker): + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_COMBINE_CONTACT_INFO_AND_PERMISSIONS_LOOKUP, 'True') + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_IDENTIFY_MOBILE_TELEPHONE_NUMBERS, 'True') + + # Simulate a 404 HTTP error + error = requests.HTTPError(response=requests.Response()) + error.response.status_code = 404 + # This test checks if VAProfileIDNotFoundException is raised for a 404 error + with pytest.raises(VAProfileIDNotFoundException): + mock_va_profile_client._handle_exceptions('some_va_profile_id', error) + + def test_ut_handle_exceptions_non_retryable_exception(self, mock_va_profile_client, mocker): + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_COMBINE_CONTACT_INFO_AND_PERMISSIONS_LOOKUP, 'True') + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_IDENTIFY_MOBILE_TELEPHONE_NUMBERS, 'True') + + # Simulate a 400 HTTP error + error = requests.HTTPError(response=requests.Response()) + error.response.status_code = 400 + # This test checks if VAProfileNonRetryableException is raised for a 400 error + with pytest.raises(VAProfileNonRetryableException): + mock_va_profile_client._handle_exceptions('some_va_profile_id', error) + + def test_ut_handle_exceptions_timeout_exception(self, mock_va_profile_client, mocker): + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_COMBINE_CONTACT_INFO_AND_PERMISSIONS_LOOKUP, 'True') + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_IDENTIFY_MOBILE_TELEPHONE_NUMBERS, 'True') + + # This test checks if VAProfileRetryableExcception is raised for a Timeout exception + # Timeout inherits from requests.RequestException, so all exceptions of type RequestException should + # raise a VAProfileRetryableException + with pytest.raises(VAProfileRetryableException): + mock_va_profile_client._handle_exceptions('some_va_profile_id', requests.Timeout()) + + @pytest.mark.parametrize('status', [429, 500]) + def test_ut_client_raises_retryable_exception( + self, + rmock, + mock_va_profile_client, + recipient_identifier, + status, + mocker, + sample_notification, + ): + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_COMBINE_CONTACT_INFO_AND_PERMISSIONS_LOOKUP, 'True') + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_IDENTIFY_MOBILE_TELEPHONE_NUMBERS, 'True') + + rmock.post(requests_mock.ANY, status_code=status) + + with pytest.raises(VAProfileRetryableException): + mock_va_profile_client.get_email_with_permission( + recipient_identifier, sample_notification(gen_type=EMAIL_TYPE) + ) + + with pytest.raises(VAProfileRetryableException): + mock_va_profile_client.get_email_with_permission(recipient_identifier, sample_notification()) + + def test_ut_client_raises_retryable_exception_when_request_exception_is_thrown( + self, + mock_va_profile_client, + recipient_identifier, + mocker, + sample_notification, + ): + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_COMBINE_CONTACT_INFO_AND_PERMISSIONS_LOOKUP, 'True') + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_IDENTIFY_MOBILE_TELEPHONE_NUMBERS, 'True') + + with requests_mock.Mocker(real_http=True) as rmock: + rmock.post(requests_mock.ANY, exc=requests.RequestException) + + with pytest.raises(VAProfileRetryableException): + mock_va_profile_client.get_email_with_permission( + recipient_identifier, sample_notification(gen_type=EMAIL_TYPE) + ) + + with pytest.raises(VAProfileRetryableException): + mock_va_profile_client.get_email_with_permission(recipient_identifier, sample_notification()) + + +class TestCommunicationPermissions: + @pytest.mark.parametrize('expected', [True, False]) + def test_ut_get_is_communication_allowed_returns_whether_permissions_granted_for_sms_communication( + self, + rmock, + mock_va_profile_client, + mock_response, + url, + expected, + mocker, + sample_notification, + ): + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_COMBINE_CONTACT_INFO_AND_PERMISSIONS_LOOKUP, 'True') + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_IDENTIFY_MOBILE_TELEPHONE_NUMBERS, 'True') + + notification = sample_notification() + mock_response['profile']['communicationPermissions'][0]['allowed'] = expected + mock_response['profile']['communicationPermissions'][0]['communicationItemId'] = notification.va_profile_item_id + + allowed = mock_va_profile_client.get_is_communication_allowed_from_profile( + mock_response['profile'], notification, CommunicationChannel.TEXT + ) + + assert allowed is expected + + @pytest.mark.parametrize('expected', [True, False]) + def test_ut_get_is_communication_allowed_returns_whether_permissions_granted_for_email_communication( + self, + rmock, + mock_va_profile_client, + mock_response, + url, + expected, + mocker, + sample_notification, + ): + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_COMBINE_CONTACT_INFO_AND_PERMISSIONS_LOOKUP, 'True') + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_IDENTIFY_MOBILE_TELEPHONE_NUMBERS, 'True') + + notification = sample_notification(gen_type=EMAIL_TYPE) + mock_response['profile']['communicationPermissions'][1]['allowed'] = expected + mock_response['profile']['communicationPermissions'][1]['communicationItemId'] = notification.va_profile_item_id + + allowed = mock_va_profile_client.get_is_communication_allowed_from_profile( + mock_response['profile'], notification, CommunicationChannel.EMAIL + ) + + assert allowed is expected + + @pytest.mark.parametrize( + 'default_send, user_set, expected', + [ + # If the user has set a preference, we always go with that and override default_send + [True, True, True], + [True, False, False], + [False, True, True], + [False, False, False], + # If the user has not set a preference, go with the default_send + [True, None, True], + [False, None, False], + ], + ) + @pytest.mark.parametrize('notification_type', [CommunicationChannel.EMAIL, CommunicationChannel.TEXT]) + def test_ut_get_email_or_sms_with_permission_utilizes_default_send( + self, + mock_va_profile_client, + mock_response, + recipient_identifier, + sample_communication_item, + sample_notification, + sample_template, + default_send, + user_set, + expected, + notification_type, + mocker, + ): + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_COMBINE_CONTACT_INFO_AND_PERMISSIONS_LOOKUP, 'True') + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_IDENTIFY_MOBILE_TELEPHONE_NUMBERS, 'True') + + profile = mock_response['profile'] + communication_item = sample_communication_item(default_send) + template = sample_template(communication_item_id=communication_item.id) + + notification = sample_notification( + template=template, gen_type=EMAIL_TYPE if notification_type == CommunicationChannel.EMAIL else SMS_TYPE + ) + + if user_set is not None: + profile['communicationPermissions'][0]['allowed'] = user_set + profile['communicationPermissions'][0]['communicationItemId'] = notification.va_profile_item_id + profile['communicationPermissions'][0]['communicationChannelId'] = notification_type.id + else: + profile['communicationPermissions'] = [] + + mocker.patch.object(mock_va_profile_client, 'get_profile', return_value=profile) + + if notification_type == CommunicationChannel.EMAIL: + client_fn = mock_va_profile_client.get_email_with_permission + else: + client_fn = mock_va_profile_client.get_telephone_with_permission + + result = client_fn(recipient_identifier, notification) + assert result.communication_allowed == expected + + +class TestSendEmailStatus: + mock_response = {} + mock_notification_data = { + 'id': '2e9e6920-4f6f-4cd5-9e16-fc306fe23867', # this is the notification id + 'reference': None, + 'to': 'test@email.com', # this is the recipient's contact info (email) + 'status': 'delivered', # this will specify the delivery status of the notification + 'status_reason': '', # populated if there's additional context on the delivery status + 'created_at': '2024-07-25T10:00:00.0', + 'completed_at': '2024-07-25T11:00:00.0', + 'sent_at': '2024-07-25T11:00:00.0', + 'notification_type': EMAIL_TYPE, # this is the channel/type of notification (email) + 'provider': 'ses', # email provider + } + + def test_ut_send_va_profile_email_status_sent_successfully(self, rmock, mock_va_profile_client, mocker): + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_COMBINE_CONTACT_INFO_AND_PERMISSIONS_LOOKUP, 'True') + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_IDENTIFY_MOBILE_TELEPHONE_NUMBERS, 'True') + + rmock.post(requests_mock.ANY, json=self.mock_response, status_code=200) + + mock_va_profile_client.send_va_profile_email_status(self.mock_notification_data) + + assert rmock.called + + expected_url = f'{MOCK_VA_PROFILE_URL}/contact-information-vanotify/notify/status' + assert rmock.request_history[0].url == expected_url + + def test_ut_send_va_profile_email_status_timeout(self, rmock, mock_va_profile_client, mocker): + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_COMBINE_CONTACT_INFO_AND_PERMISSIONS_LOOKUP, 'True') + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_IDENTIFY_MOBILE_TELEPHONE_NUMBERS, 'True') + + rmock.post(requests_mock.ANY, exc=requests.ReadTimeout) + + with pytest.raises(requests.Timeout): + mock_va_profile_client.send_va_profile_email_status(self.mock_notification_data) + + assert rmock.called + + expected_url = f'{MOCK_VA_PROFILE_URL}/contact-information-vanotify/notify/status' + assert rmock.request_history[0].url == expected_url + + def test_ut_send_va_profile_email_status_throws_exception(self, rmock, mock_va_profile_client, mocker): + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_COMBINE_CONTACT_INFO_AND_PERMISSIONS_LOOKUP, 'True') + mock_feature_flag(mocker, FeatureFlag.VA_PROFILE_V3_IDENTIFY_MOBILE_TELEPHONE_NUMBERS, 'True') + + rmock.post(requests_mock.ANY, exc=requests.RequestException) + + with pytest.raises(requests.RequestException): + mock_va_profile_client.send_va_profile_email_status(self.mock_notification_data) + + assert rmock.called + + expected_url = f'{MOCK_VA_PROFILE_URL}/contact-information-vanotify/notify/status' + assert rmock.request_history[0].url == expected_url