backend-manager 5.2.18 → 5.3.0
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/CHANGELOG.md +35 -0
- package/CLAUDE.md +6 -4
- package/bin/backend-manager +10 -1
- package/docs/admin-post-route.md +2 -0
- package/docs/ai-library.md +29 -2
- package/docs/cli-output.md +122 -0
- package/docs/common-mistakes.md +1 -1
- package/docs/environment-detection.md +87 -3
- package/docs/marketing-campaigns.md +1 -1
- package/docs/payment-system.md +1 -1
- package/docs/testing.md +64 -17
- package/package.json +1 -1
- package/src/cli/commands/base-command.js +4 -0
- package/src/cli/commands/install.js +2 -2
- package/src/cli/commands/setup-tests/bem-config.js +22 -9
- package/src/cli/commands/setup-tests/helpers/merge-line-files.js +22 -168
- package/src/cli/commands/setup.js +65 -33
- package/src/cli/commands/test.js +1 -1
- package/src/cli/index.js +72 -39
- package/src/cli/utils/ui.js +270 -0
- package/src/defaults/CLAUDE.md +2 -2
- package/src/defaults/test/_init.js +14 -0
- package/src/manager/functions/_legacy/actions/create-post-handler.js +1 -1
- package/src/manager/functions/core/actions/api/admin/edit-post.js +1 -1
- package/src/manager/functions/core/actions/api/general/send-email.js +1 -1
- package/src/manager/functions/core/actions/api/user/delete.js +1 -1
- package/src/manager/functions/core/actions/api/user/oauth2.js +1 -1
- package/src/manager/helpers/analytics.js +3 -1
- package/src/manager/helpers/assistant.js +43 -38
- package/src/manager/index.js +76 -12
- package/src/manager/libraries/ai/index.js +33 -0
- package/src/manager/libraries/ai/providers/openai.js +105 -8
- package/src/manager/libraries/content/ghostii.js +76 -16
- package/src/manager/libraries/email/data/disposable-domains.json +24 -0
- package/src/manager/libraries/email/generators/lib/image-illustrator.js +154 -0
- package/src/manager/libraries/email/generators/newsletter.js +16 -2
- package/src/manager/libraries/payment/discount-codes.js +1 -0
- package/src/manager/routes/admin/post/put.js +1 -1
- package/src/manager/routes/handler/post/post.js +1 -1
- package/src/manager/routes/payments/intent/processors/test.js +1 -1
- package/src/manager/routes/user/delete.js +1 -1
- package/src/manager/routes/user/signup/post.js +4 -2
- package/src/test/runner.js +142 -20
- package/src/test/test-accounts.js +159 -102
- package/src/utils/merge-line-files.js +16 -5
- package/templates/_.env +1 -0
- package/test/events/payments/journey-payments-cancel-endpoint.js +6 -2
- package/test/events/payments/journey-payments-cancel.js +6 -3
- package/test/events/payments/journey-payments-failure.js +6 -3
- package/test/events/payments/journey-payments-plan-change.js +6 -3
- package/test/events/payments/journey-payments-refund-webhook.js +6 -2
- package/test/events/payments/journey-payments-suspend.js +6 -3
- package/test/events/payments/journey-payments-trial-cancel.js +6 -3
- package/test/events/payments/journey-payments-trial.js +10 -4
- package/test/events/payments/journey-payments-uid-resolution.js +6 -2
- package/test/events/payments/journey-payments-upgrade.js +6 -3
- package/test/helpers/content/ghostii-blocks.js +134 -0
- package/test/helpers/environment.js +230 -0
- package/test/helpers/merge-line-files.js +271 -0
- package/test/routes/marketing/webhook-forward.js +14 -7
- package/test/routes/payments/cancel.js +4 -1
- package/test/routes/payments/dispute-alert.js +9 -6
- package/test/routes/payments/intent.js +55 -14
- package/test/routes/payments/portal.js +4 -1
- package/test/routes/payments/refund.js +4 -1
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test: mergeLineBasedFiles()
|
|
3
|
+
* Unit tests for the line-based .env / .gitignore / CLAUDE.md merge.
|
|
4
|
+
*
|
|
5
|
+
* Tests the pure function directly — no emulator, no Firestore, no HTTP.
|
|
6
|
+
*
|
|
7
|
+
* Regression coverage for the bug that scrambled consumers' `.env` files: an
|
|
8
|
+
* older positional implementation zipped comment lines and value lines by index,
|
|
9
|
+
* so any drift in key order shifted every value under the wrong header. The
|
|
10
|
+
* canonical impl merges BY KEY, so headers always re-anchor to the template and
|
|
11
|
+
* values follow their key across sections.
|
|
12
|
+
*/
|
|
13
|
+
const {
|
|
14
|
+
mergeLineBasedFiles,
|
|
15
|
+
DEFAULT_MARKER,
|
|
16
|
+
CUSTOM_MARKER,
|
|
17
|
+
} = require('../../src/utils/merge-line-files.js');
|
|
18
|
+
|
|
19
|
+
// Setup-test helper shim — must re-export the SAME canonical impl (SSOT).
|
|
20
|
+
const helperShim = require('../../src/cli/commands/setup-tests/helpers/merge-line-files.js');
|
|
21
|
+
|
|
22
|
+
// A representative framework .env template (keys grouped under headers).
|
|
23
|
+
const TEMPLATE = [
|
|
24
|
+
DEFAULT_MARKER,
|
|
25
|
+
'# GitHub',
|
|
26
|
+
'GH_TOKEN=""',
|
|
27
|
+
'',
|
|
28
|
+
'# AI',
|
|
29
|
+
'OPENAI_API_KEY=""',
|
|
30
|
+
'ANTHROPIC_API_KEY=""',
|
|
31
|
+
'',
|
|
32
|
+
'# Payment Processors',
|
|
33
|
+
'PAYPAL_CLIENT_SECRET=""',
|
|
34
|
+
'STRIPE_SECRET_KEY=""',
|
|
35
|
+
'',
|
|
36
|
+
CUSTOM_MARKER,
|
|
37
|
+
'# Add your custom environment variables below this line',
|
|
38
|
+
].join('\n');
|
|
39
|
+
|
|
40
|
+
// Pull the value assigned to a key in a given section of a merged result.
|
|
41
|
+
function keyValueInSection(merged, key, section /* 'default' | 'custom' */) {
|
|
42
|
+
const lines = merged.split('\n');
|
|
43
|
+
let mode = null;
|
|
44
|
+
for (const line of lines) {
|
|
45
|
+
const trimmed = line.trim();
|
|
46
|
+
if (trimmed === DEFAULT_MARKER) { mode = 'default'; continue; }
|
|
47
|
+
if (trimmed === CUSTOM_MARKER) { mode = 'custom'; continue; }
|
|
48
|
+
if (mode !== section || !trimmed || trimmed.startsWith('#')) continue;
|
|
49
|
+
const eq = trimmed.indexOf('=');
|
|
50
|
+
if (eq < 0) continue;
|
|
51
|
+
if (trimmed.slice(0, eq).trim() === key) return trimmed.slice(eq + 1);
|
|
52
|
+
}
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Count how many times a key appears anywhere (catches duplication bugs).
|
|
57
|
+
function keyOccurrences(merged, key) {
|
|
58
|
+
return merged.split('\n').filter((l) => l.trim().startsWith(`${key}=`)).length;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Assert a key sits directly under the expected comment header in the merged output.
|
|
62
|
+
function headerAboveKey(merged, key) {
|
|
63
|
+
const lines = merged.split('\n');
|
|
64
|
+
const idx = lines.findIndex((l) => l.trim().startsWith(`${key}=`));
|
|
65
|
+
if (idx < 0) return null;
|
|
66
|
+
for (let i = idx - 1; i >= 0; i--) {
|
|
67
|
+
const t = lines[i].trim();
|
|
68
|
+
if (t.startsWith('#') && t !== DEFAULT_MARKER && t !== CUSTOM_MARKER) return t;
|
|
69
|
+
if (t && !t.startsWith('#')) continue; // another key — keep scanning up
|
|
70
|
+
}
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
module.exports = {
|
|
75
|
+
description: 'mergeLineBasedFiles() line-based .env / .gitignore merge',
|
|
76
|
+
type: 'group',
|
|
77
|
+
|
|
78
|
+
tests: [
|
|
79
|
+
{
|
|
80
|
+
name: 'ssot-helper-shim-is-same-function',
|
|
81
|
+
async run({ assert }) {
|
|
82
|
+
assert.equal(
|
|
83
|
+
helperShim.mergeLineBasedFiles,
|
|
84
|
+
mergeLineBasedFiles,
|
|
85
|
+
'setup-tests helper must re-export the canonical mergeLineBasedFiles (SSOT)'
|
|
86
|
+
);
|
|
87
|
+
assert.equal(helperShim.DEFAULT_SECTION_MARKER, DEFAULT_MARKER, 'marker alias matches');
|
|
88
|
+
assert.equal(helperShim.CUSTOM_SECTION_MARKER, CUSTOM_MARKER, 'marker alias matches');
|
|
89
|
+
assert.ok(helperShim.hasSectionMarkers(TEMPLATE), 'hasSectionMarkers detects markers');
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
|
|
93
|
+
{
|
|
94
|
+
name: 'preserves-existing-values-by-key',
|
|
95
|
+
async run({ assert }) {
|
|
96
|
+
const existing = [
|
|
97
|
+
DEFAULT_MARKER,
|
|
98
|
+
'# GitHub',
|
|
99
|
+
'GH_TOKEN="ghp_real"',
|
|
100
|
+
'# AI',
|
|
101
|
+
'OPENAI_API_KEY="sk-real"',
|
|
102
|
+
'ANTHROPIC_API_KEY=""',
|
|
103
|
+
'# Payment Processors',
|
|
104
|
+
'PAYPAL_CLIENT_SECRET="pp_real"',
|
|
105
|
+
'STRIPE_SECRET_KEY=""',
|
|
106
|
+
CUSTOM_MARKER,
|
|
107
|
+
].join('\n');
|
|
108
|
+
|
|
109
|
+
const merged = mergeLineBasedFiles(existing, TEMPLATE, '.env');
|
|
110
|
+
assert.equal(keyValueInSection(merged, 'GH_TOKEN', 'default'), '"ghp_real"', 'GH_TOKEN value kept');
|
|
111
|
+
assert.equal(keyValueInSection(merged, 'OPENAI_API_KEY', 'default'), '"sk-real"', 'OPENAI value kept');
|
|
112
|
+
assert.equal(keyValueInSection(merged, 'PAYPAL_CLIENT_SECRET', 'default'), '"pp_real"', 'PAYPAL value kept');
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
|
|
116
|
+
{
|
|
117
|
+
name: 'keys-stay-under-correct-header-when-order-drifts',
|
|
118
|
+
async run({ assert }) {
|
|
119
|
+
// Existing file has keys in a DIFFERENT order than the template. The old
|
|
120
|
+
// positional merge would shift values under the wrong header here.
|
|
121
|
+
const existing = [
|
|
122
|
+
DEFAULT_MARKER,
|
|
123
|
+
'# Misc (old grouping)',
|
|
124
|
+
'STRIPE_SECRET_KEY="stripe_real"',
|
|
125
|
+
'OPENAI_API_KEY="openai_real"',
|
|
126
|
+
'GH_TOKEN="gh_real"',
|
|
127
|
+
'PAYPAL_CLIENT_SECRET="pp_real"',
|
|
128
|
+
'ANTHROPIC_API_KEY="anthropic_real"',
|
|
129
|
+
CUSTOM_MARKER,
|
|
130
|
+
].join('\n');
|
|
131
|
+
|
|
132
|
+
const merged = mergeLineBasedFiles(existing, TEMPLATE, '.env');
|
|
133
|
+
|
|
134
|
+
assert.equal(headerAboveKey(merged, 'OPENAI_API_KEY'), '# AI', 'OPENAI under # AI');
|
|
135
|
+
assert.equal(headerAboveKey(merged, 'ANTHROPIC_API_KEY'), '# AI', 'ANTHROPIC under # AI');
|
|
136
|
+
assert.equal(headerAboveKey(merged, 'PAYPAL_CLIENT_SECRET'), '# Payment Processors', 'PAYPAL under # Payment Processors');
|
|
137
|
+
assert.equal(headerAboveKey(merged, 'STRIPE_SECRET_KEY'), '# Payment Processors', 'STRIPE under # Payment Processors');
|
|
138
|
+
assert.equal(headerAboveKey(merged, 'GH_TOKEN'), '# GitHub', 'GH_TOKEN under # GitHub');
|
|
139
|
+
|
|
140
|
+
// Values still attached to the right keys.
|
|
141
|
+
assert.equal(keyValueInSection(merged, 'STRIPE_SECRET_KEY', 'default'), '"stripe_real"', 'STRIPE value intact');
|
|
142
|
+
assert.equal(keyValueInSection(merged, 'OPENAI_API_KEY', 'default'), '"openai_real"', 'OPENAI value intact');
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
|
|
146
|
+
{
|
|
147
|
+
name: 'promotes-custom-key-to-default-when-template-adopts-it',
|
|
148
|
+
async run({ assert }) {
|
|
149
|
+
// User had APOLLO_API_KEY in Custom; framework adds it to the template.
|
|
150
|
+
const tplWithApollo = TEMPLATE.replace('STRIPE_SECRET_KEY=""', 'STRIPE_SECRET_KEY=""\nAPOLLO_API_KEY=""');
|
|
151
|
+
const existing = [
|
|
152
|
+
DEFAULT_MARKER,
|
|
153
|
+
'# GitHub',
|
|
154
|
+
'GH_TOKEN="gh_real"',
|
|
155
|
+
'# AI',
|
|
156
|
+
'OPENAI_API_KEY=""',
|
|
157
|
+
'ANTHROPIC_API_KEY=""',
|
|
158
|
+
'# Payment Processors',
|
|
159
|
+
'PAYPAL_CLIENT_SECRET=""',
|
|
160
|
+
'STRIPE_SECRET_KEY=""',
|
|
161
|
+
CUSTOM_MARKER,
|
|
162
|
+
'APOLLO_API_KEY="apollo_real"',
|
|
163
|
+
'GITHUB_TOKEN="legacy"',
|
|
164
|
+
].join('\n');
|
|
165
|
+
|
|
166
|
+
const merged = mergeLineBasedFiles(existing, tplWithApollo, '.env');
|
|
167
|
+
|
|
168
|
+
assert.equal(keyValueInSection(merged, 'APOLLO_API_KEY', 'default'), '"apollo_real"', 'APOLLO promoted to Default with value');
|
|
169
|
+
assert.equal(keyValueInSection(merged, 'APOLLO_API_KEY', 'custom'), undefined, 'APOLLO removed from Custom');
|
|
170
|
+
assert.equal(keyOccurrences(merged, 'APOLLO_API_KEY'), 1, 'APOLLO not duplicated');
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
|
|
174
|
+
{
|
|
175
|
+
name: 'migrates-unknown-default-key-to-custom',
|
|
176
|
+
async run({ assert }) {
|
|
177
|
+
// User has a legacy key in Default that the template no longer defines.
|
|
178
|
+
const existing = [
|
|
179
|
+
DEFAULT_MARKER,
|
|
180
|
+
'# GitHub',
|
|
181
|
+
'GITHUB_TOKEN="legacy_real"', // template now uses GH_TOKEN
|
|
182
|
+
'GH_TOKEN="gh_real"',
|
|
183
|
+
'# AI',
|
|
184
|
+
'OPENAI_API_KEY=""',
|
|
185
|
+
'ANTHROPIC_API_KEY=""',
|
|
186
|
+
'# Payment Processors',
|
|
187
|
+
'PAYPAL_CLIENT_SECRET=""',
|
|
188
|
+
'STRIPE_SECRET_KEY=""',
|
|
189
|
+
CUSTOM_MARKER,
|
|
190
|
+
].join('\n');
|
|
191
|
+
|
|
192
|
+
const merged = mergeLineBasedFiles(existing, TEMPLATE, '.env');
|
|
193
|
+
|
|
194
|
+
assert.equal(keyValueInSection(merged, 'GITHUB_TOKEN', 'custom'), '"legacy_real"', 'legacy key migrated to Custom with value');
|
|
195
|
+
assert.equal(keyValueInSection(merged, 'GITHUB_TOKEN', 'default'), undefined, 'legacy key gone from Default');
|
|
196
|
+
assert.equal(keyValueInSection(merged, 'GH_TOKEN', 'default'), '"gh_real"', 'GH_TOKEN kept in Default');
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
|
|
200
|
+
{
|
|
201
|
+
name: 'preserves-custom-section-verbatim',
|
|
202
|
+
async run({ assert }) {
|
|
203
|
+
const existing = [
|
|
204
|
+
DEFAULT_MARKER,
|
|
205
|
+
'# GitHub',
|
|
206
|
+
'GH_TOKEN=""',
|
|
207
|
+
'# AI',
|
|
208
|
+
'OPENAI_API_KEY=""',
|
|
209
|
+
'ANTHROPIC_API_KEY=""',
|
|
210
|
+
'# Payment Processors',
|
|
211
|
+
'PAYPAL_CLIENT_SECRET=""',
|
|
212
|
+
'STRIPE_SECRET_KEY=""',
|
|
213
|
+
CUSTOM_MARKER,
|
|
214
|
+
'MY_CUSTOM_KEY="custom_real"',
|
|
215
|
+
'# OAuth2',
|
|
216
|
+
'OAUTH_ID="abc"',
|
|
217
|
+
].join('\n');
|
|
218
|
+
|
|
219
|
+
const merged = mergeLineBasedFiles(existing, TEMPLATE, '.env');
|
|
220
|
+
assert.equal(keyValueInSection(merged, 'MY_CUSTOM_KEY', 'custom'), '"custom_real"', 'custom key preserved');
|
|
221
|
+
assert.equal(keyValueInSection(merged, 'OAUTH_ID', 'custom'), '"abc"', 'custom OAuth key preserved');
|
|
222
|
+
assert.ok(merged.includes('# OAuth2'), 'custom comment preserved');
|
|
223
|
+
},
|
|
224
|
+
},
|
|
225
|
+
|
|
226
|
+
{
|
|
227
|
+
name: 'normalizes-raw-values-to-double-quoted',
|
|
228
|
+
async run({ assert }) {
|
|
229
|
+
const existing = [
|
|
230
|
+
DEFAULT_MARKER,
|
|
231
|
+
'# GitHub',
|
|
232
|
+
'GH_TOKEN=raw-no-quotes',
|
|
233
|
+
'# AI',
|
|
234
|
+
"OPENAI_API_KEY='single'",
|
|
235
|
+
'ANTHROPIC_API_KEY=""',
|
|
236
|
+
'# Payment Processors',
|
|
237
|
+
'PAYPAL_CLIENT_SECRET=""',
|
|
238
|
+
'STRIPE_SECRET_KEY=""',
|
|
239
|
+
CUSTOM_MARKER,
|
|
240
|
+
].join('\n');
|
|
241
|
+
|
|
242
|
+
const merged = mergeLineBasedFiles(existing, TEMPLATE, '.env');
|
|
243
|
+
assert.equal(keyValueInSection(merged, 'GH_TOKEN', 'default'), '"raw-no-quotes"', 'raw value double-quoted');
|
|
244
|
+
assert.equal(keyValueInSection(merged, 'OPENAI_API_KEY', 'default'), '"single"', 'single-quoted canonicalized to double');
|
|
245
|
+
},
|
|
246
|
+
},
|
|
247
|
+
|
|
248
|
+
{
|
|
249
|
+
name: 'is-idempotent',
|
|
250
|
+
async run({ assert }) {
|
|
251
|
+
const existing = [
|
|
252
|
+
DEFAULT_MARKER,
|
|
253
|
+
'# GitHub',
|
|
254
|
+
'GH_TOKEN="gh_real"',
|
|
255
|
+
'# AI',
|
|
256
|
+
'OPENAI_API_KEY="sk"',
|
|
257
|
+
'ANTHROPIC_API_KEY=""',
|
|
258
|
+
'# Payment Processors',
|
|
259
|
+
'PAYPAL_CLIENT_SECRET=""',
|
|
260
|
+
'STRIPE_SECRET_KEY=""',
|
|
261
|
+
CUSTOM_MARKER,
|
|
262
|
+
'CUSTOM="x"',
|
|
263
|
+
].join('\n');
|
|
264
|
+
|
|
265
|
+
const once = mergeLineBasedFiles(existing, TEMPLATE, '.env');
|
|
266
|
+
const twice = mergeLineBasedFiles(once, TEMPLATE, '.env');
|
|
267
|
+
assert.equal(once, twice, 're-running the merge produces identical output');
|
|
268
|
+
},
|
|
269
|
+
},
|
|
270
|
+
],
|
|
271
|
+
};
|
|
@@ -21,12 +21,13 @@ module.exports = {
|
|
|
21
21
|
{
|
|
22
22
|
name: 'forwarder-returns-404-on-non-parent-brand',
|
|
23
23
|
auth: 'none',
|
|
24
|
-
async run({ http, assert, config }) {
|
|
25
|
-
//
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
24
|
+
async run({ http, assert, config, skip }) {
|
|
25
|
+
// This gate only applies to CHILD brands. If this brand IS the parent
|
|
26
|
+
// (config.parent === 'self'), the forwarder route is visible by design,
|
|
27
|
+
// so there's nothing to assert here — skip rather than fail.
|
|
28
|
+
if (!config.parent || config.parent === 'self') {
|
|
29
|
+
skip('Brand is its own parent (config.parent === "self") — forwarder gate does not apply');
|
|
30
|
+
}
|
|
30
31
|
|
|
31
32
|
const response = await http.as('none').post(
|
|
32
33
|
`marketing/webhook/forward?provider=sendgrid&key=${process.env.BACKEND_MANAGER_WEBHOOK_KEY}`,
|
|
@@ -40,7 +41,13 @@ module.exports = {
|
|
|
40
41
|
{
|
|
41
42
|
name: 'forwarder-returns-404-even-with-valid-key',
|
|
42
43
|
auth: 'none',
|
|
43
|
-
async run({ http, assert }) {
|
|
44
|
+
async run({ http, assert, config, skip }) {
|
|
45
|
+
// Only meaningful on a CHILD brand. On the parent itself the forwarder is
|
|
46
|
+
// visible by design, so skip rather than fail.
|
|
47
|
+
if (!config.parent || config.parent === 'self') {
|
|
48
|
+
skip('Brand is its own parent (config.parent === "self") — forwarder gate does not apply');
|
|
49
|
+
}
|
|
50
|
+
|
|
44
51
|
// A valid key shouldn't unlock the forwarder — gate is on config.parent, not key.
|
|
45
52
|
const response = await http.as('none').post(
|
|
46
53
|
`marketing/webhook/forward?provider=beehiiv&key=${process.env.BACKEND_MANAGER_WEBHOOK_KEY}`,
|
|
@@ -92,9 +92,12 @@ module.exports = {
|
|
|
92
92
|
|
|
93
93
|
{
|
|
94
94
|
name: 'succeeds-with-test-processor',
|
|
95
|
-
async run({ http, assert, config, accounts, firestore, waitFor }) {
|
|
95
|
+
async run({ http, assert, config, accounts, firestore, waitFor, skip }) {
|
|
96
96
|
const uid = accounts['route-cancel-success'].uid;
|
|
97
97
|
const paidProduct = config.payment.products.find(p => p.id !== 'basic' && p.prices?.monthly);
|
|
98
|
+
if (!paidProduct) {
|
|
99
|
+
skip('No paid product with monthly price configured in this brand');
|
|
100
|
+
}
|
|
98
101
|
|
|
99
102
|
// Step 1: Create a test subscription intent to set up a proper paid subscription
|
|
100
103
|
const intentResponse = await http.as('route-cancel-success').post('payments/intent', {
|
|
@@ -239,15 +239,18 @@ module.exports = {
|
|
|
239
239
|
async run({ http, assert, firestore }) {
|
|
240
240
|
const alertId = '_test-dispute-duplicate';
|
|
241
241
|
|
|
242
|
-
//
|
|
243
|
-
|
|
242
|
+
// Pre-seed an in-flight dispute directly. We seed (rather than POST a first alert)
|
|
243
|
+
// because the route fires an async on-write trigger that, for synthetic test data
|
|
244
|
+
// with no matching charge, can transition the doc to 'failed' — at which point the
|
|
245
|
+
// dedup contract intentionally treats it as RETRYABLE, not a duplicate. Racing a
|
|
246
|
+
// live POST against that trigger made this test flaky. Seeding 'processing' (a
|
|
247
|
+
// non-failed, in-flight state) deterministically exercises the dedup path.
|
|
248
|
+
await firestore.set(`payments-disputes/${alertId}`, {
|
|
244
249
|
id: alertId,
|
|
245
|
-
|
|
246
|
-
amount: 29.99,
|
|
247
|
-
transactionDate: '2026-03-07',
|
|
250
|
+
status: 'processing',
|
|
248
251
|
});
|
|
249
252
|
|
|
250
|
-
//
|
|
253
|
+
// A subsequent identical alert must be reported as a duplicate (not reprocessed).
|
|
251
254
|
const response = await http.as('none').post(`payments/dispute-alert?key=${process.env.BACKEND_MANAGER_WEBHOOK_KEY}`, {
|
|
252
255
|
id: alertId,
|
|
253
256
|
card: '4242',
|
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
* Validation tests use processor=stripe (fail at SDK step, proving validation logic)
|
|
6
6
|
* Success tests use processor=test (full intent→webhook→trigger pipeline)
|
|
7
7
|
*
|
|
8
|
-
* Product-agnostic: resolves the first paid product from config.payment.products
|
|
8
|
+
* Product-agnostic: resolves the first paid product from config.payment.products.
|
|
9
|
+
* If the brand has no paid product configured, each test skips — this is a
|
|
10
|
+
* config-gap, not a code failure.
|
|
9
11
|
*/
|
|
10
12
|
module.exports = {
|
|
11
13
|
description: 'Payment intent creation',
|
|
@@ -16,8 +18,11 @@ module.exports = {
|
|
|
16
18
|
{
|
|
17
19
|
name: 'rejects-unauthenticated',
|
|
18
20
|
auth: 'none',
|
|
19
|
-
async run({ http, assert, config }) {
|
|
21
|
+
async run({ http, assert, config, skip }) {
|
|
20
22
|
const paidProduct = config.payment.products.find(p => p.id !== 'basic' && p.prices);
|
|
23
|
+
if (!paidProduct) {
|
|
24
|
+
skip('No paid product configured in this brand');
|
|
25
|
+
}
|
|
21
26
|
|
|
22
27
|
const response = await http.as('none').post('payments/intent', {
|
|
23
28
|
processor: 'stripe',
|
|
@@ -31,8 +36,11 @@ module.exports = {
|
|
|
31
36
|
|
|
32
37
|
{
|
|
33
38
|
name: 'rejects-missing-processor',
|
|
34
|
-
async run({ http, assert, config }) {
|
|
39
|
+
async run({ http, assert, config, skip }) {
|
|
35
40
|
const paidProduct = config.payment.products.find(p => p.id !== 'basic' && p.prices);
|
|
41
|
+
if (!paidProduct) {
|
|
42
|
+
skip('No paid product configured in this brand');
|
|
43
|
+
}
|
|
36
44
|
|
|
37
45
|
const response = await http.as('basic').post('payments/intent', {
|
|
38
46
|
productId: paidProduct.id,
|
|
@@ -57,8 +65,11 @@ module.exports = {
|
|
|
57
65
|
|
|
58
66
|
{
|
|
59
67
|
name: 'rejects-missing-frequency-for-subscription',
|
|
60
|
-
async run({ http, assert, config }) {
|
|
68
|
+
async run({ http, assert, config, skip }) {
|
|
61
69
|
const paidProduct = config.payment.products.find(p => p.id !== 'basic' && p.type === 'subscription' && p.prices);
|
|
70
|
+
if (!paidProduct) {
|
|
71
|
+
skip('No paid subscription product configured in this brand');
|
|
72
|
+
}
|
|
62
73
|
|
|
63
74
|
const response = await http.as('basic').post('payments/intent', {
|
|
64
75
|
processor: 'stripe',
|
|
@@ -72,8 +83,11 @@ module.exports = {
|
|
|
72
83
|
{
|
|
73
84
|
name: 'rejects-active-paid-user',
|
|
74
85
|
auth: 'premium-active',
|
|
75
|
-
async run({ http, assert, config }) {
|
|
86
|
+
async run({ http, assert, config, skip }) {
|
|
76
87
|
const paidProduct = config.payment.products.find(p => p.id !== 'basic' && p.prices);
|
|
88
|
+
if (!paidProduct) {
|
|
89
|
+
skip('No paid product configured in this brand');
|
|
90
|
+
}
|
|
77
91
|
|
|
78
92
|
const response = await http.as('premium-active').post('payments/intent', {
|
|
79
93
|
processor: 'stripe',
|
|
@@ -88,8 +102,11 @@ module.exports = {
|
|
|
88
102
|
{
|
|
89
103
|
name: 'rejects-suspended-user',
|
|
90
104
|
auth: 'premium-suspended',
|
|
91
|
-
async run({ http, assert, config }) {
|
|
105
|
+
async run({ http, assert, config, skip }) {
|
|
92
106
|
const paidProduct = config.payment.products.find(p => p.id !== 'basic' && p.prices);
|
|
107
|
+
if (!paidProduct) {
|
|
108
|
+
skip('No paid product configured in this brand');
|
|
109
|
+
}
|
|
93
110
|
|
|
94
111
|
const response = await http.as('premium-suspended').post('payments/intent', {
|
|
95
112
|
processor: 'stripe',
|
|
@@ -104,8 +121,11 @@ module.exports = {
|
|
|
104
121
|
{
|
|
105
122
|
name: 'rejects-cancelling-user',
|
|
106
123
|
auth: 'premium-cancelling',
|
|
107
|
-
async run({ http, assert, config }) {
|
|
124
|
+
async run({ http, assert, config, skip }) {
|
|
108
125
|
const paidProduct = config.payment.products.find(p => p.id !== 'basic' && p.prices);
|
|
126
|
+
if (!paidProduct) {
|
|
127
|
+
skip('No paid product configured in this brand');
|
|
128
|
+
}
|
|
109
129
|
|
|
110
130
|
const response = await http.as('premium-cancelling').post('payments/intent', {
|
|
111
131
|
processor: 'stripe',
|
|
@@ -120,8 +140,11 @@ module.exports = {
|
|
|
120
140
|
{
|
|
121
141
|
name: 'allows-cancelled-user',
|
|
122
142
|
auth: 'premium-expired',
|
|
123
|
-
async run({ http, assert, config }) {
|
|
143
|
+
async run({ http, assert, config, skip }) {
|
|
124
144
|
const paidProduct = config.payment.products.find(p => p.id !== 'basic' && p.prices);
|
|
145
|
+
if (!paidProduct) {
|
|
146
|
+
skip('No paid product configured in this brand');
|
|
147
|
+
}
|
|
125
148
|
|
|
126
149
|
const response = await http.as('premium-expired').post('payments/intent', {
|
|
127
150
|
processor: 'test',
|
|
@@ -148,8 +171,11 @@ module.exports = {
|
|
|
148
171
|
|
|
149
172
|
{
|
|
150
173
|
name: 'rejects-unknown-processor',
|
|
151
|
-
async run({ http, assert, config }) {
|
|
174
|
+
async run({ http, assert, config, skip }) {
|
|
152
175
|
const paidProduct = config.payment.products.find(p => p.id !== 'basic' && p.prices);
|
|
176
|
+
if (!paidProduct) {
|
|
177
|
+
skip('No paid product configured in this brand');
|
|
178
|
+
}
|
|
153
179
|
|
|
154
180
|
const response = await http.as('basic').post('payments/intent', {
|
|
155
181
|
processor: 'unknown-processor',
|
|
@@ -163,8 +189,11 @@ module.exports = {
|
|
|
163
189
|
|
|
164
190
|
{
|
|
165
191
|
name: 'rejects-invalid-discount-code',
|
|
166
|
-
async run({ http, assert, config }) {
|
|
192
|
+
async run({ http, assert, config, skip }) {
|
|
167
193
|
const paidProduct = config.payment.products.find(p => p.id !== 'basic' && p.prices);
|
|
194
|
+
if (!paidProduct) {
|
|
195
|
+
skip('No paid product configured in this brand');
|
|
196
|
+
}
|
|
168
197
|
|
|
169
198
|
const response = await http.as('basic').post('payments/intent', {
|
|
170
199
|
processor: 'stripe',
|
|
@@ -179,8 +208,11 @@ module.exports = {
|
|
|
179
208
|
|
|
180
209
|
{
|
|
181
210
|
name: 'saves-discount-to-intent-doc',
|
|
182
|
-
async run({ http, assert, config, firestore }) {
|
|
211
|
+
async run({ http, assert, config, firestore, skip }) {
|
|
183
212
|
const paidProduct = config.payment.products.find(p => p.id !== 'basic' && p.prices);
|
|
213
|
+
if (!paidProduct) {
|
|
214
|
+
skip('No paid product configured in this brand');
|
|
215
|
+
}
|
|
184
216
|
|
|
185
217
|
const response = await http.as('journey-payments-intent-discount').post('payments/intent', {
|
|
186
218
|
processor: 'test',
|
|
@@ -202,8 +234,11 @@ module.exports = {
|
|
|
202
234
|
|
|
203
235
|
{
|
|
204
236
|
name: 'saves-attribution-and-supplemental-to-intent-doc',
|
|
205
|
-
async run({ http, assert, config, firestore }) {
|
|
237
|
+
async run({ http, assert, config, firestore, skip }) {
|
|
206
238
|
const paidProduct = config.payment.products.find(p => p.id !== 'basic' && p.prices);
|
|
239
|
+
if (!paidProduct) {
|
|
240
|
+
skip('No paid product configured in this brand');
|
|
241
|
+
}
|
|
207
242
|
|
|
208
243
|
const response = await http.as('journey-payments-intent-attribution').post('payments/intent', {
|
|
209
244
|
processor: 'test',
|
|
@@ -223,9 +258,12 @@ module.exports = {
|
|
|
223
258
|
|
|
224
259
|
{
|
|
225
260
|
name: 'succeeds-with-test-processor',
|
|
226
|
-
async run({ http, assert, config, firestore, accounts, waitFor }) {
|
|
261
|
+
async run({ http, assert, config, firestore, accounts, waitFor, skip }) {
|
|
227
262
|
const uid = accounts['journey-payments-intent'].uid;
|
|
228
263
|
const paidProduct = config.payment.products.find(p => p.id !== 'basic' && p.prices);
|
|
264
|
+
if (!paidProduct) {
|
|
265
|
+
skip('No paid product configured in this brand');
|
|
266
|
+
}
|
|
229
267
|
|
|
230
268
|
const response = await http.as('journey-payments-intent').post('payments/intent', {
|
|
231
269
|
processor: 'test',
|
|
@@ -256,9 +294,12 @@ module.exports = {
|
|
|
256
294
|
|
|
257
295
|
{
|
|
258
296
|
name: 'downgrades-trial-for-user-with-history',
|
|
259
|
-
async run({ http, assert, config, accounts, firestore, waitFor }) {
|
|
297
|
+
async run({ http, assert, config, accounts, firestore, waitFor, skip }) {
|
|
260
298
|
const uid = accounts['journey-payments-intent-trial'].uid;
|
|
261
299
|
const paidProduct = config.payment.products.find(p => p.id !== 'basic' && p.prices);
|
|
300
|
+
if (!paidProduct) {
|
|
301
|
+
skip('No paid product configured in this brand');
|
|
302
|
+
}
|
|
262
303
|
const orderDocPath = `payments-orders/_test-order-history-${uid}`;
|
|
263
304
|
|
|
264
305
|
// Create fake subscription history so user is ineligible for trial
|
|
@@ -56,9 +56,12 @@ module.exports = {
|
|
|
56
56
|
|
|
57
57
|
{
|
|
58
58
|
name: 'succeeds-with-test-processor',
|
|
59
|
-
async run({ http, assert, config, accounts, firestore, waitFor }) {
|
|
59
|
+
async run({ http, assert, config, accounts, firestore, waitFor, skip }) {
|
|
60
60
|
const uid = accounts['journey-payments-portal-route'].uid;
|
|
61
61
|
const paidProduct = config.payment.products.find(p => p.id !== 'basic' && p.prices?.monthly);
|
|
62
|
+
if (!paidProduct) {
|
|
63
|
+
skip('No paid product with monthly price configured in this brand');
|
|
64
|
+
}
|
|
62
65
|
|
|
63
66
|
// Set up a paid subscription with the test processor
|
|
64
67
|
const intentResponse = await http.as('journey-payments-portal-route').post('payments/intent', {
|
|
@@ -112,9 +112,12 @@ module.exports = {
|
|
|
112
112
|
|
|
113
113
|
{
|
|
114
114
|
name: 'succeeds-with-test-processor',
|
|
115
|
-
async run({ http, assert, config, accounts, firestore, waitFor }) {
|
|
115
|
+
async run({ http, assert, config, accounts, firestore, waitFor, skip }) {
|
|
116
116
|
const uid = accounts['route-refund-success'].uid;
|
|
117
117
|
const paidProduct = config.payment.products.find(p => p.id !== 'basic' && p.prices?.monthly);
|
|
118
|
+
if (!paidProduct) {
|
|
119
|
+
skip('No paid product with monthly price configured in this brand');
|
|
120
|
+
}
|
|
118
121
|
|
|
119
122
|
// Step 1: Create a test subscription intent to set up a proper paid subscription
|
|
120
123
|
const intentResponse = await http.as('route-refund-success').post('payments/intent', {
|