backend-manager 5.0.147 → 5.0.149
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 +58 -0
- package/CLAUDE.md +26 -0
- package/package.json +1 -1
- package/src/cli/commands/emulator.js +14 -4
- package/src/cli/commands/test.js +4 -10
- package/src/manager/cron/daily/ghostii-auto-publisher.js +25 -25
- package/src/manager/cron/frequent/abandoned-carts.js +7 -5
- package/src/manager/cron/frequent/email-queue.js +56 -0
- package/src/manager/events/auth/before-signin.js +3 -0
- package/src/manager/events/auth/on-delete.js +8 -0
- package/src/manager/events/firestore/payments-disputes/on-write.js +2 -1
- package/src/manager/events/firestore/payments-webhooks/on-write.js +9 -0
- package/src/manager/events/firestore/payments-webhooks/transitions/send-email.js +7 -21
- package/src/manager/functions/core/actions/api/admin/get-stats.js +2 -2
- package/src/manager/functions/core/actions/api/admin/send-email.js +14 -14
- package/src/manager/functions/core/actions/api/general/add-marketing-contact.js +22 -318
- package/src/manager/functions/core/actions/api/general/emails/general:download-app-link.js +1 -1
- package/src/manager/functions/core/actions/api/general/remove-marketing-contact.js +2 -185
- package/src/manager/functions/core/actions/api/general/send-email.js +1 -1
- package/src/manager/functions/core/actions/api/special/setup-electron-manager-client.js +2 -2
- package/src/manager/functions/core/actions/api/test/health.js +1 -0
- package/src/manager/helpers/api-manager.js +2 -2
- package/src/manager/helpers/user.js +3 -1
- package/src/manager/index.js +15 -10
- package/src/manager/libraries/email/constants.js +243 -0
- package/src/manager/libraries/email/index.js +145 -0
- package/src/manager/libraries/email/marketing/index.js +377 -0
- package/src/manager/libraries/email/providers/beehiiv.js +258 -0
- package/src/manager/libraries/email/providers/sendgrid.js +429 -0
- package/src/manager/libraries/{email.js → email/transactional/index.js} +91 -99
- package/src/manager/libraries/email/validation.js +168 -0
- package/src/manager/libraries/infer-contact.js +1 -1
- package/src/manager/routes/admin/cron/post.js +3 -3
- package/src/manager/routes/admin/email/post.js +1 -1
- package/src/manager/routes/admin/stats/get.js +2 -2
- package/src/manager/routes/{app → brand}/get.js +1 -1
- package/src/manager/routes/general/email/templates/download-app-link.js +1 -1
- package/src/manager/routes/marketing/contact/delete.js +2 -164
- package/src/manager/routes/marketing/contact/post.js +45 -298
- package/src/manager/routes/marketing/contact/put.js +39 -0
- package/src/manager/routes/payments/cancel/post.js +11 -0
- package/src/manager/routes/special/electron-client/post.js +3 -3
- package/src/manager/routes/test/health/get.js +1 -0
- package/src/manager/routes/user/data-request/delete.js +2 -2
- package/src/manager/routes/user/data-request/get.js +2 -2
- package/src/manager/routes/user/data-request/post.js +2 -2
- package/src/manager/routes/user/delete.js +1 -1
- package/src/manager/routes/user/feedback/post.js +12 -8
- package/src/manager/routes/user/signup/post.js +48 -37
- package/src/manager/schemas/admin/email/post.js +4 -4
- package/src/manager/schemas/marketing/contact/delete.js +3 -1
- package/src/manager/schemas/marketing/contact/post.js +3 -1
- package/src/manager/schemas/marketing/contact/put.js +6 -0
- package/src/manager/schemas/special/electron-client/post.js +2 -2
- package/src/manager/schemas/user/feedback/post.js +2 -2
- package/src/test/run-tests.js +1 -1
- package/src/test/runner.js +22 -10
- package/src/test/test-accounts.js +9 -0
- package/src/test/utils/extended-mode-warning.js +11 -0
- package/templates/_.env +1 -0
- package/test/events/payments/journey-payments-cancel-endpoint.js +11 -0
- package/test/events/payments/journey-payments-trial-cancel.js +11 -0
- package/test/functions/admin/edit-post.js +2 -2
- package/test/functions/admin/write-repo-content.js +2 -2
- package/test/functions/general/add-marketing-contact.js +21 -23
- package/test/helpers/email-validation.js +420 -0
- package/test/helpers/email.js +119 -6
- package/test/helpers/marketing-lifecycle.js +121 -0
- package/test/helpers/user.js +2 -2
- package/test/routes/admin/create-post.js +2 -2
- package/test/routes/admin/post.js +2 -2
- package/test/routes/admin/repo-content.js +2 -2
- package/test/routes/marketing/contact.js +21 -24
- package/test/routes/payments/cancel.js +18 -0
|
@@ -96,11 +96,11 @@ function sendConfirmationEmail(assistant, user, requestId, reason) {
|
|
|
96
96
|
: '';
|
|
97
97
|
|
|
98
98
|
mailer.send({
|
|
99
|
-
to: user
|
|
99
|
+
to: user,
|
|
100
|
+
sender: 'account',
|
|
100
101
|
categories: ['account/data-request'],
|
|
101
102
|
subject: `Your data request has been received #${requestId}`,
|
|
102
103
|
template: 'default',
|
|
103
|
-
group: 'account',
|
|
104
104
|
copy: true,
|
|
105
105
|
data: {
|
|
106
106
|
email: {
|
|
@@ -100,10 +100,10 @@ function sendConfirmationEmail(assistant, email, reason) {
|
|
|
100
100
|
|
|
101
101
|
mailer.send({
|
|
102
102
|
to: email,
|
|
103
|
+
sender: 'account',
|
|
103
104
|
categories: ['account/delete'],
|
|
104
105
|
subject: `Your ${brandName} account has been deleted`,
|
|
105
106
|
template: 'default',
|
|
106
|
-
group: 'account',
|
|
107
107
|
copy: true,
|
|
108
108
|
data: {
|
|
109
109
|
email: {
|
|
@@ -21,10 +21,11 @@ module.exports = async ({ assistant, Manager, user, settings, libraries }) => {
|
|
|
21
21
|
reviewURL: null,
|
|
22
22
|
};
|
|
23
23
|
|
|
24
|
-
//
|
|
24
|
+
// Prompt for review if user gave positive rating (like/love) and wrote meaningful positive feedback (50+ chars)
|
|
25
|
+
const totalPositiveLength = (settings.positive?.length || 0) + (settings.comments?.length || 0);
|
|
25
26
|
if (
|
|
26
27
|
['like', 'love'].includes(settings.rating)
|
|
27
|
-
&&
|
|
28
|
+
&& totalPositiveLength >= 50
|
|
28
29
|
) {
|
|
29
30
|
decision.promptReview = true;
|
|
30
31
|
}
|
|
@@ -34,9 +35,12 @@ module.exports = async ({ assistant, Manager, user, settings, libraries }) => {
|
|
|
34
35
|
reviews.enabled = typeof reviews.enabled === 'undefined' ? true : reviews.enabled;
|
|
35
36
|
reviews.sites = reviews.sites || [];
|
|
36
37
|
|
|
37
|
-
// If reviews are enabled and there are review sites,
|
|
38
|
+
// If reviews are enabled and there are review sites, build the full review URL
|
|
38
39
|
if (decision.promptReview && reviews.enabled && reviews.sites.length > 0) {
|
|
39
|
-
|
|
40
|
+
const site = powertools.random(reviews.sites);
|
|
41
|
+
const brandDomain = new URL(Manager.config.brand.url).hostname;
|
|
42
|
+
|
|
43
|
+
decision.reviewURL = `https://www.${site}/review/${brandDomain}`;
|
|
40
44
|
} else {
|
|
41
45
|
decision.promptReview = false;
|
|
42
46
|
}
|
|
@@ -48,8 +52,8 @@ module.exports = async ({ assistant, Manager, user, settings, libraries }) => {
|
|
|
48
52
|
.set({
|
|
49
53
|
feedback: {
|
|
50
54
|
rating: settings.rating,
|
|
51
|
-
|
|
52
|
-
|
|
55
|
+
positive: settings.positive,
|
|
56
|
+
negative: settings.negative,
|
|
53
57
|
comments: settings.comments,
|
|
54
58
|
},
|
|
55
59
|
decision: decision,
|
|
@@ -67,8 +71,8 @@ module.exports = async ({ assistant, Manager, user, settings, libraries }) => {
|
|
|
67
71
|
review: decision,
|
|
68
72
|
originalRequest: {
|
|
69
73
|
rating: settings.rating,
|
|
70
|
-
|
|
71
|
-
|
|
74
|
+
positive: settings.positive,
|
|
75
|
+
negative: settings.negative,
|
|
72
76
|
comments: settings.comments,
|
|
73
77
|
},
|
|
74
78
|
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
const fetch = require('wonderful-fetch');
|
|
2
1
|
const moment = require('moment');
|
|
3
2
|
const { inferContact } = require('../../../libraries/infer-contact.js');
|
|
3
|
+
const { validate: validateEmail } = require('../../../libraries/email/validation.js');
|
|
4
4
|
|
|
5
5
|
const MAX_POLL_TIME_MS = 30000;
|
|
6
6
|
const POLL_INTERVAL_MS = 500;
|
|
@@ -76,7 +76,8 @@ module.exports = async ({ assistant, user, settings, libraries }) => {
|
|
|
76
76
|
await processAffiliate(assistant, uid, settings);
|
|
77
77
|
|
|
78
78
|
// 6. Send emails + marketing (non-blocking, fire-and-forget)
|
|
79
|
-
|
|
79
|
+
syncMarketingContact(assistant, uid, email);
|
|
80
|
+
sendWelcomeEmails(assistant, uid);
|
|
80
81
|
|
|
81
82
|
return assistant.respond({ signedUp: true });
|
|
82
83
|
};
|
|
@@ -234,51 +235,61 @@ async function processAffiliate(assistant, uid, settings) {
|
|
|
234
235
|
}
|
|
235
236
|
|
|
236
237
|
/**
|
|
237
|
-
*
|
|
238
|
+
* Sync marketing contact (non-blocking, fire-and-forget)
|
|
239
|
+
* Validates email first — skips sync for disposable domains
|
|
238
240
|
*/
|
|
239
|
-
function
|
|
241
|
+
async function syncMarketingContact(assistant, uid, email) {
|
|
240
242
|
const Manager = assistant.Manager;
|
|
241
243
|
const shouldSend = !assistant.isTesting() || process.env.TEST_EXTENDED_MODE;
|
|
242
244
|
|
|
243
245
|
if (!shouldSend) {
|
|
244
|
-
assistant.log(`signup(): Skipping
|
|
246
|
+
assistant.log(`signup(): Skipping marketing sync (BEM_TESTING=true, TEST_EXTENDED_MODE not set)`);
|
|
245
247
|
return;
|
|
246
248
|
}
|
|
247
249
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
250
|
+
// Validate email before adding to marketing lists (disposable check only, no ZeroBounce cost)
|
|
251
|
+
const validation = await validateEmail(email);
|
|
252
|
+
|
|
253
|
+
if (!validation.valid) {
|
|
254
|
+
assistant.log(`signup(): Skipping marketing sync — email validation failed:`, validation.checks);
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const mailer = Manager.Email(assistant);
|
|
259
|
+
mailer.sync(uid)
|
|
260
|
+
.then((r) => assistant.log('signup(): Marketing sync:', r))
|
|
261
|
+
.catch((e) => assistant.error('signup(): Marketing sync failed:', e));
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Send welcome, checkup, and feedback emails (non-blocking, fire-and-forget)
|
|
266
|
+
*/
|
|
267
|
+
function sendWelcomeEmails(assistant, uid) {
|
|
268
|
+
const shouldSend = !assistant.isTesting() || process.env.TEST_EXTENDED_MODE;
|
|
269
|
+
|
|
270
|
+
if (!shouldSend) {
|
|
271
|
+
assistant.log(`signup(): Skipping welcome emails (BEM_TESTING=true, TEST_EXTENDED_MODE not set)`);
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
262
274
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
sendFeedbackEmail(assistant, email).catch(e => assistant.error('signup(): sendFeedbackEmail failed:', e));
|
|
275
|
+
sendWelcomeEmail(assistant, uid).catch(e => assistant.error('signup(): sendWelcomeEmail failed:', e));
|
|
276
|
+
sendCheckupEmail(assistant, uid).catch(e => assistant.error('signup(): sendCheckupEmail failed:', e));
|
|
277
|
+
sendFeedbackEmail(assistant, uid).catch(e => assistant.error('signup(): sendFeedbackEmail failed:', e));
|
|
267
278
|
}
|
|
268
279
|
|
|
269
280
|
/**
|
|
270
281
|
* Send welcome email (immediate)
|
|
271
282
|
*/
|
|
272
|
-
function sendWelcomeEmail(assistant,
|
|
283
|
+
function sendWelcomeEmail(assistant, uid) {
|
|
273
284
|
const Manager = assistant.Manager;
|
|
274
285
|
const mailer = Manager.Email(assistant);
|
|
275
286
|
|
|
276
287
|
return mailer.send({
|
|
277
|
-
to:
|
|
288
|
+
to: uid,
|
|
289
|
+
sender: 'hello',
|
|
278
290
|
categories: ['account/welcome'],
|
|
279
291
|
subject: `Welcome to ${Manager.config.brand.name}!`,
|
|
280
292
|
template: 'default',
|
|
281
|
-
group: 'account',
|
|
282
293
|
copy: false,
|
|
283
294
|
data: {
|
|
284
295
|
email: {
|
|
@@ -297,7 +308,7 @@ Thank you for choosing **${Manager.config.brand.name}**. Here's to new beginning
|
|
|
297
308
|
signoff: {
|
|
298
309
|
type: 'personal',
|
|
299
310
|
name: 'Ian Wiedenman, CEO',
|
|
300
|
-
url: `https://ianwiedenman.com?utm_source=welcome-email&utm_medium=email&utm_campaign=${Manager.config.
|
|
311
|
+
url: `https://ianwiedenman.com?utm_source=welcome-email&utm_medium=email&utm_campaign=${Manager.config.brand.id}`,
|
|
301
312
|
urlText: '@ianwieds',
|
|
302
313
|
},
|
|
303
314
|
},
|
|
@@ -311,18 +322,18 @@ Thank you for choosing **${Manager.config.brand.name}**. Here's to new beginning
|
|
|
311
322
|
/**
|
|
312
323
|
* Send checkup email (7 days after signup)
|
|
313
324
|
*/
|
|
314
|
-
function sendCheckupEmail(assistant,
|
|
325
|
+
function sendCheckupEmail(assistant, uid) {
|
|
315
326
|
const Manager = assistant.Manager;
|
|
316
327
|
const mailer = Manager.Email(assistant);
|
|
317
328
|
|
|
318
329
|
return mailer.send({
|
|
319
|
-
to:
|
|
330
|
+
to: uid,
|
|
331
|
+
sender: 'hello',
|
|
320
332
|
categories: ['account/checkup'],
|
|
321
333
|
subject: `How's your experience with ${Manager.config.brand.name}?`,
|
|
322
334
|
template: 'default',
|
|
323
|
-
group: 'account',
|
|
324
335
|
copy: false,
|
|
325
|
-
sendAt: moment().add(
|
|
336
|
+
sendAt: moment().add(5, 'days').unix(),
|
|
326
337
|
data: {
|
|
327
338
|
email: {
|
|
328
339
|
preview: `Checking in from ${Manager.config.brand.name} to see how things are going. Let us know if you have any questions or feedback!`,
|
|
@@ -342,7 +353,7 @@ Thank you for choosing **${Manager.config.brand.name}**. Here's to new beginning
|
|
|
342
353
|
signoff: {
|
|
343
354
|
type: 'personal',
|
|
344
355
|
name: 'Ian Wiedenman, CEO',
|
|
345
|
-
url: `https://ianwiedenman.com?utm_source=checkup-email&utm_medium=email&utm_campaign=${Manager.config.
|
|
356
|
+
url: `https://ianwiedenman.com?utm_source=checkup-email&utm_medium=email&utm_campaign=${Manager.config.brand.id}`,
|
|
346
357
|
urlText: '@ianwieds',
|
|
347
358
|
},
|
|
348
359
|
},
|
|
@@ -354,20 +365,20 @@ Thank you for choosing **${Manager.config.brand.name}**. Here's to new beginning
|
|
|
354
365
|
}
|
|
355
366
|
|
|
356
367
|
/**
|
|
357
|
-
* Send feedback email (
|
|
368
|
+
* Send feedback email (10 days after signup)
|
|
358
369
|
*/
|
|
359
|
-
function sendFeedbackEmail(assistant,
|
|
370
|
+
function sendFeedbackEmail(assistant, uid) {
|
|
360
371
|
const Manager = assistant.Manager;
|
|
361
372
|
const mailer = Manager.Email(assistant);
|
|
362
373
|
|
|
363
374
|
return mailer.send({
|
|
364
|
-
to:
|
|
375
|
+
to: uid,
|
|
376
|
+
sender: 'hello',
|
|
365
377
|
categories: ['engagement/feedback'],
|
|
366
378
|
subject: `Want to share your feedback about ${Manager.config.brand.name}?`,
|
|
367
379
|
template: 'main/engagement/feedback',
|
|
368
|
-
group: 'account',
|
|
369
380
|
copy: false,
|
|
370
|
-
sendAt: moment().add(
|
|
381
|
+
sendAt: moment().add(10, 'days').unix(),
|
|
371
382
|
})
|
|
372
383
|
.then((result) => {
|
|
373
384
|
assistant.log('sendFeedbackEmail(): Success', result.status);
|
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
* Schema for POST /admin/email
|
|
3
3
|
*
|
|
4
4
|
* Recipients (to, cc, bcc) accept flexible formats:
|
|
5
|
-
* -
|
|
6
|
-
* - UID string: "
|
|
7
|
-
* -
|
|
5
|
+
* - Email string: "user@example.com"
|
|
6
|
+
* - UID string (no @): "abc123" — auto-fetches user doc from Firestore
|
|
7
|
+
* - Email object: { email: "user@example.com", name: "John" }
|
|
8
8
|
* - Array of any of the above
|
|
9
9
|
*/
|
|
10
10
|
module.exports = () => ({
|
|
@@ -13,11 +13,11 @@ module.exports = () => ({
|
|
|
13
13
|
bcc: { types: ['array', 'string', 'object'], default: [] },
|
|
14
14
|
from: { types: ['object'], default: undefined },
|
|
15
15
|
replyTo: { types: ['string'], default: undefined },
|
|
16
|
+
sender: { types: ['string'], default: undefined },
|
|
16
17
|
subject: { types: ['string'], default: undefined },
|
|
17
18
|
template: { types: ['string'], default: undefined },
|
|
18
19
|
group: { types: ['number'], default: undefined },
|
|
19
20
|
sendAt: { types: ['number', 'string'], default: undefined },
|
|
20
|
-
user: { types: ['object'], default: {} },
|
|
21
21
|
data: { types: ['object'], default: {} },
|
|
22
22
|
categories: { types: ['array'], default: [] },
|
|
23
23
|
copy: { types: ['boolean'], default: undefined },
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Schema for DELETE /marketing/contact
|
|
3
3
|
*/
|
|
4
|
+
const { DEFAULT_PROVIDERS } = require('../../../libraries/email/constants.js');
|
|
5
|
+
|
|
4
6
|
module.exports = () => ({
|
|
5
7
|
email: { types: ['string'], default: undefined, required: true },
|
|
6
|
-
providers: { types: ['array'], default:
|
|
8
|
+
providers: { types: ['array'], default: DEFAULT_PROVIDERS },
|
|
7
9
|
});
|
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Schema for POST /marketing/contact
|
|
3
3
|
*/
|
|
4
|
+
const { DEFAULT_PROVIDERS } = require('../../../libraries/email/constants.js');
|
|
5
|
+
|
|
4
6
|
module.exports = () => ({
|
|
5
7
|
email: { types: ['string'], default: undefined, required: true },
|
|
6
8
|
firstName: { types: ['string'], default: '' },
|
|
7
9
|
lastName: { types: ['string'], default: '' },
|
|
8
10
|
source: { types: ['string'], default: 'unknown' },
|
|
9
11
|
tags: { types: ['array'], default: [] },
|
|
10
|
-
providers: { types: ['array'], default:
|
|
12
|
+
providers: { types: ['array'], default: DEFAULT_PROVIDERS },
|
|
11
13
|
skipValidation: { types: ['boolean'], default: false },
|
|
12
14
|
'g-recaptcha-response': { types: ['string'], default: undefined },
|
|
13
15
|
});
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
module.exports = () => ({
|
|
5
5
|
uid: { types: ['string'], default: undefined },
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
brandId: { types: ['string'], default: undefined },
|
|
7
|
+
brand: { types: ['string'], default: undefined },
|
|
8
8
|
config: { types: ['object'], default: {} },
|
|
9
9
|
});
|
package/src/test/run-tests.js
CHANGED
|
@@ -25,7 +25,7 @@ async function main() {
|
|
|
25
25
|
// When running in emulator, we can initialize without credentials
|
|
26
26
|
// The emulator environment variables tell it where to connect
|
|
27
27
|
firebaseAdmin.initializeApp({
|
|
28
|
-
projectId: process.env.GCLOUD_PROJECT || testConfig.projectId,
|
|
28
|
+
projectId: process.env.GCLOUD_PROJECT || testConfig.firebaseConfig?.projectId,
|
|
29
29
|
});
|
|
30
30
|
}
|
|
31
31
|
admin = firebaseAdmin;
|
package/src/test/runner.js
CHANGED
|
@@ -8,6 +8,7 @@ const HttpClient = require('./utils/http-client.js');
|
|
|
8
8
|
const assertions = require('./utils/assertions.js');
|
|
9
9
|
const testAccounts = require('./test-accounts.js');
|
|
10
10
|
const rulesClient = require('./utils/firestore-rules-client.js');
|
|
11
|
+
const { EXTENDED_MODE_WARNING } = require('./utils/extended-mode-warning.js');
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* Error class for runtime test skipping
|
|
@@ -73,9 +74,9 @@ class TestRunner {
|
|
|
73
74
|
|
|
74
75
|
// Warn if TEST_EXTENDED_MODE is enabled
|
|
75
76
|
if (process.env.TEST_EXTENDED_MODE) {
|
|
76
|
-
console.log(chalk.yellow.bold(
|
|
77
|
-
console.log(chalk.yellow(
|
|
78
|
-
console.log(
|
|
77
|
+
console.log(chalk.yellow.bold(` ${EXTENDED_MODE_WARNING[0]}`));
|
|
78
|
+
EXTENDED_MODE_WARNING.slice(1).forEach((line) => console.log(chalk.yellow(` ${line}`)));
|
|
79
|
+
console.log('');
|
|
79
80
|
}
|
|
80
81
|
|
|
81
82
|
// Validate configuration
|
|
@@ -158,9 +159,9 @@ class TestRunner {
|
|
|
158
159
|
return false;
|
|
159
160
|
}
|
|
160
161
|
|
|
161
|
-
if (!this.options.
|
|
162
|
-
console.log(chalk.red(' ✗ Missing
|
|
163
|
-
console.log(chalk.gray(' Could not determine
|
|
162
|
+
if (!this.options.brand?.id) {
|
|
163
|
+
console.log(chalk.red(' ✗ Missing brand.id'));
|
|
164
|
+
console.log(chalk.gray(' Could not determine brand ID from configuration'));
|
|
164
165
|
return false;
|
|
165
166
|
}
|
|
166
167
|
|
|
@@ -180,10 +181,21 @@ class TestRunner {
|
|
|
180
181
|
process.stdout.write(chalk.gray(' Checking server health... '));
|
|
181
182
|
|
|
182
183
|
try {
|
|
183
|
-
const response = await http.
|
|
184
|
+
const response = await http.get('test/health');
|
|
184
185
|
|
|
185
186
|
if (response.success) {
|
|
186
187
|
console.log(chalk.green('✓'));
|
|
188
|
+
|
|
189
|
+
// Warn if TEST_EXTENDED_MODE mismatch between test runner and emulator
|
|
190
|
+
const runnerExtended = !!process.env.TEST_EXTENDED_MODE;
|
|
191
|
+
const emulatorExtended = !!response.data?.testExtendedMode;
|
|
192
|
+
|
|
193
|
+
if (runnerExtended !== emulatorExtended) {
|
|
194
|
+
console.log(chalk.red.bold(`\n ⚠️⚠️⚠️ TEST_EXTENDED_MODE mismatch (runner=${runnerExtended}, emulator=${emulatorExtended}) ⚠️⚠️⚠️`));
|
|
195
|
+
console.log(chalk.red(' Both must match or tests will behave unexpectedly.'));
|
|
196
|
+
console.log(chalk.red(` Restart with: ${runnerExtended ? '' : 'TEST_EXTENDED_MODE=true '}npx bm emulator\n`));
|
|
197
|
+
}
|
|
198
|
+
|
|
187
199
|
return true;
|
|
188
200
|
}
|
|
189
201
|
|
|
@@ -234,7 +246,7 @@ class TestRunner {
|
|
|
234
246
|
process.stdout.write(chalk.gray(' Initializing rules testing context... '));
|
|
235
247
|
try {
|
|
236
248
|
this.rulesContext = await rulesClient.createRulesContext({
|
|
237
|
-
projectId: this.options.projectId,
|
|
249
|
+
projectId: this.options.firebaseConfig?.projectId,
|
|
238
250
|
rulesPath: this.options.rulesPath,
|
|
239
251
|
accounts: this.accounts,
|
|
240
252
|
});
|
|
@@ -263,7 +275,7 @@ class TestRunner {
|
|
|
263
275
|
// Create initial stats document
|
|
264
276
|
await statsRef.set({
|
|
265
277
|
users: { total: 0 },
|
|
266
|
-
|
|
278
|
+
brand: this.options.brand?.id,
|
|
267
279
|
});
|
|
268
280
|
}
|
|
269
281
|
|
|
@@ -813,7 +825,7 @@ class TestRunner {
|
|
|
813
825
|
async trigger(functionName) {
|
|
814
826
|
const { PubSub } = require('@google-cloud/pubsub');
|
|
815
827
|
const pubsub = new PubSub({
|
|
816
|
-
projectId: config.projectId,
|
|
828
|
+
projectId: config.firebaseConfig?.projectId,
|
|
817
829
|
apiEndpoint: 'localhost:8085',
|
|
818
830
|
});
|
|
819
831
|
|
|
@@ -293,6 +293,15 @@ const JOURNEY_ACCOUNTS = {
|
|
|
293
293
|
subscription: { product: { id: 'premium', name: 'Premium' }, status: 'active', expires: getFutureExpires(), cancellation: { pending: false }, payment: { processor: 'unknown-processor', resourceId: 'sub_test_fake' } },
|
|
294
294
|
},
|
|
295
295
|
},
|
|
296
|
+
'cancel-too-young': {
|
|
297
|
+
id: 'cancel-too-young',
|
|
298
|
+
uid: '_test-cancel-too-young',
|
|
299
|
+
email: '_test.cancel-too-young@{domain}',
|
|
300
|
+
properties: {
|
|
301
|
+
roles: {},
|
|
302
|
+
subscription: { product: { id: 'premium', name: 'Premium' }, status: 'active', expires: getFutureExpires(), cancellation: { pending: false }, payment: { processor: 'test', resourceId: 'sub_test_fake', startDate: { timestamp: new Date().toISOString(), timestampUNIX: Date.now() } } },
|
|
303
|
+
},
|
|
304
|
+
},
|
|
296
305
|
// Dedicated accounts for portal validation tests
|
|
297
306
|
'portal-no-processor': {
|
|
298
307
|
id: 'portal-no-processor',
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TEST_EXTENDED_MODE warning — SSOT for consistent messaging
|
|
3
|
+
* Used by: emulator.js (console + log file), runner.js (console)
|
|
4
|
+
*/
|
|
5
|
+
const EXTENDED_MODE_WARNING = [
|
|
6
|
+
'⚠️⚠️⚠️ WARNING: TEST_EXTENDED_MODE IS TRUE ⚠️⚠️⚠️',
|
|
7
|
+
'External API calls (emails, SendGrid, etc.) are ENABLED!',
|
|
8
|
+
'This will send real emails and make real API calls.',
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
module.exports = { EXTENDED_MODE_WARNING };
|
package/templates/_.env
CHANGED
|
@@ -44,6 +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
|
+
|
|
47
58
|
{
|
|
48
59
|
name: 'call-cancel-endpoint',
|
|
49
60
|
async run({ http, assert }) {
|
|
@@ -73,6 +73,17 @@ 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
|
+
|
|
76
87
|
{
|
|
77
88
|
name: 'cancel-during-trial',
|
|
78
89
|
async run({ http, assert }) {
|
|
@@ -115,7 +115,7 @@ module.exports = {
|
|
|
115
115
|
skip: !process.env.GITHUB_TOKEN ? 'GITHUB_TOKEN env var not set' : false,
|
|
116
116
|
|
|
117
117
|
async run({ assert, state, config }) {
|
|
118
|
-
if (!config.
|
|
118
|
+
if (!config.github?.repo_website) {
|
|
119
119
|
assert.fail('githubRepoWebsite not configured');
|
|
120
120
|
return;
|
|
121
121
|
}
|
|
@@ -123,7 +123,7 @@ module.exports = {
|
|
|
123
123
|
const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN });
|
|
124
124
|
|
|
125
125
|
// Parse owner/repo from githubRepoWebsite
|
|
126
|
-
const repoMatch = config.
|
|
126
|
+
const repoMatch = config.github?.repo_website.match(/github\.com\/([^/]+)\/([^/]+)/);
|
|
127
127
|
if (!repoMatch) {
|
|
128
128
|
assert.fail('Could not parse githubRepoWebsite');
|
|
129
129
|
return;
|
|
@@ -155,14 +155,14 @@ module.exports = {
|
|
|
155
155
|
timeout: 60000,
|
|
156
156
|
|
|
157
157
|
async run({ state, config }) {
|
|
158
|
-
if (!process.env.GITHUB_TOKEN || !config.
|
|
158
|
+
if (!process.env.GITHUB_TOKEN || !config.github?.repo_website) {
|
|
159
159
|
return;
|
|
160
160
|
}
|
|
161
161
|
|
|
162
162
|
const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN });
|
|
163
163
|
|
|
164
164
|
// Parse owner/repo from githubRepoWebsite (e.g., 'https://github.com/owner/repo')
|
|
165
|
-
const repoMatch = config.
|
|
165
|
+
const repoMatch = config.github?.repo_website.match(/github\.com\/([^/]+)\/([^/]+)/);
|
|
166
166
|
if (!repoMatch) {
|
|
167
167
|
return;
|
|
168
168
|
}
|
|
@@ -245,9 +245,9 @@ module.exports = {
|
|
|
245
245
|
},
|
|
246
246
|
},
|
|
247
247
|
|
|
248
|
-
// Test 8:
|
|
248
|
+
// Test 8: Mailbox verification (only runs if TEST_EXTENDED_MODE and ZEROBOUNCE_API_KEY are set)
|
|
249
249
|
{
|
|
250
|
-
name: '
|
|
250
|
+
name: 'mailbox-validation',
|
|
251
251
|
auth: 'admin',
|
|
252
252
|
timeout: 30000,
|
|
253
253
|
skip: !process.env.TEST_EXTENDED_MODE || !process.env.ZEROBOUNCE_API_KEY
|
|
@@ -261,7 +261,6 @@ module.exports = {
|
|
|
261
261
|
const response = await http.command('general:add-marketing-contact', {
|
|
262
262
|
email: testEmail,
|
|
263
263
|
source: 'bem-test',
|
|
264
|
-
// No firstName/lastName - should be inferred as "Rachel Greene"
|
|
265
264
|
});
|
|
266
265
|
|
|
267
266
|
assert.isSuccess(response, 'Add marketing contact should succeed');
|
|
@@ -270,17 +269,17 @@ module.exports = {
|
|
|
270
269
|
assert.hasProperty(response, 'data.validation', 'Response should contain validation');
|
|
271
270
|
assert.hasProperty(response, 'data.validation.checks', 'Validation should contain checks');
|
|
272
271
|
|
|
273
|
-
//
|
|
274
|
-
assert.hasProperty(response, 'data.validation.checks.
|
|
272
|
+
// Mailbox check should be in checks when key is set
|
|
273
|
+
assert.hasProperty(response, 'data.validation.checks.mailbox', 'Should have mailbox check');
|
|
275
274
|
|
|
276
|
-
const
|
|
275
|
+
const mbResult = response.data.validation.checks.mailbox;
|
|
277
276
|
|
|
278
|
-
// If
|
|
279
|
-
if (
|
|
280
|
-
skip('
|
|
277
|
+
// If out of credits, skip test - not a failure
|
|
278
|
+
if (mbResult.error?.includes('out of credits')) {
|
|
279
|
+
skip('Mailbox verification out of credits');
|
|
281
280
|
}
|
|
282
281
|
|
|
283
|
-
assert.hasProperty(
|
|
282
|
+
assert.hasProperty(mbResult, 'status', 'Mailbox check should return status');
|
|
284
283
|
|
|
285
284
|
state.sendgridAdded = response.data?.providers?.sendgrid?.success;
|
|
286
285
|
state.beehiivAdded = response.data?.providers?.beehiiv?.success;
|
|
@@ -295,9 +294,9 @@ module.exports = {
|
|
|
295
294
|
},
|
|
296
295
|
},
|
|
297
296
|
|
|
298
|
-
// Test 9:
|
|
297
|
+
// Test 9: Mailbox verification rejects invalid email (only runs if TEST_EXTENDED_MODE and ZEROBOUNCE_API_KEY are set)
|
|
299
298
|
{
|
|
300
|
-
name: '
|
|
299
|
+
name: 'mailbox-rejects-invalid',
|
|
301
300
|
auth: 'admin',
|
|
302
301
|
timeout: 30000,
|
|
303
302
|
skip: !process.env.TEST_EXTENDED_MODE || !process.env.ZEROBOUNCE_API_KEY
|
|
@@ -305,30 +304,29 @@ module.exports = {
|
|
|
305
304
|
: false,
|
|
306
305
|
|
|
307
306
|
async run({ http, assert, skip }) {
|
|
308
|
-
// Use fake email that
|
|
307
|
+
// Use fake email that mailbox verification should flag as invalid
|
|
309
308
|
const testEmail = TEST_EMAILS.invalid();
|
|
310
309
|
|
|
311
310
|
const response = await http.command('general:add-marketing-contact', {
|
|
312
311
|
email: testEmail,
|
|
313
312
|
source: 'bem-test',
|
|
314
|
-
// No firstName/lastName - AI will try to infer from "test"
|
|
315
313
|
});
|
|
316
314
|
|
|
317
|
-
// Should still succeed (we fail open) but
|
|
315
|
+
// Should still succeed (we fail open) but mailbox should report invalid
|
|
318
316
|
assert.isSuccess(response, 'Request should succeed even with invalid email');
|
|
319
317
|
|
|
320
|
-
const
|
|
318
|
+
const mbResult = response.data?.validation?.checks?.mailbox;
|
|
321
319
|
|
|
322
|
-
// If
|
|
323
|
-
if (
|
|
324
|
-
skip('
|
|
320
|
+
// If out of credits, skip test - not a failure
|
|
321
|
+
if (mbResult?.error?.includes('out of credits')) {
|
|
322
|
+
skip('Mailbox verification out of credits');
|
|
325
323
|
}
|
|
326
324
|
|
|
327
|
-
//
|
|
328
|
-
if (
|
|
329
|
-
assert.hasProperty(
|
|
325
|
+
// Mailbox should return a status indicating the email is not valid
|
|
326
|
+
if (mbResult) {
|
|
327
|
+
assert.hasProperty(mbResult, 'status', 'Should have status');
|
|
330
328
|
// Status should NOT be 'valid' for this fake email
|
|
331
|
-
assert.notEqual(
|
|
329
|
+
assert.notEqual(mbResult.status, 'valid', 'Fake email should not be marked valid');
|
|
332
330
|
}
|
|
333
331
|
},
|
|
334
332
|
},
|