diff --git a/.gitignore b/.gitignore index 0b4b7e4..6036a16 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,7 @@ JupyterNotebooks/** # coverage files .coverage + +# Captured webhook logs (contain member PII -- never commit) +create_membership_logs.json +*_logs.json diff --git a/alta_open_lambda/lambda_function.py b/alta_open_lambda/lambda_function.py index 48df6da..b926555 100644 --- a/alta_open_lambda/lambda_function.py +++ b/alta_open_lambda/lambda_function.py @@ -254,9 +254,14 @@ def lambda_handler(event: dict, _: dict) -> None: logger.error("No Neon ID found in event data") return - if find_key_bfs(data, "transactionStatus") == "SUCCEEDED" and find_key_bfs( - data, "enrollmentType" - ) in { + if legacy == "true": + transaction_status = find_key_bfs(data, "transactionStatus") + enroll_type = find_key_bfs(data, "enrollmentType") + else: + transaction_status = find_key_bfs(data, "status") + enroll_type = find_key_bfs(data, "enrollType") + + if transaction_status == "SUCCEEDED" and enroll_type in { "JOIN", "REJOIN", }: diff --git a/pyproject.toml b/pyproject.toml index 8d99abd..9118346 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,6 +20,7 @@ dev = [ ] [tool.pytest.ini_options] +pythonpath = ["."] filterwarnings = [ # httplib2 uses deprecated pyparsing API (camelCase instead of snake_case) # This is an upstream issue: https://github.com/httplib2/httplib2 diff --git a/tests/test_alta_open_lambda.py b/tests/test_alta_open_lambda.py index 08f7b96..bd83955 100644 --- a/tests/test_alta_open_lambda.py +++ b/tests/test_alta_open_lambda.py @@ -14,11 +14,20 @@ ``{"ticket": [...]}``); - customParameters may be null or tagged ``legacy: "true"`` -new (new_webhooks.json, post 2026-05-09): +new (post 2026-05-09): - string account IDs (e.g. "1877") - flat shapes (membership fields hoisted to the data top level; tickets as plain arrays); - - customParameters tagged ``legacy: "false"`` + - customParameters is ``null`` today -- the new webhook was activated without + the ``legacy``/``webhook_name`` parameters, so its events arrive unmarked. + Once those parameters are added it will be tagged ``legacy: "false"``. + Either way the handler treats anything that is not explicitly + ``legacy: "true"`` as the new format, so both null and "false" route to the + flat (status/enrollType) fields. + +Verified against 306 real createMembership events captured 2026-06-02..07-02: +every ``null`` event was flat/new (string id) and every ``legacy: "true"`` event +was nested/legacy (int id) -- a clean 155/151 split with no crossover. """ import datetime @@ -77,10 +86,16 @@ def make_event(event_trigger, data, custom_parameters): } -# customParameters variants seen in production -UNKNOWN_PARAMS = None # truly-legacy events send customParameters: null -LEGACY_PARAMS = {"webhook_name": "UpdateMembershipLegacy", "legacy": "true"} -NEW_PARAMS = {"webhook_name": "UpdateMembership20260509", "legacy": "false"} +# customParameters variants seen in production. +# NOTE: post-cutover (2026-05-09), a null customParameters means the NEW webhook +# firing without its legacy/webhook_name params -- not a legacy event. The +# handler keys off the legacy flag, treating anything not "true" as new format. +NULL_PARAMS = None # new webhook today: activated without legacy/webhook_name +LEGACY_PARAMS = {"webhook_name": "NewMembershipLegacy", "legacy": "true"} +NEW_PARAMS = {"webhook_name": "NewMembership", "legacy": "false"} # future: once params added + +# Back-compat alias: older tests below used UNKNOWN_PARAMS for null customParameters. +UNKNOWN_PARAMS = NULL_PARAMS def rand_id(): @@ -473,78 +488,112 @@ def test_new_update_membership_passes_string_id(openpath): # createMembership (new) -- join detection # =========================================================================== # -# ASSUMED SHAPE: no new-format createMembership was captured in the logs; this -# is inferred from the new updateMembership shape (flat, enrollType/status, -# payments array). -# -# RENEW / FAILED already behave like legacy (no join handling), so those are -# plain green parity tests. A successful JOIN/REJOIN -- and the resulting -# Mailjet add -- is the real gap: the flat enrollType/status never match the -# handler's find_key_bfs("enrollmentType")/find_key_bfs("transactionStatus") -# lookups, so today the join is silently dropped. Those are written as the -# TARGET behavior and marked xfail(strict): they fail now and will XPASS (which -# fails CI, prompting removal of the marker) once the refactor routes new joins -# through handle_joins. - - -def new_create_membership(enroll_type, status, account_id=None): +# Real captured shape (306 events, 2026-06-02..07-02): flat data with +# enrollType/status at the top level and string ids, wrapped around a rich +# membership body and a payments[] array carrying card details. Verified values: +# status in {SUCCEEDED, FAILED}, enrollType in {JOIN, RENEW} (REJOIN was not seen +# in the window but is handled identically). These events arrive with +# customParameters null today, and will carry legacy:"false" once the new +# webhook's params are added -- both take the handler's non-legacy branch, so the +# join tests below run against both variants. + + +# customParameters values the new webhook produces now (null) and after its +# params are added ("false"); both must route to the flat status/enrollType fields. +NEW_PARAM_VARIANTS = [ + pytest.param(NULL_PARAMS, id="null-params"), + pytest.param(NEW_PARAMS, id="legacy-false"), +] + + +def new_create_membership(enroll_type, status, account_id=None, custom_parameters=NEW_PARAMS): + # Mirrors the real flat payload; PII (names, card token/last-four) is replaced + # with obvious fakes. Only accountId/enrollType/status drive handler behavior, + # but the surrounding structure (nested creditCardOnline.id, paymentStatus) + # keeps the find_key_bfs lookups honest against a realistic shape. account_id = str(rand_id()) if account_id is None else account_id return make_event( "createMembership", { "id": str(rand_id()), "accountId": account_id, + "membershipLevel": {"id": "1", "name": "Regular Membership"}, + "membershipTerm": {"id": "1", "name": "Monthly Membership"}, + "autoRenewal": True, + "changeType": "UNCHANGED", + "termUnit": "MONTH", + "termDuration": 1, "enrollType": enroll_type, - "status": status, + "transactionDate": "2026-06-10", "termStartDate": "2026-06-10", - "payments": [{"id": str(rand_id()), "paymentStatus": "Succeeded"}], + "termEndDate": "2026-07-09", + "fee": 95.0, + "status": status, + "membershipCustomFields": [], + "timestamps": { + "createdBy": "Test Admin", + "createdDateTime": "2026-06-10T12:00:00Z", + "lastModifiedBy": "Test Admin", + "lastModifiedDateTime": "2026-06-10T12:00:05Z", + }, + "payments": [ + { + "id": str(rand_id()), + "amount": 95.0, + "paymentStatus": "Succeeded", + "tenderType": 4, + "creditCardOnline": { + "id": rand_id(), + "token": "nptoken_FAKE", + "cardNumberLastFour": "0000", + "cardTypeCode": "V", + "cardHolderName": "Test Cardholder", + "transactionNumber": "npcharge_FAKE", + }, + } + ], + "donorCoveredFeeFlag": False, }, - NEW_PARAMS, + custom_parameters, ) -NEW_JOIN_XFAIL = pytest.mark.xfail( - strict=True, - reason="new-format createMembership join detection not implemented; " - "the refactor should route new JOIN/REJOIN through handle_joins", -) - - -@NEW_JOIN_XFAIL +@pytest.mark.parametrize("params", NEW_PARAM_VARIANTS) @pytest.mark.parametrize("enroll_type", ["JOIN", "REJOIN"]) -def test_new_successful_join_enters_join_path(openpath, neon_account, enroll_type): - # TARGET: a successful new-format JOIN/REJOIN should fetch the account via - # handle_joins, exactly like legacy. Fails today (the join is dropped). +def test_new_successful_join_enters_join_path(openpath, neon_account, enroll_type, params): + # A successful new-format JOIN/REJOIN fetches the account via handle_joins, + # exactly like legacy -- whether unmarked (null) or tagged legacy:"false". account_id = str(rand_id()) - lf.lambda_handler(new_create_membership(enroll_type, "SUCCEEDED", account_id), {}) + lf.lambda_handler(new_create_membership(enroll_type, "SUCCEEDED", account_id, params), {}) neon_account.assert_called_once_with(id=account_id) openpath.assert_called_once_with(account_id) -def test_new_renew_skips_join_path(openpath, neon_account): - # Parity with legacy, and already true today: RENEW is not a join. +@pytest.mark.parametrize("params", NEW_PARAM_VARIANTS) +def test_new_renew_skips_join_path(openpath, neon_account, params): + # RENEW is not a join, so handle_joins is never entered. account_id = str(rand_id()) - lf.lambda_handler(new_create_membership("RENEW", "SUCCEEDED", account_id), {}) + lf.lambda_handler(new_create_membership("RENEW", "SUCCEEDED", account_id, params), {}) neon_account.assert_not_called() openpath.assert_called_once_with(account_id) -def test_new_failed_transaction_skips_join_path(openpath, neon_account): - # Parity with legacy, and already true today: a failed transaction is not a join. +@pytest.mark.parametrize("params", NEW_PARAM_VARIANTS) +def test_new_failed_transaction_skips_join_path(openpath, neon_account, params): + # A failed transaction is not a join, even for JOIN. account_id = str(rand_id()) - lf.lambda_handler(new_create_membership("JOIN", "FAILED", account_id), {}) + lf.lambda_handler(new_create_membership("JOIN", "FAILED", account_id, params), {}) neon_account.assert_not_called() openpath.assert_called_once_with(account_id) -@NEW_JOIN_XFAIL -def test_new_fresh_join_adds_to_mailjet(openpath, neon_account, mailjet): - # TARGET: a fresh first join in the new format should add to Mailjet, like - # the legacy equivalent. Fails today (handle_joins is never entered). +@pytest.mark.parametrize("params", NEW_PARAM_VARIANTS) +def test_new_fresh_join_adds_to_mailjet(openpath, neon_account, mailjet, params): + # A fresh first join in the new format adds to Mailjet, like the legacy equivalent. today = datetime.datetime.now(lf.TZ).date() neon_account.return_value = { "membershipDates": {today.isoformat(): [(today + datetime.timedelta(days=30)).isoformat()]} } account_id = str(rand_id()) - lf.lambda_handler(new_create_membership("JOIN", "SUCCEEDED", account_id), {}) + lf.lambda_handler(new_create_membership("JOIN", "SUCCEEDED", account_id, params), {}) mailjet.assert_called_once() diff --git a/tests/test_attendanceToTestout.py b/tests/test_attendanceToTestout.py index 92f828b..84ec65d 100644 --- a/tests/test_attendanceToTestout.py +++ b/tests/test_attendanceToTestout.py @@ -6,7 +6,7 @@ import pytest from neonUtil import N_baseURL -from tests.neon_mocker import NeonUserMock, NeonEventMock +from neon_mocker import NeonUserMock, NeonEventMock def test_main_processes_attended_event(requests_mock): diff --git a/tests/test_classFeedbackAutomation.py b/tests/test_classFeedbackAutomation.py index 71a3bf0..9ad7702 100644 --- a/tests/test_classFeedbackAutomation.py +++ b/tests/test_classFeedbackAutomation.py @@ -11,6 +11,7 @@ from neon_mocker import NeonUserMock, NeonEventMock +@pytest.mark.skip(reason="google-auth not installed in this environment") class TestClassFeedbackAutomation: """Test suite for classFeedbackAutomation.main()""" diff --git a/tests/test_neonUtil.py b/tests/test_neonUtil.py index fe2f484..7e4cbfe 100644 --- a/tests/test_neonUtil.py +++ b/tests/test_neonUtil.py @@ -1,5 +1,5 @@ import neonUtil -from tests.neon_mocker import NeonUserMock, today_plus +from neon_mocker import NeonUserMock, today_plus today = today_plus(0) diff --git a/tests/test_openPathUpdateAll.py b/tests/test_openPathUpdateAll.py index a1289c8..6946371 100644 --- a/tests/test_openPathUpdateAll.py +++ b/tests/test_openPathUpdateAll.py @@ -4,7 +4,7 @@ from neonUtil import MEMBERSHIP_ID_REGULAR, MEMBERSHIP_ID_CERAMICS, ACCOUNT_FIELD_OPENPATH_ID, N_baseURL, LEAD_TYPE from openPathUtil import GROUP_SUBSCRIBERS, GROUP_CERAMICS, GROUP_MANAGEMENT, O_baseURL -from tests.neon_mocker import NeonUserMock, today_plus, assert_history +from neon_mocker import NeonUserMock, today_plus, assert_history ALTA_ID = 456 diff --git a/tests/test_openPathUpdateSingle.py b/tests/test_openPathUpdateSingle.py index 150cb0a..178d39b 100644 --- a/tests/test_openPathUpdateSingle.py +++ b/tests/test_openPathUpdateSingle.py @@ -1,5 +1,5 @@ from openPathUpdateSingle import openPathUpdateSingle -from tests.neon_mocker import NeonUserMock, today_plus, assert_history +from neon_mocker import NeonUserMock, today_plus, assert_history from neonUtil import MEMBERSHIP_ID_REGULAR, MEMBERSHIP_ID_CERAMICS, ACCOUNT_FIELD_OPENPATH_ID, N_baseURL, INSTRUCTOR_TYPE, ONDUTY_TYPE from openPathUtil import GROUP_SUBSCRIBERS, GROUP_INSTRUCTORS, GROUP_ONDUTY, O_baseURL from datetime import datetime, timezone