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.
- package/CHANGELOG.md +24 -0
- package/CLAUDE.md +5 -4
- package/docs/admin-post-route.md +2 -0
- package/docs/ai-library.md +29 -2
- 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 +60 -15
- package/package.json +1 -1
- package/src/cli/commands/install.js +2 -2
- package/src/cli/commands/setup.js +12 -4
- package/src/cli/commands/test.js +1 -1
- package/src/cli/index.js +6 -2
- 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 +23 -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/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/webhook-forward.js +26 -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,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 ``)
|
|
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: '' },
|
|
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: '' },
|
|
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: '' },
|
|
73
|
+
{ name: 'heading-2', content: '## Section' },
|
|
74
|
+
{ name: 'paragraph', content: 'Text.' },
|
|
75
|
+
{ name: 'image', content: '' },
|
|
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 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: '' },
|
|
116
|
+
{ name: 'paragraph', content: 'Text.' },
|
|
117
|
+
{ name: 'image', content: '' },
|
|
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
|
-
//
|
|
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
|