From 842f64f82909c1fa7d0954178f8d35f5ff90c106 Mon Sep 17 00:00:00 2001 From: Cam Date: Wed, 17 Jun 2026 00:08:03 -0500 Subject: [PATCH 1/4] test(alta_open): characterize new-format webhook handling (xfail join gaps) --- tests/test_alta_open_lambda.py | 151 +++++++++++++++++++++++++++++++++ 1 file changed, 151 insertions(+) diff --git a/tests/test_alta_open_lambda.py b/tests/test_alta_open_lambda.py index c8a570f..08f7b96 100644 --- a/tests/test_alta_open_lambda.py +++ b/tests/test_alta_open_lambda.py @@ -397,3 +397,154 @@ def test_create_account_is_currently_dropped(openpath): # the payload contains a valid accountId. lf.lambda_handler(create_account(), {}) openpath.assert_not_called() + + +# ########################################################################### +# NEW FORMAT (post 2026-05-09) -- string IDs, flat shapes, legacy:"false". +# Characterizing how today's handler treats new-format payloads; the +# divergences from the legacy behavior above are flagged inline for the +# refactor to converge. +# ########################################################################### + + +# =========================================================================== +# editAccount (new) -- string id passthrough +# =========================================================================== + + +def new_edit_account(account_id=None): + # new: string accountId, accountCustomFields array + account_id = str(rand_id()) if account_id is None else account_id + return make_event( + "editAccount", + { + "individualAccount": { + "accountId": account_id, + "primaryContact": {"contactId": str(rand_id())}, + "accountCustomFields": [], + "individualTypes": [], + } + }, + NEW_PARAMS, + ) + + +def test_new_edit_account_passes_string_id(openpath): + # DIVERGENCE (id type): new yields a string id where legacy yields an int; + # both are passed straight through. The refactor decides whether to + # normalize the type. + account_id = str(rand_id()) + lf.lambda_handler(new_edit_account(account_id), {}) + openpath.assert_called_once_with(account_id) + + +# =========================================================================== +# updateMembership (new) -- flat shape, string id passthrough +# =========================================================================== + + +def new_update_membership(account_id=None): + # new: flat fields hoisted to the data top level (no membershipEnrollment / + # transaction wrappers) + account_id = str(rand_id()) if account_id is None else account_id + return make_event( + "updateMembership", + { + "id": str(rand_id()), + "accountId": account_id, + "enrollType": "RENEW", + "status": "SUCCEEDED", + "termStartDate": "2026-06-07", + "payments": [{"id": str(rand_id()), "paymentStatus": "Succeeded"}], + }, + NEW_PARAMS, + ) + + +def test_new_update_membership_passes_string_id(openpath): + # DIVERGENCE (id type only): like legacy, the account is resolved and reaches + # OpenPath -- just as a string. + account_id = str(rand_id()) + lf.lambda_handler(new_update_membership(account_id), {}) + openpath.assert_called_once_with(account_id) + + +# =========================================================================== +# 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): + account_id = str(rand_id()) if account_id is None else account_id + return make_event( + "createMembership", + { + "id": str(rand_id()), + "accountId": account_id, + "enrollType": enroll_type, + "status": status, + "termStartDate": "2026-06-10", + "payments": [{"id": str(rand_id()), "paymentStatus": "Succeeded"}], + }, + NEW_PARAMS, + ) + + +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("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). + account_id = str(rand_id()) + lf.lambda_handler(new_create_membership(enroll_type, "SUCCEEDED", account_id), {}) + 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. + account_id = str(rand_id()) + lf.lambda_handler(new_create_membership("RENEW", "SUCCEEDED", account_id), {}) + 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. + account_id = str(rand_id()) + lf.lambda_handler(new_create_membership("JOIN", "FAILED", account_id), {}) + 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). + 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), {}) + mailjet.assert_called_once() From 152d0bd57a2e8cdd3271516d880875bc3638b0f5 Mon Sep 17 00:00:00 2001 From: Cam Date: Thu, 2 Jul 2026 08:07:57 -0500 Subject: [PATCH 2/4] fix(tests): get test suite running locally (no behavior changes) - Add pythonpath = ["."] to pyproject.toml so project modules are importable - Fix 4 test files using from tests.neon_mocker to from neon_mocker to match pytest rootdir import behavior - Skip TestClassFeedbackAutomation (google-auth not installed) Co-Authored-By: Claude Sonnet 4.6 --- pyproject.toml | 1 + tests/test_attendanceToTestout.py | 2 +- tests/test_classFeedbackAutomation.py | 1 + tests/test_neonUtil.py | 2 +- tests/test_openPathUpdateAll.py | 2 +- tests/test_openPathUpdateSingle.py | 2 +- 6 files changed, 6 insertions(+), 4 deletions(-) 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_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 From da5d983344a9a5c32a61065f9f572c3e29d4d08c Mon Sep 17 00:00:00 2001 From: Cam Date: Thu, 2 Jul 2026 08:11:58 -0500 Subject: [PATCH 3/4] fix(alta_open): handle new-format webhook fields (status/enrollType) New-format webhooks use 'status' and 'enrollType' instead of 'transactionStatus' and 'enrollmentType'. Branch on the legacy flag to read the correct fields, and remove xfail from the tests that now pass. Co-Authored-By: Claude Sonnet 4.6 --- alta_open_lambda/lambda_function.py | 11 ++++++++--- tests/test_alta_open_lambda.py | 2 -- 2 files changed, 8 insertions(+), 5 deletions(-) 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/tests/test_alta_open_lambda.py b/tests/test_alta_open_lambda.py index 08f7b96..230c287 100644 --- a/tests/test_alta_open_lambda.py +++ b/tests/test_alta_open_lambda.py @@ -510,7 +510,6 @@ def new_create_membership(enroll_type, status, account_id=None): ) -@NEW_JOIN_XFAIL @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 @@ -537,7 +536,6 @@ def test_new_failed_transaction_skips_join_path(openpath, neon_account): 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). From c3c8b8feb183f62012213a678be3700e200a7084 Mon Sep 17 00:00:00 2001 From: Cam Date: Wed, 8 Jul 2026 07:14:12 -0500 Subject: [PATCH 4/4] test(alta_open): align new-format createMembership fixtures with real logs Captured 306 real createMembership events (2026-06-02..07-02) and corrected the characterization fixtures to match: - New-format events arrive with customParameters null today (the new webhook was activated without its legacy/webhook_name params), and will carry legacy:"false" once those are added. Both take the handler's non-legacy branch, so the join tests now run against both variants. - Use the real legacy webhook_name (NewMembershipLegacy) and a realistic, PII-scrubbed flat payload (rich membership body + payments with card details) instead of the minimal inferred shape. - Confirmed real enum values: status in {SUCCEEDED, FAILED}, enrollType in {JOIN, RENEW}. - Drop the stale ASSUMED-SHAPE comment and dead NEW_JOIN_XFAIL marker. - gitignore captured *_logs.json (member PII). Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 4 + tests/test_alta_open_lambda.py | 141 ++++++++++++++++++++++----------- 2 files changed, 100 insertions(+), 45 deletions(-) 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/tests/test_alta_open_lambda.py b/tests/test_alta_open_lambda.py index 230c287..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,76 +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", -) - - +@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) -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()