@@ -151,13 +151,15 @@ def test_play_store_triple_resolves_via_normalized_key(self):
151151
152152
153153class 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
246249class 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
267280class 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