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
|
@@ -225,7 +225,7 @@ function fireMeta({ resolved, currency, uid, processor, assistant, config }) {
|
|
|
225
225
|
method: 'post',
|
|
226
226
|
response: 'json',
|
|
227
227
|
body: payload,
|
|
228
|
-
timeout:
|
|
228
|
+
timeout: 60000,
|
|
229
229
|
tries: 2,
|
|
230
230
|
})
|
|
231
231
|
.then(() => {
|
|
@@ -298,7 +298,7 @@ function fireTikTok({ resolved, currency, uid, processor, assistant, config }) {
|
|
|
298
298
|
'Access-Token': accessToken,
|
|
299
299
|
},
|
|
300
300
|
body: { data: [payload] },
|
|
301
|
-
timeout:
|
|
301
|
+
timeout: 60000,
|
|
302
302
|
tries: 2,
|
|
303
303
|
})
|
|
304
304
|
.then(() => {
|
|
@@ -35,7 +35,7 @@ Middleware.prototype.run = function (libPath, options) {
|
|
|
35
35
|
options.setupAnalytics = typeof options.setupAnalytics === 'boolean' ? options.setupAnalytics : true;
|
|
36
36
|
options.setupUsage = typeof options.setupUsage === 'boolean' ? options.setupUsage : true;
|
|
37
37
|
options.setupSettings = typeof options.setupSettings === 'undefined' ? true : options.setupSettings;
|
|
38
|
-
options.sanitize = typeof options.sanitize === 'undefined' ?
|
|
38
|
+
options.sanitize = typeof options.sanitize === 'undefined' ? false : options.sanitize;
|
|
39
39
|
options.includeNonSchemaSettings = typeof options.includeNonSchemaSettings === 'undefined' ? false : options.includeNonSchemaSettings;
|
|
40
40
|
options.schema = typeof options.schema === 'undefined' ? libPath : options.schema;
|
|
41
41
|
options.parseMultipartFormData = typeof options.parseMultipartFormData === 'undefined' ? true : options.parseMultipartFormData;
|
|
@@ -177,13 +177,16 @@ Middleware.prototype.run = function (libPath, options) {
|
|
|
177
177
|
assistant.settings = data;
|
|
178
178
|
}
|
|
179
179
|
|
|
180
|
-
//
|
|
181
|
-
|
|
180
|
+
// Trim whitespace on all string settings (always on — harmless and useful).
|
|
181
|
+
assistant.settings = Manager.Utilities().trim(assistant.settings);
|
|
182
|
+
|
|
183
|
+
// Optional HTML strip (off by default — opt in with `{ sanitize: true }`).
|
|
184
|
+
// Sanitize at the HTML-insertion site instead unless you need a belt-and-suspenders pass here.
|
|
185
|
+
// Respects sanitize: false on individual schema fields.
|
|
182
186
|
if (options.sanitize) {
|
|
183
187
|
const schema = options.setupSettings ? Manager.Settings().schema : null;
|
|
184
188
|
const utilities = Manager.Utilities();
|
|
185
189
|
|
|
186
|
-
// Walk settings, skipping fields the schema marks as sanitize: false
|
|
187
190
|
assistant.settings = sanitizeWithSchema(utilities, assistant.settings, schema);
|
|
188
191
|
}
|
|
189
192
|
|
|
@@ -528,4 +528,35 @@ Utilities.prototype.sanitize = function (input) {
|
|
|
528
528
|
return input;
|
|
529
529
|
};
|
|
530
530
|
|
|
531
|
+
/**
|
|
532
|
+
* Trim whitespace from all strings in input. Walks objects/arrays recursively.
|
|
533
|
+
* Does NOT strip HTML — use sanitize() for that.
|
|
534
|
+
*
|
|
535
|
+
* @param {*} input - The data to trim (string, object, array, or primitive)
|
|
536
|
+
* @returns {*} Trimmed copy (objects/arrays) or trimmed value (strings)
|
|
537
|
+
*/
|
|
538
|
+
Utilities.prototype.trim = function (input) {
|
|
539
|
+
if (input == null) {
|
|
540
|
+
return input;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
if (typeof input === 'string') {
|
|
544
|
+
return input.trim();
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
if (Array.isArray(input)) {
|
|
548
|
+
return input.map(item => this.trim(item));
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
if (typeof input === 'object') {
|
|
552
|
+
const result = {};
|
|
553
|
+
for (const [key, value] of Object.entries(input)) {
|
|
554
|
+
result[key] = this.trim(value);
|
|
555
|
+
}
|
|
556
|
+
return result;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
return input;
|
|
560
|
+
};
|
|
561
|
+
|
|
531
562
|
module.exports = Utilities;
|
|
@@ -517,7 +517,7 @@ async function fetchSources(parentUrl, categories, brandId, assistant) {
|
|
|
517
517
|
const data = await fetch(`${parentUrl}/newsletter-sources`, {
|
|
518
518
|
method: 'get',
|
|
519
519
|
response: 'json',
|
|
520
|
-
timeout:
|
|
520
|
+
timeout: 60000,
|
|
521
521
|
query: {
|
|
522
522
|
category,
|
|
523
523
|
limit: 3,
|
|
@@ -546,7 +546,7 @@ async function claimSources(parentUrl, sources, brandId, assistant) {
|
|
|
546
546
|
await fetch(`${parentUrl}/newsletter-sources`, {
|
|
547
547
|
method: 'put',
|
|
548
548
|
response: 'json',
|
|
549
|
-
timeout:
|
|
549
|
+
timeout: 60000,
|
|
550
550
|
body: {
|
|
551
551
|
id: source.id,
|
|
552
552
|
usedBy: brandId || 'unknown',
|
|
@@ -9,6 +9,11 @@ const { FIELDS, resolveFieldValues } = require('../constants.js');
|
|
|
9
9
|
|
|
10
10
|
const BASE_URL = 'https://api.beehiiv.com/v2';
|
|
11
11
|
|
|
12
|
+
// Beehiiv API spikes past 10s during their hiccups, dropping signups silently.
|
|
13
|
+
// 60s is generous but harmless — caches are in place for metadata calls so a
|
|
14
|
+
// slow first call costs nothing in steady state.
|
|
15
|
+
const BEEHIIV_TIMEOUT_MS = 60000;
|
|
16
|
+
|
|
12
17
|
// --- Internal helpers ---
|
|
13
18
|
|
|
14
19
|
function headers() {
|
|
@@ -63,7 +68,7 @@ async function addSubscriber({ email, firstName, lastName, source, publicationId
|
|
|
63
68
|
method: 'post',
|
|
64
69
|
response: 'json',
|
|
65
70
|
headers: headers(),
|
|
66
|
-
timeout:
|
|
71
|
+
timeout: BEEHIIV_TIMEOUT_MS,
|
|
67
72
|
body,
|
|
68
73
|
});
|
|
69
74
|
|
|
@@ -79,39 +84,55 @@ async function addSubscriber({ email, firstName, lastName, source, publicationId
|
|
|
79
84
|
}
|
|
80
85
|
|
|
81
86
|
/**
|
|
82
|
-
*
|
|
87
|
+
* Look up a Beehiiv subscriber by email. Returns the subscription object
|
|
88
|
+
* (id, email, status, custom_fields, ...) or null if not found.
|
|
89
|
+
*
|
|
90
|
+
* Useful for tests that need to verify whether a subscriber landed in the
|
|
91
|
+
* publication after a marketing sync.
|
|
83
92
|
*
|
|
84
93
|
* @param {string} email
|
|
85
94
|
* @param {string} publicationId
|
|
86
|
-
* @returns {
|
|
95
|
+
* @returns {Promise<object|null>}
|
|
87
96
|
*/
|
|
88
|
-
async function
|
|
97
|
+
async function findSubscriber(email, publicationId) {
|
|
89
98
|
try {
|
|
90
99
|
const encodedEmail = encodeURIComponent(email);
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
{
|
|
98
|
-
response: 'json',
|
|
99
|
-
headers: headers(),
|
|
100
|
-
timeout: 10000,
|
|
101
|
-
}
|
|
102
|
-
);
|
|
103
|
-
} catch (e) {
|
|
104
|
-
if (e.status === 404) {
|
|
105
|
-
return { success: true, skipped: true, reason: 'Subscriber not found' };
|
|
100
|
+
const searchData = await fetch(
|
|
101
|
+
`${BASE_URL}/publications/${publicationId}/subscriptions/by_email/${encodedEmail}`,
|
|
102
|
+
{
|
|
103
|
+
response: 'json',
|
|
104
|
+
headers: headers(),
|
|
105
|
+
timeout: BEEHIIV_TIMEOUT_MS,
|
|
106
106
|
}
|
|
107
|
-
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
return searchData.data || null;
|
|
110
|
+
} catch (e) {
|
|
111
|
+
if (e.status === 404) {
|
|
112
|
+
return null;
|
|
108
113
|
}
|
|
114
|
+
console.error('Beehiiv findSubscriber error:', e);
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Remove a subscriber from a Beehiiv publication by email.
|
|
121
|
+
*
|
|
122
|
+
* @param {string} email
|
|
123
|
+
* @param {string} publicationId
|
|
124
|
+
* @returns {{ success: boolean, deleted?: boolean, skipped?: boolean, error?: string }}
|
|
125
|
+
*/
|
|
126
|
+
async function removeSubscriber(email, publicationId) {
|
|
127
|
+
try {
|
|
128
|
+
// Step 1: Look up the subscription
|
|
129
|
+
const subscription = await findSubscriber(email, publicationId);
|
|
109
130
|
|
|
110
|
-
if (!
|
|
111
|
-
return { success: true, skipped: true, reason: '
|
|
131
|
+
if (!subscription?.id) {
|
|
132
|
+
return { success: true, skipped: true, reason: 'Subscriber not found' };
|
|
112
133
|
}
|
|
113
134
|
|
|
114
|
-
const subscriptionId =
|
|
135
|
+
const subscriptionId = subscription.id;
|
|
115
136
|
|
|
116
137
|
// Step 2: Permanently delete the subscription
|
|
117
138
|
await fetch(
|
|
@@ -119,7 +140,7 @@ async function removeSubscriber(email, publicationId) {
|
|
|
119
140
|
{
|
|
120
141
|
method: 'delete',
|
|
121
142
|
headers: headers(),
|
|
122
|
-
timeout:
|
|
143
|
+
timeout: BEEHIIV_TIMEOUT_MS,
|
|
123
144
|
}
|
|
124
145
|
);
|
|
125
146
|
|
|
@@ -186,7 +207,7 @@ function getPublicationId() {
|
|
|
186
207
|
// const data = await fetch(`${BASE_URL}/publications?limit=${limit}&page=${page}`, {
|
|
187
208
|
// response: 'json',
|
|
188
209
|
// headers: headers(),
|
|
189
|
-
// timeout:
|
|
210
|
+
// timeout: BEEHIIV_TIMEOUT_MS,
|
|
190
211
|
// });
|
|
191
212
|
//
|
|
192
213
|
// if (!data.data || data.data.length === 0) {
|
|
@@ -270,6 +291,25 @@ async function removeContact(email) {
|
|
|
270
291
|
return removeSubscriber(email, publicationId);
|
|
271
292
|
}
|
|
272
293
|
|
|
294
|
+
/**
|
|
295
|
+
* Look up a contact in this brand's Beehiiv publication. Resolves the
|
|
296
|
+
* publicationId from config and calls findSubscriber. Mirrors SendGrid's
|
|
297
|
+
* findContact() surface so tests can use the same pattern across both
|
|
298
|
+
* providers.
|
|
299
|
+
*
|
|
300
|
+
* @param {string} email
|
|
301
|
+
* @returns {Promise<object|null>}
|
|
302
|
+
*/
|
|
303
|
+
async function findContact(email) {
|
|
304
|
+
const publicationId = getPublicationId();
|
|
305
|
+
|
|
306
|
+
if (!publicationId) {
|
|
307
|
+
return null;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return findSubscriber(email, publicationId);
|
|
311
|
+
}
|
|
312
|
+
|
|
273
313
|
/**
|
|
274
314
|
* Build Beehiiv custom_fields array from a user doc.
|
|
275
315
|
* Resolves all field values, then maps to display names for Beehiiv.
|
|
@@ -316,7 +356,7 @@ async function resolveSegmentIds() {
|
|
|
316
356
|
const data = await fetch(`${BASE_URL}/publications/${publicationId}/segments?limit=100`, {
|
|
317
357
|
response: 'json',
|
|
318
358
|
headers: headers(),
|
|
319
|
-
timeout:
|
|
359
|
+
timeout: BEEHIIV_TIMEOUT_MS,
|
|
320
360
|
});
|
|
321
361
|
|
|
322
362
|
_segmentIdCache = {};
|
|
@@ -412,7 +452,7 @@ async function createPost(options) {
|
|
|
412
452
|
method: 'post',
|
|
413
453
|
response: 'json',
|
|
414
454
|
headers: headers(),
|
|
415
|
-
timeout:
|
|
455
|
+
timeout: BEEHIIV_TIMEOUT_MS,
|
|
416
456
|
body,
|
|
417
457
|
});
|
|
418
458
|
|
|
@@ -435,6 +475,8 @@ module.exports = {
|
|
|
435
475
|
|
|
436
476
|
// Contacts
|
|
437
477
|
addContact,
|
|
478
|
+
findContact,
|
|
479
|
+
findSubscriber,
|
|
438
480
|
removeContact,
|
|
439
481
|
buildFields,
|
|
440
482
|
|
|
@@ -115,7 +115,7 @@ async function upsertContacts({ contacts, listIds }) {
|
|
|
115
115
|
method: 'put',
|
|
116
116
|
response: 'json',
|
|
117
117
|
headers: headers(),
|
|
118
|
-
timeout:
|
|
118
|
+
timeout: SENDGRID_TIMEOUT_MS,
|
|
119
119
|
body,
|
|
120
120
|
});
|
|
121
121
|
|
|
@@ -131,14 +131,17 @@ async function upsertContacts({ contacts, listIds }) {
|
|
|
131
131
|
}
|
|
132
132
|
|
|
133
133
|
/**
|
|
134
|
-
*
|
|
134
|
+
* Look up a SendGrid contact by email. Returns the contact object (id, email,
|
|
135
|
+
* list_ids, custom_fields, ...) or null if not found.
|
|
136
|
+
*
|
|
137
|
+
* Useful for tests that need to verify whether a contact landed in the list
|
|
138
|
+
* after a marketing sync.
|
|
135
139
|
*
|
|
136
140
|
* @param {string} email
|
|
137
|
-
* @returns {
|
|
141
|
+
* @returns {Promise<object|null>}
|
|
138
142
|
*/
|
|
139
|
-
async function
|
|
143
|
+
async function findContact(email) {
|
|
140
144
|
try {
|
|
141
|
-
// Step 1: Get contact ID by email
|
|
142
145
|
const searchData = await fetch(`${BASE_URL}/marketing/contacts/search/emails`, {
|
|
143
146
|
method: 'post',
|
|
144
147
|
response: 'json',
|
|
@@ -147,11 +150,33 @@ async function removeContact(email) {
|
|
|
147
150
|
body: { emails: [email] },
|
|
148
151
|
});
|
|
149
152
|
|
|
150
|
-
|
|
153
|
+
return searchData.result?.[email]?.contact || null;
|
|
154
|
+
} catch (e) {
|
|
155
|
+
// 404 is the normal "not in contacts" response — return null silently.
|
|
156
|
+
if (e.status === 404) {
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
console.error('SendGrid findContact error:', e);
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Remove a contact from SendGrid by email address.
|
|
166
|
+
*
|
|
167
|
+
* @param {string} email
|
|
168
|
+
* @returns {{ success: boolean, jobId?: string, skipped?: boolean, error?: string }}
|
|
169
|
+
*/
|
|
170
|
+
async function removeContact(email) {
|
|
171
|
+
try {
|
|
172
|
+
// Step 1: Get contact ID by email
|
|
173
|
+
const contact = await findContact(email);
|
|
174
|
+
|
|
175
|
+
if (!contact?.id) {
|
|
151
176
|
return { success: true, skipped: true, reason: 'Contact not found' };
|
|
152
177
|
}
|
|
153
178
|
|
|
154
|
-
const contactId =
|
|
179
|
+
const contactId = contact.id;
|
|
155
180
|
|
|
156
181
|
// Step 2: Delete contact by ID
|
|
157
182
|
const deleteData = await fetch(`${BASE_URL}/marketing/contacts?ids=${contactId}`, {
|
|
@@ -332,7 +357,7 @@ async function createSingleSend({ name, subject, preheader, templateId, from, se
|
|
|
332
357
|
method: 'post',
|
|
333
358
|
response: 'json',
|
|
334
359
|
headers: headers(),
|
|
335
|
-
timeout:
|
|
360
|
+
timeout: SENDGRID_TIMEOUT_MS,
|
|
336
361
|
body,
|
|
337
362
|
});
|
|
338
363
|
|
|
@@ -360,7 +385,7 @@ async function scheduleSingleSend(singleSendId, sendAt) {
|
|
|
360
385
|
method: 'put',
|
|
361
386
|
response: 'json',
|
|
362
387
|
headers: headers(),
|
|
363
|
-
timeout:
|
|
388
|
+
timeout: SENDGRID_TIMEOUT_MS,
|
|
364
389
|
body: { send_at: sendAt },
|
|
365
390
|
});
|
|
366
391
|
|
|
@@ -520,7 +545,7 @@ async function getSegmentContacts(segmentId, maxWaitMs = 60000) {
|
|
|
520
545
|
method: 'post',
|
|
521
546
|
response: 'json',
|
|
522
547
|
headers: headers(),
|
|
523
|
-
timeout:
|
|
548
|
+
timeout: SENDGRID_TIMEOUT_MS,
|
|
524
549
|
body: { segment_ids: [segmentId] },
|
|
525
550
|
});
|
|
526
551
|
|
|
@@ -550,7 +575,7 @@ async function getSegmentContacts(segmentId, maxWaitMs = 60000) {
|
|
|
550
575
|
// Download CSV — disable cacheBreaker to preserve presigned S3 URL signature
|
|
551
576
|
const csvText = await fetch(statusData.urls[0], {
|
|
552
577
|
response: 'text',
|
|
553
|
-
timeout:
|
|
578
|
+
timeout: 60000,
|
|
554
579
|
cacheBreaker: false,
|
|
555
580
|
});
|
|
556
581
|
|
|
@@ -614,7 +639,7 @@ async function bulkDeleteContacts(contactIds) {
|
|
|
614
639
|
method: 'delete',
|
|
615
640
|
response: 'json',
|
|
616
641
|
headers: headers(),
|
|
617
|
-
timeout:
|
|
642
|
+
timeout: SENDGRID_TIMEOUT_MS,
|
|
618
643
|
});
|
|
619
644
|
|
|
620
645
|
if (data.job_id) {
|
|
@@ -635,6 +660,7 @@ module.exports = {
|
|
|
635
660
|
|
|
636
661
|
// Contacts
|
|
637
662
|
addContact,
|
|
663
|
+
findContact,
|
|
638
664
|
removeContact,
|
|
639
665
|
getSegmentContacts,
|
|
640
666
|
bulkDeleteContacts,
|
|
@@ -133,7 +133,7 @@ async function validate(email, options = {}) {
|
|
|
133
133
|
try {
|
|
134
134
|
const data = await fetch(
|
|
135
135
|
`https://api.zerobounce.net/v2/validate?api_key=${process.env.ZEROBOUNCE_API_KEY}&email=${encodeURIComponent(email)}`,
|
|
136
|
-
{ response: 'json', timeout:
|
|
136
|
+
{ response: 'json', timeout: 60000 }
|
|
137
137
|
);
|
|
138
138
|
|
|
139
139
|
if (data.error) {
|
|
@@ -43,7 +43,7 @@ async function inferContactWithAI(email, assistant) {
|
|
|
43
43
|
const ai = assistant.Manager.AI(assistant, process.env.BACKEND_MANAGER_OPENAI_API_KEY);
|
|
44
44
|
const result = await ai.request({
|
|
45
45
|
model: 'gpt-5-mini',
|
|
46
|
-
timeout:
|
|
46
|
+
timeout: 60000,
|
|
47
47
|
maxTokens: 1024,
|
|
48
48
|
moderate: false,
|
|
49
49
|
response: 'json',
|
|
@@ -22,10 +22,12 @@ module.exports = async ({ assistant, Manager, settings }) => {
|
|
|
22
22
|
payload: {},
|
|
23
23
|
};
|
|
24
24
|
|
|
25
|
-
// Load email template
|
|
25
|
+
// Load email template — colons in id are converted to nested folders
|
|
26
|
+
// (e.g. "general:download-app-link" → templates/general/download-app-link.js)
|
|
27
|
+
const templatePath = settings.id.split(':').join('/');
|
|
26
28
|
let emailPayload;
|
|
27
29
|
try {
|
|
28
|
-
const script = require(path.join(__dirname, 'templates', `${
|
|
30
|
+
const script = require(path.join(__dirname, 'templates', `${templatePath}.js`));
|
|
29
31
|
emailPayload = merge(
|
|
30
32
|
{},
|
|
31
33
|
DEFAULT,
|
|
@@ -166,7 +166,7 @@ async function handleAnonymous({ assistant, Manager, settings, analytics }) {
|
|
|
166
166
|
method: 'POST',
|
|
167
167
|
response: 'json',
|
|
168
168
|
headers: { 'Authorization': `Bearer ${process.env.SENDGRID_API_KEY}` },
|
|
169
|
-
timeout:
|
|
169
|
+
timeout: 60000,
|
|
170
170
|
body: { recipient_emails: [email] },
|
|
171
171
|
});
|
|
172
172
|
} else {
|
|
@@ -176,7 +176,7 @@ async function handleAnonymous({ assistant, Manager, settings, analytics }) {
|
|
|
176
176
|
method: 'DELETE',
|
|
177
177
|
response: 'text',
|
|
178
178
|
headers: { 'Authorization': `Bearer ${process.env.SENDGRID_API_KEY}` },
|
|
179
|
-
timeout:
|
|
179
|
+
timeout: 60000,
|
|
180
180
|
});
|
|
181
181
|
}
|
|
182
182
|
} catch (e) {
|
|
@@ -8,16 +8,16 @@ const powertools = require('node-powertools');
|
|
|
8
8
|
*
|
|
9
9
|
* Query params:
|
|
10
10
|
* - provider: alert provider name (default: 'chargeblast')
|
|
11
|
-
* - key: must match BACKEND_MANAGER_KEY
|
|
11
|
+
* - key: must match BACKEND_MANAGER_WEBHOOK_KEY (BACKEND_MANAGER_KEY accepted as legacy fallback)
|
|
12
12
|
*/
|
|
13
13
|
module.exports = async ({ assistant, Manager, libraries }) => {
|
|
14
14
|
const { admin } = libraries;
|
|
15
15
|
const body = assistant.request.body;
|
|
16
16
|
const query = assistant.request.query;
|
|
17
17
|
|
|
18
|
-
// Validate key
|
|
18
|
+
// Validate key — accept either BACKEND_MANAGER_WEBHOOK_KEY (preferred) or BACKEND_MANAGER_KEY (legacy)
|
|
19
19
|
const key = query.key;
|
|
20
|
-
if (!key || key !== process.env.BACKEND_MANAGER_KEY) {
|
|
20
|
+
if (!key || (key !== process.env.BACKEND_MANAGER_WEBHOOK_KEY && key !== process.env.BACKEND_MANAGER_KEY)) {
|
|
21
21
|
return assistant.respond('Invalid key', { code: 401 });
|
|
22
22
|
}
|
|
23
23
|
|
|
@@ -155,12 +155,12 @@ async function createOneTimeIntent({ uid, orderId, product, productId, confirmat
|
|
|
155
155
|
* Fire-and-forget webhook to trigger the full pipeline
|
|
156
156
|
*/
|
|
157
157
|
function fireWebhook({ event, assistant }) {
|
|
158
|
-
const webhookUrl = `${assistant.Manager.project.apiUrl}/backend-manager/payments/webhook?processor=test&key=${process.env.
|
|
158
|
+
const webhookUrl = `${assistant.Manager.project.apiUrl}/backend-manager/payments/webhook?processor=test&key=${process.env.BACKEND_MANAGER_WEBHOOK_KEY}`;
|
|
159
159
|
fetch(webhookUrl, {
|
|
160
160
|
method: 'POST',
|
|
161
161
|
response: 'json',
|
|
162
162
|
body: event,
|
|
163
|
-
timeout:
|
|
163
|
+
timeout: 60000,
|
|
164
164
|
}).catch((e) => {
|
|
165
165
|
assistant.log(`Test processor auto-webhook failed: ${e.message}`);
|
|
166
166
|
});
|
|
@@ -24,8 +24,8 @@ module.exports = async ({ assistant, Manager, libraries }) => {
|
|
|
24
24
|
return assistant.respond('Missing processor parameter', { code: 400 });
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
// Validate key
|
|
28
|
-
if (!key || key !== process.env.BACKEND_MANAGER_KEY) {
|
|
27
|
+
// Validate key — accept either BACKEND_MANAGER_WEBHOOK_KEY (preferred) or BACKEND_MANAGER_KEY (legacy)
|
|
28
|
+
if (!key || (key !== process.env.BACKEND_MANAGER_WEBHOOK_KEY && key !== process.env.BACKEND_MANAGER_KEY)) {
|
|
29
29
|
return assistant.respond('Invalid key', { code: 401 });
|
|
30
30
|
}
|
|
31
31
|
|
|
@@ -48,7 +48,7 @@ module.exports = async ({ assistant, Manager, user, settings, libraries }) => {
|
|
|
48
48
|
|
|
49
49
|
await fetch(`${Manager.project.apiUrl}/backend-manager/user/sessions`, {
|
|
50
50
|
method: 'delete',
|
|
51
|
-
timeout:
|
|
51
|
+
timeout: 60000,
|
|
52
52
|
response: 'json',
|
|
53
53
|
tries: 2,
|
|
54
54
|
log: true,
|
|
@@ -27,7 +27,7 @@ module.exports = {
|
|
|
27
27
|
|
|
28
28
|
const response = await fetch(this.urls.revoke, {
|
|
29
29
|
method: 'POST',
|
|
30
|
-
timeout:
|
|
30
|
+
timeout: 60000,
|
|
31
31
|
body: new URLSearchParams({ token }),
|
|
32
32
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
33
33
|
}).catch(e => e);
|
package/src/test/runner.js
CHANGED
|
@@ -119,25 +119,6 @@ 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
|
-
|
|
141
122
|
// Cleanup rules context
|
|
142
123
|
if (this.rulesContext) {
|
|
143
124
|
await this.rulesContext.cleanup();
|
|
@@ -166,6 +147,12 @@ class TestRunner {
|
|
|
166
147
|
return false;
|
|
167
148
|
}
|
|
168
149
|
|
|
150
|
+
if (!this.options.backendManagerWebhookKey) {
|
|
151
|
+
console.log(chalk.red(' ✗ Missing backendManagerWebhookKey'));
|
|
152
|
+
console.log(chalk.gray(' Set BEM_BACKEND_MANAGER_WEBHOOK_KEY environment variable or pass --webhook-key flag'));
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
|
|
169
156
|
if (!this.options.brand?.id) {
|
|
170
157
|
console.log(chalk.red(' ✗ Missing brand.id'));
|
|
171
158
|
console.log(chalk.gray(' Could not determine brand ID from configuration'));
|
|
@@ -229,18 +216,6 @@ class TestRunner {
|
|
|
229
216
|
const deleteResult = await testAccounts.deleteTestUsers(this.options.admin);
|
|
230
217
|
console.log(chalk.green(`✓ (${deleteResult.deleted} deleted, ${deleteResult.skipped} skipped)`));
|
|
231
218
|
|
|
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
|
-
|
|
244
219
|
process.stdout.write(chalk.gray(' Creating test accounts... '));
|
|
245
220
|
|
|
246
221
|
// Create fresh test accounts
|
|
@@ -692,6 +667,7 @@ class TestRunner {
|
|
|
692
667
|
timeout: this.options.timeout,
|
|
693
668
|
accounts: this.accounts,
|
|
694
669
|
backendManagerKey: this.options.backendManagerKey,
|
|
670
|
+
backendManagerWebhookKey: this.options.backendManagerWebhookKey,
|
|
695
671
|
});
|
|
696
672
|
|
|
697
673
|
// Set default auth
|