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.
Files changed (65) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/CLAUDE.md +6 -4
  3. package/bin/backend-manager +10 -1
  4. package/docs/admin-post-route.md +2 -0
  5. package/docs/ai-library.md +29 -2
  6. package/docs/cli-output.md +122 -0
  7. package/docs/common-mistakes.md +1 -1
  8. package/docs/environment-detection.md +87 -3
  9. package/docs/marketing-campaigns.md +1 -1
  10. package/docs/payment-system.md +1 -1
  11. package/docs/testing.md +64 -17
  12. package/package.json +1 -1
  13. package/src/cli/commands/base-command.js +4 -0
  14. package/src/cli/commands/install.js +2 -2
  15. package/src/cli/commands/setup-tests/bem-config.js +22 -9
  16. package/src/cli/commands/setup-tests/helpers/merge-line-files.js +22 -168
  17. package/src/cli/commands/setup.js +65 -33
  18. package/src/cli/commands/test.js +1 -1
  19. package/src/cli/index.js +72 -39
  20. package/src/cli/utils/ui.js +270 -0
  21. package/src/defaults/CLAUDE.md +2 -2
  22. package/src/defaults/test/_init.js +14 -0
  23. package/src/manager/functions/_legacy/actions/create-post-handler.js +1 -1
  24. package/src/manager/functions/core/actions/api/admin/edit-post.js +1 -1
  25. package/src/manager/functions/core/actions/api/general/send-email.js +1 -1
  26. package/src/manager/functions/core/actions/api/user/delete.js +1 -1
  27. package/src/manager/functions/core/actions/api/user/oauth2.js +1 -1
  28. package/src/manager/helpers/analytics.js +3 -1
  29. package/src/manager/helpers/assistant.js +43 -38
  30. package/src/manager/index.js +76 -12
  31. package/src/manager/libraries/ai/index.js +33 -0
  32. package/src/manager/libraries/ai/providers/openai.js +105 -8
  33. package/src/manager/libraries/content/ghostii.js +76 -16
  34. package/src/manager/libraries/email/data/disposable-domains.json +24 -0
  35. package/src/manager/libraries/email/generators/lib/image-illustrator.js +154 -0
  36. package/src/manager/libraries/email/generators/newsletter.js +16 -2
  37. package/src/manager/libraries/payment/discount-codes.js +1 -0
  38. package/src/manager/routes/admin/post/put.js +1 -1
  39. package/src/manager/routes/handler/post/post.js +1 -1
  40. package/src/manager/routes/payments/intent/processors/test.js +1 -1
  41. package/src/manager/routes/user/delete.js +1 -1
  42. package/src/manager/routes/user/signup/post.js +4 -2
  43. package/src/test/runner.js +142 -20
  44. package/src/test/test-accounts.js +159 -102
  45. package/src/utils/merge-line-files.js +16 -5
  46. package/templates/_.env +1 -0
  47. package/test/events/payments/journey-payments-cancel-endpoint.js +6 -2
  48. package/test/events/payments/journey-payments-cancel.js +6 -3
  49. package/test/events/payments/journey-payments-failure.js +6 -3
  50. package/test/events/payments/journey-payments-plan-change.js +6 -3
  51. package/test/events/payments/journey-payments-refund-webhook.js +6 -2
  52. package/test/events/payments/journey-payments-suspend.js +6 -3
  53. package/test/events/payments/journey-payments-trial-cancel.js +6 -3
  54. package/test/events/payments/journey-payments-trial.js +10 -4
  55. package/test/events/payments/journey-payments-uid-resolution.js +6 -2
  56. package/test/events/payments/journey-payments-upgrade.js +6 -3
  57. package/test/helpers/content/ghostii-blocks.js +134 -0
  58. package/test/helpers/environment.js +230 -0
  59. package/test/helpers/merge-line-files.js +271 -0
  60. package/test/routes/marketing/webhook-forward.js +14 -7
  61. package/test/routes/payments/cancel.js +4 -1
  62. package/test/routes/payments/dispute-alert.js +9 -6
  63. package/test/routes/payments/intent.js +55 -14
  64. package/test/routes/payments/portal.js +4 -1
  65. 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
- // Sanity check: this brand should NOT be configured as the parent
26
- assert.ok(
27
- config.parent && config.parent !== 'self',
28
- `Test brand should have config.parent set to a URL (got: ${config.parent})`
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
- // Send first alert
243
- await http.as('none').post(`payments/dispute-alert?key=${process.env.BACKEND_MANAGER_WEBHOOK_KEY}`, {
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
- card: '4242',
246
- amount: 29.99,
247
- transactionDate: '2026-03-07',
250
+ status: 'processing',
248
251
  });
249
252
 
250
- // Send duplicate
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', {