Skip to content

Commit 457cdbd

Browse files
authored
Characterization tests for alta_open_lambda webhook handler (#100)
* Initial tests for alta_open_lambda With the upcoming Neon webhook deprecation, we need for the alta-open-update lambda to be able to handle both the legacy and the new webhook format. Roughly, this is what the deprecation strategy looks like: * Enable new Neon webhooks (already done) * Update lambda to work on both styles of payload (new or legacy) * Monitor results, ensure that there are no errors and that the new webhooks have the correct behavior. * Disable legacy webhooks (one at a time) in Neon. * Monitor results, ensure that operations are happening as expected (accounts are actually being created, etc.) * Update lambda to throw an error for legacy webhook calls, without updating Alta. * (Eventually) Remove code for handling legacy Neon webhooks. To help with the first step of updating the lambda, I want to write a test suite to capture the current behavior. The point of this isn't to catch any new bugs, just to verify what's already happening in the lambda. Once we can show what behavior is currently happening under the legacy webhooks, we can start adding in the new webhook formats and ensuring that the tests work with those, too, using the `legacy` flag if needed. * Add tests for editAccount and updateMembership Adding some basic automated tests for the editAccount and updateMembership (legacy) Neon webhooks. These tests just create an event in the correct format and verify that Alta Open was called with the correct account ID. I may expand these out later, but the webhook changes are mostly around the way data is structured or named, so rough smoke tests should be sufficient to show that we can parse the new format. * Add test cases based on real logs This diff adds test cases based on real logs found from the alta-open-update lambda. Two interestings things to point out: * We have a case for createMembership, but not createAccount. I wonder if that might cause problems when someone tries to register a new Neon account? * deleteMembership hasn't been called in over a year. Not sure if that's expected (do we ever want to delete someone's account, even if they're no longer an active member?), but just found it interesting. Co-written with Claude AI.
1 parent c92fd55 commit 457cdbd

1 file changed

Lines changed: 399 additions & 0 deletions

File tree

tests/test_alta_open_lambda.py

Lines changed: 399 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,399 @@
1+
"""
2+
Characterization tests for the alta_open_lambda webhook handler.
3+
4+
These tests will pin down exactly what ``lambda_handler`` does TODAY, before the
5+
planned refactor that unifies handling of the two Neon webhook payload formats.
6+
The suite is green against the pre-refactor handler; as the refactor converges
7+
the formats, the relevant assertions get flipped one at a time.
8+
9+
Incoming event formats:
10+
------------------------------------------------------------
11+
legacy (old_webhooks.json / events.json):
12+
- integer account IDs (e.g. 9058)
13+
- nested shapes (membershipEnrollment + transaction; tickets as
14+
``{"ticket": [...]}``);
15+
- customParameters may be null or tagged ``legacy: "true"``
16+
17+
new (new_webhooks.json, post 2026-05-09):
18+
- string account IDs (e.g. "1877")
19+
- flat shapes (membership fields hoisted to the data top level; tickets as
20+
plain arrays);
21+
- customParameters tagged ``legacy: "false"``
22+
"""
23+
24+
import datetime
25+
import json
26+
import logging
27+
import random
28+
import sys
29+
from pathlib import Path
30+
31+
import pytest
32+
33+
# lambda_function lives in alta_open_lambda/, which is not a package
34+
sys.path.insert(0, str(Path(__file__).parent.parent / "alta_open_lambda"))
35+
36+
import lambda_function as lf
37+
38+
39+
@pytest.fixture
40+
def openpath(mocker):
41+
"""Mock the OpenPath update so we only exercise webhook parsing."""
42+
return mocker.patch.object(lf, "openPathUpdateSingle")
43+
44+
45+
@pytest.fixture
46+
def neon_account(mocker):
47+
"""Mock the Neon account fetch used by handle_joins. Default membershipDates
48+
are in the past, so should_add_member is False and the Mailjet add never
49+
fires -- these tests assert only whether the join path was entered."""
50+
return mocker.patch.object(
51+
lf,
52+
"getMemberById",
53+
return_value={"membershipDates": {"2020-01-01": ["2020-12-31"]}},
54+
)
55+
56+
57+
@pytest.fixture
58+
def mailjet(mocker):
59+
"""Mock the Mailjet add so handle_joins' decision is observable without
60+
touching SSM or the network."""
61+
return mocker.patch.object(lf, "add_member_to_mailjet")
62+
63+
64+
def make_event(event_trigger, data, custom_parameters):
65+
"""Wrap a webhook body in the Lambda Function URL envelope. Production
66+
always delivers body as a JSON string."""
67+
return {
68+
"body": json.dumps(
69+
{
70+
"eventTrigger": event_trigger,
71+
"eventTimestamp": "2026-06-10T12:00:00.000-05:00",
72+
"organizationId": "asmbly",
73+
"data": data,
74+
"customParameters": custom_parameters,
75+
}
76+
)
77+
}
78+
79+
80+
# customParameters variants seen in production
81+
UNKNOWN_PARAMS = None # truly-legacy events send customParameters: null
82+
LEGACY_PARAMS = {"webhook_name": "UpdateMembershipLegacy", "legacy": "true"}
83+
NEW_PARAMS = {"webhook_name": "UpdateMembership20260509", "legacy": "false"}
84+
85+
86+
def rand_id():
87+
"""A random Neon-style numeric id. The handler passes ids straight through,
88+
so the exact value is irrelevant -- randomizing makes that explicit and
89+
keeps arbitrary numbers from looking meaningful."""
90+
return random.randint(1, 9_999_999)
91+
92+
93+
# ===========================================================================
94+
# Guard clauses -- trigger-independent; these exercise the harness end to end
95+
# ===========================================================================
96+
97+
98+
def test_ignores_event_without_body(openpath):
99+
lf.lambda_handler({"requestContext": {}}, {})
100+
openpath.assert_not_called()
101+
102+
103+
def test_ignores_unknown_trigger(openpath):
104+
lf.lambda_handler(make_event("somethingElse", {"accountId": 1}, NEW_PARAMS), {})
105+
openpath.assert_not_called()
106+
107+
108+
def test_body_as_dict_currently_raises(openpath):
109+
"""Production delivers body as a JSON string; the handler always
110+
json.loads(body). A dict body (as some saved fixtures store it) raises
111+
TypeError today. Pinned so the refactor can decide whether to accept it."""
112+
event = make_event("editAccount", {"individualAccount": {"accountId": 1}}, NEW_PARAMS)
113+
event["body"] = json.loads(event["body"])
114+
with pytest.raises(TypeError):
115+
lf.lambda_handler(event, {})
116+
openpath.assert_not_called()
117+
118+
119+
# ===========================================================================
120+
# editAccount
121+
# ===========================================================================
122+
123+
124+
def legacy_edit_account(account_id=None):
125+
# legacy: integer accountId, nested individualAccount
126+
account_id = rand_id() if account_id is None else account_id
127+
return make_event(
128+
"editAccount",
129+
{
130+
"individualAccount": {
131+
"accountId": account_id,
132+
"primaryContact": {"contactId": account_id},
133+
"customFieldDataList": {"customFieldData": []},
134+
}
135+
},
136+
UNKNOWN_PARAMS,
137+
)
138+
139+
140+
def test_legacy_edit_account_passes_int_id(openpath):
141+
account_id = rand_id()
142+
lf.lambda_handler(legacy_edit_account(account_id), {})
143+
openpath.assert_called_once_with(account_id)
144+
145+
146+
# ===========================================================================
147+
# updateMembership
148+
# ===========================================================================
149+
150+
151+
def legacy_update_membership(account_id=None):
152+
# legacy: nested membershipEnrollment + transaction
153+
account_id = rand_id() if account_id is None else account_id
154+
return make_event(
155+
"updateMembership",
156+
{
157+
"membershipEnrollment": {
158+
"accountId": account_id,
159+
"membershipId": rand_id(),
160+
"enrollmentType": "RENEW",
161+
},
162+
"transaction": {
163+
"transactionId": rand_id(),
164+
"transactionStatus": "SUCCEEDED",
165+
},
166+
},
167+
LEGACY_PARAMS,
168+
)
169+
170+
171+
def test_legacy_update_membership_passes_int_id(openpath):
172+
account_id = rand_id()
173+
lf.lambda_handler(legacy_update_membership(account_id), {})
174+
openpath.assert_called_once_with(account_id)
175+
176+
177+
# ===========================================================================
178+
# Legacy-flag detection -- customParameters.legacy
179+
# ===========================================================================
180+
181+
def test_null_custom_parameters_does_not_warn_or_crash(openpath, caplog):
182+
# Truly-legacy events deliver customParameters: null. This must not warn
183+
# and must not raise (regression guard for the historical NoneType bug).
184+
account_id = rand_id()
185+
event = make_event("editAccount", {"individualAccount": {"accountId": account_id}}, UNKNOWN_PARAMS)
186+
with caplog.at_level(logging.WARNING):
187+
lf.lambda_handler(event, {})
188+
assert "LEGACY EVENT DETECTED" not in caplog.text
189+
openpath.assert_called_once_with(account_id)
190+
191+
192+
# ===========================================================================
193+
# createMembership -- join detection (enrollmentType + transactionStatus)
194+
# ===========================================================================
195+
196+
197+
def legacy_create_membership(enrollment_type, transaction_status, account_id=None):
198+
# legacy: nested membershipEnrollment.enrollmentType + transaction.transactionStatus
199+
account_id = rand_id() if account_id is None else account_id
200+
return make_event(
201+
"createMembership",
202+
{
203+
"membershipEnrollment": {
204+
"accountId": account_id,
205+
"membershipId": rand_id(),
206+
"termStartDate": "2026-06-10T05:00:00.000+0000",
207+
"enrollmentType": enrollment_type,
208+
},
209+
"transaction": {
210+
"transactionId": rand_id(),
211+
"transactionStatus": transaction_status,
212+
"payments": {"payment": [{"paymentId": rand_id(), "amount": 95.0}]},
213+
},
214+
},
215+
LEGACY_PARAMS,
216+
)
217+
218+
219+
@pytest.mark.parametrize("enrollment_type", ["JOIN", "REJOIN"])
220+
def test_legacy_successful_join_enters_join_path(openpath, neon_account, enrollment_type):
221+
# A successful JOIN/REJOIN runs handle_joins (which fetches the account)
222+
# before the usual OpenPath update.
223+
account_id = rand_id()
224+
lf.lambda_handler(legacy_create_membership(enrollment_type, "SUCCEEDED", account_id), {})
225+
neon_account.assert_called_once_with(id=account_id)
226+
openpath.assert_called_once_with(account_id)
227+
228+
229+
def test_legacy_renew_skips_join_path(openpath, neon_account):
230+
# RENEW is not a join, so handle_joins is never entered.
231+
account_id = rand_id()
232+
lf.lambda_handler(legacy_create_membership("RENEW", "SUCCEEDED", account_id), {})
233+
neon_account.assert_not_called()
234+
openpath.assert_called_once_with(account_id)
235+
236+
237+
def test_legacy_failed_transaction_skips_join_path(openpath, neon_account):
238+
# Even a JOIN skips handle_joins when the transaction did not succeed.
239+
account_id = rand_id()
240+
lf.lambda_handler(legacy_create_membership("JOIN", "FAILED", account_id), {})
241+
neon_account.assert_not_called()
242+
openpath.assert_called_once_with(account_id)
243+
244+
245+
# ===========================================================================
246+
# createMembership -- Mailjet add decision (handle_joins.should_add_member)
247+
# ===========================================================================
248+
#
249+
# handle_joins compares the latest membership start date against "today"
250+
# (America/Chicago, read live -- no frozen clock), so these membershipDates are
251+
# built relative to the current run date.
252+
253+
254+
def test_fresh_first_join_adds_to_mailjet(openpath, neon_account, mailjet):
255+
# First-ever membership, starting today -> add to Mailjet.
256+
today = datetime.datetime.now(lf.TZ).date()
257+
neon_account.return_value = {
258+
"membershipDates": {
259+
today.isoformat(): [(today + datetime.timedelta(days=30)).isoformat()],
260+
}
261+
}
262+
lf.lambda_handler(legacy_create_membership("JOIN", "SUCCEEDED"), {})
263+
mailjet.assert_called_once()
264+
265+
266+
def test_rejoin_with_recent_membership_does_not_add_to_mailjet(openpath, neon_account, mailjet):
267+
# Latest membership starts today, but a prior one ended < 365 days ago, so
268+
# this is a continuation rather than a true rejoin -- no Mailjet add.
269+
today = datetime.datetime.now(lf.TZ).date()
270+
prior_start = today - datetime.timedelta(days=60)
271+
prior_end = today - datetime.timedelta(days=30)
272+
neon_account.return_value = {
273+
"membershipDates": {
274+
prior_start.isoformat(): [prior_end.isoformat()],
275+
today.isoformat(): [(today + datetime.timedelta(days=30)).isoformat()],
276+
}
277+
}
278+
lf.lambda_handler(legacy_create_membership("REJOIN", "SUCCEEDED"), {})
279+
mailjet.assert_not_called()
280+
281+
282+
def test_rejoin_after_long_lapse_adds_to_mailjet(openpath, neon_account, mailjet):
283+
# Prior membership ended > 365 days ago -> treated as a genuine rejoin.
284+
today = datetime.datetime.now(lf.TZ).date()
285+
lapsed_start = today - datetime.timedelta(days=1000)
286+
lapsed_end = today - datetime.timedelta(days=800)
287+
neon_account.return_value = {
288+
"membershipDates": {
289+
lapsed_start.isoformat(): [lapsed_end.isoformat()],
290+
today.isoformat(): [(today + datetime.timedelta(days=30)).isoformat()],
291+
}
292+
}
293+
lf.lambda_handler(legacy_create_membership("REJOIN", "SUCCEEDED"), {})
294+
mailjet.assert_called_once()
295+
296+
297+
# ===========================================================================
298+
# mergedAccount -- resolves the surviving (matched) account
299+
# ===========================================================================
300+
#
301+
# Real captured shape (June 2026): string IDs and customParameters null. The
302+
# handler pulls matchedAccountId (the account that survives the merge), not the
303+
# merged-away mergedAccountId, and passes that string straight to OpenPath.
304+
305+
306+
def merged_account(matched_account_id=None):
307+
matched_account_id = str(rand_id()) if matched_account_id is None else matched_account_id
308+
return make_event(
309+
"mergedAccount",
310+
{
311+
"mergedAccountId": str(rand_id()),
312+
"matchedAccountId": matched_account_id,
313+
"mergeTime": "2026-06-07T20:38:57.000-05:00",
314+
"mergedBy": "Account Match",
315+
},
316+
UNKNOWN_PARAMS,
317+
)
318+
319+
320+
def test_merged_account_resolves_matched_account_string_id(openpath):
321+
matched_account_id = str(rand_id())
322+
lf.lambda_handler(merged_account(matched_account_id), {})
323+
openpath.assert_called_once_with(matched_account_id)
324+
325+
326+
# ===========================================================================
327+
# updateEventRegistration -- ignored (no OpenPath update)
328+
# ===========================================================================
329+
#
330+
# Real captured shape (fires ~1700x/yr): a flat tickets array. The handler logs
331+
# an "Ignoring..." line and returns without touching OpenPath. Pinned so a
332+
# refactor keeps event registrations out of the OpenPath path.
333+
334+
335+
def update_event_registration(account_id=None):
336+
account_id = str(rand_id()) if account_id is None else account_id
337+
return make_event(
338+
"updateEventRegistration",
339+
{
340+
"id": str(rand_id()),
341+
"eventId": str(rand_id()),
342+
"registrantAccountId": account_id,
343+
"tickets": [
344+
{
345+
"attendees": [
346+
{
347+
"attendeeId": rand_id(),
348+
"accountId": account_id,
349+
"markedAttended": False,
350+
"registrantAccountId": account_id,
351+
"registrationStatus": "SUCCEEDED",
352+
}
353+
]
354+
}
355+
],
356+
"payments": [],
357+
},
358+
UNKNOWN_PARAMS,
359+
)
360+
361+
362+
def test_event_registration_is_ignored(openpath, caplog):
363+
with caplog.at_level(logging.INFO):
364+
lf.lambda_handler(update_event_registration(), {})
365+
assert "Ignoring updateEventRegistration" in caplog.text
366+
openpath.assert_not_called()
367+
368+
369+
# ===========================================================================
370+
# createAccount -- currently dropped (no handler case)
371+
# ===========================================================================
372+
#
373+
# Real captured shape (fires ~235x/yr): string IDs, nested individualAccount.
374+
# There is NO match case for createAccount, so even though the payload carries a
375+
# usable accountId the handler resolves no neon_id and returns without an
376+
# OpenPath update. Pinned to flag this gap for the refactor.
377+
378+
379+
def create_account(account_id=None):
380+
account_id = str(rand_id()) if account_id is None else account_id
381+
return make_event(
382+
"createAccount",
383+
{
384+
"individualAccount": {
385+
"accountId": account_id,
386+
"primaryContact": {"contactId": str(rand_id())},
387+
"accountCustomFields": [],
388+
"individualTypes": [],
389+
}
390+
},
391+
{"key": ""},
392+
)
393+
394+
395+
def test_create_account_is_currently_dropped(openpath):
396+
# No case for createAccount -> no neon_id -> no OpenPath update, even though
397+
# the payload contains a valid accountId.
398+
lf.lambda_handler(create_account(), {})
399+
openpath.assert_not_called()

0 commit comments

Comments
 (0)