backend-manager 5.2.18 → 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 (65) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/CLAUDE.md +6 -4
  3. package/bin/backend-manager +10 -1
  4. package/docs/admin-post-route.md +2 -0
  5. package/docs/ai-library.md +29 -2
  6. package/docs/cli-output.md +122 -0
  7. package/docs/common-mistakes.md +1 -1
  8. package/docs/environment-detection.md +87 -3
  9. package/docs/marketing-campaigns.md +1 -1
  10. package/docs/payment-system.md +1 -1
  11. package/docs/testing.md +64 -17
  12. package/package.json +1 -1
  13. package/src/cli/commands/base-command.js +4 -0
  14. package/src/cli/commands/install.js +2 -2
  15. package/src/cli/commands/setup-tests/bem-config.js +22 -9
  16. package/src/cli/commands/setup-tests/helpers/merge-line-files.js +22 -168
  17. package/src/cli/commands/setup.js +65 -33
  18. package/src/cli/commands/test.js +1 -1
  19. package/src/cli/index.js +72 -39
  20. package/src/cli/utils/ui.js +270 -0
  21. package/src/defaults/CLAUDE.md +2 -2
  22. package/src/defaults/test/_init.js +14 -0
  23. package/src/manager/functions/_legacy/actions/create-post-handler.js +1 -1
  24. package/src/manager/functions/core/actions/api/admin/edit-post.js +1 -1
  25. package/src/manager/functions/core/actions/api/general/send-email.js +1 -1
  26. package/src/manager/functions/core/actions/api/user/delete.js +1 -1
  27. package/src/manager/functions/core/actions/api/user/oauth2.js +1 -1
  28. package/src/manager/helpers/analytics.js +3 -1
  29. package/src/manager/helpers/assistant.js +43 -38
  30. package/src/manager/index.js +76 -12
  31. package/src/manager/libraries/ai/index.js +33 -0
  32. package/src/manager/libraries/ai/providers/openai.js +105 -8
  33. package/src/manager/libraries/content/ghostii.js +76 -16
  34. package/src/manager/libraries/email/data/disposable-domains.json +24 -0
  35. package/src/manager/libraries/email/generators/lib/image-illustrator.js +154 -0
  36. package/src/manager/libraries/email/generators/newsletter.js +16 -2
  37. package/src/manager/libraries/payment/discount-codes.js +1 -0
  38. package/src/manager/routes/admin/post/put.js +1 -1
  39. package/src/manager/routes/handler/post/post.js +1 -1
  40. package/src/manager/routes/payments/intent/processors/test.js +1 -1
  41. package/src/manager/routes/user/delete.js +1 -1
  42. package/src/manager/routes/user/signup/post.js +4 -2
  43. package/src/test/runner.js +142 -20
  44. package/src/test/test-accounts.js +159 -102
  45. package/src/utils/merge-line-files.js +16 -5
  46. package/templates/_.env +1 -0
  47. package/test/events/payments/journey-payments-cancel-endpoint.js +6 -2
  48. package/test/events/payments/journey-payments-cancel.js +6 -3
  49. package/test/events/payments/journey-payments-failure.js +6 -3
  50. package/test/events/payments/journey-payments-plan-change.js +6 -3
  51. package/test/events/payments/journey-payments-refund-webhook.js +6 -2
  52. package/test/events/payments/journey-payments-suspend.js +6 -3
  53. package/test/events/payments/journey-payments-trial-cancel.js +6 -3
  54. package/test/events/payments/journey-payments-trial.js +10 -4
  55. package/test/events/payments/journey-payments-uid-resolution.js +6 -2
  56. package/test/events/payments/journey-payments-upgrade.js +6 -3
  57. package/test/helpers/content/ghostii-blocks.js +134 -0
  58. package/test/helpers/environment.js +230 -0
  59. package/test/helpers/merge-line-files.js +271 -0
  60. package/test/routes/marketing/webhook-forward.js +14 -7
  61. package/test/routes/payments/cancel.js +4 -1
  62. package/test/routes/payments/dispute-alert.js +9 -6
  63. package/test/routes/payments/intent.js +55 -14
  64. package/test/routes/payments/portal.js +4 -1
  65. package/test/routes/payments/refund.js +4 -1
@@ -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;
@@ -525,14 +525,19 @@ const TEST_ACCOUNTS = {
525
525
  * Get all test account definitions with resolved emails and dynamic product IDs
526
526
  * @param {string} domain - Domain for email addresses (e.g., 'itwcreativeworks.com')
527
527
  * @param {object} [config] - BEM config (used to resolve first paid product)
528
+ * @param {object} [extraAccounts] - Project-defined accounts from test/_init.js,
529
+ * keyed by id, each `{ id, uid, email, properties }`. Merged after the built-in
530
+ * accounts; a project account may override a built-in one by reusing its key.
528
531
  * @returns {object} Account definitions with resolved emails
529
532
  */
530
- function getAccountDefinitions(domain, config) {
533
+ function getAccountDefinitions(domain, config, extraAccounts) {
531
534
  const paidProduct = getFirstPaidProduct(config);
532
535
  const accounts = {};
533
536
 
534
- for (const [key, account] of Object.entries(TEST_ACCOUNTS)) {
535
- const properties = JSON.parse(JSON.stringify(account.properties));
537
+ const all = { ...TEST_ACCOUNTS, ...(extraAccounts || {}) };
538
+
539
+ for (const [key, account] of Object.entries(all)) {
540
+ const properties = JSON.parse(JSON.stringify(account.properties || {}));
536
541
 
537
542
  // Replace hardcoded 'premium' product with the actual first paid product from config
538
543
  if (properties.subscription?.product?.id === 'premium') {
@@ -543,7 +548,7 @@ function getAccountDefinitions(domain, config) {
543
548
  accounts[key] = {
544
549
  id: account.id,
545
550
  uid: account.uid,
546
- email: account.email.replace('{domain}', domain),
551
+ email: (account.email || '').replace('{domain}', domain),
547
552
  properties,
548
553
  };
549
554
  }
@@ -556,10 +561,11 @@ function getAccountDefinitions(domain, config) {
556
561
  * @param {object} admin - Firebase admin instance
557
562
  * @param {string} domain - Domain for email addresses (e.g., 'itwcreativeworks.com')
558
563
  * @param {object} [config] - BEM config (used to resolve first paid product)
564
+ * @param {object} [extraAccounts] - Project-defined accounts from test/_init.js
559
565
  * @returns {Promise<object>} Account credentials with privateKeys
560
566
  */
561
- async function fetchPrivateKeys(admin, domain, config) {
562
- const definitions = getAccountDefinitions(domain, config);
567
+ async function fetchPrivateKeys(admin, domain, config, extraAccounts) {
568
+ const definitions = getAccountDefinitions(domain, config, extraAccounts);
563
569
  const accounts = {};
564
570
 
565
571
  // Fetch all in parallel
@@ -626,50 +632,165 @@ async function fetchPrivateKeys(admin, domain, config) {
626
632
  async function createAccount(admin, account) {
627
633
  const userRef = admin.firestore().doc(`users/${account.uid}`);
628
634
 
629
- // Create Firebase Auth user - triggers auth:on-create
630
- await admin.auth().createUser({
631
- uid: account.uid,
632
- email: account.email,
633
- password: uuid.v4(),
634
- emailVerified: true,
635
- });
635
+ // The Auth user for this UID was just deleted (deleteTestUsers). Its auth:on-delete
636
+ // trigger deletes the Firestore doc ASYNCHRONOUSLY and the emulator does NOT guarantee
637
+ // it fires (or finishes) before our subsequent createUser()'s auth:on-create. A stale
638
+ // on-delete can therefore land AFTER on-create and silently wipe the freshly-written
639
+ // doc — leaving the account with no api.clientId/privateKey. That intermittent clobber
640
+ // is what made the account-structure validation (and every downstream auth/payment test)
641
+ // flaky. We defend with a verify-and-repair retry: create → wait for the on-create write
642
+ // to be COMPLETE (api keys present, not just metadata.tag) → merge props → re-verify the
643
+ // keys survived. If a late on-delete clobbered the doc, recreate from scratch.
644
+ const maxAttempts = 3;
645
+
646
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
647
+ // Create Firebase Auth user - triggers auth:on-create
648
+ await admin.auth().createUser({
649
+ uid: account.uid,
650
+ email: account.email,
651
+ password: uuid.v4(),
652
+ emailVerified: true,
653
+ }).catch(async (e) => {
654
+ // A retry may find the Auth user already present (its doc was clobbered, not the
655
+ // user). Delete it first so the fresh createUser re-fires a clean on-create.
656
+ if (e.code === 'auth/uid-already-exists') {
657
+ await admin.auth().deleteUser(account.uid).catch(() => {});
658
+ await waitForDocGone(userRef);
659
+ await admin.auth().createUser({
660
+ uid: account.uid,
661
+ email: account.email,
662
+ password: uuid.v4(),
663
+ emailVerified: true,
664
+ });
665
+ } else {
666
+ throw e;
667
+ }
668
+ });
669
+
670
+ // Wait for auth:on-create to COMPLETE. Poll on the api keys themselves — the fields the
671
+ // tests actually require — not just metadata.tag, which on its own doesn't prove the
672
+ // doc wasn't subsequently clobbered.
673
+ const ready = await waitForAccountReady(userRef);
674
+
675
+ // Merge test-specific properties (roles, subscription, etc.)
676
+ await userRef.set(account.properties, { merge: true });
677
+
678
+ // Re-verify after the merge: a late on-delete could have struck between the poll and
679
+ // here. If the api keys survived, the account is good. Otherwise loop and recreate.
680
+ const finalDoc = await userRef.get();
681
+ const data = finalDoc.data() || {};
682
+ if (ready && data.api?.clientId && data.api?.privateKey) {
683
+ return { uid: account.uid, email: account.email };
684
+ }
685
+
686
+ // Clobbered (or never completed). Tear down the Auth user so the next attempt starts
687
+ // from a clean slate, then retry.
688
+ if (attempt < maxAttempts) {
689
+ await admin.auth().deleteUser(account.uid).catch(() => {});
690
+ await waitForDocGone(userRef);
691
+ }
692
+ }
693
+
694
+ // Exhausted retries — return anyway so the runner reports the downstream failure with a
695
+ // meaningful test assertion rather than a setup throw.
696
+ return { uid: account.uid, email: account.email };
697
+ }
636
698
 
637
- // Wait for auth:on-create to COMPLETE
638
- // We check for metadata.tag which is set at the END of on-create
699
+ /**
700
+ * Poll until a user doc reflects a COMPLETE auth:on-create write (api keys present).
701
+ * Returns true if it became ready within the window, false on timeout.
702
+ */
703
+ async function waitForAccountReady(userRef) {
639
704
  const maxWait = 15000;
640
705
  const pollInterval = 500;
641
706
  let waited = 0;
642
707
 
643
708
  while (waited < maxWait) {
644
709
  const doc = await userRef.get();
645
- if (doc.exists && doc.data()?.metadata?.tag === 'auth:on-create') {
646
- break;
710
+ const data = doc.exists ? doc.data() : null;
711
+ if (
712
+ data?.metadata?.tag === 'auth:on-create'
713
+ && data.api?.clientId
714
+ && data.api?.privateKey
715
+ ) {
716
+ return true;
647
717
  }
648
718
  await new Promise(resolve => setTimeout(resolve, pollInterval));
649
719
  waited += pollInterval;
650
720
  }
651
721
 
652
- // Merge test-specific properties (roles, subscription, etc.)
653
- await userRef.set(account.properties, { merge: true });
722
+ return false;
723
+ }
654
724
 
655
- return { uid: account.uid, email: account.email };
725
+ /**
726
+ * Poll until a user doc no longer exists (on-delete settled). Bounded; best-effort.
727
+ */
728
+ async function waitForDocGone(userRef) {
729
+ const maxWait = 10000;
730
+ const pollInterval = 200;
731
+ let waited = 0;
732
+
733
+ while (waited < maxWait) {
734
+ const doc = await userRef.get();
735
+ if (!doc.exists) {
736
+ return;
737
+ }
738
+ await new Promise(resolve => setTimeout(resolve, pollInterval));
739
+ waited += pollInterval;
740
+ }
741
+
742
+ // Fallback: force-delete the lingering doc so the next create starts clean.
743
+ await userRef.delete().catch(() => {});
744
+ }
745
+
746
+ /**
747
+ * Flush the ENTIRE emulator Firestore — every top-level collection, recursively.
748
+ *
749
+ * SAFETY: this is destructive, so it ONLY runs when connected to the Firestore
750
+ * emulator (`FIRESTORE_EMULATOR_HOST` is set — which the test command always
751
+ * sets). If that env var is absent, this is a no-op, so it can never wipe a real
752
+ * project's data. The emulator DB is entirely test data, so a full flush is the
753
+ * simplest correct "clean slate" — no per-collection allowlist to maintain.
754
+ *
755
+ * @param {object} admin - Firebase admin instance
756
+ */
757
+ async function flushEmulatorFirestore(admin) {
758
+ if (!process.env.FIRESTORE_EMULATOR_HOST) {
759
+ // Not pointed at the emulator — refuse to mass-delete. No-op.
760
+ return;
761
+ }
762
+
763
+ const firestore = admin.firestore();
764
+ const collections = await firestore.listCollections().catch(() => []);
765
+
766
+ await Promise.all(
767
+ collections.map((collectionRef) => firestore.recursiveDelete(collectionRef).catch(() => {}))
768
+ );
656
769
  }
657
770
 
658
771
  /**
659
772
  * Delete all test users (both Auth and Firestore)
660
- * Uses TEST_ACCOUNTS as the source of truth for which UIDs to delete
661
- * Deleting Auth users triggers on-delete which handles Firestore doc + count decrement
662
- * Waits for Firestore docs to be deleted before returning to ensure clean state
663
- * Called before test runs to ensure clean state
773
+ * Uses TEST_ACCOUNTS (+ any project-defined accounts) as the source of truth for
774
+ * which UIDs to delete. Deleting Auth users triggers on-delete which handles
775
+ * Firestore doc + count decrement.
776
+ * Called before test runs to ensure a clean slate. Flushes the ENTIRE emulator
777
+ * Firestore (the emulator DB is 100% test data — there's nothing to preserve),
778
+ * then deletes the Auth test users. `test/_init.js`'s `setup()` reseeds fixtures
779
+ * afterward. Waits for Firestore docs to be deleted before returning.
664
780
  * @param {object} admin - Firebase admin instance
781
+ * @param {object} [extraAccounts] - Project-defined accounts from test/_init.js
665
782
  * @returns {Promise<object>} Result with deleted count
666
783
  */
667
- async function deleteTestUsers(admin) {
784
+ async function deleteTestUsers(admin, extraAccounts) {
668
785
  const results = { deleted: [], skipped: [], failed: [] };
669
786
 
670
- // Delete all known test accounts in parallel
787
+ // Wipe the entire emulator Firestore up front (guarded to emulator-only).
788
+ await flushEmulatorFirestore(admin);
789
+
790
+ // Delete all known test accounts in parallel (built-in + project-defined).
791
+ const allAccounts = { ...TEST_ACCOUNTS, ...(extraAccounts || {}) };
671
792
  await Promise.all(
672
- Object.values(TEST_ACCOUNTS).map(async (account) => {
793
+ Object.values(allAccounts).map(async (account) => {
673
794
  try {
674
795
  // Delete Firebase Auth user (triggers on-delete which handles Firestore doc + count)
675
796
  await admin.auth().deleteUser(account.uid);
@@ -708,81 +829,16 @@ async function deleteTestUsers(admin) {
708
829
  })
709
830
  );
710
831
 
711
- // Clean up payment-related collections for test accounts.
712
- // Two passes per collection:
713
- // 1. owner-keyed: query by owner test uids (Firestore `in` caps at 30; batch).
714
- // 2. id-keyed: delete any doc whose id starts with the `_test-` prefix.
715
- // Pass 2 catches docs that have no owner field (e.g. dispute alerts, raw test webhooks).
716
- // All test-fixture IDs MUST start with `_test-` — that's the cleanup contract.
717
- const testUids = Object.values(TEST_ACCOUNTS).map(a => a.uid);
718
- // Collections that may carry test data tied to a test user (owner-keyed) or
719
- // identified solely by an `_test-` doc id prefix (id-keyed). All must be wiped
720
- // at the start of every run so a test that died mid-execution leaves no
721
- // ghosts. New collections that participate in tests MUST be added here too.
722
- const testDataCollections = ['payments-orders', 'payments-webhooks', 'payments-intents', 'payments-disputes'];
723
- // Collections that exist solely for tests — wipe in full. All docs in these
724
- // collections come from tests, so a single recursive delete handles cleanup.
725
- const testOnlyCollections = ['_test', '_test_query'];
726
- const UID_BATCH_SIZE = 30;
727
- const TEST_ID_PREFIX = '_test-';
728
-
729
- const uidBatches = [];
730
- for (let i = 0; i < testUids.length; i += UID_BATCH_SIZE) {
731
- uidBatches.push(testUids.slice(i, i + UID_BATCH_SIZE));
832
+ // Realtime Database: wipe the `_test` namespace in full. (The Firestore-wide
833
+ // flush already ran in flushEmulatorFirestore() at the start of this function.)
834
+ // `admin.database()` throws synchronously when no Database URL is configured,
835
+ // so guard the whole thing RTDB is optional for a project.
836
+ try {
837
+ await admin.database().ref('_test').remove();
838
+ } catch (e) {
839
+ // RTDB not configured / no database URL ignore.
732
840
  }
733
841
 
734
- await Promise.all([
735
- // Mixed collections: scoped delete of test data only.
736
- ...testDataCollections.map(async (collection) => {
737
- // Pass 1 — owner-keyed
738
- for (const batch of uidBatches) {
739
- try {
740
- const snapshot = await admin.firestore().collection(collection)
741
- .where('owner', 'in', batch)
742
- .get();
743
-
744
- await Promise.all(
745
- snapshot.docs.map(doc => doc.ref.delete())
746
- );
747
- } catch (e) {
748
- // Collection may not exist yet, or doesn't carry an owner field — ignore
749
- }
750
- }
751
-
752
- // Pass 2 — id-keyed (catches ownerless test docs)
753
- // documentId() range scan: `_test-` ≤ id < `_test.` (the next ASCII char after `-` is `.`)
754
- try {
755
- const snapshot = await admin.firestore().collection(collection)
756
- .where(admin.firestore.FieldPath.documentId(), '>=', TEST_ID_PREFIX)
757
- .where(admin.firestore.FieldPath.documentId(), '<', '_test.')
758
- .get();
759
-
760
- await Promise.all(
761
- snapshot.docs.map(doc => doc.ref.delete())
762
- );
763
- } catch (e) {
764
- // Collection may not exist yet — ignore
765
- }
766
- }),
767
- // Test-only Firestore collections: wipe in full.
768
- ...testOnlyCollections.map(async (collection) => {
769
- try {
770
- const snapshot = await admin.firestore().collection(collection).get();
771
- await Promise.all(snapshot.docs.map(doc => doc.ref.delete()));
772
- } catch (e) {
773
- // Collection may not exist yet — ignore
774
- }
775
- }),
776
- // Realtime Database: wipe the `_test` namespace in full.
777
- (async () => {
778
- try {
779
- await admin.database().ref('_test').remove();
780
- } catch (e) {
781
- // RTDB may not be configured for this project — ignore
782
- }
783
- })(),
784
- ]);
785
-
786
842
  return {
787
843
  success: results.failed.length === 0,
788
844
  deleted: results.deleted.length,
@@ -798,10 +854,11 @@ async function deleteTestUsers(admin) {
798
854
  * @param {object} admin - Firebase admin instance
799
855
  * @param {string} domain - Domain for email addresses (e.g., 'itwcreativeworks.com')
800
856
  * @param {object} [config] - BEM config (used to resolve first paid product)
857
+ * @param {object} [extraAccounts] - Project-defined accounts from test/_init.js
801
858
  * @returns {Promise<object>} Result with created/failed counts
802
859
  */
803
- async function createTestAccounts(admin, domain, config) {
804
- const definitions = getAccountDefinitions(domain, config);
860
+ async function createTestAccounts(admin, domain, config, extraAccounts) {
861
+ const definitions = getAccountDefinitions(domain, config, extraAccounts);
805
862
  const results = { created: [], failed: [] };
806
863
 
807
864
  // Create all accounts in parallel