backend-manager 5.11.2 → 5.11.5
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/docs/marketing-campaigns.md +25 -7
- 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 +180 -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 +54 -58
- package/src/test/fixtures/firebase-project/pubsub-debug.log +3 -3
- package/test/email/campaign-cron-pipeline.js +361 -98
- package/test/email/newsletter-generate.js +12 -0
- package/test/events/auth-delete-race.js +11 -2
- 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,29 @@ 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
|
+
// 'sent'/'failed' — NOT merely !== 'pending': the claim flips the
|
|
259
|
+
// doc to 'processing' first, and proceeding on that mid-flight
|
|
260
|
+
// state races the send (oneoff-campaign-processed then reads
|
|
261
|
+
// 'processing' and fails).
|
|
262
|
+
return ['sent', 'failed'].includes(oneoff?.status)
|
|
263
|
+
&& recurring?.sendAt !== state.pastSendAt
|
|
264
|
+
&& recurringStale?.sendAt !== state.staleSendAt
|
|
265
|
+
&& ['sent', 'failed'].includes(staleProcessing?.status)
|
|
266
|
+
&& unknownGenerator?.status === 'failed'
|
|
267
|
+
&& unknownType?.status === 'failed';
|
|
137
268
|
},
|
|
138
269
|
90000,
|
|
139
270
|
1000,
|
|
@@ -165,14 +296,14 @@ module.exports = {
|
|
|
165
296
|
const doc = await firestore.get('marketing-campaigns/_test-recurring');
|
|
166
297
|
|
|
167
298
|
assert.ok(doc, 'Recurring campaign doc should exist');
|
|
168
|
-
assert.equal(doc.status, 'pending', 'Recurring campaign status
|
|
299
|
+
assert.equal(doc.status, 'pending', 'Recurring campaign status returns to pending');
|
|
169
300
|
|
|
170
|
-
const expectedNext =
|
|
301
|
+
const expectedNext = getNextFutureOccurrence(state.pastSendAt, {
|
|
171
302
|
pattern: 'weekly',
|
|
172
303
|
hour: 10,
|
|
173
304
|
minute: 0,
|
|
174
305
|
day: 1,
|
|
175
|
-
});
|
|
306
|
+
}, state.now);
|
|
176
307
|
assert.equal(doc.sendAt, expectedNext, 'sendAt should advance to next occurrence');
|
|
177
308
|
},
|
|
178
309
|
},
|
|
@@ -199,26 +330,102 @@ module.exports = {
|
|
|
199
330
|
},
|
|
200
331
|
|
|
201
332
|
{
|
|
202
|
-
name: '
|
|
333
|
+
name: 'stalled-recurring-skips-to-future-occurrence',
|
|
203
334
|
auth: 'none',
|
|
204
335
|
|
|
205
|
-
async run({ firestore, assert, state
|
|
206
|
-
const doc = await firestore.get('marketing-campaigns/_test-
|
|
207
|
-
|
|
336
|
+
async run({ firestore, assert, state }) {
|
|
337
|
+
const doc = await firestore.get('marketing-campaigns/_test-recurring-stale');
|
|
338
|
+
|
|
339
|
+
assert.ok(doc, 'Stale recurring campaign doc should exist');
|
|
340
|
+
assert.equal(doc.status, 'pending', 'Status returns to pending');
|
|
341
|
+
assert.ok(
|
|
342
|
+
doc.sendAt > state.now,
|
|
343
|
+
`sendAt must land in the FUTURE (no catch-up burst) — got ${doc.sendAt}, now ${state.now}`,
|
|
344
|
+
);
|
|
345
|
+
|
|
346
|
+
const expectedNext = getNextFutureOccurrence(state.staleSendAt, {
|
|
347
|
+
pattern: 'weekly',
|
|
348
|
+
hour: 10,
|
|
349
|
+
minute: 0,
|
|
350
|
+
day: 1,
|
|
351
|
+
}, state.now);
|
|
352
|
+
assert.equal(doc.sendAt, expectedNext, 'Skips all missed occurrences in one advance');
|
|
353
|
+
},
|
|
354
|
+
},
|
|
355
|
+
|
|
356
|
+
{
|
|
357
|
+
name: 'stale-processing-lease-reclaimed-and-processed',
|
|
358
|
+
auth: 'none',
|
|
359
|
+
|
|
360
|
+
async run({ firestore, assert }) {
|
|
361
|
+
const doc = await firestore.get('marketing-campaigns/_test-stale-processing');
|
|
362
|
+
|
|
363
|
+
assert.ok(doc, 'Stale-processing campaign doc should exist');
|
|
364
|
+
assert.ok(
|
|
365
|
+
doc.status === 'sent' || doc.status === 'failed',
|
|
366
|
+
`Reclaimed campaign should be processed to sent/failed, got: ${doc.status}`,
|
|
367
|
+
);
|
|
368
|
+
},
|
|
369
|
+
},
|
|
208
370
|
|
|
371
|
+
{
|
|
372
|
+
name: 'fresh-processing-lease-untouched',
|
|
373
|
+
auth: 'none',
|
|
374
|
+
|
|
375
|
+
async run({ firestore, assert, state }) {
|
|
376
|
+
const doc = await firestore.get('marketing-campaigns/_test-fresh-processing');
|
|
377
|
+
|
|
378
|
+
assert.ok(doc, 'Fresh-processing campaign doc should exist');
|
|
379
|
+
assert.equal(doc.status, 'processing', 'A fresh lease is honored — no reclaim, no double-send');
|
|
380
|
+
assert.equal(doc.processingStartedAt, state.now - 60, 'processingStartedAt is untouched');
|
|
381
|
+
},
|
|
382
|
+
},
|
|
383
|
+
|
|
384
|
+
{
|
|
385
|
+
name: 'unknown-generator-and-type-marked-failed',
|
|
386
|
+
auth: 'none',
|
|
387
|
+
|
|
388
|
+
async run({ firestore, assert }) {
|
|
389
|
+
const unknownGenerator = await firestore.get('marketing-campaigns/_test-unknown-generator');
|
|
390
|
+
const unknownType = await firestore.get('marketing-campaigns/_test-unknown-type');
|
|
391
|
+
|
|
392
|
+
assert.equal(unknownGenerator?.status, 'failed', 'Unknown generator is marked failed (not retried forever)');
|
|
393
|
+
assert.ok(/doesnotexist/.test(unknownGenerator?.error || ''), 'Error names the unknown generator');
|
|
394
|
+
assert.equal(unknownType?.status, 'failed', 'Unknown type is marked failed (not retried forever)');
|
|
395
|
+
assert.ok(/carrier-pigeon/.test(unknownType?.error || ''), 'Error names the unknown type');
|
|
396
|
+
},
|
|
397
|
+
},
|
|
398
|
+
|
|
399
|
+
{
|
|
400
|
+
name: 'generator-campaign-not-skipped',
|
|
401
|
+
auth: 'none',
|
|
402
|
+
timeout: 600000,
|
|
403
|
+
|
|
404
|
+
async run({ firestore, assert, state, config, waitFor }) {
|
|
209
405
|
const newsletterEnabled = config.marketing?.newsletter?.enabled;
|
|
210
406
|
|
|
211
407
|
if (newsletterEnabled && process.env.TEST_EXTENDED_MODE) {
|
|
212
|
-
// Extended mode with newsletter enabled: generator
|
|
213
|
-
//
|
|
214
|
-
//
|
|
215
|
-
|
|
408
|
+
// Extended mode with newsletter enabled: the generator runs the
|
|
409
|
+
// full AI pipeline inline (minutes) — wait for the sendAt advance
|
|
410
|
+
// that marks completion, then verify the history doc.
|
|
411
|
+
await waitFor(
|
|
412
|
+
async () => {
|
|
413
|
+
const doc = await firestore.get('marketing-campaigns/_test-generator');
|
|
414
|
+
return doc?.sendAt !== state.pastSendAt;
|
|
415
|
+
},
|
|
416
|
+
570000,
|
|
417
|
+
5000,
|
|
418
|
+
);
|
|
419
|
+
|
|
420
|
+
const doc = await firestore.get('marketing-campaigns/_test-generator');
|
|
421
|
+
const expectedNext = getNextFutureOccurrence(state.pastSendAt, {
|
|
216
422
|
pattern: 'weekly',
|
|
217
423
|
hour: 17,
|
|
218
424
|
minute: 30,
|
|
219
425
|
day: 2,
|
|
220
|
-
});
|
|
426
|
+
}, state.now);
|
|
221
427
|
assert.equal(doc.sendAt, expectedNext, 'Generator campaign sendAt should advance');
|
|
428
|
+
assert.equal(doc.status, 'pending', 'Recurring generator returns to pending');
|
|
222
429
|
|
|
223
430
|
const histSnapshot = await firestore.collection('marketing-campaigns')
|
|
224
431
|
.where('generatedFrom', '==', '_test-generator')
|
|
@@ -231,40 +438,96 @@ module.exports = {
|
|
|
231
438
|
'History doc status should be sent or failed',
|
|
232
439
|
);
|
|
233
440
|
assert.ok(!histDoc.generator, 'History doc should NOT have a generator field');
|
|
441
|
+
assert.ok(!histDoc.settings?.article, 'History settings should NOT carry the full article payload');
|
|
234
442
|
} else {
|
|
235
|
-
//
|
|
236
|
-
//
|
|
237
|
-
//
|
|
238
|
-
//
|
|
239
|
-
|
|
240
|
-
|
|
443
|
+
// Default mode: the generator is gated by TEST_EXTENDED_MODE and
|
|
444
|
+
// returns null instantly. The campaign must be ATTEMPTED (claimed,
|
|
445
|
+
// generator run) and then released back to pending with the
|
|
446
|
+
// attempts counter incremented — the deterministic retry path.
|
|
447
|
+
await waitFor(
|
|
448
|
+
async () => {
|
|
449
|
+
const doc = await firestore.get('marketing-campaigns/_test-generator');
|
|
450
|
+
return doc?.status === 'pending' && (doc?.generatorAttempts || 0) >= 1;
|
|
451
|
+
},
|
|
452
|
+
30000,
|
|
453
|
+
500,
|
|
454
|
+
);
|
|
455
|
+
|
|
456
|
+
const doc = await firestore.get('marketing-campaigns/_test-generator');
|
|
457
|
+
assert.equal(doc.status, 'pending', 'Status returns to pending when generator returns null');
|
|
241
458
|
assert.equal(doc.sendAt, state.pastSendAt, 'sendAt stays unchanged for retry when generator returns null');
|
|
459
|
+
assert.equal(doc.generatorAttempts, 1, 'generatorAttempts is incremented for the retry cap');
|
|
242
460
|
}
|
|
243
461
|
},
|
|
244
462
|
},
|
|
245
463
|
|
|
464
|
+
{
|
|
465
|
+
name: 'generator-retry-cap-fails-oneoff-and-skips-recurring',
|
|
466
|
+
auth: 'none',
|
|
467
|
+
|
|
468
|
+
async run({ firestore, assert, state, waitFor }) {
|
|
469
|
+
// Default-mode only: extended mode would run the real pipeline for
|
|
470
|
+
// these seeds, so they are not created there.
|
|
471
|
+
if (process.env.TEST_EXTENDED_MODE) {
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
await waitFor(
|
|
476
|
+
async () => {
|
|
477
|
+
const oneoff = await firestore.get('marketing-campaigns/_test-gen-cap-oneoff');
|
|
478
|
+
const recurring = await firestore.get('marketing-campaigns/_test-gen-cap-recurring');
|
|
479
|
+
return oneoff?.status === 'failed' && recurring?.sendAt !== state.staleSendAt;
|
|
480
|
+
},
|
|
481
|
+
30000,
|
|
482
|
+
500,
|
|
483
|
+
);
|
|
484
|
+
|
|
485
|
+
const oneoff = await firestore.get('marketing-campaigns/_test-gen-cap-oneoff');
|
|
486
|
+
assert.equal(oneoff.status, 'failed', 'One-off generator at the cap is marked failed');
|
|
487
|
+
assert.ok(/attempts/.test(oneoff.error || ''), 'Error explains the attempts cap');
|
|
488
|
+
|
|
489
|
+
const recurring = await firestore.get('marketing-campaigns/_test-gen-cap-recurring');
|
|
490
|
+
assert.equal(recurring.status, 'pending', 'Recurring generator at the cap stays pending');
|
|
491
|
+
assert.ok(recurring.sendAt > state.now, 'Recurring generator at the cap skips to a FUTURE occurrence');
|
|
492
|
+
assert.ok(!recurring.generatorAttempts, 'Attempts counter resets after skipping the occurrence');
|
|
493
|
+
},
|
|
494
|
+
},
|
|
495
|
+
|
|
246
496
|
{
|
|
247
497
|
name: 'cleanup',
|
|
248
498
|
auth: 'none',
|
|
249
499
|
|
|
250
500
|
async run({ firestore }) {
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
501
|
+
const docs = [
|
|
502
|
+
'_test-oneoff',
|
|
503
|
+
'_test-recurring',
|
|
504
|
+
'_test-recurring-stale',
|
|
505
|
+
'_test-generator',
|
|
506
|
+
'_test-unknown-generator',
|
|
507
|
+
'_test-unknown-type',
|
|
508
|
+
'_test-stale-processing',
|
|
509
|
+
'_test-fresh-processing',
|
|
510
|
+
'_test-gen-cap-oneoff',
|
|
511
|
+
'_test-gen-cap-recurring',
|
|
512
|
+
];
|
|
254
513
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
.where('recurringId', '==', '_test-recurring')
|
|
258
|
-
.get();
|
|
259
|
-
for (const doc of recurringHist.docs) {
|
|
260
|
-
await doc.ref.delete();
|
|
514
|
+
for (const id of docs) {
|
|
515
|
+
await firestore.delete(`marketing-campaigns/${id}`);
|
|
261
516
|
}
|
|
262
517
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
518
|
+
// Clean up history docs (from recurring and generator campaigns)
|
|
519
|
+
for (const [field, value] of [
|
|
520
|
+
['recurringId', '_test-recurring'],
|
|
521
|
+
['recurringId', '_test-recurring-stale'],
|
|
522
|
+
['recurringId', '_test-stale-processing'],
|
|
523
|
+
['generatedFrom', '_test-generator'],
|
|
524
|
+
]) {
|
|
525
|
+
const snapshot = await firestore.collection('marketing-campaigns')
|
|
526
|
+
.where(field, '==', value)
|
|
527
|
+
.get();
|
|
528
|
+
for (const doc of snapshot.docs) {
|
|
529
|
+
await doc.ref.delete();
|
|
530
|
+
}
|
|
268
531
|
}
|
|
269
532
|
},
|
|
270
533
|
},
|
|
@@ -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);
|
|
@@ -47,7 +47,7 @@ module.exports = {
|
|
|
47
47
|
|
|
48
48
|
{
|
|
49
49
|
name: 'no-wait-gets-clobbered',
|
|
50
|
-
async run({ Manager, assert }) {
|
|
50
|
+
async run({ Manager, assert, skip }) {
|
|
51
51
|
const admin = Manager.libraries.admin;
|
|
52
52
|
const testUid = '_test-race-no-wait';
|
|
53
53
|
const testEmail = '_test.race-no-wait@test.com';
|
|
@@ -76,7 +76,16 @@ module.exports = {
|
|
|
76
76
|
const doc = await userRef.get();
|
|
77
77
|
const survived = doc.exists && !!(doc.data()?.api?.clientId);
|
|
78
78
|
|
|
79
|
-
|
|
79
|
+
// The race resolves either way: when the emulator happens to finish
|
|
80
|
+
// the on-delete BEFORE the re-create's onCreate writes the doc, the
|
|
81
|
+
// dangerous late-clobber ordering never materializes that run —
|
|
82
|
+
// scheduler luck, not a regression. Only the clobber outcome is
|
|
83
|
+
// assertable; the benign ordering skips.
|
|
84
|
+
if (survived) {
|
|
85
|
+
await admin.auth().deleteUser(testUid).catch(() => {});
|
|
86
|
+
await pollUntilGone(userRef);
|
|
87
|
+
return skip('on-delete completed before the re-create this run — race did not manifest');
|
|
88
|
+
}
|
|
80
89
|
|
|
81
90
|
await admin.auth().deleteUser(testUid).catch(() => {});
|
|
82
91
|
await pollUntilGone(userRef);
|
|
@@ -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 }) {
|