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.
Files changed (97) hide show
  1. package/.claude/settings.local.json +12 -0
  2. package/CHANGELOG.md +52 -0
  3. package/CLAUDE.md +2 -1
  4. package/README.md +30 -0
  5. package/docs/common-mistakes.md +1 -0
  6. package/docs/consent.md +333 -0
  7. package/docs/marketing-campaigns.md +41 -4
  8. package/docs/testing.md +81 -0
  9. package/package.json +1 -1
  10. package/src/cli/commands/emulator.js +62 -9
  11. package/src/cli/commands/serve.js +73 -7
  12. package/src/cli/commands/test.js +65 -1
  13. package/src/cli/commands/watch.js +15 -3
  14. package/src/defaults/CLAUDE.md +7 -5
  15. package/src/manager/events/cron/daily/marketing-newsletter-generate.js +24 -8
  16. package/src/manager/helpers/user.js +29 -0
  17. package/src/manager/index.js +111 -5
  18. package/src/manager/libraries/ai/index.js +21 -0
  19. package/src/manager/libraries/ai/providers/openai.js +75 -0
  20. package/src/manager/libraries/email/data/disposable-domains.json +20 -0
  21. package/src/manager/libraries/email/generators/lib/image-host.js +58 -6
  22. package/src/manager/libraries/email/generators/lib/markdown-renderer.js +285 -0
  23. package/src/manager/libraries/email/generators/lib/structure.js +19 -2
  24. package/src/manager/libraries/email/generators/lib/svg-illustrator.js +12 -3
  25. package/src/manager/libraries/email/generators/lib/templates/classic-schema.js +9 -13
  26. package/src/manager/libraries/email/generators/lib/templates/clean.js +1 -1
  27. package/src/manager/libraries/email/generators/lib/templates/editorial.js +1 -1
  28. package/src/manager/libraries/email/generators/lib/templates/field-report.js +10 -14
  29. package/src/manager/libraries/email/generators/newsletter.js +154 -7
  30. package/src/manager/libraries/email/providers/beehiiv.js +8 -1
  31. package/src/manager/libraries/payment/processors/stripe.js +12 -0
  32. package/src/manager/libraries/payment/processors/test.js +8 -1
  33. package/src/manager/routes/admin/infer-contact/post.js +3 -2
  34. package/src/manager/routes/admin/post/post.js +3 -3
  35. package/src/manager/routes/marketing/email-preferences/post.js +165 -37
  36. package/src/manager/routes/marketing/webhook/forward/post.js +168 -0
  37. package/src/manager/routes/marketing/webhook/post.js +180 -0
  38. package/src/manager/routes/marketing/webhook/processors/beehiiv.js +196 -0
  39. package/src/manager/routes/marketing/webhook/processors/sendgrid.js +171 -0
  40. package/src/manager/routes/payments/cancel/post.js +2 -2
  41. package/src/manager/routes/payments/cancel/processors/test.js +5 -2
  42. package/src/manager/routes/payments/intent/processors/test.js +7 -3
  43. package/src/manager/routes/payments/refund/processors/test.js +4 -1
  44. package/src/manager/routes/test/health/get.js +17 -0
  45. package/src/manager/routes/user/signup/post.js +65 -1
  46. package/src/manager/schemas/marketing/email-preferences/post.js +8 -4
  47. package/src/manager/schemas/marketing/webhook/forward/post.js +8 -0
  48. package/src/manager/schemas/marketing/webhook/post.js +7 -0
  49. package/src/manager/schemas/payments/cancel/post.js +5 -0
  50. package/src/manager/schemas/user/signup/post.js +5 -0
  51. package/src/test/run-tests.js +30 -0
  52. package/src/test/runner.js +72 -26
  53. package/src/test/test-accounts.js +94 -12
  54. package/src/test/utils/http-client.js +4 -3
  55. package/src/test/utils/test-mode-file.js +192 -0
  56. package/test/events/payments/journey-payments-cancel-endpoint.js +3 -12
  57. package/test/events/payments/journey-payments-cancel.js +4 -5
  58. package/test/events/payments/journey-payments-failure.js +0 -1
  59. package/test/events/payments/journey-payments-one-time-failure.js +6 -3
  60. package/test/events/payments/journey-payments-one-time.js +6 -3
  61. package/test/events/payments/journey-payments-plan-change.js +5 -5
  62. package/test/events/payments/journey-payments-refund-webhook.js +2 -3
  63. package/test/events/payments/journey-payments-suspend.js +4 -5
  64. package/test/events/payments/journey-payments-trial-cancel.js +3 -12
  65. package/test/events/payments/journey-payments-trial.js +2 -3
  66. package/test/events/payments/journey-payments-uid-resolution.js +2 -3
  67. package/test/functions/admin/database-read.js +0 -14
  68. package/test/functions/admin/database-write.js +0 -14
  69. package/test/functions/admin/firestore-query.js +0 -14
  70. package/test/functions/admin/firestore-read.js +0 -15
  71. package/test/functions/admin/firestore-write.js +0 -11
  72. package/test/functions/general/add-marketing-contact.js +16 -14
  73. package/test/helpers/email.js +1 -1
  74. package/test/helpers/infer-contact.js +3 -3
  75. package/test/helpers/user.js +241 -2
  76. package/test/helpers/webhook-forward.js +392 -0
  77. package/test/marketing/fixtures/clean.json +2 -3
  78. package/test/marketing/fixtures/editorial.json +2 -3
  79. package/test/marketing/fixtures/field-report.json +3 -4
  80. package/test/marketing/newsletter-generate.js +78 -54
  81. package/test/marketing/newsletter-templates.js +12 -33
  82. package/test/routes/admin/create-post.js +2 -2
  83. package/test/routes/admin/database.js +0 -13
  84. package/test/routes/admin/firestore-query.js +0 -13
  85. package/test/routes/admin/firestore.js +0 -14
  86. package/test/routes/admin/infer-contact.js +6 -3
  87. package/test/routes/admin/post.js +4 -2
  88. package/test/routes/marketing/contact.js +60 -26
  89. package/test/routes/marketing/email-preferences.js +145 -69
  90. package/test/routes/marketing/webhook-forward.js +54 -0
  91. package/test/routes/marketing/webhook.js +582 -0
  92. package/test/routes/payments/cancel.js +2 -7
  93. package/test/routes/payments/dispute-alert.js +0 -39
  94. package/test/routes/payments/refund.js +3 -1
  95. package/test/routes/payments/webhook.js +5 -26
  96. package/test/routes/test/usage.js +2 -2
  97. 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
- * NEWSLETTER_NO_OPEN=1 Don't auto-open the preview in the browser.
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 (!env.NEWSLETTER_NO_OPEN && process.platform === 'darwin') {
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 (!env.NEWSLETTER_NO_OPEN && process.platform === 'darwin') {
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
- const parentUrl = env.PARENT_API_URL || config.parent;
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
- assert.ok(sources.length > 0, 'Fetched at least one source from parent server');
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
- // --- Build a stub Manager + assistant to call the BEM generator library ---
320
- // Newsletter config nests under beehiiv. Force `beehiiv.enabled: true` because
321
- // the iteration test IS the explicit trigger we're not checking whether
322
- // beehiiv is configured for prod use, we're driving the generator directly.
323
- const Manager = buildManagerStub({
324
- ...config,
325
- marketing: {
326
- ...config.marketing,
327
- beehiiv: {
328
- ...(config.marketing?.beehiiv || {}),
329
- enabled: true,
330
- content: newsletterConfig,
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(result.meta || {}, null, 2));
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 (!env.NEWSLETTER_NO_OPEN && process.platform === 'darwin') {
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. This one has no CTA on purpose — to verify that templates handle missing CTAs gracefully.',
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 with no CTA',
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 — no call to action attached.',
107
- dispatch: 'This one has no CTA on purpose, to verify the template handles missing CTAs gracefully.',
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, missing all CTAs.
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.', cta: null, image_prompt: '' },
394
- { title: 'No body', body: '', cta: null, image_prompt: '' },
395
- { title: 'Has CTA', body: 'Body.', cta: { label: 'Go', url: 'https://testco.example/go' }, image_prompt: '' },
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('Go'), `${templateName}: third-section CTA still renders`);
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 + CTAs.
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: [], cta: null, image_prompt: '',
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%' }], cta: null, image_prompt: '',
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 = `${response.data.path}/${response.data.date}-bem-test-create-post.md`;
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: 'alice.wonderland@example.com',
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, 'Alice', 'Should parse first name');
114
- assert.equal(result.lastName, 'Wonderland', 'Should parse last name');
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: 'https://example.com/blog/test-post',
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: (domain) => `rachel.greene+bem@${domain}`, // Should infer: Rachel Greene
13
- invalid: () => `test+bem@test.com`, // Guaranteed to fail ZeroBounce (fake domain)
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, config, state }) {
29
- const testEmail = TEST_EMAILS.valid(config.domain);
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, config, state }) {
159
+ async run({ http, assert, state }) {
141
160
  // Use valid email without providing name - should infer "Rachel Greene"
142
- const testEmail = TEST_EMAILS.valid(config.domain);
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, config, state, skip }) {
222
- const testEmail = TEST_EMAILS.valid(config.domain);
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
- // Use fake email that mailbox verification should flag as invalid
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
- // Should still succeed (we fail open) but mailbox should report invalid
280
- assert.isSuccess(response, 'Request should succeed even with invalid email');
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 out of credits, skip test - not a failure
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
- // Mailbox should return a status indicating the email is not valid
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-requires-recaptcha',
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, config }) {
305
- // Public request without reCAPTCHA should fail
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(config.domain),
338
+ email: TEST_EMAILS.valid(),
308
339
  source: 'bem-test',
309
340
  });
310
341
 
311
- // Should fail with 403 because no reCAPTCHA token
312
- assert.isError(response, 403, 'Public request without reCAPTCHA should fail');
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, config }) {
359
+ async run({ http, assert }) {
326
360
  // Clean up the rachel.greene+bem test contact from marketing providers
327
- const testEmail = TEST_EMAILS.valid(config.domain);
361
+ const testEmail = TEST_EMAILS.valid();
328
362
 
329
363
  const response = await http.delete('marketing/contact', {
330
364
  email: testEmail,