backend-manager 5.1.2 → 5.2.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/.claude/settings.local.json +12 -0
- package/CHANGELOG.md +52 -0
- package/CLAUDE.md +2 -1
- package/README.md +30 -0
- package/docs/common-mistakes.md +1 -0
- package/docs/consent.md +333 -0
- package/docs/marketing-campaigns.md +41 -4
- package/docs/testing.md +81 -0
- package/package.json +1 -1
- package/src/cli/commands/emulator.js +62 -9
- package/src/cli/commands/serve.js +73 -7
- package/src/cli/commands/test.js +65 -1
- package/src/cli/commands/watch.js +15 -3
- package/src/defaults/CLAUDE.md +7 -5
- package/src/manager/events/cron/daily/marketing-newsletter-generate.js +24 -8
- package/src/manager/helpers/user.js +29 -0
- package/src/manager/index.js +111 -5
- package/src/manager/libraries/ai/index.js +21 -0
- package/src/manager/libraries/ai/providers/openai.js +75 -0
- package/src/manager/libraries/email/data/disposable-domains.json +20 -0
- package/src/manager/libraries/email/generators/lib/image-host.js +58 -6
- package/src/manager/libraries/email/generators/lib/markdown-renderer.js +285 -0
- package/src/manager/libraries/email/generators/lib/structure.js +19 -2
- package/src/manager/libraries/email/generators/lib/svg-illustrator.js +12 -3
- package/src/manager/libraries/email/generators/lib/templates/classic-schema.js +9 -13
- package/src/manager/libraries/email/generators/lib/templates/clean.js +1 -1
- package/src/manager/libraries/email/generators/lib/templates/editorial.js +1 -1
- package/src/manager/libraries/email/generators/lib/templates/field-report.js +10 -14
- package/src/manager/libraries/email/generators/newsletter.js +154 -7
- package/src/manager/libraries/email/providers/beehiiv.js +8 -1
- package/src/manager/libraries/payment/processors/stripe.js +12 -0
- package/src/manager/libraries/payment/processors/test.js +8 -1
- package/src/manager/routes/admin/infer-contact/post.js +3 -2
- package/src/manager/routes/admin/post/post.js +3 -3
- package/src/manager/routes/marketing/email-preferences/post.js +165 -37
- package/src/manager/routes/marketing/webhook/forward/post.js +168 -0
- package/src/manager/routes/marketing/webhook/post.js +180 -0
- package/src/manager/routes/marketing/webhook/processors/beehiiv.js +196 -0
- package/src/manager/routes/marketing/webhook/processors/sendgrid.js +171 -0
- package/src/manager/routes/payments/cancel/post.js +2 -2
- package/src/manager/routes/payments/cancel/processors/test.js +5 -2
- package/src/manager/routes/payments/intent/processors/test.js +7 -3
- package/src/manager/routes/payments/refund/processors/test.js +4 -1
- package/src/manager/routes/test/health/get.js +17 -0
- package/src/manager/routes/user/signup/post.js +65 -1
- package/src/manager/schemas/marketing/email-preferences/post.js +8 -4
- package/src/manager/schemas/marketing/webhook/forward/post.js +8 -0
- package/src/manager/schemas/marketing/webhook/post.js +7 -0
- package/src/manager/schemas/payments/cancel/post.js +5 -0
- package/src/manager/schemas/user/signup/post.js +5 -0
- package/src/test/run-tests.js +30 -0
- package/src/test/runner.js +72 -26
- package/src/test/test-accounts.js +94 -12
- package/src/test/utils/http-client.js +4 -3
- package/src/test/utils/test-mode-file.js +192 -0
- package/test/events/payments/journey-payments-cancel-endpoint.js +3 -12
- package/test/events/payments/journey-payments-cancel.js +4 -5
- package/test/events/payments/journey-payments-failure.js +0 -1
- package/test/events/payments/journey-payments-one-time-failure.js +6 -3
- package/test/events/payments/journey-payments-one-time.js +6 -3
- package/test/events/payments/journey-payments-plan-change.js +5 -5
- package/test/events/payments/journey-payments-refund-webhook.js +2 -3
- package/test/events/payments/journey-payments-suspend.js +4 -5
- package/test/events/payments/journey-payments-trial-cancel.js +3 -12
- package/test/events/payments/journey-payments-trial.js +2 -3
- package/test/events/payments/journey-payments-uid-resolution.js +2 -3
- package/test/functions/admin/database-read.js +0 -14
- package/test/functions/admin/database-write.js +0 -14
- package/test/functions/admin/firestore-query.js +0 -14
- package/test/functions/admin/firestore-read.js +0 -15
- package/test/functions/admin/firestore-write.js +0 -11
- package/test/functions/general/add-marketing-contact.js +16 -14
- package/test/helpers/email.js +1 -1
- package/test/helpers/infer-contact.js +3 -3
- package/test/helpers/user.js +241 -2
- package/test/helpers/webhook-forward.js +392 -0
- package/test/marketing/fixtures/clean.json +2 -3
- package/test/marketing/fixtures/editorial.json +2 -3
- package/test/marketing/fixtures/field-report.json +3 -4
- package/test/marketing/newsletter-generate.js +78 -54
- package/test/marketing/newsletter-templates.js +12 -33
- package/test/routes/admin/create-post.js +2 -2
- package/test/routes/admin/database.js +0 -13
- package/test/routes/admin/firestore-query.js +0 -13
- package/test/routes/admin/firestore.js +0 -14
- package/test/routes/admin/infer-contact.js +6 -3
- package/test/routes/admin/post.js +4 -2
- package/test/routes/marketing/contact.js +60 -26
- package/test/routes/marketing/email-preferences.js +145 -69
- package/test/routes/marketing/webhook-forward.js +54 -0
- package/test/routes/marketing/webhook.js +582 -0
- package/test/routes/payments/cancel.js +2 -7
- package/test/routes/payments/dispute-alert.js +0 -39
- package/test/routes/payments/refund.js +3 -1
- package/test/routes/payments/webhook.js +5 -26
- package/test/routes/test/usage.js +2 -2
- package/test/routes/user/signup.js +114 -0
|
@@ -162,6 +162,33 @@ const STATIC_ACCOUNTS = {
|
|
|
162
162
|
subscription: { product: { id: 'basic' }, status: 'active' },
|
|
163
163
|
},
|
|
164
164
|
},
|
|
165
|
+
'consent-granted': {
|
|
166
|
+
id: 'consent-granted',
|
|
167
|
+
uid: '_test-consent-granted',
|
|
168
|
+
email: '_test.consent-granted@{domain}',
|
|
169
|
+
properties: {
|
|
170
|
+
roles: {},
|
|
171
|
+
subscription: { product: { id: 'basic' }, status: 'active' },
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
'consent-declined': {
|
|
175
|
+
id: 'consent-declined',
|
|
176
|
+
uid: '_test-consent-declined',
|
|
177
|
+
email: '_test.consent-declined@{domain}',
|
|
178
|
+
properties: {
|
|
179
|
+
roles: {},
|
|
180
|
+
subscription: { product: { id: 'basic' }, status: 'active' },
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
'consent-missing': {
|
|
184
|
+
id: 'consent-missing',
|
|
185
|
+
uid: '_test-consent-missing',
|
|
186
|
+
email: '_test.consent-missing@{domain}',
|
|
187
|
+
properties: {
|
|
188
|
+
roles: {},
|
|
189
|
+
subscription: { product: { id: 'basic' }, status: 'active' },
|
|
190
|
+
},
|
|
191
|
+
},
|
|
165
192
|
};
|
|
166
193
|
|
|
167
194
|
/**
|
|
@@ -652,15 +679,53 @@ async function deleteTestUsers(admin) {
|
|
|
652
679
|
})
|
|
653
680
|
);
|
|
654
681
|
|
|
655
|
-
// Clean up payment-related collections for test accounts
|
|
682
|
+
// Clean up payment-related collections for test accounts.
|
|
683
|
+
// Two passes per collection:
|
|
684
|
+
// 1. owner-keyed: query by owner ∈ test uids (Firestore `in` caps at 30; batch).
|
|
685
|
+
// 2. id-keyed: delete any doc whose id starts with the `_test-` prefix.
|
|
686
|
+
// Pass 2 catches docs that have no owner field (e.g. dispute alerts, raw test webhooks).
|
|
687
|
+
// All test-fixture IDs MUST start with `_test-` — that's the cleanup contract.
|
|
656
688
|
const testUids = Object.values(TEST_ACCOUNTS).map(a => a.uid);
|
|
657
|
-
|
|
689
|
+
// Collections that may carry test data tied to a test user (owner-keyed) or
|
|
690
|
+
// identified solely by an `_test-` doc id prefix (id-keyed). All must be wiped
|
|
691
|
+
// at the start of every run so a test that died mid-execution leaves no
|
|
692
|
+
// ghosts. New collections that participate in tests MUST be added here too.
|
|
693
|
+
const testDataCollections = ['payments-orders', 'payments-webhooks', 'payments-intents', 'payments-disputes', 'marketing-webhooks'];
|
|
694
|
+
// Collections that exist solely for tests — wipe in full. All docs in these
|
|
695
|
+
// collections come from tests, so a single recursive delete handles cleanup.
|
|
696
|
+
const testOnlyCollections = ['_test', '_test_query'];
|
|
697
|
+
const UID_BATCH_SIZE = 30;
|
|
698
|
+
const TEST_ID_PREFIX = '_test-';
|
|
699
|
+
|
|
700
|
+
const uidBatches = [];
|
|
701
|
+
for (let i = 0; i < testUids.length; i += UID_BATCH_SIZE) {
|
|
702
|
+
uidBatches.push(testUids.slice(i, i + UID_BATCH_SIZE));
|
|
703
|
+
}
|
|
658
704
|
|
|
659
|
-
await Promise.all(
|
|
660
|
-
|
|
705
|
+
await Promise.all([
|
|
706
|
+
// Mixed collections: scoped delete of test data only.
|
|
707
|
+
...testDataCollections.map(async (collection) => {
|
|
708
|
+
// Pass 1 — owner-keyed
|
|
709
|
+
for (const batch of uidBatches) {
|
|
710
|
+
try {
|
|
711
|
+
const snapshot = await admin.firestore().collection(collection)
|
|
712
|
+
.where('owner', 'in', batch)
|
|
713
|
+
.get();
|
|
714
|
+
|
|
715
|
+
await Promise.all(
|
|
716
|
+
snapshot.docs.map(doc => doc.ref.delete())
|
|
717
|
+
);
|
|
718
|
+
} catch (e) {
|
|
719
|
+
// Collection may not exist yet, or doesn't carry an owner field — ignore
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
// Pass 2 — id-keyed (catches ownerless test docs)
|
|
724
|
+
// documentId() range scan: `_test-` ≤ id < `_test.` (the next ASCII char after `-` is `.`)
|
|
661
725
|
try {
|
|
662
726
|
const snapshot = await admin.firestore().collection(collection)
|
|
663
|
-
.where(
|
|
727
|
+
.where(admin.firestore.FieldPath.documentId(), '>=', TEST_ID_PREFIX)
|
|
728
|
+
.where(admin.firestore.FieldPath.documentId(), '<', '_test.')
|
|
664
729
|
.get();
|
|
665
730
|
|
|
666
731
|
await Promise.all(
|
|
@@ -669,8 +734,25 @@ async function deleteTestUsers(admin) {
|
|
|
669
734
|
} catch (e) {
|
|
670
735
|
// Collection may not exist yet — ignore
|
|
671
736
|
}
|
|
672
|
-
})
|
|
673
|
-
|
|
737
|
+
}),
|
|
738
|
+
// Test-only Firestore collections: wipe in full.
|
|
739
|
+
...testOnlyCollections.map(async (collection) => {
|
|
740
|
+
try {
|
|
741
|
+
const snapshot = await admin.firestore().collection(collection).get();
|
|
742
|
+
await Promise.all(snapshot.docs.map(doc => doc.ref.delete()));
|
|
743
|
+
} catch (e) {
|
|
744
|
+
// Collection may not exist yet — ignore
|
|
745
|
+
}
|
|
746
|
+
}),
|
|
747
|
+
// Realtime Database: wipe the `_test` namespace in full.
|
|
748
|
+
(async () => {
|
|
749
|
+
try {
|
|
750
|
+
await admin.database().ref('_test').remove();
|
|
751
|
+
} catch (e) {
|
|
752
|
+
// RTDB may not be configured for this project — ignore
|
|
753
|
+
}
|
|
754
|
+
})(),
|
|
755
|
+
]);
|
|
674
756
|
|
|
675
757
|
return {
|
|
676
758
|
success: results.failed.length === 0,
|
|
@@ -728,16 +810,16 @@ const TEST_DATA = {
|
|
|
728
810
|
* Called after account setup when TEST_EXTENDED_MODE is set to remove
|
|
729
811
|
* contacts added by auth:on-create
|
|
730
812
|
* @param {string} domain - Domain for email addresses
|
|
731
|
-
* @param {object} options - Options with
|
|
813
|
+
* @param {object} options - Options with apiUrl and backendManagerKey
|
|
732
814
|
* @returns {Promise<object>} Result with cleaned count
|
|
733
815
|
*/
|
|
734
816
|
async function cleanupMarketingProviders(domain, options = {}) {
|
|
735
817
|
const fetch = require('wonderful-fetch');
|
|
736
818
|
const results = { cleaned: 0, errors: [] };
|
|
737
819
|
|
|
738
|
-
const {
|
|
739
|
-
if (!
|
|
740
|
-
console.error('cleanupMarketingProviders: Missing
|
|
820
|
+
const { apiUrl, backendManagerKey } = options;
|
|
821
|
+
if (!apiUrl || !backendManagerKey) {
|
|
822
|
+
console.error('cleanupMarketingProviders: Missing apiUrl or backendManagerKey');
|
|
741
823
|
return results;
|
|
742
824
|
}
|
|
743
825
|
|
|
@@ -749,7 +831,7 @@ async function cleanupMarketingProviders(domain, options = {}) {
|
|
|
749
831
|
await Promise.all(
|
|
750
832
|
emails.map(async (email) => {
|
|
751
833
|
try {
|
|
752
|
-
const response = await fetch(`${
|
|
834
|
+
const response = await fetch(`${apiUrl}/backend-manager/marketing/contact`, {
|
|
753
835
|
method: 'DELETE',
|
|
754
836
|
response: 'json',
|
|
755
837
|
timeout: 30000,
|
|
@@ -11,9 +11,10 @@ class HttpClient {
|
|
|
11
11
|
constructor(options) {
|
|
12
12
|
options = options || {};
|
|
13
13
|
|
|
14
|
-
// Use
|
|
15
|
-
// All requests go through /backend-manager which
|
|
16
|
-
|
|
14
|
+
// Use API URL (port 5002, the hosting emulator) for all requests, not the
|
|
15
|
+
// functions URL (port 5001). All requests go through /backend-manager which
|
|
16
|
+
// rewrites to the bm_api function.
|
|
17
|
+
this.baseUrl = options.apiUrl || '';
|
|
17
18
|
this.defaultHeaders = {};
|
|
18
19
|
this.defaultAuthParams = {};
|
|
19
20
|
this.timeout = options.timeout || 30000;
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared state file that lets the test command and the running emulator
|
|
3
|
+
* agree on a small set of env vars without coordinated shell flags.
|
|
4
|
+
*
|
|
5
|
+
* The test command writes this file pre-flight (before invoking the runner).
|
|
6
|
+
* The emulator process watches it and mutates the corresponding entries in
|
|
7
|
+
* `process.env` in place when it changes. All existing branch sites read
|
|
8
|
+
* `process.env.X` per call so the mutation is invisible to them — no
|
|
9
|
+
* code-branch refactor needed.
|
|
10
|
+
*
|
|
11
|
+
* File lives at `<consumerProject>/.temp/test-mode.json` — `.temp/` is the
|
|
12
|
+
* standard transient cache directory across UJM/BXM/EM/BEM consumer projects
|
|
13
|
+
* (sits at the repo root, gitignored by default).
|
|
14
|
+
*
|
|
15
|
+
* ## Allowlist
|
|
16
|
+
*
|
|
17
|
+
* `SYNCED_ENV_KEYS` is the explicit list of env vars allowed to flow from the
|
|
18
|
+
* test command into the emulator. Adding a new live-sync var = one-line addition.
|
|
19
|
+
*
|
|
20
|
+
* Why an allowlist (not "sync everything"):
|
|
21
|
+
* - Some env vars are process-specific (e.g. FIRESTORE_EMULATOR_HOST is only
|
|
22
|
+
* correct on the test runner, never on the emulator) and would break things
|
|
23
|
+
* if synced. The allowlist prevents that.
|
|
24
|
+
* - Sensitive values (API keys) shouldn't be silently overwritten on the
|
|
25
|
+
* emulator just because the test runner happens to have them set.
|
|
26
|
+
* - Keeps mutation explicit and reviewable.
|
|
27
|
+
*
|
|
28
|
+
* File format:
|
|
29
|
+
* {
|
|
30
|
+
* "env": {
|
|
31
|
+
* "TEST_EXTENDED_MODE": "true"
|
|
32
|
+
* },
|
|
33
|
+
* "updatedAt": "2026-05-14T..."
|
|
34
|
+
* }
|
|
35
|
+
*
|
|
36
|
+
* Values are strings to match `process.env` semantics. Empty string means
|
|
37
|
+
* "unset" — applyEnvFromFile() will `delete process.env[key]` when the value
|
|
38
|
+
* is empty, matching the way Node treats unset vs falsy env vars.
|
|
39
|
+
*/
|
|
40
|
+
const path = require('path');
|
|
41
|
+
const jetpack = require('fs-jetpack');
|
|
42
|
+
|
|
43
|
+
const TEST_MODE_FILENAME = 'test-mode.json';
|
|
44
|
+
const TEMP_DIR_NAME = '.temp';
|
|
45
|
+
|
|
46
|
+
// Explicit allowlist of env vars that flow from the test command into the
|
|
47
|
+
// running emulator. Add a key here to make it live-syncable; nothing else
|
|
48
|
+
// flows through.
|
|
49
|
+
const SYNCED_ENV_KEYS = [
|
|
50
|
+
'TEST_EXTENDED_MODE',
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Resolve the absolute path to the test-mode file for a given consumer project.
|
|
55
|
+
*
|
|
56
|
+
* @param {string} projectDir - Consumer project root (the directory that
|
|
57
|
+
* contains `firebase.json` / `functions/`).
|
|
58
|
+
* @returns {string} Absolute path to `<projectDir>/.temp/test-mode.json`.
|
|
59
|
+
*/
|
|
60
|
+
function getTestModeFilePath(projectDir) {
|
|
61
|
+
return path.join(projectDir, TEMP_DIR_NAME, TEST_MODE_FILENAME);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Read the current test-mode payload from disk. Tolerant — returns `null`
|
|
66
|
+
* if the file is missing or unreadable, never throws.
|
|
67
|
+
*
|
|
68
|
+
* @param {string} projectDir
|
|
69
|
+
* @returns {{ env: Object<string, string>, updatedAt: string } | null}
|
|
70
|
+
*/
|
|
71
|
+
function readTestMode(projectDir) {
|
|
72
|
+
const filePath = getTestModeFilePath(projectDir);
|
|
73
|
+
|
|
74
|
+
if (!jetpack.exists(filePath)) {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
const data = jetpack.read(filePath, 'json');
|
|
80
|
+
if (!data || typeof data !== 'object') {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
return {
|
|
84
|
+
env: (data.env && typeof data.env === 'object') ? data.env : {},
|
|
85
|
+
updatedAt: data.updatedAt || null,
|
|
86
|
+
};
|
|
87
|
+
} catch (e) {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Write the desired env subset to disk. Atomic via fs-jetpack. Creates the
|
|
94
|
+
* `.temp/` directory if missing. Filters input through SYNCED_ENV_KEYS so
|
|
95
|
+
* only allowlisted keys are persisted, even if the caller passes extras.
|
|
96
|
+
*
|
|
97
|
+
* @param {string} projectDir
|
|
98
|
+
* @param {Object<string, string|undefined>} envInput - Map of env vars to sync.
|
|
99
|
+
* Keys outside SYNCED_ENV_KEYS are dropped.
|
|
100
|
+
* Empty/undefined values are persisted as ''
|
|
101
|
+
* (meaning "unset on receiving side").
|
|
102
|
+
* @returns {string} Absolute path of the written file (for logging).
|
|
103
|
+
*/
|
|
104
|
+
function writeTestMode(projectDir, envInput) {
|
|
105
|
+
const filePath = getTestModeFilePath(projectDir);
|
|
106
|
+
const env = {};
|
|
107
|
+
|
|
108
|
+
for (const key of SYNCED_ENV_KEYS) {
|
|
109
|
+
const v = envInput?.[key];
|
|
110
|
+
env[key] = v == null ? '' : String(v);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const payload = {
|
|
114
|
+
env,
|
|
115
|
+
updatedAt: new Date().toISOString(),
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
jetpack.write(filePath, payload, { atomic: true });
|
|
119
|
+
|
|
120
|
+
return filePath;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Capture the allowlisted subset of `process.env` into a plain object.
|
|
125
|
+
* Convenience for callers that want to write "whatever I currently have".
|
|
126
|
+
*
|
|
127
|
+
* @param {NodeJS.ProcessEnv} [source=process.env]
|
|
128
|
+
* @returns {Object<string, string>}
|
|
129
|
+
*/
|
|
130
|
+
function captureSyncedEnv(source) {
|
|
131
|
+
const src = source || process.env;
|
|
132
|
+
const out = {};
|
|
133
|
+
|
|
134
|
+
for (const key of SYNCED_ENV_KEYS) {
|
|
135
|
+
out[key] = src[key] == null ? '' : String(src[key]);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return out;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Apply a `data.env` payload to the current process's `process.env`. Used by
|
|
143
|
+
* the watcher inside the emulator process. Returns a list of `{key, was, now}`
|
|
144
|
+
* for any key that actually changed (caller can log these).
|
|
145
|
+
*
|
|
146
|
+
* Empty-string values in the payload are treated as "unset" — the
|
|
147
|
+
* corresponding `process.env[key]` is deleted. This matches Node semantics
|
|
148
|
+
* where `delete process.env.X` makes `process.env.X === undefined` and
|
|
149
|
+
* `!!process.env.X === false`.
|
|
150
|
+
*
|
|
151
|
+
* @param {{ env: Object<string, string> } | null} data
|
|
152
|
+
* @returns {Array<{ key: string, was: string|undefined, now: string|undefined }>}
|
|
153
|
+
*/
|
|
154
|
+
function applyEnvFromFile(data) {
|
|
155
|
+
if (!data || !data.env) {
|
|
156
|
+
return [];
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const changed = [];
|
|
160
|
+
|
|
161
|
+
for (const key of SYNCED_ENV_KEYS) {
|
|
162
|
+
if (!Object.prototype.hasOwnProperty.call(data.env, key)) {
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const was = process.env[key];
|
|
167
|
+
const next = data.env[key];
|
|
168
|
+
|
|
169
|
+
if (next === '' || next == null) {
|
|
170
|
+
if (was != null) {
|
|
171
|
+
delete process.env[key];
|
|
172
|
+
changed.push({ key, was, now: undefined });
|
|
173
|
+
}
|
|
174
|
+
} else if (was !== next) {
|
|
175
|
+
process.env[key] = next;
|
|
176
|
+
changed.push({ key, was, now: next });
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return changed;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
module.exports = {
|
|
184
|
+
TEST_MODE_FILENAME,
|
|
185
|
+
TEMP_DIR_NAME,
|
|
186
|
+
SYNCED_ENV_KEYS,
|
|
187
|
+
getTestModeFilePath,
|
|
188
|
+
readTestMode,
|
|
189
|
+
writeTestMode,
|
|
190
|
+
captureSyncedEnv,
|
|
191
|
+
applyEnvFromFile,
|
|
192
|
+
};
|
|
@@ -44,26 +44,17 @@ module.exports = {
|
|
|
44
44
|
},
|
|
45
45
|
},
|
|
46
46
|
|
|
47
|
-
{
|
|
48
|
-
name: 'backdate-start-date',
|
|
49
|
-
async run({ firestore, state }) {
|
|
50
|
-
// Backdate startDate so the 24-hour guard doesn't block cancellation
|
|
51
|
-
const twoDaysAgo = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000);
|
|
52
|
-
await firestore.set(`users/${state.uid}`, {
|
|
53
|
-
subscription: { payment: { startDate: { timestamp: twoDaysAgo.toISOString(), timestampUNIX: twoDaysAgo.getTime() } } },
|
|
54
|
-
}, { merge: true });
|
|
55
|
-
},
|
|
56
|
-
},
|
|
57
|
-
|
|
58
47
|
{
|
|
59
48
|
name: 'call-cancel-endpoint',
|
|
60
49
|
async run({ http, assert }) {
|
|
61
50
|
// Test processor writes a payments-webhooks doc directly,
|
|
62
|
-
// triggering the on-write pipeline automatically — no manual webhook needed
|
|
51
|
+
// triggering the on-write pipeline automatically — no manual webhook needed.
|
|
52
|
+
// skipGuards bypasses the 24-hour subscription-age guard.
|
|
63
53
|
const response = await http.as('journey-payments-cancel-route').post('payments/cancel', {
|
|
64
54
|
confirmed: true,
|
|
65
55
|
reason: 'Too expensive',
|
|
66
56
|
feedback: 'Would return at a lower price',
|
|
57
|
+
skipGuards: true,
|
|
67
58
|
});
|
|
68
59
|
|
|
69
60
|
assert.isSuccess(response, 'Cancel endpoint should succeed');
|
|
@@ -23,7 +23,6 @@ module.exports = {
|
|
|
23
23
|
state.uid = uid;
|
|
24
24
|
state.paidProductId = paidProduct.id;
|
|
25
25
|
state.paidProductName = paidProduct.name;
|
|
26
|
-
state.paidStripeProductId = paidProduct.stripe?.productId;
|
|
27
26
|
|
|
28
27
|
// Create subscription via test intent
|
|
29
28
|
const response = await http.as('journey-payments-cancel').post('payments/intent', {
|
|
@@ -51,7 +50,7 @@ module.exports = {
|
|
|
51
50
|
|
|
52
51
|
{
|
|
53
52
|
name: 'send-pending-cancel-webhook',
|
|
54
|
-
async run({ http, assert, state, config }) {
|
|
53
|
+
async run({ http, assert, state, config, payments }) {
|
|
55
54
|
const futureDate = new Date();
|
|
56
55
|
futureDate.setFullYear(futureDate.getFullYear() + 1);
|
|
57
56
|
|
|
@@ -74,7 +73,7 @@ module.exports = {
|
|
|
74
73
|
start_date: Math.floor(Date.now() / 1000) - 86400 * 30,
|
|
75
74
|
trial_start: null,
|
|
76
75
|
trial_end: null,
|
|
77
|
-
plan: { product: state.
|
|
76
|
+
plan: { product: payments.stripeProductIds[state.paidProductId], interval: 'month' },
|
|
78
77
|
},
|
|
79
78
|
},
|
|
80
79
|
});
|
|
@@ -103,7 +102,7 @@ module.exports = {
|
|
|
103
102
|
|
|
104
103
|
{
|
|
105
104
|
name: 'send-cancelled-webhook',
|
|
106
|
-
async run({ http, assert, state, config }) {
|
|
105
|
+
async run({ http, assert, state, config, payments }) {
|
|
107
106
|
state.eventId2 = `_test-evt-journey-cancel-final-${Date.now()}`;
|
|
108
107
|
|
|
109
108
|
const response = await http.as('none').post(`payments/webhook?processor=test&key=${config.backendManagerKey}`, {
|
|
@@ -122,7 +121,7 @@ module.exports = {
|
|
|
122
121
|
start_date: Math.floor(Date.now() / 1000) - 86400 * 60,
|
|
123
122
|
trial_start: null,
|
|
124
123
|
trial_end: null,
|
|
125
|
-
plan: { product: state.
|
|
124
|
+
plan: { product: payments.stripeProductIds[state.paidProductId], interval: 'month' },
|
|
126
125
|
},
|
|
127
126
|
},
|
|
128
127
|
});
|
|
@@ -26,7 +26,6 @@ module.exports = {
|
|
|
26
26
|
state.uid = uid;
|
|
27
27
|
state.paidProductId = paidProduct.id;
|
|
28
28
|
state.paidProductName = paidProduct.name;
|
|
29
|
-
state.paidStripeProductId = paidProduct.stripe?.productId;
|
|
30
29
|
|
|
31
30
|
// Create subscription via test intent
|
|
32
31
|
const response = await http.as('journey-payments-failure').post('payments/intent', {
|
|
@@ -15,12 +15,15 @@ module.exports = {
|
|
|
15
15
|
tests: [
|
|
16
16
|
{
|
|
17
17
|
name: 'resolve-one-time-product',
|
|
18
|
-
async run({ accounts, assert, state, config }) {
|
|
18
|
+
async run({ accounts, assert, state, config, skip }) {
|
|
19
19
|
const uid = accounts['journey-payments-one-time'].uid;
|
|
20
20
|
|
|
21
|
-
// Resolve first one-time product from config
|
|
21
|
+
// Resolve first one-time product from config. If none configured, skip the
|
|
22
|
+
// entire journey — this is a config-gap, not a code failure.
|
|
22
23
|
const oneTimeProduct = config.payment.products.find(p => p.type === 'one-time' && p.prices?.once);
|
|
23
|
-
|
|
24
|
+
if (!oneTimeProduct) {
|
|
25
|
+
skip('No one-time product configured in this brand');
|
|
26
|
+
}
|
|
24
27
|
|
|
25
28
|
state.uid = uid;
|
|
26
29
|
state.productId = oneTimeProduct.id;
|
|
@@ -16,15 +16,18 @@ module.exports = {
|
|
|
16
16
|
tests: [
|
|
17
17
|
{
|
|
18
18
|
name: 'resolve-one-time-product',
|
|
19
|
-
async run({ accounts, firestore, assert, state, config }) {
|
|
19
|
+
async run({ accounts, firestore, assert, state, config, skip }) {
|
|
20
20
|
const uid = accounts['journey-payments-one-time'].uid;
|
|
21
21
|
const userDoc = await firestore.get(`users/${uid}`);
|
|
22
22
|
|
|
23
23
|
assert.ok(userDoc, 'User doc should exist');
|
|
24
24
|
|
|
25
|
-
// Resolve first one-time product from config
|
|
25
|
+
// Resolve first one-time product from config. If the brand has none configured,
|
|
26
|
+
// skip the entire journey — this is a config-gap, not a code failure.
|
|
26
27
|
const oneTimeProduct = config.payment.products.find(p => p.type === 'one-time' && p.prices?.once);
|
|
27
|
-
|
|
28
|
+
if (!oneTimeProduct) {
|
|
29
|
+
skip('No one-time product configured in this brand');
|
|
30
|
+
}
|
|
28
31
|
|
|
29
32
|
state.uid = uid;
|
|
30
33
|
state.productId = oneTimeProduct.id;
|
|
@@ -24,8 +24,8 @@ module.exports = {
|
|
|
24
24
|
const productB = paidProducts[1];
|
|
25
25
|
|
|
26
26
|
state.uid = uid;
|
|
27
|
-
state.productA = { id: productA.id, name: productA.name
|
|
28
|
-
state.productB = { id: productB.id, name: productB.name
|
|
27
|
+
state.productA = { id: productA.id, name: productA.name };
|
|
28
|
+
state.productB = { id: productB.id, name: productB.name };
|
|
29
29
|
|
|
30
30
|
// Create subscription via test intent (product A)
|
|
31
31
|
const response = await http.as('journey-payments-plan-change').post('payments/intent', {
|
|
@@ -52,13 +52,13 @@ module.exports = {
|
|
|
52
52
|
|
|
53
53
|
{
|
|
54
54
|
name: 'send-plan-change-webhook',
|
|
55
|
-
async run({ http, assert, state, config }) {
|
|
55
|
+
async run({ http, assert, state, config, payments }) {
|
|
56
56
|
const futureDate = new Date();
|
|
57
57
|
futureDate.setMonth(futureDate.getMonth() + 1);
|
|
58
58
|
|
|
59
59
|
state.eventId = `_test-evt-journey-plan-change-${Date.now()}`;
|
|
60
60
|
|
|
61
|
-
// Send subscription.updated with
|
|
61
|
+
// Send subscription.updated with product B's Stripe product ID (or test sentinel)
|
|
62
62
|
const response = await http.as('none').post(`payments/webhook?processor=test&key=${config.backendManagerKey}`, {
|
|
63
63
|
id: state.eventId,
|
|
64
64
|
type: 'customer.subscription.updated',
|
|
@@ -75,7 +75,7 @@ module.exports = {
|
|
|
75
75
|
start_date: Math.floor(Date.now() / 1000) - 86400 * 30,
|
|
76
76
|
trial_start: null,
|
|
77
77
|
trial_end: null,
|
|
78
|
-
plan: { product: state.productB.
|
|
78
|
+
plan: { product: payments.stripeProductIds[state.productB.id], interval: 'month' },
|
|
79
79
|
},
|
|
80
80
|
},
|
|
81
81
|
});
|
|
@@ -26,7 +26,6 @@ module.exports = {
|
|
|
26
26
|
|
|
27
27
|
state.uid = uid;
|
|
28
28
|
state.paidProductId = paidProduct.id;
|
|
29
|
-
state.paidStripeProductId = paidProduct.stripe?.productId;
|
|
30
29
|
|
|
31
30
|
// Create subscription via test intent
|
|
32
31
|
const response = await http.as('journey-payments-refund-webhook').post('payments/intent', {
|
|
@@ -54,7 +53,7 @@ module.exports = {
|
|
|
54
53
|
|
|
55
54
|
{
|
|
56
55
|
name: 'send-pending-cancel-webhook',
|
|
57
|
-
async run({ http, assert, state, config }) {
|
|
56
|
+
async run({ http, assert, state, config, payments }) {
|
|
58
57
|
const futureDate = new Date();
|
|
59
58
|
futureDate.setMonth(futureDate.getMonth() + 1);
|
|
60
59
|
|
|
@@ -77,7 +76,7 @@ module.exports = {
|
|
|
77
76
|
start_date: Math.floor(Date.now() / 1000) - 86400 * 30,
|
|
78
77
|
trial_start: null,
|
|
79
78
|
trial_end: null,
|
|
80
|
-
plan: { product: state.
|
|
79
|
+
plan: { product: payments.stripeProductIds[state.paidProductId], interval: 'month' },
|
|
81
80
|
},
|
|
82
81
|
},
|
|
83
82
|
});
|
|
@@ -23,7 +23,6 @@ module.exports = {
|
|
|
23
23
|
state.uid = uid;
|
|
24
24
|
state.paidProductId = paidProduct.id;
|
|
25
25
|
state.paidProductName = paidProduct.name;
|
|
26
|
-
state.paidStripeProductId = paidProduct.stripe?.productId;
|
|
27
26
|
|
|
28
27
|
// Create subscription via test intent
|
|
29
28
|
const response = await http.as('journey-payments-suspend').post('payments/intent', {
|
|
@@ -51,7 +50,7 @@ module.exports = {
|
|
|
51
50
|
|
|
52
51
|
{
|
|
53
52
|
name: 'send-past-due-webhook',
|
|
54
|
-
async run({ http, assert, state, config }) {
|
|
53
|
+
async run({ http, assert, state, config, payments }) {
|
|
55
54
|
state.eventId1 = `_test-evt-journey-suspend-fail-${Date.now()}`;
|
|
56
55
|
|
|
57
56
|
const response = await http.as('none').post(`payments/webhook?processor=test&key=${config.backendManagerKey}`, {
|
|
@@ -70,7 +69,7 @@ module.exports = {
|
|
|
70
69
|
start_date: Math.floor(Date.now() / 1000) - 86400 * 60,
|
|
71
70
|
trial_start: null,
|
|
72
71
|
trial_end: null,
|
|
73
|
-
plan: { product: state.
|
|
72
|
+
plan: { product: payments.stripeProductIds[state.paidProductId], interval: 'month' },
|
|
74
73
|
},
|
|
75
74
|
},
|
|
76
75
|
});
|
|
@@ -99,7 +98,7 @@ module.exports = {
|
|
|
99
98
|
|
|
100
99
|
{
|
|
101
100
|
name: 'send-recovery-webhook',
|
|
102
|
-
async run({ http, assert, state, config }) {
|
|
101
|
+
async run({ http, assert, state, config, payments }) {
|
|
103
102
|
const futureDate = new Date();
|
|
104
103
|
futureDate.setMonth(futureDate.getMonth() + 1);
|
|
105
104
|
|
|
@@ -121,7 +120,7 @@ module.exports = {
|
|
|
121
120
|
start_date: Math.floor(Date.now() / 1000) - 86400 * 60,
|
|
122
121
|
trial_start: null,
|
|
123
122
|
trial_end: null,
|
|
124
|
-
plan: { product: state.
|
|
123
|
+
plan: { product: payments.stripeProductIds[state.paidProductId], interval: 'month' },
|
|
125
124
|
},
|
|
126
125
|
},
|
|
127
126
|
});
|
|
@@ -73,25 +73,16 @@ module.exports = {
|
|
|
73
73
|
},
|
|
74
74
|
},
|
|
75
75
|
|
|
76
|
-
{
|
|
77
|
-
name: 'backdate-start-date',
|
|
78
|
-
async run({ firestore, state }) {
|
|
79
|
-
// Backdate startDate so the 24-hour guard doesn't block cancellation
|
|
80
|
-
const twoDaysAgo = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000);
|
|
81
|
-
await firestore.set(`users/${state.uid}`, {
|
|
82
|
-
subscription: { payment: { startDate: { timestamp: twoDaysAgo.toISOString(), timestampUNIX: twoDaysAgo.getTime() } } },
|
|
83
|
-
}, { merge: true });
|
|
84
|
-
},
|
|
85
|
-
},
|
|
86
|
-
|
|
87
76
|
{
|
|
88
77
|
name: 'cancel-during-trial',
|
|
89
78
|
async run({ http, assert }) {
|
|
90
|
-
// Cancel via endpoint — test processor should detect trial and simulate immediate cancel
|
|
79
|
+
// Cancel via endpoint — test processor should detect trial and simulate immediate cancel.
|
|
80
|
+
// skipGuards bypasses the 24-hour subscription-age guard.
|
|
91
81
|
const response = await http.as('journey-payments-trial-cancel').post('payments/cancel', {
|
|
92
82
|
confirmed: true,
|
|
93
83
|
reason: 'Changed my mind during trial',
|
|
94
84
|
feedback: 'Testing trial cancellation',
|
|
85
|
+
skipGuards: true,
|
|
95
86
|
});
|
|
96
87
|
|
|
97
88
|
assert.isSuccess(response, 'Cancel endpoint should succeed');
|
|
@@ -27,7 +27,6 @@ module.exports = {
|
|
|
27
27
|
|
|
28
28
|
state.uid = uid;
|
|
29
29
|
state.paidProductId = paidProduct.id;
|
|
30
|
-
state.paidStripeProductId = paidProduct.stripe?.productId;
|
|
31
30
|
},
|
|
32
31
|
},
|
|
33
32
|
|
|
@@ -110,7 +109,7 @@ module.exports = {
|
|
|
110
109
|
|
|
111
110
|
{
|
|
112
111
|
name: 'send-trial-to-active-webhook',
|
|
113
|
-
async run({ http, assert, state, config }) {
|
|
112
|
+
async run({ http, assert, state, config, payments }) {
|
|
114
113
|
const futureDate = new Date();
|
|
115
114
|
futureDate.setMonth(futureDate.getMonth() + 1);
|
|
116
115
|
|
|
@@ -132,7 +131,7 @@ module.exports = {
|
|
|
132
131
|
start_date: Math.floor(Date.now() / 1000) - 86400 * 14,
|
|
133
132
|
trial_start: Math.floor(Date.now() / 1000) - 86400 * 14,
|
|
134
133
|
trial_end: Math.floor(Date.now() / 1000),
|
|
135
|
-
plan: { product: state.
|
|
134
|
+
plan: { product: payments.stripeProductIds[state.paidProductId], interval: 'month' },
|
|
136
135
|
},
|
|
137
136
|
},
|
|
138
137
|
});
|
|
@@ -29,7 +29,6 @@ module.exports = {
|
|
|
29
29
|
|
|
30
30
|
state.uid = uid;
|
|
31
31
|
state.paidProductId = paidProduct.id;
|
|
32
|
-
state.paidStripeProductId = paidProduct.stripe?.productId;
|
|
33
32
|
|
|
34
33
|
// Create subscription via test intent
|
|
35
34
|
const response = await http.as('journey-payments-uid-resolution').post('payments/intent', {
|
|
@@ -56,7 +55,7 @@ module.exports = {
|
|
|
56
55
|
|
|
57
56
|
{
|
|
58
57
|
name: 'send-webhook-without-uid',
|
|
59
|
-
async run({ http, assert, state, config }) {
|
|
58
|
+
async run({ http, assert, state, config, payments }) {
|
|
60
59
|
// Send a subscription update webhook WITHOUT uid in metadata
|
|
61
60
|
// The test processor's fetchResource() will look up payments-orders by resourceId
|
|
62
61
|
// and reconstruct a Stripe-shaped subscription that includes metadata.uid
|
|
@@ -84,7 +83,7 @@ module.exports = {
|
|
|
84
83
|
start_date: Math.floor(Date.now() / 1000) - 86400 * 30,
|
|
85
84
|
trial_start: null,
|
|
86
85
|
trial_end: null,
|
|
87
|
-
plan: { product: state.
|
|
86
|
+
plan: { product: payments.stripeProductIds[state.paidProductId], interval: 'month' },
|
|
88
87
|
},
|
|
89
88
|
},
|
|
90
89
|
});
|