backend-manager 5.2.18 → 5.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/CLAUDE.md +6 -4
  3. package/bin/backend-manager +10 -1
  4. package/docs/admin-post-route.md +2 -0
  5. package/docs/ai-library.md +29 -2
  6. package/docs/cli-output.md +122 -0
  7. package/docs/common-mistakes.md +1 -1
  8. package/docs/environment-detection.md +87 -3
  9. package/docs/marketing-campaigns.md +1 -1
  10. package/docs/payment-system.md +1 -1
  11. package/docs/testing.md +64 -17
  12. package/package.json +1 -1
  13. package/src/cli/commands/base-command.js +4 -0
  14. package/src/cli/commands/install.js +2 -2
  15. package/src/cli/commands/setup-tests/bem-config.js +22 -9
  16. package/src/cli/commands/setup-tests/helpers/merge-line-files.js +22 -168
  17. package/src/cli/commands/setup.js +65 -33
  18. package/src/cli/commands/test.js +1 -1
  19. package/src/cli/index.js +72 -39
  20. package/src/cli/utils/ui.js +270 -0
  21. package/src/defaults/CLAUDE.md +2 -2
  22. package/src/defaults/test/_init.js +14 -0
  23. package/src/manager/functions/_legacy/actions/create-post-handler.js +1 -1
  24. package/src/manager/functions/core/actions/api/admin/edit-post.js +1 -1
  25. package/src/manager/functions/core/actions/api/general/send-email.js +1 -1
  26. package/src/manager/functions/core/actions/api/user/delete.js +1 -1
  27. package/src/manager/functions/core/actions/api/user/oauth2.js +1 -1
  28. package/src/manager/helpers/analytics.js +3 -1
  29. package/src/manager/helpers/assistant.js +43 -38
  30. package/src/manager/index.js +76 -12
  31. package/src/manager/libraries/ai/index.js +33 -0
  32. package/src/manager/libraries/ai/providers/openai.js +105 -8
  33. package/src/manager/libraries/content/ghostii.js +76 -16
  34. package/src/manager/libraries/email/data/disposable-domains.json +24 -0
  35. package/src/manager/libraries/email/generators/lib/image-illustrator.js +154 -0
  36. package/src/manager/libraries/email/generators/newsletter.js +16 -2
  37. package/src/manager/libraries/payment/discount-codes.js +1 -0
  38. package/src/manager/routes/admin/post/put.js +1 -1
  39. package/src/manager/routes/handler/post/post.js +1 -1
  40. package/src/manager/routes/payments/intent/processors/test.js +1 -1
  41. package/src/manager/routes/user/delete.js +1 -1
  42. package/src/manager/routes/user/signup/post.js +4 -2
  43. package/src/test/runner.js +142 -20
  44. package/src/test/test-accounts.js +159 -102
  45. package/src/utils/merge-line-files.js +16 -5
  46. package/templates/_.env +1 -0
  47. package/test/events/payments/journey-payments-cancel-endpoint.js +6 -2
  48. package/test/events/payments/journey-payments-cancel.js +6 -3
  49. package/test/events/payments/journey-payments-failure.js +6 -3
  50. package/test/events/payments/journey-payments-plan-change.js +6 -3
  51. package/test/events/payments/journey-payments-refund-webhook.js +6 -2
  52. package/test/events/payments/journey-payments-suspend.js +6 -3
  53. package/test/events/payments/journey-payments-trial-cancel.js +6 -3
  54. package/test/events/payments/journey-payments-trial.js +10 -4
  55. package/test/events/payments/journey-payments-uid-resolution.js +6 -2
  56. package/test/events/payments/journey-payments-upgrade.js +6 -3
  57. package/test/helpers/content/ghostii-blocks.js +134 -0
  58. package/test/helpers/environment.js +230 -0
  59. package/test/helpers/merge-line-files.js +271 -0
  60. package/test/routes/marketing/webhook-forward.js +14 -7
  61. package/test/routes/payments/cancel.js +4 -1
  62. package/test/routes/payments/dispute-alert.js +9 -6
  63. package/test/routes/payments/intent.js +55 -14
  64. package/test/routes/payments/portal.js +4 -1
  65. package/test/routes/payments/refund.js +4 -1
@@ -61,8 +61,10 @@ function mergeLineBasedFiles(existingContent, newContent, fileName) {
61
61
  continue;
62
62
  }
63
63
  if (existingCustomKeys.has(key)) {
64
- // Key the user moved to custom leave it in custom; emit empty default value.
65
- emit(line);
64
+ // The framework newly adopted a key the user had in their Custom section.
65
+ // Promote it UP into Default with the user's value, and drop the Custom copy
66
+ // (handled below) so the key isn't duplicated / left empty.
67
+ emit(findKeyLine(existingCustom, key));
66
68
  continue;
67
69
  }
68
70
  }
@@ -97,10 +99,19 @@ function mergeLineBasedFiles(existingContent, newContent, fileName) {
97
99
  }
98
100
  }
99
101
 
100
- // The user's Custom section is preserved verbatim — except .env values get
101
- // normalized to double-quoted form so the file's quoting style is consistent.
102
+ // The user's Custom section is preserved — except (a) .env values get normalized
103
+ // to double-quoted form for consistent quoting, and (b) any key the framework just
104
+ // adopted into its Default section is dropped here, since it was promoted UP into
105
+ // Default above (otherwise the key would appear in both sections).
102
106
  const finalCustom = isEnvFile
103
- ? existingCustom.map((line) => normalizeEnvLine(line))
107
+ ? existingCustom
108
+ .filter((line) => {
109
+ const trimmed = line.trim();
110
+ if (!trimmed || trimmed.startsWith('#')) return true;
111
+ const key = trimmed.split('=')[0].trim();
112
+ return !(key && newDefaultKeys.has(key));
113
+ })
114
+ .map((line) => normalizeEnvLine(line))
104
115
  : existingCustom;
105
116
 
106
117
  const result = [];
package/templates/_.env CHANGED
@@ -34,6 +34,7 @@ SENDGRID_API_KEY=""
34
34
  BEEHIIV_API_KEY=""
35
35
  ZEROBOUNCE_API_KEY=""
36
36
  UNSUBSCRIBE_HMAC_KEY=""
37
+ APOLLO_API_KEY=""
37
38
 
38
39
  # ========== Custom Values ==========
39
40
  # Add your custom environment variables below this line
@@ -14,10 +14,14 @@ module.exports = {
14
14
  tests: [
15
15
  {
16
16
  name: 'setup-paid-subscription',
17
- async run({ accounts, firestore, assert, state, config, http, waitFor }) {
17
+ async run({ accounts, firestore, assert, state, config, http, waitFor, skip }) {
18
18
  const uid = accounts['journey-payments-cancel-route'].uid;
19
+ // Resolve first paid product from config. If the brand has none configured,
20
+ // skip the entire journey — this is a config-gap, not a code failure.
19
21
  const paidProduct = config.payment.products.find(p => p.id !== 'basic' && p.prices);
20
- assert.ok(paidProduct, 'Config should have at least one paid product');
22
+ if (!paidProduct) {
23
+ skip('No paid product configured in this brand');
24
+ }
21
25
 
22
26
  state.uid = uid;
23
27
  state.paidProductId = paidProduct.id;
@@ -13,12 +13,15 @@ module.exports = {
13
13
  tests: [
14
14
  {
15
15
  name: 'setup-paid-subscription',
16
- async run({ accounts, firestore, assert, state, config, http, waitFor }) {
16
+ async run({ accounts, firestore, assert, state, config, http, waitFor, skip }) {
17
17
  const uid = accounts['journey-payments-cancel'].uid;
18
18
 
19
- // Resolve first paid product from config
19
+ // Resolve first paid product from config. If the brand has none configured,
20
+ // skip the entire journey — this is a config-gap, not a code failure.
20
21
  const paidProduct = config.payment.products.find(p => p.id !== 'basic' && p.prices);
21
- assert.ok(paidProduct, 'Config should have at least one paid product');
22
+ if (!paidProduct) {
23
+ skip('No paid product configured in this brand');
24
+ }
22
25
 
23
26
  state.uid = uid;
24
27
  state.paidProductId = paidProduct.id;
@@ -16,12 +16,15 @@ module.exports = {
16
16
  tests: [
17
17
  {
18
18
  name: 'setup-paid-subscription',
19
- async run({ accounts, firestore, assert, state, config, http, waitFor }) {
19
+ async run({ accounts, firestore, assert, state, config, http, waitFor, skip }) {
20
20
  const uid = accounts['journey-payments-failure'].uid;
21
21
 
22
- // Resolve first paid subscription product
22
+ // Resolve first paid subscription product. If the brand has none configured,
23
+ // skip the entire journey — this is a config-gap, not a code failure.
23
24
  const paidProduct = config.payment.products.find(p => p.id !== 'basic' && p.type === 'subscription' && p.prices);
24
- assert.ok(paidProduct, 'Config should have at least one paid subscription product');
25
+ if (!paidProduct) {
26
+ skip('No paid subscription product configured in this brand');
27
+ }
25
28
 
26
29
  state.uid = uid;
27
30
  state.paidProductId = paidProduct.id;
@@ -13,12 +13,15 @@ module.exports = {
13
13
  tests: [
14
14
  {
15
15
  name: 'setup-paid-subscription',
16
- async run({ accounts, firestore, assert, state, config, http, waitFor }) {
16
+ async run({ accounts, firestore, assert, state, config, http, waitFor, skip }) {
17
17
  const uid = accounts['journey-payments-plan-change'].uid;
18
18
 
19
- // Resolve two distinct paid subscription products from config
19
+ // Resolve two distinct paid subscription products from config. If the brand
20
+ // has fewer than two, skip the journey — this is a config-gap, not a code failure.
20
21
  const paidProducts = config.payment.products.filter(p => p.id !== 'basic' && p.type === 'subscription' && p.prices);
21
- assert.ok(paidProducts.length >= 2, 'Config should have at least two paid subscription products');
22
+ if (paidProducts.length < 2) {
23
+ skip('Fewer than two paid subscription products configured in this brand');
24
+ }
22
25
 
23
26
  const productA = paidProducts[0];
24
27
  const productB = paidProducts[1];
@@ -18,11 +18,15 @@ module.exports = {
18
18
  tests: [
19
19
  {
20
20
  name: 'setup-paid-subscription',
21
- async run({ accounts, firestore, assert, state, config, http, waitFor }) {
21
+ async run({ accounts, firestore, assert, state, config, http, waitFor, skip }) {
22
22
  const uid = accounts['journey-payments-refund-webhook'].uid;
23
23
 
24
+ // Resolve a paid product with a monthly price. If the brand has none configured,
25
+ // skip the entire journey — this is a config-gap, not a code failure.
24
26
  const paidProduct = config.payment.products.find(p => p.id !== 'basic' && p.prices?.monthly);
25
- assert.ok(paidProduct, 'Config should have at least one paid product with monthly price');
27
+ if (!paidProduct) {
28
+ skip('No paid product with monthly price configured in this brand');
29
+ }
26
30
 
27
31
  state.uid = uid;
28
32
  state.paidProductId = paidProduct.id;
@@ -13,12 +13,15 @@ module.exports = {
13
13
  tests: [
14
14
  {
15
15
  name: 'setup-paid-subscription',
16
- async run({ accounts, firestore, assert, state, config, http, waitFor }) {
16
+ async run({ accounts, firestore, assert, state, config, http, waitFor, skip }) {
17
17
  const uid = accounts['journey-payments-suspend'].uid;
18
18
 
19
- // Resolve first paid product from config
19
+ // Resolve first paid product from config. If the brand has none configured,
20
+ // skip the entire journey — this is a config-gap, not a code failure.
20
21
  const paidProduct = config.payment.products.find(p => p.id !== 'basic' && p.prices);
21
- assert.ok(paidProduct, 'Config should have at least one paid product');
22
+ if (!paidProduct) {
23
+ skip('No paid product configured in this brand');
24
+ }
22
25
 
23
26
  state.uid = uid;
24
27
  state.paidProductId = paidProduct.id;
@@ -16,7 +16,7 @@ module.exports = {
16
16
  tests: [
17
17
  {
18
18
  name: 'verify-starts-as-basic',
19
- async run({ accounts, firestore, assert, state, config }) {
19
+ async run({ accounts, firestore, assert, state, config, skip }) {
20
20
  const uid = accounts['journey-payments-trial-cancel'].uid;
21
21
  const userDoc = await firestore.get(`users/${uid}`);
22
22
 
@@ -24,9 +24,12 @@ module.exports = {
24
24
  assert.equal(userDoc.subscription?.product?.id, 'basic', 'Should start as basic');
25
25
  assert.equal(userDoc.subscription?.trial?.claimed, false, 'Trial should not be claimed');
26
26
 
27
- // Resolve first paid product with trial from config
27
+ // Resolve first paid product with trial from config. If the brand has none
28
+ // configured, skip the entire journey — this is a config-gap, not a code failure.
28
29
  const trialProduct = config.payment.products.find(p => p.id !== 'basic' && p.prices && p.trial?.days);
29
- assert.ok(trialProduct, 'Config should have at least one paid product with trial');
30
+ if (!trialProduct) {
31
+ skip('No paid product with trial configured in this brand');
32
+ }
30
33
 
31
34
  state.uid = uid;
32
35
  state.paidProductId = trialProduct.id;
@@ -13,7 +13,7 @@ module.exports = {
13
13
  tests: [
14
14
  {
15
15
  name: 'verify-starts-as-basic',
16
- async run({ accounts, firestore, assert, state, config }) {
16
+ async run({ accounts, firestore, assert, state, config, skip }) {
17
17
  const uid = accounts['journey-payments-trial'].uid;
18
18
  const userDoc = await firestore.get(`users/${uid}`);
19
19
 
@@ -21,9 +21,15 @@ module.exports = {
21
21
  assert.equal(userDoc.subscription?.product?.id, 'basic', 'Should start as basic');
22
22
  assert.equal(userDoc.subscription?.trial?.claimed, false, 'Trial should not be claimed');
23
23
 
24
- // Resolve first paid product from config
25
- const paidProduct = config.payment.products.find(p => p.id !== 'basic' && p.prices);
26
- assert.ok(paidProduct, 'Config should have at least one paid product');
24
+ // Resolve first paid product WITH a trial from config. The test processor
25
+ // only creates a trialing subscription when product.trial.days > 0, so a
26
+ // brand whose paid products all have trial.days: 0 (e.g. trials disabled)
27
+ // has nothing to exercise here — skip the journey. This is a config-gap,
28
+ // not a code failure, and mirrors journey-payments-trial-cancel.js.
29
+ const paidProduct = config.payment.products.find(p => p.id !== 'basic' && p.prices && p.trial?.days);
30
+ if (!paidProduct) {
31
+ skip('No paid product with trial configured in this brand');
32
+ }
27
33
 
28
34
  state.uid = uid;
29
35
  state.paidProductId = paidProduct.id;
@@ -21,11 +21,15 @@ module.exports = {
21
21
  tests: [
22
22
  {
23
23
  name: 'setup-paid-subscription',
24
- async run({ accounts, firestore, assert, state, config, http, waitFor }) {
24
+ async run({ accounts, firestore, assert, state, config, http, waitFor, skip }) {
25
25
  const uid = accounts['journey-payments-uid-resolution'].uid;
26
26
 
27
+ // Resolve a paid product with a monthly price. If the brand has none configured,
28
+ // skip the entire journey — this is a config-gap, not a code failure.
27
29
  const paidProduct = config.payment.products.find(p => p.id !== 'basic' && p.prices?.monthly);
28
- assert.ok(paidProduct, 'Config should have at least one paid product with monthly price');
30
+ if (!paidProduct) {
31
+ skip('No paid product with monthly price configured in this brand');
32
+ }
29
33
 
30
34
  state.uid = uid;
31
35
  state.paidProductId = paidProduct.id;
@@ -13,7 +13,7 @@ module.exports = {
13
13
  tests: [
14
14
  {
15
15
  name: 'verify-starts-as-basic',
16
- async run({ accounts, firestore, assert, state, config }) {
16
+ async run({ accounts, firestore, assert, state, config, skip }) {
17
17
  const uid = accounts['journey-payments-upgrade'].uid;
18
18
  const userDoc = await firestore.get(`users/${uid}`);
19
19
 
@@ -21,9 +21,12 @@ module.exports = {
21
21
  assert.equal(userDoc.subscription?.product?.id, 'basic', 'Should start as basic');
22
22
  assert.equal(userDoc.subscription?.status, 'active', 'Should be active');
23
23
 
24
- // Resolve first paid product from config
24
+ // Resolve first paid product from config. If the brand has none configured,
25
+ // skip the entire journey — this is a config-gap, not a code failure.
25
26
  const paidProduct = config.payment.products.find(p => p.id !== 'basic' && p.prices);
26
- assert.ok(paidProduct, 'Config should have at least one paid product');
27
+ if (!paidProduct) {
28
+ skip('No paid product configured in this brand');
29
+ }
27
30
 
28
31
  state.uid = uid;
29
32
  state.paidProductId = paidProduct.id;
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Test: content/ghostii.blocksToPost()
3
+ * Unit tests for the Ghostii-JSON → BEM-post transform.
4
+ *
5
+ * Run: npx mgr test helpers/content/ghostii-blocks
6
+ *
7
+ * Ghostii is unopinionated about BEM: its /write/article response is a generic
8
+ * block array ([{ name, content }], name ∈ heading-1..6/image/paragraph/blockquote/list).
9
+ * blocksToPost() is the SSOT that turns those blocks into what admin/post wants —
10
+ * title + header image as SEPARATE fields, body = content only.
11
+ *
12
+ * Pure function (no I/O) → required directly and called with plain inputs. NOT a mock.
13
+ *
14
+ * Contract:
15
+ * - title ← first heading-1 block, leading markdown `#` stripped
16
+ * - headerImageUrl ← first image block's URL (from `![alt](url)`)
17
+ * - body ← every remaining block, joined with blank lines, trimmed
18
+ * - the title block and the header-image block are EXCLUDED from body
19
+ * - section images (non-first image blocks) STAY in the body
20
+ * - non-array / empty input → all empty strings
21
+ */
22
+ const { blocksToPost } = require('../../../src/manager/libraries/content/ghostii.js');
23
+
24
+ module.exports = {
25
+ description: 'content/ghostii.blocksToPost()',
26
+ type: 'group',
27
+
28
+ tests: [
29
+ {
30
+ name: 'extracts-title-from-heading-1-stripping-marker',
31
+ async run({ assert }) {
32
+ const result = blocksToPost([
33
+ { name: 'heading-1', content: '# My Great Article' },
34
+ { name: 'paragraph', content: 'Body.' },
35
+ ]);
36
+ assert.equal(result.title, 'My Great Article');
37
+ },
38
+ },
39
+
40
+ {
41
+ name: 'extracts-header-image-url-from-first-image-block',
42
+ async run({ assert }) {
43
+ const result = blocksToPost([
44
+ { name: 'heading-1', content: '# Title' },
45
+ { name: 'image', content: '![Title](https://images.unsplash.com/photo-abc?w=1080)' },
46
+ { name: 'paragraph', content: 'Body.' },
47
+ ]);
48
+ assert.equal(result.headerImageUrl, 'https://images.unsplash.com/photo-abc?w=1080');
49
+ },
50
+ },
51
+
52
+ {
53
+ name: 'body-excludes-title-and-header-image',
54
+ async run({ assert }) {
55
+ const result = blocksToPost([
56
+ { name: 'heading-1', content: '# Title' },
57
+ { name: 'image', content: '![Title](https://img/header.jpg)' },
58
+ { name: 'heading-2', content: '## First Section' },
59
+ { name: 'paragraph', content: 'First paragraph.' },
60
+ ]);
61
+ assert.equal(result.body, '## First Section\n\nFirst paragraph.', 'body is content only');
62
+ assert.notMatch(result.body, /^#\s/m, 'no H1 title in body');
63
+ assert.notMatch(result.body, /^!\[Title\]\(https:\/\/img\/header\.jpg\)/m, 'no header image in body');
64
+ },
65
+ },
66
+
67
+ {
68
+ name: 'keeps-section-images-in-body',
69
+ async run({ assert }) {
70
+ const result = blocksToPost([
71
+ { name: 'heading-1', content: '# Title' },
72
+ { name: 'image', content: '![Title](https://img/header.jpg)' },
73
+ { name: 'heading-2', content: '## Section' },
74
+ { name: 'paragraph', content: 'Text.' },
75
+ { name: 'image', content: '![Section](https://img/section.jpg)' },
76
+ { name: 'paragraph', content: 'More text.' },
77
+ ]);
78
+ // Only the FIRST image is the header; later images belong to the body.
79
+ assert.ok(result.body.includes('![Section](https://img/section.jpg)'), 'section image stays in body');
80
+ assert.equal((result.body.match(/!\[/g) || []).length, 1, 'exactly one (section) image in body');
81
+ },
82
+ },
83
+
84
+ {
85
+ name: 'preserves-blockquotes-and-lists-in-body',
86
+ async run({ assert }) {
87
+ const result = blocksToPost([
88
+ { name: 'heading-1', content: '# Title' },
89
+ { name: 'heading-2', content: '## Section' },
90
+ { name: 'blockquote', content: '> A pithy quote.' },
91
+ { name: 'list', content: '- one\n- two' },
92
+ ]);
93
+ assert.ok(result.body.includes('> A pithy quote.'), 'blockquote kept');
94
+ assert.ok(result.body.includes('- one\n- two'), 'list kept');
95
+ },
96
+ },
97
+
98
+ {
99
+ name: 'handles-missing-heading-1-and-image-gracefully',
100
+ async run({ assert }) {
101
+ const result = blocksToPost([
102
+ { name: 'heading-2', content: '## Only Section' },
103
+ { name: 'paragraph', content: 'Text.' },
104
+ ]);
105
+ assert.equal(result.title, '', 'no title when no heading-1');
106
+ assert.equal(result.headerImageUrl, '', 'no header image when no image block');
107
+ assert.equal(result.body, '## Only Section\n\nText.', 'body is all blocks when nothing excluded');
108
+ },
109
+ },
110
+
111
+ {
112
+ name: 'uses-only-the-first-image-as-header',
113
+ async run({ assert }) {
114
+ const result = blocksToPost([
115
+ { name: 'image', content: '![first](https://img/first.jpg)' },
116
+ { name: 'paragraph', content: 'Text.' },
117
+ { name: 'image', content: '![second](https://img/second.jpg)' },
118
+ ]);
119
+ assert.equal(result.headerImageUrl, 'https://img/first.jpg', 'first image is the header');
120
+ assert.ok(result.body.includes('https://img/second.jpg'), 'second image stays in body');
121
+ assert.ok(!result.body.includes('https://img/first.jpg'), 'first image not in body');
122
+ },
123
+ },
124
+
125
+ {
126
+ name: 'non-array-or-empty-input-returns-empty-strings',
127
+ async run({ assert }) {
128
+ assert.deepEqual(blocksToPost([]), { title: '', headerImageUrl: '', body: '' }, 'empty array');
129
+ assert.deepEqual(blocksToPost(null), { title: '', headerImageUrl: '', body: '' }, 'null');
130
+ assert.deepEqual(blocksToPost(undefined), { title: '', headerImageUrl: '', body: '' }, 'undefined');
131
+ },
132
+ },
133
+ ],
134
+ };
@@ -0,0 +1,230 @@
1
+ /**
2
+ * Test: environment detection + URL helpers
3
+ * Covers the Manager's getEnvironment() SSOT, the derived is*() checks, the URL
4
+ * builders (getApiUrl / getFunctionsUrl / getWebsiteUrl + parent variants), and the
5
+ * assistant→Manager forwarding.
6
+ *
7
+ * Run: npx mgr test bem:helpers/environment
8
+ *
9
+ * Contract (see docs/environment-detection.md):
10
+ * - getEnvironment() is the SINGLE SOURCE OF TRUTH — the only reader of the raw env
11
+ * vars (BEM_TESTING / ENVIRONMENT / FUNCTIONS_EMULATOR / TERM_PROGRAM). Returns
12
+ * exactly ONE of 'development' | 'testing' | 'production' (testing wins).
13
+ * - isDevelopment()/isProduction()/isTesting() DERIVE from getEnvironment() — they
14
+ * never read raw signals, so they can NEVER disagree with it. Exactly one is true.
15
+ * - getApiUrl/getFunctionsUrl/getWebsiteUrl resolve LOCAL in dev OR testing, prod
16
+ * otherwise. The parent helpers ALWAYS return the live URL (no localhost).
17
+ * - The assistant forwards each method to its Manager (identical results).
18
+ */
19
+
20
+ // Run a thunk with the env-detection vars cleared, restoring them afterward. These are
21
+ // the only inputs getEnvironment() reads, so clearing them gives a clean slate per case.
22
+ function withEnv(overrides, fn) {
23
+ const KEYS = ['BEM_TESTING', 'ENVIRONMENT', 'FUNCTIONS_EMULATOR', 'TERM_PROGRAM'];
24
+ const saved = {};
25
+ for (const k of KEYS) saved[k] = process.env[k];
26
+ try {
27
+ for (const k of KEYS) delete process.env[k];
28
+ for (const k of Object.keys(overrides)) process.env[k] = overrides[k];
29
+ return fn();
30
+ } finally {
31
+ for (const k of KEYS) {
32
+ if (saved[k] === undefined) delete process.env[k];
33
+ else process.env[k] = saved[k];
34
+ }
35
+ }
36
+ }
37
+
38
+ module.exports = {
39
+ description: 'Environment detection + URL helpers',
40
+ type: 'group',
41
+
42
+ tests: [
43
+ // ─── getEnvironment() resolution + precedence ───
44
+
45
+ {
46
+ name: 'getEnvironment: testing wins over everything (BEM_TESTING=true)',
47
+ async run({ Manager, assert }) {
48
+ withEnv({ BEM_TESTING: 'true', ENVIRONMENT: 'production' }, () => {
49
+ assert.equal(Manager.getEnvironment(), 'testing');
50
+ });
51
+ },
52
+ },
53
+ {
54
+ name: 'getEnvironment: production when ENVIRONMENT=production (not testing)',
55
+ async run({ Manager, assert }) {
56
+ withEnv({ ENVIRONMENT: 'production' }, () => {
57
+ assert.equal(Manager.getEnvironment(), 'production');
58
+ });
59
+ },
60
+ },
61
+ {
62
+ name: 'getEnvironment: development when ENVIRONMENT=development',
63
+ async run({ Manager, assert }) {
64
+ withEnv({ ENVIRONMENT: 'development' }, () => {
65
+ assert.equal(Manager.getEnvironment(), 'development');
66
+ });
67
+ },
68
+ },
69
+ {
70
+ name: 'getEnvironment: development when FUNCTIONS_EMULATOR is set',
71
+ async run({ Manager, assert }) {
72
+ withEnv({ FUNCTIONS_EMULATOR: 'true' }, () => {
73
+ assert.equal(Manager.getEnvironment(), 'development');
74
+ });
75
+ },
76
+ },
77
+ {
78
+ name: 'getEnvironment: defaults to production with no signal (deployed function)',
79
+ async run({ Manager, assert }) {
80
+ withEnv({}, () => {
81
+ assert.equal(Manager.getEnvironment(), 'production');
82
+ });
83
+ },
84
+ },
85
+
86
+ // ─── The KEY invariant: is*() derive from getEnvironment() and can NEVER disagree ───
87
+
88
+ {
89
+ name: 'invariant: is*() exactly matches getEnvironment() across every scenario',
90
+ async run({ Manager, assert }) {
91
+ const scenarios = [
92
+ { env: { BEM_TESTING: 'true', ENVIRONMENT: 'production' }, expect: 'testing' },
93
+ { env: { ENVIRONMENT: 'production' }, expect: 'production' },
94
+ { env: { ENVIRONMENT: 'development' }, expect: 'development' },
95
+ { env: { FUNCTIONS_EMULATOR: 'true' }, expect: 'development' },
96
+ { env: {}, expect: 'production' },
97
+ ];
98
+ for (const s of scenarios) {
99
+ withEnv(s.env, () => {
100
+ const e = Manager.getEnvironment();
101
+ assert.equal(e, s.expect, `getEnvironment for ${JSON.stringify(s.env)}`);
102
+ // Each is*() must equal (getEnvironment() === its value) — no independent reads.
103
+ assert.equal(Manager.isDevelopment(), e === 'development', `isDevelopment for ${e}`);
104
+ assert.equal(Manager.isTesting(), e === 'testing', `isTesting for ${e}`);
105
+ assert.equal(Manager.isProduction(), e === 'production', `isProduction for ${e}`);
106
+ });
107
+ }
108
+ },
109
+ },
110
+ {
111
+ name: 'invariant: exactly one of is*() is true in every scenario (mutually exclusive)',
112
+ async run({ Manager, assert }) {
113
+ const envs = [
114
+ { BEM_TESTING: 'true' },
115
+ { ENVIRONMENT: 'production' },
116
+ { ENVIRONMENT: 'development' },
117
+ { FUNCTIONS_EMULATOR: 'true' },
118
+ {},
119
+ ];
120
+ for (const env of envs) {
121
+ withEnv(env, () => {
122
+ const trueCount = [Manager.isDevelopment(), Manager.isTesting(), Manager.isProduction()]
123
+ .filter(Boolean).length;
124
+ assert.equal(trueCount, 1, `exactly one true for ${JSON.stringify(env)}`);
125
+ });
126
+ }
127
+ },
128
+ },
129
+ {
130
+ name: 'isProduction is a real positive check (NOT just !isDevelopment) — false in testing',
131
+ async run({ Manager, assert }) {
132
+ withEnv({ BEM_TESTING: 'true' }, () => {
133
+ assert.equal(Manager.isDevelopment(), false, 'isDevelopment false in testing');
134
+ assert.equal(Manager.isProduction(), false, 'isProduction false in testing');
135
+ assert.equal(Manager.isTesting(), true, 'isTesting true in testing');
136
+ });
137
+ },
138
+ },
139
+
140
+ // ─── assistant forwards to the Manager (identical results) ───
141
+
142
+ {
143
+ name: 'assistant forwards getEnvironment()/is*() to the Manager (identical)',
144
+ async run({ Manager, assistant, assert }) {
145
+ const cases = [
146
+ { BEM_TESTING: 'true' },
147
+ { ENVIRONMENT: 'production' },
148
+ { ENVIRONMENT: 'development' },
149
+ ];
150
+ for (const env of cases) {
151
+ withEnv(env, () => {
152
+ assert.equal(assistant.getEnvironment(), Manager.getEnvironment(), 'getEnvironment forward');
153
+ assert.equal(assistant.isDevelopment(), Manager.isDevelopment(), 'isDevelopment forward');
154
+ assert.equal(assistant.isTesting(), Manager.isTesting(), 'isTesting forward');
155
+ assert.equal(assistant.isProduction(), Manager.isProduction(), 'isProduction forward');
156
+ });
157
+ }
158
+ },
159
+ },
160
+
161
+ // ─── URL helpers: local in dev/testing, production otherwise ───
162
+
163
+ {
164
+ name: 'getApiUrl: localhost in development AND testing, prod otherwise',
165
+ async run({ Manager, assert }) {
166
+ withEnv({ FUNCTIONS_EMULATOR: 'true' }, () => {
167
+ assert.equal(Manager.getApiUrl(), 'http://localhost:5002', 'dev → localhost');
168
+ });
169
+ withEnv({ BEM_TESTING: 'true' }, () => {
170
+ assert.equal(Manager.getApiUrl(), 'http://localhost:5002', 'testing → localhost');
171
+ });
172
+ withEnv({ ENVIRONMENT: 'production' }, () => {
173
+ assert.match(Manager.getApiUrl(), /^https:\/\/api\./, 'prod → api.<domain>');
174
+ });
175
+ },
176
+ },
177
+ {
178
+ name: 'getApiUrl: explicit env arg overrides current environment',
179
+ async run({ Manager, assert }) {
180
+ // Under the test harness we're in 'testing', but an explicit arg forces the mapping.
181
+ assert.equal(Manager.getApiUrl('development'), 'http://localhost:5002', "arg 'development' → localhost");
182
+ assert.match(Manager.getApiUrl('production'), /^https:\/\/api\./, "arg 'production' → prod");
183
+ },
184
+ },
185
+ {
186
+ name: 'getFunctionsUrl: localhost in development AND testing, cloudfunctions otherwise',
187
+ async run({ Manager, assert }) {
188
+ withEnv({ FUNCTIONS_EMULATOR: 'true' }, () => {
189
+ assert.match(Manager.getFunctionsUrl(), /^http:\/\/localhost:5001\//, 'dev → localhost:5001');
190
+ });
191
+ withEnv({ BEM_TESTING: 'true' }, () => {
192
+ assert.match(Manager.getFunctionsUrl(), /^http:\/\/localhost:5001\//, 'testing → localhost:5001');
193
+ });
194
+ withEnv({ ENVIRONMENT: 'production' }, () => {
195
+ assert.match(Manager.getFunctionsUrl(), /cloudfunctions\.net$/, 'prod → cloudfunctions.net');
196
+ });
197
+ },
198
+ },
199
+ {
200
+ name: 'getWebsiteUrl: localhost:4000 in development AND testing, brand.url otherwise',
201
+ async run({ Manager, assert }) {
202
+ withEnv({ FUNCTIONS_EMULATOR: 'true' }, () => {
203
+ assert.equal(Manager.getWebsiteUrl(), 'https://localhost:4000', 'dev → localhost:4000');
204
+ });
205
+ withEnv({ BEM_TESTING: 'true' }, () => {
206
+ assert.equal(Manager.getWebsiteUrl(), 'https://localhost:4000', 'testing → localhost:4000');
207
+ });
208
+ withEnv({ ENVIRONMENT: 'production' }, () => {
209
+ const url = Manager.getWebsiteUrl();
210
+ assert.equal(url.startsWith('https://localhost'), false, 'prod → NOT localhost');
211
+ });
212
+ },
213
+ },
214
+
215
+ // ─── Parent helpers ALWAYS resolve live (never localhost), even in dev/testing ───
216
+
217
+ {
218
+ name: 'getParentApiUrl / getParentUrl never redirect to localhost (always live)',
219
+ async run({ Manager, assert }) {
220
+ // Even under the test harness ('testing'), the parent is a real remote server.
221
+ const parentUrl = Manager.getParentUrl();
222
+ const parentApi = Manager.getParentApiUrl();
223
+ assert.equal((parentUrl || '').includes('localhost'), false, 'getParentUrl not localhost');
224
+ assert.equal((parentApi || '').includes('localhost'), false, 'getParentApiUrl not localhost');
225
+ // When set, the parent API URL carries the api. subdomain.
226
+ if (parentApi) assert.match(parentApi, /^https:\/\/api\./, 'parent api uses api. subdomain');
227
+ },
228
+ },
229
+ ],
230
+ };