backend-manager 5.2.19 → 5.3.0

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.
Files changed (56) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/CLAUDE.md +5 -4
  3. package/docs/admin-post-route.md +2 -0
  4. package/docs/ai-library.md +29 -2
  5. package/docs/common-mistakes.md +1 -1
  6. package/docs/environment-detection.md +87 -3
  7. package/docs/marketing-campaigns.md +1 -1
  8. package/docs/payment-system.md +1 -1
  9. package/docs/testing.md +60 -15
  10. package/package.json +1 -1
  11. package/src/cli/commands/install.js +2 -2
  12. package/src/cli/commands/setup.js +12 -4
  13. package/src/cli/commands/test.js +1 -1
  14. package/src/cli/index.js +6 -2
  15. package/src/defaults/CLAUDE.md +2 -2
  16. package/src/defaults/test/_init.js +14 -0
  17. package/src/manager/functions/_legacy/actions/create-post-handler.js +1 -1
  18. package/src/manager/functions/core/actions/api/admin/edit-post.js +1 -1
  19. package/src/manager/functions/core/actions/api/general/send-email.js +1 -1
  20. package/src/manager/functions/core/actions/api/user/delete.js +1 -1
  21. package/src/manager/functions/core/actions/api/user/oauth2.js +1 -1
  22. package/src/manager/helpers/analytics.js +3 -1
  23. package/src/manager/helpers/assistant.js +43 -38
  24. package/src/manager/index.js +76 -12
  25. package/src/manager/libraries/ai/index.js +33 -0
  26. package/src/manager/libraries/ai/providers/openai.js +105 -8
  27. package/src/manager/libraries/content/ghostii.js +76 -16
  28. package/src/manager/libraries/email/data/disposable-domains.json +23 -0
  29. package/src/manager/libraries/email/generators/lib/image-illustrator.js +154 -0
  30. package/src/manager/libraries/email/generators/newsletter.js +16 -2
  31. package/src/manager/libraries/payment/discount-codes.js +1 -0
  32. package/src/manager/routes/admin/post/put.js +1 -1
  33. package/src/manager/routes/handler/post/post.js +1 -1
  34. package/src/manager/routes/payments/intent/processors/test.js +1 -1
  35. package/src/manager/routes/user/delete.js +1 -1
  36. package/src/manager/routes/user/signup/post.js +4 -2
  37. package/src/test/runner.js +142 -20
  38. package/src/test/test-accounts.js +159 -102
  39. package/test/events/payments/journey-payments-cancel-endpoint.js +6 -2
  40. package/test/events/payments/journey-payments-cancel.js +6 -3
  41. package/test/events/payments/journey-payments-failure.js +6 -3
  42. package/test/events/payments/journey-payments-plan-change.js +6 -3
  43. package/test/events/payments/journey-payments-refund-webhook.js +6 -2
  44. package/test/events/payments/journey-payments-suspend.js +6 -3
  45. package/test/events/payments/journey-payments-trial-cancel.js +6 -3
  46. package/test/events/payments/journey-payments-trial.js +10 -4
  47. package/test/events/payments/journey-payments-uid-resolution.js +6 -2
  48. package/test/events/payments/journey-payments-upgrade.js +6 -3
  49. package/test/helpers/content/ghostii-blocks.js +134 -0
  50. package/test/helpers/environment.js +230 -0
  51. package/test/routes/marketing/webhook-forward.js +14 -7
  52. package/test/routes/payments/cancel.js +4 -1
  53. package/test/routes/payments/dispute-alert.js +9 -6
  54. package/test/routes/payments/intent.js +55 -14
  55. package/test/routes/payments/portal.js +4 -1
  56. package/test/routes/payments/refund.js +4 -1
@@ -602,6 +602,7 @@
602
602
  "beaconmessenger.com",
603
603
  "bearsarefuzzy.com",
604
604
  "beastmail.space",
605
+ "beatbok.online",
605
606
  "beddly.com",
606
607
  "beefmilk.com",
607
608
  "beelsil.com",
@@ -1833,6 +1834,7 @@
1833
1834
  "freeteenbums.com",
1834
1835
  "freetimer.online",
1835
1836
  "freundin.ru",
1837
+ "friday12.store",
1836
1838
  "friendlymail.co.uk",
1837
1839
  "frm.ovh",
1838
1840
  "front14.org",
@@ -2058,6 +2060,7 @@
2058
2060
  "gowikitv.com",
2059
2061
  "grandmamail.com",
2060
2062
  "grandmasmail.com",
2063
+ "graphicbulk.site",
2061
2064
  "graphicenda.site",
2062
2065
  "grassdev.com",
2063
2066
  "gravityengine.cc",
@@ -2128,6 +2131,7 @@
2128
2131
  "haltospam.com",
2129
2132
  "hamham.uk",
2130
2133
  "handrik.com",
2134
+ "hanghai232.shop",
2131
2135
  "hangxomcuatoilatotoro.ml",
2132
2136
  "happiseektest.com",
2133
2137
  "happy-new-year.top",
@@ -2238,6 +2242,7 @@
2238
2242
  "housereformas.es",
2239
2243
  "hpari.com",
2240
2244
  "hpc.tw",
2245
+ "hqpdf.com",
2241
2246
  "hs.vc",
2242
2247
  "hsmw.net",
2243
2248
  "ht.cx",
@@ -2257,6 +2262,7 @@
2257
2262
  "hushmail.cf",
2258
2263
  "huskion.net",
2259
2264
  "hutudns.com",
2265
+ "huytuyen.click",
2260
2266
  "hvastudiesucces.nl",
2261
2267
  "hwsye.net",
2262
2268
  "hxopi.ru",
@@ -2489,6 +2495,7 @@
2489
2495
  "jdmadventures.com",
2490
2496
  "jdz.ro",
2491
2497
  "je-recycle.info",
2498
+ "jejes.de",
2492
2499
  "jellow.ml",
2493
2500
  "jellyrolls.com",
2494
2501
  "jeoce.com",
@@ -2714,6 +2721,7 @@
2714
2721
  "laste.ml",
2715
2722
  "lastmail.co",
2716
2723
  "lastmail.com",
2724
+ "laulund.info",
2717
2725
  "launders.money",
2718
2726
  "lawlita.com",
2719
2727
  "laxex.ru",
@@ -3163,6 +3171,7 @@
3163
3171
  "midlertidig.com",
3164
3172
  "midlertidig.net",
3165
3173
  "midlertidig.org",
3174
+ "midwestpride.org",
3166
3175
  "mierdamail.com",
3167
3176
  "migmail.net",
3168
3177
  "migmail.pl",
@@ -3376,6 +3385,7 @@
3376
3385
  "nanonym.ch",
3377
3386
  "nanopools.info",
3378
3387
  "naobk.com",
3388
+ "narrowment.com",
3379
3389
  "narsub.online",
3380
3390
  "narsub.shop",
3381
3391
  "naslazhdai.ru",
@@ -3542,6 +3552,7 @@
3542
3552
  "nutpa.net",
3543
3553
  "nuts2trade.com",
3544
3554
  "nvhrw.com",
3555
+ "nvqt304.xyz",
3545
3556
  "nwldx.com",
3546
3557
  "nwytg.com",
3547
3558
  "nwytg.net",
@@ -3584,6 +3595,7 @@
3584
3595
  "oku.ovh",
3585
3596
  "oky.ovh",
3586
3597
  "okzk.com",
3598
+ "oldao.com",
3587
3599
  "olimp-case.ru",
3588
3600
  "oliviadiffuser.store",
3589
3601
  "oloh.ru",
@@ -3594,6 +3606,7 @@
3594
3606
  "omarnasrrr.com",
3595
3607
  "omfg.run",
3596
3608
  "omnievents.org",
3609
+ "omnilog.space",
3597
3610
  "omtecha.com",
3598
3611
  "onbap.com",
3599
3612
  "one-mail.top",
@@ -3822,6 +3835,7 @@
3822
3835
  "powerscrews.com",
3823
3836
  "powlearn.com",
3824
3837
  "pox2.com",
3838
+ "pozima.rest",
3825
3839
  "pp7rvv.com",
3826
3840
  "ppetw.com",
3827
3841
  "pptrvv.com",
@@ -4060,6 +4074,7 @@
4060
4074
  "royaldoodles.org",
4061
4075
  "royalmarket.life",
4062
4076
  "royandk.com",
4077
+ "roybl.com",
4063
4078
  "rppkn.com",
4064
4079
  "rr.nu",
4065
4080
  "rsvhr.com",
@@ -4069,6 +4084,7 @@
4069
4084
  "rudymail.ml",
4070
4085
  "rulersonline.com",
4071
4086
  "rumgel.com",
4087
+ "run.place",
4072
4088
  "runi.ca",
4073
4089
  "rupayamail.com",
4074
4090
  "ruru.be",
@@ -4097,6 +4113,7 @@
4097
4113
  "sahildash.dev",
4098
4114
  "sailmail.io",
4099
4115
  "sajutadollars.com",
4116
+ "salakoais.shop",
4100
4117
  "saleis.live",
4101
4118
  "salmeow.tk",
4102
4119
  "sam1.eu.org",
@@ -4419,6 +4436,7 @@
4419
4436
  "spymail.one",
4420
4437
  "squizzy.de",
4421
4438
  "squizzy.net",
4439
+ "srame.com",
4422
4440
  "sroff.com",
4423
4441
  "sry.li",
4424
4442
  "ssi-bsn.infos.st",
@@ -4816,6 +4834,7 @@
4816
4834
  "trackden.com",
4817
4835
  "tradermail.info",
4818
4836
  "tranceversal.com",
4837
+ "trandigabapoe.shop",
4819
4838
  "trangiabao.click",
4820
4839
  "trangzim.uk",
4821
4840
  "translateid.com",
@@ -5287,6 +5306,7 @@
5287
5306
  "wyoxafp.com",
5288
5307
  "wywnxa.com",
5289
5308
  "wzofit.com",
5309
+ "x-clone.asia",
5290
5310
  "x-lab.net",
5291
5311
  "x0q.net",
5292
5312
  "x1ix.com",
@@ -5295,6 +5315,8 @@
5295
5315
  "xagloo.com",
5296
5316
  "xapimail.top",
5297
5317
  "xbaby69.top",
5318
+ "xbrok.com",
5319
+ "xclone.asia",
5298
5320
  "xcode.ro",
5299
5321
  "xcodes.net",
5300
5322
  "xcompress.com",
@@ -5365,6 +5387,7 @@
5365
5387
  "yeezus.ru",
5366
5388
  "yep.it",
5367
5389
  "yermail.net",
5390
+ "yeummo.io.vn",
5368
5391
  "yhg.biz",
5369
5392
  "ynmrealty.com",
5370
5393
  "yodx.ro",
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Image illustrator — AI generates a flat-vector PNG illustration per section
3
+ * directly via OpenAI's image model (gpt-image-2), no SVG-author-then-rasterize step.
4
+ *
5
+ * This is the DEFAULT newsletter illustration method. It replaces the older
6
+ * svg-illustrator.js approach (AI writes an <svg>, resvg rasterizes it) which is
7
+ * still available as a fallback via newsletterConfig.method.image = 'svg'.
8
+ *
9
+ * Output matches svg-illustrator's contract: { png: Buffer, fallback, meta }.
10
+ * The newsletter pipeline (image-host.js) only requires `img.png` to be a PNG Buffer.
11
+ *
12
+ * Style: clean flat 2D vector illustration (Stripe / Linear / undraw.co aesthetic),
13
+ * built from the brand palette, on a white background, no text. The prompt mirrors
14
+ * the validated spike in scripts/.temp/ai-image-test/.
15
+ */
16
+ const DEFAULT_MODEL = 'gpt-image-2';
17
+ const DEFAULT_SIZE = '1024x1024'; // square reads best for flat section art
18
+ const DEFAULT_QUALITY = 'medium'; // medium is plenty for flat vector; ~40-50s/image
19
+
20
+ /**
21
+ * Generate one illustration for a section.
22
+ *
23
+ * @param {object} args
24
+ * @param {string} args.imagePrompt - Visual description from the structure
25
+ * @param {object} args.brand - { name, tagline, color: { primary, secondary, ... } }
26
+ * @param {object} args.newsletterConfig - marketing.beehiiv.content
27
+ * @param {object} args.ai - Manager.AI() instance
28
+ * @param {object} args.assistant - BEM assistant
29
+ * @returns {Promise<{png: Buffer, fallback: boolean, meta: object}>}
30
+ */
31
+ async function generateSectionImage({ imagePrompt, brand, newsletterConfig, ai, assistant }) {
32
+ const startTime = Date.now();
33
+ const model = newsletterConfig?.model?.image || DEFAULT_MODEL;
34
+ const size = newsletterConfig?.image?.size || DEFAULT_SIZE;
35
+ const quality = newsletterConfig?.image?.quality || DEFAULT_QUALITY;
36
+
37
+ const palette = resolvePalette(brand, newsletterConfig);
38
+ const subject = imagePrompt || 'An abstract geometric shape representing the topic.';
39
+ const prompt = buildImagePrompt({ brand, palette, subject });
40
+
41
+ let png = null;
42
+ let fallback = false;
43
+ let revisedPrompt = null;
44
+ let attempts = 0;
45
+
46
+ for (let attempt = 0; attempt < 2; attempt++) {
47
+ attempts++;
48
+ try {
49
+ const result = await ai.image({
50
+ provider: 'openai',
51
+ model,
52
+ prompt,
53
+ size,
54
+ quality,
55
+ background: 'opaque',
56
+ });
57
+
58
+ if (result?.png || result?.buffer) {
59
+ png = result.png || result.buffer;
60
+ revisedPrompt = result.revisedPrompt || null;
61
+ break;
62
+ }
63
+
64
+ assistant.log(`Image generation attempt ${attempt + 1} returned no buffer`);
65
+ } catch (e) {
66
+ assistant.error(`Image generation attempt ${attempt + 1} failed: ${e.message}`);
67
+ }
68
+ }
69
+
70
+ // No transparent-PNG placeholder fallback that's valid as an image — if both
71
+ // attempts fail, return a 1x1 transparent PNG so the pipeline doesn't crash on
72
+ // a non-Buffer. The caller logs and proceeds.
73
+ if (!png) {
74
+ png = TRANSPARENT_PNG;
75
+ fallback = true;
76
+ }
77
+
78
+ return {
79
+ png,
80
+ fallback,
81
+ meta: {
82
+ method: 'image',
83
+ provider: 'openai',
84
+ model,
85
+ size,
86
+ quality,
87
+ durationMs: Date.now() - startTime,
88
+ attempts,
89
+ fallback,
90
+ revisedPrompt,
91
+ },
92
+ };
93
+ }
94
+
95
+ /**
96
+ * Resolve brand palette — same precedence as svg-illustrator.js#resolvePalette:
97
+ * newsletter theme colors → brand.color.* → sensible defaults.
98
+ */
99
+ function resolvePalette(brand, newsletterConfig) {
100
+ const theme = newsletterConfig?.theme || {};
101
+
102
+ return {
103
+ primary: theme.primaryColor || brand?.color?.primary || '#4B5BFF',
104
+ secondary: theme.secondaryColor || brand?.color?.secondary || '#1E1E2A',
105
+ accent: theme.accentColor || brand?.color?.accent || '#F4F6FA',
106
+ };
107
+ }
108
+
109
+ /**
110
+ * Build the flat-vector art-direction prompt with brand identity + palette baked in.
111
+ * Mirrors the validated prompt from scripts/.temp/ai-image-test/generate.js.
112
+ */
113
+ function buildImagePrompt({ brand, palette, subject }) {
114
+ const { primary, secondary, accent } = palette;
115
+ const name = brand?.name || 'the brand';
116
+ const tagline = brand?.tagline ? ` — ${brand.tagline}` : '';
117
+
118
+ return [
119
+ `Minimal flat vector illustration for the newsletter of "${name}"${tagline}.`,
120
+ '',
121
+ 'STYLE: modern flat 2D vector illustration, like the marketing graphics on',
122
+ 'Stripe, Linear, Vercel, Notion, or undraw.co. Clean geometric shapes with',
123
+ 'flat solid color fills and crisp edges. Simple, friendly, and minimal.',
124
+ '',
125
+ 'ABSOLUTELY NOT: no photorealism, no 3D renders, no realistic textures, no',
126
+ 'photography, no depth of field, no dramatic lighting, no film grain, no',
127
+ 'shading or gradients beyond a single subtle flat tone shift, no glossy',
128
+ 'reflections, no metallic surfaces. This must look hand-designed in a vector',
129
+ 'tool (Figma/Illustrator), NOT rendered or photographed.',
130
+ '',
131
+ 'PALETTE (use ONLY these flat colors, plus white):',
132
+ `- Primary: ${primary}`,
133
+ `- Secondary: ${secondary}`,
134
+ `- Accent: ${accent}`,
135
+ 'Flat solid fills only. A clean white or very-light background.',
136
+ '',
137
+ 'COMPOSITION: single clear simple subject, lots of clean negative space,',
138
+ 'centered or rule-of-thirds, uncluttered, plenty of breathing room.',
139
+ '',
140
+ 'HARD CONSTRAINTS: absolutely NO text, NO words, NO letters, NO numbers,',
141
+ 'NO logos, NO watermarks, NO UI mockups, NO borders or frames.',
142
+ '',
143
+ `SUBJECT (the focal scene to illustrate): ${subject}`,
144
+ ].join('\n');
145
+ }
146
+
147
+ // 1x1 transparent PNG — last-resort fallback so a failed generation still yields a
148
+ // valid PNG Buffer instead of crashing image-host.js's Buffer check.
149
+ const TRANSPARENT_PNG = Buffer.from(
150
+ 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
151
+ 'base64'
152
+ );
153
+
154
+ module.exports = { generateSectionImage };
@@ -9,7 +9,9 @@
9
9
  * 1. Read newsletter categories from Manager.config.marketing.beehiiv.content.categories
10
10
  * 2. Fetch ready sources from parent server (atomic claim via claimFor=brandId)
11
11
  * 3. structure.js → AI authors subject, preheader, intro, sections, signoff
12
- * 4. svg-illustrator.js → AI authors one SVG per section, rasterize to PNG
12
+ * 4. image-illustrator.js → AI generates one flat-vector PNG per section via
13
+ * gpt-image-2 (default). Set content.method.image = 'svg' to use the legacy
14
+ * svg-illustrator.js (AI writes an <svg>, resvg rasterizes it) instead.
13
15
  * 5. mjml-template.js → compile MJML → email-safe HTML
14
16
  * 6. Persist PNGs (caller-provided via opts.persistImage or default no-op)
15
17
  * 7. Mark sources as used on parent server
@@ -22,8 +24,19 @@ const fetch = require('wonderful-fetch');
22
24
 
23
25
  const { filterSources } = require('./lib/filter.js');
24
26
  const { generateStructure } = require('./lib/structure.js');
25
- const { generateSectionImage } = require('./lib/svg-illustrator.js');
27
+ const { generateSectionImage: generateImageSection } = require('./lib/image-illustrator.js');
28
+ const { generateSectionImage: generateSvgSection } = require('./lib/svg-illustrator.js');
26
29
  const { renderNewsletter } = require('./lib/mjml-template.js');
30
+
31
+ // Default illustration method. 'image' = gpt-image-2 flat-vector PNGs (default).
32
+ // 'svg' = legacy AI-authored SVG rasterized via resvg. Selected per-brand via
33
+ // marketing.beehiiv.content.method.image.
34
+ const DEFAULT_IMAGE_METHOD = 'image';
35
+
36
+ function resolveSectionImageFn(newsletterConfig) {
37
+ const method = newsletterConfig?.method?.image || DEFAULT_IMAGE_METHOD;
38
+ return method === 'svg' ? generateSvgSection : generateImageSection;
39
+ }
27
40
  const { renderMarkdown } = require('./lib/markdown-renderer.js');
28
41
  const { uploadAssets, RAW_BASE } = require('./lib/image-host.js');
29
42
  const { buildPublicConfig } = require('../../../routes/brand/get.js');
@@ -165,6 +178,7 @@ async function generate(Manager, assistant, settings, opts = {}) {
165
178
  return;
166
179
  }
167
180
 
181
+ const generateSectionImage = resolveSectionImageFn(config);
168
182
  const images = await Promise.all(
169
183
  structure.sections.map((s) => generateSectionImage({
170
184
  imagePrompt: s.image_prompt,
@@ -15,6 +15,7 @@ const DISCOUNT_CODES = {
15
15
  'WELCOME15': { percent: 15, duration: 'once' },
16
16
  'SAVE10': { percent: 10, duration: 'once' },
17
17
  'FLASH20': { percent: 20, duration: 'once' },
18
+ 'GIFT15': { percent: 15, duration: 'once' },
18
19
 
19
20
  // Email campaigns (used by recurring sale seeds — restricted by audience)
20
21
  'UPGRADE15': {
@@ -91,7 +91,7 @@ async function fetchPost(assistant, url) {
91
91
  const fetch = Manager.require('wonderful-fetch');
92
92
 
93
93
  // Use NEW API format
94
- const result = await fetch(`${Manager.project.apiUrl}/backend-manager/content/post`, {
94
+ const result = await fetch(`${Manager.getApiUrl()}/backend-manager/content/post`, {
95
95
  method: 'get',
96
96
  response: 'json',
97
97
  timeout: 190000,
@@ -100,7 +100,7 @@ module.exports = async ({ assistant, Manager, user, settings, analytics }) => {
100
100
  // Send notification (unless explicitly disabled)
101
101
  if (settings.sendNotification !== false) {
102
102
  // Use NEW API format
103
- await fetch(`${Manager.project.apiUrl}/backend-manager/admin/notification`, {
103
+ await fetch(`${Manager.getApiUrl()}/backend-manager/admin/notification`, {
104
104
  method: 'POST',
105
105
  response: 'json',
106
106
  headers: {
@@ -155,7 +155,7 @@ async function createOneTimeIntent({ uid, orderId, product, productId, confirmat
155
155
  * Fire-and-forget webhook to trigger the full pipeline
156
156
  */
157
157
  function fireWebhook({ event, assistant }) {
158
- const webhookUrl = `${assistant.Manager.project.apiUrl}/backend-manager/payments/webhook?processor=test&key=${process.env.BACKEND_MANAGER_WEBHOOK_KEY}`;
158
+ const webhookUrl = `${assistant.Manager.getApiUrl()}/backend-manager/payments/webhook?processor=test&key=${process.env.BACKEND_MANAGER_WEBHOOK_KEY}`;
159
159
  fetch(webhookUrl, {
160
160
  method: 'POST',
161
161
  response: 'json',
@@ -46,7 +46,7 @@ module.exports = async ({ assistant, Manager, user, settings, libraries }) => {
46
46
  // Sign out of all sessions first
47
47
  assistant.log(`Signing out of all sessions for ${uid}...`);
48
48
 
49
- await fetch(`${Manager.project.apiUrl}/backend-manager/user/sessions`, {
49
+ await fetch(`${Manager.getApiUrl()}/backend-manager/user/sessions`, {
50
50
  method: 'delete',
51
51
  timeout: 60000,
52
52
  response: 'json',
@@ -478,9 +478,11 @@ function sendDiscountNudgeEmail(assistant, uid, firstName) {
478
478
 
479
479
  It's Ian, the founder of **${Manager.config.brand.name}**.
480
480
 
481
- As a thank-you for giving us a try, I'd love to send you a code for a **premium upgrade**. **Just reply to this email** and I'll get one over to you.
481
+ As a thank-you for giving us a try, I'd love to send you a code for a **premium upgrade**.
482
482
 
483
- I read every reply, so if you have any questions, feedback, or there's anything I can help with, this is the place. Looking forward to hearing from you!`,
483
+ **Just reply to this email** and I'll get one over to you.
484
+
485
+ I read every reply and I'm looking forward to hearing from you!`,
484
486
  },
485
487
  signoff: {
486
488
  type: 'personal',
@@ -186,7 +186,7 @@ class TestRunner {
186
186
  // the health endpoint re-reads the file as a freshness guard. By
187
187
  // construction these are equal — no mismatch warning needed.
188
188
  const emulatorExtended = !!response.data?.testExtendedMode;
189
- console.log(chalk.gray(` Mode: ${emulatorExtended ? 'EXTENDED (real APIs)' : 'normal (mocked)'}`));
189
+ console.log(chalk.gray(` Mode: ${emulatorExtended ? 'extended (real external APIs)' : 'normal (external APIs skipped)'}`));
190
190
 
191
191
  return true;
192
192
  }
@@ -206,23 +206,32 @@ class TestRunner {
206
206
  * Setup test accounts - deletes existing test users and recreates them fresh
207
207
  */
208
208
  async setupAccounts() {
209
- // Ensure meta/stats doc exists (required for on-create batch writes)
209
+ // Load the optional test/_init.js hooks from BOTH test roots (BEM core +
210
+ // consumer project): extra `accounts` to create and `setup()` to seed fixtures.
211
+ const initHooks = this.loadInitHooks();
212
+
213
+ // Flush the entire emulator Firestore + delete Auth test users (clean slate).
214
+ process.stdout.write(chalk.gray(' Wiping emulator + deleting test users... '));
215
+ const deleteResult = await testAccounts.deleteTestUsers(this.options.admin, initHooks.accounts);
216
+ console.log(chalk.green(`✓ (${deleteResult.deleted} deleted, ${deleteResult.skipped} skipped)`));
217
+
218
+ // Ensure meta/stats doc exists (required for on-create batch writes + the
219
+ // admin stats route). MUST run AFTER the wipe above — the flush recursively
220
+ // deletes every collection (including meta/stats), so seeding before it would
221
+ // be clobbered. Seeding here guarantees a `users` field survives even after
222
+ // the notification on-write trigger merges in its `notifications` counter.
210
223
  process.stdout.write(chalk.gray(' Ensuring meta/stats doc exists... '));
211
224
  await this.ensureMetaStats();
212
225
  console.log(chalk.green('✓'));
213
226
 
214
- // Delete existing test user documents to ensure clean state
215
- process.stdout.write(chalk.gray(' Deleting existing test users... '));
216
- const deleteResult = await testAccounts.deleteTestUsers(this.options.admin);
217
- console.log(chalk.green(`✓ (${deleteResult.deleted} deleted, ${deleteResult.skipped} skipped)`));
218
-
219
227
  process.stdout.write(chalk.gray(' Creating test accounts... '));
220
228
 
221
- // Create fresh test accounts
229
+ // Create fresh test accounts (built-in + any project-defined via _init.js).
222
230
  const result = await testAccounts.createTestAccounts(
223
231
  this.options.admin,
224
232
  this.options.domain,
225
- this.config
233
+ this.config,
234
+ initHooks.accounts
226
235
  );
227
236
 
228
237
  if (!result.success) {
@@ -232,8 +241,28 @@ class TestRunner {
232
241
 
233
242
  console.log(chalk.green(`✓ (${result.created} created)`));
234
243
 
235
- // Fetch account privateKeys
236
- this.accounts = await testAccounts.fetchPrivateKeys(this.options.admin, this.options.domain, this.config);
244
+ // Fetch account privateKeys (built-in + project-defined).
245
+ this.accounts = await testAccounts.fetchPrivateKeys(this.options.admin, this.options.domain, this.config, initHooks.accounts);
246
+
247
+ // Run custom setup hooks (BEM core first, then consumer). Runs AFTER the
248
+ // standard test accounts exist and AFTER the clean slate, so they can seed
249
+ // fixtures (brands, etc.) and reference the created accounts.
250
+ for (const setup of initHooks.setups) {
251
+ process.stdout.write(chalk.gray(' Running test/_init.js setup... '));
252
+ try {
253
+ await setup({
254
+ admin: this.options.admin,
255
+ config: this.config,
256
+ accounts: this.accounts,
257
+ Manager: this.config.Manager,
258
+ assistant: this.config.assistant,
259
+ });
260
+ console.log(chalk.green('✓'));
261
+ } catch (e) {
262
+ console.log(chalk.red(`✗ (${e.message})`));
263
+ return false;
264
+ }
265
+ }
237
266
 
238
267
  // Initialize rules testing context for security rules tests
239
268
  process.stdout.write(chalk.gray(' Initializing rules testing context... '));
@@ -253,23 +282,30 @@ class TestRunner {
253
282
  }
254
283
 
255
284
  /**
256
- * Ensure meta/stats document exists (required for user count increments)
257
- * Creates with initial values if missing, does not overwrite existing
285
+ * Ensure meta/stats document has a baseline `users` counter (required for user
286
+ * count increments and the admin stats route).
287
+ *
288
+ * Uses a MERGE write that always runs — it does NOT early-return when the doc
289
+ * already exists. The reason: the preceding emulator wipe recursively deletes
290
+ * the `notifications` collection, which fires the notification on-write *delete*
291
+ * trigger for each doc. Those triggers merge `{ notifications: increment(-1) }`
292
+ * into meta/stats and can race ahead of this seed, re-creating the doc as
293
+ * `{ notifications: { total: -N } }` with NO `users` field. A plain
294
+ * "create-if-missing" seed would then skip (doc.exists === true) and leave
295
+ * `users` absent — exactly the bug that made the admin stats tests flaky.
296
+ *
297
+ * Merging `{ users: { total: 0 } }` is safe to run unconditionally: it seeds the
298
+ * baseline without clobbering whatever `notifications` value those triggers land,
299
+ * and the real user-count increments overwrite total: 0 as accounts are created.
258
300
  */
259
301
  async ensureMetaStats() {
260
302
  const admin = this.options.admin;
261
303
  const statsRef = admin.firestore().doc('meta/stats');
262
304
 
263
- const doc = await statsRef.get();
264
- if (doc.exists) {
265
- return; // Already exists, don't overwrite
266
- }
267
-
268
- // Create initial stats document
269
305
  await statsRef.set({
270
306
  users: { total: 0 },
271
307
  brand: this.options.brand?.id,
272
- });
308
+ }, { merge: true });
273
309
  }
274
310
 
275
311
  /**
@@ -284,6 +320,87 @@ class TestRunner {
284
320
  }
285
321
  }
286
322
 
323
+ /**
324
+ * Load a single `test/_init.js` lifecycle hook from a test root.
325
+ *
326
+ * The module MUST export a function — `module.exports = (ctx) => ({ ... })` —
327
+ * called with `{ config, Manager }` and returning the hook object
328
+ * (`{ accounts, setup }`). This lets a project compute its accounts/fixtures
329
+ * from config at load time.
330
+ *
331
+ * Returns `{}` if the file doesn't exist, isn't a function, or fails to resolve.
332
+ */
333
+ loadInit(testDir, label) {
334
+ const initPath = path.join(testDir, '_init.js');
335
+
336
+ if (!jetpack.exists(initPath)) {
337
+ return {};
338
+ }
339
+
340
+ try {
341
+ const fn = require(initPath);
342
+
343
+ if (typeof fn !== 'function') {
344
+ console.log(chalk.red(` ✗ ${label} test/_init.js must export a function: module.exports = (ctx) => ({ ... })`));
345
+ return {};
346
+ }
347
+
348
+ const mod = fn({ config: this.config, Manager: this.config?.Manager });
349
+ return mod && typeof mod === 'object' ? mod : {};
350
+ } catch (e) {
351
+ console.log(chalk.red(` ✗ Failed to load ${label} test/_init.js: ${e.message}`));
352
+ return {};
353
+ }
354
+ }
355
+
356
+ /**
357
+ * Load and merge the `test/_init.js` lifecycle hooks from BOTH test roots —
358
+ * BEM core (`<bem>/test/_init.js`) and the consumer project
359
+ * (`<projectDir>/test/_init.js`). Same contract for both, so framework and
360
+ * consumer authors write the identical file shape. Each exports a function
361
+ * (see loadInit) returning:
362
+ * - `accounts` — extra test accounts to create alongside the built-in ones,
363
+ * each `{ id, uid, email, properties }` (email may use the `{domain}`
364
+ * placeholder). One per lifecycle this project needs to exercise.
365
+ * - `async setup({ admin, config, accounts, Manager, assistant })` — seed
366
+ * fixtures (brands, etc.) AFTER the clean slate + account creation.
367
+ *
368
+ * There is no `cleanup` hook: the entire emulator Firestore is flushed before
369
+ * every run (deleteTestUsers → flushEmulatorFirestore) and each test cleans up
370
+ * after itself, so there is nothing project-level to tear down.
371
+ *
372
+ * Returns the merged extra `accounts` map (BEM core then consumer; consumer
373
+ * wins on key collision) and the ordered `setups` runners (BEM core first).
374
+ */
375
+ loadInitHooks() {
376
+ const bemTestsDir = path.resolve(__dirname, '../../test');
377
+ const projectTestsDir = path.join(this.options.projectDir, 'test');
378
+
379
+ const hooks = [
380
+ this.loadInit(bemTestsDir, 'BEM core'),
381
+ this.loadInit(projectTestsDir, 'project'),
382
+ ];
383
+
384
+ // Merge extra accounts from both roots. Accept either an array of account
385
+ // defs or a keyed object; normalize to a keyed object on `id`.
386
+ const accounts = {};
387
+ for (const h of hooks) {
388
+ const list = Array.isArray(h.accounts)
389
+ ? h.accounts
390
+ : Object.values(h.accounts || {});
391
+ for (const account of list) {
392
+ if (account && account.id) {
393
+ accounts[account.id] = account;
394
+ }
395
+ }
396
+ }
397
+
398
+ return {
399
+ accounts,
400
+ setups: hooks.filter((h) => typeof h.setup === 'function').map((h) => h.setup),
401
+ };
402
+ }
403
+
287
404
  /**
288
405
  * Discover test files in directory
289
406
  */
@@ -297,6 +414,11 @@ class TestRunner {
297
414
  continue;
298
415
  }
299
416
 
417
+ // Skip the _init.js lifecycle hook — it's run by setupAccounts(), not as a test.
418
+ if (item === '_init.js') {
419
+ continue;
420
+ }
421
+
300
422
  // Skip legacy 'functions' directory unless --legacy flag is set
301
423
  if (item === 'functions' && !this.options.includeLegacy) {
302
424
  continue;