@tellescope/sdk 1.256.2 → 1.256.3
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.
- package/lib/cjs/tests/api_tests/metriport_push_form_response.test.d.ts +6 -0
- package/lib/cjs/tests/api_tests/metriport_push_form_response.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/metriport_push_form_response.test.js +298 -0
- package/lib/cjs/tests/api_tests/metriport_push_form_response.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/phone_calls_move_enduser.test.d.ts +6 -0
- package/lib/cjs/tests/api_tests/phone_calls_move_enduser.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/phone_calls_move_enduser.test.js +274 -0
- package/lib/cjs/tests/api_tests/phone_calls_move_enduser.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/stripe_webhook_triggers.test.d.ts +18 -0
- package/lib/cjs/tests/api_tests/stripe_webhook_triggers.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/stripe_webhook_triggers.test.js +964 -0
- package/lib/cjs/tests/api_tests/stripe_webhook_triggers.test.js.map +1 -0
- package/lib/cjs/tests/api_tests/welle_partner_token.test.d.ts +6 -0
- package/lib/cjs/tests/api_tests/welle_partner_token.test.d.ts.map +1 -0
- package/lib/cjs/tests/api_tests/welle_partner_token.test.js +313 -0
- package/lib/cjs/tests/api_tests/welle_partner_token.test.js.map +1 -0
- package/lib/cjs/tests/stripe_webhook_helpers.d.ts +204 -0
- package/lib/cjs/tests/stripe_webhook_helpers.d.ts.map +1 -0
- package/lib/cjs/tests/stripe_webhook_helpers.js +286 -0
- package/lib/cjs/tests/stripe_webhook_helpers.js.map +1 -0
- package/lib/cjs/tests/tests.d.ts.map +1 -1
- package/lib/cjs/tests/tests.js +192 -180
- package/lib/cjs/tests/tests.js.map +1 -1
- package/lib/cjs/tests/unit_tests/translation.test.d.ts +3 -0
- package/lib/cjs/tests/unit_tests/translation.test.d.ts.map +1 -0
- package/lib/cjs/tests/unit_tests/translation.test.js.map +1 -0
- package/lib/esm/tests/api_tests/metriport_push_form_response.test.d.ts +6 -0
- package/lib/esm/tests/api_tests/metriport_push_form_response.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/metriport_push_form_response.test.js +294 -0
- package/lib/esm/tests/api_tests/metriport_push_form_response.test.js.map +1 -0
- package/lib/esm/tests/api_tests/phone_calls_move_enduser.test.d.ts +6 -0
- package/lib/esm/tests/api_tests/phone_calls_move_enduser.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/phone_calls_move_enduser.test.js +270 -0
- package/lib/esm/tests/api_tests/phone_calls_move_enduser.test.js.map +1 -0
- package/lib/esm/tests/api_tests/stripe_webhook_triggers.test.d.ts +18 -0
- package/lib/esm/tests/api_tests/stripe_webhook_triggers.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/stripe_webhook_triggers.test.js +960 -0
- package/lib/esm/tests/api_tests/stripe_webhook_triggers.test.js.map +1 -0
- package/lib/esm/tests/api_tests/welle_partner_token.test.d.ts +6 -0
- package/lib/esm/tests/api_tests/welle_partner_token.test.d.ts.map +1 -0
- package/lib/esm/tests/api_tests/welle_partner_token.test.js +309 -0
- package/lib/esm/tests/api_tests/welle_partner_token.test.js.map +1 -0
- package/lib/esm/tests/stripe_webhook_helpers.d.ts +204 -0
- package/lib/esm/tests/stripe_webhook_helpers.d.ts.map +1 -0
- package/lib/esm/tests/stripe_webhook_helpers.js +267 -0
- package/lib/esm/tests/stripe_webhook_helpers.js.map +1 -0
- package/lib/esm/tests/tests.d.ts.map +1 -1
- package/lib/esm/tests/tests.js +192 -180
- package/lib/esm/tests/tests.js.map +1 -1
- package/lib/esm/tests/unit_tests/translation.test.d.ts +3 -0
- package/lib/esm/tests/unit_tests/translation.test.d.ts.map +1 -0
- package/lib/esm/tests/unit_tests/translation.test.js +52 -0
- package/lib/esm/tests/unit_tests/translation.test.js.map +1 -0
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/package.json +10 -10
- package/src/tests/api_tests/metriport_push_form_response.test.ts +226 -0
- package/src/tests/api_tests/stripe_webhook_triggers.test.ts +676 -0
- package/src/tests/api_tests/welle_partner_token.test.ts +249 -0
- package/src/tests/stripe_webhook_helpers.ts +240 -0
- package/src/tests/tests.ts +6 -0
- 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
|
+
}
|