Skip to content

Commit 30e5f96

Browse files
committed
backend: update Superwall webhook tests for real payload shape
Mirrors the production-code fix. Test payloads now use the real Superwall envelope shape: top-level type + nested data dict with camelCase keys (originalAppUserId / productId / originalTransactionId / expirationAt in ms / store in UPPERCASE). Adds two new tests: - TestDispatch.test_anonymous_alias_returns_error: $SuperwallAlias:UUID uid is rejected (would mean identify() never called before purchase). - TestSourceDetection.test_stripe_store_defaults_to_ios: 'STRIPE' store value isn't a path we use; default to iOS labeling. Test count for the Superwall surface: 31 -> 33.
1 parent a0ae399 commit 30e5f96

2 files changed

Lines changed: 125 additions & 45 deletions

File tree

backend/tests/unit/test_superwall_e2e.py

Lines changed: 44 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -247,12 +247,20 @@ def test_initial_purchase_writes_plus_subscription(self):
247247
"""Signed initial_purchase POST → 200, sub written with plan=plus, source=superwall_ios."""
248248
resp = _post_webhook(
249249
{
250+
"object": "event",
250251
"type": "initial_purchase",
251-
"app_user_id": "uid_test",
252-
"product_id": "com.omi.app.plus_monthly",
253-
"subscription_id": "sw_sub_123",
254-
"expires_at": 1900000000,
255-
"store": "app_store",
252+
"projectId": 22416,
253+
"applicationId": 44831,
254+
"timestamp": 1900000000000,
255+
"data": {
256+
"originalAppUserId": "uid_test",
257+
"productId": "com.omi.app.plus_monthly",
258+
"originalTransactionId": "sw_sub_123",
259+
"transactionId": "txn_123",
260+
"expirationAt": 1900000000000, # ms since epoch
261+
"store": "APP_STORE",
262+
"environment": "SANDBOX",
263+
},
256264
}
257265
)
258266
assert resp.status_code == 200
@@ -266,18 +274,22 @@ def test_initial_purchase_writes_plus_subscription(self):
266274
assert sub_dict["source"] == "superwall_ios"
267275
assert sub_dict["status"] == "active"
268276
assert sub_dict["superwall_subscription_id"] == "sw_sub_123"
277+
# expirationAt ms → current_period_end seconds
269278
assert sub_dict["current_period_end"] == 1900000000
270279
assert sub_dict["cancel_at_period_end"] is False
271280

272281
def test_play_store_event_routes_android_source(self):
273282
resp = _post_webhook(
274283
{
284+
"object": "event",
275285
"type": "initial_purchase",
276-
"app_user_id": "uid_test",
277-
"product_id": "com.omi.app.lite_yearly",
278-
"subscription_id": "sw_sub_play_1",
279-
"expires_at": 1900000000,
280-
"store": "play_store",
286+
"data": {
287+
"originalAppUserId": "uid_test",
288+
"productId": "com.omi.app.lite_yearly",
289+
"originalTransactionId": "sw_sub_play_1",
290+
"expirationAt": 1900000000000,
291+
"store": "PLAY_STORE",
292+
},
281293
},
282294
svix_id="msg_e2e_play",
283295
)
@@ -297,11 +309,13 @@ def test_cancellation_then_expiration(self):
297309
resp1 = _post_webhook(
298310
{
299311
"type": "cancellation",
300-
"app_user_id": "uid_test",
301-
"product_id": "com.omi.app.plus_monthly",
302-
"subscription_id": "sw_sub_lc",
303-
"expires_at": 1900000000,
304-
"store": "app_store",
312+
"data": {
313+
"originalAppUserId": "uid_test",
314+
"productId": "com.omi.app.plus_monthly",
315+
"originalTransactionId": "sw_sub_lc",
316+
"expirationAt": 1900000000000,
317+
"store": "APP_STORE",
318+
},
305319
},
306320
svix_id="msg_e2e_cancel",
307321
)
@@ -315,11 +329,13 @@ def test_cancellation_then_expiration(self):
315329
resp2 = _post_webhook(
316330
{
317331
"type": "expiration",
318-
"app_user_id": "uid_test",
319-
"product_id": "com.omi.app.plus_monthly",
320-
"subscription_id": "sw_sub_lc",
321-
"expires_at": 1900000000,
322-
"store": "app_store",
332+
"data": {
333+
"originalAppUserId": "uid_test",
334+
"productId": "com.omi.app.plus_monthly",
335+
"originalTransactionId": "sw_sub_lc",
336+
"expirationAt": 1900000000000,
337+
"store": "APP_STORE",
338+
},
323339
},
324340
svix_id="msg_e2e_expire",
325341
)
@@ -334,7 +350,7 @@ def setup_method(self):
334350
_reset_state()
335351

336352
def test_bad_signature_rejected(self):
337-
body = {"type": "initial_purchase", "app_user_id": "uid_test"}
353+
body = {"type": "initial_purchase", "data": {"originalAppUserId": "uid_test"}}
338354
resp = _post_webhook(body, svix_id="msg_e2e_bad", svix_signature="v1,deadbeefdeadbeefdeadbeefdead==")
339355
assert resp.status_code == 401
340356
# No DB write on auth failure
@@ -349,11 +365,13 @@ def test_duplicate_svix_id_short_circuits(self):
349365
"""Second delivery of the same svix-id returns 'duplicate' and doesn't re-write."""
350366
payload = {
351367
"type": "initial_purchase",
352-
"app_user_id": "uid_test",
353-
"product_id": "com.omi.app.plus_monthly",
354-
"subscription_id": "sw_sub_dup",
355-
"expires_at": 1900000000,
356-
"store": "app_store",
368+
"data": {
369+
"originalAppUserId": "uid_test",
370+
"productId": "com.omi.app.plus_monthly",
371+
"originalTransactionId": "sw_sub_dup",
372+
"expirationAt": 1900000000000,
373+
"store": "APP_STORE",
374+
},
357375
}
358376
resp1 = _post_webhook(payload, svix_id="msg_e2e_dup")
359377
assert resp1.status_code == 200

backend/tests/unit/test_superwall_webhook.py

Lines changed: 81 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -151,13 +151,15 @@ def test_play_store_triple_resolves_via_normalized_key(self):
151151

152152

153153
class TestHandlers:
154+
# Handlers receive the inner `data` envelope from Superwall's webhook —
155+
# camelCase keys, expirationAt in milliseconds, store as APP_STORE/PLAY_STORE.
154156
def _payload(self, **overrides) -> dict:
155157
base = {
156-
'app_user_id': 'uid_test',
157-
'product_id': 'com.omi.app.lite_monthly',
158-
'subscription_id': 'sub_xyz',
159-
'expires_at': 1900000000,
160-
'store': 'app_store',
158+
'originalAppUserId': 'uid_test',
159+
'productId': 'com.omi.app.lite_monthly',
160+
'originalTransactionId': 'sub_xyz',
161+
'expirationAt': 1900000000000, # ms since epoch (Superwall's format)
162+
'store': 'APP_STORE',
161163
}
162164
base.update(overrides)
163165
return base
@@ -178,6 +180,7 @@ def test_initial_purchase_writes_active_sub(self):
178180
assert sub_dict["status"] == "active"
179181
assert sub_dict["source"] == "superwall_ios"
180182
assert sub_dict["superwall_subscription_id"] == "sub_xyz"
183+
# expirationAt was 1900000000000 ms → stored as 1900000000 seconds
181184
assert sub_dict["current_period_end"] == 1900000000
182185
assert sub_dict["cancel_at_period_end"] is False
183186

@@ -237,23 +240,33 @@ def test_product_change_overwrites_plan(self):
237240
"uid_test",
238241
PlanType.unlimited_v2,
239242
SubscriptionSource.superwall_ios,
240-
self._payload(product_id="com.omi.app.unlimited_v2_monthly"),
243+
self._payload(productId="com.omi.app.unlimited_v2_monthly"),
241244
)
242245
sub_dict = mock_update.call_args.args[1]
243246
assert sub_dict["plan"] == "unlimited_v2"
244247

245248

246249
class TestSourceDetection:
250+
"""Superwall's `store` field uses uppercase values per their webhook spec:
251+
APP_STORE / PLAY_STORE / STRIPE.
252+
"""
253+
247254
def test_play_store_event_is_android(self):
248255
from routers.superwall import _detect_source
249256

250-
assert _detect_source({"store": "play_store"}) == SubscriptionSource.superwall_android
251-
assert _detect_source({"store": "google_play"}) == SubscriptionSource.superwall_android
257+
assert _detect_source({"store": "PLAY_STORE"}) == SubscriptionSource.superwall_android
252258

253259
def test_app_store_event_is_ios(self):
254260
from routers.superwall import _detect_source
255261

256-
assert _detect_source({"store": "app_store"}) == SubscriptionSource.superwall_ios
262+
assert _detect_source({"store": "APP_STORE"}) == SubscriptionSource.superwall_ios
263+
264+
def test_stripe_store_defaults_to_ios(self):
265+
# Superwall-on-Stripe isn't a path we use; default to iOS rather than
266+
# silently mislabeling.
267+
from routers.superwall import _detect_source
268+
269+
assert _detect_source({"store": "STRIPE"}) == SubscriptionSource.superwall_ios
257270

258271
def test_missing_store_defaults_to_ios(self):
259272
from routers.superwall import _detect_source
@@ -265,37 +278,83 @@ def test_missing_store_defaults_to_ios(self):
265278

266279

267280
class TestDispatch:
281+
"""dispatch_event reads the outer Superwall envelope: top-level `type` +
282+
nested `data` dict containing originalAppUserId / productId / etc.
283+
"""
284+
268285
def test_unknown_event_type_ignored(self):
269286
from routers import superwall
270287

271-
result = superwall.dispatch_event("unsupported_event", {"app_user_id": "uid_x"})
288+
result = superwall.dispatch_event(
289+
"unsupported_event",
290+
{"type": "unsupported_event", "data": {"originalAppUserId": "uid_x"}},
291+
)
272292
assert result == "ignored"
273293

274294
def test_missing_app_user_id_returns_error(self):
275295
from routers import superwall
276296

277-
result = superwall.dispatch_event("initial_purchase", {"product_id": "com.omi.app.lite_monthly"})
297+
result = superwall.dispatch_event(
298+
"initial_purchase",
299+
{"type": "initial_purchase", "data": {"productId": "com.omi.app.lite_monthly"}},
300+
)
301+
assert result == "missing_uid"
302+
303+
def test_anonymous_alias_returns_error(self):
304+
"""If the user purchased before identify() was called, Superwall sends
305+
the SDK's anonymous alias ($SuperwallAlias:UUID). We can't reconcile
306+
that to an omi user — reject so svix stops retrying.
307+
"""
308+
from routers import superwall
309+
310+
result = superwall.dispatch_event(
311+
"initial_purchase",
312+
{
313+
"type": "initial_purchase",
314+
"data": {
315+
"originalAppUserId": "$SuperwallAlias:7152E89E-60A6-4B2E-9C67-D7ED8F5BE372",
316+
"productId": "com.omi.app.lite_monthly",
317+
},
318+
},
319+
)
278320
assert result == "missing_uid"
279321

280322
def test_unknown_product_returns_error(self):
281323
from routers import superwall
282324

283325
with patch.object(superwall, "get_superwall_product_map", return_value={}):
284326
result = superwall.dispatch_event(
285-
"initial_purchase", {"app_user_id": "uid_x", "product_id": "com.omi.app.unknown"}
327+
"initial_purchase",
328+
{
329+
"type": "initial_purchase",
330+
"data": {
331+
"originalAppUserId": "uid_x",
332+
"productId": "com.omi.app.unknown",
333+
},
334+
},
286335
)
287336
assert result == "unknown_product"
288337

289338
def test_full_initial_purchase_dispatch(self):
290339
from routers import superwall
291340

341+
# Real Superwall envelope: top-level routing fields + nested data dict.
292342
payload = {
293-
'type': 'initial_purchase',
294-
'app_user_id': 'uid_z',
295-
'product_id': 'com.omi.app.lite_monthly',
296-
'subscription_id': 'sub_z',
297-
'expires_at': 1900000000,
298-
'store': 'app_store',
343+
"object": "event",
344+
"type": "initial_purchase",
345+
"projectId": 22416,
346+
"applicationId": 44831,
347+
"timestamp": 1900000000000,
348+
"data": {
349+
"originalAppUserId": "uid_z",
350+
"productId": "com.omi.app.lite_monthly",
351+
"originalTransactionId": "sub_z",
352+
"transactionId": "txn_z",
353+
"expirationAt": 1900000000000, # ms
354+
"store": "APP_STORE",
355+
"environment": "SANDBOX",
356+
"isTrialConversion": False,
357+
},
299358
}
300359
with (
301360
patch.object(superwall, "get_superwall_product_map", return_value={"com.omi.app.lite_monthly": "lite"}),
@@ -304,6 +363,9 @@ def test_full_initial_purchase_dispatch(self):
304363
):
305364
assert superwall.dispatch_event("initial_purchase", payload) == "processed"
306365
assert mock_update.called
307-
sub_dict = mock_update.call_args.args[1]
366+
called_uid, sub_dict = mock_update.call_args.args
367+
assert called_uid == "uid_z"
308368
assert sub_dict["plan"] == "lite"
309369
assert sub_dict["source"] == "superwall_ios"
370+
assert sub_dict["superwall_subscription_id"] == "sub_z"
371+
assert sub_dict["current_period_end"] == 1900000000 # ms → s

0 commit comments

Comments
 (0)