From 33b0efc0fe40d67a1fba8f788b8a676c55d99372 Mon Sep 17 00:00:00 2001 From: Angela Tran Date: Wed, 27 Mar 2024 16:30:47 +0000 Subject: [PATCH 01/18] refactor(enrollment): check for existing group funding source first this is instead of catching the 409 response code --- benefits/enrollment/views.py | 46 +++++++++++++++++++-------- tests/pytest/enrollment/test_views.py | 28 ++++++++++++---- 2 files changed, 54 insertions(+), 20 deletions(-) diff --git a/benefits/enrollment/views.py b/benefits/enrollment/views.py index 9b3eaa76f..27ac1f368 100644 --- a/benefits/enrollment/views.py +++ b/benefits/enrollment/views.py @@ -81,25 +81,29 @@ def index(request): client.oauth.ensure_active_token(client.token) funding_source = client.get_funding_source_by_token(card_token) + group_id = eligibility.group_id try: - client.link_concession_group_funding_source(funding_source_id=funding_source.id, group_id=eligibility.group_id) - except HTTPError as e: - # 409 means that customer already belongs to a concession group. - # the response JSON will look like: - # {"errors":[{"detail":"Conflict (409) - Customer already belongs to a concession group."}]} - if e.response.status_code == 409: - analytics.returned_success(request, eligibility.group_id) - return success(request) + group_funding_source = _get_group_funding_source( + client=client, group_id=group_id, funding_source_id=funding_source.id + ) + + already_enrolled = group_funding_source is not None + + if not already_enrolled: + # enroll user with no expiration date, return success + client.link_concession_group_funding_source(group_id=group_id, funding_source_id=funding_source.id) + return _success(request, group_id) else: - analytics.returned_error(request, str(e)) - raise Exception(f"{e}: {e.response.json()}") + # no action, return success + return _success(request, group_id) + + except HTTPError as e: + analytics.returned_error(request, str(e)) + raise Exception(f"{e}: {e.response.json()}") except Exception as e: analytics.returned_error(request, str(e)) raise e - else: - analytics.returned_success(request, eligibility.group_id) - return success(request) # GET enrollment index else: @@ -122,6 +126,22 @@ def index(request): return TemplateResponse(request, eligibility.enrollment_index_template, context) +def _success(request, group_id): + analytics.returned_success(request, group_id) + return success(request) + + +def _get_group_funding_source(client: Client, group_id, funding_source_id): + group_funding_sources = client.get_concession_group_linked_funding_sources(group_id) + matching_group_funding_source = None + for group_funding_source in group_funding_sources: + if group_funding_source.id == funding_source_id: + matching_group_funding_source = group_funding_source + break + + return matching_group_funding_source + + @decorator_from_middleware(EligibleSessionRequired) def retry(request): """View handler for a recoverable failure condition.""" diff --git a/tests/pytest/enrollment/test_views.py b/tests/pytest/enrollment/test_views.py index c2643262c..2bad76a23 100644 --- a/tests/pytest/enrollment/test_views.py +++ b/tests/pytest/enrollment/test_views.py @@ -3,6 +3,7 @@ from django.urls import reverse from littlepay.api.funding_sources import FundingSourceResponse +from littlepay.api.groups import GroupFundingSourceResponse from requests import HTTPError import pytest @@ -51,6 +52,17 @@ def mocked_funding_source(): ) +@pytest.fixture +def mocked_group_funding_source_no_expiration(mocked_funding_source): + return GroupFundingSourceResponse( + id=mocked_funding_source.id, + participant_id=mocked_funding_source.participant_id, + concession_expiry=None, + concession_created_at=None, + concession_updated_at=None, + ) + + @pytest.mark.django_db def test_token_ineligible(client): path = reverse(ROUTE_TOKEN) @@ -131,7 +143,6 @@ def test_index_eligible_post_valid_form_http_error(mocker, client, card_tokenize mock_client_cls = mocker.patch("benefits.enrollment.views.Client") mock_client = mock_client_cls.return_value - # any status_code that isn't 409 is considered an error mock_error = {"message": "Mock error message"} mock_error_response = mocker.Mock(status_code=400, **mock_error) mock_error_response.json.return_value = mock_error @@ -160,20 +171,23 @@ def test_index_eligible_post_valid_form_failure(mocker, client, card_tokenize_fo @pytest.mark.django_db @pytest.mark.usefixtures("mocked_session_agency", "mocked_session_verifier", "mocked_session_eligibility") def test_index_eligible_post_valid_form_customer_already_enrolled( - mocker, client, card_tokenize_form_data, mocked_analytics_module, model_EligibilityType, mocked_funding_source + mocker, + client, + card_tokenize_form_data, + mocked_analytics_module, + model_EligibilityType, + mocked_funding_source, + mocked_group_funding_source_no_expiration, ): mock_client_cls = mocker.patch("benefits.enrollment.views.Client") mock_client = mock_client_cls.return_value mock_client.get_funding_source_by_token.return_value = mocked_funding_source - mock_error_response = mocker.Mock(status_code=409) - mock_client.link_concession_group_funding_source.side_effect = HTTPError(response=mock_error_response) + + mocker.patch("benefits.enrollment.views._get_group_funding_source", return_value=mocked_group_funding_source_no_expiration) path = reverse(ROUTE_INDEX) response = client.post(path, card_tokenize_form_data) - mock_client.link_concession_group_funding_source.assert_called_once_with( - funding_source_id=mocked_funding_source.id, group_id=model_EligibilityType.group_id - ) assert response.status_code == 200 assert response.template_name == TEMPLATE_SUCCESS mocked_analytics_module.returned_success.assert_called_once() From 0ee7251c292e0f2fda73c05d6c87cd2c04fbcde1 Mon Sep 17 00:00:00 2001 From: Angela Tran Date: Wed, 27 Mar 2024 17:56:04 +0000 Subject: [PATCH 02/18] feat(enrollment): outline new scenarios that consider expiration --- benefits/enrollment/views.py | 64 +++++++++++++++++++++++++++++++----- 1 file changed, 56 insertions(+), 8 deletions(-) diff --git a/benefits/enrollment/views.py b/benefits/enrollment/views.py index 27ac1f368..17cd3781a 100644 --- a/benefits/enrollment/views.py +++ b/benefits/enrollment/views.py @@ -89,14 +89,47 @@ def index(request): ) already_enrolled = group_funding_source is not None - - if not already_enrolled: - # enroll user with no expiration date, return success - client.link_concession_group_funding_source(group_id=group_id, funding_source_id=funding_source.id) - return _success(request, group_id) - else: - # no action, return success - return _success(request, group_id) + has_no_expiration_date = already_enrolled and group_funding_source.concession_expiry is None + has_expiration_date = already_enrolled and group_funding_source.concession_expiry is not None + + if eligibility.supports_expiration: + # set expiry on session + if has_expiration_date: + session.update(request, enrollment_expiry=group_funding_source.concession_expiry) + else: + session.update(request, enrollment_expiry=_calculate_expiry(eligibility.expiration_days)) + + if not already_enrolled: + # enroll user with an expiration date, return success + pass + else: + if has_no_expiration_date: + # update expiration of existing enrollment, return success + pass + else: + is_expired = _is_expired(group_funding_source.concession_expiry) + is_within_reenrollment_window = _is_within_reenrollment_window( + group_funding_source.concession_expiry, session.enrollment_reenrollment(request) + ) + + if is_expired or is_within_reenrollment_window: + # update expiration of existing enrollment, return success + pass + else: + # re-enrollment error, return enrollment error with expiration and reenrollment_date + pass + else: # eligibility does not support expiration + if not already_enrolled: + # enroll user with no expiration date, return success + client.link_concession_group_funding_source(group_id=group_id, funding_source_id=funding_source.id) + return _success(request, group_id) + else: + if has_no_expiration_date: + # no action, return success + return _success(request, group_id) + else: + # remove expiration date, return success + pass except HTTPError as e: analytics.returned_error(request, str(e)) @@ -142,6 +175,21 @@ def _get_group_funding_source(client: Client, group_id, funding_source_id): return matching_group_funding_source +def _is_expired(concession_expiry): + """Returns whether the passed in datetime is expired or not.""" + pass + + +def _is_within_reenrollment_window(concession_expiry, enrollment_reenrollment_date): + """Returns whether the passed in datetime is within the reenrollment window.""" + pass + + +def _calculate_expiry(expiration_days): + """Returns the expiry datetime.""" + pass + + @decorator_from_middleware(EligibleSessionRequired) def retry(request): """View handler for a recoverable failure condition.""" From 0e6b8a98fc7f97e44f09797c8de86b0c4b22e148 Mon Sep 17 00:00:00 2001 From: Kegan Maher Date: Tue, 26 Mar 2024 22:54:10 +0000 Subject: [PATCH 03/18] feat(settings): app time zone is America/Los_Angeles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit see https://docs.djangoproject.com/en/5.0/ref/settings/#std-setting-TIME_ZONE > Note that this isn’t necessarily the time zone of the server. > When USE_TZ is True, this is the default time zone that Django will use to display datetimes in templates > and to interpret datetimes entered in forms. --- benefits/settings.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/benefits/settings.py b/benefits/settings.py index c1ad706c0..da5f947d9 100644 --- a/benefits/settings.py +++ b/benefits/settings.py @@ -204,7 +204,11 @@ def RUNTIME_ENVIRONMENT(): USE_I18N = True -TIME_ZONE = "UTC" +# See https://docs.djangoproject.com/en/5.0/ref/settings/#std-setting-TIME_ZONE +# > Note that this isn’t necessarily the time zone of the server. +# > When USE_TZ is True, this is the default time zone that Django will use to display datetimes in templates +# > and to interpret datetimes entered in forms. +TIME_ZONE = "America/Los_Angeles" USE_TZ = True # Static files (CSS, JavaScript, Images) From 245d484d17bd072e427f83d6046b0595ae0d8fa5 Mon Sep 17 00:00:00 2001 From: Angela Tran Date: Wed, 27 Mar 2024 22:55:49 +0000 Subject: [PATCH 04/18] feat: implement expiration date calculation use `django.utils.timezone` for access to the TIME_ZONE setting. the business logic is to set the expiration date as midnight in Pacific Time of the (N+1)th day, where N is the value of `expiration_days`. --- benefits/enrollment/views.py | 11 ++++++++-- tests/pytest/enrollment/test_views.py | 29 +++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/benefits/enrollment/views.py b/benefits/enrollment/views.py index 17cd3781a..5eabcce87 100644 --- a/benefits/enrollment/views.py +++ b/benefits/enrollment/views.py @@ -3,10 +3,12 @@ """ import logging +from datetime import timedelta from django.http import JsonResponse from django.template.response import TemplateResponse from django.urls import reverse +from django.utils import timezone from django.utils.decorators import decorator_from_middleware from littlepay.api.client import Client from requests.exceptions import HTTPError @@ -186,8 +188,13 @@ def _is_within_reenrollment_window(concession_expiry, enrollment_reenrollment_da def _calculate_expiry(expiration_days): - """Returns the expiry datetime.""" - pass + """Returns the expiry datetime, which should be midnight in our configured timezone of the (N + 1)th day from now, + where N is expiration_days.""" + default_time_zone = timezone.get_default_timezone() + expiry_date = timezone.localtime(timezone=default_time_zone) + timedelta(days=expiration_days + 1) + expiry_datetime = expiry_date.replace(hour=0, minute=0, second=0, microsecond=0) + + return expiry_datetime @decorator_from_middleware(EligibleSessionRequired) diff --git a/tests/pytest/enrollment/test_views.py b/tests/pytest/enrollment/test_views.py index 2bad76a23..f8badf56d 100644 --- a/tests/pytest/enrollment/test_views.py +++ b/tests/pytest/enrollment/test_views.py @@ -1,6 +1,8 @@ +from datetime import timedelta import time from django.urls import reverse +from django.utils import timezone from littlepay.api.funding_sources import FundingSourceResponse from littlepay.api.groups import GroupFundingSourceResponse @@ -16,6 +18,7 @@ ROUTE_RETRY, TEMPLATE_SUCCESS, TEMPLATE_RETRY, + _calculate_expiry, ) import benefits.enrollment.views @@ -215,6 +218,32 @@ def test_index_eligible_post_valid_form_success( assert model_EligibilityType.group_id in mocked_analytics_module.returned_success.call_args.args +def test_calculate_expiry(): + expiration_days = 365 + + expiry_date = _calculate_expiry(expiration_days) + + assert expiry_date == ( + timezone.localtime(timezone=timezone.get_default_timezone()) + timedelta(days=expiration_days + 1) + ).replace(hour=0, minute=0, second=0, microsecond=0) + + +def test_calculate_expiry_specific_date(mocker): + expiration_days = 14 + mocker.patch( + "benefits.enrollment.views.timezone.now", + return_value=timezone.make_aware( + value=timezone.datetime(2024, 3, 1, 13, 37, 11, 5), timezone=timezone.get_fixed_timezone(offset=0) + ), + ) + + expiry_date = _calculate_expiry(expiration_days) + + assert expiry_date == timezone.make_aware( + value=timezone.datetime(2024, 3, 16, 0, 0, 0, 0), timezone=timezone.get_default_timezone() + ) + + @pytest.mark.django_db def test_index_ineligible(client): path = reverse(ROUTE_INDEX) From 6ae841650dbb85c0a22fba960b025ae8200eeab4 Mon Sep 17 00:00:00 2001 From: Angela Tran Date: Wed, 27 Mar 2024 21:57:14 +0000 Subject: [PATCH 05/18] feat: implement scenario - supports expiry and not enrolled yet --- benefits/enrollment/views.py | 5 ++++- tests/pytest/enrollment/test_views.py | 28 +++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/benefits/enrollment/views.py b/benefits/enrollment/views.py index 5eabcce87..48baa4765 100644 --- a/benefits/enrollment/views.py +++ b/benefits/enrollment/views.py @@ -103,7 +103,10 @@ def index(request): if not already_enrolled: # enroll user with an expiration date, return success - pass + client.link_concession_group_funding_source( + group_id=group_id, funding_source_id=funding_source.id, expiry_date=session.enrollment_expiry(request) + ) + return _success(request, group_id) else: if has_no_expiration_date: # update expiration of existing enrollment, return success diff --git a/tests/pytest/enrollment/test_views.py b/tests/pytest/enrollment/test_views.py index f8badf56d..c1db85660 100644 --- a/tests/pytest/enrollment/test_views.py +++ b/tests/pytest/enrollment/test_views.py @@ -244,6 +244,34 @@ def test_calculate_expiry_specific_date(mocker): ) +@pytest.mark.django_db +@pytest.mark.usefixtures("mocked_session_agency", "mocked_session_verifier", "mocked_session_eligibility") +def test_index_eligible_post_valid_form_success_supports_expiration( + mocker, + client, + card_tokenize_form_data, + mocked_analytics_module, + model_EligibilityType_supports_expiration, + mocked_funding_source, +): + mock_client_cls = mocker.patch("benefits.enrollment.views.Client") + mock_client = mock_client_cls.return_value + mock_client.get_funding_source_by_token.return_value = mocked_funding_source + + path = reverse(ROUTE_INDEX) + response = client.post(path, card_tokenize_form_data) + + mock_client.link_concession_group_funding_source.assert_called_once_with( + funding_source_id=mocked_funding_source.id, + group_id=model_EligibilityType_supports_expiration.group_id, + expiry_date=mocker.ANY, + ) + assert response.status_code == 200 + assert response.template_name == TEMPLATE_SUCCESS + mocked_analytics_module.returned_success.assert_called_once() + assert model_EligibilityType_supports_expiration.group_id in mocked_analytics_module.returned_success.call_args.args + + @pytest.mark.django_db def test_index_ineligible(client): path = reverse(ROUTE_INDEX) From 2be2891a803850fd8f7d3eacf8c06b1d81e0d298 Mon Sep 17 00:00:00 2001 From: Angela Tran Date: Wed, 27 Mar 2024 23:19:36 +0000 Subject: [PATCH 06/18] feat: implement scenario - supports expiration, but no expiration this is an unlikely but possible scenario --- benefits/enrollment/views.py | 7 +++++- tests/pytest/enrollment/test_views.py | 31 +++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/benefits/enrollment/views.py b/benefits/enrollment/views.py index 48baa4765..e9e4d6776 100644 --- a/benefits/enrollment/views.py +++ b/benefits/enrollment/views.py @@ -110,7 +110,12 @@ def index(request): else: if has_no_expiration_date: # update expiration of existing enrollment, return success - pass + client.update_concession_group_funding_source_expiry( + group_id=group_id, + funding_source_id=funding_source.id, + expiry_date=session.enrollment_expiry(request), + ) + return _success(request, group_id) else: is_expired = _is_expired(group_funding_source.concession_expiry) is_within_reenrollment_window = _is_within_reenrollment_window( diff --git a/tests/pytest/enrollment/test_views.py b/tests/pytest/enrollment/test_views.py index c1db85660..50c551125 100644 --- a/tests/pytest/enrollment/test_views.py +++ b/tests/pytest/enrollment/test_views.py @@ -272,6 +272,37 @@ def test_index_eligible_post_valid_form_success_supports_expiration( assert model_EligibilityType_supports_expiration.group_id in mocked_analytics_module.returned_success.call_args.args +@pytest.mark.django_db +@pytest.mark.usefixtures("mocked_session_agency", "mocked_session_verifier", "mocked_session_eligibility") +def test_index_eligible_post_valid_form_success_supports_expiration_no_expiration( + mocker, + client, + card_tokenize_form_data, + mocked_analytics_module, + model_EligibilityType_supports_expiration, + mocked_funding_source, + mocked_group_funding_source_no_expiration, +): + mock_client_cls = mocker.patch("benefits.enrollment.views.Client") + mock_client = mock_client_cls.return_value + mock_client.get_funding_source_by_token.return_value = mocked_funding_source + + mocker.patch("benefits.enrollment.views._get_group_funding_source", return_value=mocked_group_funding_source_no_expiration) + + path = reverse(ROUTE_INDEX) + response = client.post(path, card_tokenize_form_data) + + mock_client.update_concession_group_funding_source_expiry.assert_called_once_with( + funding_source_id=mocked_funding_source.id, + group_id=model_EligibilityType_supports_expiration.group_id, + expiry_date=mocker.ANY, + ) + assert response.status_code == 200 + assert response.template_name == TEMPLATE_SUCCESS + mocked_analytics_module.returned_success.assert_called_once() + assert model_EligibilityType_supports_expiration.group_id in mocked_analytics_module.returned_success.call_args.args + + @pytest.mark.django_db def test_index_ineligible(client): path = reverse(ROUTE_INDEX) From 1757d3b8c392991d85b7361a08e1e63e77d75bb6 Mon Sep 17 00:00:00 2001 From: Angela Tran Date: Thu, 28 Mar 2024 00:34:59 +0000 Subject: [PATCH 07/18] feat: implement scenario - supports expiration, is expired --- benefits/enrollment/views.py | 7 +++- tests/pytest/enrollment/test_views.py | 47 +++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/benefits/enrollment/views.py b/benefits/enrollment/views.py index e9e4d6776..abd7784d2 100644 --- a/benefits/enrollment/views.py +++ b/benefits/enrollment/views.py @@ -124,7 +124,12 @@ def index(request): if is_expired or is_within_reenrollment_window: # update expiration of existing enrollment, return success - pass + client.update_concession_group_funding_source_expiry( + group_id=group_id, + funding_source_id=funding_source.id, + expiry_date=session.enrollment_expiry(request), + ) + return _success(request, group_id) else: # re-enrollment error, return enrollment error with expiration and reenrollment_date pass diff --git a/tests/pytest/enrollment/test_views.py b/tests/pytest/enrollment/test_views.py index 50c551125..b82d18c4c 100644 --- a/tests/pytest/enrollment/test_views.py +++ b/tests/pytest/enrollment/test_views.py @@ -66,6 +66,17 @@ def mocked_group_funding_source_no_expiration(mocked_funding_source): ) +@pytest.fixture +def mocked_group_funding_source_with_expiration(mocked_funding_source): + return GroupFundingSourceResponse( + id=mocked_funding_source.id, + participant_id=mocked_funding_source.participant_id, + concession_expiry="2023-01-01T00:00:00Z", + concession_created_at="2021-01-01T00:00:00Z", + concession_updated_at="2021-01-01T00:00:00Z", + ) + + @pytest.mark.django_db def test_token_ineligible(client): path = reverse(ROUTE_TOKEN) @@ -303,6 +314,42 @@ def test_index_eligible_post_valid_form_success_supports_expiration_no_expiratio assert model_EligibilityType_supports_expiration.group_id in mocked_analytics_module.returned_success.call_args.args +@pytest.mark.django_db +@pytest.mark.usefixtures("mocked_session_agency", "mocked_session_verifier", "mocked_session_eligibility") +def test_index_eligible_post_valid_form_success_supports_expiration_is_expired( + mocker, + client, + card_tokenize_form_data, + mocked_analytics_module, + model_EligibilityType_supports_expiration, + mocked_funding_source, + mocked_group_funding_source_with_expiration, +): + mock_client_cls = mocker.patch("benefits.enrollment.views.Client") + mock_client = mock_client_cls.return_value + mock_client.get_funding_source_by_token.return_value = mocked_funding_source + + # mock that a funding source already exists, doesn't matter what concession_expiry is + mocker.patch( + "benefits.enrollment.views._get_group_funding_source", return_value=mocked_group_funding_source_with_expiration + ) + + mocker.patch("benefits.enrollment.views._is_expired", return_value=True) + + path = reverse(ROUTE_INDEX) + response = client.post(path, card_tokenize_form_data) + + mock_client.update_concession_group_funding_source_expiry.assert_called_once_with( + funding_source_id=mocked_funding_source.id, + group_id=model_EligibilityType_supports_expiration.group_id, + expiry_date=mocker.ANY, + ) + assert response.status_code == 200 + assert response.template_name == TEMPLATE_SUCCESS + mocked_analytics_module.returned_success.assert_called_once() + assert model_EligibilityType_supports_expiration.group_id in mocked_analytics_module.returned_success.call_args.args + + @pytest.mark.django_db def test_index_ineligible(client): path = reverse(ROUTE_INDEX) From df49eea22411e36be366570f226b4ac831dd78bb Mon Sep 17 00:00:00 2001 From: Angela Tran Date: Thu, 28 Mar 2024 00:50:51 +0000 Subject: [PATCH 08/18] feat: implement helper function to indicate expiration --- benefits/enrollment/views.py | 2 +- tests/pytest/enrollment/test_views.py | 37 +++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/benefits/enrollment/views.py b/benefits/enrollment/views.py index abd7784d2..acc285125 100644 --- a/benefits/enrollment/views.py +++ b/benefits/enrollment/views.py @@ -192,7 +192,7 @@ def _get_group_funding_source(client: Client, group_id, funding_source_id): def _is_expired(concession_expiry): """Returns whether the passed in datetime is expired or not.""" - pass + return concession_expiry <= timezone.now() def _is_within_reenrollment_window(concession_expiry, enrollment_reenrollment_date): diff --git a/tests/pytest/enrollment/test_views.py b/tests/pytest/enrollment/test_views.py index b82d18c4c..c634343d5 100644 --- a/tests/pytest/enrollment/test_views.py +++ b/tests/pytest/enrollment/test_views.py @@ -19,6 +19,7 @@ TEMPLATE_SUCCESS, TEMPLATE_RETRY, _calculate_expiry, + _is_expired, ) import benefits.enrollment.views @@ -314,6 +315,42 @@ def test_index_eligible_post_valid_form_success_supports_expiration_no_expiratio assert model_EligibilityType_supports_expiration.group_id in mocked_analytics_module.returned_success.call_args.args +def test_is_expired_concession_expiry_is_in_the_past(mocker): + concession_expiry = timezone.make_aware(timezone.datetime(2023, 12, 31), timezone.get_default_timezone()) + + # mock datetime of "now" to be specific date for testing + mocker.patch( + "benefits.enrollment.views.timezone.now", + return_value=timezone.make_aware(timezone.datetime(2024, 1, 1, 10, 30), timezone.get_default_timezone()), + ) + + assert _is_expired(concession_expiry) + + +def test_is_expired_concession_expiry_is_in_the_future(mocker): + concession_expiry = timezone.make_aware(timezone.datetime(2024, 1, 1, 17, 34), timezone.get_default_timezone()) + + # mock datetime of "now" to be specific date for testing + mocker.patch( + "benefits.enrollment.views.timezone.now", + return_value=timezone.make_aware(timezone.datetime(2024, 1, 1, 11, 5), timezone.get_default_timezone()), + ) + + assert not _is_expired(concession_expiry) + + +def test_is_expired_concession_expiry_equals_now(mocker): + concession_expiry = timezone.make_aware(timezone.datetime(2024, 1, 1, 13, 37), timezone.get_default_timezone()) + + # mock datetime of "now" to be specific date for testing + mocker.patch( + "benefits.enrollment.views.timezone.now", + return_value=timezone.make_aware(timezone.datetime(2024, 1, 1, 13, 37), timezone.get_default_timezone()), + ) + + assert _is_expired(concession_expiry) + + @pytest.mark.django_db @pytest.mark.usefixtures("mocked_session_agency", "mocked_session_verifier", "mocked_session_eligibility") def test_index_eligible_post_valid_form_success_supports_expiration_is_expired( From d43a265285f23860ddeace4cb95fd951aad108fe Mon Sep 17 00:00:00 2001 From: Angela Tran Date: Thu, 28 Mar 2024 17:50:06 +0000 Subject: [PATCH 09/18] feat: implement helper function to indicate being within reenrollment also add view test for this scenario --- benefits/enrollment/views.py | 4 +- tests/pytest/enrollment/test_views.py | 112 ++++++++++++++++++++++++++ 2 files changed, 114 insertions(+), 2 deletions(-) diff --git a/benefits/enrollment/views.py b/benefits/enrollment/views.py index acc285125..e3df43fcb 100644 --- a/benefits/enrollment/views.py +++ b/benefits/enrollment/views.py @@ -196,8 +196,8 @@ def _is_expired(concession_expiry): def _is_within_reenrollment_window(concession_expiry, enrollment_reenrollment_date): - """Returns whether the passed in datetime is within the reenrollment window.""" - pass + """Returns if we are currently within the reenrollment window.""" + return enrollment_reenrollment_date <= timezone.now() < concession_expiry def _calculate_expiry(expiration_days): diff --git a/tests/pytest/enrollment/test_views.py b/tests/pytest/enrollment/test_views.py index c634343d5..236efc41e 100644 --- a/tests/pytest/enrollment/test_views.py +++ b/tests/pytest/enrollment/test_views.py @@ -20,6 +20,7 @@ TEMPLATE_RETRY, _calculate_expiry, _is_expired, + _is_within_reenrollment_window, ) import benefits.enrollment.views @@ -387,6 +388,117 @@ def test_index_eligible_post_valid_form_success_supports_expiration_is_expired( assert model_EligibilityType_supports_expiration.group_id in mocked_analytics_module.returned_success.call_args.args +def test_is_within_enrollment_window_True(mocker): + enrollment_reenrollment_date = timezone.make_aware(timezone.datetime(2023, 2, 1), timezone=timezone.get_default_timezone()) + concession_expiry = timezone.make_aware(timezone.datetime(2023, 3, 1), timezone=timezone.get_default_timezone()) + + # mock datetime of "now" to be specific date for testing + mocker.patch( + "benefits.enrollment.views.timezone.now", + return_value=timezone.make_aware(timezone.datetime(2023, 2, 15, 15, 30), timezone=timezone.get_default_timezone()), + ) + + is_within_reenrollment_window = _is_within_reenrollment_window(concession_expiry, enrollment_reenrollment_date) + + assert is_within_reenrollment_window + + +def test_is_within_enrollment_window_before_window(mocker): + enrollment_reenrollment_date = timezone.make_aware(timezone.datetime(2023, 2, 1), timezone=timezone.get_default_timezone()) + concession_expiry = timezone.make_aware(timezone.datetime(2023, 3, 1), timezone=timezone.get_default_timezone()) + + # mock datetime of "now" to be specific date for testing + mocker.patch( + "benefits.enrollment.views.timezone.now", + return_value=timezone.make_aware(timezone.datetime(2023, 1, 15, 15, 30), timezone=timezone.get_default_timezone()), + ) + + is_within_reenrollment_window = _is_within_reenrollment_window(concession_expiry, enrollment_reenrollment_date) + + assert not is_within_reenrollment_window + + +def test_is_within_enrollment_window_after_window(mocker): + enrollment_reenrollment_date = timezone.make_aware(timezone.datetime(2023, 2, 1), timezone=timezone.get_default_timezone()) + concession_expiry = timezone.make_aware(timezone.datetime(2023, 3, 1), timezone=timezone.get_default_timezone()) + + # mock datetime of "now" to be specific date for testing + mocker.patch( + "benefits.enrollment.views.timezone.now", + return_value=timezone.make_aware(timezone.datetime(2023, 3, 15, 15, 30), timezone=timezone.get_default_timezone()), + ) + + is_within_reenrollment_window = _is_within_reenrollment_window(concession_expiry, enrollment_reenrollment_date) + + assert not is_within_reenrollment_window + + +def test_is_within_enrollment_window_equal_reenrollment_date(mocker): + enrollment_reenrollment_date = timezone.make_aware(timezone.datetime(2023, 2, 1), timezone=timezone.get_default_timezone()) + concession_expiry = timezone.make_aware(timezone.datetime(2023, 3, 1), timezone=timezone.get_default_timezone()) + + # mock datetime of "now" to be specific date for testing + mocker.patch( + "benefits.enrollment.views.timezone.now", + return_value=enrollment_reenrollment_date, + ) + + is_within_reenrollment_window = _is_within_reenrollment_window(concession_expiry, enrollment_reenrollment_date) + + assert is_within_reenrollment_window + + +def test_is_within_enrollment_window_equal_concession_expiry(mocker): + enrollment_reenrollment_date = timezone.make_aware(timezone.datetime(2023, 2, 1), timezone=timezone.get_default_timezone()) + concession_expiry = timezone.make_aware(timezone.datetime(2023, 3, 1), timezone=timezone.get_default_timezone()) + + # mock datetime of "now" to be specific date for testing + mocker.patch( + "benefits.enrollment.views.timezone.now", + return_value=concession_expiry, + ) + + is_within_reenrollment_window = _is_within_reenrollment_window(concession_expiry, enrollment_reenrollment_date) + + assert not is_within_reenrollment_window + + +@pytest.mark.django_db +@pytest.mark.usefixtures("mocked_session_agency", "mocked_session_verifier", "mocked_session_eligibility") +def test_index_eligible_post_valid_form_success_supports_expiration_is_within_reenrollment_window( + mocker, + client, + card_tokenize_form_data, + mocked_analytics_module, + model_EligibilityType_supports_expiration, + mocked_funding_source, + mocked_group_funding_source_with_expiration, +): + mock_client_cls = mocker.patch("benefits.enrollment.views.Client") + mock_client = mock_client_cls.return_value + mock_client.get_funding_source_by_token.return_value = mocked_funding_source + + # mock that a funding source already exists, doesn't matter what concession_expiry is + mocker.patch( + "benefits.enrollment.views._get_group_funding_source", return_value=mocked_group_funding_source_with_expiration + ) + + mocker.patch("benefits.enrollment.views._is_within_reenrollment_window", return_value=True) + + path = reverse(ROUTE_INDEX) + response = client.post(path, card_tokenize_form_data) + + mock_client.update_concession_group_funding_source_expiry.assert_called_once_with( + funding_source_id=mocked_funding_source.id, + group_id=model_EligibilityType_supports_expiration.group_id, + expiry_date=mocker.ANY, + ) + assert response.status_code == 200 + assert response.template_name == TEMPLATE_SUCCESS + mocked_analytics_module.returned_success.assert_called_once() + assert model_EligibilityType_supports_expiration.group_id in mocked_analytics_module.returned_success.call_args.args + + @pytest.mark.django_db def test_index_ineligible(client): path = reverse(ROUTE_INDEX) From 4fa50a7cbec41df96a650dc53803267bcce545ad Mon Sep 17 00:00:00 2001 From: Angela Tran Date: Thu, 28 Mar 2024 18:39:22 +0000 Subject: [PATCH 10/18] chore: raise error for a scenario that cannot be implemented yet we want to remove the expiration date for the scenario of expiration is not supported and yet there is an expiration date. this functionality has not been implemented in cal-itp/littlepay, nor have we confirmed that it is possible --- benefits/enrollment/views.py | 2 +- tests/pytest/enrollment/test_views.py | 37 +++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/benefits/enrollment/views.py b/benefits/enrollment/views.py index e3df43fcb..8047d65ac 100644 --- a/benefits/enrollment/views.py +++ b/benefits/enrollment/views.py @@ -144,7 +144,7 @@ def index(request): return _success(request, group_id) else: # remove expiration date, return success - pass + raise NotImplementedError("Removing expiration date is currently not supported") except HTTPError as e: analytics.returned_error(request, str(e)) diff --git a/tests/pytest/enrollment/test_views.py b/tests/pytest/enrollment/test_views.py index 236efc41e..f3ccd6937 100644 --- a/tests/pytest/enrollment/test_views.py +++ b/tests/pytest/enrollment/test_views.py @@ -499,6 +499,43 @@ def test_index_eligible_post_valid_form_success_supports_expiration_is_within_re assert model_EligibilityType_supports_expiration.group_id in mocked_analytics_module.returned_success.call_args.args +@pytest.mark.django_db +@pytest.mark.usefixtures("mocked_session_agency", "mocked_session_verifier", "mocked_session_eligibility") +def test_index_eligible_post_valid_form_success_does_not_support_expiration_has_expiration_date( + mocker, + client, + card_tokenize_form_data, + mocked_analytics_module, + model_EligibilityType, + mocked_funding_source, + mocked_group_funding_source_with_expiration, +): + mock_client_cls = mocker.patch("benefits.enrollment.views.Client") + mock_client = mock_client_cls.return_value + mock_client.get_funding_source_by_token.return_value = mocked_funding_source + + # mock that a funding source already exists, doesn't matter what concession_expiry is + mocker.patch( + "benefits.enrollment.views._get_group_funding_source", return_value=mocked_group_funding_source_with_expiration + ) + + path = reverse(ROUTE_INDEX) + with pytest.raises(NotImplementedError): + client.post(path, card_tokenize_form_data) + + # this is what we would assert if removing expiration were supported + # + # mock_client.link_concession_group_funding_source.assert_called_once_with( + # funding_source_id=mocked_funding_source.id, + # group_id=model_EligibilityType.group_id, + # expiry_date=None, + # ) + # assert response.status_code == 200 + # assert response.template_name == TEMPLATE_SUCCESS + # mocked_analytics_module.returned_success.assert_called_once() + # assert model_EligibilityType.group_id in mocked_analytics_module.returned_success.call_args.args + + @pytest.mark.django_db def test_index_ineligible(client): path = reverse(ROUTE_INDEX) From 768e3ea92cf5d72effe485030679fa4071211f26 Mon Sep 17 00:00:00 2001 From: Angela Tran Date: Thu, 28 Mar 2024 19:26:55 +0000 Subject: [PATCH 11/18] feat: implement scenario - supports expiration, not expired yet leave view function and template for #1921 to implement --- .../enrollment/reenrollment-error.html | 0 benefits/enrollment/views.py | 9 ++++- tests/pytest/enrollment/test_views.py | 36 +++++++++++++++++++ 3 files changed, 44 insertions(+), 1 deletion(-) create mode 100644 benefits/enrollment/templates/enrollment/reenrollment-error.html diff --git a/benefits/enrollment/templates/enrollment/reenrollment-error.html b/benefits/enrollment/templates/enrollment/reenrollment-error.html new file mode 100644 index 000000000..e69de29bb diff --git a/benefits/enrollment/views.py b/benefits/enrollment/views.py index 8047d65ac..16bda30ae 100644 --- a/benefits/enrollment/views.py +++ b/benefits/enrollment/views.py @@ -28,6 +28,7 @@ ROUTE_SUCCESS = "enrollment:success" ROUTE_TOKEN = "enrollment:token" +TEMPLATE_REENROLLMENT_ERROR = "enrollment/reenrollment-error.html" TEMPLATE_RETRY = "enrollment/retry.html" TEMPLATE_SUCCESS = "enrollment/success.html" @@ -132,7 +133,7 @@ def index(request): return _success(request, group_id) else: # re-enrollment error, return enrollment error with expiration and reenrollment_date - pass + return reenrollment_error(request) else: # eligibility does not support expiration if not already_enrolled: # enroll user with no expiration date, return success @@ -210,6 +211,12 @@ def _calculate_expiry(expiration_days): return expiry_datetime +def reenrollment_error(request): + """View handler for a re-enrollment attempt that is not yet within the re-enrollment window.""" + analytics.returned_error(request, "Re-enrollment error") + return TemplateResponse(request, TEMPLATE_REENROLLMENT_ERROR) + + @decorator_from_middleware(EligibleSessionRequired) def retry(request): """View handler for a recoverable failure condition.""" diff --git a/tests/pytest/enrollment/test_views.py b/tests/pytest/enrollment/test_views.py index f3ccd6937..23cf531f8 100644 --- a/tests/pytest/enrollment/test_views.py +++ b/tests/pytest/enrollment/test_views.py @@ -16,6 +16,7 @@ ROUTE_TOKEN, ROUTE_SUCCESS, ROUTE_RETRY, + TEMPLATE_REENROLLMENT_ERROR, TEMPLATE_SUCCESS, TEMPLATE_RETRY, _calculate_expiry, @@ -499,6 +500,41 @@ def test_index_eligible_post_valid_form_success_supports_expiration_is_within_re assert model_EligibilityType_supports_expiration.group_id in mocked_analytics_module.returned_success.call_args.args +@pytest.mark.django_db +@pytest.mark.usefixtures( + "mocked_session_agency", + "mocked_session_verifier", + "mocked_session_eligibility", + "model_EligibilityType_supports_expiration", +) +def test_index_eligible_post_valid_form_success_supports_expiration_is_not_expired_yet( + mocker, + client, + card_tokenize_form_data, + mocked_analytics_module, + mocked_funding_source, + mocked_group_funding_source_with_expiration, +): + mock_client_cls = mocker.patch("benefits.enrollment.views.Client") + mock_client = mock_client_cls.return_value + mock_client.get_funding_source_by_token.return_value = mocked_funding_source + + # mock that a funding source already exists, doesn't matter what concession_expiry is + mocker.patch( + "benefits.enrollment.views._get_group_funding_source", return_value=mocked_group_funding_source_with_expiration + ) + + mocker.patch("benefits.enrollment.views._is_expired", return_value=False) + mocker.patch("benefits.enrollment.views._is_within_reenrollment_window", return_value=False) + + path = reverse(ROUTE_INDEX) + response = client.post(path, card_tokenize_form_data) + + assert response.status_code == 200 + assert response.template_name == TEMPLATE_REENROLLMENT_ERROR + mocked_analytics_module.returned_error.assert_called_once() + + @pytest.mark.django_db @pytest.mark.usefixtures("mocked_session_agency", "mocked_session_verifier", "mocked_session_eligibility") def test_index_eligible_post_valid_form_success_does_not_support_expiration_has_expiration_date( From 945e4c021a0e12de6d5ae9a469522ee1fcfe2e7a Mon Sep 17 00:00:00 2001 From: Angela Tran Date: Thu, 28 Mar 2024 19:42:16 +0000 Subject: [PATCH 12/18] test: add coverage for function that checks for existing enrollment --- tests/pytest/enrollment/test_views.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/pytest/enrollment/test_views.py b/tests/pytest/enrollment/test_views.py index 23cf531f8..33f5606ba 100644 --- a/tests/pytest/enrollment/test_views.py +++ b/tests/pytest/enrollment/test_views.py @@ -19,6 +19,7 @@ TEMPLATE_REENROLLMENT_ERROR, TEMPLATE_SUCCESS, TEMPLATE_RETRY, + _get_group_funding_source, _calculate_expiry, _is_expired, _is_within_reenrollment_window, @@ -185,6 +186,30 @@ def test_index_eligible_post_valid_form_failure(mocker, client, card_tokenize_fo client.post(path, card_tokenize_form_data) +@pytest.mark.django_db +@pytest.mark.usefixtures("model_EligibilityType") +def test_get_group_funding_sources_funding_source_not_enrolled_yet(mocker, mocked_funding_source): + mock_client = mocker.Mock() + mock_client.get_concession_group_linked_funding_sources.return_value = [] + + matching_group_funding_source = _get_group_funding_source(mock_client, "group123", mocked_funding_source.id) + + assert matching_group_funding_source is None + + +@pytest.mark.django_db +@pytest.mark.usefixtures("model_EligibilityType") +def test_get_group_funding_sources_funding_source_already_enrolled( + mocker, mocked_funding_source, mocked_group_funding_source_no_expiration +): + mock_client = mocker.Mock() + mock_client.get_concession_group_linked_funding_sources.return_value = [mocked_group_funding_source_no_expiration] + + matching_group_funding_source = _get_group_funding_source(mock_client, "group123", mocked_funding_source.id) + + assert matching_group_funding_source == mocked_group_funding_source_no_expiration + + @pytest.mark.django_db @pytest.mark.usefixtures("mocked_session_agency", "mocked_session_verifier", "mocked_session_eligibility") def test_index_eligible_post_valid_form_customer_already_enrolled( From 25f6d3886ca898b48e5b60f4b2e6490abaacca36 Mon Sep 17 00:00:00 2001 From: Angela Tran Date: Thu, 28 Mar 2024 20:09:06 +0000 Subject: [PATCH 13/18] test: make assertion about expiry date more specific we know that we should be using the value from session.enrollment_expiry --- tests/pytest/conftest.py | 10 ++++++++++ tests/pytest/enrollment/test_views.py | 12 ++++++++---- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/tests/pytest/conftest.py b/tests/pytest/conftest.py index c10093d6d..aea5920b3 100644 --- a/tests/pytest/conftest.py +++ b/tests/pytest/conftest.py @@ -1,6 +1,7 @@ from unittest.mock import create_autospec from django.contrib.sessions.middleware import SessionMiddleware from django.middleware.locale import LocaleMiddleware +from django.utils import timezone import pytest from pytest_socket import disable_socket @@ -256,6 +257,15 @@ def mocked_session_oauth_token(mocker): return mocker.patch("benefits.core.session.oauth_token", autospec=True, return_value="token") +@pytest.fixture +def mocked_session_enrollment_expiry(mocker): + return mocker.patch( + "benefits.core.session.enrollment_expiry", + autospec=True, + return_value=timezone.make_aware(timezone.datetime(2024, 1, 1), timezone=timezone.get_default_timezone()), + ) + + @pytest.fixture def mocked_session_verifier(mocker, model_EligibilityVerifier): return mocker.patch("benefits.core.session.verifier", autospec=True, return_value=model_EligibilityVerifier) diff --git a/tests/pytest/enrollment/test_views.py b/tests/pytest/enrollment/test_views.py index 33f5606ba..11bc8188f 100644 --- a/tests/pytest/enrollment/test_views.py +++ b/tests/pytest/enrollment/test_views.py @@ -292,6 +292,7 @@ def test_index_eligible_post_valid_form_success_supports_expiration( mocked_analytics_module, model_EligibilityType_supports_expiration, mocked_funding_source, + mocked_session_enrollment_expiry, ): mock_client_cls = mocker.patch("benefits.enrollment.views.Client") mock_client = mock_client_cls.return_value @@ -303,7 +304,7 @@ def test_index_eligible_post_valid_form_success_supports_expiration( mock_client.link_concession_group_funding_source.assert_called_once_with( funding_source_id=mocked_funding_source.id, group_id=model_EligibilityType_supports_expiration.group_id, - expiry_date=mocker.ANY, + expiry_date=mocked_session_enrollment_expiry.return_value, ) assert response.status_code == 200 assert response.template_name == TEMPLATE_SUCCESS @@ -321,6 +322,7 @@ def test_index_eligible_post_valid_form_success_supports_expiration_no_expiratio model_EligibilityType_supports_expiration, mocked_funding_source, mocked_group_funding_source_no_expiration, + mocked_session_enrollment_expiry, ): mock_client_cls = mocker.patch("benefits.enrollment.views.Client") mock_client = mock_client_cls.return_value @@ -334,7 +336,7 @@ def test_index_eligible_post_valid_form_success_supports_expiration_no_expiratio mock_client.update_concession_group_funding_source_expiry.assert_called_once_with( funding_source_id=mocked_funding_source.id, group_id=model_EligibilityType_supports_expiration.group_id, - expiry_date=mocker.ANY, + expiry_date=mocked_session_enrollment_expiry.return_value, ) assert response.status_code == 200 assert response.template_name == TEMPLATE_SUCCESS @@ -388,6 +390,7 @@ def test_index_eligible_post_valid_form_success_supports_expiration_is_expired( model_EligibilityType_supports_expiration, mocked_funding_source, mocked_group_funding_source_with_expiration, + mocked_session_enrollment_expiry, ): mock_client_cls = mocker.patch("benefits.enrollment.views.Client") mock_client = mock_client_cls.return_value @@ -406,7 +409,7 @@ def test_index_eligible_post_valid_form_success_supports_expiration_is_expired( mock_client.update_concession_group_funding_source_expiry.assert_called_once_with( funding_source_id=mocked_funding_source.id, group_id=model_EligibilityType_supports_expiration.group_id, - expiry_date=mocker.ANY, + expiry_date=mocked_session_enrollment_expiry.return_value, ) assert response.status_code == 200 assert response.template_name == TEMPLATE_SUCCESS @@ -499,6 +502,7 @@ def test_index_eligible_post_valid_form_success_supports_expiration_is_within_re model_EligibilityType_supports_expiration, mocked_funding_source, mocked_group_funding_source_with_expiration, + mocked_session_enrollment_expiry, ): mock_client_cls = mocker.patch("benefits.enrollment.views.Client") mock_client = mock_client_cls.return_value @@ -517,7 +521,7 @@ def test_index_eligible_post_valid_form_success_supports_expiration_is_within_re mock_client.update_concession_group_funding_source_expiry.assert_called_once_with( funding_source_id=mocked_funding_source.id, group_id=model_EligibilityType_supports_expiration.group_id, - expiry_date=mocker.ANY, + expiry_date=mocked_session_enrollment_expiry.return_value, ) assert response.status_code == 200 assert response.template_name == TEMPLATE_SUCCESS From 77bb1f8839ea73f915789dfd35523f19c6bec76f Mon Sep 17 00:00:00 2001 From: Angela Tran Date: Fri, 29 Mar 2024 19:05:12 +0000 Subject: [PATCH 14/18] refactor(view): remove potentially misleading variables use the specific condition that is leading to the branch of code, which namely is if the concession expiry is None or not. --- benefits/enrollment/views.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/benefits/enrollment/views.py b/benefits/enrollment/views.py index 16bda30ae..5d0826f9e 100644 --- a/benefits/enrollment/views.py +++ b/benefits/enrollment/views.py @@ -92,12 +92,10 @@ def index(request): ) already_enrolled = group_funding_source is not None - has_no_expiration_date = already_enrolled and group_funding_source.concession_expiry is None - has_expiration_date = already_enrolled and group_funding_source.concession_expiry is not None if eligibility.supports_expiration: # set expiry on session - if has_expiration_date: + if already_enrolled and group_funding_source.concession_expiry is not None: session.update(request, enrollment_expiry=group_funding_source.concession_expiry) else: session.update(request, enrollment_expiry=_calculate_expiry(eligibility.expiration_days)) @@ -108,8 +106,8 @@ def index(request): group_id=group_id, funding_source_id=funding_source.id, expiry_date=session.enrollment_expiry(request) ) return _success(request, group_id) - else: - if has_no_expiration_date: + else: # already_enrolled + if group_funding_source.concession_expiry is None: # update expiration of existing enrollment, return success client.update_concession_group_funding_source_expiry( group_id=group_id, @@ -139,8 +137,8 @@ def index(request): # enroll user with no expiration date, return success client.link_concession_group_funding_source(group_id=group_id, funding_source_id=funding_source.id) return _success(request, group_id) - else: - if has_no_expiration_date: + else: # already_enrolled + if group_funding_source.concession_expiry is None: # no action, return success return _success(request, group_id) else: From f39d9093eb5f7309f91d809856447a4c488952b5 Mon Sep 17 00:00:00 2001 From: Angela Tran Date: Fri, 29 Mar 2024 19:22:16 +0000 Subject: [PATCH 15/18] chore: make test and fixture names more explicit about no expiry --- tests/pytest/enrollment/test_views.py | 48 +++++++++++---------------- 1 file changed, 20 insertions(+), 28 deletions(-) diff --git a/tests/pytest/enrollment/test_views.py b/tests/pytest/enrollment/test_views.py index 11bc8188f..c93b91e3a 100644 --- a/tests/pytest/enrollment/test_views.py +++ b/tests/pytest/enrollment/test_views.py @@ -60,7 +60,7 @@ def mocked_funding_source(): @pytest.fixture -def mocked_group_funding_source_no_expiration(mocked_funding_source): +def mocked_group_funding_source_no_expiry(mocked_funding_source): return GroupFundingSourceResponse( id=mocked_funding_source.id, participant_id=mocked_funding_source.participant_id, @@ -71,7 +71,7 @@ def mocked_group_funding_source_no_expiration(mocked_funding_source): @pytest.fixture -def mocked_group_funding_source_with_expiration(mocked_funding_source): +def mocked_group_funding_source_with_expiry(mocked_funding_source): return GroupFundingSourceResponse( id=mocked_funding_source.id, participant_id=mocked_funding_source.participant_id, @@ -200,32 +200,32 @@ def test_get_group_funding_sources_funding_source_not_enrolled_yet(mocker, mocke @pytest.mark.django_db @pytest.mark.usefixtures("model_EligibilityType") def test_get_group_funding_sources_funding_source_already_enrolled( - mocker, mocked_funding_source, mocked_group_funding_source_no_expiration + mocker, mocked_funding_source, mocked_group_funding_source_no_expiry ): mock_client = mocker.Mock() - mock_client.get_concession_group_linked_funding_sources.return_value = [mocked_group_funding_source_no_expiration] + mock_client.get_concession_group_linked_funding_sources.return_value = [mocked_group_funding_source_no_expiry] matching_group_funding_source = _get_group_funding_source(mock_client, "group123", mocked_funding_source.id) - assert matching_group_funding_source == mocked_group_funding_source_no_expiration + assert matching_group_funding_source == mocked_group_funding_source_no_expiry @pytest.mark.django_db @pytest.mark.usefixtures("mocked_session_agency", "mocked_session_verifier", "mocked_session_eligibility") -def test_index_eligible_post_valid_form_customer_already_enrolled( +def test_index_eligible_post_valid_form_customer_already_enrolled_no_expiry( mocker, client, card_tokenize_form_data, mocked_analytics_module, model_EligibilityType, mocked_funding_source, - mocked_group_funding_source_no_expiration, + mocked_group_funding_source_no_expiry, ): mock_client_cls = mocker.patch("benefits.enrollment.views.Client") mock_client = mock_client_cls.return_value mock_client.get_funding_source_by_token.return_value = mocked_funding_source - mocker.patch("benefits.enrollment.views._get_group_funding_source", return_value=mocked_group_funding_source_no_expiration) + mocker.patch("benefits.enrollment.views._get_group_funding_source", return_value=mocked_group_funding_source_no_expiry) path = reverse(ROUTE_INDEX) response = client.post(path, card_tokenize_form_data) @@ -238,7 +238,7 @@ def test_index_eligible_post_valid_form_customer_already_enrolled( @pytest.mark.django_db @pytest.mark.usefixtures("mocked_session_agency", "mocked_session_verifier", "mocked_session_eligibility") -def test_index_eligible_post_valid_form_success( +def test_index_eligible_post_valid_form_success_no_expiry( mocker, client, card_tokenize_form_data, mocked_analytics_module, model_EligibilityType, mocked_funding_source ): mock_client_cls = mocker.patch("benefits.enrollment.views.Client") @@ -314,21 +314,21 @@ def test_index_eligible_post_valid_form_success_supports_expiration( @pytest.mark.django_db @pytest.mark.usefixtures("mocked_session_agency", "mocked_session_verifier", "mocked_session_eligibility") -def test_index_eligible_post_valid_form_success_supports_expiration_no_expiration( +def test_index_eligible_post_valid_form_success_supports_expiration_no_expiry( mocker, client, card_tokenize_form_data, mocked_analytics_module, model_EligibilityType_supports_expiration, mocked_funding_source, - mocked_group_funding_source_no_expiration, + mocked_group_funding_source_no_expiry, mocked_session_enrollment_expiry, ): mock_client_cls = mocker.patch("benefits.enrollment.views.Client") mock_client = mock_client_cls.return_value mock_client.get_funding_source_by_token.return_value = mocked_funding_source - mocker.patch("benefits.enrollment.views._get_group_funding_source", return_value=mocked_group_funding_source_no_expiration) + mocker.patch("benefits.enrollment.views._get_group_funding_source", return_value=mocked_group_funding_source_no_expiry) path = reverse(ROUTE_INDEX) response = client.post(path, card_tokenize_form_data) @@ -389,7 +389,7 @@ def test_index_eligible_post_valid_form_success_supports_expiration_is_expired( mocked_analytics_module, model_EligibilityType_supports_expiration, mocked_funding_source, - mocked_group_funding_source_with_expiration, + mocked_group_funding_source_with_expiry, mocked_session_enrollment_expiry, ): mock_client_cls = mocker.patch("benefits.enrollment.views.Client") @@ -397,9 +397,7 @@ def test_index_eligible_post_valid_form_success_supports_expiration_is_expired( mock_client.get_funding_source_by_token.return_value = mocked_funding_source # mock that a funding source already exists, doesn't matter what concession_expiry is - mocker.patch( - "benefits.enrollment.views._get_group_funding_source", return_value=mocked_group_funding_source_with_expiration - ) + mocker.patch("benefits.enrollment.views._get_group_funding_source", return_value=mocked_group_funding_source_with_expiry) mocker.patch("benefits.enrollment.views._is_expired", return_value=True) @@ -501,7 +499,7 @@ def test_index_eligible_post_valid_form_success_supports_expiration_is_within_re mocked_analytics_module, model_EligibilityType_supports_expiration, mocked_funding_source, - mocked_group_funding_source_with_expiration, + mocked_group_funding_source_with_expiry, mocked_session_enrollment_expiry, ): mock_client_cls = mocker.patch("benefits.enrollment.views.Client") @@ -509,9 +507,7 @@ def test_index_eligible_post_valid_form_success_supports_expiration_is_within_re mock_client.get_funding_source_by_token.return_value = mocked_funding_source # mock that a funding source already exists, doesn't matter what concession_expiry is - mocker.patch( - "benefits.enrollment.views._get_group_funding_source", return_value=mocked_group_funding_source_with_expiration - ) + mocker.patch("benefits.enrollment.views._get_group_funding_source", return_value=mocked_group_funding_source_with_expiry) mocker.patch("benefits.enrollment.views._is_within_reenrollment_window", return_value=True) @@ -542,16 +538,14 @@ def test_index_eligible_post_valid_form_success_supports_expiration_is_not_expir card_tokenize_form_data, mocked_analytics_module, mocked_funding_source, - mocked_group_funding_source_with_expiration, + mocked_group_funding_source_with_expiry, ): mock_client_cls = mocker.patch("benefits.enrollment.views.Client") mock_client = mock_client_cls.return_value mock_client.get_funding_source_by_token.return_value = mocked_funding_source # mock that a funding source already exists, doesn't matter what concession_expiry is - mocker.patch( - "benefits.enrollment.views._get_group_funding_source", return_value=mocked_group_funding_source_with_expiration - ) + mocker.patch("benefits.enrollment.views._get_group_funding_source", return_value=mocked_group_funding_source_with_expiry) mocker.patch("benefits.enrollment.views._is_expired", return_value=False) mocker.patch("benefits.enrollment.views._is_within_reenrollment_window", return_value=False) @@ -573,16 +567,14 @@ def test_index_eligible_post_valid_form_success_does_not_support_expiration_has_ mocked_analytics_module, model_EligibilityType, mocked_funding_source, - mocked_group_funding_source_with_expiration, + mocked_group_funding_source_with_expiry, ): mock_client_cls = mocker.patch("benefits.enrollment.views.Client") mock_client = mock_client_cls.return_value mock_client.get_funding_source_by_token.return_value = mocked_funding_source # mock that a funding source already exists, doesn't matter what concession_expiry is - mocker.patch( - "benefits.enrollment.views._get_group_funding_source", return_value=mocked_group_funding_source_with_expiration - ) + mocker.patch("benefits.enrollment.views._get_group_funding_source", return_value=mocked_group_funding_source_with_expiry) path = reverse(ROUTE_INDEX) with pytest.raises(NotImplementedError): From b9caeed6930b069fe5f9c54e6bc7d1baf7b80ec2 Mon Sep 17 00:00:00 2001 From: Angela Tran Date: Fri, 29 Mar 2024 19:31:15 +0000 Subject: [PATCH 16/18] chore: use more specific fixture for cases of not supporting expiration --- tests/pytest/enrollment/test_views.py | 31 ++++++++++++++++++--------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/tests/pytest/enrollment/test_views.py b/tests/pytest/enrollment/test_views.py index c93b91e3a..62af609f7 100644 --- a/tests/pytest/enrollment/test_views.py +++ b/tests/pytest/enrollment/test_views.py @@ -212,12 +212,12 @@ def test_get_group_funding_sources_funding_source_already_enrolled( @pytest.mark.django_db @pytest.mark.usefixtures("mocked_session_agency", "mocked_session_verifier", "mocked_session_eligibility") -def test_index_eligible_post_valid_form_customer_already_enrolled_no_expiry( +def test_index_eligible_post_valid_form_success_does_not_support_expiration_customer_already_enrolled_no_expiry( mocker, client, card_tokenize_form_data, mocked_analytics_module, - model_EligibilityType, + model_EligibilityType_does_not_support_expiration, mocked_funding_source, mocked_group_funding_source_no_expiry, ): @@ -233,13 +233,20 @@ def test_index_eligible_post_valid_form_customer_already_enrolled_no_expiry( assert response.status_code == 200 assert response.template_name == TEMPLATE_SUCCESS mocked_analytics_module.returned_success.assert_called_once() - assert model_EligibilityType.group_id in mocked_analytics_module.returned_success.call_args.args + assert ( + model_EligibilityType_does_not_support_expiration.group_id in mocked_analytics_module.returned_success.call_args.args + ) @pytest.mark.django_db @pytest.mark.usefixtures("mocked_session_agency", "mocked_session_verifier", "mocked_session_eligibility") -def test_index_eligible_post_valid_form_success_no_expiry( - mocker, client, card_tokenize_form_data, mocked_analytics_module, model_EligibilityType, mocked_funding_source +def test_index_eligible_post_valid_form_success_does_not_support_expiration_no_expiry( + mocker, + client, + card_tokenize_form_data, + mocked_analytics_module, + model_EligibilityType_does_not_support_expiration, + mocked_funding_source, ): mock_client_cls = mocker.patch("benefits.enrollment.views.Client") mock_client = mock_client_cls.return_value @@ -249,12 +256,14 @@ def test_index_eligible_post_valid_form_success_no_expiry( response = client.post(path, card_tokenize_form_data) mock_client.link_concession_group_funding_source.assert_called_once_with( - funding_source_id=mocked_funding_source.id, group_id=model_EligibilityType.group_id + funding_source_id=mocked_funding_source.id, group_id=model_EligibilityType_does_not_support_expiration.group_id ) assert response.status_code == 200 assert response.template_name == TEMPLATE_SUCCESS mocked_analytics_module.returned_success.assert_called_once() - assert model_EligibilityType.group_id in mocked_analytics_module.returned_success.call_args.args + assert ( + model_EligibilityType_does_not_support_expiration.group_id in mocked_analytics_module.returned_success.call_args.args + ) def test_calculate_expiry(): @@ -565,7 +574,7 @@ def test_index_eligible_post_valid_form_success_does_not_support_expiration_has_ client, card_tokenize_form_data, mocked_analytics_module, - model_EligibilityType, + model_EligibilityType_does_not_support_expiration, mocked_funding_source, mocked_group_funding_source_with_expiry, ): @@ -584,13 +593,15 @@ def test_index_eligible_post_valid_form_success_does_not_support_expiration_has_ # # mock_client.link_concession_group_funding_source.assert_called_once_with( # funding_source_id=mocked_funding_source.id, - # group_id=model_EligibilityType.group_id, + # group_id=model_EligibilityType_does_not_support_expiration.group_id, # expiry_date=None, # ) # assert response.status_code == 200 # assert response.template_name == TEMPLATE_SUCCESS # mocked_analytics_module.returned_success.assert_called_once() - # assert model_EligibilityType.group_id in mocked_analytics_module.returned_success.call_args.args + # assert ( + # model_EligibilityType_does_not_support_expiration.group_id in mocked_analytics_module.returned_success.call_args.args + # ) @pytest.mark.django_db From 86ea1187580e3d52a194c5603b52f1e528262f5d Mon Sep 17 00:00:00 2001 From: Angela Tran Date: Mon, 1 Apr 2024 22:23:56 +0000 Subject: [PATCH 17/18] refactor(view): remove unnecessary log statements similar to what was done in 5666973 --- benefits/enrollment/views.py | 1 - 1 file changed, 1 deletion(-) diff --git a/benefits/enrollment/views.py b/benefits/enrollment/views.py index 5d0826f9e..708edf0b4 100644 --- a/benefits/enrollment/views.py +++ b/benefits/enrollment/views.py @@ -72,7 +72,6 @@ def index(request): if not form.is_valid(): raise Exception("Invalid card token form") - logger.debug("Read tokenized card") card_token = form.cleaned_data.get("card_token") client = Client( From 8cb5e3390ebeffbdbcd8e9983cee73ac1edace7f Mon Sep 17 00:00:00 2001 From: Angela Tran Date: Mon, 1 Apr 2024 22:31:47 +0000 Subject: [PATCH 18/18] refactor(view): consolidate sending analytics event into success view this removes the need for any helper function --- benefits/enrollment/views.py | 18 ++++++++---------- tests/pytest/enrollment/test_views.py | 15 +++++++++------ 2 files changed, 17 insertions(+), 16 deletions(-) diff --git a/benefits/enrollment/views.py b/benefits/enrollment/views.py index 708edf0b4..c25bf92a8 100644 --- a/benefits/enrollment/views.py +++ b/benefits/enrollment/views.py @@ -104,7 +104,7 @@ def index(request): client.link_concession_group_funding_source( group_id=group_id, funding_source_id=funding_source.id, expiry_date=session.enrollment_expiry(request) ) - return _success(request, group_id) + return success(request) else: # already_enrolled if group_funding_source.concession_expiry is None: # update expiration of existing enrollment, return success @@ -113,7 +113,7 @@ def index(request): funding_source_id=funding_source.id, expiry_date=session.enrollment_expiry(request), ) - return _success(request, group_id) + return success(request) else: is_expired = _is_expired(group_funding_source.concession_expiry) is_within_reenrollment_window = _is_within_reenrollment_window( @@ -127,7 +127,7 @@ def index(request): funding_source_id=funding_source.id, expiry_date=session.enrollment_expiry(request), ) - return _success(request, group_id) + return success(request) else: # re-enrollment error, return enrollment error with expiration and reenrollment_date return reenrollment_error(request) @@ -135,11 +135,11 @@ def index(request): if not already_enrolled: # enroll user with no expiration date, return success client.link_concession_group_funding_source(group_id=group_id, funding_source_id=funding_source.id) - return _success(request, group_id) + return success(request) else: # already_enrolled if group_funding_source.concession_expiry is None: # no action, return success - return _success(request, group_id) + return success(request) else: # remove expiration date, return success raise NotImplementedError("Removing expiration date is currently not supported") @@ -172,11 +172,6 @@ def index(request): return TemplateResponse(request, eligibility.enrollment_index_template, context) -def _success(request, group_id): - analytics.returned_success(request, group_id) - return success(request) - - def _get_group_funding_source(client: Client, group_id, funding_source_id): group_funding_sources = client.get_concession_group_linked_funding_sources(group_id) matching_group_funding_source = None @@ -231,6 +226,7 @@ def retry(request): @pageview_decorator +@decorator_from_middleware(EligibleSessionRequired) @decorator_from_middleware(VerifierSessionRequired) def success(request): """View handler for the final success page.""" @@ -239,10 +235,12 @@ def success(request): agency = session.agency(request) verifier = session.verifier(request) + eligibility = session.eligibility(request) if session.logged_in(request) and verifier.auth_provider.supports_sign_out: # overwrite origin for a logged in user # if they click the logout button, they are taken to the new route session.update(request, origin=reverse(ROUTE_LOGGED_OUT)) + analytics.returned_success(request, eligibility.group_id) return TemplateResponse(request, agency.enrollment_success_template) diff --git a/tests/pytest/enrollment/test_views.py b/tests/pytest/enrollment/test_views.py index 62af609f7..bb5595117 100644 --- a/tests/pytest/enrollment/test_views.py +++ b/tests/pytest/enrollment/test_views.py @@ -666,8 +666,8 @@ def test_success_no_verifier(client): @pytest.mark.django_db -@pytest.mark.usefixtures("mocked_session_verifier_auth_required") -def test_success_authentication_logged_in(mocker, client, model_TransitAgency): +@pytest.mark.usefixtures("mocked_session_verifier_auth_required", "mocked_session_eligibility") +def test_success_authentication_logged_in(mocker, client, model_TransitAgency, mocked_analytics_module): mock_session = mocker.patch("benefits.enrollment.views.session") mock_session.logged_in.return_value = True mock_session.agency.return_value = model_TransitAgency @@ -678,11 +678,12 @@ def test_success_authentication_logged_in(mocker, client, model_TransitAgency): assert response.status_code == 200 assert response.template_name == TEMPLATE_SUCCESS assert {"origin": reverse(ROUTE_LOGGED_OUT)} in mock_session.update.call_args + mocked_analytics_module.returned_success.assert_called_once() @pytest.mark.django_db -@pytest.mark.usefixtures("mocked_session_verifier_auth_required") -def test_success_authentication_not_logged_in(mocker, client, model_TransitAgency): +@pytest.mark.usefixtures("mocked_session_verifier_auth_required", "mocked_session_eligibility") +def test_success_authentication_not_logged_in(mocker, client, model_TransitAgency, mocked_analytics_module): mock_session = mocker.patch("benefits.enrollment.views.session") mock_session.logged_in.return_value = False mock_session.agency.return_value = model_TransitAgency @@ -692,13 +693,15 @@ def test_success_authentication_not_logged_in(mocker, client, model_TransitAgenc assert response.status_code == 200 assert response.template_name == TEMPLATE_SUCCESS + mocked_analytics_module.returned_success.assert_called_once() @pytest.mark.django_db -@pytest.mark.usefixtures("mocked_session_agency", "mocked_session_verifier_auth_not_required") -def test_success_no_authentication(client): +@pytest.mark.usefixtures("mocked_session_agency", "mocked_session_verifier_auth_not_required", "mocked_session_eligibility") +def test_success_no_authentication(client, mocked_analytics_module): path = reverse(ROUTE_SUCCESS) response = client.get(path) assert response.status_code == 200 assert response.template_name == TEMPLATE_SUCCESS + mocked_analytics_module.returned_success.assert_called_once()