Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,7 @@ JupyterNotebooks/**

# coverage files
.coverage

# Captured webhook logs (contain member PII -- never commit)
create_membership_logs.json
*_logs.json
11 changes: 8 additions & 3 deletions alta_open_lambda/lambda_function.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}:
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
143 changes: 96 additions & 47 deletions tests/test_alta_open_lambda.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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():
Expand Down Expand Up @@ -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()
2 changes: 1 addition & 1 deletion tests/test_attendanceToTestout.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
1 change: 1 addition & 0 deletions tests/test_classFeedbackAutomation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()"""

Expand Down
2 changes: 1 addition & 1 deletion tests/test_neonUtil.py
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
2 changes: 1 addition & 1 deletion tests/test_openPathUpdateAll.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion tests/test_openPathUpdateSingle.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Loading