From febc29af0317bc8edaccaf9b6995db8511246124 Mon Sep 17 00:00:00 2001 From: William B <7444334+whabanks@users.noreply.github.com> Date: Tue, 3 Dec 2024 16:33:51 -0400 Subject: [PATCH] Add backend validators for annual limits (#2372) * Add backend validators for annual limits * Fix CodeQL issues * formatting --- app/dao/fact_notification_status_dao.py | 21 ++ app/notifications/validators.py | 117 ++++++++++- app/utils.py | 59 ++++++ app/v2/errors.py | 32 +++ tests/app/conftest.py | 4 + .../dao/test_fact_notification_status_dao.py | 65 ++++++ tests/app/notifications/test_validators.py | 197 ++++++++++++++++++ tests/app/test_utils.py | 34 +++ 8 files changed, 528 insertions(+), 1 deletion(-) diff --git a/app/dao/fact_notification_status_dao.py b/app/dao/fact_notification_status_dao.py index 75e0ca1371..e976104599 100644 --- a/app/dao/fact_notification_status_dao.py +++ b/app/dao/fact_notification_status_dao.py @@ -32,6 +32,7 @@ User, ) from app.utils import ( + get_fiscal_dates, get_local_timezone_midnight_in_utc, get_local_timezone_month_from_utc_column, ) @@ -916,3 +917,23 @@ def fetch_quarter_data(start_date, end_date, service_ids): .group_by(FactNotificationStatus.service_id, FactNotificationStatus.notification_type) ) return query.all() + + +def fetch_notification_status_totals_for_service_by_fiscal_year(service_id, fiscal_year, notification_type=None): + start_date, end_date = get_fiscal_dates(year=fiscal_year) + + filters = [ + FactNotificationStatus.service_id == (service_id), + FactNotificationStatus.bst_date >= start_date, + FactNotificationStatus.bst_date <= end_date, + ] + + if notification_type: + filters.append(FactNotificationStatus.notification_type == notification_type) + + query = ( + db.session.query(func.sum(FactNotificationStatus.notification_count).label("notification_count")) + .filter(*filters) + .scalar() + ) + return query or 0 diff --git a/app/notifications/validators.py b/app/notifications/validators.py index a144c22229..082532af21 100644 --- a/app/notifications/validators.py +++ b/app/notifications/validators.py @@ -14,6 +14,7 @@ over_sms_daily_limit_cache_key, rate_limit_cache_key, ) +from notifications_utils.decorators import requires_feature from notifications_utils.recipients import ( get_international_phone_info, validate_and_format_email_address, @@ -22,8 +23,11 @@ from notifications_utils.statsd_decorators import statsd_catch from sqlalchemy.orm.exc import NoResultFound -from app import redis_store +from app import annual_limit_client, redis_store from app.dao import services_dao, templates_dao +from app.dao.fact_notification_status_dao import ( + fetch_notification_status_totals_for_service_by_fiscal_year, +) from app.dao.service_email_reply_to_dao import dao_get_reply_to_by_id from app.dao.service_letter_contact_dao import dao_get_letter_contact_by_id from app.dao.service_sms_sender_dao import dao_get_service_sms_senders_by_id @@ -53,22 +57,28 @@ ) from app.utils import ( get_document_url, + get_fiscal_year, get_limit_reset_time_et, get_public_notify_type_text, is_blank, ) from app.v2.errors import ( BadRequestError, + LiveServiceRequestExceedsEmailAnnualLimitError, + LiveServiceRequestExceedsSMSAnnualLimitError, LiveServiceTooManyEmailRequestsError, LiveServiceTooManyRequestsError, LiveServiceTooManySMSRequestsError, RateLimitError, + TrialServiceRequestExceedsEmailAnnualLimitError, + TrialServiceRequestExceedsSMSAnnualLimitError, TrialServiceTooManyEmailRequestsError, TrialServiceTooManyRequestsError, TrialServiceTooManySMSRequestsError, ) NEAR_DAILY_LIMIT_PERCENTAGE = 80 / 100 +NEAR_ANNUAL_LIMIT_PERCENTAGE = 80 / 100 def check_service_over_api_rate_limit_and_update_rate(service: Service, api_key: ApiKey): @@ -162,6 +172,111 @@ def check_email_daily_limit(service: Service, requested_email=0): raise LiveServiceTooManyEmailRequestsError(service.message_limit) +# TODO: FF_ANNUAL_LIMIT removal +@requires_feature("FF_ANNUAL_LIMIT") +@statsd_catch( + namespace="validators", + counter_name="rate_limit.trial_service_annual_email", + exception=TrialServiceRequestExceedsEmailAnnualLimitError, +) +@statsd_catch( + namespace="validators", + counter_name="rate_limit.live_service_annual_email", + exception=LiveServiceRequestExceedsEmailAnnualLimitError, +) +def check_email_annual_limit(service: Service, requested_emails=0): + current_fiscal_year = get_fiscal_year(datetime.utcnow()) + emails_sent_today = fetch_todays_email_count(service.id) + emails_sent_this_fiscal = fetch_notification_status_totals_for_service_by_fiscal_year( + service.id, current_fiscal_year, notification_type=EMAIL_TYPE + ) + send_exceeds_annual_limit = (emails_sent_today + emails_sent_this_fiscal + requested_emails) > service.email_annual_limit + send_reaches_annual_limit = (emails_sent_today + emails_sent_this_fiscal + requested_emails) == service.email_annual_limit + is_near_annual_limit = (emails_sent_today + emails_sent_this_fiscal + requested_emails) > ( + service.email_annual_limit * NEAR_ANNUAL_LIMIT_PERCENTAGE + ) + + if not send_exceeds_annual_limit: + # Will this send put the service right at their limit? + if send_reaches_annual_limit and not annual_limit_client.check_has_over_limit_been_sent(service.id, SMS_TYPE): + annual_limit_client.set_over_sms_limit(service.id) + current_app.logger.info( + f"Service {service.id} reached their annual email limit of {service.email_annual_limit} when sending {requested_emails} messages. Sending reached annual limit email." + ) + # TODO Send reached limit email + + # Will this send put annual usage within 80% of the limit? + if is_near_annual_limit and not annual_limit_client.check_has_warning_been_sent(service.id, EMAIL_TYPE): + annual_limit_client.set_nearing_email_limit(service.id) + current_app.logger.info( + f"Service {service.id} reached 80% of their annual email limit of {service.email_annual_limit} messages. Sending annual limit usage warning email." + ) + # TODO: Send warning email + + return + + current_app.logger.info( + f"Service {service.id} is exceeding their annual email limit [total sent this fiscal: {int(emails_sent_today + emails_sent_this_fiscal)} limit: {service.email_annual_limit}, attempted send: {requested_emails}" + ) + if service.restricted: + raise TrialServiceRequestExceedsEmailAnnualLimitError(service.email_annual_limit) + else: + raise LiveServiceRequestExceedsEmailAnnualLimitError(service.email_annual_limit) + + +# TODO: FF_ANNUAL_LIMIT removal +@requires_feature("FF_ANNUAL_LIMIT") +@statsd_catch( + namespace="validators", + counter_name="rate_limit.trial_service_annual_sms", + exception=TrialServiceRequestExceedsSMSAnnualLimitError, +) +@statsd_catch( + namespace="validators", + counter_name="rate_limit.live_service_annual_sms", + exception=LiveServiceRequestExceedsSMSAnnualLimitError, +) +def check_sms_annual_limit(service: Service, requested_sms=0): + current_fiscal_year = get_fiscal_year(datetime.utcnow()) + sms_sent_today = fetch_todays_requested_sms_count(service.id) + sms_sent_this_fiscal = fetch_notification_status_totals_for_service_by_fiscal_year( + service.id, current_fiscal_year, notification_type=SMS_TYPE + ) + send_exceeds_annual_limit = (sms_sent_today + sms_sent_this_fiscal + requested_sms) > service.sms_annual_limit + send_reaches_annual_limit = (sms_sent_today + sms_sent_this_fiscal + requested_sms) == service.sms_annual_limit + is_near_annual_limit = (sms_sent_today + sms_sent_this_fiscal + requested_sms) >= ( + service.sms_annual_limit * NEAR_ANNUAL_LIMIT_PERCENTAGE + ) + + if not send_exceeds_annual_limit: + # Will this send put the service right at their limit? + if send_reaches_annual_limit and not annual_limit_client.check_has_over_limit_been_sent(service.id, SMS_TYPE): + annual_limit_client.set_over_sms_limit(service.id) + current_app.logger.info( + f"Service {service.id} reached their annual SMS limit of {service.sms_annual_limit} messages. Sending reached annual limit email." + ) + # TODO Send reached limit email + + # Will this send put annual usage within 80% of the limit? + if is_near_annual_limit and not annual_limit_client.check_has_warning_been_sent(service.id, EMAIL_TYPE): + annual_limit_client.set_nearing_email_limit(service.id) + current_app.logger.info( + f"Service {service.id} reached 80% of their annual SMS limit of {service.sms_annual_limit} messages. Sending annual limit usage warning email." + ) + # TODO: Send warning email + + return + + current_app.logger.info( + f"Service {service.id} is exceeding their annual SMS limit [total sent this fiscal: {int(sms_sent_today + sms_sent_this_fiscal)} limit: {service.sms_annual_limit}, attempted send: {requested_sms}" + ) + + if service.restricted: + raise TrialServiceRequestExceedsSMSAnnualLimitError(service.sms_annual_limit) + else: + raise LiveServiceRequestExceedsSMSAnnualLimitError(service.sms_annual_limit) + + def send_warning_email_limit_emails_if_needed(service: Service) -> None: """ Function that decides if we should send email warnings about nearing or reaching the email daily limit. diff --git a/app/utils.py b/app/utils.py index 165991a71d..e808123295 100644 --- a/app/utils.py +++ b/app/utils.py @@ -256,3 +256,62 @@ def prepare_notification_counts_for_seeding(notification_counts: list) -> dict: for _, notification_type, status, count in notification_counts if status in DELIVERED_STATUSES or status in FAILURE_STATUSES } + + +def get_fiscal_year(current_date=None): + """ + Determine the fiscal year for a given date. + + Args: + current_date (datetime.date, optional): The date to determine the fiscal year for. + Defaults to today's date. + + Returns: + int: The fiscal year (starting year). + """ + if current_date is None: + current_date = datetime.today() + + # Fiscal year starts on April 1st + fiscal_year_start_month = 4 + if current_date.month >= fiscal_year_start_month: + return current_date.year + else: + return current_date.year - 1 + + +def get_fiscal_dates(current_date=None, year=None): + """ + Determine the start and end dates of the fiscal year for a given date or year. + If no parameters are passed into the method, the fiscal year for the current date will be determined. + + Args: + current_date (datetime.date, optional): The date to determine the fiscal year for. + year (int, optional): The year to determine the fiscal year for. + + Returns: + tuple: A tuple containing the start and end dates of the fiscal year (datetime.date). + """ + if current_date and year: + raise ValueError("Only one of current_date or year should be provided.") + + if not current_date and not year: + current_date = datetime.today() + + # Fiscal year starts on April 1st + fiscal_year_start_month = 4 + fiscal_year_start_day = 1 + + if current_date: + if current_date.month >= fiscal_year_start_month: + fiscal_year_start = datetime(current_date.year, fiscal_year_start_month, fiscal_year_start_day) + fiscal_year_end = datetime(current_date.year + 1, fiscal_year_start_month - 1, 31) # March 31 of the next year + else: + fiscal_year_start = datetime(current_date.year - 1, fiscal_year_start_month, fiscal_year_start_day) + fiscal_year_end = datetime(current_date.year, fiscal_year_start_month - 1, 31) # March 31 of the current year + + if year: + fiscal_year_start = datetime(year, fiscal_year_start_month, fiscal_year_start_day) + fiscal_year_end = datetime(year + 1, fiscal_year_start_month - 1, 31) + + return fiscal_year_start, fiscal_year_end diff --git a/app/v2/errors.py b/app/v2/errors.py index 62187005ea..8adbc85f71 100644 --- a/app/v2/errors.py +++ b/app/v2/errors.py @@ -46,6 +46,22 @@ def __init__(self, sending_limit): self.message = self.messsage_template.format(sending_limit) +class RequestExceedsEmailAnnualLimitError(InvalidRequest): + status_code = 429 + message_template = "Exceeded annual email sending limit of {} messages" + + def __init__(self, annual_limit): + self.message = self.message_template.format(annual_limit) + + +class RequestExceedsSMSAnnualLimitError(InvalidRequest): + status_code = 429 + message_template = "Exceeded annual SMS sending limit of {} messages" + + def __init__(self, annual_limit): + self.message = self.message_template.format(annual_limit) + + class LiveServiceTooManyRequestsError(TooManyRequestsError): pass @@ -58,6 +74,22 @@ class LiveServiceTooManyEmailRequestsError(TooManyEmailRequestsError): pass +class LiveServiceRequestExceedsEmailAnnualLimitError(RequestExceedsEmailAnnualLimitError): + pass + + +class LiveServiceRequestExceedsSMSAnnualLimitError(RequestExceedsSMSAnnualLimitError): + pass + + +class TrialServiceRequestExceedsEmailAnnualLimitError(RequestExceedsEmailAnnualLimitError): + pass + + +class TrialServiceRequestExceedsSMSAnnualLimitError(RequestExceedsSMSAnnualLimitError): + pass + + class TrialServiceTooManyRequestsError(TooManyRequestsError): pass diff --git a/tests/app/conftest.py b/tests/app/conftest.py index 8dbfd4c5b7..f81be1e9bb 100644 --- a/tests/app/conftest.py +++ b/tests/app/conftest.py @@ -162,6 +162,8 @@ def create_sample_service( restricted=False, limit=1000, sms_limit=1000, + email_annual_limit=None, + sms_annual_limit=None, email_from=None, permissions=None, research_mode=None, @@ -175,6 +177,8 @@ def create_sample_service( "name": service_name, "message_limit": limit, "sms_daily_limit": sms_limit, + "email_annual_limit": email_annual_limit, + "sms_annual_limit": sms_annual_limit, "restricted": restricted, "email_from": email_from, "created_by": user, diff --git a/tests/app/dao/test_fact_notification_status_dao.py b/tests/app/dao/test_fact_notification_status_dao.py index 5c2135ffe7..409eaef2f8 100644 --- a/tests/app/dao/test_fact_notification_status_dao.py +++ b/tests/app/dao/test_fact_notification_status_dao.py @@ -17,6 +17,7 @@ fetch_notification_status_for_service_for_day, fetch_notification_status_for_service_for_today_and_7_previous_days, fetch_notification_status_totals_for_all_services, + fetch_notification_status_totals_for_service_by_fiscal_year, fetch_notification_statuses_for_job, fetch_quarter_data, fetch_stats_for_all_services_by_date_range, @@ -1348,6 +1349,70 @@ def test_fetch_monthly_notification_statuses_per_service_for_rows_that_should_be assert len(results) == 0 +@freeze_time("2024-11-28") +@pytest.mark.parametrize( + "notification_type, expected_count", + [ + ("sms", 50), + ("email", 50), + (None, 100), + ], +) +def test_fetch_notification_status_totals_for_service_by_fiscal_year_only_counts_notifications_of_type_in_specified_fiscal_year( + notify_db_session, + notification_type, + expected_count, +): + service = create_service(service_name="service") + sms_template = create_template(service=service, template_type="sms") + email_template = create_template(service=service, template_type="email") + + # Before current fiscal year + create_ft_notification_status( + utc_date=datetime(2024, 3, 31), + service=service, + template=sms_template, + count=5, + ) + create_ft_notification_status( + utc_date=datetime(2024, 3, 31), + service=service, + template=email_template, + count=5, + ) + + # Within current fiscal year + create_ft_notification_status( + utc_date=datetime(2024, 4, 1), + service=service, + template=sms_template, + count=50, + ) + create_ft_notification_status( + utc_date=datetime(2024, 4, 1), + service=service, + template=email_template, + count=50, + ) + + # After current fiscal year + create_ft_notification_status( + utc_date=datetime(2025, 4, 1), + service=service, + template=sms_template, + count=50, + ) + create_ft_notification_status( + utc_date=datetime(2025, 4, 1), + service=service, + template=email_template, + count=50, + ) + + results = fetch_notification_status_totals_for_service_by_fiscal_year(service.id, 2024, notification_type) + assert results == expected_count + + class TestFetchQuarterData: def test_fetch_quarter_data(self, notify_db_session): service_1 = create_service(service_name="service_1") diff --git a/tests/app/notifications/test_validators.py b/tests/app/notifications/test_validators.py index f49c4dc6e0..955bcdaa89 100644 --- a/tests/app/notifications/test_validators.py +++ b/tests/app/notifications/test_validators.py @@ -16,6 +16,7 @@ ApiKeyType, ) from app.notifications.validators import ( + check_email_annual_limit, check_email_daily_limit, check_reply_to, check_service_email_reply_to_id, @@ -23,6 +24,7 @@ check_service_over_api_rate_limit_and_update_rate, check_service_over_daily_message_limit, check_service_sms_sender_id, + check_sms_annual_limit, check_sms_content_char_count, check_sms_daily_limit, check_template_is_active, @@ -35,10 +37,14 @@ from app.utils import get_document_url from app.v2.errors import ( BadRequestError, + LiveServiceRequestExceedsEmailAnnualLimitError, + LiveServiceRequestExceedsSMSAnnualLimitError, RateLimitError, TooManyEmailRequestsError, TooManyRequestsError, TooManySMSRequestsError, + TrialServiceRequestExceedsEmailAnnualLimitError, + TrialServiceRequestExceedsSMSAnnualLimitError, ) from tests.app.conftest import ( create_sample_api_key, @@ -48,6 +54,7 @@ create_sample_template, ) from tests.app.db import ( + create_ft_notification_status, create_letter_contact, create_reply_to_email, create_service_sms_sender, @@ -696,3 +703,193 @@ def test_check_reply_to_sms_type(sample_service): def test_check_reply_to_letter_type(sample_service): letter_contact = create_letter_contact(service=sample_service, contact_block="123456") assert check_reply_to(sample_service.id, letter_contact.id, LETTER_TYPE) == "123456" + + +class TestAnnualLimitValidators: + @freeze_time("2024-11-26") + @pytest.mark.parametrize( + "annual_limit, counts_from_redis, do_ft_insert, ft_count, notifications_requested, will_raise, has_sent_reached_limit_email, has_sent_near_limit_email, log_msg", + [ + (100, 81, False, 0, 20, True, False, True, "is exceeding their annual email limit"), + (100, 0, True, 100, 5, True, False, True, "is exceeding their annual email limit"), + (100, 50, True, 50, 1, True, False, True, "is exceeding their annual email limit"), + (100, 5, True, 50, 5, False, False, False, None), + (100, 50, True, 29, 5, False, False, True, "reached 80% of their annual email limit of"), + (100, 5, True, 50, 5, False, True, False, "reached their annual email limit of"), + ], + ids=[ + " Cache only - Service attempts to go over annual limit", + " DB only - Service exceeded annual limit - attempts more sends", + " Cache & DB - Service attempts to go over annual limit", + " Cache only - Within annual limit - not near or over limit", + " DB only - Within annual limit - near limit", + " Cache & DB - Within annual limit - reaches limit with current send", + ], + ) + @pytest.mark.parametrize( + "is_trial_service, exception_type", + [(True, TrialServiceRequestExceedsEmailAnnualLimitError), (False, LiveServiceRequestExceedsEmailAnnualLimitError)], + ids=["Trial service ", "Live service "], + ) + def test_check_email_annual_limit( + self, + notify_api, + notify_db, + notify_db_session, + annual_limit, + counts_from_redis, + do_ft_insert, + ft_count, + is_trial_service, + exception_type, + notifications_requested, + will_raise, + has_sent_reached_limit_email, + has_sent_near_limit_email, + log_msg, + mocker, + ): + mock_logger = mocker.patch("app.notifications.validators.current_app.logger.info") + mock_redis_set = mocker.patch("app.redis_store.set_hash_value") # Set over / near limit keys + mocker.patch("app.redis_store.get", return_value=counts_from_redis) # notifications fetched from Redis + mocker.patch("app.annual_limit_client.check_has_warning_been_sent", return_value=has_sent_near_limit_email) + mocker.patch( + "app.annual_limit_client.check_has_warning_been_sent", return_value=has_sent_reached_limit_email + ) # Email sent flag checks + + is_near = (counts_from_redis + ft_count + notifications_requested) >= (annual_limit * 0.8) + is_reached = (counts_from_redis + ft_count + notifications_requested) == annual_limit + + service = create_sample_service( + notify_db, notify_db_session, restricted=is_trial_service, email_annual_limit=annual_limit + ) + email_template = create_sample_template(notify_db, notify_db_session, template_type=EMAIL_TYPE) + sms_template = create_sample_template(notify_db, notify_db_session, template_type=SMS_TYPE) + + if do_ft_insert: + # Previous fiscal year + create_ft_notification_status( + utc_date="2024-03-31", + service=service, + template=email_template, + notification_type=EMAIL_TYPE, + ) + # Within current fiscal year + create_ft_notification_status( + utc_date="2024-04-01", service=service, template=email_template, notification_type=EMAIL_TYPE, count=ft_count + ) + # In the next fiscal year + create_ft_notification_status( + utc_date="2025-04-01", service=service, template=email_template, notification_type=EMAIL_TYPE + ) + # Make sure we're not counting non-email notifications + create_ft_notification_status( + utc_date="2024-04-01", service=service, template=sms_template, notification_type=SMS_TYPE + ) + + with set_config(notify_api, "FF_ANNUAL_LIMIT", True): + if will_raise: + with pytest.raises(exception_type) as e: + check_email_annual_limit(service, notifications_requested) + assert e.value.status_code == 429 + assert e.value.message == f"Exceeded annual email sending limit of {service.email_annual_limit} messages" + assert log_msg in mock_logger.call_args[0][0] + else: + assert check_email_annual_limit(service, notifications_requested) is None + if (not has_sent_reached_limit_email and is_reached) or (not has_sent_near_limit_email and is_near): + mock_redis_set.assert_called_with(service.id) + if log_msg: + assert log_msg in mock_logger.call_args[0][0] + + @freeze_time("2024-11-26") + @pytest.mark.parametrize( + "annual_limit, counts_from_redis, do_ft_insert, ft_count, notifications_requested, will_raise, has_sent_reached_limit_email, has_sent_near_limit_email, log_msg", + [ + (100, 81, False, 0, 20, True, False, True, "is exceeding their annual SMS limit"), + (100, 0, True, 100, 5, True, False, True, "is exceeding their annual SMS limit"), + (100, 50, True, 50, 1, True, False, True, "is exceeding their annual SMS limit"), + (100, 5, True, 50, 5, False, False, False, None), + (100, 50, True, 29, 5, False, False, True, "reached 80% of their annual SMS limit of"), + (100, 5, True, 50, 5, False, True, False, "reached their annual SMS limit of"), + ], + ids=[ + " Cache only - Service attempts to go over annual limit", + " DB only - Service exceeded annual limit - attempts more sends", + " Cache & DB - Service attempts to go over annual limit", + " Cache only - Within annual limit - not near or over limit", + " DB only - Within annual limit - near limit", + " Cache & DB - Within annual limit - reaches limit with current send", + ], + ) + @pytest.mark.parametrize( + "is_trial_service, exception_type", + [(True, TrialServiceRequestExceedsSMSAnnualLimitError), (False, LiveServiceRequestExceedsSMSAnnualLimitError)], + ids=["Trial service ", "Live service "], + ) + def test_check_sms_annual_limit( + self, + notify_api, + notify_db, + notify_db_session, + annual_limit, + counts_from_redis, + do_ft_insert, + ft_count, + is_trial_service, + exception_type, + notifications_requested, + will_raise, + has_sent_reached_limit_email, + has_sent_near_limit_email, + log_msg, + mocker, + ): + mock_logger = mocker.patch("app.notifications.validators.current_app.logger.info") + mock_redis_set = mocker.patch("app.redis_store.set_hash_value") # Set over / near limit keys + mocker.patch("app.redis_store.get", return_value=counts_from_redis) # notifications fetched from Redis + mocker.patch("app.annual_limit_client.check_has_warning_been_sent", return_value=has_sent_near_limit_email) + mocker.patch( + "app.annual_limit_client.check_has_warning_been_sent", return_value=has_sent_reached_limit_email + ) # Email sent flag checks + + is_near = (counts_from_redis + ft_count + notifications_requested) >= (annual_limit * 0.8) + is_reached = (counts_from_redis + ft_count + notifications_requested) == annual_limit + + service = create_sample_service(notify_db, notify_db_session, restricted=is_trial_service, sms_annual_limit=annual_limit) + email_template = create_sample_template(notify_db, notify_db_session, template_type=EMAIL_TYPE) + sms_template = create_sample_template(notify_db, notify_db_session, template_type=SMS_TYPE) + + if do_ft_insert: + # Previous fiscal year + create_ft_notification_status( + utc_date="2024-03-31", + service=service, + template=sms_template, + notification_type=SMS_TYPE, + ) + # Within current fiscal year + create_ft_notification_status( + utc_date="2024-04-01", service=service, template=sms_template, notification_type=SMS_TYPE, count=ft_count + ) + # In the next fiscal year + create_ft_notification_status( + utc_date="2025-04-01", service=service, template=sms_template, notification_type=SMS_TYPE + ) + # Make sure we're not counting non-email notifications + create_ft_notification_status( + utc_date="2024-04-01", service=service, template=email_template, notification_type=EMAIL_TYPE + ) + + with set_config(notify_api, "FF_ANNUAL_LIMIT", True): + if will_raise: + with pytest.raises(exception_type) as e: + check_sms_annual_limit(service, notifications_requested) + assert e.value.status_code == 429 + assert e.value.message == f"Exceeded annual SMS sending limit of {service.sms_annual_limit} messages" + assert log_msg in mock_logger.call_args[0][0] + else: + assert check_sms_annual_limit(service, notifications_requested) is None + if (not has_sent_reached_limit_email and is_reached) or (not has_sent_near_limit_email and is_near): + mock_redis_set.assert_called_with(service.id) + if log_msg: + assert log_msg in mock_logger.call_args[0][0] diff --git a/tests/app/test_utils.py b/tests/app/test_utils.py index 2d504c2050..f5efb78433 100644 --- a/tests/app/test_utils.py +++ b/tests/app/test_utils.py @@ -9,6 +9,8 @@ from app.utils import ( get_delivery_queue_for_template, get_document_url, + get_fiscal_dates, + get_fiscal_year, get_limit_reset_time_et, get_local_timezone_midnight, get_local_timezone_midnight_in_utc, @@ -156,3 +158,35 @@ def test_get_limit_reset_time_et(): def test_get_delivery_queue_for_template(sample_service, template_type, process_type, expected_queue): template = create_template(sample_service, process_type=process_type, template_type=template_type) assert get_delivery_queue_for_template(template) == expected_queue + + +@pytest.mark.parametrize( + "current_date, expected_fiscal_year", + [ + (datetime(2023, 3, 31), 2022), + (datetime(2023, 4, 1), 2023), + (datetime(2023, 12, 31), 2023), + (None, datetime.today().year if datetime.today().month >= 4 else datetime.today().year - 1), + ], +) +def test_get_fiscal_year(current_date, expected_fiscal_year): + assert get_fiscal_year(current_date) == expected_fiscal_year + + +@freeze_time("2024-11-28") +@pytest.mark.parametrize( + "current_date, year, expected_start, expected_end", + [ + (datetime(2023, 3, 31), None, datetime(2022, 4, 1), datetime(2023, 3, 31)), + (datetime(2023, 4, 1), None, datetime(2023, 4, 1), datetime(2024, 3, 31)), + (None, 2023, datetime(2023, 4, 1), datetime(2024, 3, 31)), + (None, None, datetime(2024, 4, 1), datetime(2025, 3, 31)), + ], +) +def test_get_fiscal_dates(current_date, year, expected_start, expected_end): + assert get_fiscal_dates(current_date, year) == (expected_start, expected_end) + + +def test_get_fiscal_dates_raises_value_error(): + with pytest.raises(ValueError): + get_fiscal_dates(current_date=datetime(2023, 4, 1), year=2023)