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.
- package/CHANGELOG.md +35 -0
- package/CLAUDE.md +6 -4
- package/bin/backend-manager +10 -1
- package/docs/admin-post-route.md +2 -0
- package/docs/ai-library.md +29 -2
- package/docs/cli-output.md +122 -0
- package/docs/common-mistakes.md +1 -1
- package/docs/environment-detection.md +87 -3
- package/docs/marketing-campaigns.md +1 -1
- package/docs/payment-system.md +1 -1
- package/docs/testing.md +64 -17
- package/package.json +1 -1
- package/src/cli/commands/base-command.js +4 -0
- package/src/cli/commands/install.js +2 -2
- package/src/cli/commands/setup-tests/bem-config.js +22 -9
- package/src/cli/commands/setup-tests/helpers/merge-line-files.js +22 -168
- package/src/cli/commands/setup.js +65 -33
- package/src/cli/commands/test.js +1 -1
- package/src/cli/index.js +72 -39
- package/src/cli/utils/ui.js +270 -0
- package/src/defaults/CLAUDE.md +2 -2
- package/src/defaults/test/_init.js +14 -0
- package/src/manager/functions/_legacy/actions/create-post-handler.js +1 -1
- package/src/manager/functions/core/actions/api/admin/edit-post.js +1 -1
- package/src/manager/functions/core/actions/api/general/send-email.js +1 -1
- package/src/manager/functions/core/actions/api/user/delete.js +1 -1
- package/src/manager/functions/core/actions/api/user/oauth2.js +1 -1
- package/src/manager/helpers/analytics.js +3 -1
- package/src/manager/helpers/assistant.js +43 -38
- package/src/manager/index.js +76 -12
- package/src/manager/libraries/ai/index.js +33 -0
- package/src/manager/libraries/ai/providers/openai.js +105 -8
- package/src/manager/libraries/content/ghostii.js +76 -16
- package/src/manager/libraries/email/data/disposable-domains.json +24 -0
- package/src/manager/libraries/email/generators/lib/image-illustrator.js +154 -0
- package/src/manager/libraries/email/generators/newsletter.js +16 -2
- package/src/manager/libraries/payment/discount-codes.js +1 -0
- package/src/manager/routes/admin/post/put.js +1 -1
- package/src/manager/routes/handler/post/post.js +1 -1
- package/src/manager/routes/payments/intent/processors/test.js +1 -1
- package/src/manager/routes/user/delete.js +1 -1
- package/src/manager/routes/user/signup/post.js +4 -2
- package/src/test/runner.js +142 -20
- package/src/test/test-accounts.js +159 -102
- package/src/utils/merge-line-files.js +16 -5
- package/templates/_.env +1 -0
- package/test/events/payments/journey-payments-cancel-endpoint.js +6 -2
- package/test/events/payments/journey-payments-cancel.js +6 -3
- package/test/events/payments/journey-payments-failure.js +6 -3
- package/test/events/payments/journey-payments-plan-change.js +6 -3
- package/test/events/payments/journey-payments-refund-webhook.js +6 -2
- package/test/events/payments/journey-payments-suspend.js +6 -3
- package/test/events/payments/journey-payments-trial-cancel.js +6 -3
- package/test/events/payments/journey-payments-trial.js +10 -4
- package/test/events/payments/journey-payments-uid-resolution.js +6 -2
- package/test/events/payments/journey-payments-upgrade.js +6 -3
- package/test/helpers/content/ghostii-blocks.js +134 -0
- package/test/helpers/environment.js +230 -0
- package/test/helpers/merge-line-files.js +271 -0
- package/test/routes/marketing/webhook-forward.js +14 -7
- package/test/routes/payments/cancel.js +4 -1
- package/test/routes/payments/dispute-alert.js +9 -6
- package/test/routes/payments/intent.js +55 -14
- package/test/routes/payments/portal.js +4 -1
- 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.
|
|
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/
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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**.
|
|
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
|
-
|
|
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',
|
package/src/test/runner.js
CHANGED
|
@@ -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 ? '
|
|
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
|
-
//
|
|
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
|
|
257
|
-
*
|
|
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
|
-
|
|
535
|
-
|
|
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
|
-
//
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
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
|
-
|
|
638
|
-
|
|
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
|
-
|
|
646
|
-
|
|
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
|
-
|
|
653
|
-
|
|
722
|
+
return false;
|
|
723
|
+
}
|
|
654
724
|
|
|
655
|
-
|
|
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
|
|
661
|
-
* Deleting Auth users triggers on-delete which handles
|
|
662
|
-
*
|
|
663
|
-
* Called before test runs to ensure clean
|
|
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
|
-
//
|
|
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(
|
|
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
|
-
//
|
|
712
|
-
//
|
|
713
|
-
//
|
|
714
|
-
//
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
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
|