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.
@@ -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 (real claim) ---
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
- appendClaimed(claimedFile, sources.map((s) => s.id));
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 is a MIRROR of the production cron no escape hatches.
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 (claims them atomically for the brand)
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: { category, limit: 3, claimFor: brandId, backendManagerKey: key },
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',