backend-manager 5.1.4 → 5.2.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/.claude/settings.local.json +12 -0
- package/CHANGELOG.md +29 -0
- package/CLAUDE.md +2 -1
- package/README.md +15 -0
- package/docs/common-mistakes.md +1 -0
- package/docs/consent.md +333 -0
- package/docs/testing.md +36 -0
- package/package.json +1 -1
- package/src/cli/commands/emulator.js +44 -8
- package/src/cli/commands/serve.js +73 -7
- package/src/cli/commands/test.js +47 -1
- package/src/cli/commands/watch.js +15 -3
- package/src/manager/helpers/user.js +29 -0
- package/src/manager/index.js +29 -0
- package/src/manager/libraries/email/data/disposable-domains.json +8 -0
- package/src/manager/libraries/email/generators/newsletter.js +2 -2
- package/src/manager/libraries/email/providers/beehiiv.js +1 -0
- package/src/manager/libraries/payment/processors/stripe.js +12 -0
- package/src/manager/libraries/payment/processors/test.js +8 -1
- package/src/manager/routes/admin/infer-contact/post.js +3 -2
- package/src/manager/routes/marketing/email-preferences/post.js +165 -37
- package/src/manager/routes/marketing/webhook/forward/post.js +168 -0
- package/src/manager/routes/marketing/webhook/post.js +180 -0
- package/src/manager/routes/marketing/webhook/processors/beehiiv.js +196 -0
- package/src/manager/routes/marketing/webhook/processors/sendgrid.js +171 -0
- package/src/manager/routes/payments/cancel/post.js +2 -2
- package/src/manager/routes/payments/cancel/processors/test.js +5 -2
- package/src/manager/routes/payments/intent/processors/test.js +7 -3
- package/src/manager/routes/payments/refund/processors/test.js +4 -1
- package/src/manager/routes/user/signup/post.js +65 -1
- package/src/manager/schemas/marketing/email-preferences/post.js +8 -4
- package/src/manager/schemas/marketing/webhook/forward/post.js +8 -0
- package/src/manager/schemas/marketing/webhook/post.js +7 -0
- package/src/manager/schemas/payments/cancel/post.js +5 -0
- package/src/manager/schemas/user/signup/post.js +5 -0
- package/src/test/runner.js +61 -18
- package/src/test/test-accounts.js +94 -12
- package/src/test/utils/http-client.js +4 -3
- package/templates/_.env +1 -0
- package/test/events/payments/journey-payments-cancel-endpoint.js +3 -12
- package/test/events/payments/journey-payments-cancel.js +4 -5
- package/test/events/payments/journey-payments-failure.js +0 -1
- package/test/events/payments/journey-payments-one-time-failure.js +6 -3
- package/test/events/payments/journey-payments-one-time.js +6 -3
- package/test/events/payments/journey-payments-plan-change.js +5 -5
- package/test/events/payments/journey-payments-refund-webhook.js +2 -3
- package/test/events/payments/journey-payments-suspend.js +4 -5
- package/test/events/payments/journey-payments-trial-cancel.js +3 -12
- package/test/events/payments/journey-payments-trial.js +2 -3
- package/test/events/payments/journey-payments-uid-resolution.js +2 -3
- package/test/functions/admin/database-read.js +0 -14
- package/test/functions/admin/database-write.js +0 -14
- package/test/functions/admin/firestore-query.js +0 -14
- package/test/functions/admin/firestore-read.js +0 -15
- package/test/functions/admin/firestore-write.js +0 -11
- package/test/functions/general/add-marketing-contact.js +16 -14
- package/test/helpers/email.js +1 -1
- package/test/helpers/infer-contact.js +3 -3
- package/test/helpers/user.js +241 -2
- package/test/helpers/webhook-forward.js +392 -0
- package/test/marketing/newsletter-generate.js +17 -7
- package/test/routes/admin/database.js +0 -13
- package/test/routes/admin/firestore-query.js +0 -13
- package/test/routes/admin/firestore.js +0 -14
- package/test/routes/admin/infer-contact.js +6 -3
- package/test/routes/admin/post.js +4 -2
- package/test/routes/marketing/contact.js +60 -26
- package/test/routes/marketing/email-preferences.js +145 -69
- package/test/routes/marketing/webhook-forward.js +54 -0
- package/test/routes/marketing/webhook.js +582 -0
- package/test/routes/payments/cancel.js +2 -7
- package/test/routes/payments/dispute-alert.js +0 -39
- package/test/routes/payments/refund.js +3 -1
- package/test/routes/payments/webhook.js +5 -26
- package/test/routes/test/usage.js +2 -2
- package/test/routes/user/signup.js +114 -0
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test: marketing/webhook/forward route — unit-style coverage
|
|
3
|
+
*
|
|
4
|
+
* Why this exists as a unit test (not an emulator test):
|
|
5
|
+
*
|
|
6
|
+
* The forwarder is gated on Manager.isParent() (config.parent === 'self'). In real test runs
|
|
7
|
+
* we run AGAINST a child brand's BEM (Somiibo, etc.), so the route is invisible
|
|
8
|
+
* (404). To verify the fan-out logic, we exercise the route handler directly
|
|
9
|
+
* against a mocked admin SDK + mocked fetch, no HTTP needed.
|
|
10
|
+
*
|
|
11
|
+
* What's covered:
|
|
12
|
+
* - Gate: returns 404 if Manager.isParent() returns false (config.parent !== 'self')
|
|
13
|
+
* - Auth: returns 401 if key missing/wrong
|
|
14
|
+
* - Provider validation: returns 400 if missing
|
|
15
|
+
* - Brand iteration: reads brands collection, derives API URLs
|
|
16
|
+
* - URL derivation: brand.url 'https://somiibo.com' → 'https://api.somiibo.com/backend-manager/marketing/webhook?provider=X&key=Y'
|
|
17
|
+
* - Body forwarding: raw body POSTed to every child unchanged
|
|
18
|
+
* - Failure isolation: one failed child doesn't break the others
|
|
19
|
+
* - Brands without brand.url skipped silently
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
// Mock wonderful-fetch before requiring the route — the route does
|
|
23
|
+
// `require('wonderful-fetch')` at module load time.
|
|
24
|
+
const originalFetchPath = require.resolve('wonderful-fetch');
|
|
25
|
+
const fetchCalls = [];
|
|
26
|
+
let fetchMockBehavior = () => ({ received: true });
|
|
27
|
+
|
|
28
|
+
// Intercept require cache so our mock takes the place of wonderful-fetch
|
|
29
|
+
require.cache[originalFetchPath] = {
|
|
30
|
+
id: originalFetchPath,
|
|
31
|
+
filename: originalFetchPath,
|
|
32
|
+
loaded: true,
|
|
33
|
+
exports: async (url, opts) => {
|
|
34
|
+
fetchCalls.push({ url, opts });
|
|
35
|
+
return fetchMockBehavior(url, opts);
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const route = require('../../src/manager/routes/marketing/webhook/forward/post.js');
|
|
40
|
+
|
|
41
|
+
// --- Test scaffolding ---
|
|
42
|
+
|
|
43
|
+
function makeFirestoreMock(brandDocs) {
|
|
44
|
+
return {
|
|
45
|
+
collection: (name) => {
|
|
46
|
+
if (name !== 'brands') {
|
|
47
|
+
throw new Error(`Unexpected collection: ${name}`);
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
get: async () => ({
|
|
51
|
+
forEach: (cb) => {
|
|
52
|
+
for (const { id, data } of brandDocs) {
|
|
53
|
+
cb({ id, data: () => data });
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
}),
|
|
57
|
+
};
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function makeAdminMock(brandDocs) {
|
|
63
|
+
const fs = makeFirestoreMock(brandDocs);
|
|
64
|
+
return { firestore: () => fs };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function makeAssistant({ query, body }) {
|
|
68
|
+
const responses = [];
|
|
69
|
+
return {
|
|
70
|
+
request: { query: query || {} },
|
|
71
|
+
ref: { req: { body: body !== undefined ? body : [] } },
|
|
72
|
+
log: () => {},
|
|
73
|
+
error: () => {},
|
|
74
|
+
respond: (data, opts) => {
|
|
75
|
+
responses.push({ data, code: opts?.code || 200 });
|
|
76
|
+
return { data, code: opts?.code || 200 };
|
|
77
|
+
},
|
|
78
|
+
_responses: responses,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function makeManager(configOverrides) {
|
|
83
|
+
const config = {
|
|
84
|
+
parent: 'self',
|
|
85
|
+
...configOverrides,
|
|
86
|
+
};
|
|
87
|
+
return {
|
|
88
|
+
config,
|
|
89
|
+
libraries: {}, // Not used in this route — admin comes in via libraries arg
|
|
90
|
+
isParent: () => config.parent === 'self',
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function resetFetchMock() {
|
|
95
|
+
fetchCalls.length = 0;
|
|
96
|
+
fetchMockBehavior = () => ({ received: true });
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Saves and restores process.env so tests don't leak side effects
|
|
100
|
+
function withEnv(envOverrides, fn) {
|
|
101
|
+
const saved = {};
|
|
102
|
+
for (const k of Object.keys(envOverrides)) {
|
|
103
|
+
saved[k] = process.env[k];
|
|
104
|
+
process.env[k] = envOverrides[k];
|
|
105
|
+
}
|
|
106
|
+
return Promise.resolve(fn()).finally(() => {
|
|
107
|
+
for (const k of Object.keys(envOverrides)) {
|
|
108
|
+
if (saved[k] === undefined) delete process.env[k];
|
|
109
|
+
else process.env[k] = saved[k];
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
module.exports = {
|
|
115
|
+
description: 'webhook/forward unit tests (mocked admin + fetch)',
|
|
116
|
+
type: 'group',
|
|
117
|
+
tests: [
|
|
118
|
+
// ─── Gating ───
|
|
119
|
+
|
|
120
|
+
{
|
|
121
|
+
name: 'returns-404-when-parent-not-self',
|
|
122
|
+
async run({ assert }) {
|
|
123
|
+
await withEnv({ BACKEND_MANAGER_WEBHOOK_KEY: 'test-key' }, async () => {
|
|
124
|
+
resetFetchMock();
|
|
125
|
+
const assistant = makeAssistant({ query: { provider: 'sendgrid', key: 'test-key' } });
|
|
126
|
+
const Manager = makeManager({ parent: 'https://api.itwcreativeworks.com' }); // NOT 'self'
|
|
127
|
+
const admin = makeAdminMock([]);
|
|
128
|
+
|
|
129
|
+
await route({ assistant, Manager, libraries: { admin } });
|
|
130
|
+
|
|
131
|
+
assert.equal(assistant._responses.length, 1, 'should respond once');
|
|
132
|
+
assert.equal(assistant._responses[0].code, 404, 'should return 404');
|
|
133
|
+
assert.equal(fetchCalls.length, 0, 'should NOT call fetch when gated');
|
|
134
|
+
});
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
|
|
138
|
+
{
|
|
139
|
+
name: 'allows-route-when-parent-is-self',
|
|
140
|
+
async run({ assert }) {
|
|
141
|
+
await withEnv({ BACKEND_MANAGER_WEBHOOK_KEY: 'test-key' }, async () => {
|
|
142
|
+
resetFetchMock();
|
|
143
|
+
const assistant = makeAssistant({ query: { provider: 'sendgrid', key: 'test-key' } });
|
|
144
|
+
const Manager = makeManager({ parent: 'self' });
|
|
145
|
+
const admin = makeAdminMock([]); // No brands — but route still reaches the fan-out step
|
|
146
|
+
|
|
147
|
+
await route({ assistant, Manager, libraries: { admin } });
|
|
148
|
+
|
|
149
|
+
assert.equal(assistant._responses[0].code, 200, 'should return 200 when parent: self');
|
|
150
|
+
assert.equal(assistant._responses[0].data.forwarded, 0, 'no brands means 0 forwarded');
|
|
151
|
+
});
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
|
|
155
|
+
// ─── Auth ───
|
|
156
|
+
|
|
157
|
+
{
|
|
158
|
+
name: 'rejects-missing-provider',
|
|
159
|
+
async run({ assert }) {
|
|
160
|
+
await withEnv({ BACKEND_MANAGER_WEBHOOK_KEY: 'test-key' }, async () => {
|
|
161
|
+
resetFetchMock();
|
|
162
|
+
const assistant = makeAssistant({ query: { key: 'test-key' } }); // no provider
|
|
163
|
+
const Manager = makeManager();
|
|
164
|
+
const admin = makeAdminMock([]);
|
|
165
|
+
|
|
166
|
+
await route({ assistant, Manager, libraries: { admin } });
|
|
167
|
+
|
|
168
|
+
assert.equal(assistant._responses[0].code, 400, 'missing provider → 400');
|
|
169
|
+
});
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
|
|
173
|
+
{
|
|
174
|
+
name: 'rejects-missing-key',
|
|
175
|
+
async run({ assert }) {
|
|
176
|
+
await withEnv({ BACKEND_MANAGER_WEBHOOK_KEY: 'test-key' }, async () => {
|
|
177
|
+
resetFetchMock();
|
|
178
|
+
const assistant = makeAssistant({ query: { provider: 'sendgrid' } }); // no key
|
|
179
|
+
const Manager = makeManager();
|
|
180
|
+
const admin = makeAdminMock([]);
|
|
181
|
+
|
|
182
|
+
await route({ assistant, Manager, libraries: { admin } });
|
|
183
|
+
|
|
184
|
+
assert.equal(assistant._responses[0].code, 401, 'missing key → 401');
|
|
185
|
+
});
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
|
|
189
|
+
{
|
|
190
|
+
name: 'rejects-wrong-key',
|
|
191
|
+
async run({ assert }) {
|
|
192
|
+
await withEnv({ BACKEND_MANAGER_WEBHOOK_KEY: 'real-key' }, async () => {
|
|
193
|
+
resetFetchMock();
|
|
194
|
+
const assistant = makeAssistant({ query: { provider: 'sendgrid', key: 'wrong-key' } });
|
|
195
|
+
const Manager = makeManager();
|
|
196
|
+
const admin = makeAdminMock([]);
|
|
197
|
+
|
|
198
|
+
await route({ assistant, Manager, libraries: { admin } });
|
|
199
|
+
|
|
200
|
+
assert.equal(assistant._responses[0].code, 401, 'wrong key → 401');
|
|
201
|
+
});
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
|
|
205
|
+
// ─── Fan-out logic ───
|
|
206
|
+
|
|
207
|
+
{
|
|
208
|
+
name: 'derives-api-url-from-brand-url-and-fans-out',
|
|
209
|
+
async run({ assert }) {
|
|
210
|
+
await withEnv({ BACKEND_MANAGER_WEBHOOK_KEY: 'test-key' }, async () => {
|
|
211
|
+
resetFetchMock();
|
|
212
|
+
const body = [{ sg_event_id: 'evt1', event: 'group_unsubscribe', email: 't@example.com' }];
|
|
213
|
+
const assistant = makeAssistant({
|
|
214
|
+
query: { provider: 'sendgrid', key: 'test-key' },
|
|
215
|
+
body,
|
|
216
|
+
});
|
|
217
|
+
const Manager = makeManager();
|
|
218
|
+
const admin = makeAdminMock([
|
|
219
|
+
{ id: 'somiibo', data: { brand: { id: 'somiibo', url: 'https://somiibo.com' } } },
|
|
220
|
+
{ id: 'chatsy', data: { brand: { id: 'chatsy', url: 'https://chatsy.com' } } },
|
|
221
|
+
]);
|
|
222
|
+
|
|
223
|
+
await route({ assistant, Manager, libraries: { admin } });
|
|
224
|
+
|
|
225
|
+
assert.equal(fetchCalls.length, 2, 'should fan out to both brands');
|
|
226
|
+
assert.equal(
|
|
227
|
+
fetchCalls[0].url,
|
|
228
|
+
'https://api.somiibo.com/backend-manager/marketing/webhook?provider=sendgrid&key=test-key',
|
|
229
|
+
'first child URL derived correctly'
|
|
230
|
+
);
|
|
231
|
+
assert.equal(
|
|
232
|
+
fetchCalls[1].url,
|
|
233
|
+
'https://api.chatsy.com/backend-manager/marketing/webhook?provider=sendgrid&key=test-key',
|
|
234
|
+
'second child URL derived correctly'
|
|
235
|
+
);
|
|
236
|
+
assert.deepEqual(fetchCalls[0].opts.body, body, 'raw body forwarded unchanged');
|
|
237
|
+
assert.equal(assistant._responses[0].data.succeeded, 2, '2 children succeeded');
|
|
238
|
+
});
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
|
|
242
|
+
{
|
|
243
|
+
name: 'forwards-raw-body-unchanged-for-beehiiv',
|
|
244
|
+
async run({ assert }) {
|
|
245
|
+
await withEnv({ BACKEND_MANAGER_WEBHOOK_KEY: 'test-key' }, async () => {
|
|
246
|
+
resetFetchMock();
|
|
247
|
+
const body = {
|
|
248
|
+
id: 'beehiiv-evt1',
|
|
249
|
+
event: 'subscription.unsubscribed',
|
|
250
|
+
email: 'x@example.com',
|
|
251
|
+
publication_id: 'pub_abc',
|
|
252
|
+
};
|
|
253
|
+
const assistant = makeAssistant({
|
|
254
|
+
query: { provider: 'beehiiv', key: 'test-key' },
|
|
255
|
+
body,
|
|
256
|
+
});
|
|
257
|
+
const Manager = makeManager();
|
|
258
|
+
const admin = makeAdminMock([
|
|
259
|
+
{ id: 'somiibo', data: { brand: { id: 'somiibo', url: 'https://somiibo.com' } } },
|
|
260
|
+
]);
|
|
261
|
+
|
|
262
|
+
await route({ assistant, Manager, libraries: { admin } });
|
|
263
|
+
|
|
264
|
+
assert.equal(fetchCalls.length, 1);
|
|
265
|
+
assert.ok(fetchCalls[0].url.includes('provider=beehiiv'), 'provider param preserved');
|
|
266
|
+
assert.deepEqual(fetchCalls[0].opts.body, body, 'raw Beehiiv body forwarded unchanged');
|
|
267
|
+
});
|
|
268
|
+
},
|
|
269
|
+
},
|
|
270
|
+
|
|
271
|
+
{
|
|
272
|
+
name: 'skips-brands-without-brand-url',
|
|
273
|
+
async run({ assert }) {
|
|
274
|
+
await withEnv({ BACKEND_MANAGER_WEBHOOK_KEY: 'test-key' }, async () => {
|
|
275
|
+
resetFetchMock();
|
|
276
|
+
const assistant = makeAssistant({ query: { provider: 'sendgrid', key: 'test-key' }, body: [] });
|
|
277
|
+
const Manager = makeManager();
|
|
278
|
+
const admin = makeAdminMock([
|
|
279
|
+
{ id: 'somiibo', data: { brand: { id: 'somiibo', url: 'https://somiibo.com' } } },
|
|
280
|
+
{ id: 'partial-brand', data: { brand: { id: 'partial-brand' /* no url */ } } },
|
|
281
|
+
{ id: 'no-brand-key', data: { /* no brand at all */ } },
|
|
282
|
+
]);
|
|
283
|
+
|
|
284
|
+
await route({ assistant, Manager, libraries: { admin } });
|
|
285
|
+
|
|
286
|
+
assert.equal(fetchCalls.length, 1, 'only the brand with a URL should be fanned to');
|
|
287
|
+
assert.ok(fetchCalls[0].url.includes('api.somiibo.com'), 'somiibo was the one called');
|
|
288
|
+
});
|
|
289
|
+
},
|
|
290
|
+
},
|
|
291
|
+
|
|
292
|
+
{
|
|
293
|
+
name: 'failure-isolation-one-bad-child-does-not-break-others',
|
|
294
|
+
async run({ assert }) {
|
|
295
|
+
await withEnv({ BACKEND_MANAGER_WEBHOOK_KEY: 'test-key' }, async () => {
|
|
296
|
+
resetFetchMock();
|
|
297
|
+
fetchMockBehavior = (url) => {
|
|
298
|
+
if (url.includes('chatsy.com')) {
|
|
299
|
+
throw new Error('connection refused');
|
|
300
|
+
}
|
|
301
|
+
return { received: true };
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
const assistant = makeAssistant({ query: { provider: 'sendgrid', key: 'test-key' }, body: [] });
|
|
305
|
+
const Manager = makeManager();
|
|
306
|
+
const admin = makeAdminMock([
|
|
307
|
+
{ id: 'somiibo', data: { brand: { id: 'somiibo', url: 'https://somiibo.com' } } },
|
|
308
|
+
{ id: 'chatsy', data: { brand: { id: 'chatsy', url: 'https://chatsy.com' } } },
|
|
309
|
+
{ id: 'dashqr', data: { brand: { id: 'dashqr', url: 'https://dashqr.com' } } },
|
|
310
|
+
]);
|
|
311
|
+
|
|
312
|
+
await route({ assistant, Manager, libraries: { admin } });
|
|
313
|
+
|
|
314
|
+
assert.equal(fetchCalls.length, 3, 'all 3 children attempted');
|
|
315
|
+
const response = assistant._responses[0];
|
|
316
|
+
assert.equal(response.code, 200, 'response is still 200 — provider should not retry parent');
|
|
317
|
+
assert.equal(response.data.succeeded, 2, '2 children succeeded');
|
|
318
|
+
assert.equal(response.data.failed, 1, '1 child failed');
|
|
319
|
+
assert.ok(response.data.failures, 'failures array populated');
|
|
320
|
+
assert.equal(response.data.failures[0].brandId, 'chatsy', 'failure entry names the brand');
|
|
321
|
+
});
|
|
322
|
+
},
|
|
323
|
+
},
|
|
324
|
+
|
|
325
|
+
{
|
|
326
|
+
name: 'invalid-brand-url-counted-as-failure-not-thrown',
|
|
327
|
+
async run({ assert }) {
|
|
328
|
+
await withEnv({ BACKEND_MANAGER_WEBHOOK_KEY: 'test-key' }, async () => {
|
|
329
|
+
resetFetchMock();
|
|
330
|
+
const assistant = makeAssistant({ query: { provider: 'sendgrid', key: 'test-key' }, body: [] });
|
|
331
|
+
const Manager = makeManager();
|
|
332
|
+
const admin = makeAdminMock([
|
|
333
|
+
{ id: 'somiibo', data: { brand: { id: 'somiibo', url: 'https://somiibo.com' } } },
|
|
334
|
+
{ id: 'broken', data: { brand: { id: 'broken', url: 'not-a-valid-url' } } },
|
|
335
|
+
]);
|
|
336
|
+
|
|
337
|
+
await route({ assistant, Manager, libraries: { admin } });
|
|
338
|
+
|
|
339
|
+
const response = assistant._responses[0];
|
|
340
|
+
assert.equal(response.code, 200, 'route still returns 200');
|
|
341
|
+
assert.equal(response.data.succeeded, 1, 'somiibo succeeded');
|
|
342
|
+
assert.equal(response.data.failed, 1, 'broken brand counted as failed');
|
|
343
|
+
assert.equal(fetchCalls.length, 1, 'only the valid URL was actually fetched');
|
|
344
|
+
});
|
|
345
|
+
},
|
|
346
|
+
},
|
|
347
|
+
|
|
348
|
+
{
|
|
349
|
+
name: 'self-is-included-in-fanout',
|
|
350
|
+
async run({ assert }) {
|
|
351
|
+
// The parent's own brand IS expected to be in the brands collection.
|
|
352
|
+
// It should be fanned to via HTTP like any other brand, so its own
|
|
353
|
+
// BEM processes its own user updates the same way as siblings.
|
|
354
|
+
await withEnv({ BACKEND_MANAGER_WEBHOOK_KEY: 'test-key' }, async () => {
|
|
355
|
+
resetFetchMock();
|
|
356
|
+
const assistant = makeAssistant({ query: { provider: 'sendgrid', key: 'test-key' }, body: [] });
|
|
357
|
+
const Manager = makeManager({ brand: { id: 'itw-creative-works' } });
|
|
358
|
+
const admin = makeAdminMock([
|
|
359
|
+
{ id: 'itw-creative-works', data: { brand: { id: 'itw-creative-works', url: 'https://itwcreativeworks.com' } } },
|
|
360
|
+
{ id: 'somiibo', data: { brand: { id: 'somiibo', url: 'https://somiibo.com' } } },
|
|
361
|
+
]);
|
|
362
|
+
|
|
363
|
+
await route({ assistant, Manager, libraries: { admin } });
|
|
364
|
+
|
|
365
|
+
assert.equal(fetchCalls.length, 2, 'parent fans out to ALL brands including itself');
|
|
366
|
+
assert.ok(
|
|
367
|
+
fetchCalls.some(c => c.url.includes('api.itwcreativeworks.com')),
|
|
368
|
+
'self IS in the fan-out target list'
|
|
369
|
+
);
|
|
370
|
+
});
|
|
371
|
+
},
|
|
372
|
+
},
|
|
373
|
+
|
|
374
|
+
{
|
|
375
|
+
name: 'zero-brands-handled-gracefully',
|
|
376
|
+
async run({ assert }) {
|
|
377
|
+
await withEnv({ BACKEND_MANAGER_WEBHOOK_KEY: 'test-key' }, async () => {
|
|
378
|
+
resetFetchMock();
|
|
379
|
+
const assistant = makeAssistant({ query: { provider: 'sendgrid', key: 'test-key' }, body: [] });
|
|
380
|
+
const Manager = makeManager();
|
|
381
|
+
const admin = makeAdminMock([]);
|
|
382
|
+
|
|
383
|
+
await route({ assistant, Manager, libraries: { admin } });
|
|
384
|
+
|
|
385
|
+
assert.equal(fetchCalls.length, 0);
|
|
386
|
+
assert.equal(assistant._responses[0].code, 200, 'still 200 with no brands');
|
|
387
|
+
assert.equal(assistant._responses[0].data.forwarded, 0);
|
|
388
|
+
});
|
|
389
|
+
},
|
|
390
|
+
},
|
|
391
|
+
],
|
|
392
|
+
};
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
* and re-render. Different from FIXTURE: this loads
|
|
39
39
|
* prior AI output, FIXTURE loads hand-crafted JSON.
|
|
40
40
|
* NEWSLETTER_REUSE_RUN=<dir> Pair with THEME_ONLY: reuse a specific run dir.
|
|
41
|
-
*
|
|
41
|
+
* NEWSLETTER_OPEN=1 Auto-open the rendered preview in the default browser (macOS only).
|
|
42
42
|
*
|
|
43
43
|
* --- AI-mode-only env vars (require TEST_EXTENDED_MODE=1) ---
|
|
44
44
|
* NEWSLETTER_PEEK=1 Fetch + list ready sources, do not claim, exit.
|
|
@@ -79,7 +79,7 @@ module.exports = {
|
|
|
79
79
|
// CI). Set TEST_EXTENDED_MODE=1 to switch to the full AI pipeline that fetches
|
|
80
80
|
// real sources, calls the structure + SVG providers, and writes a preview.
|
|
81
81
|
// Other modes (FIXTURE, THEME_ONLY, RELEASE, PEEK) are also opt-in via env.
|
|
82
|
-
async run({ assert, config, Manager, assistant }) {
|
|
82
|
+
async run({ assert, config, Manager, assistant, skip }) {
|
|
83
83
|
const env = process.env;
|
|
84
84
|
|
|
85
85
|
// --- Apply env overrides into newsletterConfig ---
|
|
@@ -204,7 +204,7 @@ module.exports = {
|
|
|
204
204
|
console.log(`[fixture=${requestedFixture}] Markdown: ${path.join(runDir, 'newsletter.md')}`);
|
|
205
205
|
console.log(`[fixture=${requestedFixture}] (Set TEST_EXTENDED_MODE=1 to switch to the full AI pipeline.)`);
|
|
206
206
|
|
|
207
|
-
if (
|
|
207
|
+
if (env.NEWSLETTER_OPEN === '1' && process.platform === 'darwin') {
|
|
208
208
|
try { execSync(`open "${previewPath}"`); } catch (e) { /* no-op */ }
|
|
209
209
|
}
|
|
210
210
|
|
|
@@ -301,7 +301,7 @@ module.exports = {
|
|
|
301
301
|
});
|
|
302
302
|
}
|
|
303
303
|
|
|
304
|
-
if (
|
|
304
|
+
if (env.NEWSLETTER_OPEN === '1' && process.platform === 'darwin') {
|
|
305
305
|
try { execSync(`open "${previewPath}"`); } catch (e) { /* no-op */ }
|
|
306
306
|
}
|
|
307
307
|
|
|
@@ -312,7 +312,12 @@ module.exports = {
|
|
|
312
312
|
// --- AI pipeline path (TEST_EXTENDED_MODE) ---
|
|
313
313
|
// Everything below this point talks to the real parent server and the AI
|
|
314
314
|
// providers. The parent URL is required for any of it.
|
|
315
|
-
|
|
315
|
+
// Use Manager.getParentApiUrl() — same helper the production newsletter
|
|
316
|
+
// generator uses. config.parent stores the parent's brand URL WITHOUT the
|
|
317
|
+
// `api.` subdomain (e.g. 'https://itwcreativeworks.com'); the helper
|
|
318
|
+
// inserts `api.` at call time. PARENT_API_URL env override is honored
|
|
319
|
+
// verbatim for one-off testing against a different parent.
|
|
320
|
+
const parentUrl = env.PARENT_API_URL || Manager.getParentApiUrl();
|
|
316
321
|
assert.ok(parentUrl, 'PARENT_API_URL (env) or parent (config) must be set for the AI pipeline. Set TEST_EXTENDED_MODE=1 to run it, or omit TEST_EXTENDED_MODE for the fast fixture preview.');
|
|
317
322
|
|
|
318
323
|
// --- Peek mode (early return) ---
|
|
@@ -349,7 +354,12 @@ module.exports = {
|
|
|
349
354
|
key: env.BACKEND_MANAGER_KEY,
|
|
350
355
|
});
|
|
351
356
|
|
|
352
|
-
|
|
357
|
+
// Environmental precondition: the parent server must have ready sources in
|
|
358
|
+
// at least one configured category. Skip cleanly when the pool is empty
|
|
359
|
+
// (transient state — no point hard-failing CI on an external queue).
|
|
360
|
+
if (sources.length === 0) {
|
|
361
|
+
return skip('No ready newsletter sources available on parent server (environmental)');
|
|
362
|
+
}
|
|
353
363
|
|
|
354
364
|
// Track claimed IDs for later --release-all
|
|
355
365
|
appendClaimed(claimedFile, sources.map((s) => s.id));
|
|
@@ -461,7 +471,7 @@ module.exports = {
|
|
|
461
471
|
}
|
|
462
472
|
|
|
463
473
|
// --- Auto-open in browser (macOS) ---
|
|
464
|
-
if (
|
|
474
|
+
if (env.NEWSLETTER_OPEN === '1' && process.platform === 'darwin') {
|
|
465
475
|
try {
|
|
466
476
|
execSync(`open "${previewPath}"`);
|
|
467
477
|
} catch (e) {
|
|
@@ -127,18 +127,5 @@ module.exports = {
|
|
|
127
127
|
},
|
|
128
128
|
},
|
|
129
129
|
|
|
130
|
-
// Test 7: Cleanup
|
|
131
|
-
{
|
|
132
|
-
name: 'cleanup',
|
|
133
|
-
auth: 'admin',
|
|
134
|
-
timeout: 15000,
|
|
135
|
-
|
|
136
|
-
async run({ http }) {
|
|
137
|
-
await http.post('admin/database', {
|
|
138
|
-
path: TEST_PATH,
|
|
139
|
-
document: null,
|
|
140
|
-
});
|
|
141
|
-
},
|
|
142
|
-
},
|
|
143
130
|
],
|
|
144
131
|
};
|
|
@@ -200,18 +200,5 @@ module.exports = {
|
|
|
200
200
|
},
|
|
201
201
|
},
|
|
202
202
|
|
|
203
|
-
// Test 10: Cleanup
|
|
204
|
-
{
|
|
205
|
-
name: 'cleanup',
|
|
206
|
-
async run({ firestore }) {
|
|
207
|
-
try {
|
|
208
|
-
await firestore.delete(`${TEST_COLLECTION}/doc1`);
|
|
209
|
-
await firestore.delete(`${TEST_COLLECTION}/doc2`);
|
|
210
|
-
await firestore.delete(`${TEST_COLLECTION}/doc3`);
|
|
211
|
-
} catch (error) {
|
|
212
|
-
// Ignore cleanup errors
|
|
213
|
-
}
|
|
214
|
-
},
|
|
215
|
-
},
|
|
216
203
|
],
|
|
217
204
|
};
|
|
@@ -123,19 +123,5 @@ module.exports = {
|
|
|
123
123
|
},
|
|
124
124
|
},
|
|
125
125
|
|
|
126
|
-
// Test 7: Cleanup
|
|
127
|
-
{
|
|
128
|
-
name: 'cleanup',
|
|
129
|
-
auth: 'admin',
|
|
130
|
-
timeout: 15000,
|
|
131
|
-
|
|
132
|
-
async run({ firestore }) {
|
|
133
|
-
try {
|
|
134
|
-
await firestore.delete(TEST_PATH);
|
|
135
|
-
} catch (error) {
|
|
136
|
-
// Ignore cleanup errors
|
|
137
|
-
}
|
|
138
|
-
},
|
|
139
|
-
},
|
|
140
126
|
],
|
|
141
127
|
};
|
|
@@ -103,15 +103,18 @@ module.exports = {
|
|
|
103
103
|
: false,
|
|
104
104
|
|
|
105
105
|
async run({ http, assert }) {
|
|
106
|
+
// Use a name unlikely to trigger the "fictional/brand" rejection in the AI prompt.
|
|
107
|
+
// The infer-contact prompt rejects placeholder + fictional names (e.g. alice.wonderland,
|
|
108
|
+
// john.doe). Use a generic-but-realistic name to exercise dot-separated parsing.
|
|
106
109
|
const response = await http.post('admin/infer-contact', {
|
|
107
|
-
email: '
|
|
110
|
+
email: 'sarah.martinez@example.com',
|
|
108
111
|
});
|
|
109
112
|
|
|
110
113
|
assert.isSuccess(response);
|
|
111
114
|
const result = response.data.results[0];
|
|
112
115
|
|
|
113
|
-
assert.equal(result.firstName, '
|
|
114
|
-
assert.equal(result.lastName, '
|
|
116
|
+
assert.equal(result.firstName, 'Sarah', 'Should parse first name');
|
|
117
|
+
assert.equal(result.lastName, 'Martinez', 'Should parse last name');
|
|
115
118
|
},
|
|
116
119
|
},
|
|
117
120
|
|
|
@@ -90,6 +90,8 @@ module.exports = {
|
|
|
90
90
|
},
|
|
91
91
|
|
|
92
92
|
// Test 4: Non-existent repo returns 404
|
|
93
|
+
// PUT first calls content/post to fetch the existing post; with a unique URL that's never been
|
|
94
|
+
// created, the fetch itself returns 404 before we ever try to push to the nonexistent repo.
|
|
93
95
|
{
|
|
94
96
|
name: 'nonexistent-repo-returns-404',
|
|
95
97
|
auth: 'admin',
|
|
@@ -97,13 +99,13 @@ module.exports = {
|
|
|
97
99
|
|
|
98
100
|
async run({ http, assert }) {
|
|
99
101
|
const response = await http.put('admin/post', {
|
|
100
|
-
url:
|
|
102
|
+
url: `https://example.com/blog/never-created-${Date.now()}`,
|
|
101
103
|
body: 'Test content',
|
|
102
104
|
githubUser: 'nonexistent-user-12345',
|
|
103
105
|
githubRepo: 'nonexistent-repo-12345',
|
|
104
106
|
});
|
|
105
107
|
|
|
106
|
-
assert.isError(response, 404, 'Non-existent repo should return 404');
|
|
108
|
+
assert.isError(response, 404, 'Non-existent repo or post should return 404');
|
|
107
109
|
},
|
|
108
110
|
},
|
|
109
111
|
|