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
|
@@ -61,8 +61,10 @@ function mergeLineBasedFiles(existingContent, newContent, fileName) {
|
|
|
61
61
|
continue;
|
|
62
62
|
}
|
|
63
63
|
if (existingCustomKeys.has(key)) {
|
|
64
|
-
//
|
|
65
|
-
|
|
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
|
|
101
|
-
//
|
|
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
|
|
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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
26
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ``)
|
|
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: '' },
|
|
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: '' },
|
|
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: '' },
|
|
73
|
+
{ name: 'heading-2', content: '## Section' },
|
|
74
|
+
{ name: 'paragraph', content: 'Text.' },
|
|
75
|
+
{ name: 'image', content: '' },
|
|
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 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: '' },
|
|
116
|
+
{ name: 'paragraph', content: 'Text.' },
|
|
117
|
+
{ name: 'image', content: '' },
|
|
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
|
+
};
|