backend-manager 5.2.3 → 5.2.6
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 +52 -0
- package/CLAUDE.md +3 -3
- package/TODO-CANCEL-EMAIL-MISSING-ORDER-ID.md +159 -0
- package/TODO-WEBHOOK-KEY-LEGACY-REMOVAL.md +15 -0
- package/TODO-WEBHOOK-KEY-UPGRADE.md +138 -0
- package/docs/consent.md +5 -10
- package/docs/sanitization.md +32 -24
- package/docs/schemas.md +1 -1
- package/docs/stripe-webhook-forwarding.md +2 -2
- package/docs/testing.md +8 -7
- package/package.json +1 -1
- package/scripts/test-helper-providers.js +162 -0
- package/src/cli/commands/base-command.js +5 -5
- package/src/cli/commands/emulator.js +201 -54
- package/src/cli/commands/test.js +80 -9
- package/src/manager/events/cron/daily/ghostii-auto-publisher.js +2 -2
- package/src/manager/events/firestore/payments-webhooks/analytics.js +2 -2
- package/src/manager/functions/core/actions/api/user/delete.js +1 -1
- package/src/manager/helpers/analytics.js +1 -1
- package/src/manager/helpers/middleware.js +7 -4
- package/src/manager/helpers/utilities.js +31 -0
- package/src/manager/libraries/email/generators/newsletter.js +2 -2
- package/src/manager/libraries/email/providers/beehiiv.js +69 -27
- package/src/manager/libraries/email/providers/sendgrid.js +38 -12
- package/src/manager/libraries/email/validation.js +1 -1
- package/src/manager/libraries/infer-contact.js +1 -1
- package/src/manager/routes/general/email/post.js +4 -2
- package/src/manager/routes/marketing/email-preferences/post.js +2 -2
- package/src/manager/routes/payments/dispute-alert/post.js +3 -3
- package/src/manager/routes/payments/intent/processors/test.js +2 -2
- package/src/manager/routes/payments/webhook/post.js +2 -2
- package/src/manager/routes/user/delete.js +1 -1
- package/src/manager/routes/user/oauth2/providers/discord.js +1 -1
- package/src/manager/routes/user/oauth2/providers/google.js +1 -1
- package/src/test/runner.js +7 -31
- package/src/test/test-accounts.js +8 -63
- package/src/test/utils/http-client.js +1 -0
- package/test/events/payments/journey-payments-cancel.js +4 -4
- package/test/events/payments/journey-payments-failure.js +2 -2
- package/test/events/payments/journey-payments-legacy-product.js +1 -1
- package/test/events/payments/journey-payments-one-time-failure.js +1 -1
- package/test/events/payments/journey-payments-plan-change.js +1 -1
- package/test/events/payments/journey-payments-refund-webhook.js +4 -4
- package/test/events/payments/journey-payments-suspend.js +4 -4
- package/test/events/payments/journey-payments-trial.js +2 -2
- package/test/events/payments/journey-payments-uid-resolution.js +1 -1
- package/test/marketing/consent-lifecycle.js +255 -0
- package/test/routes/payments/dispute-alert.js +13 -13
- package/test/routes/payments/webhook.js +3 -3
- /package/src/manager/routes/general/email/templates/{download-app-link.js → general/download-app-link.js} +0 -0
|
@@ -162,10 +162,14 @@ const STATIC_ACCOUNTS = {
|
|
|
162
162
|
subscription: { product: { id: 'basic' }, status: 'active' },
|
|
163
163
|
},
|
|
164
164
|
},
|
|
165
|
+
// The two `consent-*` accounts use the `_test.allow_*` prefix so they bypass
|
|
166
|
+
// the `_test.*` marketing-block in blocked-local-patterns.js. They're the
|
|
167
|
+
// live-provider integration sentinels — they intentionally round-trip through
|
|
168
|
+
// SendGrid + Beehiiv to verify the consent gate works end-to-end.
|
|
165
169
|
'consent-granted': {
|
|
166
170
|
id: 'consent-granted',
|
|
167
|
-
uid: '_test-consent-granted',
|
|
168
|
-
email: '_test.
|
|
171
|
+
uid: '_test-allow-consent-granted',
|
|
172
|
+
email: '_test.allow_consent-granted@{domain}',
|
|
169
173
|
properties: {
|
|
170
174
|
roles: {},
|
|
171
175
|
subscription: { product: { id: 'basic' }, status: 'active' },
|
|
@@ -173,8 +177,8 @@ const STATIC_ACCOUNTS = {
|
|
|
173
177
|
},
|
|
174
178
|
'consent-declined': {
|
|
175
179
|
id: 'consent-declined',
|
|
176
|
-
uid: '_test-consent-declined',
|
|
177
|
-
email: '_test.
|
|
180
|
+
uid: '_test-allow-consent-declined',
|
|
181
|
+
email: '_test.allow_consent-declined@{domain}',
|
|
178
182
|
properties: {
|
|
179
183
|
roles: {},
|
|
180
184
|
subscription: { product: { id: 'basic' }, status: 'active' },
|
|
@@ -805,64 +809,6 @@ const TEST_DATA = {
|
|
|
805
809
|
defaultProjectId: 'demo-test',
|
|
806
810
|
};
|
|
807
811
|
|
|
808
|
-
/**
|
|
809
|
-
* Clean up test accounts from marketing providers (SendGrid + Beehiiv)
|
|
810
|
-
* Called after account setup when TEST_EXTENDED_MODE is set to remove
|
|
811
|
-
* contacts added by auth:on-create
|
|
812
|
-
* @param {string} domain - Domain for email addresses
|
|
813
|
-
* @param {object} options - Options with apiUrl and backendManagerKey
|
|
814
|
-
* @returns {Promise<object>} Result with cleaned count
|
|
815
|
-
*/
|
|
816
|
-
async function cleanupMarketingProviders(domain, options = {}) {
|
|
817
|
-
const fetch = require('wonderful-fetch');
|
|
818
|
-
const results = { cleaned: 0, errors: [] };
|
|
819
|
-
|
|
820
|
-
const { apiUrl, backendManagerKey } = options;
|
|
821
|
-
if (!apiUrl || !backendManagerKey) {
|
|
822
|
-
console.error('cleanupMarketingProviders: Missing apiUrl or backendManagerKey');
|
|
823
|
-
return results;
|
|
824
|
-
}
|
|
825
|
-
|
|
826
|
-
// Get all test account emails (test contacts like rachel.greene+bem cleaned up by their own tests)
|
|
827
|
-
const definitions = getAccountDefinitions(domain);
|
|
828
|
-
const emails = Object.values(definitions).map(acc => acc.email);
|
|
829
|
-
|
|
830
|
-
// Clean up each email via the API endpoint (uses hosting port 5002)
|
|
831
|
-
await Promise.all(
|
|
832
|
-
emails.map(async (email) => {
|
|
833
|
-
try {
|
|
834
|
-
const response = await fetch(`${apiUrl}/backend-manager/marketing/contact`, {
|
|
835
|
-
method: 'DELETE',
|
|
836
|
-
response: 'json',
|
|
837
|
-
timeout: 30000,
|
|
838
|
-
body: {
|
|
839
|
-
backendManagerKey,
|
|
840
|
-
email,
|
|
841
|
-
},
|
|
842
|
-
});
|
|
843
|
-
|
|
844
|
-
// Log the result for debugging
|
|
845
|
-
if (response.providers?.beehiiv?.deleted) {
|
|
846
|
-
results.cleaned++;
|
|
847
|
-
} else if (response.providers?.beehiiv?.skipped) {
|
|
848
|
-
// Skipped means not found - that's fine
|
|
849
|
-
results.cleaned++;
|
|
850
|
-
} else if (response.providers?.beehiiv?.error) {
|
|
851
|
-
console.error(`Failed to delete ${email} from Beehiiv:`, response.providers.beehiiv.error);
|
|
852
|
-
results.errors.push({ email, error: response.providers.beehiiv.error });
|
|
853
|
-
} else {
|
|
854
|
-
results.cleaned++;
|
|
855
|
-
}
|
|
856
|
-
} catch (error) {
|
|
857
|
-
console.error(`Failed to cleanup ${email}:`, error.message);
|
|
858
|
-
results.errors.push({ email, error: error.message });
|
|
859
|
-
}
|
|
860
|
-
})
|
|
861
|
-
);
|
|
862
|
-
|
|
863
|
-
return results;
|
|
864
|
-
}
|
|
865
|
-
|
|
866
812
|
module.exports = {
|
|
867
813
|
STATIC_ACCOUNTS,
|
|
868
814
|
JOURNEY_ACCOUNTS,
|
|
@@ -873,5 +819,4 @@ module.exports = {
|
|
|
873
819
|
fetchPrivateKeys,
|
|
874
820
|
deleteTestUsers,
|
|
875
821
|
createTestAccounts,
|
|
876
|
-
cleanupMarketingProviders,
|
|
877
822
|
};
|
|
@@ -56,7 +56,7 @@ module.exports = {
|
|
|
56
56
|
|
|
57
57
|
state.eventId1 = `_test-evt-journey-cancel-pending-${Date.now()}`;
|
|
58
58
|
|
|
59
|
-
const response = await http.as('none').post(`payments/webhook?processor=test&key=${config.
|
|
59
|
+
const response = await http.as('none').post(`payments/webhook?processor=test&key=${config.backendManagerWebhookKey}`, {
|
|
60
60
|
id: state.eventId1,
|
|
61
61
|
type: 'customer.subscription.updated',
|
|
62
62
|
data: {
|
|
@@ -64,7 +64,7 @@ module.exports = {
|
|
|
64
64
|
id: state.subscriptionId,
|
|
65
65
|
object: 'subscription',
|
|
66
66
|
status: 'active',
|
|
67
|
-
metadata: { uid: state.uid },
|
|
67
|
+
metadata: { uid: state.uid, orderId: state.orderId },
|
|
68
68
|
cancel_at_period_end: true,
|
|
69
69
|
cancel_at: Math.floor(futureDate.getTime() / 1000),
|
|
70
70
|
canceled_at: null,
|
|
@@ -105,7 +105,7 @@ module.exports = {
|
|
|
105
105
|
async run({ http, assert, state, config, payments }) {
|
|
106
106
|
state.eventId2 = `_test-evt-journey-cancel-final-${Date.now()}`;
|
|
107
107
|
|
|
108
|
-
const response = await http.as('none').post(`payments/webhook?processor=test&key=${config.
|
|
108
|
+
const response = await http.as('none').post(`payments/webhook?processor=test&key=${config.backendManagerWebhookKey}`, {
|
|
109
109
|
id: state.eventId2,
|
|
110
110
|
type: 'customer.subscription.deleted',
|
|
111
111
|
data: {
|
|
@@ -113,7 +113,7 @@ module.exports = {
|
|
|
113
113
|
id: state.subscriptionId,
|
|
114
114
|
object: 'subscription',
|
|
115
115
|
status: 'canceled',
|
|
116
|
-
metadata: { uid: state.uid },
|
|
116
|
+
metadata: { uid: state.uid, orderId: state.orderId },
|
|
117
117
|
cancel_at_period_end: false,
|
|
118
118
|
canceled_at: Math.floor(Date.now() / 1000),
|
|
119
119
|
current_period_end: Math.floor(Date.now() / 1000),
|
|
@@ -58,7 +58,7 @@ module.exports = {
|
|
|
58
58
|
|
|
59
59
|
// Send invoice.payment_failed with subscription billing reason
|
|
60
60
|
// This tests the new parseWebhook routing: billing_reason=subscription_cycle → subscription category
|
|
61
|
-
const response = await http.as('none').post(`payments/webhook?processor=test&key=${config.
|
|
61
|
+
const response = await http.as('none').post(`payments/webhook?processor=test&key=${config.backendManagerWebhookKey}`, {
|
|
62
62
|
id: state.eventId,
|
|
63
63
|
type: 'invoice.payment_failed',
|
|
64
64
|
data: {
|
|
@@ -72,7 +72,7 @@ module.exports = {
|
|
|
72
72
|
parent: {
|
|
73
73
|
subscription_details: {
|
|
74
74
|
subscription: state.subscriptionId,
|
|
75
|
-
metadata: { uid: state.uid },
|
|
75
|
+
metadata: { uid: state.uid, orderId: state.orderId },
|
|
76
76
|
},
|
|
77
77
|
type: 'subscription_details',
|
|
78
78
|
},
|
|
@@ -66,7 +66,7 @@ module.exports = {
|
|
|
66
66
|
// Send a subscription created webhook with the LEGACY product ID
|
|
67
67
|
// This simulates an existing subscriber whose Stripe subscription still
|
|
68
68
|
// references the old product ID from before migration
|
|
69
|
-
const response = await http.as('none').post(`payments/webhook?processor=test&key=${config.
|
|
69
|
+
const response = await http.as('none').post(`payments/webhook?processor=test&key=${config.backendManagerWebhookKey}`, {
|
|
70
70
|
id: state.legacyEventId,
|
|
71
71
|
type: 'customer.subscription.created',
|
|
72
72
|
data: {
|
|
@@ -39,7 +39,7 @@ module.exports = {
|
|
|
39
39
|
|
|
40
40
|
// Send invoice.payment_failed with a non-subscription billing reason
|
|
41
41
|
// This routes to category: 'one-time' in the webhook parser
|
|
42
|
-
const response = await http.as('none').post(`payments/webhook?processor=test&key=${config.
|
|
42
|
+
const response = await http.as('none').post(`payments/webhook?processor=test&key=${config.backendManagerWebhookKey}`, {
|
|
43
43
|
id: state.eventId,
|
|
44
44
|
type: 'invoice.payment_failed',
|
|
45
45
|
data: {
|
|
@@ -59,7 +59,7 @@ module.exports = {
|
|
|
59
59
|
state.eventId = `_test-evt-journey-plan-change-${Date.now()}`;
|
|
60
60
|
|
|
61
61
|
// Send subscription.updated with product B's Stripe product ID (or test sentinel)
|
|
62
|
-
const response = await http.as('none').post(`payments/webhook?processor=test&key=${config.
|
|
62
|
+
const response = await http.as('none').post(`payments/webhook?processor=test&key=${config.backendManagerWebhookKey}`, {
|
|
63
63
|
id: state.eventId,
|
|
64
64
|
type: 'customer.subscription.updated',
|
|
65
65
|
data: {
|
|
@@ -59,7 +59,7 @@ module.exports = {
|
|
|
59
59
|
|
|
60
60
|
state.cancelEventId = `_test-evt-journey-refund-cancel-${Date.now()}`;
|
|
61
61
|
|
|
62
|
-
const response = await http.as('none').post(`payments/webhook?processor=test&key=${config.
|
|
62
|
+
const response = await http.as('none').post(`payments/webhook?processor=test&key=${config.backendManagerWebhookKey}`, {
|
|
63
63
|
id: state.cancelEventId,
|
|
64
64
|
type: 'customer.subscription.updated',
|
|
65
65
|
data: {
|
|
@@ -67,7 +67,7 @@ module.exports = {
|
|
|
67
67
|
id: state.subscriptionId,
|
|
68
68
|
object: 'subscription',
|
|
69
69
|
status: 'active',
|
|
70
|
-
metadata: { uid: state.uid },
|
|
70
|
+
metadata: { uid: state.uid, orderId: state.orderId },
|
|
71
71
|
cancel_at_period_end: true,
|
|
72
72
|
cancel_at: Math.floor(futureDate.getTime() / 1000),
|
|
73
73
|
canceled_at: null,
|
|
@@ -110,7 +110,7 @@ module.exports = {
|
|
|
110
110
|
state.refundEventId = `_test-evt-journey-refund-charge-${Date.now()}`;
|
|
111
111
|
state.refundAmountCents = 2800; // $28.00
|
|
112
112
|
|
|
113
|
-
const response = await http.as('none').post(`payments/webhook?processor=test&key=${config.
|
|
113
|
+
const response = await http.as('none').post(`payments/webhook?processor=test&key=${config.backendManagerWebhookKey}`, {
|
|
114
114
|
id: state.refundEventId,
|
|
115
115
|
type: 'charge.refunded',
|
|
116
116
|
data: {
|
|
@@ -121,7 +121,7 @@ module.exports = {
|
|
|
121
121
|
amount_refunded: state.refundAmountCents,
|
|
122
122
|
currency: 'usd',
|
|
123
123
|
subscription: state.subscriptionId,
|
|
124
|
-
metadata: { uid: state.uid },
|
|
124
|
+
metadata: { uid: state.uid, orderId: state.orderId },
|
|
125
125
|
refunds: {
|
|
126
126
|
data: [
|
|
127
127
|
{
|
|
@@ -53,7 +53,7 @@ module.exports = {
|
|
|
53
53
|
async run({ http, assert, state, config, payments }) {
|
|
54
54
|
state.eventId1 = `_test-evt-journey-suspend-fail-${Date.now()}`;
|
|
55
55
|
|
|
56
|
-
const response = await http.as('none').post(`payments/webhook?processor=test&key=${config.
|
|
56
|
+
const response = await http.as('none').post(`payments/webhook?processor=test&key=${config.backendManagerWebhookKey}`, {
|
|
57
57
|
id: state.eventId1,
|
|
58
58
|
type: 'customer.subscription.updated',
|
|
59
59
|
data: {
|
|
@@ -61,7 +61,7 @@ module.exports = {
|
|
|
61
61
|
id: state.subscriptionId,
|
|
62
62
|
object: 'subscription',
|
|
63
63
|
status: 'past_due',
|
|
64
|
-
metadata: { uid: state.uid },
|
|
64
|
+
metadata: { uid: state.uid, orderId: state.orderId },
|
|
65
65
|
cancel_at_period_end: false,
|
|
66
66
|
canceled_at: null,
|
|
67
67
|
current_period_end: Math.floor(Date.now() / 1000) + 86400,
|
|
@@ -104,7 +104,7 @@ module.exports = {
|
|
|
104
104
|
|
|
105
105
|
state.eventId2 = `_test-evt-journey-suspend-recover-${Date.now()}`;
|
|
106
106
|
|
|
107
|
-
const response = await http.as('none').post(`payments/webhook?processor=test&key=${config.
|
|
107
|
+
const response = await http.as('none').post(`payments/webhook?processor=test&key=${config.backendManagerWebhookKey}`, {
|
|
108
108
|
id: state.eventId2,
|
|
109
109
|
type: 'customer.subscription.updated',
|
|
110
110
|
data: {
|
|
@@ -112,7 +112,7 @@ module.exports = {
|
|
|
112
112
|
id: state.subscriptionId,
|
|
113
113
|
object: 'subscription',
|
|
114
114
|
status: 'active',
|
|
115
|
-
metadata: { uid: state.uid },
|
|
115
|
+
metadata: { uid: state.uid, orderId: state.orderId },
|
|
116
116
|
cancel_at_period_end: false,
|
|
117
117
|
canceled_at: null,
|
|
118
118
|
current_period_end: Math.floor(futureDate.getTime() / 1000),
|
|
@@ -115,7 +115,7 @@ module.exports = {
|
|
|
115
115
|
|
|
116
116
|
state.eventId2 = `_test-evt-journey-trial-active-${Date.now()}`;
|
|
117
117
|
|
|
118
|
-
const response = await http.as('none').post(`payments/webhook?processor=test&key=${config.
|
|
118
|
+
const response = await http.as('none').post(`payments/webhook?processor=test&key=${config.backendManagerWebhookKey}`, {
|
|
119
119
|
id: state.eventId2,
|
|
120
120
|
type: 'customer.subscription.updated',
|
|
121
121
|
data: {
|
|
@@ -123,7 +123,7 @@ module.exports = {
|
|
|
123
123
|
id: state.subscriptionId,
|
|
124
124
|
object: 'subscription',
|
|
125
125
|
status: 'active',
|
|
126
|
-
metadata: { uid: state.uid },
|
|
126
|
+
metadata: { uid: state.uid, orderId: state.orderId },
|
|
127
127
|
cancel_at_period_end: false,
|
|
128
128
|
canceled_at: null,
|
|
129
129
|
current_period_end: Math.floor(futureDate.getTime() / 1000),
|
|
@@ -65,7 +65,7 @@ module.exports = {
|
|
|
65
65
|
|
|
66
66
|
state.noUidEventId = `_test-evt-journey-uid-resolve-${Date.now()}`;
|
|
67
67
|
|
|
68
|
-
const response = await http.as('none').post(`payments/webhook?processor=test&key=${config.
|
|
68
|
+
const response = await http.as('none').post(`payments/webhook?processor=test&key=${config.backendManagerWebhookKey}`, {
|
|
69
69
|
id: state.noUidEventId,
|
|
70
70
|
type: 'customer.subscription.updated',
|
|
71
71
|
data: {
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test: Marketing Provider Lifecycle (end-to-end against live SendGrid + Beehiiv)
|
|
3
|
+
*
|
|
4
|
+
* Walks two long-lived test accounts through their full lifecycle and verifies
|
|
5
|
+
* provider state at every transition:
|
|
6
|
+
*
|
|
7
|
+
* 1. Pre-check — both accounts should be absent from SendGrid + Beehiiv
|
|
8
|
+
* 2. Sync — flip consent.marketing.status and call Marketing.sync()
|
|
9
|
+
* 3. Verify — granted account present in both, declined account absent from both
|
|
10
|
+
* 4. Unsubscribe — hit the email-preferences endpoint for the granted account
|
|
11
|
+
* 5. Verify — granted account now absent from both
|
|
12
|
+
*
|
|
13
|
+
* The two accounts use the `_test.allow_*` prefix so they bypass the
|
|
14
|
+
* blocked-local-patterns gate (which blocks plain `_test.*` from reaching
|
|
15
|
+
* providers). They are the only accounts intentionally allowed to round-trip
|
|
16
|
+
* through SendGrid + Beehiiv.
|
|
17
|
+
*
|
|
18
|
+
* Run with TEST_EXTENDED_MODE=true (no-op otherwise). Requires SENDGRID_API_KEY
|
|
19
|
+
* and BEEHIIV_API_KEY in env. Total runtime is ~60-90s — most of it spent waiting
|
|
20
|
+
* for SendGrid's async upsert/delete background jobs to surface.
|
|
21
|
+
*/
|
|
22
|
+
const sendgridProvider = require('../../src/manager/libraries/email/providers/sendgrid.js');
|
|
23
|
+
const beehiivProvider = require('../../src/manager/libraries/email/providers/beehiiv.js');
|
|
24
|
+
|
|
25
|
+
const SETTLE_MS = 5000; // Beehiiv settles in 1-2s; SendGrid's background-job upsert can take 10-20s+
|
|
26
|
+
const POLL_INTERVAL_MS = 2000;
|
|
27
|
+
const POLL_MAX_MS = 90000; // SendGrid's delete-contact job can take 30-60s+ to surface
|
|
28
|
+
|
|
29
|
+
function sleep(ms) {
|
|
30
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Poll a provider lookup until it matches the expected state (present/absent).
|
|
35
|
+
* Returns the resolved value (contact object or null) once condition is met, or
|
|
36
|
+
* the last value seen after timing out.
|
|
37
|
+
*
|
|
38
|
+
* @param {Function} fetchFn - async function that returns contact or null
|
|
39
|
+
* @param {boolean} expectPresent - true = wait until present, false = wait until absent
|
|
40
|
+
*/
|
|
41
|
+
async function pollProvider(fetchFn, expectPresent) {
|
|
42
|
+
const start = Date.now();
|
|
43
|
+
let lastValue = null;
|
|
44
|
+
|
|
45
|
+
while (Date.now() - start < POLL_MAX_MS) {
|
|
46
|
+
lastValue = await fetchFn();
|
|
47
|
+
const present = !!lastValue;
|
|
48
|
+
if (present === expectPresent) {
|
|
49
|
+
return lastValue;
|
|
50
|
+
}
|
|
51
|
+
await sleep(POLL_INTERVAL_MS);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return lastValue;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
module.exports = {
|
|
58
|
+
description: 'Marketing provider lifecycle (live SendGrid + Beehiiv round-trip)',
|
|
59
|
+
type: 'group',
|
|
60
|
+
skip: !process.env.TEST_EXTENDED_MODE
|
|
61
|
+
? 'TEST_EXTENDED_MODE not set (this test hits live SendGrid + Beehiiv APIs)'
|
|
62
|
+
: false,
|
|
63
|
+
tests: [
|
|
64
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
65
|
+
// Phase 1 — Pre-check: both accounts should be absent from providers
|
|
66
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
67
|
+
{
|
|
68
|
+
name: 'phase-1-pre-check-both-accounts-absent',
|
|
69
|
+
auth: 'admin',
|
|
70
|
+
timeout: 180000,
|
|
71
|
+
|
|
72
|
+
async run({ accounts, assert }) {
|
|
73
|
+
const granted = accounts['consent-granted'];
|
|
74
|
+
const declined = accounts['consent-declined'];
|
|
75
|
+
|
|
76
|
+
// Force-clean any leftovers from a prior run (e.g. the previous phase-2a
|
|
77
|
+
// left a contact in SendGrid that THIS run's phase-1 needs to start
|
|
78
|
+
// without). SendGrid's delete is an async job, so wait for absence.
|
|
79
|
+
await sendgridProvider.removeContact(granted.email);
|
|
80
|
+
await sendgridProvider.removeContact(declined.email);
|
|
81
|
+
await beehiivProvider.removeContact(granted.email);
|
|
82
|
+
await beehiivProvider.removeContact(declined.email);
|
|
83
|
+
|
|
84
|
+
const grantedSg = await pollProvider(() => sendgridProvider.findContact(granted.email), false);
|
|
85
|
+
const declinedSg = await pollProvider(() => sendgridProvider.findContact(declined.email), false);
|
|
86
|
+
const grantedBh = await pollProvider(() => beehiivProvider.findContact(granted.email), false);
|
|
87
|
+
const declinedBh = await pollProvider(() => beehiivProvider.findContact(declined.email), false);
|
|
88
|
+
|
|
89
|
+
assert.equal(grantedSg, null, 'granted account should be absent from SendGrid');
|
|
90
|
+
assert.equal(declinedSg, null, 'declined account should be absent from SendGrid');
|
|
91
|
+
assert.equal(grantedBh, null, 'granted account should be absent from Beehiiv');
|
|
92
|
+
assert.equal(declinedBh, null, 'declined account should be absent from Beehiiv');
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
|
|
96
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
97
|
+
// Phase 2 — Sync granted account → both providers
|
|
98
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
99
|
+
{
|
|
100
|
+
name: 'phase-2a-granted-account-syncs-to-both-providers',
|
|
101
|
+
auth: 'admin',
|
|
102
|
+
timeout: 180000,
|
|
103
|
+
|
|
104
|
+
async run({ accounts, assert, Manager, assistant }) {
|
|
105
|
+
const granted = accounts['consent-granted'];
|
|
106
|
+
const admin = Manager.libraries.admin;
|
|
107
|
+
|
|
108
|
+
// Set consent.marketing.status = 'granted' on the user doc
|
|
109
|
+
await admin.firestore().doc(`users/${granted.uid}`).set({
|
|
110
|
+
consent: {
|
|
111
|
+
marketing: {
|
|
112
|
+
status: 'granted',
|
|
113
|
+
grantedAt: {
|
|
114
|
+
timestamp: new Date().toISOString(),
|
|
115
|
+
timestampUNIX: Math.floor(Date.now() / 1000),
|
|
116
|
+
source: 'test',
|
|
117
|
+
ip: null,
|
|
118
|
+
text: 'consent-lifecycle test',
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
legal: {
|
|
122
|
+
status: 'granted',
|
|
123
|
+
grantedAt: {
|
|
124
|
+
timestamp: new Date().toISOString(),
|
|
125
|
+
timestampUNIX: Math.floor(Date.now() / 1000),
|
|
126
|
+
source: 'test',
|
|
127
|
+
ip: null,
|
|
128
|
+
text: 'consent-lifecycle test',
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
}, { merge: true });
|
|
133
|
+
|
|
134
|
+
// Trigger marketing sync via the Email() surface
|
|
135
|
+
const result = await Manager.Email(assistant).sync(granted.uid);
|
|
136
|
+
assert.ok(result, 'sync should return a result');
|
|
137
|
+
assert.notEqual(result.blocked, 'validation', 'sync should not be blocked by validation (uses _test.allow_*)');
|
|
138
|
+
|
|
139
|
+
// Poll providers until they reflect the upsert. SendGrid's upsert is
|
|
140
|
+
// an async background job (returns a job_id, not the inserted contact)
|
|
141
|
+
// so a single check 5s later isn't enough — it can take 10-20s to
|
|
142
|
+
// surface. Beehiiv is usually instant but uses the same poll for symmetry.
|
|
143
|
+
const sgContact = await pollProvider(() => sendgridProvider.findContact(granted.email), true);
|
|
144
|
+
const bhContact = await pollProvider(() => beehiivProvider.findContact(granted.email), true);
|
|
145
|
+
|
|
146
|
+
assert.ok(sgContact, 'granted account should now exist in SendGrid');
|
|
147
|
+
assert.ok(bhContact, 'granted account should now exist in Beehiiv');
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
|
|
151
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
152
|
+
// Phase 2b — Declined account: verify it stays out of providers when we
|
|
153
|
+
// DON'T call sync (which is what the signup route does for declined
|
|
154
|
+
// marketing consent).
|
|
155
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
156
|
+
{
|
|
157
|
+
name: 'phase-2b-declined-account-stays-out-of-providers',
|
|
158
|
+
auth: 'admin',
|
|
159
|
+
timeout: 180000,
|
|
160
|
+
|
|
161
|
+
async run({ accounts, assert, Manager, assistant }) {
|
|
162
|
+
const declined = accounts['consent-declined'];
|
|
163
|
+
const admin = Manager.libraries.admin;
|
|
164
|
+
|
|
165
|
+
// Set consent.marketing.status = 'revoked' on the user doc.
|
|
166
|
+
// We deliberately do NOT call sync — the production signup route gates
|
|
167
|
+
// on consent.marketing.status === 'granted' before calling sync, so a
|
|
168
|
+
// declined user simply never gets a sync call. We verify that contract
|
|
169
|
+
// by NOT calling sync and asserting the user stays absent.
|
|
170
|
+
await admin.firestore().doc(`users/${declined.uid}`).set({
|
|
171
|
+
consent: {
|
|
172
|
+
marketing: {
|
|
173
|
+
status: 'revoked',
|
|
174
|
+
grantedAt: { timestamp: null, timestampUNIX: null, source: null, ip: null, text: null },
|
|
175
|
+
revokedAt: {
|
|
176
|
+
timestamp: new Date().toISOString(),
|
|
177
|
+
timestampUNIX: Math.floor(Date.now() / 1000),
|
|
178
|
+
source: 'test',
|
|
179
|
+
ip: null,
|
|
180
|
+
text: null,
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
legal: {
|
|
184
|
+
status: 'granted',
|
|
185
|
+
grantedAt: {
|
|
186
|
+
timestamp: new Date().toISOString(),
|
|
187
|
+
timestampUNIX: Math.floor(Date.now() / 1000),
|
|
188
|
+
source: 'test',
|
|
189
|
+
ip: null,
|
|
190
|
+
text: 'consent-lifecycle test',
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
}, { merge: true });
|
|
195
|
+
|
|
196
|
+
await sleep(SETTLE_MS);
|
|
197
|
+
|
|
198
|
+
const sgContact = await sendgridProvider.findContact(declined.email);
|
|
199
|
+
const bhContact = await beehiivProvider.findContact(declined.email);
|
|
200
|
+
|
|
201
|
+
assert.equal(sgContact, null, 'declined account should remain absent from SendGrid');
|
|
202
|
+
assert.equal(bhContact, null, 'declined account should remain absent from Beehiiv');
|
|
203
|
+
},
|
|
204
|
+
},
|
|
205
|
+
|
|
206
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
207
|
+
// Phase 3 — Unsubscribe: granted account is removed via Manager.Email().remove
|
|
208
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
209
|
+
{
|
|
210
|
+
name: 'phase-3-granted-account-unsubscribe-removes-from-both',
|
|
211
|
+
auth: 'admin',
|
|
212
|
+
timeout: 180000,
|
|
213
|
+
|
|
214
|
+
async run({ accounts, assert, Manager, assistant }) {
|
|
215
|
+
const granted = accounts['consent-granted'];
|
|
216
|
+
|
|
217
|
+
// Trigger removal — simulates the email-preferences opt-out flow
|
|
218
|
+
const result = await Manager.Email(assistant).remove(granted.email);
|
|
219
|
+
assert.ok(result, 'remove should return a result');
|
|
220
|
+
|
|
221
|
+
// Poll for absence — SendGrid's contact delete is also an async job.
|
|
222
|
+
const sgContact = await pollProvider(() => sendgridProvider.findContact(granted.email), false);
|
|
223
|
+
const bhContact = await pollProvider(() => beehiivProvider.findContact(granted.email), false);
|
|
224
|
+
|
|
225
|
+
assert.equal(sgContact, null, 'granted account should now be absent from SendGrid');
|
|
226
|
+
assert.equal(bhContact, null, 'granted account should now be absent from Beehiiv');
|
|
227
|
+
},
|
|
228
|
+
},
|
|
229
|
+
|
|
230
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
231
|
+
// Phase 4 — Validation gate: _test.* (non-allow) is blocked by validate()
|
|
232
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
233
|
+
{
|
|
234
|
+
name: 'phase-4-non-allow-test-email-blocked-by-validation',
|
|
235
|
+
auth: 'admin',
|
|
236
|
+
timeout: 30000,
|
|
237
|
+
|
|
238
|
+
async run({ assert, Manager, assistant }) {
|
|
239
|
+
// _test.never-reaches-providers@... is NOT _test.allow_* → should be blocked
|
|
240
|
+
const blockedEmail = '_test.never-reaches-providers@somiibo.com';
|
|
241
|
+
|
|
242
|
+
const result = await Manager.Email(assistant).add({ email: blockedEmail });
|
|
243
|
+
|
|
244
|
+
assert.equal(result.blocked, 'validation', 'non-allow _test.* email should be blocked by validation');
|
|
245
|
+
|
|
246
|
+
// And the provider lookup should show nothing
|
|
247
|
+
const sgContact = await sendgridProvider.findContact(blockedEmail);
|
|
248
|
+
const bhContact = await beehiivProvider.findContact(blockedEmail);
|
|
249
|
+
|
|
250
|
+
assert.equal(sgContact, null, 'blocked email should never appear in SendGrid');
|
|
251
|
+
assert.equal(bhContact, null, 'blocked email should never appear in Beehiiv');
|
|
252
|
+
},
|
|
253
|
+
},
|
|
254
|
+
],
|
|
255
|
+
};
|
|
@@ -32,7 +32,7 @@ module.exports = {
|
|
|
32
32
|
name: 'rejects-unknown-provider',
|
|
33
33
|
auth: 'none',
|
|
34
34
|
async run({ http, assert }) {
|
|
35
|
-
const response = await http.as('none').post(`payments/dispute-alert?provider=unknown&key=${process.env.
|
|
35
|
+
const response = await http.as('none').post(`payments/dispute-alert?provider=unknown&key=${process.env.BACKEND_MANAGER_WEBHOOK_KEY}`, {
|
|
36
36
|
id: '_test-dispute-unknown-provider',
|
|
37
37
|
card: '4242',
|
|
38
38
|
amount: 9.99,
|
|
@@ -47,7 +47,7 @@ module.exports = {
|
|
|
47
47
|
name: 'rejects-missing-id',
|
|
48
48
|
auth: 'none',
|
|
49
49
|
async run({ http, assert }) {
|
|
50
|
-
const response = await http.as('none').post(`payments/dispute-alert?key=${process.env.
|
|
50
|
+
const response = await http.as('none').post(`payments/dispute-alert?key=${process.env.BACKEND_MANAGER_WEBHOOK_KEY}`, {
|
|
51
51
|
card: '4242',
|
|
52
52
|
amount: 9.99,
|
|
53
53
|
transactionDate: '2026-01-15',
|
|
@@ -61,7 +61,7 @@ module.exports = {
|
|
|
61
61
|
name: 'rejects-missing-card',
|
|
62
62
|
auth: 'none',
|
|
63
63
|
async run({ http, assert }) {
|
|
64
|
-
const response = await http.as('none').post(`payments/dispute-alert?key=${process.env.
|
|
64
|
+
const response = await http.as('none').post(`payments/dispute-alert?key=${process.env.BACKEND_MANAGER_WEBHOOK_KEY}`, {
|
|
65
65
|
id: '_test-dispute-no-card',
|
|
66
66
|
amount: 9.99,
|
|
67
67
|
transactionDate: '2026-01-15',
|
|
@@ -75,7 +75,7 @@ module.exports = {
|
|
|
75
75
|
name: 'rejects-missing-amount',
|
|
76
76
|
auth: 'none',
|
|
77
77
|
async run({ http, assert }) {
|
|
78
|
-
const response = await http.as('none').post(`payments/dispute-alert?key=${process.env.
|
|
78
|
+
const response = await http.as('none').post(`payments/dispute-alert?key=${process.env.BACKEND_MANAGER_WEBHOOK_KEY}`, {
|
|
79
79
|
id: '_test-dispute-no-amount',
|
|
80
80
|
card: '4242',
|
|
81
81
|
transactionDate: '2026-01-15',
|
|
@@ -89,7 +89,7 @@ module.exports = {
|
|
|
89
89
|
name: 'rejects-missing-transaction-date',
|
|
90
90
|
auth: 'none',
|
|
91
91
|
async run({ http, assert }) {
|
|
92
|
-
const response = await http.as('none').post(`payments/dispute-alert?key=${process.env.
|
|
92
|
+
const response = await http.as('none').post(`payments/dispute-alert?key=${process.env.BACKEND_MANAGER_WEBHOOK_KEY}`, {
|
|
93
93
|
id: '_test-dispute-no-date',
|
|
94
94
|
card: '4242',
|
|
95
95
|
amount: 9.99,
|
|
@@ -105,7 +105,7 @@ module.exports = {
|
|
|
105
105
|
async run({ http, assert, firestore }) {
|
|
106
106
|
const alertId = '_test-dispute-valid';
|
|
107
107
|
|
|
108
|
-
const response = await http.as('none').post(`payments/dispute-alert?key=${process.env.
|
|
108
|
+
const response = await http.as('none').post(`payments/dispute-alert?key=${process.env.BACKEND_MANAGER_WEBHOOK_KEY}`, {
|
|
109
109
|
id: alertId,
|
|
110
110
|
card: '4242424242424242',
|
|
111
111
|
cardBrand: 'Visa',
|
|
@@ -164,7 +164,7 @@ module.exports = {
|
|
|
164
164
|
const alertId = '_test-dispute-alertid-field';
|
|
165
165
|
|
|
166
166
|
// Chargeblast alert.created events use alertId instead of id
|
|
167
|
-
const response = await http.as('none').post(`payments/dispute-alert?key=${process.env.
|
|
167
|
+
const response = await http.as('none').post(`payments/dispute-alert?key=${process.env.BACKEND_MANAGER_WEBHOOK_KEY}`, {
|
|
168
168
|
alertId: alertId,
|
|
169
169
|
card: '546616******5805',
|
|
170
170
|
cardBrand: 'Mastercard',
|
|
@@ -189,7 +189,7 @@ module.exports = {
|
|
|
189
189
|
const alertId = '_test-dispute-minimal';
|
|
190
190
|
|
|
191
191
|
// Send minimal alert (alert.created shape — no externalOrder, metadata, etc.)
|
|
192
|
-
const response = await http.as('none').post(`payments/dispute-alert?key=${process.env.
|
|
192
|
+
const response = await http.as('none').post(`payments/dispute-alert?key=${process.env.BACKEND_MANAGER_WEBHOOK_KEY}`, {
|
|
193
193
|
id: alertId,
|
|
194
194
|
card: '9124',
|
|
195
195
|
amount: 10,
|
|
@@ -218,7 +218,7 @@ module.exports = {
|
|
|
218
218
|
async run({ http, assert, firestore }) {
|
|
219
219
|
const alertId = '_test-dispute-last4';
|
|
220
220
|
|
|
221
|
-
const response = await http.as('none').post(`payments/dispute-alert?key=${process.env.
|
|
221
|
+
const response = await http.as('none').post(`payments/dispute-alert?key=${process.env.BACKEND_MANAGER_WEBHOOK_KEY}`, {
|
|
222
222
|
id: alertId,
|
|
223
223
|
card: '1234',
|
|
224
224
|
amount: 9.99,
|
|
@@ -240,7 +240,7 @@ module.exports = {
|
|
|
240
240
|
const alertId = '_test-dispute-duplicate';
|
|
241
241
|
|
|
242
242
|
// Send first alert
|
|
243
|
-
await http.as('none').post(`payments/dispute-alert?key=${process.env.
|
|
243
|
+
await http.as('none').post(`payments/dispute-alert?key=${process.env.BACKEND_MANAGER_WEBHOOK_KEY}`, {
|
|
244
244
|
id: alertId,
|
|
245
245
|
card: '4242',
|
|
246
246
|
amount: 29.99,
|
|
@@ -248,7 +248,7 @@ module.exports = {
|
|
|
248
248
|
});
|
|
249
249
|
|
|
250
250
|
// Send duplicate
|
|
251
|
-
const response = await http.as('none').post(`payments/dispute-alert?key=${process.env.
|
|
251
|
+
const response = await http.as('none').post(`payments/dispute-alert?key=${process.env.BACKEND_MANAGER_WEBHOOK_KEY}`, {
|
|
252
252
|
id: alertId,
|
|
253
253
|
card: '4242',
|
|
254
254
|
amount: 29.99,
|
|
@@ -274,7 +274,7 @@ module.exports = {
|
|
|
274
274
|
});
|
|
275
275
|
|
|
276
276
|
// Send alert with same ID — should retry since previous status was 'failed'
|
|
277
|
-
const response = await http.as('none').post(`payments/dispute-alert?key=${process.env.
|
|
277
|
+
const response = await http.as('none').post(`payments/dispute-alert?key=${process.env.BACKEND_MANAGER_WEBHOOK_KEY}`, {
|
|
278
278
|
id: alertId,
|
|
279
279
|
card: '4242',
|
|
280
280
|
amount: 29.99,
|
|
@@ -300,7 +300,7 @@ module.exports = {
|
|
|
300
300
|
const alertId = '_test-dispute-default-provider';
|
|
301
301
|
|
|
302
302
|
// Send without provider query param
|
|
303
|
-
const response = await http.as('none').post(`payments/dispute-alert?key=${process.env.
|
|
303
|
+
const response = await http.as('none').post(`payments/dispute-alert?key=${process.env.BACKEND_MANAGER_WEBHOOK_KEY}`, {
|
|
304
304
|
id: alertId,
|
|
305
305
|
card: '4242',
|
|
306
306
|
amount: 9.99,
|