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.
- package/CHANGELOG.md +29 -0
- package/README.md +15 -0
- package/docs/marketing-campaigns.md +41 -4
- package/docs/testing.md +45 -0
- package/package.json +1 -1
- package/src/cli/commands/emulator.js +18 -1
- package/src/cli/commands/test.js +18 -0
- package/src/defaults/CLAUDE.md +7 -5
- package/src/manager/events/cron/daily/marketing-newsletter-generate.js +24 -8
- package/src/manager/index.js +82 -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 +12 -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 +152 -5
- package/src/manager/libraries/email/providers/beehiiv.js +7 -1
- package/src/manager/routes/admin/post/post.js +3 -3
- package/src/manager/routes/test/health/get.js +17 -0
- package/src/test/run-tests.js +30 -0
- package/src/test/runner.js +11 -8
- package/src/test/utils/test-mode-file.js +192 -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 +62 -48
- package/test/marketing/newsletter-templates.js +12 -33
- 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,
|
|
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
|
-
//
|
|
320
|
-
//
|
|
321
|
-
//
|
|
322
|
-
//
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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(
|
|
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.
|
|
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
|
|