backend-manager 5.1.4 → 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 +23 -0
- package/CLAUDE.md +2 -1
- package/README.md +15 -0
- package/docs/common-mistakes.md +1 -0
- package/docs/consent.md +333 -0
- package/docs/testing.md +36 -0
- package/package.json +1 -1
- package/src/cli/commands/emulator.js +44 -8
- package/src/cli/commands/serve.js +73 -7
- package/src/cli/commands/test.js +47 -1
- package/src/cli/commands/watch.js +15 -3
- package/src/manager/helpers/user.js +29 -0
- package/src/manager/index.js +29 -0
- package/src/manager/libraries/email/data/disposable-domains.json +8 -0
- package/src/manager/libraries/email/generators/newsletter.js +2 -2
- package/src/manager/libraries/email/providers/beehiiv.js +1 -0
- 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/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/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/runner.js +61 -18
- package/src/test/test-accounts.js +94 -12
- package/src/test/utils/http-client.js +4 -3
- 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/newsletter-generate.js +17 -7
- 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
package/src/test/runner.js
CHANGED
|
@@ -87,7 +87,7 @@ class TestRunner {
|
|
|
87
87
|
// Health check (use basic http client without accounts)
|
|
88
88
|
// Use hosting URL for all requests (rewrites to bm_api function)
|
|
89
89
|
const healthHttp = new HttpClient({
|
|
90
|
-
|
|
90
|
+
apiUrl: this.options.apiUrl,
|
|
91
91
|
timeout: this.options.timeout,
|
|
92
92
|
});
|
|
93
93
|
|
|
@@ -119,23 +119,30 @@ class TestRunner {
|
|
|
119
119
|
await this.runTestsInDir(projectTestsDir, 'project');
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
+
// Post-run cleanup: scrub test accounts from third-party marketing providers
|
|
123
|
+
// (SendGrid/Beehiiv) so each test run leaves the contact list in the same
|
|
124
|
+
// state it found it. Pairs with the pre-run cleanup as defense in depth —
|
|
125
|
+
// pre-run handles crashed previous runs, post-run handles the current run.
|
|
126
|
+
// Only fires in extended mode (normal mode never touches real providers).
|
|
127
|
+
if (process.env.TEST_EXTENDED_MODE) {
|
|
128
|
+
process.stdout.write(chalk.gray('\n Cleaning up test accounts from marketing providers... '));
|
|
129
|
+
try {
|
|
130
|
+
const cleanupResult = await testAccounts.cleanupMarketingProviders(this.options.domain, {
|
|
131
|
+
apiUrl: this.options.apiUrl,
|
|
132
|
+
backendManagerKey: this.options.backendManagerKey,
|
|
133
|
+
});
|
|
134
|
+
console.log(chalk.green(`✓ (${cleanupResult.cleaned} cleaned)`));
|
|
135
|
+
} catch (e) {
|
|
136
|
+
// Post-run cleanup is best-effort — failures shouldn't change the test result
|
|
137
|
+
console.log(chalk.yellow(`⚠ cleanup error: ${e.message}`));
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
122
141
|
// Cleanup rules context
|
|
123
142
|
if (this.rulesContext) {
|
|
124
143
|
await this.rulesContext.cleanup();
|
|
125
144
|
}
|
|
126
145
|
|
|
127
|
-
// Clean up test accounts from marketing providers (SendGrid/Beehiiv)
|
|
128
|
-
// Run at end of tests so auth:on-create has time to complete
|
|
129
|
-
if (process.env.TEST_EXTENDED_MODE) {
|
|
130
|
-
console.log('');
|
|
131
|
-
process.stdout.write(chalk.gray(' Cleaning test accounts from marketing providers... '));
|
|
132
|
-
const cleanupResult = await testAccounts.cleanupMarketingProviders(this.options.domain, {
|
|
133
|
-
apiUrl: this.options.apiUrl,
|
|
134
|
-
backendManagerKey: this.options.backendManagerKey,
|
|
135
|
-
});
|
|
136
|
-
console.log(chalk.green(`✓ (${cleanupResult.cleaned} cleaned)`));
|
|
137
|
-
}
|
|
138
|
-
|
|
139
146
|
// Report results
|
|
140
147
|
this.reportResults();
|
|
141
148
|
|
|
@@ -147,9 +154,9 @@ class TestRunner {
|
|
|
147
154
|
* Validate configuration
|
|
148
155
|
*/
|
|
149
156
|
validateConfig() {
|
|
150
|
-
if (!this.options.
|
|
151
|
-
console.log(chalk.red(' ✗ Missing
|
|
152
|
-
console.log(chalk.gray(' Set
|
|
157
|
+
if (!this.options.apiUrl) {
|
|
158
|
+
console.log(chalk.red(' ✗ Missing apiUrl'));
|
|
159
|
+
console.log(chalk.gray(' Set BEM_API_URL environment variable or pass --url flag'));
|
|
153
160
|
return false;
|
|
154
161
|
}
|
|
155
162
|
|
|
@@ -199,7 +206,7 @@ class TestRunner {
|
|
|
199
206
|
|
|
200
207
|
console.log(chalk.red('✗'));
|
|
201
208
|
console.log(chalk.red(` Server not responding: ${response.error}`));
|
|
202
|
-
console.log(chalk.gray(` Make sure your functions are deployed and running at ${this.options.
|
|
209
|
+
console.log(chalk.gray(` Make sure your functions are deployed and running at ${this.options.apiUrl}`));
|
|
203
210
|
return false;
|
|
204
211
|
} catch (error) {
|
|
205
212
|
console.log(chalk.red('✗'));
|
|
@@ -222,6 +229,18 @@ class TestRunner {
|
|
|
222
229
|
const deleteResult = await testAccounts.deleteTestUsers(this.options.admin);
|
|
223
230
|
console.log(chalk.green(`✓ (${deleteResult.deleted} deleted, ${deleteResult.skipped} skipped)`));
|
|
224
231
|
|
|
232
|
+
// Clean any leftover test accounts from third-party marketing providers
|
|
233
|
+
// (SendGrid/Beehiiv). Runs BEFORE we create fresh users so a previously
|
|
234
|
+
// killed run doesn't leave the contact list polluted.
|
|
235
|
+
if (process.env.TEST_EXTENDED_MODE) {
|
|
236
|
+
process.stdout.write(chalk.gray(' Cleaning test accounts from marketing providers... '));
|
|
237
|
+
const cleanupResult = await testAccounts.cleanupMarketingProviders(this.options.domain, {
|
|
238
|
+
apiUrl: this.options.apiUrl,
|
|
239
|
+
backendManagerKey: this.options.backendManagerKey,
|
|
240
|
+
});
|
|
241
|
+
console.log(chalk.green(`✓ (${cleanupResult.cleaned} cleaned)`));
|
|
242
|
+
}
|
|
243
|
+
|
|
225
244
|
process.stdout.write(chalk.gray(' Creating test accounts... '));
|
|
226
245
|
|
|
227
246
|
// Create fresh test accounts
|
|
@@ -532,6 +551,20 @@ class TestRunner {
|
|
|
532
551
|
duration,
|
|
533
552
|
suite: suiteDescription,
|
|
534
553
|
});
|
|
554
|
+
|
|
555
|
+
// For suites (sequential, state-dependent tests), a skip on any step means
|
|
556
|
+
// subsequent steps can't run cleanly — propagate skip to the rest of the suite.
|
|
557
|
+
// Groups (independent tests) continue normally.
|
|
558
|
+
const shouldStopOnSkip = suite.type !== 'group' && suite.stopOnFailure !== false;
|
|
559
|
+
if (shouldStopOnSkip) {
|
|
560
|
+
const remaining = tests.length - i - 1;
|
|
561
|
+
if (remaining > 0) {
|
|
562
|
+
console.log(chalk.yellow(` Skipping ${remaining} remaining test(s) in suite (suite-level skip)`));
|
|
563
|
+
this.results.skipped += remaining;
|
|
564
|
+
}
|
|
565
|
+
break;
|
|
566
|
+
}
|
|
567
|
+
|
|
535
568
|
continue;
|
|
536
569
|
}
|
|
537
570
|
|
|
@@ -655,7 +688,7 @@ class TestRunner {
|
|
|
655
688
|
// Create HTTP client with accounts for as() method
|
|
656
689
|
// Use hosting URL for all requests (rewrites to bm_api function)
|
|
657
690
|
const http = new HttpClient({
|
|
658
|
-
|
|
691
|
+
apiUrl: this.options.apiUrl,
|
|
659
692
|
timeout: this.options.timeout,
|
|
660
693
|
accounts: this.accounts,
|
|
661
694
|
backendManagerKey: this.options.backendManagerKey,
|
|
@@ -694,6 +727,15 @@ class TestRunner {
|
|
|
694
727
|
throw new SkipError(reason);
|
|
695
728
|
};
|
|
696
729
|
|
|
730
|
+
// Precomputed map of BEM product id → Stripe-compatible product ID for tests.
|
|
731
|
+
// Falls back to the "_test_<id>" sentinel when no real Stripe product is configured,
|
|
732
|
+
// letting the Stripe resolver match it back to the BEM product. SSOT for tests that
|
|
733
|
+
// need to construct Stripe-shaped webhook payloads (cancel, refund, plan-change, etc.).
|
|
734
|
+
const products = this.config.payment?.products || [];
|
|
735
|
+
const stripeProductIds = Object.fromEntries(
|
|
736
|
+
products.map((p) => [p.id, p.stripe?.productId || `_test_${p.id}`])
|
|
737
|
+
);
|
|
738
|
+
|
|
697
739
|
return {
|
|
698
740
|
http,
|
|
699
741
|
accounts: this.accounts,
|
|
@@ -711,6 +753,7 @@ class TestRunner {
|
|
|
711
753
|
assistant: this.config.assistant,
|
|
712
754
|
rules: this.rulesContext,
|
|
713
755
|
config: this.config,
|
|
756
|
+
payments: { stripeProductIds },
|
|
714
757
|
};
|
|
715
758
|
}
|
|
716
759
|
|
|
@@ -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;
|
|
@@ -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
|
});
|
|
@@ -130,19 +130,5 @@ module.exports = {
|
|
|
130
130
|
},
|
|
131
131
|
},
|
|
132
132
|
|
|
133
|
-
// Test 7: Cleanup
|
|
134
|
-
{
|
|
135
|
-
name: 'cleanup',
|
|
136
|
-
auth: 'admin',
|
|
137
|
-
timeout: 15000,
|
|
138
|
-
|
|
139
|
-
async run({ http }) {
|
|
140
|
-
// Clean up test data by writing null
|
|
141
|
-
await http.command('admin:database-write', {
|
|
142
|
-
path: TEST_PATH,
|
|
143
|
-
document: null,
|
|
144
|
-
});
|
|
145
|
-
},
|
|
146
|
-
},
|
|
147
133
|
],
|
|
148
134
|
};
|
|
@@ -155,19 +155,5 @@ module.exports = {
|
|
|
155
155
|
},
|
|
156
156
|
},
|
|
157
157
|
|
|
158
|
-
// Test 7: Cleanup
|
|
159
|
-
{
|
|
160
|
-
name: 'cleanup',
|
|
161
|
-
auth: 'admin',
|
|
162
|
-
timeout: 15000,
|
|
163
|
-
|
|
164
|
-
async run({ http }) {
|
|
165
|
-
// Clean up test data by writing null
|
|
166
|
-
await http.command('admin:database-write', {
|
|
167
|
-
path: TEST_PATH,
|
|
168
|
-
document: null,
|
|
169
|
-
});
|
|
170
|
-
},
|
|
171
|
-
},
|
|
172
158
|
],
|
|
173
159
|
};
|