backend-manager 5.2.19 → 5.3.1

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 (57) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/CLAUDE.md +5 -4
  3. package/docs/admin-post-route.md +2 -0
  4. package/docs/ai-library.md +29 -2
  5. package/docs/common-mistakes.md +1 -1
  6. package/docs/environment-detection.md +87 -3
  7. package/docs/marketing-campaigns.md +1 -1
  8. package/docs/payment-system.md +1 -1
  9. package/docs/testing.md +60 -15
  10. package/package.json +1 -1
  11. package/src/cli/commands/install.js +2 -2
  12. package/src/cli/commands/setup.js +12 -4
  13. package/src/cli/commands/test.js +1 -1
  14. package/src/cli/index.js +6 -2
  15. package/src/defaults/CLAUDE.md +2 -2
  16. package/src/defaults/test/_init.js +14 -0
  17. package/src/manager/functions/_legacy/actions/create-post-handler.js +1 -1
  18. package/src/manager/functions/core/actions/api/admin/edit-post.js +1 -1
  19. package/src/manager/functions/core/actions/api/general/send-email.js +1 -1
  20. package/src/manager/functions/core/actions/api/user/delete.js +1 -1
  21. package/src/manager/functions/core/actions/api/user/oauth2.js +1 -1
  22. package/src/manager/helpers/analytics.js +3 -1
  23. package/src/manager/helpers/assistant.js +43 -38
  24. package/src/manager/index.js +76 -12
  25. package/src/manager/libraries/ai/index.js +33 -0
  26. package/src/manager/libraries/ai/providers/openai.js +105 -8
  27. package/src/manager/libraries/content/ghostii.js +76 -16
  28. package/src/manager/libraries/email/data/disposable-domains.json +23 -0
  29. package/src/manager/libraries/email/generators/lib/image-illustrator.js +154 -0
  30. package/src/manager/libraries/email/generators/newsletter.js +16 -2
  31. package/src/manager/libraries/payment/discount-codes.js +1 -0
  32. package/src/manager/routes/admin/post/put.js +1 -1
  33. package/src/manager/routes/handler/post/post.js +1 -1
  34. package/src/manager/routes/payments/intent/processors/test.js +1 -1
  35. package/src/manager/routes/user/delete.js +1 -1
  36. package/src/manager/routes/user/signup/post.js +4 -2
  37. package/src/test/runner.js +142 -20
  38. package/src/test/test-accounts.js +159 -102
  39. package/test/events/payments/journey-payments-cancel-endpoint.js +6 -2
  40. package/test/events/payments/journey-payments-cancel.js +6 -3
  41. package/test/events/payments/journey-payments-failure.js +6 -3
  42. package/test/events/payments/journey-payments-plan-change.js +6 -3
  43. package/test/events/payments/journey-payments-refund-webhook.js +6 -2
  44. package/test/events/payments/journey-payments-suspend.js +6 -3
  45. package/test/events/payments/journey-payments-trial-cancel.js +6 -3
  46. package/test/events/payments/journey-payments-trial.js +10 -4
  47. package/test/events/payments/journey-payments-uid-resolution.js +6 -2
  48. package/test/events/payments/journey-payments-upgrade.js +6 -3
  49. package/test/helpers/content/ghostii-blocks.js +134 -0
  50. package/test/helpers/environment.js +230 -0
  51. package/test/helpers/webhook-forward.js +26 -0
  52. package/test/routes/marketing/webhook-forward.js +14 -7
  53. package/test/routes/payments/cancel.js +4 -1
  54. package/test/routes/payments/dispute-alert.js +9 -6
  55. package/test/routes/payments/intent.js +55 -14
  56. package/test/routes/payments/portal.js +4 -1
  57. package/test/routes/payments/refund.js +4 -1
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Test: content/ghostii.blocksToPost()
3
+ * Unit tests for the Ghostii-JSON → BEM-post transform.
4
+ *
5
+ * Run: npx mgr test helpers/content/ghostii-blocks
6
+ *
7
+ * Ghostii is unopinionated about BEM: its /write/article response is a generic
8
+ * block array ([{ name, content }], name ∈ heading-1..6/image/paragraph/blockquote/list).
9
+ * blocksToPost() is the SSOT that turns those blocks into what admin/post wants —
10
+ * title + header image as SEPARATE fields, body = content only.
11
+ *
12
+ * Pure function (no I/O) → required directly and called with plain inputs. NOT a mock.
13
+ *
14
+ * Contract:
15
+ * - title ← first heading-1 block, leading markdown `#` stripped
16
+ * - headerImageUrl ← first image block's URL (from `![alt](url)`)
17
+ * - body ← every remaining block, joined with blank lines, trimmed
18
+ * - the title block and the header-image block are EXCLUDED from body
19
+ * - section images (non-first image blocks) STAY in the body
20
+ * - non-array / empty input → all empty strings
21
+ */
22
+ const { blocksToPost } = require('../../../src/manager/libraries/content/ghostii.js');
23
+
24
+ module.exports = {
25
+ description: 'content/ghostii.blocksToPost()',
26
+ type: 'group',
27
+
28
+ tests: [
29
+ {
30
+ name: 'extracts-title-from-heading-1-stripping-marker',
31
+ async run({ assert }) {
32
+ const result = blocksToPost([
33
+ { name: 'heading-1', content: '# My Great Article' },
34
+ { name: 'paragraph', content: 'Body.' },
35
+ ]);
36
+ assert.equal(result.title, 'My Great Article');
37
+ },
38
+ },
39
+
40
+ {
41
+ name: 'extracts-header-image-url-from-first-image-block',
42
+ async run({ assert }) {
43
+ const result = blocksToPost([
44
+ { name: 'heading-1', content: '# Title' },
45
+ { name: 'image', content: '![Title](https://images.unsplash.com/photo-abc?w=1080)' },
46
+ { name: 'paragraph', content: 'Body.' },
47
+ ]);
48
+ assert.equal(result.headerImageUrl, 'https://images.unsplash.com/photo-abc?w=1080');
49
+ },
50
+ },
51
+
52
+ {
53
+ name: 'body-excludes-title-and-header-image',
54
+ async run({ assert }) {
55
+ const result = blocksToPost([
56
+ { name: 'heading-1', content: '# Title' },
57
+ { name: 'image', content: '![Title](https://img/header.jpg)' },
58
+ { name: 'heading-2', content: '## First Section' },
59
+ { name: 'paragraph', content: 'First paragraph.' },
60
+ ]);
61
+ assert.equal(result.body, '## First Section\n\nFirst paragraph.', 'body is content only');
62
+ assert.notMatch(result.body, /^#\s/m, 'no H1 title in body');
63
+ assert.notMatch(result.body, /^!\[Title\]\(https:\/\/img\/header\.jpg\)/m, 'no header image in body');
64
+ },
65
+ },
66
+
67
+ {
68
+ name: 'keeps-section-images-in-body',
69
+ async run({ assert }) {
70
+ const result = blocksToPost([
71
+ { name: 'heading-1', content: '# Title' },
72
+ { name: 'image', content: '![Title](https://img/header.jpg)' },
73
+ { name: 'heading-2', content: '## Section' },
74
+ { name: 'paragraph', content: 'Text.' },
75
+ { name: 'image', content: '![Section](https://img/section.jpg)' },
76
+ { name: 'paragraph', content: 'More text.' },
77
+ ]);
78
+ // Only the FIRST image is the header; later images belong to the body.
79
+ assert.ok(result.body.includes('![Section](https://img/section.jpg)'), 'section image stays in body');
80
+ assert.equal((result.body.match(/!\[/g) || []).length, 1, 'exactly one (section) image in body');
81
+ },
82
+ },
83
+
84
+ {
85
+ name: 'preserves-blockquotes-and-lists-in-body',
86
+ async run({ assert }) {
87
+ const result = blocksToPost([
88
+ { name: 'heading-1', content: '# Title' },
89
+ { name: 'heading-2', content: '## Section' },
90
+ { name: 'blockquote', content: '> A pithy quote.' },
91
+ { name: 'list', content: '- one\n- two' },
92
+ ]);
93
+ assert.ok(result.body.includes('> A pithy quote.'), 'blockquote kept');
94
+ assert.ok(result.body.includes('- one\n- two'), 'list kept');
95
+ },
96
+ },
97
+
98
+ {
99
+ name: 'handles-missing-heading-1-and-image-gracefully',
100
+ async run({ assert }) {
101
+ const result = blocksToPost([
102
+ { name: 'heading-2', content: '## Only Section' },
103
+ { name: 'paragraph', content: 'Text.' },
104
+ ]);
105
+ assert.equal(result.title, '', 'no title when no heading-1');
106
+ assert.equal(result.headerImageUrl, '', 'no header image when no image block');
107
+ assert.equal(result.body, '## Only Section\n\nText.', 'body is all blocks when nothing excluded');
108
+ },
109
+ },
110
+
111
+ {
112
+ name: 'uses-only-the-first-image-as-header',
113
+ async run({ assert }) {
114
+ const result = blocksToPost([
115
+ { name: 'image', content: '![first](https://img/first.jpg)' },
116
+ { name: 'paragraph', content: 'Text.' },
117
+ { name: 'image', content: '![second](https://img/second.jpg)' },
118
+ ]);
119
+ assert.equal(result.headerImageUrl, 'https://img/first.jpg', 'first image is the header');
120
+ assert.ok(result.body.includes('https://img/second.jpg'), 'second image stays in body');
121
+ assert.ok(!result.body.includes('https://img/first.jpg'), 'first image not in body');
122
+ },
123
+ },
124
+
125
+ {
126
+ name: 'non-array-or-empty-input-returns-empty-strings',
127
+ async run({ assert }) {
128
+ assert.deepEqual(blocksToPost([]), { title: '', headerImageUrl: '', body: '' }, 'empty array');
129
+ assert.deepEqual(blocksToPost(null), { title: '', headerImageUrl: '', body: '' }, 'null');
130
+ assert.deepEqual(blocksToPost(undefined), { title: '', headerImageUrl: '', body: '' }, 'undefined');
131
+ },
132
+ },
133
+ ],
134
+ };
@@ -0,0 +1,230 @@
1
+ /**
2
+ * Test: environment detection + URL helpers
3
+ * Covers the Manager's getEnvironment() SSOT, the derived is*() checks, the URL
4
+ * builders (getApiUrl / getFunctionsUrl / getWebsiteUrl + parent variants), and the
5
+ * assistant→Manager forwarding.
6
+ *
7
+ * Run: npx mgr test bem:helpers/environment
8
+ *
9
+ * Contract (see docs/environment-detection.md):
10
+ * - getEnvironment() is the SINGLE SOURCE OF TRUTH — the only reader of the raw env
11
+ * vars (BEM_TESTING / ENVIRONMENT / FUNCTIONS_EMULATOR / TERM_PROGRAM). Returns
12
+ * exactly ONE of 'development' | 'testing' | 'production' (testing wins).
13
+ * - isDevelopment()/isProduction()/isTesting() DERIVE from getEnvironment() — they
14
+ * never read raw signals, so they can NEVER disagree with it. Exactly one is true.
15
+ * - getApiUrl/getFunctionsUrl/getWebsiteUrl resolve LOCAL in dev OR testing, prod
16
+ * otherwise. The parent helpers ALWAYS return the live URL (no localhost).
17
+ * - The assistant forwards each method to its Manager (identical results).
18
+ */
19
+
20
+ // Run a thunk with the env-detection vars cleared, restoring them afterward. These are
21
+ // the only inputs getEnvironment() reads, so clearing them gives a clean slate per case.
22
+ function withEnv(overrides, fn) {
23
+ const KEYS = ['BEM_TESTING', 'ENVIRONMENT', 'FUNCTIONS_EMULATOR', 'TERM_PROGRAM'];
24
+ const saved = {};
25
+ for (const k of KEYS) saved[k] = process.env[k];
26
+ try {
27
+ for (const k of KEYS) delete process.env[k];
28
+ for (const k of Object.keys(overrides)) process.env[k] = overrides[k];
29
+ return fn();
30
+ } finally {
31
+ for (const k of KEYS) {
32
+ if (saved[k] === undefined) delete process.env[k];
33
+ else process.env[k] = saved[k];
34
+ }
35
+ }
36
+ }
37
+
38
+ module.exports = {
39
+ description: 'Environment detection + URL helpers',
40
+ type: 'group',
41
+
42
+ tests: [
43
+ // ─── getEnvironment() resolution + precedence ───
44
+
45
+ {
46
+ name: 'getEnvironment: testing wins over everything (BEM_TESTING=true)',
47
+ async run({ Manager, assert }) {
48
+ withEnv({ BEM_TESTING: 'true', ENVIRONMENT: 'production' }, () => {
49
+ assert.equal(Manager.getEnvironment(), 'testing');
50
+ });
51
+ },
52
+ },
53
+ {
54
+ name: 'getEnvironment: production when ENVIRONMENT=production (not testing)',
55
+ async run({ Manager, assert }) {
56
+ withEnv({ ENVIRONMENT: 'production' }, () => {
57
+ assert.equal(Manager.getEnvironment(), 'production');
58
+ });
59
+ },
60
+ },
61
+ {
62
+ name: 'getEnvironment: development when ENVIRONMENT=development',
63
+ async run({ Manager, assert }) {
64
+ withEnv({ ENVIRONMENT: 'development' }, () => {
65
+ assert.equal(Manager.getEnvironment(), 'development');
66
+ });
67
+ },
68
+ },
69
+ {
70
+ name: 'getEnvironment: development when FUNCTIONS_EMULATOR is set',
71
+ async run({ Manager, assert }) {
72
+ withEnv({ FUNCTIONS_EMULATOR: 'true' }, () => {
73
+ assert.equal(Manager.getEnvironment(), 'development');
74
+ });
75
+ },
76
+ },
77
+ {
78
+ name: 'getEnvironment: defaults to production with no signal (deployed function)',
79
+ async run({ Manager, assert }) {
80
+ withEnv({}, () => {
81
+ assert.equal(Manager.getEnvironment(), 'production');
82
+ });
83
+ },
84
+ },
85
+
86
+ // ─── The KEY invariant: is*() derive from getEnvironment() and can NEVER disagree ───
87
+
88
+ {
89
+ name: 'invariant: is*() exactly matches getEnvironment() across every scenario',
90
+ async run({ Manager, assert }) {
91
+ const scenarios = [
92
+ { env: { BEM_TESTING: 'true', ENVIRONMENT: 'production' }, expect: 'testing' },
93
+ { env: { ENVIRONMENT: 'production' }, expect: 'production' },
94
+ { env: { ENVIRONMENT: 'development' }, expect: 'development' },
95
+ { env: { FUNCTIONS_EMULATOR: 'true' }, expect: 'development' },
96
+ { env: {}, expect: 'production' },
97
+ ];
98
+ for (const s of scenarios) {
99
+ withEnv(s.env, () => {
100
+ const e = Manager.getEnvironment();
101
+ assert.equal(e, s.expect, `getEnvironment for ${JSON.stringify(s.env)}`);
102
+ // Each is*() must equal (getEnvironment() === its value) — no independent reads.
103
+ assert.equal(Manager.isDevelopment(), e === 'development', `isDevelopment for ${e}`);
104
+ assert.equal(Manager.isTesting(), e === 'testing', `isTesting for ${e}`);
105
+ assert.equal(Manager.isProduction(), e === 'production', `isProduction for ${e}`);
106
+ });
107
+ }
108
+ },
109
+ },
110
+ {
111
+ name: 'invariant: exactly one of is*() is true in every scenario (mutually exclusive)',
112
+ async run({ Manager, assert }) {
113
+ const envs = [
114
+ { BEM_TESTING: 'true' },
115
+ { ENVIRONMENT: 'production' },
116
+ { ENVIRONMENT: 'development' },
117
+ { FUNCTIONS_EMULATOR: 'true' },
118
+ {},
119
+ ];
120
+ for (const env of envs) {
121
+ withEnv(env, () => {
122
+ const trueCount = [Manager.isDevelopment(), Manager.isTesting(), Manager.isProduction()]
123
+ .filter(Boolean).length;
124
+ assert.equal(trueCount, 1, `exactly one true for ${JSON.stringify(env)}`);
125
+ });
126
+ }
127
+ },
128
+ },
129
+ {
130
+ name: 'isProduction is a real positive check (NOT just !isDevelopment) — false in testing',
131
+ async run({ Manager, assert }) {
132
+ withEnv({ BEM_TESTING: 'true' }, () => {
133
+ assert.equal(Manager.isDevelopment(), false, 'isDevelopment false in testing');
134
+ assert.equal(Manager.isProduction(), false, 'isProduction false in testing');
135
+ assert.equal(Manager.isTesting(), true, 'isTesting true in testing');
136
+ });
137
+ },
138
+ },
139
+
140
+ // ─── assistant forwards to the Manager (identical results) ───
141
+
142
+ {
143
+ name: 'assistant forwards getEnvironment()/is*() to the Manager (identical)',
144
+ async run({ Manager, assistant, assert }) {
145
+ const cases = [
146
+ { BEM_TESTING: 'true' },
147
+ { ENVIRONMENT: 'production' },
148
+ { ENVIRONMENT: 'development' },
149
+ ];
150
+ for (const env of cases) {
151
+ withEnv(env, () => {
152
+ assert.equal(assistant.getEnvironment(), Manager.getEnvironment(), 'getEnvironment forward');
153
+ assert.equal(assistant.isDevelopment(), Manager.isDevelopment(), 'isDevelopment forward');
154
+ assert.equal(assistant.isTesting(), Manager.isTesting(), 'isTesting forward');
155
+ assert.equal(assistant.isProduction(), Manager.isProduction(), 'isProduction forward');
156
+ });
157
+ }
158
+ },
159
+ },
160
+
161
+ // ─── URL helpers: local in dev/testing, production otherwise ───
162
+
163
+ {
164
+ name: 'getApiUrl: localhost in development AND testing, prod otherwise',
165
+ async run({ Manager, assert }) {
166
+ withEnv({ FUNCTIONS_EMULATOR: 'true' }, () => {
167
+ assert.equal(Manager.getApiUrl(), 'http://localhost:5002', 'dev → localhost');
168
+ });
169
+ withEnv({ BEM_TESTING: 'true' }, () => {
170
+ assert.equal(Manager.getApiUrl(), 'http://localhost:5002', 'testing → localhost');
171
+ });
172
+ withEnv({ ENVIRONMENT: 'production' }, () => {
173
+ assert.match(Manager.getApiUrl(), /^https:\/\/api\./, 'prod → api.<domain>');
174
+ });
175
+ },
176
+ },
177
+ {
178
+ name: 'getApiUrl: explicit env arg overrides current environment',
179
+ async run({ Manager, assert }) {
180
+ // Under the test harness we're in 'testing', but an explicit arg forces the mapping.
181
+ assert.equal(Manager.getApiUrl('development'), 'http://localhost:5002', "arg 'development' → localhost");
182
+ assert.match(Manager.getApiUrl('production'), /^https:\/\/api\./, "arg 'production' → prod");
183
+ },
184
+ },
185
+ {
186
+ name: 'getFunctionsUrl: localhost in development AND testing, cloudfunctions otherwise',
187
+ async run({ Manager, assert }) {
188
+ withEnv({ FUNCTIONS_EMULATOR: 'true' }, () => {
189
+ assert.match(Manager.getFunctionsUrl(), /^http:\/\/localhost:5001\//, 'dev → localhost:5001');
190
+ });
191
+ withEnv({ BEM_TESTING: 'true' }, () => {
192
+ assert.match(Manager.getFunctionsUrl(), /^http:\/\/localhost:5001\//, 'testing → localhost:5001');
193
+ });
194
+ withEnv({ ENVIRONMENT: 'production' }, () => {
195
+ assert.match(Manager.getFunctionsUrl(), /cloudfunctions\.net$/, 'prod → cloudfunctions.net');
196
+ });
197
+ },
198
+ },
199
+ {
200
+ name: 'getWebsiteUrl: localhost:4000 in development AND testing, brand.url otherwise',
201
+ async run({ Manager, assert }) {
202
+ withEnv({ FUNCTIONS_EMULATOR: 'true' }, () => {
203
+ assert.equal(Manager.getWebsiteUrl(), 'https://localhost:4000', 'dev → localhost:4000');
204
+ });
205
+ withEnv({ BEM_TESTING: 'true' }, () => {
206
+ assert.equal(Manager.getWebsiteUrl(), 'https://localhost:4000', 'testing → localhost:4000');
207
+ });
208
+ withEnv({ ENVIRONMENT: 'production' }, () => {
209
+ const url = Manager.getWebsiteUrl();
210
+ assert.equal(url.startsWith('https://localhost'), false, 'prod → NOT localhost');
211
+ });
212
+ },
213
+ },
214
+
215
+ // ─── Parent helpers ALWAYS resolve live (never localhost), even in dev/testing ───
216
+
217
+ {
218
+ name: 'getParentApiUrl / getParentUrl never redirect to localhost (always live)',
219
+ async run({ Manager, assert }) {
220
+ // Even under the test harness ('testing'), the parent is a real remote server.
221
+ const parentUrl = Manager.getParentUrl();
222
+ const parentApi = Manager.getParentApiUrl();
223
+ assert.equal((parentUrl || '').includes('localhost'), false, 'getParentUrl not localhost');
224
+ assert.equal((parentApi || '').includes('localhost'), false, 'getParentApiUrl not localhost');
225
+ // When set, the parent API URL carries the api. subdomain.
226
+ if (parentApi) assert.match(parentApi, /^https:\/\/api\./, 'parent api uses api. subdomain');
227
+ },
228
+ },
229
+ ],
230
+ };
@@ -21,7 +21,15 @@
21
21
 
22
22
  // Mock wonderful-fetch before requiring the route — the route does
23
23
  // `require('wonderful-fetch')` at module load time.
24
+ //
25
+ // CRITICAL: every test file is `require()`d into the SAME Node process by the
26
+ // runner, so the require cache is shared. We MUST save the original
27
+ // wonderful-fetch cache entry here and restore it in the suite-level `cleanup`
28
+ // below — otherwise this mock leaks into every test file that runs afterwards
29
+ // (e.g. emulator route tests whose `Manager.require('wonderful-fetch')` would
30
+ // then return our stub instead of doing a real HTTP round-trip).
24
31
  const originalFetchPath = require.resolve('wonderful-fetch');
32
+ const originalFetchCacheEntry = require.cache[originalFetchPath];
25
33
  const fetchCalls = [];
26
34
  let fetchMockBehavior = () => ({ received: true });
27
35
 
@@ -38,6 +46,17 @@ require.cache[originalFetchPath] = {
38
46
 
39
47
  const route = require('../../src/manager/routes/marketing/webhook/forward/post.js');
40
48
 
49
+ // Restore the real wonderful-fetch in the require cache so the mock is confined
50
+ // to this file. Without this, the stub leaks process-wide and poisons every
51
+ // later test file's HTTP calls.
52
+ function restoreFetch() {
53
+ if (originalFetchCacheEntry) {
54
+ require.cache[originalFetchPath] = originalFetchCacheEntry;
55
+ } else {
56
+ delete require.cache[originalFetchPath];
57
+ }
58
+ }
59
+
41
60
  // --- Test scaffolding ---
42
61
 
43
62
  function makeFirestoreMock(brandDocs) {
@@ -114,6 +133,13 @@ function withEnv(envOverrides, fn) {
114
133
  module.exports = {
115
134
  description: 'webhook/forward unit tests (mocked admin + fetch)',
116
135
  type: 'group',
136
+
137
+ // Restore the real wonderful-fetch after this file's tests so the require-cache
138
+ // stub does not leak into any test file that runs later in the same process.
139
+ cleanup() {
140
+ restoreFetch();
141
+ },
142
+
117
143
  tests: [
118
144
  // ─── Gating ───
119
145
 
@@ -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