backend-manager 5.1.2 → 5.1.4

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 (34) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +15 -0
  3. package/docs/marketing-campaigns.md +41 -4
  4. package/docs/testing.md +45 -0
  5. package/package.json +1 -1
  6. package/src/cli/commands/emulator.js +18 -1
  7. package/src/cli/commands/test.js +18 -0
  8. package/src/defaults/CLAUDE.md +7 -5
  9. package/src/manager/events/cron/daily/marketing-newsletter-generate.js +24 -8
  10. package/src/manager/index.js +82 -5
  11. package/src/manager/libraries/ai/index.js +21 -0
  12. package/src/manager/libraries/ai/providers/openai.js +75 -0
  13. package/src/manager/libraries/email/data/disposable-domains.json +12 -0
  14. package/src/manager/libraries/email/generators/lib/image-host.js +58 -6
  15. package/src/manager/libraries/email/generators/lib/markdown-renderer.js +285 -0
  16. package/src/manager/libraries/email/generators/lib/structure.js +19 -2
  17. package/src/manager/libraries/email/generators/lib/svg-illustrator.js +12 -3
  18. package/src/manager/libraries/email/generators/lib/templates/classic-schema.js +9 -13
  19. package/src/manager/libraries/email/generators/lib/templates/clean.js +1 -1
  20. package/src/manager/libraries/email/generators/lib/templates/editorial.js +1 -1
  21. package/src/manager/libraries/email/generators/lib/templates/field-report.js +10 -14
  22. package/src/manager/libraries/email/generators/newsletter.js +152 -5
  23. package/src/manager/libraries/email/providers/beehiiv.js +7 -1
  24. package/src/manager/routes/admin/post/post.js +3 -3
  25. package/src/manager/routes/test/health/get.js +17 -0
  26. package/src/test/run-tests.js +30 -0
  27. package/src/test/runner.js +11 -8
  28. package/src/test/utils/test-mode-file.js +192 -0
  29. package/test/marketing/fixtures/clean.json +2 -3
  30. package/test/marketing/fixtures/editorial.json +2 -3
  31. package/test/marketing/fixtures/field-report.json +3 -4
  32. package/test/marketing/newsletter-generate.js +62 -48
  33. package/test/marketing/newsletter-templates.js +12 -33
  34. package/test/routes/admin/create-post.js +2 -2
@@ -2,24 +2,23 @@
2
2
  "_comment": "Predefined fixture for the `clean` template. Loaded via NEWSLETTER_FIXTURE=clean. Edit freely — no AI involved. Classic content shape: intro + sections[{title, body, cta, image_prompt}].",
3
3
  "subject": "Identity is the new growth lever",
4
4
  "preheader": "Three platform shifts to lock in this week.",
5
+ "summary": "LinkedIn, YouTube, and the broader creator stack are all tightening identity controls this week. The operators with documented attribution histories will outrun the rest. Documentation is no longer overhead — it's the moat.",
6
+ "tags": ["linkedin", "youtube", "platform-policy", "creator-economy", "operator-tools"],
5
7
  "intro": "Identity matters because trust is the new growth lever. Teams that handle this well will spend less time cleaning up later.",
6
8
  "sections": [
7
9
  {
8
10
  "title": "LinkedIn ships verified-profile gates for business accounts",
9
11
  "body": "LinkedIn now requires verified identity attestations on any business profile claiming more than 500 followers. Accounts with documented attribution histories sail through verification in under twelve hours. The practical implication is to lock down your attribution log this week, not next.",
10
- "cta": { "label": "Read the brief", "url": "https://somiibo.com/blog/linkedin-verification" },
11
12
  "image_prompt": "Abstract geometric illustration of a verification checkmark inside a layered profile card."
12
13
  },
13
14
  {
14
15
  "title": "Documentation is the new operator moat",
15
16
  "body": "Operator playbooks are now the difference between an account that recovers from a flag and one that gets permanently restricted. Teams running documented processes recover in days. The ones without burn weeks negotiating with support queues.",
16
- "cta": { "label": "See the playbook", "url": "https://somiibo.com/blog/operator-playbook" },
17
17
  "image_prompt": "Stack of geometric document layers casting clean shadows on a flat surface."
18
18
  },
19
19
  {
20
20
  "title": "YouTube tests creator-attribution metadata on uploads",
21
21
  "body": "A subset of channels saw a new upload checkbox this week asking whether content was assisted by automation. The checkbox is optional today. Reading the room: it will not stay optional. Creators with clear internal workflows will adapt in an afternoon.",
22
- "cta": null,
23
22
  "image_prompt": "Minimalist play button morphing into a label tag, flat geometric style."
24
23
  }
25
24
  ],
@@ -2,24 +2,23 @@
2
2
  "_comment": "Predefined fixture for the `editorial` template. Loaded via NEWSLETTER_FIXTURE=editorial. Same classic content shape as clean.json — both templates render the same data.",
3
3
  "subject": "Identity is the new growth lever",
4
4
  "preheader": "Three platform shifts to lock in this week.",
5
+ "summary": "LinkedIn, YouTube, and the broader creator stack are all tightening identity controls this week. The operators with documented attribution histories will outrun the rest. Documentation is no longer overhead — it's the moat.",
6
+ "tags": ["linkedin", "youtube", "platform-policy", "creator-economy", "operator-tools"],
5
7
  "intro": "Identity matters because trust is the new growth lever. Teams that handle this well will spend less time cleaning up later, and the patterns emerging this week tell you exactly where to focus.",
6
8
  "sections": [
7
9
  {
8
10
  "title": "LinkedIn ships verified-profile gates for business accounts",
9
11
  "body": "LinkedIn now requires verified identity attestations on any business profile claiming more than 500 followers. The rollout is fast and uneven — some accounts hit the gate inside two days of profile activity, others sit unchallenged for weeks. The pattern that matters is this: accounts with documented attribution histories sail through verification in under twelve hours. Accounts without get queued. The practical implication is to lock down your attribution log this week, not next.",
10
- "cta": { "label": "Read the brief", "url": "https://somiibo.com/blog/linkedin-verification" },
11
12
  "image_prompt": "Abstract geometric illustration of a verification checkmark inside a layered profile card."
12
13
  },
13
14
  {
14
15
  "title": "Documentation is the new operator moat",
15
16
  "body": "Operator playbooks — once dismissed as overhead — are now the difference between an account that recovers from a flag and one that gets permanently restricted. The teams already running on documented processes recover in days. The ones without burn weeks negotiating with support queues. Treat your playbook as a compliance artifact, not a knowledge-management nice-to-have.",
16
- "cta": { "label": "See the playbook", "url": "https://somiibo.com/blog/operator-playbook" },
17
17
  "image_prompt": "Stack of geometric document layers casting clean shadows on a flat surface."
18
18
  },
19
19
  {
20
20
  "title": "YouTube tests creator-attribution metadata on uploads",
21
21
  "body": "A subset of channels saw a new upload checkbox this week asking whether content was assisted by automation. The checkbox is optional today. Reading the room: it will not stay optional. Creators with clear internal workflows already labeled will adapt in an afternoon. Everyone else will spend a quarter retrofitting.",
22
- "cta": null,
23
22
  "image_prompt": "Minimalist play button morphing into a label tag, flat geometric style."
24
23
  }
25
24
  ],
@@ -1,7 +1,9 @@
1
1
  {
2
- "_comment": "Predefined fixture for the `field-report` template. Loaded via NEWSLETTER_FIXTURE=field-report. Wire-service content shape: tldr + dateline + dispatches[{kicker, headline, byline, location, lede, dispatch, dataPoints, cta, image_prompt}].",
2
+ "_comment": "Predefined fixture for the `field-report` template. Loaded via NEWSLETTER_FIXTURE=field-report. Wire-service content shape: tldr + dateline + dispatches[{kicker, headline, byline, location, lede, dispatch, dataPoints, image_prompt}].",
3
3
  "subject": "LinkedIn tightens identity — operators document everything",
4
4
  "preheader": "Three filings from this week's platform front lines.",
5
+ "summary": "Wire from the platform front: LinkedIn is rolling identity verification across every business profile, YouTube is piloting upload-time attribution metadata, and the operators with clean documentation are outrunning everyone else.",
6
+ "tags": ["linkedin", "youtube", "platform-policy", "verification", "operator-playbook"],
5
7
  "tldr": "LinkedIn is rolling out verified-account scrutiny across every business profile. Operators with paper trails will outrun the rest. Documentation is the new growth lever.",
6
8
  "dateline": "OAKLAND",
7
9
  "signoff": "— Stay sharp,\nThe Somiibo Desk",
@@ -22,7 +24,6 @@
22
24
  { "label": "AVG REVIEW TIME", "value": "14HR" },
23
25
  { "label": "WoW APPROVAL", "value": "+38%" }
24
26
  ],
25
- "cta": { "label": "READ THE BRIEF", "url": "https://somiibo.com/blog/linkedin-verification" },
26
27
  "image_prompt": "Abstract geometric illustration of a verification checkmark inside a layered profile card."
27
28
  },
28
29
  {
@@ -36,7 +37,6 @@
36
37
  { "label": "RECOVERY (DOCUMENTED)", "value": "4D" },
37
38
  { "label": "RECOVERY (UNDOC)", "value": "21D" }
38
39
  ],
39
- "cta": { "label": "SEE THE PLAYBOOK", "url": "https://somiibo.com/blog/operator-playbook" },
40
40
  "image_prompt": "Stack of geometric document layers casting clean shadows on a flat surface."
41
41
  },
42
42
  {
@@ -47,7 +47,6 @@
47
47
  "lede": "A small pilot suggests YouTube will soon ask creators to declare automated tooling and cross-posting workflows at upload time.",
48
48
  "dispatch": "A subset of channels saw a new upload checkbox this week asking whether content was assisted by automation. The checkbox is optional today. Reading the room: it will not stay optional. Creators with clear internal workflows already labeled will adapt in an afternoon. Everyone else will spend a quarter retrofitting.",
49
49
  "dataPoints": [],
50
- "cta": null,
51
50
  "image_prompt": "Minimalist play button morphing into a label tag, flat geometric style."
52
51
  }
53
52
  ]
@@ -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 }) {
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,11 +195,13 @@ 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
207
  if (!env.NEWSLETTER_NO_OPEN && process.platform === 'darwin') {
@@ -190,6 +209,7 @@ module.exports = {
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
 
@@ -316,22 +354,20 @@ module.exports = {
316
354
  // Track claimed IDs for later --release-all
317
355
  appendClaimed(claimedFile, sources.map((s) => s.id));
318
356
 
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
- },
357
+ // Force `beehiiv.enabled: true` and inject the per-run newsletter config
358
+ // overrides onto Manager.config. The iteration test IS the explicit trigger
359
+ // we're not checking whether beehiiv is configured for prod use, we're
360
+ // driving the generator directly. Mutating Manager.config is fine here
361
+ // because this is a `type: 'standalone'` test (one test per process — no
362
+ // cross-test config leakage).
363
+ Manager.config.marketing = {
364
+ ...(Manager.config.marketing || {}),
365
+ beehiiv: {
366
+ ...(Manager.config.marketing?.beehiiv || {}),
367
+ enabled: true,
368
+ content: newsletterConfig,
332
369
  },
333
- });
334
- const assistant = buildAssistantStub(Manager);
370
+ };
335
371
 
336
372
  // --- Run the production generator with the local-persist image hook ---
337
373
  const generator = require('../../src/manager/libraries/email/generators/newsletter.js');
@@ -380,14 +416,23 @@ module.exports = {
380
416
 
381
417
  assert.ok(result, 'Generator returned a result');
382
418
  assert.ok(result.contentHtml, 'Generator returned contentHtml');
419
+ assert.ok(result.contentMarkdown, 'Generator returned contentMarkdown');
383
420
  assert.ok(result.structure?.sections?.length >= 2, 'Has at least 2 sections');
384
421
 
385
422
  // --- Write outputs ---
386
423
  const previewPath = path.join(runDir, 'newsletter.html');
387
424
  jetpack.write(previewPath, result.contentHtml);
425
+ jetpack.write(path.join(runDir, 'newsletter.md'), result.contentMarkdown);
426
+ if (result.summary) {
427
+ jetpack.write(path.join(runDir, 'summary.md'), result.summary + '\n');
428
+ }
388
429
  jetpack.write(path.join(runDir, 'structure.json'), JSON.stringify(result.structure, null, 2));
389
430
  jetpack.write(path.join(runDir, 'newsletter.mjml'), result.mjml || '');
390
- jetpack.write(path.join(runDir, 'metadata.json'), JSON.stringify(result.meta || {}, null, 2));
431
+ jetpack.write(path.join(runDir, 'metadata.json'), JSON.stringify({
432
+ ...(result.meta || {}),
433
+ tags: result.tags || [],
434
+ assets: result.assets || null,
435
+ }, null, 2));
391
436
 
392
437
  console.log(`\nNewsletter preview written: ${previewPath}`);
393
438
  console.log(`Subject: ${result.subject}`);
@@ -698,34 +743,3 @@ async function releaseAll(claimedFile) {
698
743
  return released;
699
744
  }
700
745
 
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