Skip to content
Draft
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
230 changes: 230 additions & 0 deletions tests/test_alta_open_lambda.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
- customParameters tagged ``legacy: "false"``
"""

import datetime
import json
import logging
import random
Expand All @@ -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."""
Expand Down Expand Up @@ -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()
Loading