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.
Files changed (74) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/CLAUDE.md +26 -0
  3. package/package.json +1 -1
  4. package/src/cli/commands/emulator.js +14 -4
  5. package/src/cli/commands/test.js +4 -10
  6. package/src/manager/cron/daily/ghostii-auto-publisher.js +25 -25
  7. package/src/manager/cron/frequent/abandoned-carts.js +7 -5
  8. package/src/manager/cron/frequent/email-queue.js +56 -0
  9. package/src/manager/events/auth/before-signin.js +3 -0
  10. package/src/manager/events/auth/on-delete.js +8 -0
  11. package/src/manager/events/firestore/payments-disputes/on-write.js +2 -1
  12. package/src/manager/events/firestore/payments-webhooks/on-write.js +9 -0
  13. package/src/manager/events/firestore/payments-webhooks/transitions/send-email.js +7 -21
  14. package/src/manager/functions/core/actions/api/admin/get-stats.js +2 -2
  15. package/src/manager/functions/core/actions/api/admin/send-email.js +14 -14
  16. package/src/manager/functions/core/actions/api/general/add-marketing-contact.js +22 -318
  17. package/src/manager/functions/core/actions/api/general/emails/general:download-app-link.js +1 -1
  18. package/src/manager/functions/core/actions/api/general/remove-marketing-contact.js +2 -185
  19. package/src/manager/functions/core/actions/api/general/send-email.js +1 -1
  20. package/src/manager/functions/core/actions/api/special/setup-electron-manager-client.js +2 -2
  21. package/src/manager/functions/core/actions/api/test/health.js +1 -0
  22. package/src/manager/helpers/api-manager.js +2 -2
  23. package/src/manager/helpers/user.js +3 -1
  24. package/src/manager/index.js +15 -10
  25. package/src/manager/libraries/email/constants.js +243 -0
  26. package/src/manager/libraries/email/index.js +145 -0
  27. package/src/manager/libraries/email/marketing/index.js +377 -0
  28. package/src/manager/libraries/email/providers/beehiiv.js +258 -0
  29. package/src/manager/libraries/email/providers/sendgrid.js +429 -0
  30. package/src/manager/libraries/{email.js → email/transactional/index.js} +91 -99
  31. package/src/manager/libraries/email/validation.js +168 -0
  32. package/src/manager/libraries/infer-contact.js +1 -1
  33. package/src/manager/routes/admin/cron/post.js +3 -3
  34. package/src/manager/routes/admin/email/post.js +1 -1
  35. package/src/manager/routes/admin/stats/get.js +2 -2
  36. package/src/manager/routes/{app → brand}/get.js +1 -1
  37. package/src/manager/routes/general/email/templates/download-app-link.js +1 -1
  38. package/src/manager/routes/marketing/contact/delete.js +2 -164
  39. package/src/manager/routes/marketing/contact/post.js +45 -298
  40. package/src/manager/routes/marketing/contact/put.js +39 -0
  41. package/src/manager/routes/payments/cancel/post.js +11 -0
  42. package/src/manager/routes/special/electron-client/post.js +3 -3
  43. package/src/manager/routes/test/health/get.js +1 -0
  44. package/src/manager/routes/user/data-request/delete.js +2 -2
  45. package/src/manager/routes/user/data-request/get.js +2 -2
  46. package/src/manager/routes/user/data-request/post.js +2 -2
  47. package/src/manager/routes/user/delete.js +1 -1
  48. package/src/manager/routes/user/feedback/post.js +12 -8
  49. package/src/manager/routes/user/signup/post.js +48 -37
  50. package/src/manager/schemas/admin/email/post.js +4 -4
  51. package/src/manager/schemas/marketing/contact/delete.js +3 -1
  52. package/src/manager/schemas/marketing/contact/post.js +3 -1
  53. package/src/manager/schemas/marketing/contact/put.js +6 -0
  54. package/src/manager/schemas/special/electron-client/post.js +2 -2
  55. package/src/manager/schemas/user/feedback/post.js +2 -2
  56. package/src/test/run-tests.js +1 -1
  57. package/src/test/runner.js +22 -10
  58. package/src/test/test-accounts.js +9 -0
  59. package/src/test/utils/extended-mode-warning.js +11 -0
  60. package/templates/_.env +1 -0
  61. package/test/events/payments/journey-payments-cancel-endpoint.js +11 -0
  62. package/test/events/payments/journey-payments-trial-cancel.js +11 -0
  63. package/test/functions/admin/edit-post.js +2 -2
  64. package/test/functions/admin/write-repo-content.js +2 -2
  65. package/test/functions/general/add-marketing-contact.js +21 -23
  66. package/test/helpers/email-validation.js +420 -0
  67. package/test/helpers/email.js +119 -6
  68. package/test/helpers/marketing-lifecycle.js +121 -0
  69. package/test/helpers/user.js +2 -2
  70. package/test/routes/admin/create-post.js +2 -2
  71. package/test/routes/admin/post.js +2 -2
  72. package/test/routes/admin/repo-content.js +2 -2
  73. package/test/routes/marketing/contact.js +21 -24
  74. 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.auth.email,
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
- // If rating is like or love and like feedback is more than dislike feedback
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
- && (settings.like?.length || 0) >= (settings.dislike?.length || 0) + 10
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, prompt review
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
- decision.reviewURL = powertools.random(reviews.sites);
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
- like: settings.like,
52
- dislike: settings.dislike,
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
- like: settings.like,
71
- dislike: settings.dislike,
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
- sendEmailsAndMarketing(assistant, uid, email, inferred);
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
- * Send welcome emails and add to marketing lists (non-blocking, fire-and-forget)
238
+ * Sync marketing contact (non-blocking, fire-and-forget)
239
+ * Validates email first — skips sync for disposable domains
238
240
  */
239
- function sendEmailsAndMarketing(assistant, uid, email, inferred) {
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 emails/marketing (BEM_TESTING=true, TEST_EXTENDED_MODE not set)`);
246
+ assistant.log(`signup(): Skipping marketing sync (BEM_TESTING=true, TEST_EXTENDED_MODE not set)`);
245
247
  return;
246
248
  }
247
249
 
248
- assistant.log(`signup(): Sending emails/adding to marketing for ${uid}`);
249
-
250
- // Add to marketing lists (SendGrid + Beehiiv) via centralized endpoint
251
- fetch(`${Manager.project.apiUrl}/backend-manager/marketing/contact`, {
252
- method: 'POST',
253
- response: 'json',
254
- body: {
255
- backendManagerKey: process.env.BACKEND_MANAGER_KEY,
256
- email: email,
257
- firstName: inferred?.firstName || '',
258
- lastName: inferred?.lastName || '',
259
- source: 'user:signup',
260
- },
261
- }).catch(e => assistant.error('signup(): marketing-contact failed:', e));
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
- // Send welcome emails (non-blocking, don't fail on error)
264
- sendWelcomeEmail(assistant, email).catch(e => assistant.error('signup(): sendWelcomeEmail failed:', e));
265
- sendCheckupEmail(assistant, email).catch(e => assistant.error('signup(): sendCheckupEmail failed:', e));
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, email) {
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: email,
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.app.id}`,
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, email) {
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: email,
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(7, 'days').unix(),
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.app.id}`,
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 (14 days after signup)
368
+ * Send feedback email (10 days after signup)
358
369
  */
359
- function sendFeedbackEmail(assistant, email) {
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: email,
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(14, 'days').unix(),
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
- * - String email: "user@example.com"
6
- * - UID string: "uid:abc123" (auto-resolves from Firestore)
7
- * - Object: { email: "user@example.com", name: "John" }
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: ['sendgrid', 'beehiiv'] },
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: ['sendgrid', 'beehiiv'] },
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
  });
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Schema for PUT /marketing/contact (sync)
3
+ */
4
+ module.exports = () => ({
5
+ uid: { types: ['string'], default: undefined, required: true },
6
+ });
@@ -3,7 +3,7 @@
3
3
  */
4
4
  module.exports = () => ({
5
5
  uid: { types: ['string'], default: undefined },
6
- appId: { types: ['string'], default: undefined },
7
- app: { types: ['string'], default: undefined },
6
+ brandId: { types: ['string'], default: undefined },
7
+ brand: { types: ['string'], default: undefined },
8
8
  config: { types: ['object'], default: {} },
9
9
  });
@@ -4,12 +4,12 @@ module.exports = () => ({
4
4
  default: undefined,
5
5
  required: true,
6
6
  },
7
- like: {
7
+ positive: {
8
8
  types: ['string'],
9
9
  default: '',
10
10
  required: false,
11
11
  },
12
- dislike: {
12
+ negative: {
13
13
  types: ['string'],
14
14
  default: '',
15
15
  required: false,
@@ -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;
@@ -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(' ⚠️⚠️⚠️ WARNING: TEST_EXTENDED_MODE IS TRUE ⚠️⚠️⚠️'));
77
- console.log(chalk.yellow(' External API calls (emails, SendGrid, etc.) are ENABLED!'));
78
- console.log(chalk.yellow(' This will send real emails and make real API calls.\n'));
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.appId) {
162
- console.log(chalk.red(' ✗ Missing appId'));
163
- console.log(chalk.gray(' Could not determine app ID from configuration'));
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.command('test:health', {});
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
- app: this.options.appId,
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
@@ -2,6 +2,7 @@
2
2
  # Backend Manager
3
3
  BACKEND_MANAGER_KEY=""
4
4
  BACKEND_MANAGER_NAMESPACE=""
5
+ BACKEND_MANAGER_OPENAI_API_KEY=""
5
6
 
6
7
  # GitHub
7
8
  GITHUB_TOKEN=""
@@ -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.githubRepoWebsite) {
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.githubRepoWebsite.match(/github\.com\/([^/]+)\/([^/]+)/);
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.githubRepoWebsite) {
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.githubRepoWebsite.match(/github\.com\/([^/]+)\/([^/]+)/);
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: ZeroBounce validation (only runs if TEST_EXTENDED_MODE and ZEROBOUNCE_API_KEY are set)
248
+ // Test 8: Mailbox verification (only runs if TEST_EXTENDED_MODE and ZEROBOUNCE_API_KEY are set)
249
249
  {
250
- name: 'zerobounce-validation',
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
- // ZeroBounce should be in checks when key is set
274
- assert.hasProperty(response, 'data.validation.checks.zerobounce', 'Should have ZeroBounce check');
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 zbResult = response.data.validation.checks.zerobounce;
275
+ const mbResult = response.data.validation.checks.mailbox;
277
276
 
278
- // If ZeroBounce is out of credits, skip test - not a failure
279
- if (zbResult.error?.includes('out of credits')) {
280
- skip('ZeroBounce out of credits');
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(zbResult, 'status', 'ZeroBounce should return status');
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: ZeroBounce rejects invalid email (only runs if TEST_EXTENDED_MODE and ZEROBOUNCE_API_KEY are set)
297
+ // Test 9: Mailbox verification rejects invalid email (only runs if TEST_EXTENDED_MODE and ZEROBOUNCE_API_KEY are set)
299
298
  {
300
- name: 'zerobounce-rejects-invalid',
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 ZeroBounce should flag as invalid
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 ZeroBounce should report invalid
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 zbResult = response.data?.validation?.checks?.zerobounce;
318
+ const mbResult = response.data?.validation?.checks?.mailbox;
321
319
 
322
- // If ZeroBounce is out of credits, skip test - not a failure
323
- if (zbResult?.error?.includes('out of credits')) {
324
- skip('ZeroBounce out of credits');
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
- // ZeroBounce should return a status indicating the email is not valid
328
- if (zbResult) {
329
- assert.hasProperty(zbResult, 'status', 'Should have status');
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(zbResult.status, 'valid', 'Fake email should not be marked valid');
329
+ assert.notEqual(mbResult.status, 'valid', 'Fake email should not be marked valid');
332
330
  }
333
331
  },
334
332
  },