backend-manager 5.11.1 → 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.
- package/CLAUDE.md +1 -1
- package/docs/marketing-campaigns.md +25 -7
- package/docs/test-framework.md +2 -0
- package/package.json +1 -1
- package/src/manager/events/cron/daily/blog-auto-publisher.js +9 -0
- package/src/manager/events/cron/frequent/marketing-campaigns.js +177 -37
- package/src/manager/index.js +5 -1
- package/src/manager/libraries/content/source-resolver.js +7 -0
- package/src/manager/libraries/email/constants.js +32 -0
- package/src/manager/libraries/email/generators/lib/image-host.js +3 -4
- package/src/manager/libraries/email/generators/newsletter.js +14 -2
- package/src/test/fixtures/firebase-project/.temp/test-mode.json +1 -1
- package/src/test/fixtures/firebase-project/database-debug.log +8 -8
- package/src/test/fixtures/firebase-project/firestore-debug.log +57 -51
- package/src/test/fixtures/firebase-project/pubsub-debug.log +3 -3
- package/test/email/campaign-cron-pipeline.js +357 -98
- package/test/email/newsletter-generate.js +12 -0
- package/test/helpers/ai-schema-resolve.js +16 -11
- package/test/helpers/content/blog-auto-publisher.js +13 -0
|
@@ -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
|
|
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:
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* and the
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
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
|
|
130
|
-
// The cron processes
|
|
131
|
-
// but individual Firestore writes
|
|
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
|
-
|
|
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
|
|
295
|
+
assert.equal(doc.status, 'pending', 'Recurring campaign status returns to pending');
|
|
169
296
|
|
|
170
|
-
const expectedNext =
|
|
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: '
|
|
329
|
+
name: 'stalled-recurring-skips-to-future-occurrence',
|
|
203
330
|
auth: 'none',
|
|
204
331
|
|
|
205
|
-
async run({ firestore, assert, state
|
|
206
|
-
const doc = await firestore.get('marketing-campaigns/_test-
|
|
207
|
-
|
|
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
|
|
213
|
-
//
|
|
214
|
-
//
|
|
215
|
-
|
|
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
|
-
//
|
|
236
|
-
//
|
|
237
|
-
//
|
|
238
|
-
//
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
-
|
|
256
|
-
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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);
|
|
@@ -12,6 +12,7 @@ function noopLog() {}
|
|
|
12
12
|
|
|
13
13
|
const FIXTURES_DIR = path.join(__dirname, '..', 'fixtures', 'ai-schema');
|
|
14
14
|
const VALID_SCHEMA_PATH = path.join(FIXTURES_DIR, 'valid.json');
|
|
15
|
+
const INVALID_SCHEMA_PATH = path.join(FIXTURES_DIR, 'invalid.json');
|
|
15
16
|
const VALID_SCHEMA = {
|
|
16
17
|
type: 'object',
|
|
17
18
|
properties: {
|
|
@@ -22,20 +23,17 @@ const VALID_SCHEMA = {
|
|
|
22
23
|
additionalProperties: false,
|
|
23
24
|
};
|
|
24
25
|
|
|
26
|
+
// The runner has no before/after hooks (module contract: tests[] + cleanup),
|
|
27
|
+
// so each test that reads from disk seeds the fixtures itself (idempotent).
|
|
28
|
+
function ensureFixtures() {
|
|
29
|
+
jetpack.write(VALID_SCHEMA_PATH, VALID_SCHEMA);
|
|
30
|
+
jetpack.write(INVALID_SCHEMA_PATH, '{ not valid json !!!');
|
|
31
|
+
}
|
|
32
|
+
|
|
25
33
|
module.exports = {
|
|
26
34
|
description: 'AI schema resolution (inline vs file path)',
|
|
27
35
|
type: 'group',
|
|
28
36
|
|
|
29
|
-
before() {
|
|
30
|
-
jetpack.dir(FIXTURES_DIR);
|
|
31
|
-
jetpack.write(VALID_SCHEMA_PATH, VALID_SCHEMA);
|
|
32
|
-
jetpack.write(path.join(FIXTURES_DIR, 'invalid.json'), '{ not valid json !!!');
|
|
33
|
-
},
|
|
34
|
-
|
|
35
|
-
after() {
|
|
36
|
-
jetpack.remove(FIXTURES_DIR);
|
|
37
|
-
},
|
|
38
|
-
|
|
39
37
|
tests: [
|
|
40
38
|
{
|
|
41
39
|
name: 'undefined-returns-undefined',
|
|
@@ -70,6 +68,7 @@ module.exports = {
|
|
|
70
68
|
{
|
|
71
69
|
name: 'path-loads-json-file',
|
|
72
70
|
async run({ assert }) {
|
|
71
|
+
ensureFixtures();
|
|
73
72
|
const result = resolveSchema({ path: VALID_SCHEMA_PATH }, noopLog);
|
|
74
73
|
assert.deepEqual(result, VALID_SCHEMA, 'loaded schema matches fixture');
|
|
75
74
|
},
|
|
@@ -92,6 +91,7 @@ module.exports = {
|
|
|
92
91
|
{
|
|
93
92
|
name: 'path-to-directory-throws',
|
|
94
93
|
async run({ assert }) {
|
|
94
|
+
ensureFixtures();
|
|
95
95
|
let threw = false;
|
|
96
96
|
try {
|
|
97
97
|
resolveSchema({ path: FIXTURES_DIR }, noopLog);
|
|
@@ -106,9 +106,10 @@ module.exports = {
|
|
|
106
106
|
{
|
|
107
107
|
name: 'invalid-json-throws',
|
|
108
108
|
async run({ assert }) {
|
|
109
|
+
ensureFixtures();
|
|
109
110
|
let threw = false;
|
|
110
111
|
try {
|
|
111
|
-
resolveSchema({ path:
|
|
112
|
+
resolveSchema({ path: INVALID_SCHEMA_PATH }, noopLog);
|
|
112
113
|
} catch (e) {
|
|
113
114
|
threw = true;
|
|
114
115
|
}
|
|
@@ -116,4 +117,8 @@ module.exports = {
|
|
|
116
117
|
},
|
|
117
118
|
},
|
|
118
119
|
],
|
|
120
|
+
|
|
121
|
+
async cleanup() {
|
|
122
|
+
jetpack.remove(FIXTURES_DIR);
|
|
123
|
+
},
|
|
119
124
|
};
|