backend-manager 5.1.2 → 5.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +12 -0
- package/CHANGELOG.md +52 -0
- package/CLAUDE.md +2 -1
- package/README.md +30 -0
- package/docs/common-mistakes.md +1 -0
- package/docs/consent.md +333 -0
- package/docs/marketing-campaigns.md +41 -4
- package/docs/testing.md +81 -0
- package/package.json +1 -1
- package/src/cli/commands/emulator.js +62 -9
- package/src/cli/commands/serve.js +73 -7
- package/src/cli/commands/test.js +65 -1
- package/src/cli/commands/watch.js +15 -3
- package/src/defaults/CLAUDE.md +7 -5
- package/src/manager/events/cron/daily/marketing-newsletter-generate.js +24 -8
- package/src/manager/helpers/user.js +29 -0
- package/src/manager/index.js +111 -5
- package/src/manager/libraries/ai/index.js +21 -0
- package/src/manager/libraries/ai/providers/openai.js +75 -0
- package/src/manager/libraries/email/data/disposable-domains.json +20 -0
- package/src/manager/libraries/email/generators/lib/image-host.js +58 -6
- package/src/manager/libraries/email/generators/lib/markdown-renderer.js +285 -0
- package/src/manager/libraries/email/generators/lib/structure.js +19 -2
- package/src/manager/libraries/email/generators/lib/svg-illustrator.js +12 -3
- package/src/manager/libraries/email/generators/lib/templates/classic-schema.js +9 -13
- package/src/manager/libraries/email/generators/lib/templates/clean.js +1 -1
- package/src/manager/libraries/email/generators/lib/templates/editorial.js +1 -1
- package/src/manager/libraries/email/generators/lib/templates/field-report.js +10 -14
- package/src/manager/libraries/email/generators/newsletter.js +154 -7
- package/src/manager/libraries/email/providers/beehiiv.js +8 -1
- 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/admin/post/post.js +3 -3
- 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/test/health/get.js +17 -0
- 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/run-tests.js +30 -0
- package/src/test/runner.js +72 -26
- package/src/test/test-accounts.js +94 -12
- package/src/test/utils/http-client.js +4 -3
- package/src/test/utils/test-mode-file.js +192 -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/fixtures/clean.json +2 -3
- package/test/marketing/fixtures/editorial.json +2 -3
- package/test/marketing/fixtures/field-report.json +3 -4
- package/test/marketing/newsletter-generate.js +78 -54
- package/test/marketing/newsletter-templates.js +12 -33
- package/test/routes/admin/create-post.js +2 -2
- 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
|
@@ -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 }) {
|
|
82
|
+
async run({ assert, config, Manager, assistant, skip }) {
|
|
83
83
|
const env = process.env;
|
|
84
84
|
|
|
85
85
|
// --- Apply env overrides into newsletterConfig ---
|
|
@@ -158,6 +158,7 @@ module.exports = {
|
|
|
158
158
|
}
|
|
159
159
|
|
|
160
160
|
const { renderNewsletter } = require('../../src/manager/libraries/email/generators/lib/mjml-template.js');
|
|
161
|
+
const { renderMarkdown } = require('../../src/manager/libraries/email/generators/lib/markdown-renderer.js');
|
|
161
162
|
|
|
162
163
|
const renderStart = Date.now();
|
|
163
164
|
const { html, mjml, template: templateName } = await renderNewsletter({
|
|
@@ -167,10 +168,26 @@ module.exports = {
|
|
|
167
168
|
imagePaths: [],
|
|
168
169
|
campaign: `fixture-${requestedFixture}`,
|
|
169
170
|
});
|
|
171
|
+
// Force the template metadata onto structure so renderMarkdown picks
|
|
172
|
+
// the right body strategy (fixtures don't carry _meta on their own).
|
|
173
|
+
Object.defineProperty(structure, '_meta', {
|
|
174
|
+
enumerable: false,
|
|
175
|
+
configurable: true,
|
|
176
|
+
value: { template: templateName },
|
|
177
|
+
});
|
|
178
|
+
const markdown = renderMarkdown({
|
|
179
|
+
structure,
|
|
180
|
+
brand: config.brand,
|
|
181
|
+
imagePaths: [],
|
|
182
|
+
});
|
|
170
183
|
const renderMs = Date.now() - renderStart;
|
|
171
184
|
|
|
172
185
|
const previewPath = path.join(runDir, 'newsletter.html');
|
|
173
186
|
jetpack.write(previewPath, html);
|
|
187
|
+
jetpack.write(path.join(runDir, 'newsletter.md'), markdown);
|
|
188
|
+
if (structure.summary) {
|
|
189
|
+
jetpack.write(path.join(runDir, 'summary.md'), structure.summary + '\n');
|
|
190
|
+
}
|
|
174
191
|
jetpack.write(path.join(runDir, 'newsletter.mjml'), mjml || '');
|
|
175
192
|
jetpack.write(path.join(runDir, 'structure.json'), JSON.stringify(structure, null, 2));
|
|
176
193
|
jetpack.write(path.join(runDir, 'metadata.json'), JSON.stringify({
|
|
@@ -178,18 +195,21 @@ module.exports = {
|
|
|
178
195
|
fixture: requestedFixture,
|
|
179
196
|
template: templateName,
|
|
180
197
|
renderMs,
|
|
198
|
+
tags: structure.tags || [],
|
|
181
199
|
timestamp: new Date().toISOString(),
|
|
182
200
|
}, null, 2));
|
|
183
201
|
|
|
184
202
|
console.log(`\n[fixture=${requestedFixture}] Rendered in ${renderMs}ms using template: ${templateName}`);
|
|
185
203
|
console.log(`[fixture=${requestedFixture}] Preview: ${previewPath}`);
|
|
204
|
+
console.log(`[fixture=${requestedFixture}] Markdown: ${path.join(runDir, 'newsletter.md')}`);
|
|
186
205
|
console.log(`[fixture=${requestedFixture}] (Set TEST_EXTENDED_MODE=1 to switch to the full AI pipeline.)`);
|
|
187
206
|
|
|
188
|
-
if (
|
|
207
|
+
if (env.NEWSLETTER_OPEN === '1' && process.platform === 'darwin') {
|
|
189
208
|
try { execSync(`open "${previewPath}"`); } catch (e) { /* no-op */ }
|
|
190
209
|
}
|
|
191
210
|
|
|
192
211
|
assert.ok(html.includes('<html'), 'Rendered HTML');
|
|
212
|
+
assert.ok(markdown.includes('# '), 'Rendered markdown has a heading');
|
|
193
213
|
return;
|
|
194
214
|
}
|
|
195
215
|
|
|
@@ -226,6 +246,7 @@ module.exports = {
|
|
|
226
246
|
}
|
|
227
247
|
|
|
228
248
|
const { renderNewsletter } = require('../../src/manager/libraries/email/generators/lib/mjml-template.js');
|
|
249
|
+
const { renderMarkdown } = require('../../src/manager/libraries/email/generators/lib/markdown-renderer.js');
|
|
229
250
|
|
|
230
251
|
const renderStart = Date.now();
|
|
231
252
|
const { html, mjml, template: templateName } = await renderNewsletter({
|
|
@@ -235,10 +256,26 @@ module.exports = {
|
|
|
235
256
|
imagePaths,
|
|
236
257
|
campaign: `theme-only-${stamp}`,
|
|
237
258
|
});
|
|
259
|
+
// Force the template metadata onto structure so renderMarkdown picks
|
|
260
|
+
// the right body strategy (reused structures may have lost _meta).
|
|
261
|
+
Object.defineProperty(structure, '_meta', {
|
|
262
|
+
enumerable: false,
|
|
263
|
+
configurable: true,
|
|
264
|
+
value: { template: templateName },
|
|
265
|
+
});
|
|
266
|
+
const markdown = renderMarkdown({
|
|
267
|
+
structure,
|
|
268
|
+
brand: config.brand,
|
|
269
|
+
imagePaths,
|
|
270
|
+
});
|
|
238
271
|
const renderMs = Date.now() - renderStart;
|
|
239
272
|
|
|
240
273
|
const previewPath = path.join(runDir, 'newsletter.html');
|
|
241
274
|
jetpack.write(previewPath, html);
|
|
275
|
+
jetpack.write(path.join(runDir, 'newsletter.md'), markdown);
|
|
276
|
+
if (structure.summary) {
|
|
277
|
+
jetpack.write(path.join(runDir, 'summary.md'), structure.summary + '\n');
|
|
278
|
+
}
|
|
242
279
|
jetpack.write(path.join(runDir, 'newsletter.mjml'), mjml || '');
|
|
243
280
|
jetpack.write(path.join(runDir, 'structure.json'), JSON.stringify(structure, null, 2));
|
|
244
281
|
jetpack.write(path.join(runDir, 'metadata.json'), JSON.stringify({
|
|
@@ -246,6 +283,7 @@ module.exports = {
|
|
|
246
283
|
reusedFrom: path.basename(sourceRun),
|
|
247
284
|
template: templateName,
|
|
248
285
|
renderMs,
|
|
286
|
+
tags: structure.tags || [],
|
|
249
287
|
timestamp: new Date().toISOString(),
|
|
250
288
|
}, null, 2));
|
|
251
289
|
|
|
@@ -263,7 +301,7 @@ module.exports = {
|
|
|
263
301
|
});
|
|
264
302
|
}
|
|
265
303
|
|
|
266
|
-
if (
|
|
304
|
+
if (env.NEWSLETTER_OPEN === '1' && process.platform === 'darwin') {
|
|
267
305
|
try { execSync(`open "${previewPath}"`); } catch (e) { /* no-op */ }
|
|
268
306
|
}
|
|
269
307
|
|
|
@@ -274,7 +312,12 @@ module.exports = {
|
|
|
274
312
|
// --- AI pipeline path (TEST_EXTENDED_MODE) ---
|
|
275
313
|
// Everything below this point talks to the real parent server and the AI
|
|
276
314
|
// providers. The parent URL is required for any of it.
|
|
277
|
-
|
|
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();
|
|
278
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.');
|
|
279
322
|
|
|
280
323
|
// --- Peek mode (early return) ---
|
|
@@ -311,27 +354,30 @@ module.exports = {
|
|
|
311
354
|
key: env.BACKEND_MANAGER_KEY,
|
|
312
355
|
});
|
|
313
356
|
|
|
314
|
-
|
|
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
|
+
}
|
|
315
363
|
|
|
316
364
|
// Track claimed IDs for later --release-all
|
|
317
365
|
appendClaimed(claimedFile, sources.map((s) => s.id));
|
|
318
366
|
|
|
319
|
-
//
|
|
320
|
-
//
|
|
321
|
-
//
|
|
322
|
-
//
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
},
|
|
367
|
+
// Force `beehiiv.enabled: true` and inject the per-run newsletter config
|
|
368
|
+
// overrides onto Manager.config. The iteration test IS the explicit trigger
|
|
369
|
+
// — we're not checking whether beehiiv is configured for prod use, we're
|
|
370
|
+
// driving the generator directly. Mutating Manager.config is fine here
|
|
371
|
+
// because this is a `type: 'standalone'` test (one test per process — no
|
|
372
|
+
// cross-test config leakage).
|
|
373
|
+
Manager.config.marketing = {
|
|
374
|
+
...(Manager.config.marketing || {}),
|
|
375
|
+
beehiiv: {
|
|
376
|
+
...(Manager.config.marketing?.beehiiv || {}),
|
|
377
|
+
enabled: true,
|
|
378
|
+
content: newsletterConfig,
|
|
332
379
|
},
|
|
333
|
-
}
|
|
334
|
-
const assistant = buildAssistantStub(Manager);
|
|
380
|
+
};
|
|
335
381
|
|
|
336
382
|
// --- Run the production generator with the local-persist image hook ---
|
|
337
383
|
const generator = require('../../src/manager/libraries/email/generators/newsletter.js');
|
|
@@ -380,14 +426,23 @@ module.exports = {
|
|
|
380
426
|
|
|
381
427
|
assert.ok(result, 'Generator returned a result');
|
|
382
428
|
assert.ok(result.contentHtml, 'Generator returned contentHtml');
|
|
429
|
+
assert.ok(result.contentMarkdown, 'Generator returned contentMarkdown');
|
|
383
430
|
assert.ok(result.structure?.sections?.length >= 2, 'Has at least 2 sections');
|
|
384
431
|
|
|
385
432
|
// --- Write outputs ---
|
|
386
433
|
const previewPath = path.join(runDir, 'newsletter.html');
|
|
387
434
|
jetpack.write(previewPath, result.contentHtml);
|
|
435
|
+
jetpack.write(path.join(runDir, 'newsletter.md'), result.contentMarkdown);
|
|
436
|
+
if (result.summary) {
|
|
437
|
+
jetpack.write(path.join(runDir, 'summary.md'), result.summary + '\n');
|
|
438
|
+
}
|
|
388
439
|
jetpack.write(path.join(runDir, 'structure.json'), JSON.stringify(result.structure, null, 2));
|
|
389
440
|
jetpack.write(path.join(runDir, 'newsletter.mjml'), result.mjml || '');
|
|
390
|
-
jetpack.write(path.join(runDir, 'metadata.json'), JSON.stringify(
|
|
441
|
+
jetpack.write(path.join(runDir, 'metadata.json'), JSON.stringify({
|
|
442
|
+
...(result.meta || {}),
|
|
443
|
+
tags: result.tags || [],
|
|
444
|
+
assets: result.assets || null,
|
|
445
|
+
}, null, 2));
|
|
391
446
|
|
|
392
447
|
console.log(`\nNewsletter preview written: ${previewPath}`);
|
|
393
448
|
console.log(`Subject: ${result.subject}`);
|
|
@@ -416,7 +471,7 @@ module.exports = {
|
|
|
416
471
|
}
|
|
417
472
|
|
|
418
473
|
// --- Auto-open in browser (macOS) ---
|
|
419
|
-
if (
|
|
474
|
+
if (env.NEWSLETTER_OPEN === '1' && process.platform === 'darwin') {
|
|
420
475
|
try {
|
|
421
476
|
execSync(`open "${previewPath}"`);
|
|
422
477
|
} catch (e) {
|
|
@@ -698,34 +753,3 @@ async function releaseAll(claimedFile) {
|
|
|
698
753
|
return released;
|
|
699
754
|
}
|
|
700
755
|
|
|
701
|
-
/**
|
|
702
|
-
* Minimal Manager stub for tests. Provides what newsletter.js + lib/* read:
|
|
703
|
-
* Manager.config — full config (used for brand, marketing.beehiiv.content, parent)
|
|
704
|
-
* Manager.AI(assistant) — instantiates the unified AI library
|
|
705
|
-
*/
|
|
706
|
-
function buildManagerStub(config) {
|
|
707
|
-
return {
|
|
708
|
-
config,
|
|
709
|
-
libraries: {},
|
|
710
|
-
AI(assistant) {
|
|
711
|
-
const AI = require('../../src/manager/libraries/ai/index.js');
|
|
712
|
-
return new AI(assistant);
|
|
713
|
-
},
|
|
714
|
-
};
|
|
715
|
-
}
|
|
716
|
-
|
|
717
|
-
function buildAssistantStub(Manager) {
|
|
718
|
-
return {
|
|
719
|
-
Manager,
|
|
720
|
-
user: null,
|
|
721
|
-
request: { geolocation: { ip: 'local' } },
|
|
722
|
-
log: (...args) => console.log('[ASSIST]', ...args),
|
|
723
|
-
error: (...args) => console.error('[ASSIST ERROR]', ...args),
|
|
724
|
-
getUser: () => null,
|
|
725
|
-
errorify: (msg, opts) => {
|
|
726
|
-
const err = new Error(typeof msg === 'string' ? msg : String(msg));
|
|
727
|
-
if (opts) Object.assign(err, opts);
|
|
728
|
-
return err;
|
|
729
|
-
},
|
|
730
|
-
};
|
|
731
|
-
}
|
|
@@ -48,19 +48,16 @@ const CLASSIC_STRUCTURE = {
|
|
|
48
48
|
{
|
|
49
49
|
title: 'Section one',
|
|
50
50
|
body: 'First section body. Identity matters because trust is the new growth lever. Teams that handle this well will spend less time cleaning up later.',
|
|
51
|
-
cta: { label: 'Learn more', url: 'https://testco.example/one' },
|
|
52
51
|
image_prompt: 'abstract illustration',
|
|
53
52
|
},
|
|
54
53
|
{
|
|
55
54
|
title: 'Section two',
|
|
56
55
|
body: 'Second section body. The practical takeaway is straightforward: keep your account hygiene tight and document your processes.',
|
|
57
|
-
cta: { label: 'Take action', url: 'https://testco.example/two' },
|
|
58
56
|
image_prompt: 'abstract illustration',
|
|
59
57
|
},
|
|
60
58
|
{
|
|
61
59
|
title: 'Section three',
|
|
62
|
-
body: 'Third section body.
|
|
63
|
-
cta: null,
|
|
60
|
+
body: 'Third section body. Self-contained, no outbound links.',
|
|
64
61
|
image_prompt: 'abstract illustration',
|
|
65
62
|
},
|
|
66
63
|
],
|
|
@@ -84,7 +81,6 @@ const FIELD_REPORT_STRUCTURE = {
|
|
|
84
81
|
{ label: 'USERS REACHED', value: '12.4K' },
|
|
85
82
|
{ label: 'WoW GROWTH', value: '+38%' },
|
|
86
83
|
],
|
|
87
|
-
cta: { label: 'READ THE BRIEF', url: 'https://testco.example/one' },
|
|
88
84
|
image_prompt: 'abstract illustration',
|
|
89
85
|
},
|
|
90
86
|
{
|
|
@@ -95,18 +91,16 @@ const FIELD_REPORT_STRUCTURE = {
|
|
|
95
91
|
lede: 'The accounts that survive the next platform sweep are the ones with paper trails.',
|
|
96
92
|
dispatch: 'Account hygiene is now a documentation problem, not a tooling problem. Save your processes. The teams already running on documented playbooks are ahead.',
|
|
97
93
|
dataPoints: [],
|
|
98
|
-
cta: { label: 'SEE THE PLAYBOOK', url: 'https://testco.example/two' },
|
|
99
94
|
image_prompt: 'abstract illustration',
|
|
100
95
|
},
|
|
101
96
|
{
|
|
102
97
|
kicker: 'WATCH',
|
|
103
|
-
headline: 'A third dispatch
|
|
98
|
+
headline: 'A third dispatch',
|
|
104
99
|
byline: 'Filed by The TestCo signals desk',
|
|
105
100
|
location: 'NEW YORK',
|
|
106
|
-
lede: 'Some filings just observe
|
|
107
|
-
dispatch: '
|
|
101
|
+
lede: 'Some filings just observe.',
|
|
102
|
+
dispatch: 'Self-contained dispatch, no outbound links.',
|
|
108
103
|
dataPoints: [{ label: 'OBSERVATIONS', value: '3' }],
|
|
109
|
-
cta: null,
|
|
110
104
|
image_prompt: 'abstract illustration',
|
|
111
105
|
},
|
|
112
106
|
],
|
|
@@ -253,21 +247,6 @@ module.exports = {
|
|
|
253
247
|
}
|
|
254
248
|
},
|
|
255
249
|
},
|
|
256
|
-
{
|
|
257
|
-
name: 'CTAs render only when both label+url present',
|
|
258
|
-
async run({ assert }) {
|
|
259
|
-
// Classic templates use section CTAs; Field Report uses dispatch CTAs.
|
|
260
|
-
// Either way the fixture has 2 CTAs in items 1-2 and null in item 3.
|
|
261
|
-
for (const templateName of ['clean', 'editorial']) {
|
|
262
|
-
const result = await render(templateName);
|
|
263
|
-
assert.ok(result.html.includes('Learn more'), `${templateName}: section 1 CTA renders`);
|
|
264
|
-
assert.ok(result.html.includes('Take action'), `${templateName}: section 2 CTA renders`);
|
|
265
|
-
}
|
|
266
|
-
const fr = await render('field-report');
|
|
267
|
-
assert.ok(fr.html.includes('READ THE BRIEF'), `field-report: dispatch 1 CTA renders`);
|
|
268
|
-
assert.ok(fr.html.includes('SEE THE PLAYBOOK'), `field-report: dispatch 2 CTA renders`);
|
|
269
|
-
},
|
|
270
|
-
},
|
|
271
250
|
{
|
|
272
251
|
name: 'signoff renders without dramatic dark-block treatment',
|
|
273
252
|
async run({ assert }) {
|
|
@@ -384,35 +363,35 @@ module.exports = {
|
|
|
384
363
|
{
|
|
385
364
|
name: 'gracefully omits missing optional fields without breaking the template',
|
|
386
365
|
async run({ assert }) {
|
|
387
|
-
// Classic templates — missing intro, missing one section body
|
|
366
|
+
// Classic templates — missing intro, missing one section body.
|
|
388
367
|
for (const templateName of ['clean', 'editorial']) {
|
|
389
368
|
const partial = {
|
|
390
369
|
...CLASSIC_STRUCTURE,
|
|
391
370
|
intro: '',
|
|
392
371
|
sections: [
|
|
393
|
-
{ title: 'Has body', body: 'Body text here.',
|
|
394
|
-
{ title: 'No body', body: '',
|
|
395
|
-
{ title: 'Has
|
|
372
|
+
{ title: 'Has body', body: 'Body text here.', image_prompt: '' },
|
|
373
|
+
{ title: 'No body', body: '', image_prompt: '' },
|
|
374
|
+
{ title: 'Has third', body: 'Body.', image_prompt: '' },
|
|
396
375
|
],
|
|
397
376
|
};
|
|
398
377
|
const result = await render(templateName, partial);
|
|
399
378
|
assert.equal(result.errors.length, 0, `${templateName}: partial sections produce no MJML errors`);
|
|
400
379
|
assert.ok(result.html.includes('Has body'), `${templateName}: section with body renders`);
|
|
401
380
|
assert.ok(result.html.includes('No body'), `${templateName}: section with empty body renders title`);
|
|
402
|
-
assert.ok(result.html.includes('
|
|
381
|
+
assert.ok(result.html.includes('Has third'), `${templateName}: third section title renders`);
|
|
403
382
|
}
|
|
404
383
|
|
|
405
|
-
// Field Report — missing tldr, missing one dispatch body+lede, missing dataPoints
|
|
384
|
+
// Field Report — missing tldr, missing one dispatch body+lede, missing dataPoints.
|
|
406
385
|
const fr = await render('field-report', {
|
|
407
386
|
tldr: '',
|
|
408
387
|
dispatches: [
|
|
409
388
|
{
|
|
410
389
|
kicker: 'DISPATCH', headline: 'Only headline + body', byline: 'Filed by desk',
|
|
411
|
-
location: 'REMOTE', lede: '', dispatch: 'Some body text.', dataPoints: [],
|
|
390
|
+
location: 'REMOTE', lede: '', dispatch: 'Some body text.', dataPoints: [], image_prompt: '',
|
|
412
391
|
},
|
|
413
392
|
{
|
|
414
393
|
kicker: 'WATCH', headline: 'Just data, no body', byline: 'Filed by desk',
|
|
415
|
-
location: 'REMOTE', lede: '', dispatch: '', dataPoints: [{ label: 'STAT', value: '99%' }],
|
|
394
|
+
location: 'REMOTE', lede: '', dispatch: '', dataPoints: [{ label: 'STAT', value: '99%' }], image_prompt: '',
|
|
416
395
|
},
|
|
417
396
|
],
|
|
418
397
|
});
|
|
@@ -142,11 +142,11 @@ module.exports = {
|
|
|
142
142
|
|
|
143
143
|
assert.isSuccess(response, 'Create post should succeed');
|
|
144
144
|
assert.hasProperty(response, 'data.id', 'Response should have post id');
|
|
145
|
-
assert.hasProperty(response, 'data.path', 'Response should have post path');
|
|
145
|
+
assert.hasProperty(response, 'data.path', 'Response should have post file path');
|
|
146
146
|
|
|
147
147
|
// Store for cleanup
|
|
148
148
|
state.postId = response.data.id;
|
|
149
|
-
state.postPath =
|
|
149
|
+
state.postPath = response.data.path;
|
|
150
150
|
},
|
|
151
151
|
},
|
|
152
152
|
|
|
@@ -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
|
|
|
@@ -6,11 +6,25 @@
|
|
|
6
6
|
* (requires SENDGRID_API_KEY and BEEHIIV_API_KEY env vars)
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
// Test email patterns - look like real emails but +bem suffix identifies them for cleanup
|
|
10
|
-
// Names should be inferred by AI from the email local part
|
|
9
|
+
// Test email patterns - look like real emails but +bem suffix identifies them for cleanup.
|
|
10
|
+
// Names should be inferred by AI from the email local part.
|
|
11
|
+
//
|
|
12
|
+
// Fixed test domain (`acme.com`) — deterministic across brands. Using the running brand's
|
|
13
|
+
// domain caused cross-brand state divergence in SendGrid/Beehiiv and non-deterministic
|
|
14
|
+
// company inference (different domain → different inferred company name).
|
|
15
|
+
//
|
|
16
|
+
// `valid`: use a name that won't be flagged as fictional/placeholder by the AI prompt.
|
|
17
|
+
// (The infer-contact prompt rejects fictional names — e.g. "rachel.greene" sometimes
|
|
18
|
+
// matches the Friends character and returns empty. Use a more anonymous name.)
|
|
19
|
+
//
|
|
20
|
+
// `invalid`: must reach the ZeroBounce mailbox check (so previous checks all pass — must
|
|
21
|
+
// NOT start with "test"/"example" which are in BLOCKED_LOCAL_PATTERNS, NOT be on
|
|
22
|
+
// a corporate/disposable domain). Real-looking name on a real domain with no actual
|
|
23
|
+
// mailbox there is the safest pick.
|
|
24
|
+
const TEST_DOMAIN = 'acme.com';
|
|
11
25
|
const TEST_EMAILS = {
|
|
12
|
-
valid: (
|
|
13
|
-
invalid: () => `
|
|
26
|
+
valid: () => `sarah.martinez+bem@${TEST_DOMAIN}`, // Should infer: Sarah Martinez
|
|
27
|
+
invalid: () => `nonexistent.user+bem@${TEST_DOMAIN}`, // No such mailbox — ZeroBounce should flag as invalid
|
|
14
28
|
};
|
|
15
29
|
|
|
16
30
|
module.exports = {
|
|
@@ -25,13 +39,18 @@ module.exports = {
|
|
|
25
39
|
auth: 'admin',
|
|
26
40
|
timeout: 30000,
|
|
27
41
|
|
|
28
|
-
async run({ http, assert,
|
|
29
|
-
const testEmail = TEST_EMAILS.valid(
|
|
42
|
+
async run({ http, assert, state }) {
|
|
43
|
+
const testEmail = TEST_EMAILS.valid();
|
|
30
44
|
state.testEmail = testEmail;
|
|
31
45
|
|
|
32
46
|
const response = await http.post('marketing/contact', {
|
|
33
47
|
email: testEmail,
|
|
34
48
|
source: 'bem-test',
|
|
49
|
+
// skipValidation bypasses the ZeroBounce mailbox check — the test email
|
|
50
|
+
// (rachel.greene+bem@{brand}) doesn't have a real mailbox so ZeroBounce
|
|
51
|
+
// (correctly) marks it as not deliverable. We're testing the route flow,
|
|
52
|
+
// not the deliverability check itself.
|
|
53
|
+
skipValidation: true,
|
|
35
54
|
// No firstName/lastName - should be inferred as "Rachel Greene"
|
|
36
55
|
});
|
|
37
56
|
|
|
@@ -137,9 +156,9 @@ module.exports = {
|
|
|
137
156
|
auth: 'admin',
|
|
138
157
|
timeout: 30000,
|
|
139
158
|
|
|
140
|
-
async run({ http, assert,
|
|
159
|
+
async run({ http, assert, state }) {
|
|
141
160
|
// Use valid email without providing name - should infer "Rachel Greene"
|
|
142
|
-
const testEmail = TEST_EMAILS.valid(
|
|
161
|
+
const testEmail = TEST_EMAILS.valid();
|
|
143
162
|
state.testEmail = testEmail;
|
|
144
163
|
|
|
145
164
|
const response = await http.post('marketing/contact', {
|
|
@@ -218,8 +237,8 @@ module.exports = {
|
|
|
218
237
|
? 'TEST_EXTENDED_MODE or ZEROBOUNCE_API_KEY not set'
|
|
219
238
|
: false,
|
|
220
239
|
|
|
221
|
-
async run({ http, assert,
|
|
222
|
-
const testEmail = TEST_EMAILS.valid(
|
|
240
|
+
async run({ http, assert, state, skip }) {
|
|
241
|
+
const testEmail = TEST_EMAILS.valid();
|
|
223
242
|
state.testEmail = testEmail;
|
|
224
243
|
|
|
225
244
|
const response = await http.post('marketing/contact', {
|
|
@@ -268,7 +287,8 @@ module.exports = {
|
|
|
268
287
|
: false,
|
|
269
288
|
|
|
270
289
|
async run({ http, assert, skip }) {
|
|
271
|
-
//
|
|
290
|
+
// Email that should reach ZeroBounce and be flagged as undeliverable.
|
|
291
|
+
// Must NOT trip earlier checks (localPart blocklist, disposable, corporate).
|
|
272
292
|
const testEmail = TEST_EMAILS.invalid();
|
|
273
293
|
|
|
274
294
|
const response = await http.post('marketing/contact', {
|
|
@@ -276,17 +296,24 @@ module.exports = {
|
|
|
276
296
|
source: 'bem-test',
|
|
277
297
|
});
|
|
278
298
|
|
|
279
|
-
//
|
|
280
|
-
|
|
281
|
-
|
|
299
|
+
// With no ZeroBounce credits the route fails-open and returns 200; with credits
|
|
300
|
+
// the route should EITHER succeed (200) and report invalid in checks, OR error
|
|
301
|
+
// (400) with "Email validation failed". Either is correct behavior — what we
|
|
302
|
+
// verify here is that mailbox check ran and didn't mark the email as `valid`.
|
|
282
303
|
const mbResult = response.data?.validation?.checks?.mailbox;
|
|
283
304
|
|
|
284
|
-
// If
|
|
285
|
-
if (mbResult?.error?.includes('out of credits')) {
|
|
305
|
+
// If credits are out, the test can't actually exercise rejection — skip.
|
|
306
|
+
if (mbResult?.error?.includes('out of credits') || mbResult?.error?.includes('Invalid API key')) {
|
|
286
307
|
skip('Mailbox verification out of credits');
|
|
287
308
|
}
|
|
288
309
|
|
|
289
|
-
//
|
|
310
|
+
// If the response was a 400, that's the legitimate rejection path — done.
|
|
311
|
+
if (response.status === 400) {
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Otherwise expect a 200 with a non-"valid" mailbox status.
|
|
316
|
+
assert.isSuccess(response, 'Request should succeed (fail-open) or error 400');
|
|
290
317
|
if (mbResult) {
|
|
291
318
|
assert.hasProperty(mbResult, 'status', 'Should have status');
|
|
292
319
|
assert.notEqual(mbResult.status, 'valid', 'Fake email should not be marked valid');
|
|
@@ -296,20 +323,27 @@ module.exports = {
|
|
|
296
323
|
|
|
297
324
|
// --- Auth rejection tests ---
|
|
298
325
|
{
|
|
299
|
-
name: 'add-unauthenticated-
|
|
326
|
+
name: 'add-unauthenticated-rejected',
|
|
300
327
|
auth: 'none',
|
|
301
328
|
timeout: 15000,
|
|
302
|
-
skip: !process.env.TEST_EXTENDED_MODE && 'reCAPTCHA is skipped in test mode (TEST_EXTENDED_MODE not set)',
|
|
303
329
|
|
|
304
|
-
async run({ http, assert
|
|
305
|
-
// Public request without
|
|
330
|
+
async run({ http, assert }) {
|
|
331
|
+
// Public request without auth must be rejected. The exact rejection mechanism
|
|
332
|
+
// depends on environment:
|
|
333
|
+
// - Production: missing reCAPTCHA token → 403
|
|
334
|
+
// - Local emulator (BEM_TESTING=true): reCAPTCHA is bypassed, but unauthenticated
|
|
335
|
+
// users hit the marketing-subscribe rate limit (quota 0/0) → 429
|
|
336
|
+
// Both are correct: the route protects itself from anonymous abuse. Accept either.
|
|
306
337
|
const response = await http.post('marketing/contact', {
|
|
307
|
-
email: TEST_EMAILS.valid(
|
|
338
|
+
email: TEST_EMAILS.valid(),
|
|
308
339
|
source: 'bem-test',
|
|
309
340
|
});
|
|
310
341
|
|
|
311
|
-
|
|
312
|
-
assert.
|
|
342
|
+
assert.ok(!response.success, 'Public request should be rejected');
|
|
343
|
+
assert.ok(
|
|
344
|
+
response.status === 403 || response.status === 429,
|
|
345
|
+
`Expected 403 or 429 but got ${response.status}`
|
|
346
|
+
);
|
|
313
347
|
},
|
|
314
348
|
},
|
|
315
349
|
|
|
@@ -322,9 +356,9 @@ module.exports = {
|
|
|
322
356
|
timeout: 30000,
|
|
323
357
|
skip: !process.env.TEST_EXTENDED_MODE ? 'TEST_EXTENDED_MODE not set' : false,
|
|
324
358
|
|
|
325
|
-
async run({ http, assert
|
|
359
|
+
async run({ http, assert }) {
|
|
326
360
|
// Clean up the rachel.greene+bem test contact from marketing providers
|
|
327
|
-
const testEmail = TEST_EMAILS.valid(
|
|
361
|
+
const testEmail = TEST_EMAILS.valid();
|
|
328
362
|
|
|
329
363
|
const response = await http.delete('marketing/contact', {
|
|
330
364
|
email: testEmail,
|