diff --git a/tests/test_alta_open_lambda.py b/tests/test_alta_open_lambda.py index 3eb36e6..c8a570f 100644 --- a/tests/test_alta_open_lambda.py +++ b/tests/test_alta_open_lambda.py @@ -21,6 +21,7 @@ - customParameters tagged ``legacy: "false"`` """ +import datetime import json import logging import random @@ -41,6 +42,25 @@ def openpath(mocker): return mocker.patch.object(lf, "openPathUpdateSingle") +@pytest.fixture +def neon_account(mocker): + """Mock the Neon account fetch used by handle_joins. Default membershipDates + are in the past, so should_add_member is False and the Mailjet add never + fires -- these tests assert only whether the join path was entered.""" + return mocker.patch.object( + lf, + "getMemberById", + return_value={"membershipDates": {"2020-01-01": ["2020-12-31"]}}, + ) + + +@pytest.fixture +def mailjet(mocker): + """Mock the Mailjet add so handle_joins' decision is observable without + touching SSM or the network.""" + return mocker.patch.object(lf, "add_member_to_mailjet") + + def make_event(event_trigger, data, custom_parameters): """Wrap a webhook body in the Lambda Function URL envelope. Production always delivers body as a JSON string.""" @@ -167,3 +187,213 @@ def test_null_custom_parameters_does_not_warn_or_crash(openpath, caplog): lf.lambda_handler(event, {}) assert "LEGACY EVENT DETECTED" not in caplog.text openpath.assert_called_once_with(account_id) + + +# =========================================================================== +# createMembership -- join detection (enrollmentType + transactionStatus) +# =========================================================================== + + +def legacy_create_membership(enrollment_type, transaction_status, account_id=None): + # legacy: nested membershipEnrollment.enrollmentType + transaction.transactionStatus + account_id = rand_id() if account_id is None else account_id + return make_event( + "createMembership", + { + "membershipEnrollment": { + "accountId": account_id, + "membershipId": rand_id(), + "termStartDate": "2026-06-10T05:00:00.000+0000", + "enrollmentType": enrollment_type, + }, + "transaction": { + "transactionId": rand_id(), + "transactionStatus": transaction_status, + "payments": {"payment": [{"paymentId": rand_id(), "amount": 95.0}]}, + }, + }, + LEGACY_PARAMS, + ) + + +@pytest.mark.parametrize("enrollment_type", ["JOIN", "REJOIN"]) +def test_legacy_successful_join_enters_join_path(openpath, neon_account, enrollment_type): + # A successful JOIN/REJOIN runs handle_joins (which fetches the account) + # before the usual OpenPath update. + account_id = rand_id() + lf.lambda_handler(legacy_create_membership(enrollment_type, "SUCCEEDED", account_id), {}) + neon_account.assert_called_once_with(id=account_id) + openpath.assert_called_once_with(account_id) + + +def test_legacy_renew_skips_join_path(openpath, neon_account): + # RENEW is not a join, so handle_joins is never entered. + account_id = rand_id() + lf.lambda_handler(legacy_create_membership("RENEW", "SUCCEEDED", account_id), {}) + neon_account.assert_not_called() + openpath.assert_called_once_with(account_id) + + +def test_legacy_failed_transaction_skips_join_path(openpath, neon_account): + # Even a JOIN skips handle_joins when the transaction did not succeed. + account_id = rand_id() + lf.lambda_handler(legacy_create_membership("JOIN", "FAILED", account_id), {}) + neon_account.assert_not_called() + openpath.assert_called_once_with(account_id) + + +# =========================================================================== +# createMembership -- Mailjet add decision (handle_joins.should_add_member) +# =========================================================================== +# +# handle_joins compares the latest membership start date against "today" +# (America/Chicago, read live -- no frozen clock), so these membershipDates are +# built relative to the current run date. + + +def test_fresh_first_join_adds_to_mailjet(openpath, neon_account, mailjet): + # First-ever membership, starting today -> add to Mailjet. + today = datetime.datetime.now(lf.TZ).date() + neon_account.return_value = { + "membershipDates": { + today.isoformat(): [(today + datetime.timedelta(days=30)).isoformat()], + } + } + lf.lambda_handler(legacy_create_membership("JOIN", "SUCCEEDED"), {}) + mailjet.assert_called_once() + + +def test_rejoin_with_recent_membership_does_not_add_to_mailjet(openpath, neon_account, mailjet): + # Latest membership starts today, but a prior one ended < 365 days ago, so + # this is a continuation rather than a true rejoin -- no Mailjet add. + today = datetime.datetime.now(lf.TZ).date() + prior_start = today - datetime.timedelta(days=60) + prior_end = today - datetime.timedelta(days=30) + neon_account.return_value = { + "membershipDates": { + prior_start.isoformat(): [prior_end.isoformat()], + today.isoformat(): [(today + datetime.timedelta(days=30)).isoformat()], + } + } + lf.lambda_handler(legacy_create_membership("REJOIN", "SUCCEEDED"), {}) + mailjet.assert_not_called() + + +def test_rejoin_after_long_lapse_adds_to_mailjet(openpath, neon_account, mailjet): + # Prior membership ended > 365 days ago -> treated as a genuine rejoin. + today = datetime.datetime.now(lf.TZ).date() + lapsed_start = today - datetime.timedelta(days=1000) + lapsed_end = today - datetime.timedelta(days=800) + neon_account.return_value = { + "membershipDates": { + lapsed_start.isoformat(): [lapsed_end.isoformat()], + today.isoformat(): [(today + datetime.timedelta(days=30)).isoformat()], + } + } + lf.lambda_handler(legacy_create_membership("REJOIN", "SUCCEEDED"), {}) + mailjet.assert_called_once() + + +# =========================================================================== +# mergedAccount -- resolves the surviving (matched) account +# =========================================================================== +# +# Real captured shape (June 2026): string IDs and customParameters null. The +# handler pulls matchedAccountId (the account that survives the merge), not the +# merged-away mergedAccountId, and passes that string straight to OpenPath. + + +def merged_account(matched_account_id=None): + matched_account_id = str(rand_id()) if matched_account_id is None else matched_account_id + return make_event( + "mergedAccount", + { + "mergedAccountId": str(rand_id()), + "matchedAccountId": matched_account_id, + "mergeTime": "2026-06-07T20:38:57.000-05:00", + "mergedBy": "Account Match", + }, + UNKNOWN_PARAMS, + ) + + +def test_merged_account_resolves_matched_account_string_id(openpath): + matched_account_id = str(rand_id()) + lf.lambda_handler(merged_account(matched_account_id), {}) + openpath.assert_called_once_with(matched_account_id) + + +# =========================================================================== +# updateEventRegistration -- ignored (no OpenPath update) +# =========================================================================== +# +# Real captured shape (fires ~1700x/yr): a flat tickets array. The handler logs +# an "Ignoring..." line and returns without touching OpenPath. Pinned so a +# refactor keeps event registrations out of the OpenPath path. + + +def update_event_registration(account_id=None): + account_id = str(rand_id()) if account_id is None else account_id + return make_event( + "updateEventRegistration", + { + "id": str(rand_id()), + "eventId": str(rand_id()), + "registrantAccountId": account_id, + "tickets": [ + { + "attendees": [ + { + "attendeeId": rand_id(), + "accountId": account_id, + "markedAttended": False, + "registrantAccountId": account_id, + "registrationStatus": "SUCCEEDED", + } + ] + } + ], + "payments": [], + }, + UNKNOWN_PARAMS, + ) + + +def test_event_registration_is_ignored(openpath, caplog): + with caplog.at_level(logging.INFO): + lf.lambda_handler(update_event_registration(), {}) + assert "Ignoring updateEventRegistration" in caplog.text + openpath.assert_not_called() + + +# =========================================================================== +# createAccount -- currently dropped (no handler case) +# =========================================================================== +# +# Real captured shape (fires ~235x/yr): string IDs, nested individualAccount. +# There is NO match case for createAccount, so even though the payload carries a +# usable accountId the handler resolves no neon_id and returns without an +# OpenPath update. Pinned to flag this gap for the refactor. + + +def create_account(account_id=None): + account_id = str(rand_id()) if account_id is None else account_id + return make_event( + "createAccount", + { + "individualAccount": { + "accountId": account_id, + "primaryContact": {"contactId": str(rand_id())}, + "accountCustomFields": [], + "individualTypes": [], + } + }, + {"key": ""}, + ) + + +def test_create_account_is_currently_dropped(openpath): + # No case for createAccount -> no neon_id -> no OpenPath update, even though + # the payload contains a valid accountId. + lf.lambda_handler(create_account(), {}) + openpath.assert_not_called()