backend-manager 5.2.19 → 5.3.1

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 (57) hide show
  1. package/CHANGELOG.md +24 -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/helpers/webhook-forward.js +26 -0
  52. package/test/routes/marketing/webhook-forward.js +14 -7
  53. package/test/routes/payments/cancel.js +4 -1
  54. package/test/routes/payments/dispute-alert.js +9 -6
  55. package/test/routes/payments/intent.js +55 -14
  56. package/test/routes/payments/portal.js +4 -1
  57. package/test/routes/payments/refund.js +4 -1
package/CHANGELOG.md CHANGED
@@ -14,6 +14,30 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
14
14
  - `Fixed` for any bug fixes.
15
15
  - `Security` in case of vulnerabilities.
16
16
 
17
+ # [5.3.1] - 2026-06-02
18
+
19
+ ### Fixed
20
+ - **Leaking `wonderful-fetch` mock in the webhook-forward unit test.** `test/helpers/webhook-forward.js` installs a stub into `require.cache['wonderful-fetch']` at module load (the sanctioned cross-project fan-out exception — there's no second BEM emulator to receive the real fan-out POSTs), but never restored it. Because every test file is `require()`d into the same process, the stub leaked process-wide: every later test whose route did `require('wonderful-fetch')` got `{ received: true }` in 0ms instead of a real HTTP round-trip (observed breaking consumer sponsorship + inbound-email route tests). The helper now saves the original cache entry and restores it in a suite-level `cleanup()`, confining the mock to its own file.
21
+
22
+ # [5.3.0] - 2026-06-02
23
+
24
+ ### Added
25
+ - **`Manager.AI(assistant).image({ prompt, ... })`** — image generation via OpenAI's `gpt-image-2`. Separate method from `request()` (return type is bytes, not text; bypasses moderation/token-accounting/schema/prompt-normalization, none of which apply to image gen). Returns `{ buffer, b64, mime, revisedPrompt, model, size, quality, raw }` for a single image, or an array when `n > 1`. Options: `prompt` (required), `model` (default `gpt-image-2`), `size` (`1024x1024` default), `quality` (`medium` default), `background`, `n`, `timeout` (default 5min — image gen is slow). Only `openai` implements it. Lives on `OpenAI.prototype.image()` + dispatched via `AI.prototype.image()`. See [docs/ai-library.md](docs/ai-library.md).
26
+ - **AI tools passthrough (nested) — `ai.request({ tools: { list, choice } })`.** `tools.list` is an array of tool definitions passed to the OpenAI Responses API verbatim — built-in hosted tools (e.g. `{ type: 'web_search' }`) OR custom function tools (`{ type: 'function', name, parameters }`); `tools.choice` *(optional)* maps to `tool_choice`. Opt-in — omitted/empty means no tools and identical behavior to a plain request. Primary use is OpenAI's built-in **web search** so the model finds and cites real, currently-live URLs instead of hallucinating them. When tools are active the response `output` may carry tool-call items (e.g. `web_search_call`) + `url_citation` annotations; the message-text extractor ignores non-message items so `r.content` is unaffected. See [docs/ai-library.md](docs/ai-library.md).
27
+ - **`image-illustrator.js` — newsletter section illustrations now generated as flat-vector PNGs via `gpt-image-2` by default**, replacing the SVG-author-then-rasterize approach as the default. Clean flat 2D vector style (Stripe / Linear / undraw.co aesthetic) built from the brand palette (`content.theme.{primary,secondary,accent}Color`), white background, no text. The legacy `svg-illustrator.js` method is still available per-brand via `marketing.beehiiv.content.method.image = 'svg'`. Both return the same `{ png: Buffer, fallback, meta }` contract, so `image-host.js` / `uploadAssets` are unchanged. Validated end-to-end against live `gpt-image-2` with Somiibo's real config. See [docs/marketing-campaigns.md](docs/marketing-campaigns.md).
28
+ - **Full emulator-Firestore flush before every test run.** `deleteTestUsers()` now calls `flushEmulatorFirestore()` — `listCollections()` + `recursiveDelete()` on the entire emulator DB — before recreating test accounts. The emulator DB is 100% test data, so a full flush is the simplest correct clean slate; there are no per-collection allowlists to maintain. Guarded to run ONLY when `FIRESTORE_EMULATOR_HOST` is set, so it can never touch a real project.
29
+ - **`test/_init.js` pre-test lifecycle hook.** The test runner loads an optional `test/_init.js` from BOTH test roots (BEM core + consumer project) and runs it before any test (it is not run as a test itself). The module **must export a function** — `module.exports = (ctx) => ({ ... })` — called with `{ config, Manager }` and returning the hook object. It may declare `accounts` (array of extra test accounts `{ id, uid, email, properties }`, created/fetched/deleted on the same path as the built-ins so a project has a user per lifecycle) and `async setup({ admin, config, accounts, Manager, assistant })` (reseed fixtures into the freshly-flushed DB, after account creation). There is **no `cleanup` hook** — the whole DB is flushed each run and each test cleans up after itself. A default boilerplate `test/_init.js` now ships via `src/defaults/` (copied into consumers on first `npx mgr setup`, never overwriting an existing one). Mirrored across all four OMEGA frameworks. See `docs/testing.md`.
30
+
31
+ ### Changed
32
+ - **Environment detection consolidated onto the Manager as SSOT.** `getEnvironment()` returns exactly one of `development | testing | production` (mutually exclusive, testing wins), read **live** from `process.env` on every call (no caching). `assistant.isDevelopment/isProduction/isTesting/getEnvironment` forward to the Manager. Fixes a bug where a cached environment made `getApiUrl()` resolve to the production URL inside the test runner.
33
+ - **Install-command parity.** `npx mgr install` accepts the unified alias set across all four frameworks — `dev|d|development|local|l` for the local source install and `live|prod|p|production` for the published version; docs advertise the canonical `dev` + `live`.
34
+ - **`copyDefaults` no longer skips `_`-prefixed filenames.** The archive-skip rule now only applies to `_`-prefixed *directory* segments (e.g. `_legacy/`); a `_`-prefixed *filename* like `test/_init.js` ships verbatim. The `_.env` / `_.gitignore` dotfile rename is unchanged.
35
+
36
+ ### Fixed
37
+ - **Test-account creation race.** A late `auth:on-delete` could clobber the fresh `auth:on-create` doc, leaving accounts without `api.clientId`/`privateKey` and cascading into dozens of auth/payment test failures. `createAccount()` now verifies the API keys landed and repairs (recreate) if a stale delete clobbered the doc, making the suite deterministic.
38
+ - **`meta/stats` seed ordering.** `ensureMetaStats()` ran before the emulator flush (so it was wiped) and skipped when the doc already existed (so the notification on-write trigger could leave it without a `users` field). It now merges the `users` baseline AFTER the flush, fixing flaky admin-stats tests.
39
+ - **Flaky payment/dispute tests.** The trial journey now skips cleanly when no paid product has `trial.days > 0` (mirrors trial-cancel) instead of timing out; the dispute-alert dedup test seeds a deterministic in-flight doc instead of racing a live webhook to `failed`.
40
+
17
41
  # [5.2.19] - 2026-05-29
18
42
 
19
43
  ### Added
package/CLAUDE.md CHANGED
@@ -34,7 +34,7 @@ All `npx mgr <cmd>` aliases work: `npx bm <cmd>`, `npx bem <cmd>`, `npx backend-
34
34
  1. `npm install` — install BEM's own deps
35
35
  2. `npm run prepare` — build once: copies `src/` → `dist/` via prepare-package
36
36
  3. `npm run prepare:watch` — watch mode
37
- 4. Test in a consumer project: from inside the consumer, run `npx mgr install dev` to swap BEM to this local repo — required whenever you edit the framework source and want the consumer to pick up the changes (the consumer otherwise keeps its installed `node_modules/backend-manager`). Reverse with `npx mgr install prod`. If `npx mgr` then errors with "could not determine executable to run", the local install skipped bin-linking — re-run `npm install` to relink, or call `node node_modules/backend-manager/bin/backend-manager <cmd>` directly.
37
+ 4. Test in a consumer project: from inside the consumer, run `npx mgr install dev` to swap BEM to this local repo — required whenever you edit the framework source and want the consumer to pick up the changes (the consumer otherwise keeps its installed `node_modules/backend-manager`). Reverse with `npx mgr install live`. If `npx mgr` then errors with "could not determine executable to run", the local install skipped bin-linking — re-run `npm install` to relink, or call `node node_modules/backend-manager/bin/backend-manager <cmd>` directly.
38
38
 
39
39
  ## Architecture
40
40
 
@@ -79,6 +79,7 @@ See [docs/cli-firestore-auth.md](docs/cli-firestore-auth.md) and [docs/cli-logs.
79
79
  - **Routes receive whitespace-trimmed data; HTML is preserved.** Sanitize at the HTML-insertion site via `utilities.sanitize()`. Opt into middleware-level HTML strip per-route with `{ sanitize: true }`. See [docs/sanitization.md](docs/sanitization.md).
80
80
  - **Match schema names to route names** — if route is `myEndpoint`, schema is `myEndpoint`.
81
81
  - **Always use `assistant.respond()` for responses** — do NOT use `res.send()` directly.
82
+ - **Always use `Manager.getApiUrl()` for the API URL** — never read the cached `Manager.project.apiUrl` property. The getter is the SSOT and auto-resolves to the local emulator in dev AND test (and production otherwise), so it's safe everywhere without passing an env arg. See [docs/environment-detection.md](docs/environment-detection.md).
82
83
  - **Add Firestore composite indexes** for any compound query (`where` + `orderBy`, or multiple `where`s) to `src/cli/commands/setup-tests/helpers/required-indexes.js` (the SSOT). Without the index, queries crash with `FAILED_PRECONDITION` in production.
83
84
 
84
85
  See [docs/code-patterns.md](docs/code-patterns.md) for code-pattern detail, [docs/common-mistakes.md](docs/common-mistakes.md) for the full anti-pattern checklist, and [docs/file-naming.md](docs/file-naming.md) for the naming table (routes / schemas / API commands / events / cron jobs / hooks).
@@ -107,7 +108,7 @@ Deep references live in `docs/`. **Whenever you make a behavioral change, update
107
108
  - [docs/common-mistakes.md](docs/common-mistakes.md) — anti-pattern checklist (don't modify Manager internals, always await, increment-before-update, etc.)
108
109
  - [docs/key-files.md](docs/key-files.md) — quick lookup for the most-touched files (Manager, helpers, auth events, cron, payment processors, CLI commands)
109
110
  - [docs/cli-output.md](docs/cli-output.md) — shared CLI styling module (`src/cli/utils/ui.js`): OMEGA-style banner/dividers/sections/status symbols + the `Summary` block; used by `setup`, adoptable by other commands
110
- - [docs/environment-detection.md](docs/environment-detection.md) — `assistant.isDevelopment/isProduction/isTesting()`
111
+ - [docs/environment-detection.md](docs/environment-detection.md) — `getEnvironment()` returns `'development' | 'testing' | 'production'` (mutually exclusive); gate side effects on the INTENTIONAL check (`isProduction()` for prod-only, `isDevelopment() || isTesting()` for local-or-test) — never `!isDevelopment()`. Plus the URL helper convention (always `Manager.getApiUrl()` — auto-resolves local in dev+test, never read `project.apiUrl`)
111
112
  - [docs/response-headers.md](docs/response-headers.md) — automatic `bm-properties` header
112
113
 
113
114
  ### Building Routes & Components
@@ -129,12 +130,12 @@ Deep references live in `docs/`. **Whenever you make a behavioral change, update
129
130
  ### Subsystems & Libraries
130
131
 
131
132
  - [docs/usage-rate-limiting.md](docs/usage-rate-limiting.md) — usage tracking, monthly/daily caps, `setUser()` + mirrors for proxy usage, reset schedule
132
- - [docs/ai-library.md](docs/ai-library.md) — `Manager.AI()` unified entry for OpenAI + Anthropic
133
+ - [docs/ai-library.md](docs/ai-library.md) — `Manager.AI()` unified entry for OpenAI + Anthropic (text via `.request()`, images via `.image()` → `gpt-image-2`)
133
134
  - [docs/marketing-fields.md](docs/marketing-fields.md) — adding custom fields to SendGrid + Beehiiv via the BEM/OMEGA SSOT pair
134
135
  - [docs/stripe-webhook-forwarding.md](docs/stripe-webhook-forwarding.md) — auto-started Stripe CLI forwarding for local dev
135
136
 
136
137
  ### Testing & CLI
137
138
 
138
- - [docs/testing.md](docs/testing.md) — running, filtering, log files, test types (standalone/suite/group), context object, assertions, auth levels. **Each test file `module.exports` a `{ description, type, tests }` object — NOT raw Mocha (`describe`/`it`/`beforeEach`); those globals are not injected and the file fails to load. Split tests one-file-per-concern under `test/<area>/`, never one giant `test/test.js`.** **All cleanup runs at the START of every run, never at the end.** If you add a test that writes Firestore data, register the collection/namespace in the runner's pre-test wipe list, don't add a trailing cleanup step. Marketing providers (SendGrid/Beehiiv) don't need a special exception — `_test.*` emails are blocked at the validation layer so test signups never reach providers. The `_test.allow_*` carve-out exists only for the live-provider lifecycle test (`test/marketing/consent-lifecycle.js`), which manages its own teardown.
139
+ - [docs/testing.md](docs/testing.md) — running, filtering, log files, test types (standalone/suite/group), context object, assertions, auth levels. **NEVER mock — test against the real emulator.** No `mockManager`/`mockAdmin`/fake `firestore`/stubbed `assistant`; every `run()` gets the real `Manager`/`assistant`/`firestore`/`http`/`accounts` — use them. Pure functions (zero I/O) are the only thing you call directly; anything touching Firestore or an external API runs for real. Real external APIs (OpenAI/PayPal/GitHub/SendGrid/Stripe) are gated behind `TEST_EXTENDED_MODE` in-source (not mocked), and anything an extended test creates externally must be cleaned up by the test. **Each test file `module.exports` a `{ description, type, tests }` object — NOT raw Mocha (`describe`/`it`/`beforeEach`); those globals are not injected and the file fails to load. Split tests one-file-per-concern under `test/<area>/`, never one giant `test/test.js`.** **All cleanup runs at the START of every run, never at the end** the runner flushes the ENTIRE emulator Firestore before every run, so there's nothing to register; seed any needed fixtures in `test/_init.js`'s `setup()`, and never add a trailing cleanup step. Marketing providers (SendGrid/Beehiiv) don't need a special exception — `_test.*` emails are blocked at the validation layer so test signups never reach providers. The `_test.allow_*` carve-out exists only for the live-provider lifecycle test (`test/marketing/consent-lifecycle.js`), which manages its own teardown.
139
140
  - [docs/cli-firestore-auth.md](docs/cli-firestore-auth.md) — `npx mgr firestore:*` and `auth:*` commands, shared flags, examples
140
141
  - [docs/cli-logs.md](docs/cli-logs.md) — `npx mgr logs:read` / `logs:tail` with full flag reference and built-in Cloud Function names
@@ -4,6 +4,8 @@ The `POST /admin/post` route creates blog posts via GitHub's API. It handles ima
4
4
 
5
5
  **Consumers.** Besides direct admin/blogger HTTP calls, this route is the publish target for the **Ghostii article engine** (`src/manager/libraries/content/ghostii.js` → `publishArticle()`), which is used by two paths: the standalone daily `ghostii-auto-publisher.js` cron (off by default), and the newsletter generator's linked-article flow (`marketing.beehiiv.content.article.enabled`). Both POST `title`/`url`/`description`/`headerImageURL`/`body`/`author`/`categories`/`tags`/`postPath`/`githubUser`/`githubRepo` with a `backendManagerKey`. Note: `headerImageURL` MUST resolve to a `.jpg` (the downloader rejects non-`.jpg` headers); Ghostii's `unsplash` hero satisfies this. See [docs/marketing-campaigns.md](marketing-campaigns.md).
6
6
 
7
+ **Ghostii is unopinionated about BEM.** Its `/write/article` response is a generic article — a `json` block array (`[{ name, content }]` where name ∈ `heading-1..6`/`image`/`paragraph`/`blockquote`/`list`) plus top-level `title`/`description`/`headerImageUrl`/`images`/`categories`/`keywords`. BEM owns the transform into this route's shape: `blocksToPost(article.json)` extracts the `heading-1` as the title, the first `image` block as `headerImageURL`, and joins every remaining block as the `body` (content only — NO title, NO header image embedded, since this route adds those itself; section images stay in the body and are extracted normally). Older Ghostii responses without `json` fall back to the flat `article.{title,body,headerImageUrl}` fields.
8
+
7
9
  ## Image Processing Flow
8
10
 
9
11
  1. Receives markdown body with external image URLs (e.g., `![alt](https://images.unsplash.com/...)`)
@@ -14,16 +14,43 @@ Return shape (same for all providers): `{ content, output, tokens, raw }`.
14
14
 
15
15
  API keys: `BACKEND_MANAGER_OPENAI_API_KEY`, `BACKEND_MANAGER_ANTHROPIC_API_KEY` (process.env or config).
16
16
 
17
+ ## Image generation (OpenAI)
18
+
19
+ `Manager.AI(assistant).image({ prompt, ... })` generates an image via OpenAI's image model (`gpt-image-2` by default). Separate from `request()` because the return type is bytes, not text — it bypasses moderation, token accounting, schema, and prompt-normalization (none apply to image gen).
20
+
21
+ ```js
22
+ const ai = Manager.AI(assistant);
23
+ const { buffer, b64, mime, revisedPrompt } = await ai.image({
24
+ prompt: 'Minimal flat vector illustration of a rocket, undraw.co style, blue + white, no text.',
25
+ size: '1024x1024', // 1024x1024 | 1536x1024 | 1024x1536 | auto (default 1024x1024)
26
+ quality: 'medium', // low | medium | high | auto (default medium)
27
+ background: 'opaque', // transparent | opaque | auto (opt-in)
28
+ n: 1, // default 1; n > 1 returns an array
29
+ });
30
+ // buffer is a PNG Buffer; b64 is the same data base64-encoded.
31
+ ```
32
+
33
+ Return shape (single image): `{ buffer, b64, mime, revisedPrompt, model, size, quality, raw }`. With `n > 1` it returns an array of those.
34
+
35
+ `gpt-image-2` always returns base64 (no URL round-trip). Generation is slow — `medium`/`1024²` ≈ 40-50s; the default request timeout is 5 minutes. Only `openai` implements `image()`; calling it on another provider throws.
36
+
37
+ API key resolution is the same as `request()` — `BACKEND_MANAGER_OPENAI_API_KEY` / `OPENAI_API_KEY` (process.env or config).
38
+
17
39
  ## Tools / web search (OpenAI)
18
40
 
19
- `options.tools` (and optional `options.toolChoice`) are passed through to the OpenAI Responses API verbatim. Opt-in — when omitted, no tools are sent and behavior is identical to a plain request. Use this to enable OpenAI's built-in **web search** so the model finds and cites real, currently-live URLs instead of hallucinating them:
41
+ Tools are nested under `options.tools` and opt-in — when omitted, no tools are sent and behavior is identical to a plain request:
42
+
43
+ - `tools.list` — array of tool definitions passed to the OpenAI Responses API verbatim. Built-in hosted tools (e.g. `{ type: 'web_search' }`, `{ type: 'code_interpreter' }`) OR custom function tools (`{ type: 'function', name, parameters }`).
44
+ - `tools.choice` *(optional)* — maps to `tool_choice` (`'auto'` | `'required'` | `'none'`, or a specific tool). Omit to let OpenAI default to `auto`.
45
+
46
+ The most common use is OpenAI's built-in **web search** so the model finds and cites real, currently-live URLs instead of hallucinating them:
20
47
 
21
48
  ```js
22
49
  const r = await ai.request({
23
50
  model: 'gpt-5.4',
24
51
  response: 'json',
25
52
  reasoning: { effort: 'medium' },
26
- tools: [{ type: 'web_search' }],
53
+ tools: { list: [{ type: 'web_search' }] },
27
54
  prompt: { path: '.../research/system.md', settings },
28
55
  message: { path: '.../research/user.md', settings },
29
56
  });
@@ -9,4 +9,4 @@
9
9
  7. **Use short-circuit returns** — Return early from error conditions
10
10
  8. **Increment usage before update** — Call `usage.increment()` then `usage.update()`
11
11
  9. **Add Firestore composite indexes for new compound queries** — Any new Firestore query using multiple `.where()` clauses or `.where()` + `.orderBy()` requires a composite index. Add it to `src/cli/commands/setup-tests/helpers/required-indexes.js` (the SSOT). Consumer projects pick these up via `npx mgr setup`, which syncs them into `firestore.indexes.json`. Without the index, the query will crash with `FAILED_PRECONDITION` in production.
12
- 10. **Don't put test data cleanup at the END of a test** — End-of-test cleanup doesn't fire when the previous run was killed mid-execution, so the next run inherits stale state. ALL test-data cleanup belongs in the runner's pre-test phase ([../src/test/test-accounts.js](../src/test/test-accounts.js) `deleteTestUsers()` + [../src/test/runner.js](../src/test/runner.js) `setupAccounts()`). When adding a test that writes data: register the Firestore collection in `testDataCollections` (mixed prod+test data) or `testOnlyCollections` (test-only), or use the `_test-` doc-id prefix so the id-keyed pass catches it. The only acceptable trailing cleanup is within-run state isolation (one test removes a doc so the NEXT test in the same run sees a clean slate) — that's not preparing the next run, it's intra-run housekeeping. See [testing.md](testing.md) "Test Data Cleanup".
12
+ 10. **Don't put test data cleanup at the END of a test** — End-of-test cleanup doesn't fire when the previous run was killed mid-execution, so the next run inherits stale state. ALL test-data cleanup belongs in the runner's pre-test phase ([../src/test/test-accounts.js](../src/test/test-accounts.js) `deleteTestUsers()` + [../src/test/runner.js](../src/test/runner.js) `setupAccounts()`), which **flushes the entire emulator Firestore before every run** so there's nothing to register when you add a test that writes data. Seed any required fixtures in `test/_init.js`'s `setup()` (runs after the flush). The only acceptable trailing cleanup is within-run state isolation (one test removes a doc so the NEXT test in the same run sees a clean slate) — that's not preparing the next run, it's intra-run housekeeping. See [testing.md](testing.md) "Test Data Cleanup".
@@ -1,7 +1,91 @@
1
1
  # Environment Detection
2
2
 
3
+ `getEnvironment()` returns exactly ONE of three mutually-exclusive, exhaustive values:
4
+
5
+ ```javascript
6
+ Manager.getEnvironment() // 'development' | 'testing' | 'production'
7
+
8
+ Manager.isDevelopment() // true ONLY in development
9
+ Manager.isTesting() // true ONLY in testing
10
+ Manager.isProduction() // true ONLY in production
11
+ ```
12
+
13
+ **The Manager is the single source of truth.** `getEnvironment()` is the ONLY function that reads the raw signals (`BEM_TESTING` / `ENVIRONMENT` / `FUNCTIONS_EMULATOR` / `TERM_PROGRAM`). The three `is*()` checks **derive** from it live on every call — they never read raw signals themselves, so they can never disagree with `getEnvironment()`.
14
+
15
+ **The assistant forwards to the Manager.** Request handlers receive an `assistant`, so the same methods are exposed there and return identical results — call whichever is in scope:
16
+
17
+ ```javascript
18
+ assistant.getEnvironment() // === Manager.getEnvironment() — a thin forward
19
+ assistant.isTesting() // === Manager.isTesting()
20
+ ```
21
+
22
+ (An assistant always has a Manager — `init()` throws without one. The `assistant.meta.environment` field is still populated for code that reads it, but the `is*()` checks no longer depend on that snapshot.)
23
+
24
+ **Resolution order:** testing wins first, then production, else development. The three checks are mutually exclusive — exactly one is true. `isDevelopment()` is **false** during testing, and `isProduction()` is a real positive check (it is NOT `!isDevelopment()`).
25
+
26
+ ## Available helpers
27
+
28
+ | Helper | Returns |
29
+ |---|---|
30
+ | `getEnvironment()` | `'development' \| 'testing' \| 'production'` — the SSOT resolver; the only reader of raw signals. |
31
+ | `isDevelopment()` | `true` ONLY in development (local Firebase emulator / dev), and NOT testing. Derives from `getEnvironment()`. |
32
+ | `isTesting()` | `true` ONLY in testing (`BEM_TESTING === 'true'`). **Takes precedence** — a test run is not development. |
33
+ | `isProduction()` | `true` ONLY in production (deployed Cloud Functions). A **real positive check** — NOT `!isDevelopment()`. |
34
+
35
+ ## Gating side effects — use the INTENTIONAL check
36
+
37
+ Because there are three environments, never gate a side effect on a two-value assumption. State what you mean:
38
+
3
39
  ```javascript
4
- assistant.isDevelopment() // true when ENVIRONMENT !== 'production' or in emulator
5
- assistant.isProduction() // true when ENVIRONMENT === 'production'
6
- assistant.isTesting() // true when running tests (via npx mgr test)
40
+ // Production-only (skip real emails/analytics/Sentry/webhooks in dev AND testing):
41
+ if (isProduction()) { /* do the real thing */ }
42
+ if (!isProduction()) { /* skip / use the safe local behavior */ }
43
+
44
+ // Local-or-test (anything that should run in BOTH dev and testing):
45
+ if (isDevelopment() || isTesting()) { /* localhost URL, console logging, etc. */ }
7
46
  ```
47
+
48
+ **Avoid** `if (!isDevelopment())` or `if (env !== 'development')` to gate production behavior — those wrongly include `testing` as production and leak real side effects (emails, analytics, Sentry) during test runs. This is the bug class that motivated the 3-value model.
49
+
50
+ ## URL helpers
51
+
52
+ ```javascript
53
+ Manager.getApiUrl() // this brand's API URL — the SSOT for calling the BEM API
54
+ ```
55
+
56
+ **`Manager.getApiUrl()` is the one and only way to get the API URL.** It resolves to the **local** hosting emulator (`http://localhost:5002`) in development OR testing, and to production (`https://api.{domain}`) otherwise. Always call `getApiUrl()` directly — do NOT read the cached `Manager.project.apiUrl` property (it's a boot-time snapshot kept only for internal env-var export; the getter is the SSOT and always fresh). Build full endpoints by appending the path: `` `${Manager.getApiUrl()}/backend-manager/admin/post` ``.
57
+
58
+ Resolving local in test mode is required because tests hit the local emulator — without it, internal BEM→BEM calls (and tests calling `getApiUrl()`) would leak to the live production server. Pass an explicit `env` arg (`getApiUrl('production')`) only to force a specific environment regardless of the current one — rarely needed, and mainly used by tests to pin a specific environment's mapping.
59
+
60
+ > `getFunctionsUrl()` (raw Cloud Functions URL) exists for the ONE internal case that must name a specific deployed function by its raw address (`assistant.tryUrl()`). Application/route code should never need it — use `getApiUrl()`.
61
+
62
+ **Exception — parent helpers stay live:** `Manager.getParentApiUrl()` / `getParentUrl()` ALWAYS return the live production URL, even in dev/test. The parent BEM is a real remote server with no localhost equivalent, so cross-brand parent calls are never redirected to localhost.
63
+
64
+ ## Where they live
65
+
66
+ Source: [src/manager/index.js](../src/manager/index.js). BEM has a single Manager (no multi-context mixin like EM/UJM/BXM), so `getEnvironment()` + `is*()` + the URL helpers live directly on the Manager. The `assistant` exposes the same methods and forwards each to its Manager (`assistant.isTesting()` → `Manager.isTesting()`), so request handlers can call whichever object is in scope.
67
+
68
+ ## How detection works
69
+
70
+ `getEnvironment()` resolves in this precedence order:
71
+
72
+ 1. **Testing** — `process.env.BEM_TESTING === 'true'` (set by the test runner / emulator). A test run is a test run regardless of any other signal.
73
+ 2. **Production** — `process.env.ENVIRONMENT === 'production'`.
74
+ 3. **Development** — `process.env.ENVIRONMENT === 'development'`, or `FUNCTIONS_EMULATOR` is set, or `TERM_PROGRAM` is `Apple_Terminal` / `vscode` (running locally).
75
+ 4. **Default** — production. BEM's deployed *runtime* can legitimately lack a dev signal (a live Cloud Function has no `FUNCTIONS_EMULATOR`), so "no signal" IS the normal production state. (Contrast UJM/BXM, whose deployed artifacts always carry their signal baked in, so they default to **development** — a bare context there is just build tooling. EM defaults to production for the same reason as BEM.)
76
+
77
+ ## Adding a new helper
78
+
79
+ If you need a new environment-derived helper, add it next to the others on the Manager in [src/manager/index.js](../src/manager/index.js), and forward it from the assistant if request handlers need it. Don't read `process.env` ad-hoc elsewhere — derive from `getEnvironment()` so there is one source of truth and no chance of drift.
80
+
81
+ ## Why this matters
82
+
83
+ **One signal, used everywhere.** The test runner sets `BEM_TESTING=true`; every piece of code that calls `isTesting()` (framework or consumer) then sees `true` — no need to invent a per-module env var.
84
+
85
+ **Sub-modules check the same signal.** When framework code (an analytics flush, a webhook fan-out) needs to skip side effects in tests, it checks `isTesting()` — the same answer the consumer's own code gets. No drift.
86
+
87
+ **`is*()` can never disagree with `getEnvironment()`.** Because the checks derive from the single resolver instead of reading raw signals, there is exactly one definition of "what environment is this," and a wrong-but-confident gate (leaking real emails during a test run) is structurally impossible.
88
+
89
+ ## See also
90
+
91
+ - [testing.md](testing.md) — `BEM_TESTING` is set automatically by the test runner; `TEST_EXTENDED_MODE` gates real external APIs.
@@ -96,7 +96,7 @@ Campaigns reference segments by SSOT key: `segments: ['subscription_free']`. Aut
96
96
  Pipeline:
97
97
  1. Fetch sources: `GET {parentUrl}/newsletter/sources?category=X&claimFor=brandId` (atomic claim)
98
98
  2. **structure.js** — Generic dispatcher. Resolves the active template, merges `BASE_SCHEMA` (universal fields: subject, preheader, signoff, citations) with the template's own `schema` fragment, calls the template's `buildPrompt({brand, newsletterConfig, sources})` to get the AI brief, runs the AI call, and normalizes the result via the template's optional `normalize()`. Default provider: `openai` (override per-run only via `NEWSLETTER_PROVIDER_STRUCTURE` env).
99
- 3. **svg-illustrator.js** — One SVG per section in parallel (`Promise.all`). Iterates `structure.sections` — templates whose content shape isn't section-based (e.g. field-report uses `dispatches`) populate `sections` in their `normalize()` step so this loop keeps working unchanged. Default provider: `anthropic` (override via `NEWSLETTER_PROVIDER_SVG` env).
99
+ 3. **image-illustrator.js** (default) — One flat-vector PNG per section in parallel (`Promise.all`), generated directly via `Manager.AI(assistant).image()` → `gpt-image-2`. Iterates `structure.sections` — templates whose content shape isn't section-based (e.g. field-report uses `dispatches`) populate `sections` in their `normalize()` step so this loop keeps working unchanged. The prompt enforces a clean flat 2D vector style (Stripe / Linear / undraw.co aesthetic) built from the brand palette (`content.theme.{primary,secondary,accent}Color`), on a white background, no text. **Legacy method:** set `marketing.beehiiv.content.method.image = 'svg'` to use the older `svg-illustrator.js` (AI authors an `<svg>`, rasterized via `@resvg/resvg-js`). Both methods return the same `{ png: Buffer, fallback, meta }` contract.
100
100
  4. **mjml-template.js** — Resolves the template by name from `templates/index.js`, calls `template.build({structure, imagePaths, theme, ...})` for the MJML, compiles to email-safe HTML via the `mjml` package. Brand-domain links get UTM-tagged via the existing `tagLinks()` utility.
101
101
  5. Mark used: `PUT {parentUrl}/newsletter/sources` per source
102
102
 
@@ -180,7 +180,7 @@ module.exports = async function ({ before, after, uid, userDoc, admin, assistant
180
180
 
181
181
  1. Add detection logic in `transitions/index.js` (in priority order)
182
182
  2. Create handler file in `transitions/{category}/{name}.js`
183
- 3. Handler receives full context — use `assistant.log()` for logging, `Manager.project.apiUrl` for API calls
183
+ 3. Handler receives full context — use `assistant.log()` for logging, `Manager.getApiUrl()` for API calls
184
184
 
185
185
  ## Processor Interface
186
186
 
package/docs/testing.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # Testing
2
2
 
3
+ ## 🚫 NEVER mock — test against the real emulator (HARD RULE)
4
+
5
+ Tests run against a **real Firebase emulator** (real Firestore/Auth). **Do NOT hand-roll fake/stub/mock objects** — no `mockManager`, `mockAdmin`, `makeManager()`, fake `firestore()`/`admin`, stubbed `assistant`, or fake HTTP. Every test `run()` receives the **real** booted `Manager`, `assistant`, `firestore`, `http`, and `accounts` (see [the test context](#test-context)). Use them.
6
+
7
+ - Call routes over `http.as(...)`; call handlers/helpers with the real `Manager`/`assistant` from context; read/write/verify with the real `firestore` helper.
8
+ - **Pure functions are the only exception** — a function with zero I/O can be `require()`d and called with plain inputs (nothing to mock). The instant it touches Firestore or any external system, it must run for real against the emulator.
9
+ - **Real external APIs (OpenAI, PayPal, GitHub, SendGrid, Beehiiv, Stripe) are gated behind `TEST_EXTENDED_MODE` in the source, NOT mocked** — see [Extended Mode](#extended-mode-test_extended_mode). Normal mode skips them; extended mode runs them for real.
10
+ - **Anything an extended test creates in an external system must be cleaned up by the test** (delete the GitHub file, cancel the PayPal invoice, etc.) — the runner's pre-test wipe only covers local Firestore/Auth.
11
+
12
+ If you're writing `const mockX = {...}` to satisfy a function under test, STOP and pass the real context object instead.
13
+
14
+ ### The ONLY two exceptions where a stub is allowed
15
+
16
+ Mock **nothing** by default. There are exactly two narrow cases where the real dependency genuinely cannot run in the test environment — and even then, stub the *smallest possible seam*, never a whole `Manager`/`assistant`:
17
+
18
+ 1. **A side effect that would destroy the test run itself.** If invoking the real method would kill or corrupt the harness — e.g. a process-exit, an `app.quit()`, a destructive filesystem wipe, a recursive re-invocation of the test/build command — you may stub *that one call* to a no-op, assert the surrounding logic, then restore it. You are not faking behavior; you are preventing the harness from terminating mid-assertion.
19
+ 2. **Cross-project fan-out that needs infrastructure you can't run locally.** Some routes fan out to *other* BEM backends (parent → child brand servers). Only **one** BEM emulator runs locally, so the real cross-project call has no second backend to hit. A unit test may hand-roll the minimal inputs (`makeManager`/`makeAdminMock`/mocked `wonderful-fetch`) to exercise the *fan-out logic* in isolation — but a companion integration test MUST still verify the real route's gate/wiring against the emulator. (Example: `test/helpers/webhook-forward.js`.)
20
+
21
+ **Rules for both exceptions:** stub the narrowest seam (one method / one module), restore it immediately, and add a comment stating *why the real thing can't run here*. If you can run it for real, you must.
22
+
3
23
  ## Running Tests
4
24
 
5
25
  ```bash
@@ -18,12 +38,11 @@ npx mgr test
18
38
  What the runner wipes pre-test (in [src/test/test-accounts.js](../src/test/test-accounts.js) `deleteTestUsers()` and [src/test/runner.js](../src/test/runner.js) `setupAccounts()`):
19
39
 
20
40
  1. **`meta/stats`** doc ensured (required for on-create batch writes).
21
- 2. **`users/_test-*`** Firebase Auth users + Firestore docs (delete).
22
- 3. **Mixed Firestore collections** — `payments-orders`, `payments-webhooks`, `payments-intents`, `payments-disputes`. Two-pass cleanup per collection:
23
- - Pass 1owner-keyed: `where('owner', 'in', [...testUids])` (batched at 30 uids per `in` query).
24
- - Pass 2 — id-keyed: any doc whose ID starts with `_test-` (catches ownerless test docs like dispute alerts and raw test webhooks).
25
- 4. **Test-only Firestore collections** — `_test`, `_test_query` wiped in full.
26
- 5. **Realtime Database** — the `_test` namespace removed in full (`admin.database().ref('_test').remove()`).
41
+ 2. **The ENTIRE emulator Firestore** — every top-level collection, flushed recursively (`flushEmulatorFirestore()` → `listCollections()` + `recursiveDelete()`). The emulator DB is 100% test data, so a full flush is the simplest correct clean slate — no per-collection allowlist to maintain. **SAFETY: it only runs when `FIRESTORE_EMULATOR_HOST` is set** (which the test command always sets); if absent it is a no-op, so it can never wipe a real project.
42
+ 3. **Firebase Auth test users** — all `TEST_ACCOUNTS` uids deleted (Auth is a separate store from Firestore, so this still runs explicitly).
43
+ 4. **Realtime Database**the `_test` namespace removed in full (`admin.database().ref('_test').remove()`, guarded RTDB is optional).
44
+
45
+ After the flush, `test/_init.js`'s `setup()` reseeds fixtures into the empty DB.
27
46
 
28
47
  ### Marketing-provider cleanup
29
48
 
@@ -35,18 +54,44 @@ All cleanup follows the start-only rule. No trailing-cleanup exception.
35
54
 
36
55
  ### When adding a new test that writes data
37
56
 
38
- | If your test writes to... | Then... |
39
- |---|---|
40
- | A new Firestore collection (mixed test + prod data) | Add the collection name to `testDataCollections` in `src/test/test-accounts.js`. |
41
- | A new Firestore collection (test-only data) | Add it to `testOnlyCollections` in `src/test/test-accounts.js`. |
42
- | Realtime Database | Use a path under `_test/...` — already wiped pre-test. |
43
- | Anywhere else | Use the `_test-` doc-id prefix so id-keyed pass-2 catches it. |
57
+ Nothing to register — the **entire emulator Firestore is flushed before every run**, so any collection a test writes starts empty next run automatically. If a test needs a fixture to exist (e.g. a brand doc) before the suite runs, seed it in `test/_init.js`'s `setup()` (it runs after the flush). Realtime Database test data should live under `_test/...` (that namespace is wiped pre-run).
44
58
 
45
59
  ### Within-run state isolation is different
46
60
 
47
- Per-test cleanup is still appropriate when a test sets up DB state that would pollute a **later test in the same run** — e.g. trial-eligibility's `try/finally` that removes the fake `payments-orders/_test-trial-eligibility-*` doc so the next sibling test sees a clean slate. Those stay in the test. They are *intra-run* state management, not *next-run* cleanup, and the distinction matters.
61
+ Per-test cleanup is still appropriate when a test sets up DB state that would pollute a **later test in the same run** — e.g. a `try/finally` that removes a fixture so the next sibling test sees a clean slate. Those stay in the test. They are *intra-run* state management, not *next-run* cleanup, and the distinction matters.
62
+
63
+ The rule: **never put cleanup at the END of a test file or suite for the purpose of preparing the next run** — for LOCAL state. The pre-test full flush already guarantees a clean slate. (The third-party provider exception lives in the runner's post-suite hook, not in individual tests.)
48
64
 
49
- The rule: **never put cleanup at the END of a test file or suite for the purpose of preparing the next run** for LOCAL state. If a test cleans up local Firestore/Auth data only to "leave no trace," that cleanup belongs in the runner's pre-test phase instead. Add the collection/namespace to the runner's wipe list and remove the trailing cleanup step. The third-party provider exception (see above) lives in the runner's post-suite hook, not in individual tests.
65
+ ## `test/_init.js` — pre-test lifecycle hook
66
+
67
+ The runner loads an optional `test/_init.js` from **both** test roots — BEM core (`<bem>/test/_init.js`) and the consumer project (`<projectDir>/test/_init.js`) — and runs it before any test (it is NOT itself run as a test). Same contract for both roots, so framework and consumer authors write the identical file. Because the entire emulator Firestore is flushed each run, there are **no collection lists to declare** — `_init.js` only declares accounts and reseeds fixtures.
68
+
69
+ The module **must export a function** — `module.exports = (ctx) => ({ ... })` — called with `{ config, Manager }` and returning the hook object. (The function form lets a project compute its accounts/fixtures from config.) It may declare:
70
+
71
+ - `accounts` — array of extra test accounts to create alongside the built-in ones (admin/basic/premium-*/journey-*), so this project has a user for each lifecycle it exercises. Each entry is `{ id, uid, email, properties }` (email may use the `{domain}` placeholder, `properties` is merged into the user doc after `auth:on-create`). These accounts are created, fetched (privateKeys), and deleted on the same path as the built-ins, and show up in the `accounts` map that tests and `setup()` receive. A project account may override a built-in one by reusing its `id`.
72
+ - `async setup({ admin, config, accounts, Manager, assistant })` — seed fixtures (e.g. a brand doc) into the freshly-flushed DB, AFTER the clean slate + account creation. `accounts` is available so fixtures can reference a test uid. Use real ids that mirror production shape (no `_test-` prefix needed — the whole DB is wiped each run).
73
+
74
+ There is **no `cleanup` hook**: the entire emulator Firestore is flushed before every run and each test cleans up after itself, so there is nothing project-level to tear down.
75
+
76
+ ```javascript
77
+ // <projectDir>/test/_init.js
78
+ module.exports = ({ config }) => ({
79
+ // One account per lifecycle this project needs. Created alongside the built-ins.
80
+ accounts: [
81
+ { id: 'shop-owner', uid: '_test-shop-owner', email: '_test.shop-owner@{domain}', properties: { roles: {} } },
82
+ ],
83
+
84
+ // The entire emulator Firestore is flushed before each run, so just reseed.
85
+ async setup({ admin, accounts }) {
86
+ await admin.firestore().doc('brands/ultimate-jekyll').set({
87
+ id: 'ultimate-jekyll',
88
+ brand: { id: 'ultimate-jekyll', name: 'Ultimate Jekyll', url: 'https://ultimate-jekyll.itwcreativeworks.com' },
89
+ owner: accounts['shop-owner'].uid,
90
+ sponsorships: { prices: { 'guest-post': 50, 'link-insertion': 30 } },
91
+ });
92
+ },
93
+ });
94
+ ```
50
95
 
51
96
  ## Extended Mode (`TEST_EXTENDED_MODE`)
52
97
 
@@ -121,7 +166,7 @@ npx mgr test user/ admin/ # Multiple paths
121
166
  - **BEM core tests:** `test/` (in the framework repo)
122
167
  - **Project tests:** the consumer project's repo-root `test/` directory (NOT inside `functions/`)
123
168
 
124
- Use `bem:` or `project:` prefix to filter by source. Mirror BEM's own per-area layout **one file per concern** under `test/<area>/` (e.g. `test/article/allocation.js`, `test/article/markdown.js`, `test/article/generate.js`), never one giant `test/test.js`. The runner discovers files by directory, so the split is also what the `project:<path>` filter targets (`npx mgr test project:article`).
169
+ Use `bem:` or `project:` prefix to filter by source. **Mirror the source path so a test reads like what it tests.** Route tests live under `test/routes/<route-path>/<concern>.js`, mirroring `functions/routes/<route-path>/` e.g. `functions/routes/write/article/` → `test/routes/write/article/generate.js`, `functions/routes/sponsorship/post.js` → `test/routes/sponsorship/post.js`. Split each route into **one file per concern** under its mirrored dir (`test/routes/sponsorship/post.js`, `.../manual-validation.js`), never one giant `test/test.js`. The runner discovers files by directory, so the split also drives the `project:<path>` filter: `npx mgr test project:routes/write` runs a whole route's tests, `project:routes/write/markdown` runs one concern.
125
170
 
126
171
  ## Test Types
127
172
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backend-manager",
3
- "version": "5.2.19",
3
+ "version": "5.3.1",
4
4
  "description": "Quick tools for developing Firebase functions",
5
5
  "main": "src/manager/index.js",
6
6
  "bin": {
@@ -8,9 +8,9 @@ const wonderfulVersion = require('wonderful-version');
8
8
 
9
9
  class InstallCommand extends BaseCommand {
10
10
  async execute(type) {
11
- if (type === 'local' || type === 'dev' || type === 'development') {
11
+ if (['local', 'l', 'dev', 'd', 'development'].includes(type)) {
12
12
  await this.installLocal();
13
- } else if (type === 'live' || type === 'prod' || type === 'production') {
13
+ } else if (['live', 'prod', 'p', 'production'].includes(type)) {
14
14
  await this.installLive();
15
15
  }
16
16
  }
@@ -195,10 +195,14 @@ class SetupCommand extends BaseCommand {
195
195
  const rel = path.relative(defaultsDir, src);
196
196
  const segments = rel.split(path.sep);
197
197
 
198
- // Skip "archive" directoriesanything under a path segment starting with `_` and
199
- // followed by a non-`.` character. Matches EM/BXM/UJM convention. The `_.env` /
200
- // `_.gitignore` files are NOT skipped; their leading `_` strips on copy below.
201
- if (segments.some((s) => s.startsWith('_') && !s.startsWith('_.'))) {
198
+ // Skip "archive" DIRECTORIESany non-final path segment starting with `_` and
199
+ // followed by a non-`.` character (e.g. `_legacy/`). Matches EM/BXM/UJM convention.
200
+ // The check is restricted to directory segments (all but the last) so a `_`-prefixed
201
+ // FILENAME still ships e.g. `test/_init.js` copies verbatim (the test runner skips
202
+ // it from discovery on its own). The `_.env` / `_.gitignore` files are likewise not
203
+ // skipped; their leading `_` strips on copy below.
204
+ const dirSegments = segments.slice(0, -1);
205
+ if (dirSegments.some((s) => s.startsWith('_') && !s.startsWith('_.'))) {
202
206
  continue;
203
207
  }
204
208
 
@@ -274,6 +278,10 @@ class SetupCommand extends BaseCommand {
274
278
  // Get all tests
275
279
  const tests = testRegistry.getTests(testContext);
276
280
 
281
+ // Expose the total count so the per-check `[N]` prefix can right-align its
282
+ // width (single- vs double-digit indices stay aligned).
283
+ self.testTotalExpected = tests.length;
284
+
277
285
  // Run each test
278
286
  for (const test of tests) {
279
287
  await self.test(
@@ -36,7 +36,7 @@ class TestCommand extends BaseCommand {
36
36
  const envSubset = captureSyncedEnv(process.env);
37
37
  writeTestMode(projectDir, envSubset);
38
38
  const extended = !!process.env.TEST_EXTENDED_MODE;
39
- this.log(chalk.gray(` Test mode: ${extended ? 'EXTENDED (real APIs)' : 'normal (mocked)'}`));
39
+ this.log(chalk.gray(` Test mode: ${extended ? 'extended (real external APIs)' : 'normal (external APIs skipped)'}`));
40
40
  }
41
41
 
42
42
  // Load emulator ports from firebase.json
package/src/cli/index.js CHANGED
@@ -168,12 +168,16 @@ Main.prototype.test = async function(name, fn, fix, args) {
168
168
  const ui = require('./utils/ui');
169
169
 
170
170
  // Prints ` [N] <symbol> <name>` — the OMEGA-style per-check status line
171
- // (indented one level under the `[CHECKS]` section label).
171
+ // (indented one level under the `[CHECKS]` section label). The `[N]` bracket
172
+ // is padded to the width of the total check count so single- and double-digit
173
+ // numbers (`[1] ` … `[13]`) keep the symbols/names aligned.
174
+ const indexWidth = String(self.testTotalExpected || 99).length;
172
175
  const printLine = (index, kind, label) => {
173
176
  const colorByKind = { pass: chalk.green, fail: chalk.red, warn: chalk.yellow };
174
177
  const color = colorByKind[kind] || chalk.white;
175
178
  const suffix = label ? ` ${chalk.dim(label)}` : '';
176
- console.log(`${ui.indent(2)}${chalk.dim(`[${index}]`)} ${color(ui.SYMBOLS[kind])} ${name}${suffix}`);
179
+ const bracket = `[${index}]`.padEnd(indexWidth + 2, ' ');
180
+ console.log(`${ui.indent(2)}${chalk.dim(bracket)} ${color(ui.SYMBOLS[kind])} ${name}${suffix}`);
177
181
  };
178
182
 
179
183
  let passed = await fn();
@@ -24,12 +24,12 @@ npx mgr logs:read # read Cloud Functions logs (also: logs:tail to stream
24
24
  npx mgr firestore:get # read a doc from Firestore (also: firestore:set / :query / :delete)
25
25
  npx mgr auth:get # read an Auth user (also: auth:list / :delete / :set-claims)
26
26
  npx mgr install dev # use LOCAL backend-manager source (to test framework edits)
27
- npx mgr install prod # restore the published backend-manager from npm
27
+ npx mgr install live # restore the published backend-manager from npm
28
28
  ```
29
29
 
30
30
  All `npx mgr <cmd>` aliases — `npx bm <cmd>`, `npx bem <cmd>`, `npx backend-manager <cmd>` work too.
31
31
 
32
- > Editing the BEM framework source while working here? Run `npx mgr install dev` so this project picks up your uncommitted framework changes (it otherwise uses its installed `node_modules/backend-manager`). Run `npx mgr install prod` to switch back.
32
+ > Editing the BEM framework source while working here? Run `npx mgr install dev` so this project picks up your uncommitted framework changes (it otherwise uses its installed `node_modules/backend-manager`). Run `npx mgr install live` to switch back.
33
33
 
34
34
  ## Where things live
35
35
 
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Test lifecycle hook for this project. Runs before any test (not a test itself).
3
+ * See backend-manager/docs/testing.md → "test/_init.js".
4
+ */
5
+
6
+ module.exports = ({ config }) => ({
7
+ // Extra test accounts (one per lifecycle this project exercises):
8
+ // { id, uid, email, properties }. email may use the {domain} placeholder.
9
+ accounts: [],
10
+
11
+ // Seed fixtures into the freshly-flushed emulator, after accounts are created.
12
+ async setup({ admin, accounts }) {
13
+ },
14
+ });
@@ -51,7 +51,7 @@ let Module = {
51
51
  assistant.error(response.error)
52
52
  } else {
53
53
  mailchimp = new Mailchimp(self.Manager.config?.mailchimp?.key ?? '');
54
- await fetch(`${self.Manager.project.apiUrl}/backend-manager`, {
54
+ await fetch(`${self.Manager.getApiUrl()}/backend-manager`, {
55
55
  method: 'POST',
56
56
  response: 'json',
57
57
  body: {
@@ -100,7 +100,7 @@ Module.prototype.fetchPost = function (url) {
100
100
  const payload = self.payload;
101
101
 
102
102
  return new Promise(async function(resolve, reject) {
103
- fetch(`${Manager.project.apiUrl}/backend-manager`, {
103
+ fetch(`${Manager.getApiUrl()}/backend-manager`, {
104
104
  method: 'post',
105
105
  response: 'json',
106
106
  timeout: 190000,
@@ -83,7 +83,7 @@ Module.prototype.main = function () {
83
83
  assistant.log('Email payload:', emailPayload);
84
84
 
85
85
  // Send the email via admin:send-email
86
- await fetch(`${Manager.project.apiUrl}/backend-manager`, {
86
+ await fetch(`${Manager.getApiUrl()}/backend-manager`, {
87
87
  method: 'post',
88
88
  response: 'json',
89
89
  log: true,
@@ -29,7 +29,7 @@ Module.prototype.main = function () {
29
29
 
30
30
  // Signout of all sessions
31
31
  assistant.log(`Signout of all sessions...`);
32
- await fetch(`${self.Manager.project.apiUrl}/backend-manager`, {
32
+ await fetch(`${self.Manager.getApiUrl()}/backend-manager`, {
33
33
  method: 'post',
34
34
  timeout: 60000,
35
35
  response: 'json',
@@ -40,7 +40,7 @@ Module.prototype.main = function () {
40
40
  : payload.data.payload.referrer
41
41
 
42
42
  payload.data.payload.serverUrl = typeof payload.data.payload.serverUrl === 'undefined'
43
- ? `${Manager.project.apiUrl}/backend-manager`
43
+ ? `${Manager.getApiUrl()}/backend-manager`
44
44
  : payload.data.payload.serverUrl
45
45
 
46
46
  payload.data.payload.provider = payload.data.payload.provider || '';
@@ -43,7 +43,9 @@ function Analytics(Manager, options) {
43
43
  // Fix options
44
44
  options.dataSource = options.dataSource || 'server';
45
45
  options.uuid = options.uuid || self.request.ip || Manager.SERVER_UUID;
46
- options.isDevelopment = typeof options.isDevelopment === 'undefined' ? self.assistant.isDevelopment() : options.isDevelopment;
46
+ // Skip real analytics in ANY non-production environment (development OR testing) so
47
+ // test/dev runs never pollute GA4. Intentional `!isProduction()` check.
48
+ options.isDevelopment = typeof options.isDevelopment === 'undefined' ? !self.assistant.isProduction() : options.isDevelopment;
47
49
  options.pageview = typeof options.pageview === 'undefined' ? true : options.pageview;
48
50
  options.version = options.version || Manager.package.version;
49
51
  options.userProperties = options.userProperties || {};