backend-manager 5.2.15 → 5.2.17
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 +27 -0
- package/CLAUDE.md +3 -3
- package/README.md +1 -0
- package/docs/admin-post-route.md +2 -0
- package/docs/ai-library.md +17 -0
- package/docs/consent.md +7 -4
- package/docs/marketing-campaigns.md +23 -1
- package/package.json +1 -1
- package/src/cli/commands/base-command.js +30 -4
- package/src/defaults/CLAUDE.md +4 -0
- package/src/manager/events/cron/daily/ghostii-auto-publisher.js +14 -47
- package/src/manager/events/cron/daily/marketing-newsletter-generate.js +3 -1
- package/src/manager/libraries/ai/providers/openai.js +17 -0
- package/src/manager/libraries/content/ghostii.js +100 -0
- package/src/manager/libraries/email/generators/lib/markdown-renderer.js +9 -0
- package/src/manager/libraries/email/generators/newsletter.js +148 -3
- package/src/manager/routes/user/signup/post.js +129 -38
- package/src/manager/schemas/user/signup/post.js +12 -8
- package/src/test/test-accounts.js +25 -0
- package/templates/backend-manager-config.json +9 -2
- package/test/marketing/newsletter-generate.js +70 -7
- package/test/routes/user/signup.js +168 -0
|
@@ -44,8 +44,16 @@
|
|
|
44
44
|
* NEWSLETTER_PEEK=1 Fetch + list ready sources, do not claim, exit.
|
|
45
45
|
* NEWSLETTER_SOURCE_ID=<id> Generate from one specific source WITHOUT claiming it.
|
|
46
46
|
* NEWSLETTER_LIMIT=10 Sources per category for PEEK (default 10).
|
|
47
|
+
* NEWSLETTER_CLAIM=1 CLAIM the fetched sources (claimFor=brandId), consuming them so the
|
|
48
|
+
* real newsletter won't reuse them. OFF by default — the test fetches
|
|
49
|
+
* without claiming so runs are repeatable and non-destructive.
|
|
47
50
|
* NEWSLETTER_RELEASE=1 Reset locally-tracked claimed sources back to 'ready'.
|
|
48
51
|
* NEWSLETTER_NO_IMAGES=1 Skip SVG/PNG generation (fast iteration on copy only).
|
|
52
|
+
* NEWSLETTER_CREATE_ARTICLE=1 PUBLISH the linked blog article to the website repo (Ghostii → admin/post → GitHub).
|
|
53
|
+
* The article is always GENERATED when the brand's
|
|
54
|
+
* marketing.beehiiv.content.article.enabled is on (exercises the Ghostii write +
|
|
55
|
+
* URL/CTA path); this flag only controls whether it's actually committed.
|
|
56
|
+
* OFF by default — a newsletter test run never commits a real post.
|
|
49
57
|
* NEWSLETTER_PROVIDER_STRUCTURE=X Override structure provider (openai|anthropic).
|
|
50
58
|
* NEWSLETTER_PROVIDER_SVG=X Override SVG provider (openai|anthropic).
|
|
51
59
|
* NEWSLETTER_CAMPAIGN_ID=<id> Override the auto-generated campaign ID (folder name in newsletter-assets).
|
|
@@ -345,13 +353,20 @@ module.exports = {
|
|
|
345
353
|
return;
|
|
346
354
|
}
|
|
347
355
|
|
|
348
|
-
// --- Fetch sources
|
|
356
|
+
// --- Fetch sources ---
|
|
357
|
+
// By DEFAULT the test does NOT claim sources — it fetches them without
|
|
358
|
+
// claimFor, so they stay 'ready' and available for the real newsletter.
|
|
359
|
+
// This keeps test runs repeatable and non-destructive (a test should never
|
|
360
|
+
// silently consume production resources). Set NEWSLETTER_CLAIM=1 to exercise
|
|
361
|
+
// the real claim/consume path (then use NEWSLETTER_RELEASE=1 to put them back).
|
|
362
|
+
const claim = !!env.NEWSLETTER_CLAIM;
|
|
349
363
|
const sources = await fetchSourcesForRun({
|
|
350
364
|
parentUrl,
|
|
351
365
|
newsletterConfig,
|
|
352
366
|
brandId: config.brand?.id,
|
|
353
367
|
sourceId: env.NEWSLETTER_SOURCE_ID,
|
|
354
368
|
key: env.BACKEND_MANAGER_KEY,
|
|
369
|
+
claim,
|
|
355
370
|
});
|
|
356
371
|
|
|
357
372
|
// Environmental precondition: the parent server must have ready sources in
|
|
@@ -361,8 +376,10 @@ module.exports = {
|
|
|
361
376
|
return skip('No ready newsletter sources available on parent server (environmental)');
|
|
362
377
|
}
|
|
363
378
|
|
|
364
|
-
// Track claimed IDs for later --release-all
|
|
365
|
-
|
|
379
|
+
// Track claimed IDs for later --release-all (only when we actually claimed)
|
|
380
|
+
if (claim && !env.NEWSLETTER_SOURCE_ID) {
|
|
381
|
+
appendClaimed(claimedFile, sources.map((s) => s.id));
|
|
382
|
+
}
|
|
366
383
|
|
|
367
384
|
// Force `beehiiv.enabled: true` and inject the per-run newsletter config
|
|
368
385
|
// overrides onto Manager.config. The iteration test IS the explicit trigger
|
|
@@ -382,12 +399,18 @@ module.exports = {
|
|
|
382
399
|
// --- Run the production generator with the local-persist image hook ---
|
|
383
400
|
const generator = require('../../src/manager/libraries/email/generators/newsletter.js');
|
|
384
401
|
|
|
385
|
-
// EXTENDED mode
|
|
402
|
+
// EXTENDED mode mirrors the production cron's newsletter side effects:
|
|
386
403
|
// GH upload always happens (PNGs + newsletter.html), Beehiiv draft upload
|
|
387
404
|
// always happens (governed inside newsletter.js by beehiiv.enabled, which
|
|
388
405
|
// we force true above). If you don't want the side effects, run fixture
|
|
389
406
|
// mode instead.
|
|
390
407
|
//
|
|
408
|
+
// The ONE deliberate exception is PUBLISHING the linked blog article: the
|
|
409
|
+
// article is still generated (so the test exercises the Ghostii write + CTA
|
|
410
|
+
// path), but it's NOT committed to the website repo unless you opt in with
|
|
411
|
+
// NEWSLETTER_CREATE_ARTICLE=1 (publishArticle below). Committing a real post
|
|
412
|
+
// is out of scope for a routine newsletter test.
|
|
413
|
+
//
|
|
391
414
|
// persistImage is a side-effect callback that writes PNG+SVG to runDir for
|
|
392
415
|
// local preview / debug. Its return value is ignored when imageHost: 'github'
|
|
393
416
|
// because the generator uses the uploaded CDN URLs in the rendered HTML.
|
|
@@ -416,6 +439,11 @@ module.exports = {
|
|
|
416
439
|
sources,
|
|
417
440
|
skipClaim: true, // We manage the claim/release lifecycle ourselves
|
|
418
441
|
skipImages: !!env.NEWSLETTER_NO_IMAGES,
|
|
442
|
+
// The article is GENERATED whenever the brand's config.article.enabled is on
|
|
443
|
+
// (exercises the Ghostii write + URL/CTA path), but only PUBLISHED to the
|
|
444
|
+
// website repo when you opt in with NEWSLETTER_CREATE_ARTICLE=1. Default
|
|
445
|
+
// test run generates but does not commit a real post.
|
|
446
|
+
publishArticle: !!env.NEWSLETTER_CREATE_ARTICLE,
|
|
419
447
|
// Local disk persistence runs unconditionally (for preview/debug)
|
|
420
448
|
persistImage,
|
|
421
449
|
// EXTENDED always uploads to GitHub — mirrors production cron exactly
|
|
@@ -444,10 +472,37 @@ module.exports = {
|
|
|
444
472
|
assets: result.assets || null,
|
|
445
473
|
}, null, 2));
|
|
446
474
|
|
|
475
|
+
// Linked blog article (when content.article.enabled). Save the full Ghostii
|
|
476
|
+
// output + the computed URL so you can review what would be published — both
|
|
477
|
+
// the raw JSON and a readable markdown view of the article body.
|
|
478
|
+
if (result.article?.article) {
|
|
479
|
+
const a = result.article.article;
|
|
480
|
+
jetpack.write(path.join(runDir, 'article.json'), JSON.stringify(result.article, null, 2));
|
|
481
|
+
jetpack.write(path.join(runDir, 'article.md'), [
|
|
482
|
+
`# ${a.title || ''}`,
|
|
483
|
+
'',
|
|
484
|
+
`> ${a.description || ''}`,
|
|
485
|
+
'',
|
|
486
|
+
`**URL:** ${result.article.url || '(none)'}`,
|
|
487
|
+
`**Published:** ${result.article.published ? 'yes' : 'no (generate-only)'}`,
|
|
488
|
+
a.headerImageUrl ? `**Header image:** ${a.headerImageUrl}` : '',
|
|
489
|
+
a.categories?.length ? `**Categories:** ${a.categories.join(', ')}` : '',
|
|
490
|
+
a.keywords?.length ? `**Keywords:** ${a.keywords.join(', ')}` : '',
|
|
491
|
+
'',
|
|
492
|
+
'---',
|
|
493
|
+
'',
|
|
494
|
+
a.body || '',
|
|
495
|
+
].filter(line => line !== undefined).join('\n'));
|
|
496
|
+
}
|
|
497
|
+
|
|
447
498
|
console.log(`\nNewsletter preview written: ${previewPath}`);
|
|
448
499
|
console.log(`Subject: ${result.subject}`);
|
|
449
500
|
console.log(`Preheader: ${result.preheader}`);
|
|
450
501
|
console.log(`Sections: ${result.structure.sections.length}`);
|
|
502
|
+
if (result.article?.article) {
|
|
503
|
+
console.log(`Article: "${result.article.article.title}" → ${result.article.url} (${result.article.published ? 'published' : 'generate-only'})`);
|
|
504
|
+
console.log(` ${path.join(runDir, 'article.md')}`);
|
|
505
|
+
}
|
|
451
506
|
if (result.meta?.totals) {
|
|
452
507
|
const t = result.meta.totals;
|
|
453
508
|
console.log(`\nRun summary:`);
|
|
@@ -606,13 +661,14 @@ async function peekSources({ parentUrl, categories, limit, key }) {
|
|
|
606
661
|
/**
|
|
607
662
|
* Fetch sources for an actual generation run. Either:
|
|
608
663
|
* - A specific source by id — preview only, NO claim (iterate repeatedly on the same source)
|
|
609
|
-
* - Or N per category
|
|
664
|
+
* - Or N per category. Fetches WITHOUT claiming by default (claim=false), so runs are
|
|
665
|
+
* repeatable; pass claim=true (NEWSLETTER_CLAIM=1) to atomically claim/consume them.
|
|
610
666
|
*
|
|
611
667
|
* When NEWSLETTER_SOURCE_ID is set, we look the source up in any status
|
|
612
668
|
* (ready, claimed, used) so you can keep iterating on it across runs without
|
|
613
669
|
* the parent server's claim mechanism marking it consumed.
|
|
614
670
|
*/
|
|
615
|
-
async function fetchSourcesForRun({ parentUrl, newsletterConfig, brandId, sourceId, key }) {
|
|
671
|
+
async function fetchSourcesForRun({ parentUrl, newsletterConfig, brandId, sourceId, key, claim = false }) {
|
|
616
672
|
if (sourceId) {
|
|
617
673
|
// Peek across ALL ready sources (no claim). Search broadly first, then
|
|
618
674
|
// fall back to any-status if needed. We never call claimFor with sourceId
|
|
@@ -657,11 +713,18 @@ async function fetchSourcesForRun({ parentUrl, newsletterConfig, brandId, source
|
|
|
657
713
|
|
|
658
714
|
const all = [];
|
|
659
715
|
for (const category of categories) {
|
|
716
|
+
// claimFor is what tells the parent server to mark sources consumed. Omit it
|
|
717
|
+
// (claim=false) to fetch the same sources without claiming them.
|
|
718
|
+
const query = { category, limit: 3, backendManagerKey: key };
|
|
719
|
+
if (claim) {
|
|
720
|
+
query.claimFor = brandId;
|
|
721
|
+
}
|
|
722
|
+
|
|
660
723
|
const data = await fetch(`${parentUrl}/newsletter-sources`, {
|
|
661
724
|
method: 'get',
|
|
662
725
|
response: 'json',
|
|
663
726
|
timeout: 15000,
|
|
664
|
-
query
|
|
727
|
+
query,
|
|
665
728
|
});
|
|
666
729
|
all.push(...(data.sources || []));
|
|
667
730
|
}
|
|
@@ -347,6 +347,174 @@ module.exports = {
|
|
|
347
347
|
},
|
|
348
348
|
},
|
|
349
349
|
|
|
350
|
+
// --- Consent downgrade-protection tests ---
|
|
351
|
+
// Guards against data loss when a LEGACY account (signed up before the flags.signupProcessed
|
|
352
|
+
// flow existed, so flag never set) re-fires /user/signup on page load. Its localStorage
|
|
353
|
+
// consent is long gone, so the payload is empty — without the guard, buildConsentRecord
|
|
354
|
+
// would compute 'revoked' and the {merge:true} write would wipe the consent the user
|
|
355
|
+
// actually granted months ago. The guard preserves any existing 'granted' status.
|
|
356
|
+
{
|
|
357
|
+
name: 'consent-empty-payload-preserves-existing-grant',
|
|
358
|
+
async run({ http, firestore, assert, accounts }) {
|
|
359
|
+
const uid = accounts['consent-preserve'].uid;
|
|
360
|
+
|
|
361
|
+
// Seed the doc as an established account whose consent is already granted (as a real
|
|
362
|
+
// legacy signup would be after the OMEGA migration backfilled consent). flags is left
|
|
363
|
+
// at the schema default (signupProcessed: false) to mimic the legacy state exactly.
|
|
364
|
+
// merge:true — preserve the runner-provisioned auth.uid (pollForUserDoc needs it).
|
|
365
|
+
await firestore.set(`users/${uid}`, {
|
|
366
|
+
consent: {
|
|
367
|
+
legal: {
|
|
368
|
+
status: 'granted',
|
|
369
|
+
grantedAt: { timestamp: '2025-01-01T00:00:00.000Z', timestampUNIX: 1735689600, source: 'signup', ip: null, text: 'Legacy legal grant' },
|
|
370
|
+
},
|
|
371
|
+
marketing: {
|
|
372
|
+
status: 'granted',
|
|
373
|
+
grantedAt: { timestamp: '2025-01-01T00:00:00.000Z', timestampUNIX: 1735689600, source: 'signup', ip: null, text: 'Legacy marketing grant' },
|
|
374
|
+
revokedAt: { timestamp: null, timestampUNIX: null, source: null, ip: null, text: null },
|
|
375
|
+
},
|
|
376
|
+
},
|
|
377
|
+
flags: { signupProcessed: false },
|
|
378
|
+
}, { merge: true });
|
|
379
|
+
|
|
380
|
+
// Re-fire signup with NO consent payload (the legacy page-load case).
|
|
381
|
+
const signupResponse = await http.as('consent-preserve').post('user/signup', {});
|
|
382
|
+
assert.isSuccess(signupResponse, `Signup should succeed: ${JSON.stringify(signupResponse, null, 2)}`);
|
|
383
|
+
|
|
384
|
+
const userDoc = await firestore.get(`users/${uid}`);
|
|
385
|
+
|
|
386
|
+
// CRITICAL: the prior grants must survive — NOT be downgraded to revoked.
|
|
387
|
+
assert.equal(userDoc?.consent?.legal?.status, 'granted', 'legal.status must stay granted (not downgraded by empty payload)');
|
|
388
|
+
assert.equal(userDoc?.consent?.legal?.grantedAt?.text, 'Legacy legal grant', 'legal grantedAt must be the preserved original, not wiped');
|
|
389
|
+
assert.equal(userDoc?.consent?.legal?.grantedAt?.timestampUNIX, 1735689600, 'legal grantedAt timestamp must be preserved');
|
|
390
|
+
|
|
391
|
+
assert.equal(userDoc?.consent?.marketing?.status, 'granted', 'marketing.status must stay granted (not downgraded by empty payload)');
|
|
392
|
+
assert.equal(userDoc?.consent?.marketing?.grantedAt?.text, 'Legacy marketing grant', 'marketing grantedAt must be the preserved original');
|
|
393
|
+
// No spurious revokedAt should have been stamped over the preserved grant.
|
|
394
|
+
assert.equal(userDoc?.consent?.marketing?.revokedAt?.timestamp, null, 'marketing revokedAt must stay null (no decline was recorded)');
|
|
395
|
+
|
|
396
|
+
// signupProcessed should now be flipped true by this run.
|
|
397
|
+
assert.equal(userDoc?.flags?.signupProcessed, true, 'signupProcessed should be set true after the run');
|
|
398
|
+
},
|
|
399
|
+
},
|
|
400
|
+
{
|
|
401
|
+
name: 'consent-explicit-decline-does-not-downgrade-existing-grant',
|
|
402
|
+
async run({ http, firestore, assert, accounts }) {
|
|
403
|
+
const uid = accounts['consent-preserve'].uid;
|
|
404
|
+
|
|
405
|
+
// Re-seed: granted marketing + UNSET signupProcessed so the route processes this call.
|
|
406
|
+
// Then send a payload that explicitly DECLINES marketing. The guard must still preserve
|
|
407
|
+
// the existing grant — only an explicit RE-GRANT may overwrite; a decline-over-grant on
|
|
408
|
+
// the signup path is treated as a non-grant and must not wipe a prior consent.
|
|
409
|
+
// merge:true — preserve the runner-provisioned auth.uid (pollForUserDoc needs it).
|
|
410
|
+
await firestore.set(`users/${uid}`, {
|
|
411
|
+
consent: {
|
|
412
|
+
marketing: {
|
|
413
|
+
status: 'granted',
|
|
414
|
+
grantedAt: { timestamp: '2025-01-01T00:00:00.000Z', timestampUNIX: 1735689600, source: 'signup', ip: null, text: 'Prior marketing grant' },
|
|
415
|
+
revokedAt: { timestamp: null, timestampUNIX: null, source: null, ip: null, text: null },
|
|
416
|
+
},
|
|
417
|
+
},
|
|
418
|
+
flags: { signupProcessed: false },
|
|
419
|
+
}, { merge: true });
|
|
420
|
+
|
|
421
|
+
const signupResponse = await http.as('consent-preserve').post('user/signup', {
|
|
422
|
+
consent: {
|
|
423
|
+
legal: { granted: true, text: 'Legal grant on re-fire' },
|
|
424
|
+
marketing: { granted: false, text: 'Declining marketing' },
|
|
425
|
+
},
|
|
426
|
+
});
|
|
427
|
+
assert.isSuccess(signupResponse, `Signup should succeed: ${JSON.stringify(signupResponse, null, 2)}`);
|
|
428
|
+
|
|
429
|
+
const userDoc = await firestore.get(`users/${uid}`);
|
|
430
|
+
|
|
431
|
+
// Legal newly granted this call.
|
|
432
|
+
assert.equal(userDoc?.consent?.legal?.status, 'granted', 'legal.status should be granted from this call');
|
|
433
|
+
// Marketing was already granted; an explicit decline must NOT downgrade it.
|
|
434
|
+
assert.equal(userDoc?.consent?.marketing?.status, 'granted', 'marketing.status must stay granted (decline cannot downgrade an existing grant on signup path)');
|
|
435
|
+
assert.equal(userDoc?.consent?.marketing?.grantedAt?.text, 'Prior marketing grant', 'marketing grant must be the preserved original');
|
|
436
|
+
},
|
|
437
|
+
},
|
|
438
|
+
|
|
439
|
+
// --- buildUserRecord layered deep-merge tests ---
|
|
440
|
+
// The signup write must: (a) fill every schema leaf so the doc is complete (no migration
|
|
441
|
+
// churn), (b) PRESERVE existing real values (api keys, subscription, roles, affiliate.code,
|
|
442
|
+
// custom non-schema fields), and (c) apply the signup data on top — without Firestore's
|
|
443
|
+
// map-replace wiping nested data. These tests seed adversarial existing state and verify.
|
|
444
|
+
{
|
|
445
|
+
name: 'merge-preserves-existing-and-fills-schema',
|
|
446
|
+
async run({ http, firestore, assert, accounts }) {
|
|
447
|
+
const uid = accounts['signup-merge'].uid;
|
|
448
|
+
|
|
449
|
+
// Seed real/custom state that the signup write must NOT clobber, plus a deliberately
|
|
450
|
+
// PARTIAL attribution (only affiliate.code) to prove leaves get filled, not replaced-away.
|
|
451
|
+
await firestore.set(`users/${uid}`, {
|
|
452
|
+
api: { clientId: 'REAL-CLIENT-ID', privateKey: 'REAL-PRIVATE-KEY' },
|
|
453
|
+
affiliate: { code: 'REALAFFILIATECODE', referrals: [{ uid: 'someone' }] },
|
|
454
|
+
subscription: { product: { id: 'pro', name: 'Pro' }, status: 'active' },
|
|
455
|
+
roles: { admin: true, betaTester: false, developer: false },
|
|
456
|
+
attribution: { affiliate: { code: 'PARTIALONLY' } },
|
|
457
|
+
myCustomIntegration: { slackWebhook: 'https://hooks.slack.com/services/XXX' },
|
|
458
|
+
}, { merge: true });
|
|
459
|
+
|
|
460
|
+
const signupResponse = await http.as('signup-merge').post('user/signup', {
|
|
461
|
+
consent: { legal: { granted: true, text: 'I agree.' }, marketing: { granted: true, text: 'Updates please.' } },
|
|
462
|
+
attribution: { utm: { tags: { utm_source: 'newsletter' } } },
|
|
463
|
+
});
|
|
464
|
+
assert.isSuccess(signupResponse, `Signup should succeed: ${JSON.stringify(signupResponse, null, 2)}`);
|
|
465
|
+
|
|
466
|
+
const doc = await firestore.get(`users/${uid}`);
|
|
467
|
+
|
|
468
|
+
// (b) Existing real values must survive untouched — NOT regenerated/reset by the schema layer.
|
|
469
|
+
assert.equal(doc?.api?.clientId, 'REAL-CLIENT-ID', 'api.clientId must be preserved (not regenerated)');
|
|
470
|
+
assert.equal(doc?.api?.privateKey, 'REAL-PRIVATE-KEY', 'api.privateKey must be preserved (not regenerated)');
|
|
471
|
+
assert.equal(doc?.affiliate?.code, 'REALAFFILIATECODE', 'affiliate.code must be preserved (not regenerated)');
|
|
472
|
+
assert.equal(doc?.subscription?.product?.id, 'pro', 'subscription must be preserved (not reset to basic)');
|
|
473
|
+
assert.equal(doc?.roles?.admin, true, 'roles.admin must be preserved (not reset to false)');
|
|
474
|
+
|
|
475
|
+
// (b) Custom non-schema field must survive the merge.
|
|
476
|
+
assert.equal(doc?.myCustomIntegration?.slackWebhook, 'https://hooks.slack.com/services/XXX', 'custom non-schema field must survive');
|
|
477
|
+
|
|
478
|
+
// (a) Every attribution leaf must be present (the bug: partial write flattened the map).
|
|
479
|
+
assert.hasProperty(doc, 'attribution.affiliate.code', 'attribution.affiliate.code must exist');
|
|
480
|
+
assert.hasProperty(doc, 'attribution.affiliate.url', 'attribution.affiliate.url must exist (filled)');
|
|
481
|
+
assert.hasProperty(doc, 'attribution.affiliate.page', 'attribution.affiliate.page must exist (filled)');
|
|
482
|
+
assert.hasProperty(doc, 'attribution.affiliate.timestamp', 'attribution.affiliate.timestamp must exist (filled)');
|
|
483
|
+
assert.hasProperty(doc, 'attribution.utm.url', 'attribution.utm.url must exist (filled)');
|
|
484
|
+
assert.hasProperty(doc, 'attribution.utm.page', 'attribution.utm.page must exist (filled)');
|
|
485
|
+
assert.hasProperty(doc, 'attribution.utm.timestamp', 'attribution.utm.timestamp must exist (filled)');
|
|
486
|
+
// filled leaves should be null, not undefined/missing
|
|
487
|
+
assert.equal(doc?.attribution?.affiliate?.url, null, 'unset attribution leaf should be null');
|
|
488
|
+
|
|
489
|
+
// (c) Signup data applied on top.
|
|
490
|
+
assert.equal(doc?.attribution?.affiliate?.code, 'PARTIALONLY', 'pre-existing affiliate.code preserved (signup did not send one)');
|
|
491
|
+
assert.equal(doc?.attribution?.utm?.tags?.utm_source, 'newsletter', 'signup utm tag applied');
|
|
492
|
+
assert.equal(doc?.flags?.signupProcessed, true, 'flags.signupProcessed set true');
|
|
493
|
+
assert.equal(doc?.consent?.legal?.status, 'granted', 'consent applied');
|
|
494
|
+
},
|
|
495
|
+
},
|
|
496
|
+
{
|
|
497
|
+
name: 'merge-fills-all-leaves-on-schema-complete-doc',
|
|
498
|
+
async run({ http, firestore, assert, accounts }) {
|
|
499
|
+
// Sanity: after signup, the doc must contain the full set of top-level schema sections,
|
|
500
|
+
// so a subsequent migration finds NOTHING to backfill. Reuses the signup-merge account
|
|
501
|
+
// (already processed above → re-fire is rejected, but the doc from the prior test is the
|
|
502
|
+
// artifact we assert against; this test just validates that doc's completeness).
|
|
503
|
+
const uid = accounts['signup-merge'].uid;
|
|
504
|
+
const doc = await firestore.get(`users/${uid}`);
|
|
505
|
+
|
|
506
|
+
for (const section of ['auth', 'roles', 'flags', 'affiliate', 'metadata', 'activity', 'api', 'personal', 'attribution', 'consent', 'subscription']) {
|
|
507
|
+
assert.hasProperty(doc, section, `doc must have top-level '${section}' section after signup`);
|
|
508
|
+
}
|
|
509
|
+
// Nested completeness spot-checks across the sections signup writes.
|
|
510
|
+
assert.hasProperty(doc, 'activity.geolocation.ip', 'activity.geolocation.ip must exist');
|
|
511
|
+
assert.hasProperty(doc, 'activity.client.userAgent', 'activity.client.userAgent must exist');
|
|
512
|
+
assert.hasProperty(doc, 'personal.name.first', 'personal.name.first must exist');
|
|
513
|
+
assert.hasProperty(doc, 'consent.marketing.revokedAt.source', 'consent.marketing.revokedAt.source must exist');
|
|
514
|
+
assert.hasProperty(doc, 'metadata.created.timestampUNIX', 'metadata.created.timestampUNIX must exist');
|
|
515
|
+
},
|
|
516
|
+
},
|
|
517
|
+
|
|
350
518
|
// --- Auth rejection test (at end per convention) ---
|
|
351
519
|
{
|
|
352
520
|
name: 'unauthenticated-rejected',
|