@tellescope/sdk 1.256.2 → 1.256.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/lib/cjs/tests/api_tests/metriport_push_form_response.test.d.ts +6 -0
  2. package/lib/cjs/tests/api_tests/metriport_push_form_response.test.d.ts.map +1 -0
  3. package/lib/cjs/tests/api_tests/metriport_push_form_response.test.js +298 -0
  4. package/lib/cjs/tests/api_tests/metriport_push_form_response.test.js.map +1 -0
  5. package/lib/cjs/tests/api_tests/phone_calls_move_enduser.test.d.ts +6 -0
  6. package/lib/cjs/tests/api_tests/phone_calls_move_enduser.test.d.ts.map +1 -0
  7. package/lib/cjs/tests/api_tests/phone_calls_move_enduser.test.js +274 -0
  8. package/lib/cjs/tests/api_tests/phone_calls_move_enduser.test.js.map +1 -0
  9. package/lib/cjs/tests/api_tests/stripe_webhook_triggers.test.d.ts +18 -0
  10. package/lib/cjs/tests/api_tests/stripe_webhook_triggers.test.d.ts.map +1 -0
  11. package/lib/cjs/tests/api_tests/stripe_webhook_triggers.test.js +964 -0
  12. package/lib/cjs/tests/api_tests/stripe_webhook_triggers.test.js.map +1 -0
  13. package/lib/cjs/tests/api_tests/welle_partner_token.test.d.ts +6 -0
  14. package/lib/cjs/tests/api_tests/welle_partner_token.test.d.ts.map +1 -0
  15. package/lib/cjs/tests/api_tests/welle_partner_token.test.js +313 -0
  16. package/lib/cjs/tests/api_tests/welle_partner_token.test.js.map +1 -0
  17. package/lib/cjs/tests/stripe_webhook_helpers.d.ts +204 -0
  18. package/lib/cjs/tests/stripe_webhook_helpers.d.ts.map +1 -0
  19. package/lib/cjs/tests/stripe_webhook_helpers.js +286 -0
  20. package/lib/cjs/tests/stripe_webhook_helpers.js.map +1 -0
  21. package/lib/cjs/tests/tests.d.ts.map +1 -1
  22. package/lib/cjs/tests/tests.js +192 -180
  23. package/lib/cjs/tests/tests.js.map +1 -1
  24. package/lib/cjs/tests/unit_tests/translation.test.d.ts +3 -0
  25. package/lib/cjs/tests/unit_tests/translation.test.d.ts.map +1 -0
  26. package/lib/cjs/tests/unit_tests/translation.test.js.map +1 -0
  27. package/lib/esm/enduser.d.ts +1 -0
  28. package/lib/esm/enduser.d.ts.map +1 -1
  29. package/lib/esm/sdk.d.ts +3 -2
  30. package/lib/esm/sdk.d.ts.map +1 -1
  31. package/lib/esm/session.d.ts +1 -0
  32. package/lib/esm/session.d.ts.map +1 -1
  33. package/lib/esm/tests/api_tests/metriport_push_form_response.test.d.ts +6 -0
  34. package/lib/esm/tests/api_tests/metriport_push_form_response.test.d.ts.map +1 -0
  35. package/lib/esm/tests/api_tests/metriport_push_form_response.test.js +294 -0
  36. package/lib/esm/tests/api_tests/metriport_push_form_response.test.js.map +1 -0
  37. package/lib/esm/tests/api_tests/phone_calls_move_enduser.test.d.ts +6 -0
  38. package/lib/esm/tests/api_tests/phone_calls_move_enduser.test.d.ts.map +1 -0
  39. package/lib/esm/tests/api_tests/phone_calls_move_enduser.test.js +270 -0
  40. package/lib/esm/tests/api_tests/phone_calls_move_enduser.test.js.map +1 -0
  41. package/lib/esm/tests/api_tests/stripe_webhook_triggers.test.d.ts +18 -0
  42. package/lib/esm/tests/api_tests/stripe_webhook_triggers.test.d.ts.map +1 -0
  43. package/lib/esm/tests/api_tests/stripe_webhook_triggers.test.js +960 -0
  44. package/lib/esm/tests/api_tests/stripe_webhook_triggers.test.js.map +1 -0
  45. package/lib/esm/tests/api_tests/welle_partner_token.test.d.ts +6 -0
  46. package/lib/esm/tests/api_tests/welle_partner_token.test.d.ts.map +1 -0
  47. package/lib/esm/tests/api_tests/welle_partner_token.test.js +309 -0
  48. package/lib/esm/tests/api_tests/welle_partner_token.test.js.map +1 -0
  49. package/lib/esm/tests/stripe_webhook_helpers.d.ts +204 -0
  50. package/lib/esm/tests/stripe_webhook_helpers.d.ts.map +1 -0
  51. package/lib/esm/tests/stripe_webhook_helpers.js +267 -0
  52. package/lib/esm/tests/stripe_webhook_helpers.js.map +1 -0
  53. package/lib/esm/tests/tests.d.ts.map +1 -1
  54. package/lib/esm/tests/tests.js +192 -180
  55. package/lib/esm/tests/tests.js.map +1 -1
  56. package/lib/esm/tests/unit_tests/translation.test.d.ts +3 -0
  57. package/lib/esm/tests/unit_tests/translation.test.d.ts.map +1 -0
  58. package/lib/esm/tests/unit_tests/translation.test.js +52 -0
  59. package/lib/esm/tests/unit_tests/translation.test.js.map +1 -0
  60. package/lib/tsconfig.tsbuildinfo +1 -1
  61. package/package.json +10 -10
  62. package/src/tests/api_tests/metriport_push_form_response.test.ts +226 -0
  63. package/src/tests/api_tests/stripe_webhook_triggers.test.ts +676 -0
  64. package/src/tests/api_tests/welle_partner_token.test.ts +249 -0
  65. package/src/tests/stripe_webhook_helpers.ts +240 -0
  66. package/src/tests/tests.ts +6 -0
  67. package/test_generated.pdf +0 -0
@@ -0,0 +1,676 @@
1
+ require('source-map-support').install();
2
+
3
+ import { Session } from "../../sdk"
4
+ import { assert, log_header, wait } from "@tellescope/testing"
5
+ import { setup_tests } from "../setup"
6
+ import {
7
+ cleanup_stripe_test_integrations,
8
+ invoice_payment_failed_event,
9
+ item_with_expanded_price_product,
10
+ item_with_plan_product,
11
+ item_with_price_product,
12
+ line_with_expanded_price_product,
13
+ line_with_plan_product,
14
+ line_with_price_product,
15
+ PAUSE_COLLECTION,
16
+ post_stripe_webhook,
17
+ setup_stripe_test_integration,
18
+ sign_stripe_payload,
19
+ subscription_deleted_event,
20
+ subscription_paused_event,
21
+ subscription_resumed_event,
22
+ subscription_updated_event,
23
+ } from "../stripe_webhook_helpers"
24
+
25
+ const host = process.env.API_URL || "http://localhost:8080"
26
+
27
+ /**
28
+ * End-to-end coverage of the custom Stripe webhook -> automation trigger path, driven by synthetic
29
+ * but genuinely-signed payloads (see ../stripe_webhook_helpers).
30
+ *
31
+ * Fixture rules, all forced by handle_actions_for_triggers:
32
+ * - 'Add Tags' is the action because it applies inline via $addToSet, with no worker involved.
33
+ * - Every webhook POST gets its own enduser: triggers throttle per (trigger, enduser) for 60s, and
34
+ * neither handle_payment_failed nor handle_subscription_ended opts out.
35
+ * - No journeyId (the handler skips the action entirely for journey-scoped triggers), no
36
+ * oncePerEnduser, no enduserCondition, no weeklyAvailabilities.
37
+ * - Tags are run-unique, so a stale tag can't satisfy an assertion and 'Add Tags' can't no-op.
38
+ */
39
+ export const stripe_webhook_triggers_tests = async ({ sdk, sdkNonAdmin } : { sdk: Session, sdkNonAdmin: Session }) => {
40
+ log_header("Stripe Webhook Triggers")
41
+
42
+ const RUN = `${Date.now()}`
43
+ const tag = (name: string) => `stripe-wh-${name}-${RUN}`
44
+
45
+ const createdEnduserIds: string[] = []
46
+ const createdTriggerIds: string[] = []
47
+ const createdProductIds: string[] = []
48
+
49
+ const { stripePublicKey, webhooksSecret, cleanup } = await setup_stripe_test_integration(sdk)
50
+
51
+ // Each enduser is matched by the handler on stripeCustomerId, so it must be unique per case.
52
+ const new_enduser = async (label: string) => {
53
+ const stripeCustomerId = `cus_test_${label}_${RUN}`
54
+ const enduser = await sdk.api.endusers.createOne({
55
+ fname: 'Stripe',
56
+ lname: `Webhook ${label}`,
57
+ email: `stripe-webhook-${label}-${RUN}@tellescope.com`,
58
+ // settable by a staff session, but declared readonly in the client types
59
+ stripeCustomerId,
60
+ } as any)
61
+ createdEnduserIds.push(enduser.id)
62
+
63
+ return { id: enduser.id, stripeCustomerId }
64
+ }
65
+
66
+ // `event` is loosely typed so this helper can build both trigger types, including
67
+ // configurations that only became valid once productIds was added to Subscription Payment Failed.
68
+ const new_trigger = async ({ title, event, tags } : { title: string, event: any, tags: string[] }) => {
69
+ const trigger = await sdk.api.automation_triggers.createOne({
70
+ title,
71
+ status: 'Active',
72
+ event,
73
+ action: { type: 'Add Tags', info: { tags } },
74
+ })
75
+ createdTriggerIds.push(trigger.id)
76
+
77
+ return trigger
78
+ }
79
+
80
+ // stripeSubscriptionId is the field the backend matches on, and it holds a Stripe *product* id
81
+ const new_product = async (label: string, stripeProductId: string) => {
82
+ const product = await sdk.api.products.createOne({
83
+ title: `Stripe Webhook Test ${label} ${RUN}`, // products.title is unique
84
+ cost: { amount: 1999, currency: 'USD' },
85
+ processor: 'Stripe',
86
+ stripeSubscriptionId: stripeProductId,
87
+ })
88
+ createdProductIds.push(product.id)
89
+
90
+ return product
91
+ }
92
+
93
+ const poll_for_tag = async (enduserId: string, expected: string, iterations = 20) => {
94
+ for (let i = 0; i < iterations; i++) {
95
+ await wait(undefined, 500)
96
+ const enduser = await sdk.api.endusers.getOne(enduserId)
97
+ if (enduser.tags?.includes(expected)) return true
98
+ }
99
+ return false
100
+ }
101
+
102
+ const expect_fired = async (enduserId: string, expected: string, title: string) => {
103
+ assert(await poll_for_tag(enduserId, expected), `expected tag ${expected} to be added by the trigger`, title)
104
+ }
105
+
106
+ // Nothing to poll for when asserting absence, so wait out the trigger handling like the
107
+ // other webhook trigger tests do
108
+ const expect_not_fired = async (enduserId: string, expected: string, title: string) => {
109
+ await wait(undefined, 2500)
110
+ const enduser = await sdk.api.endusers.getOne(enduserId)
111
+ assert(!enduser.tags?.includes(expected), `expected the trigger NOT to fire, but tag ${expected} was added`, title)
112
+ }
113
+
114
+ try {
115
+ /* ===================== invoice.payment_failed ===================== */
116
+
117
+ // B1: a valid-format signature over different bytes must be rejected. This is what proves the
118
+ // rest of the suite is going through real signature verification rather than around it.
119
+ const B1_TAG = tag('b1-bad-signature')
120
+ const b1Enduser = await new_enduser('b1')
121
+ await new_trigger({
122
+ title: `Stripe payment failed (bad signature) ${RUN}`,
123
+ event: { type: 'Subscription Payment Failed', info: {} },
124
+ tags: [B1_TAG],
125
+ })
126
+ const b1Event = invoice_payment_failed_event({ stripeCustomerId: b1Enduser.stripeCustomerId })
127
+ const b1Res = await post_stripe_webhook({
128
+ stripePublicKey,
129
+ secret: webhooksSecret,
130
+ event: b1Event,
131
+ signature: sign_stripe_payload('{"tampered":true}', webhooksSecret),
132
+ })
133
+ assert(b1Res.status === 400, `expected 400 for a mismatched signature, got ${b1Res.status}`, 'invalid signature rejected')
134
+ await expect_not_fired(b1Enduser.id, B1_TAG, 'invalid signature does not fire triggers')
135
+
136
+ // B2: unknown public key in the URL -> no integration -> 404
137
+ const b2Res = await post_stripe_webhook({
138
+ stripePublicKey: `pk_test_missing_${RUN}`,
139
+ secret: webhooksSecret,
140
+ event: invoice_payment_failed_event({ stripeCustomerId: `cus_test_missing_${RUN}` }),
141
+ })
142
+ assert(b2Res.status === 404, `expected 404 for an unknown public key, got ${b2Res.status}`, 'unknown integration rejected')
143
+
144
+ // B3: a signed event for a customer no enduser claims is accepted but fires nothing
145
+ const B3_TAG = tag('b3-unknown-customer')
146
+ const b3Enduser = await new_enduser('b3')
147
+ await new_trigger({
148
+ title: `Stripe payment failed (unknown customer) ${RUN}`,
149
+ event: { type: 'Subscription Payment Failed', info: {} },
150
+ tags: [B3_TAG],
151
+ })
152
+ const b3Res = await post_stripe_webhook({
153
+ stripePublicKey,
154
+ secret: webhooksSecret,
155
+ event: invoice_payment_failed_event({ stripeCustomerId: `cus_test_nobody_${RUN}` }),
156
+ })
157
+ assert(b3Res.status === 200, `expected 200 for an unmatched customer, got ${b3Res.status}`, 'unmatched customer accepted')
158
+ await expect_not_fired(b3Enduser.id, B3_TAG, 'unmatched customer does not fire triggers')
159
+
160
+ // B4: the baseline behavior the productIds feature must preserve — a Subscription Payment Failed
161
+ // trigger with no product scoping fires for any failed invoice.
162
+ const B4_TAG = tag('b4-unscoped')
163
+ const b4Enduser = await new_enduser('b4')
164
+ await new_trigger({
165
+ title: `Stripe payment failed (unscoped) ${RUN}`,
166
+ event: { type: 'Subscription Payment Failed', info: {} },
167
+ tags: [B4_TAG],
168
+ })
169
+ const b4Res = await post_stripe_webhook({
170
+ stripePublicKey,
171
+ secret: webhooksSecret,
172
+ event: invoice_payment_failed_event({
173
+ stripeCustomerId: b4Enduser.stripeCustomerId,
174
+ lines: [line_with_price_product(`prod_test_b4_${RUN}`)],
175
+ }),
176
+ })
177
+ assert(b4Res.status === 200, `expected 200, got ${b4Res.status} (${b4Res.data})`, 'payment failed webhook accepted')
178
+ await expect_fired(b4Enduser.id, B4_TAG, 'unscoped Subscription Payment Failed trigger fires')
179
+
180
+ /* ===================== customer.subscription.deleted ===================== */
181
+
182
+ // Products A and B exist to distinguish "the subscription's product" from "some other product".
183
+ const stripeProductA = `prod_test_a_${RUN}`
184
+ const stripeProductB = `prod_test_b_${RUN}`
185
+ const productA = await new_product('A', stripeProductA)
186
+ const productB = await new_product('B', stripeProductB)
187
+
188
+ // B5: unscoped Subscription Ended fires
189
+ const B5_TAG = tag('b5-ended-unscoped')
190
+ const b5Enduser = await new_enduser('b5')
191
+ await new_trigger({
192
+ title: `Stripe subscription ended (unscoped) ${RUN}`,
193
+ event: { type: 'Subscription Ended', info: {} },
194
+ tags: [B5_TAG],
195
+ })
196
+ const b5Res = await post_stripe_webhook({
197
+ stripePublicKey,
198
+ secret: webhooksSecret,
199
+ event: subscription_deleted_event({
200
+ stripeCustomerId: b5Enduser.stripeCustomerId,
201
+ stripeProductIds: [stripeProductA],
202
+ }),
203
+ })
204
+ assert(b5Res.status === 200, `expected 200, got ${b5Res.status} (${b5Res.data})`, 'subscription deleted webhook accepted')
205
+ await expect_fired(b5Enduser.id, B5_TAG, 'unscoped Subscription Ended trigger fires')
206
+
207
+ // B6: an explicitly empty productIds list means "all products"
208
+ const B6_TAG = tag('b6-ended-empty-list')
209
+ const b6Enduser = await new_enduser('b6')
210
+ await new_trigger({
211
+ title: `Stripe subscription ended (empty productIds) ${RUN}`,
212
+ event: { type: 'Subscription Ended', info: { productIds: [] } },
213
+ tags: [B6_TAG],
214
+ })
215
+ await post_stripe_webhook({
216
+ stripePublicKey,
217
+ secret: webhooksSecret,
218
+ event: subscription_deleted_event({
219
+ stripeCustomerId: b6Enduser.stripeCustomerId,
220
+ stripeProductIds: [stripeProductA],
221
+ }),
222
+ })
223
+ await expect_fired(b6Enduser.id, B6_TAG, 'Subscription Ended trigger with empty productIds fires')
224
+
225
+ // B7/B8 pin the existing product-matching semantics end-to-end, so any refactor of the
226
+ // productIds query clause is provably behavior-preserving.
227
+
228
+ // B7: productIds matching the subscription's product fires
229
+ const B7_TAG = tag('b7-ended-matching')
230
+ const b7Enduser = await new_enduser('b7')
231
+ await new_trigger({
232
+ title: `Stripe subscription ended (matching product) ${RUN}`,
233
+ event: { type: 'Subscription Ended', info: { productIds: [productA.id] } },
234
+ tags: [B7_TAG],
235
+ })
236
+ await post_stripe_webhook({
237
+ stripePublicKey,
238
+ secret: webhooksSecret,
239
+ event: subscription_deleted_event({
240
+ stripeCustomerId: b7Enduser.stripeCustomerId,
241
+ stripeProductIds: [stripeProductA],
242
+ }),
243
+ })
244
+ await expect_fired(b7Enduser.id, B7_TAG, 'Subscription Ended trigger scoped to the matching product fires')
245
+
246
+ // B8: productIds naming a different product does not fire
247
+ const B8_TAG = tag('b8-ended-nonmatching')
248
+ const b8Enduser = await new_enduser('b8')
249
+ await new_trigger({
250
+ title: `Stripe subscription ended (other product) ${RUN}`,
251
+ event: { type: 'Subscription Ended', info: { productIds: [productB.id] } },
252
+ tags: [B8_TAG],
253
+ })
254
+ await post_stripe_webhook({
255
+ stripePublicKey,
256
+ secret: webhooksSecret,
257
+ event: subscription_deleted_event({
258
+ stripeCustomerId: b8Enduser.stripeCustomerId,
259
+ stripeProductIds: [stripeProductA],
260
+ }),
261
+ })
262
+ await expect_not_fired(b8Enduser.id, B8_TAG, 'Subscription Ended trigger scoped to another product does not fire')
263
+
264
+ /* ============ invoice.payment_failed, scoped to products ============ */
265
+
266
+ // Shared shape for the cases below: a trigger scoped to productA, and an invoice whose line
267
+ // items are varied per case to cover how Stripe can reference a product.
268
+ const run_scoped_payment_failed_case = async ({ label, productIds, lines, shouldFire, title } : {
269
+ label: string,
270
+ productIds: string[],
271
+ lines: object[],
272
+ shouldFire: boolean,
273
+ title: string,
274
+ }) => {
275
+ const caseTag = tag(label)
276
+ const enduser = await new_enduser(label)
277
+ await new_trigger({
278
+ title: `Stripe payment failed (${label}) ${RUN}`,
279
+ event: { type: 'Subscription Payment Failed', info: { productIds } },
280
+ tags: [caseTag],
281
+ })
282
+
283
+ const res = await post_stripe_webhook({
284
+ stripePublicKey,
285
+ secret: webhooksSecret,
286
+ event: invoice_payment_failed_event({ stripeCustomerId: enduser.stripeCustomerId, lines }),
287
+ })
288
+ assert(res.status === 200, `expected 200, got ${res.status} (${res.data})`, `${label} webhook accepted`)
289
+
290
+ if (shouldFire) await expect_fired(enduser.id, caseTag, title)
291
+ else await expect_not_fired(enduser.id, caseTag, title)
292
+ }
293
+
294
+ // F1: the feature — a trigger scoped to the product that actually failed fires
295
+ await run_scoped_payment_failed_case({
296
+ label: 'f1-matching',
297
+ productIds: [productA.id],
298
+ lines: [line_with_price_product(stripeProductA)],
299
+ shouldFire: true,
300
+ title: 'Subscription Payment Failed scoped to the failed product fires',
301
+ })
302
+
303
+ // F2: the point of the feature — a trigger scoped to a different product stays quiet
304
+ await run_scoped_payment_failed_case({
305
+ label: 'f2-nonmatching',
306
+ productIds: [productB.id],
307
+ lines: [line_with_price_product(stripeProductA)],
308
+ shouldFire: false,
309
+ title: 'Subscription Payment Failed scoped to another product does not fire',
310
+ })
311
+
312
+ // F3: "leave blank for all" — an empty list is not a filter
313
+ await run_scoped_payment_failed_case({
314
+ label: 'f3-empty-list',
315
+ productIds: [],
316
+ lines: [line_with_price_product(stripeProductA)],
317
+ shouldFire: true,
318
+ title: 'Subscription Payment Failed with empty productIds fires',
319
+ })
320
+
321
+ // F4: price.product may arrive expanded as an object rather than a bare id
322
+ await run_scoped_payment_failed_case({
323
+ label: 'f4-expanded-product',
324
+ productIds: [productA.id],
325
+ lines: [line_with_expanded_price_product(stripeProductA)],
326
+ shouldFire: true,
327
+ title: 'expanded price.product object resolves to the product',
328
+ })
329
+
330
+ // F5: subscription/proration lines can carry plan.product with no price
331
+ await run_scoped_payment_failed_case({
332
+ label: 'f5-plan-product',
333
+ productIds: [productA.id],
334
+ lines: [line_with_plan_product(stripeProductA)],
335
+ shouldFire: true,
336
+ title: 'plan.product fallback resolves to the product',
337
+ })
338
+
339
+ // F6: one matching line among several is enough
340
+ await run_scoped_payment_failed_case({
341
+ label: 'f6-multi-line',
342
+ productIds: [productA.id],
343
+ lines: [line_with_price_product(stripeProductB), line_with_price_product(stripeProductA)],
344
+ shouldFire: true,
345
+ title: 'a matching line among several fires the scoped trigger',
346
+ })
347
+
348
+ /* ===================== validator ===================== */
349
+
350
+ // F7: productIds must round-trip, and an omitted info must stay valid — it was accepted before
351
+ // productIds existed, so requiring it would break existing API clients.
352
+ const f7Scoped = await new_trigger({
353
+ title: `Stripe payment failed (validator round-trip) ${RUN}`,
354
+ event: { type: 'Subscription Payment Failed', info: { productIds: [productA.id] } },
355
+ tags: [tag('f7')],
356
+ })
357
+ const f7Fetched = await sdk.api.automation_triggers.getOne(f7Scoped.id)
358
+ assert(
359
+ JSON.stringify((f7Fetched.event as any).info?.productIds) === JSON.stringify([productA.id]),
360
+ `expected productIds to round-trip, got ${JSON.stringify(f7Fetched.event)}`,
361
+ 'productIds round-trips through the validator',
362
+ )
363
+
364
+ await new_trigger({
365
+ title: `Stripe payment failed (empty info) ${RUN}`,
366
+ event: { type: 'Subscription Payment Failed', info: {} },
367
+ tags: [tag('f7-empty-info')],
368
+ })
369
+ assert(true, '', 'empty info accepted')
370
+
371
+ await new_trigger({
372
+ title: `Stripe payment failed (no info) ${RUN}`,
373
+ event: { type: 'Subscription Payment Failed' },
374
+ tags: [tag('f7-no-info')],
375
+ })
376
+ assert(true, '', 'omitted info still accepted')
377
+
378
+ let rejectedInvalidProductId = false
379
+ try {
380
+ await new_trigger({
381
+ title: `Stripe payment failed (invalid productId) ${RUN}`,
382
+ event: { type: 'Subscription Payment Failed', info: { productIds: ['not-a-mongo-id'] } },
383
+ tags: [tag('f7-invalid')],
384
+ })
385
+ } catch(e) {
386
+ rejectedInvalidProductId = true
387
+ }
388
+ assert(rejectedInvalidProductId, 'expected a non-ObjectId productId to be rejected', 'invalid productId rejected')
389
+
390
+ /* ============ subscription paused / resumed ============ */
391
+
392
+ // Every case below is one webhook POST against a trigger of one of the two new types. `event` is
393
+ // built by the caller so a case can vary the pause mechanism (pause_collection transition on
394
+ // customer.subscription.updated, vs the dedicated paused/resumed events) independently of the
395
+ // product scoping.
396
+ const run_pause_case = async ({ label, type, productIds, event: buildEvent, shouldFire, title } : {
397
+ label: string,
398
+ type: 'Subscription Paused' | 'Subscription Resumed',
399
+ productIds?: string[],
400
+ event: (stripeCustomerId: string) => object,
401
+ shouldFire: boolean,
402
+ title: string,
403
+ }) => {
404
+ const caseTag = tag(label)
405
+ const enduser = await new_enduser(label)
406
+ await new_trigger({
407
+ title: `Stripe ${type} (${label}) ${RUN}`,
408
+ event: { type, ...productIds !== undefined ? { info: { productIds } } : {} },
409
+ tags: [caseTag],
410
+ })
411
+
412
+ const res = await post_stripe_webhook({
413
+ stripePublicKey,
414
+ secret: webhooksSecret,
415
+ event: buildEvent(enduser.stripeCustomerId),
416
+ })
417
+ assert(res.status === 200, `expected 200, got ${res.status} (${res.data})`, `${label} webhook accepted`)
418
+
419
+ if (shouldFire) await expect_fired(enduser.id, caseTag, title)
420
+ else await expect_not_fired(enduser.id, caseTag, title)
421
+ }
422
+
423
+ // P1: the core pause path — pause_collection goes null -> set on customer.subscription.updated.
424
+ // This is how a pause from the billing portal or dashboard actually arrives; there is no
425
+ // dedicated event for it.
426
+ await run_pause_case({
427
+ label: 'p1-pause-collection-set',
428
+ type: 'Subscription Paused',
429
+ event: stripeCustomerId => subscription_updated_event({
430
+ stripeCustomerId,
431
+ previousAttributes: { pause_collection: null },
432
+ pauseCollection: PAUSE_COLLECTION,
433
+ }),
434
+ shouldFire: true,
435
+ title: 'pause_collection null -> set fires Subscription Paused',
436
+ })
437
+
438
+ // P2: the mirror — pause_collection cleared is a resume
439
+ await run_pause_case({
440
+ label: 'p2-pause-collection-cleared',
441
+ type: 'Subscription Resumed',
442
+ event: stripeCustomerId => subscription_updated_event({
443
+ stripeCustomerId,
444
+ previousAttributes: { pause_collection: PAUSE_COLLECTION },
445
+ pauseCollection: null,
446
+ }),
447
+ shouldFire: true,
448
+ title: 'pause_collection set -> null fires Subscription Resumed',
449
+ })
450
+
451
+ // P3: a pause that is only *modified* (behavior or resumes_at changed) is neither a pause nor a
452
+ // resume — firing here would re-trigger every time a customer adjusts an existing pause
453
+ await run_pause_case({
454
+ label: 'p3-pause-modified',
455
+ type: 'Subscription Paused',
456
+ event: stripeCustomerId => subscription_updated_event({
457
+ stripeCustomerId,
458
+ previousAttributes: { pause_collection: { behavior: 'void', resumes_at: null } },
459
+ pauseCollection: { behavior: 'keep_as_draft', resumes_at: null },
460
+ }),
461
+ shouldFire: false,
462
+ title: 'a modified pause_collection fires neither trigger',
463
+ })
464
+
465
+ // P4: the volume guard. customer.subscription.updated fires on every renewal, price change and
466
+ // cancel_at_period_end toggle; with no pause_collection key in previous_attributes the handler
467
+ // must do nothing (and, in the API, must not even reach the enduser lookup).
468
+ await run_pause_case({
469
+ label: 'p4-unrelated-update',
470
+ type: 'Subscription Paused',
471
+ event: stripeCustomerId => subscription_updated_event({
472
+ stripeCustomerId,
473
+ previousAttributes: { items: { object: 'list', data: [] } },
474
+ }),
475
+ shouldFire: false,
476
+ title: 'an unrelated subscription update fires neither trigger',
477
+ })
478
+
479
+ // P4b: the idempotency case. previous_attributes carries only what changed, so an unrelated
480
+ // update to an already-paused subscription has no pause_collection key even though
481
+ // pause_collection is currently set. Firing here would re-trigger on every renewal, quantity
482
+ // change and coupon edit for the whole duration of a pause — which is what would happen if the
483
+ // gate were ever "simplified" to read current state instead of the transition.
484
+ await run_pause_case({
485
+ label: 'p4b-update-while-paused',
486
+ type: 'Subscription Paused',
487
+ event: stripeCustomerId => subscription_updated_event({
488
+ stripeCustomerId,
489
+ previousAttributes: { quantity: 1 },
490
+ pauseCollection: PAUSE_COLLECTION,
491
+ }),
492
+ shouldFire: false,
493
+ title: 'an unrelated update while already paused does not re-fire Subscription Paused',
494
+ })
495
+
496
+ // P5/P6: the dedicated events, which Stripe emits for status='paused' transitions only
497
+ await run_pause_case({
498
+ label: 'p5-dedicated-paused',
499
+ type: 'Subscription Paused',
500
+ event: stripeCustomerId => subscription_paused_event({ stripeCustomerId }),
501
+ shouldFire: true,
502
+ title: 'customer.subscription.paused fires Subscription Paused',
503
+ })
504
+ await run_pause_case({
505
+ label: 'p6-dedicated-resumed',
506
+ type: 'Subscription Resumed',
507
+ event: stripeCustomerId => subscription_resumed_event({ stripeCustomerId }),
508
+ shouldFire: true,
509
+ title: 'customer.subscription.resumed fires Subscription Resumed',
510
+ })
511
+
512
+ // P7: a Paused trigger must not fire on a resume, and vice versa — the two are separate types
513
+ await run_pause_case({
514
+ label: 'p7-wrong-direction',
515
+ type: 'Subscription Paused',
516
+ event: stripeCustomerId => subscription_resumed_event({ stripeCustomerId }),
517
+ shouldFire: false,
518
+ title: 'a resume does not fire a Subscription Paused trigger',
519
+ })
520
+
521
+ // P8: the dedupe guard — the .updated that accompanies a status='paused' transition must not
522
+ // fire on top of the dedicated event. Stripe isn't expected to send this combination at all;
523
+ // the case pins the guard so a future refactor can't quietly drop it.
524
+ await run_pause_case({
525
+ label: 'p8-paused-status-suppressed',
526
+ type: 'Subscription Paused',
527
+ event: stripeCustomerId => subscription_updated_event({
528
+ stripeCustomerId,
529
+ previousAttributes: { pause_collection: null, status: 'trialing' },
530
+ pauseCollection: PAUSE_COLLECTION,
531
+ status: 'paused',
532
+ }),
533
+ shouldFire: false,
534
+ title: 'a pause_collection change coinciding with status=paused is suppressed',
535
+ })
536
+
537
+ /* ---- product scoping, over the pause_collection path ---- */
538
+
539
+ // P9-P12: the same matrix the payment-failed cases cover, applied to the new triggers
540
+ await run_pause_case({
541
+ label: 'p9-scoped-matching',
542
+ type: 'Subscription Paused',
543
+ productIds: [productA.id],
544
+ event: stripeCustomerId => subscription_updated_event({
545
+ stripeCustomerId,
546
+ items: [item_with_price_product(stripeProductA)],
547
+ previousAttributes: { pause_collection: null },
548
+ pauseCollection: PAUSE_COLLECTION,
549
+ }),
550
+ shouldFire: true,
551
+ title: 'Subscription Paused scoped to the paused product fires',
552
+ })
553
+
554
+ await run_pause_case({
555
+ label: 'p10-scoped-nonmatching',
556
+ type: 'Subscription Paused',
557
+ productIds: [productB.id],
558
+ event: stripeCustomerId => subscription_updated_event({
559
+ stripeCustomerId,
560
+ items: [item_with_price_product(stripeProductA)],
561
+ previousAttributes: { pause_collection: null },
562
+ pauseCollection: PAUSE_COLLECTION,
563
+ }),
564
+ shouldFire: false,
565
+ title: 'Subscription Paused scoped to another product does not fire',
566
+ })
567
+
568
+ await run_pause_case({
569
+ label: 'p11-empty-list',
570
+ type: 'Subscription Paused',
571
+ productIds: [],
572
+ event: stripeCustomerId => subscription_updated_event({
573
+ stripeCustomerId,
574
+ items: [item_with_price_product(stripeProductA)],
575
+ previousAttributes: { pause_collection: null },
576
+ pauseCollection: PAUSE_COLLECTION,
577
+ }),
578
+ shouldFire: true,
579
+ title: 'Subscription Paused with empty productIds fires',
580
+ })
581
+
582
+ // plan.product and expanded price.product objects both have to resolve, same as the invoice path
583
+ await run_pause_case({
584
+ label: 'p12-plan-product',
585
+ type: 'Subscription Resumed',
586
+ productIds: [productA.id],
587
+ event: stripeCustomerId => subscription_updated_event({
588
+ stripeCustomerId,
589
+ items: [item_with_plan_product(stripeProductA)],
590
+ previousAttributes: { pause_collection: PAUSE_COLLECTION },
591
+ pauseCollection: null,
592
+ }),
593
+ shouldFire: true,
594
+ title: 'plan.product resolves for a scoped Subscription Resumed trigger',
595
+ })
596
+
597
+ await run_pause_case({
598
+ label: 'p13-expanded-product',
599
+ type: 'Subscription Resumed',
600
+ productIds: [productA.id],
601
+ event: stripeCustomerId => subscription_resumed_event({
602
+ stripeCustomerId,
603
+ items: [item_with_expanded_price_product(stripeProductA)],
604
+ }),
605
+ shouldFire: true,
606
+ title: 'expanded price.product resolves on the dedicated resumed event',
607
+ })
608
+
609
+ /* ---- validator ---- */
610
+
611
+ // P14: same round-trip guarantees as F7, for both new types
612
+ const p14Scoped = await new_trigger({
613
+ title: `Stripe subscription paused (validator round-trip) ${RUN}`,
614
+ event: { type: 'Subscription Paused', info: { productIds: [productA.id] } },
615
+ tags: [tag('p14')],
616
+ })
617
+ const p14Fetched = await sdk.api.automation_triggers.getOne(p14Scoped.id)
618
+ assert(
619
+ JSON.stringify((p14Fetched.event as any).info?.productIds) === JSON.stringify([productA.id]),
620
+ `expected productIds to round-trip, got ${JSON.stringify(p14Fetched.event)}`,
621
+ 'Subscription Paused productIds round-trips through the validator',
622
+ )
623
+
624
+ await new_trigger({
625
+ title: `Stripe subscription resumed (no info) ${RUN}`,
626
+ event: { type: 'Subscription Resumed' },
627
+ tags: [tag('p14-no-info')],
628
+ })
629
+ assert(true, '', 'Subscription Resumed with omitted info accepted')
630
+
631
+ let rejectedInvalidPausedProductId = false
632
+ try {
633
+ await new_trigger({
634
+ title: `Stripe subscription paused (invalid productId) ${RUN}`,
635
+ event: { type: 'Subscription Paused', info: { productIds: ['not-a-mongo-id'] } },
636
+ tags: [tag('p14-invalid')],
637
+ })
638
+ } catch(e) {
639
+ rejectedInvalidPausedProductId = true
640
+ }
641
+ assert(rejectedInvalidPausedProductId, 'expected a non-ObjectId productId to be rejected', 'invalid Subscription Paused productId rejected')
642
+
643
+ console.log("All Stripe webhook trigger tests passed!")
644
+ } finally {
645
+ // Best-effort only: the harness exits the process on the first failed assertion, which skips
646
+ // this block. setup_stripe_test_integration clears integration residue on the next run.
647
+ await cleanup().catch(console.error)
648
+ await Promise.all([
649
+ ...createdTriggerIds.map(id => sdk.api.automation_triggers.deleteOne(id).catch(console.error)),
650
+ ...createdProductIds.map(id => sdk.api.products.deleteOne(id).catch(console.error)),
651
+ ...createdEnduserIds.map(id => sdk.api.endusers.deleteOne(id).catch(console.error)),
652
+ ])
653
+ }
654
+ }
655
+
656
+ // Allow running this test file independently
657
+ if (require.main === module) {
658
+ console.log(`Using API URL: ${host}`)
659
+ const sdk = new Session({ host })
660
+ const sdkNonAdmin = new Session({ host })
661
+
662
+ const runTests = async () => {
663
+ await setup_tests(sdk, sdkNonAdmin)
664
+ await stripe_webhook_triggers_tests({ sdk, sdkNonAdmin })
665
+ }
666
+
667
+ runTests()
668
+ .then(() => {
669
+ console.log("Stripe webhook triggers test suite completed successfully")
670
+ process.exit(0)
671
+ })
672
+ .catch((error) => {
673
+ console.error("Stripe webhook triggers test suite failed:", error)
674
+ process.exit(1)
675
+ })
676
+ }