backend-manager 5.11.2 → 5.11.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.
@@ -6,18 +6,56 @@
6
6
  *
7
7
  * Covers:
8
8
  * 1. One-off campaigns: status changes from 'pending' to 'sent'/'failed'
9
- * 2. Recurring campaigns: sendAt advances, history doc created
9
+ * 2. Recurring campaigns: sendAt advances, history doc created, status
10
+ * returns to 'pending'
10
11
  * 3. Generator campaigns (e.g. newsletter): NOT skipped — the generator
11
- * pipeline runs inline and produces a new campaign doc
12
+ * pipeline runs inline; when it yields nothing the campaign stays
13
+ * pending with generatorAttempts incremented (retry next run)
14
+ * 4. Claim/lease lifecycle: stale 'processing' leases are reclaimed and
15
+ * re-processed; fresh leases are left alone (no double-send)
16
+ * 5. Unknown campaign types / generators are marked 'failed' (config
17
+ * typos must not retry forever)
18
+ * 6. Generator retry cap: after GENERATOR_MAX_ATTEMPTS empty runs, a
19
+ * recurring campaign skips to its next occurrence and a one-off fails
20
+ * 7. Catch-up-safe advance: a recurring campaign stalled multiple periods
21
+ * advances to the next FUTURE occurrence (no catch-up burst)
12
22
  *
13
- * Default mode: seeds campaigns and triggers the cron, verifies doc state
14
- * changes. The generator test verifies the campaign is attempted (not skipped);
15
- * if the consumer config has newsletter disabled, the generator returns null
16
- * and the test confirms the graceful "will retry" path.
17
- *
18
- * Extended mode: same, but the newsletter generator runs the full AI pipeline.
23
+ * Default mode: the newsletter generator is gated by TEST_EXTENDED_MODE and
24
+ * returns null the suite verifies the retry bookkeeping deterministically
25
+ * with zero AI cost. Extended mode: the generator runs the full AI pipeline
26
+ * and the suite verifies the generated history doc + sendAt advance.
19
27
  */
20
- const { getNextOccurrence } = require('../../src/manager/libraries/email/constants.js');
28
+ const {
29
+ getNextOccurrence,
30
+ getNextFutureOccurrence,
31
+ } = require('../../src/manager/libraries/email/constants.js');
32
+ const {
33
+ PROCESSING_LEASE_SECONDS,
34
+ GENERATOR_MAX_ATTEMPTS,
35
+ } = require('../../src/manager/events/cron/frequent/marketing-campaigns.js');
36
+
37
+ const WEEK = 7 * 86400;
38
+
39
+ function stamp(now) {
40
+ return { timestamp: new Date(now * 1000).toISOString(), timestampUNIX: now };
41
+ }
42
+
43
+ function emailSettings(name) {
44
+ return {
45
+ name,
46
+ subject: `${name} subject`,
47
+ preheader: `${name} preheader`,
48
+ template: 'card',
49
+ data: {
50
+ content: {
51
+ title: name,
52
+ message: `${name} body`,
53
+ button: { text: 'Click', url: 'https://example.com' },
54
+ },
55
+ },
56
+ test: true,
57
+ };
58
+ }
21
59
 
22
60
  module.exports = {
23
61
  description: 'Campaign cron pipeline (frequent cron processes all campaign types)',
@@ -25,6 +63,42 @@ module.exports = {
25
63
  timeout: 60000,
26
64
 
27
65
  tests: [
66
+ {
67
+ name: 'get-next-future-occurrence-pure',
68
+ auth: 'none',
69
+
70
+ async run({ assert }) {
71
+ const now = Math.round(Date.now() / 1000);
72
+ const weekly = { pattern: 'weekly', hour: 10, minute: 0, day: 1 };
73
+
74
+ // Near-past sendAt: single-step advance already lands in the future
75
+ const recent = now - 600;
76
+ assert.equal(
77
+ getNextFutureOccurrence(recent, weekly, now),
78
+ getNextOccurrence(recent, weekly),
79
+ 'Single-step advance is preserved when it lands in the future',
80
+ );
81
+
82
+ // Stalled 3 weeks: all missed occurrences are skipped
83
+ const stale = now - (3 * WEEK) - 600;
84
+ const next = getNextFutureOccurrence(stale, weekly, now);
85
+ assert.ok(next > now, `Advance from a 3-week-stale anchor must be in the future (got ${next}, now ${now})`);
86
+
87
+ let manual = stale;
88
+ for (let i = 0; i < 4; i++) {
89
+ manual = getNextOccurrence(manual, weekly);
90
+ }
91
+ assert.equal(next, manual, 'Skips exactly the missed occurrences (4 weekly steps)');
92
+
93
+ // Daily pattern stalled 5 days: lands within the next 24h
94
+ const daily = { pattern: 'daily', hour: 0, minute: 0 };
95
+ const staleDaily = now - (5 * 86400) - 60;
96
+ const nextDaily = getNextFutureOccurrence(staleDaily, daily, now);
97
+ assert.ok(nextDaily > now, 'Daily advance is in the future');
98
+ assert.ok(nextDaily <= now + 86400, 'Daily advance lands within one period of now');
99
+ },
100
+ },
101
+
28
102
  {
29
103
  name: 'seed-campaigns',
30
104
  auth: 'none',
@@ -32,30 +106,21 @@ module.exports = {
32
106
  async run({ firestore, state }) {
33
107
  const now = Math.round(Date.now() / 1000);
34
108
  const pastSendAt = now - 600;
109
+ const staleSendAt = now - (3 * WEEK) - 600;
110
+
111
+ state.now = now;
112
+ state.pastSendAt = pastSendAt;
113
+ state.staleSendAt = staleSendAt;
114
+
115
+ const meta = { created: stamp(now), updated: stamp(now) };
35
116
 
36
117
  // One-off email campaign (sendAt 10 min ago)
37
118
  await firestore.set('marketing-campaigns/_test-oneoff', {
38
119
  status: 'pending',
39
120
  type: 'email',
40
121
  sendAt: pastSendAt,
41
- settings: {
42
- name: '[TEST] One-off blast',
43
- subject: 'Test subject',
44
- preheader: 'Test preheader',
45
- template: 'card',
46
- data: {
47
- content: {
48
- title: 'Test',
49
- message: 'Test campaign body',
50
- button: { text: 'Click', url: 'https://example.com' },
51
- },
52
- },
53
- test: true,
54
- },
55
- metadata: {
56
- created: { timestamp: new Date().toISOString(), timestampUNIX: now },
57
- updated: { timestamp: new Date().toISOString(), timestampUNIX: now },
58
- },
122
+ settings: emailSettings('[TEST] One-off blast'),
123
+ metadata: meta,
59
124
  });
60
125
 
61
126
  // Recurring email campaign (sendAt 10 min ago, weekly recurrence)
@@ -63,30 +128,20 @@ module.exports = {
63
128
  status: 'pending',
64
129
  type: 'email',
65
130
  sendAt: pastSendAt,
66
- recurrence: {
67
- pattern: 'weekly',
68
- hour: 10,
69
- minute: 0,
70
- day: 1,
71
- },
72
- settings: {
73
- name: '[TEST] Weekly digest',
74
- subject: 'Weekly digest',
75
- preheader: 'Your weekly summary',
76
- template: 'card',
77
- data: {
78
- content: {
79
- title: 'Weekly Digest',
80
- message: 'Here is your weekly digest.',
81
- button: { text: 'Read more', url: 'https://example.com' },
82
- },
83
- },
84
- test: true,
85
- },
86
- metadata: {
87
- created: { timestamp: new Date().toISOString(), timestampUNIX: now },
88
- updated: { timestamp: new Date().toISOString(), timestampUNIX: now },
89
- },
131
+ recurrence: { pattern: 'weekly', hour: 10, minute: 0, day: 1 },
132
+ settings: emailSettings('[TEST] Weekly digest'),
133
+ metadata: meta,
134
+ });
135
+
136
+ // Recurring email campaign stalled for 3 weeks — must advance to a
137
+ // FUTURE occurrence in one step (no catch-up burst)
138
+ await firestore.set('marketing-campaigns/_test-recurring-stale', {
139
+ status: 'pending',
140
+ type: 'email',
141
+ sendAt: staleSendAt,
142
+ recurrence: { pattern: 'weekly', hour: 10, minute: 0, day: 1 },
143
+ settings: emailSettings('[TEST] Stale weekly digest'),
144
+ metadata: meta,
90
145
  });
91
146
 
92
147
  // Generator campaign (newsletter — sendAt 10 min ago)
@@ -95,12 +150,7 @@ module.exports = {
95
150
  type: 'email',
96
151
  generator: 'newsletter',
97
152
  sendAt: pastSendAt,
98
- recurrence: {
99
- pattern: 'weekly',
100
- hour: 17,
101
- minute: 30,
102
- day: 2,
103
- },
153
+ recurrence: { pattern: 'weekly', hour: 17, minute: 30, day: 2 },
104
154
  settings: {
105
155
  name: '{brand.name} Newsletter — {date.month} {date.year}',
106
156
  subject: '',
@@ -108,13 +158,79 @@ module.exports = {
108
158
  sender: 'newsletter',
109
159
  providers: ['newsletter'],
110
160
  },
111
- metadata: {
112
- created: { timestamp: new Date().toISOString(), timestampUNIX: now },
113
- updated: { timestamp: new Date().toISOString(), timestampUNIX: now },
114
- },
161
+ metadata: meta,
115
162
  });
116
163
 
117
- state.pastSendAt = pastSendAt;
164
+ // Unknown generator — a config typo must be marked failed, not
165
+ // retried every 10 minutes forever
166
+ await firestore.set('marketing-campaigns/_test-unknown-generator', {
167
+ status: 'pending',
168
+ type: 'email',
169
+ generator: 'doesnotexist',
170
+ sendAt: pastSendAt,
171
+ settings: emailSettings('[TEST] Unknown generator'),
172
+ metadata: meta,
173
+ });
174
+
175
+ // Unknown campaign type — same treatment
176
+ await firestore.set('marketing-campaigns/_test-unknown-type', {
177
+ status: 'pending',
178
+ type: 'carrier-pigeon',
179
+ sendAt: pastSendAt,
180
+ settings: emailSettings('[TEST] Unknown type'),
181
+ metadata: meta,
182
+ });
183
+
184
+ // Stale processing lease (crashed run) — must be reclaimed and
185
+ // processed in the same cron run
186
+ await firestore.set('marketing-campaigns/_test-stale-processing', {
187
+ status: 'processing',
188
+ processingStartedAt: now - PROCESSING_LEASE_SECONDS - 600,
189
+ type: 'email',
190
+ sendAt: pastSendAt,
191
+ settings: emailSettings('[TEST] Stale processing reclaim'),
192
+ metadata: meta,
193
+ });
194
+
195
+ // Fresh processing lease (another run is mid-flight) — must be left
196
+ // completely alone
197
+ await firestore.set('marketing-campaigns/_test-fresh-processing', {
198
+ status: 'processing',
199
+ processingStartedAt: now - 60,
200
+ type: 'email',
201
+ sendAt: pastSendAt,
202
+ settings: emailSettings('[TEST] Fresh processing lease'),
203
+ metadata: meta,
204
+ });
205
+
206
+ // Retry-cap seeds run the real generator in extended mode, so they
207
+ // are DEFAULT-MODE ONLY (the generator nulls instantly via the
208
+ // test-mode gate, letting us exercise the cap deterministically).
209
+ if (!process.env.TEST_EXTENDED_MODE) {
210
+ // One-off generator at the attempts cap → must be marked failed
211
+ await firestore.set('marketing-campaigns/_test-gen-cap-oneoff', {
212
+ status: 'pending',
213
+ type: 'email',
214
+ generator: 'newsletter',
215
+ sendAt: pastSendAt,
216
+ generatorAttempts: GENERATOR_MAX_ATTEMPTS - 1,
217
+ settings: emailSettings('[TEST] Generator cap one-off'),
218
+ metadata: meta,
219
+ });
220
+
221
+ // Recurring generator at the attempts cap, stalled 3 weeks → must
222
+ // skip to the next FUTURE occurrence and reset the counter
223
+ await firestore.set('marketing-campaigns/_test-gen-cap-recurring', {
224
+ status: 'pending',
225
+ type: 'email',
226
+ generator: 'newsletter',
227
+ sendAt: staleSendAt,
228
+ generatorAttempts: GENERATOR_MAX_ATTEMPTS - 1,
229
+ recurrence: { pattern: 'weekly', hour: 10, minute: 0, day: 1 },
230
+ settings: emailSettings('[TEST] Generator cap recurring'),
231
+ metadata: meta,
232
+ });
233
+ }
118
234
  },
119
235
  },
120
236
 
@@ -126,14 +242,25 @@ module.exports = {
126
242
  async run({ pubsub, waitFor, firestore, state }) {
127
243
  await pubsub.trigger('bm_cronFrequent');
128
244
 
129
- // Wait for BOTH the one-off and recurring campaigns to be processed.
130
- // The cron processes all campaigns in parallel via Promise.allSettled,
131
- // but individual Firestore writes may land at different times.
245
+ // Wait for the fast (non-generator) campaigns to reach a terminal
246
+ // state. The cron processes campaigns in parallel via
247
+ // Promise.allSettled, but individual Firestore writes land at
248
+ // different times.
132
249
  await waitFor(
133
250
  async () => {
134
251
  const oneoff = await firestore.get('marketing-campaigns/_test-oneoff');
135
252
  const recurring = await firestore.get('marketing-campaigns/_test-recurring');
136
- return oneoff?.status !== 'pending' && recurring?.sendAt !== state.pastSendAt;
253
+ const recurringStale = await firestore.get('marketing-campaigns/_test-recurring-stale');
254
+ const staleProcessing = await firestore.get('marketing-campaigns/_test-stale-processing');
255
+ const unknownGenerator = await firestore.get('marketing-campaigns/_test-unknown-generator');
256
+ const unknownType = await firestore.get('marketing-campaigns/_test-unknown-type');
257
+
258
+ return oneoff?.status !== 'pending'
259
+ && recurring?.sendAt !== state.pastSendAt
260
+ && recurringStale?.sendAt !== state.staleSendAt
261
+ && ['sent', 'failed'].includes(staleProcessing?.status)
262
+ && unknownGenerator?.status === 'failed'
263
+ && unknownType?.status === 'failed';
137
264
  },
138
265
  90000,
139
266
  1000,
@@ -165,14 +292,14 @@ module.exports = {
165
292
  const doc = await firestore.get('marketing-campaigns/_test-recurring');
166
293
 
167
294
  assert.ok(doc, 'Recurring campaign doc should exist');
168
- assert.equal(doc.status, 'pending', 'Recurring campaign status stays pending');
295
+ assert.equal(doc.status, 'pending', 'Recurring campaign status returns to pending');
169
296
 
170
- const expectedNext = getNextOccurrence(state.pastSendAt, {
297
+ const expectedNext = getNextFutureOccurrence(state.pastSendAt, {
171
298
  pattern: 'weekly',
172
299
  hour: 10,
173
300
  minute: 0,
174
301
  day: 1,
175
- });
302
+ }, state.now);
176
303
  assert.equal(doc.sendAt, expectedNext, 'sendAt should advance to next occurrence');
177
304
  },
178
305
  },
@@ -199,26 +326,102 @@ module.exports = {
199
326
  },
200
327
 
201
328
  {
202
- name: 'generator-campaign-not-skipped',
329
+ name: 'stalled-recurring-skips-to-future-occurrence',
203
330
  auth: 'none',
204
331
 
205
- async run({ firestore, assert, state, config }) {
206
- const doc = await firestore.get('marketing-campaigns/_test-generator');
207
- assert.ok(doc, 'Generator campaign doc should still exist');
332
+ async run({ firestore, assert, state }) {
333
+ const doc = await firestore.get('marketing-campaigns/_test-recurring-stale');
334
+
335
+ assert.ok(doc, 'Stale recurring campaign doc should exist');
336
+ assert.equal(doc.status, 'pending', 'Status returns to pending');
337
+ assert.ok(
338
+ doc.sendAt > state.now,
339
+ `sendAt must land in the FUTURE (no catch-up burst) — got ${doc.sendAt}, now ${state.now}`,
340
+ );
341
+
342
+ const expectedNext = getNextFutureOccurrence(state.staleSendAt, {
343
+ pattern: 'weekly',
344
+ hour: 10,
345
+ minute: 0,
346
+ day: 1,
347
+ }, state.now);
348
+ assert.equal(doc.sendAt, expectedNext, 'Skips all missed occurrences in one advance');
349
+ },
350
+ },
351
+
352
+ {
353
+ name: 'stale-processing-lease-reclaimed-and-processed',
354
+ auth: 'none',
355
+
356
+ async run({ firestore, assert }) {
357
+ const doc = await firestore.get('marketing-campaigns/_test-stale-processing');
358
+
359
+ assert.ok(doc, 'Stale-processing campaign doc should exist');
360
+ assert.ok(
361
+ doc.status === 'sent' || doc.status === 'failed',
362
+ `Reclaimed campaign should be processed to sent/failed, got: ${doc.status}`,
363
+ );
364
+ },
365
+ },
208
366
 
367
+ {
368
+ name: 'fresh-processing-lease-untouched',
369
+ auth: 'none',
370
+
371
+ async run({ firestore, assert, state }) {
372
+ const doc = await firestore.get('marketing-campaigns/_test-fresh-processing');
373
+
374
+ assert.ok(doc, 'Fresh-processing campaign doc should exist');
375
+ assert.equal(doc.status, 'processing', 'A fresh lease is honored — no reclaim, no double-send');
376
+ assert.equal(doc.processingStartedAt, state.now - 60, 'processingStartedAt is untouched');
377
+ },
378
+ },
379
+
380
+ {
381
+ name: 'unknown-generator-and-type-marked-failed',
382
+ auth: 'none',
383
+
384
+ async run({ firestore, assert }) {
385
+ const unknownGenerator = await firestore.get('marketing-campaigns/_test-unknown-generator');
386
+ const unknownType = await firestore.get('marketing-campaigns/_test-unknown-type');
387
+
388
+ assert.equal(unknownGenerator?.status, 'failed', 'Unknown generator is marked failed (not retried forever)');
389
+ assert.ok(/doesnotexist/.test(unknownGenerator?.error || ''), 'Error names the unknown generator');
390
+ assert.equal(unknownType?.status, 'failed', 'Unknown type is marked failed (not retried forever)');
391
+ assert.ok(/carrier-pigeon/.test(unknownType?.error || ''), 'Error names the unknown type');
392
+ },
393
+ },
394
+
395
+ {
396
+ name: 'generator-campaign-not-skipped',
397
+ auth: 'none',
398
+ timeout: 600000,
399
+
400
+ async run({ firestore, assert, state, config, waitFor }) {
209
401
  const newsletterEnabled = config.marketing?.newsletter?.enabled;
210
402
 
211
403
  if (newsletterEnabled && process.env.TEST_EXTENDED_MODE) {
212
- // Extended mode with newsletter enabled: generator should have
213
- // generated + sent in one shot, created a history doc, and
214
- // advanced sendAt
215
- const expectedNext = getNextOccurrence(state.pastSendAt, {
404
+ // Extended mode with newsletter enabled: the generator runs the
405
+ // full AI pipeline inline (minutes) wait for the sendAt advance
406
+ // that marks completion, then verify the history doc.
407
+ await waitFor(
408
+ async () => {
409
+ const doc = await firestore.get('marketing-campaigns/_test-generator');
410
+ return doc?.sendAt !== state.pastSendAt;
411
+ },
412
+ 570000,
413
+ 5000,
414
+ );
415
+
416
+ const doc = await firestore.get('marketing-campaigns/_test-generator');
417
+ const expectedNext = getNextFutureOccurrence(state.pastSendAt, {
216
418
  pattern: 'weekly',
217
419
  hour: 17,
218
420
  minute: 30,
219
421
  day: 2,
220
- });
422
+ }, state.now);
221
423
  assert.equal(doc.sendAt, expectedNext, 'Generator campaign sendAt should advance');
424
+ assert.equal(doc.status, 'pending', 'Recurring generator returns to pending');
222
425
 
223
426
  const histSnapshot = await firestore.collection('marketing-campaigns')
224
427
  .where('generatedFrom', '==', '_test-generator')
@@ -231,40 +434,96 @@ module.exports = {
231
434
  'History doc status should be sent or failed',
232
435
  );
233
436
  assert.ok(!histDoc.generator, 'History doc should NOT have a generator field');
437
+ assert.ok(!histDoc.settings?.article, 'History settings should NOT carry the full article payload');
234
438
  } else {
235
- // Non-extended or newsletter disabled: generator returns null,
236
- // sendAt stays unchanged (will retry next run). The critical
237
- // assertion is that the campaign was ATTEMPTED, not skipped
238
- // verified by the cron logs showing "Running generator" and
239
- // "returned no content" instead of "Skipping generator campaign".
240
- assert.equal(doc.status, 'pending', 'Status stays pending when generator returns null');
439
+ // Default mode: the generator is gated by TEST_EXTENDED_MODE and
440
+ // returns null instantly. The campaign must be ATTEMPTED (claimed,
441
+ // generator run) and then released back to pending with the
442
+ // attempts counter incremented the deterministic retry path.
443
+ await waitFor(
444
+ async () => {
445
+ const doc = await firestore.get('marketing-campaigns/_test-generator');
446
+ return doc?.status === 'pending' && (doc?.generatorAttempts || 0) >= 1;
447
+ },
448
+ 30000,
449
+ 500,
450
+ );
451
+
452
+ const doc = await firestore.get('marketing-campaigns/_test-generator');
453
+ assert.equal(doc.status, 'pending', 'Status returns to pending when generator returns null');
241
454
  assert.equal(doc.sendAt, state.pastSendAt, 'sendAt stays unchanged for retry when generator returns null');
455
+ assert.equal(doc.generatorAttempts, 1, 'generatorAttempts is incremented for the retry cap');
242
456
  }
243
457
  },
244
458
  },
245
459
 
460
+ {
461
+ name: 'generator-retry-cap-fails-oneoff-and-skips-recurring',
462
+ auth: 'none',
463
+
464
+ async run({ firestore, assert, state, waitFor }) {
465
+ // Default-mode only: extended mode would run the real pipeline for
466
+ // these seeds, so they are not created there.
467
+ if (process.env.TEST_EXTENDED_MODE) {
468
+ return;
469
+ }
470
+
471
+ await waitFor(
472
+ async () => {
473
+ const oneoff = await firestore.get('marketing-campaigns/_test-gen-cap-oneoff');
474
+ const recurring = await firestore.get('marketing-campaigns/_test-gen-cap-recurring');
475
+ return oneoff?.status === 'failed' && recurring?.sendAt !== state.staleSendAt;
476
+ },
477
+ 30000,
478
+ 500,
479
+ );
480
+
481
+ const oneoff = await firestore.get('marketing-campaigns/_test-gen-cap-oneoff');
482
+ assert.equal(oneoff.status, 'failed', 'One-off generator at the cap is marked failed');
483
+ assert.ok(/attempts/.test(oneoff.error || ''), 'Error explains the attempts cap');
484
+
485
+ const recurring = await firestore.get('marketing-campaigns/_test-gen-cap-recurring');
486
+ assert.equal(recurring.status, 'pending', 'Recurring generator at the cap stays pending');
487
+ assert.ok(recurring.sendAt > state.now, 'Recurring generator at the cap skips to a FUTURE occurrence');
488
+ assert.ok(!recurring.generatorAttempts, 'Attempts counter resets after skipping the occurrence');
489
+ },
490
+ },
491
+
246
492
  {
247
493
  name: 'cleanup',
248
494
  auth: 'none',
249
495
 
250
496
  async run({ firestore }) {
251
- await firestore.delete('marketing-campaigns/_test-oneoff');
252
- await firestore.delete('marketing-campaigns/_test-recurring');
253
- await firestore.delete('marketing-campaigns/_test-generator');
497
+ const docs = [
498
+ '_test-oneoff',
499
+ '_test-recurring',
500
+ '_test-recurring-stale',
501
+ '_test-generator',
502
+ '_test-unknown-generator',
503
+ '_test-unknown-type',
504
+ '_test-stale-processing',
505
+ '_test-fresh-processing',
506
+ '_test-gen-cap-oneoff',
507
+ '_test-gen-cap-recurring',
508
+ ];
254
509
 
255
- // Clean up history docs (from both recurring and generator campaigns)
256
- const recurringHist = await firestore.collection('marketing-campaigns')
257
- .where('recurringId', '==', '_test-recurring')
258
- .get();
259
- for (const doc of recurringHist.docs) {
260
- await doc.ref.delete();
510
+ for (const id of docs) {
511
+ await firestore.delete(`marketing-campaigns/${id}`);
261
512
  }
262
513
 
263
- const generatorHist = await firestore.collection('marketing-campaigns')
264
- .where('generatedFrom', '==', '_test-generator')
265
- .get();
266
- for (const doc of generatorHist.docs) {
267
- await doc.ref.delete();
514
+ // Clean up history docs (from recurring and generator campaigns)
515
+ for (const [field, value] of [
516
+ ['recurringId', '_test-recurring'],
517
+ ['recurringId', '_test-recurring-stale'],
518
+ ['recurringId', '_test-stale-processing'],
519
+ ['generatedFrom', '_test-generator'],
520
+ ]) {
521
+ const snapshot = await firestore.collection('marketing-campaigns')
522
+ .where(field, '==', value)
523
+ .get();
524
+ for (const doc of snapshot.docs) {
525
+ await doc.ref.delete();
526
+ }
268
527
  }
269
528
  },
270
529
  },
@@ -474,6 +474,18 @@ module.exports = {
474
474
  assert.ok(result.contentMarkdown, 'Generator returned contentMarkdown');
475
475
  assert.ok(result.structure?.sections?.length >= 2, 'Has at least 2 sections');
476
476
 
477
+ // Regression guard: with NEWSLETTER_CREATE_ARTICLE=1 the linked article
478
+ // must actually PUBLISH (commit to the website repo) and the CTA must be
479
+ // injected. Catches publish-path failures that generate-only runs mask
480
+ // (e.g. the v5.11.0 `sources` scope bug that silently failed every
481
+ // production publish inside buildLinkedArticle's try/catch).
482
+ if (env.NEWSLETTER_CREATE_ARTICLE && result.article) {
483
+ assert.ok(result.article.published === true,
484
+ `Linked article must be published when NEWSLETTER_CREATE_ARTICLE=1 (url: ${result.article.url})`);
485
+ assert.ok(result.structure.sections[0]?.cta?.url === result.article.url,
486
+ 'Lead section CTA must point at the published article');
487
+ }
488
+
477
489
  // --- Write outputs ---
478
490
  const previewPath = path.join(runDir, 'newsletter.html');
479
491
  jetpack.write(previewPath, result.contentHtml);
@@ -38,6 +38,19 @@ module.exports = {
38
38
  },
39
39
  },
40
40
 
41
+ {
42
+ name: 'isURL-rejects-colon-prefixed-text-and-non-http-schemes',
43
+ async run({ assert }) {
44
+ // "AI:" parses as a URL scheme via new URL() — these are text seeds,
45
+ // not URLs, and must NOT be fetched (the fetch fails and the seed is
46
+ // silently lost)
47
+ assert.equal(isURL('AI: the future of work'), false);
48
+ assert.equal(isURL('Growth: 10 tactics for creators'), false);
49
+ assert.equal(isURL('mailto:someone@example.com'), false);
50
+ assert.equal(isURL('ftp://example.com/file'), false);
51
+ },
52
+ },
53
+
41
54
  {
42
55
  name: 'isURL-rejects-empty-and-null',
43
56
  async run({ assert }) {