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 9b3eaa76f..c25bf92a8 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 @@ -26,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" @@ -69,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( @@ -81,25 +83,73 @@ 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) + 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 eligibility.supports_expiration: + # set expiry on session + 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)) + + if not already_enrolled: + # enroll user with an expiration date, return success + 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) + 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, + funding_source_id=funding_source.id, + expiry_date=session.enrollment_expiry(request), + ) + return success(request) + 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 + 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) + else: + # re-enrollment error, return enrollment error with expiration and reenrollment_date + return reenrollment_error(request) + 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) + else: # already_enrolled + if group_funding_source.concession_expiry is None: + # no action, return success + return success(request) + else: + # remove expiration date, return success + raise NotImplementedError("Removing expiration date is currently not supported") + 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) - else: - analytics.returned_error(request, str(e)) - raise Exception(f"{e}: {e.response.json()}") + 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 +172,43 @@ def index(request): return TemplateResponse(request, eligibility.enrollment_index_template, context) +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 + + +def _is_expired(concession_expiry): + """Returns whether the passed in datetime is expired or not.""" + return concession_expiry <= timezone.now() + + +def _is_within_reenrollment_window(concession_expiry, enrollment_reenrollment_date): + """Returns if we are currently within the reenrollment window.""" + return enrollment_reenrollment_date <= timezone.now() < concession_expiry + + +def _calculate_expiry(expiration_days): + """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 + + +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.""" @@ -139,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.""" @@ -147,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/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) 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 c2643262c..bb5595117 100644 --- a/tests/pytest/enrollment/test_views.py +++ b/tests/pytest/enrollment/test_views.py @@ -1,8 +1,11 @@ +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 from requests import HTTPError import pytest @@ -13,8 +16,13 @@ ROUTE_TOKEN, ROUTE_SUCCESS, ROUTE_RETRY, + TEMPLATE_REENROLLMENT_ERROR, TEMPLATE_SUCCESS, TEMPLATE_RETRY, + _get_group_funding_source, + _calculate_expiry, + _is_expired, + _is_within_reenrollment_window, ) import benefits.enrollment.views @@ -51,6 +59,28 @@ def mocked_funding_source(): ) +@pytest.fixture +def mocked_group_funding_source_no_expiry(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.fixture +def mocked_group_funding_source_with_expiry(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) @@ -131,7 +161,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 @@ -157,33 +186,122 @@ 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_expiry +): + mock_client = mocker.Mock() + 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_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_does_not_support_expiration_customer_already_enrolled_no_expiry( + mocker, + client, + card_tokenize_form_data, + mocked_analytics_module, + model_EligibilityType_does_not_support_expiration, + mocked_funding_source, + 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_expiry) + + path = reverse(ROUTE_INDEX) + response = client.post(path, card_tokenize_form_data) + + assert response.status_code == 200 + assert response.template_name == TEMPLATE_SUCCESS + mocked_analytics_module.returned_success.assert_called_once() + 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_customer_already_enrolled( - 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 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) 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 + 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(): + 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 @pytest.mark.usefixtures("mocked_session_agency", "mocked_session_verifier", "mocked_session_eligibility") -def test_index_eligible_post_valid_form_success( - mocker, client, card_tokenize_form_data, mocked_analytics_module, model_EligibilityType, mocked_funding_source +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, + mocked_session_enrollment_expiry, ): mock_client_cls = mocker.patch("benefits.enrollment.views.Client") mock_client = mock_client_cls.return_value @@ -193,12 +311,297 @@ def test_index_eligible_post_valid_form_success( 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_supports_expiration.group_id, + expiry_date=mocked_session_enrollment_expiry.return_value, + ) + 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 +@pytest.mark.usefixtures("mocked_session_agency", "mocked_session_verifier", "mocked_session_eligibility") +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_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_expiry) + + 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=mocked_session_enrollment_expiry.return_value, + ) + 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 + + +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( + mocker, + client, + card_tokenize_form_data, + mocked_analytics_module, + model_EligibilityType_supports_expiration, + mocked_funding_source, + mocked_group_funding_source_with_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 + + # 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_expiry) + + 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=mocked_session_enrollment_expiry.return_value, + ) + 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 + + +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_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 + + # 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_expiry) + + 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=mocked_session_enrollment_expiry.return_value, ) 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_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_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_expiry) + + 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( + mocker, + client, + card_tokenize_form_data, + mocked_analytics_module, + model_EligibilityType_does_not_support_expiration, + mocked_funding_source, + 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_expiry) + + 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_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_does_not_support_expiration.group_id in mocked_analytics_module.returned_success.call_args.args + # ) @pytest.mark.django_db @@ -263,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 @@ -275,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 @@ -289,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()