backend-manager 5.0.148 → 5.0.150

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 +63 -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 +21 -319
  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 -186
  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 +240 -0
  26. package/src/manager/libraries/email/index.js +136 -0
  27. package/src/manager/libraries/email/marketing/index.js +370 -0
  28. package/src/manager/libraries/email/providers/beehiiv.js +274 -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/routes/admin/cron/post.js +3 -3
  33. package/src/manager/routes/admin/email/post.js +1 -1
  34. package/src/manager/routes/admin/stats/get.js +2 -2
  35. package/src/manager/routes/{app → brand}/get.js +1 -1
  36. package/src/manager/routes/general/email/templates/download-app-link.js +1 -1
  37. package/src/manager/routes/marketing/contact/delete.js +2 -165
  38. package/src/manager/routes/marketing/contact/post.js +42 -298
  39. package/src/manager/routes/marketing/contact/put.js +39 -0
  40. package/src/manager/routes/payments/cancel/post.js +11 -0
  41. package/src/manager/routes/special/electron-client/post.js +3 -3
  42. package/src/manager/routes/test/health/get.js +1 -0
  43. package/src/manager/routes/user/data-request/delete.js +2 -2
  44. package/src/manager/routes/user/data-request/get.js +2 -2
  45. package/src/manager/routes/user/data-request/post.js +2 -2
  46. package/src/manager/routes/user/delete.js +1 -1
  47. package/src/manager/routes/user/feedback/post.js +12 -8
  48. package/src/manager/routes/user/signup/post.js +48 -37
  49. package/src/manager/schemas/admin/email/post.js +4 -4
  50. package/src/manager/schemas/marketing/contact/delete.js +0 -1
  51. package/src/manager/schemas/marketing/contact/post.js +0 -1
  52. package/src/manager/schemas/marketing/contact/put.js +6 -0
  53. package/src/manager/schemas/special/electron-client/post.js +2 -2
  54. package/src/manager/schemas/user/feedback/post.js +2 -2
  55. package/src/test/run-tests.js +1 -1
  56. package/src/test/runner.js +22 -10
  57. package/src/test/test-accounts.js +9 -0
  58. package/src/test/utils/extended-mode-warning.js +11 -0
  59. package/templates/backend-manager-config.json +9 -0
  60. package/test/events/payments/journey-payments-cancel-endpoint.js +11 -0
  61. package/test/events/payments/journey-payments-trial-cancel.js +11 -0
  62. package/test/functions/admin/edit-post.js +2 -2
  63. package/test/functions/admin/write-repo-content.js +2 -2
  64. package/test/functions/general/add-marketing-contact.js +21 -62
  65. package/test/helpers/email-validation.js +420 -0
  66. package/test/helpers/email.js +119 -6
  67. package/test/helpers/marketing-lifecycle.js +121 -0
  68. package/test/helpers/user.js +2 -2
  69. package/test/routes/admin/create-post.js +2 -2
  70. package/test/routes/admin/post.js +2 -2
  71. package/test/routes/admin/repo-content.js +2 -2
  72. package/test/routes/marketing/contact.js +21 -61
  73. package/test/routes/payments/cancel.js +18 -0
  74. /package/src/manager/schemas/{app → brand}/get.js +0 -0
@@ -76,7 +76,7 @@ module.exports = {
76
76
  async run({ http, assert }) {
77
77
  const response = await http.post('admin/email', {
78
78
  subject: 'BEM Test Email - Bad UID',
79
- to: 'uid:nonexistent_uid_12345',
79
+ to: 'nonexistent_uid_12345',
80
80
  copy: false,
81
81
  });
82
82
 
@@ -141,7 +141,7 @@ module.exports = {
141
141
  async run({ http, assert, accounts }) {
142
142
  const response = await http.post('admin/email', {
143
143
  subject: 'BEM Test Email - UID Recipient',
144
- to: `uid:${accounts.admin.uid}`,
144
+ to: accounts.admin.uid,
145
145
  copy: false,
146
146
  data: {
147
147
  email: {
@@ -167,7 +167,7 @@ module.exports = {
167
167
  to: [
168
168
  `_test-receiver@${config.domain}`,
169
169
  { email: `_test-receiver-2@${config.domain}`, name: 'Receiver 2' },
170
- `uid:${accounts.admin.uid}`,
170
+ accounts.admin.uid,
171
171
  ],
172
172
  copy: false,
173
173
  data: {
@@ -378,18 +378,131 @@ module.exports = {
378
378
  assert.isSuccess(response, 'Should send email');
379
379
  assert.equal(response.data.status, 'sent', 'Status should be sent');
380
380
 
381
- const appImages = response.data.options.dynamicTemplateData.app.images;
381
+ const brandImages = response.data.options.dynamicTemplateData.brand.images;
382
382
 
383
383
  // Any image that was an SVG should now be a PNG (-x.svg → -1024.png)
384
- for (const [key, value] of Object.entries(appImages)) {
384
+ for (const [key, value] of Object.entries(brandImages)) {
385
385
  assert.ok(
386
386
  !String(value || '').endsWith('.svg'),
387
- `app.images.${key} should not be an SVG (got: ${value})`,
387
+ `brand.images.${key} should not be an SVG (got: ${value})`,
388
388
  );
389
389
  }
390
390
  },
391
391
  },
392
392
 
393
+ // --- Sender Resolution ---
394
+
395
+ {
396
+ name: 'sender-orders-resolves-from-and-asm',
397
+ auth: 'admin',
398
+ timeout: 30000,
399
+
400
+ async run({ http, assert, config }) {
401
+ const response = await http.post('admin/email', {
402
+ subject: 'BEM Test Email - Sender Orders',
403
+ to: `_test-receiver@${config.domain}`,
404
+ sender: 'orders',
405
+ copy: false,
406
+ data: {
407
+ email: {
408
+ subject: 'BEM Test Email - Sender Orders',
409
+ body: 'Testing sender resolution for orders.',
410
+ },
411
+ },
412
+ });
413
+
414
+ assert.isSuccess(response, 'Should send email with orders sender');
415
+ assert.equal(response.data.status, 'sent', 'Status should be sent');
416
+ assert.ok(response.data.options.from.email.startsWith('orders@'), 'From email should start with orders@');
417
+ assert.ok(response.data.options.from.name.includes('Orders'), 'From name should include Orders');
418
+ assert.ok(response.data.options.asm, 'Should have ASM group');
419
+ assert.ok(response.data.options.replyTo.startsWith('orders@'), 'replyTo should match from address');
420
+ },
421
+ },
422
+
423
+ {
424
+ name: 'sender-security-resolves-from-asm-and-unsubscribe',
425
+ auth: 'admin',
426
+ timeout: 30000,
427
+
428
+ async run({ http, assert, config }) {
429
+ const response = await http.post('admin/email', {
430
+ subject: 'BEM Test Email - Sender Security',
431
+ to: `_test-receiver@${config.domain}`,
432
+ sender: 'security',
433
+ copy: false,
434
+ data: {
435
+ email: {
436
+ subject: 'BEM Test Email - Sender Security',
437
+ body: 'Testing that security sender resolves correctly.',
438
+ },
439
+ },
440
+ });
441
+
442
+ assert.isSuccess(response, 'Should send email with security sender');
443
+ assert.equal(response.data.status, 'sent', 'Status should be sent');
444
+ assert.ok(response.data.options.from.email.startsWith('security@'), 'From email should start with security@');
445
+ assert.ok(response.data.options.asm, 'Should have ASM group');
446
+ assert.ok(response.data.options.headers['List-Unsubscribe'], 'Should have List-Unsubscribe header');
447
+ assert.ok(response.data.options.dynamicTemplateData.email.unsubscribeUrl, 'Should have unsubscribeUrl');
448
+ },
449
+ },
450
+
451
+ {
452
+ name: 'sender-explicit-from-overrides-sender',
453
+ auth: 'admin',
454
+ timeout: 30000,
455
+
456
+ async run({ http, assert, config }) {
457
+ const customFrom = { email: `custom@${config.domain}`, name: 'Custom Sender' };
458
+
459
+ const response = await http.post('admin/email', {
460
+ subject: 'BEM Test Email - From Override',
461
+ to: `_test-receiver@${config.domain}`,
462
+ sender: 'orders',
463
+ from: customFrom,
464
+ copy: false,
465
+ data: {
466
+ email: {
467
+ subject: 'BEM Test Email - From Override',
468
+ body: 'Testing that explicit from overrides sender.',
469
+ },
470
+ },
471
+ });
472
+
473
+ assert.isSuccess(response, 'Should send email with explicit from');
474
+ assert.equal(response.data.status, 'sent', 'Status should be sent');
475
+ assert.equal(response.data.options.from.email, customFrom.email, 'Explicit from should override sender');
476
+ assert.equal(response.data.options.from.name, customFrom.name, 'Explicit from name should override sender');
477
+ },
478
+ },
479
+
480
+ {
481
+ name: 'sender-unknown-falls-back-to-defaults',
482
+ auth: 'admin',
483
+ timeout: 30000,
484
+
485
+ async run({ http, assert, config }) {
486
+ const response = await http.post('admin/email', {
487
+ subject: 'BEM Test Email - Unknown Sender',
488
+ to: `_test-receiver@${config.domain}`,
489
+ sender: 'nonexistent',
490
+ copy: false,
491
+ data: {
492
+ email: {
493
+ subject: 'BEM Test Email - Unknown Sender',
494
+ body: 'Testing that unknown sender falls back to brand defaults.',
495
+ },
496
+ },
497
+ });
498
+
499
+ assert.isSuccess(response, 'Should send email with default from');
500
+ assert.equal(response.data.status, 'sent', 'Status should be sent');
501
+ assert.ok(response.data.options.from.email, 'Should have a from email (brand default)');
502
+ assert.ok(response.data.options.asm, 'Should have default ASM group');
503
+ },
504
+ },
505
+
393
506
  {
394
507
  name: 'sendat-iso-string-accepted',
395
508
  auth: 'admin',
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Test: Marketing lifecycle (add → sync → remove)
3
+ * End-to-end suite testing the full marketing contact flow via routes
4
+ *
5
+ * Requires TEST_EXTENDED_MODE=true and SENDGRID_API_KEY / BEEHIIV_API_KEY env vars.
6
+ * These tests hit real external APIs.
7
+ */
8
+ module.exports = {
9
+ description: 'Marketing lifecycle (add → sync → remove)',
10
+ type: 'suite',
11
+ timeout: 60000,
12
+ skip: !process.env.TEST_EXTENDED_MODE ? 'TEST_EXTENDED_MODE not set' : false,
13
+
14
+ tests: [
15
+ // Step 0: Pre-clean test contacts from providers (in case a previous run left them)
16
+ {
17
+ name: 'pre-clean-test-contacts',
18
+ auth: 'admin',
19
+
20
+ async run({ http, config, state }) {
21
+ const testEmail = `lifecycle.test+bem@${config.domain}`;
22
+ state.testEmail = testEmail;
23
+
24
+ // Delete from all providers — handles "not found" gracefully
25
+ await http.delete('marketing/contact', { email: testEmail }).catch(() => {});
26
+ },
27
+ },
28
+
29
+ // Step 1: Add a contact via POST /marketing/contact
30
+ {
31
+ name: 'add-contact',
32
+ auth: 'admin',
33
+
34
+ async run({ http, assert, state }) {
35
+ const response = await http.post('marketing/contact', {
36
+ email: state.testEmail,
37
+ firstName: 'Lifecycle',
38
+ lastName: 'Test',
39
+ source: 'bem-test-lifecycle',
40
+ });
41
+
42
+ assert.isSuccess(response, 'Add contact should succeed');
43
+ assert.propertyEquals(response, 'data.success', true, 'success should be true');
44
+
45
+ if (process.env.SENDGRID_API_KEY) {
46
+ assert.hasProperty(response, 'data.providers.sendgrid', 'Should have SendGrid result');
47
+ assert.propertyEquals(response, 'data.providers.sendgrid.success', true, 'SendGrid add should succeed');
48
+ }
49
+
50
+ if (process.env.BEEHIIV_API_KEY) {
51
+ assert.hasProperty(response, 'data.providers.beehiiv', 'Should have Beehiiv result');
52
+ assert.propertyEquals(response, 'data.providers.beehiiv.success', true, 'Beehiiv add should succeed');
53
+ }
54
+ },
55
+ },
56
+
57
+ // Step 2: Sync by UID via PUT /marketing/contact
58
+ // Tests the full sync pipeline: UID resolution → buildFields → upsert with custom fields
59
+ {
60
+ name: 'sync-contact-by-uid',
61
+ auth: 'admin',
62
+
63
+ async run({ http, assert, accounts }) {
64
+ // Sync the admin test account — exercises UID→doc resolution + buildFields
65
+ const response = await http.put('marketing/contact', {
66
+ uid: accounts.admin.uid,
67
+ });
68
+
69
+ assert.isSuccess(response, 'Sync contact should succeed');
70
+ assert.propertyEquals(response, 'data.success', true, 'success should be true');
71
+
72
+ if (process.env.SENDGRID_API_KEY) {
73
+ assert.hasProperty(response, 'data.providers.sendgrid', 'Should have SendGrid result');
74
+ assert.propertyEquals(response, 'data.providers.sendgrid.success', true, 'SendGrid sync should succeed');
75
+ }
76
+
77
+ if (process.env.BEEHIIV_API_KEY) {
78
+ assert.hasProperty(response, 'data.providers.beehiiv', 'Should have Beehiiv result');
79
+ assert.propertyEquals(response, 'data.providers.beehiiv.success', true, 'Beehiiv sync should succeed');
80
+ }
81
+ },
82
+ },
83
+
84
+ // Step 3: Remove the contact via DELETE /marketing/contact
85
+ {
86
+ name: 'remove-contact',
87
+ auth: 'admin',
88
+
89
+ async run({ http, assert, state }) {
90
+ const response = await http.delete('marketing/contact', {
91
+ email: state.testEmail,
92
+ });
93
+
94
+ assert.isSuccess(response, 'Remove contact should succeed');
95
+ assert.propertyEquals(response, 'data.success', true, 'success should be true');
96
+
97
+ if (process.env.SENDGRID_API_KEY) {
98
+ assert.hasProperty(response, 'data.providers.sendgrid', 'Should have SendGrid result');
99
+ assert.propertyEquals(response, 'data.providers.sendgrid.success', true, 'SendGrid remove should succeed');
100
+ }
101
+
102
+ if (process.env.BEEHIIV_API_KEY) {
103
+ assert.hasProperty(response, 'data.providers.beehiiv', 'Should have Beehiiv result');
104
+ assert.propertyEquals(response, 'data.providers.beehiiv.success', true, 'Beehiiv remove should succeed');
105
+ }
106
+ },
107
+ },
108
+
109
+ // Step 4: Clean up the admin test contact that sync added
110
+ {
111
+ name: 'cleanup-synced-admin-contact',
112
+ auth: 'admin',
113
+
114
+ async run({ http, accounts }) {
115
+ await http.delete('marketing/contact', {
116
+ email: accounts.admin.email,
117
+ }).catch(() => {});
118
+ },
119
+ },
120
+ ],
121
+ };
@@ -410,7 +410,7 @@ module.exports = {
410
410
  const user = createUser({});
411
411
  const expectedKeys = [
412
412
  'auth', 'subscription', 'roles', 'flags', 'affiliate',
413
- 'activity', 'api', 'usage', 'personal', 'oauth2', 'attribution',
413
+ 'activity', 'api', 'usage', 'personal', 'oauth2', 'attribution', 'metadata',
414
414
  ];
415
415
 
416
416
  for (const key of expectedKeys) {
@@ -425,7 +425,7 @@ module.exports = {
425
425
  const user = createUser({});
426
426
  const expectedKeys = [
427
427
  'auth', 'subscription', 'roles', 'flags', 'affiliate',
428
- 'activity', 'api', 'usage', 'personal', 'oauth2', 'attribution',
428
+ 'activity', 'api', 'usage', 'personal', 'oauth2', 'attribution', 'metadata',
429
429
  ];
430
430
 
431
431
  for (const key of Object.keys(user)) {
@@ -113,13 +113,13 @@ module.exports = {
113
113
  return;
114
114
  }
115
115
 
116
- if (!config.githubRepoWebsite) {
116
+ if (!config.github?.repo_website) {
117
117
  assert.fail('githubRepoWebsite not configured');
118
118
  return;
119
119
  }
120
120
 
121
121
  // Parse owner/repo for cleanup later
122
- const repoMatch = config.githubRepoWebsite.match(/github\.com\/([^/]+)\/([^/]+)/);
122
+ const repoMatch = config.github?.repo_website.match(/github\.com\/([^/]+)\/([^/]+)/);
123
123
  if (!repoMatch) {
124
124
  assert.fail('Could not parse githubRepoWebsite');
125
125
  return;
@@ -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
  }
@@ -208,46 +208,9 @@ module.exports = {
208
208
  },
209
209
  },
210
210
 
211
- // Test 7: Admin can specify providers
211
+ // Test 7: Mailbox verification (only runs if TEST_EXTENDED_MODE and ZEROBOUNCE_API_KEY are set)
212
212
  {
213
- name: 'add-admin-specify-providers',
214
- auth: 'admin',
215
- timeout: 30000,
216
-
217
- async run({ http, assert, config, state }) {
218
- const testEmail = TEST_EMAILS.valid(config.domain);
219
- state.testEmail = testEmail;
220
-
221
- const response = await http.post('marketing/contact', {
222
- email: testEmail,
223
- source: 'bem-test',
224
- providers: ['sendgrid'], // Only SendGrid, not Beehiiv
225
- // No firstName/lastName - should be inferred as "Rachel Greene"
226
- });
227
-
228
- assert.isSuccess(response, 'Add marketing contact with specific providers should succeed');
229
-
230
- // Provider calls only happen in extended mode
231
- if (process.env.TEST_EXTENDED_MODE) {
232
- // Should only have sendgrid result (since we specified providers: ['sendgrid'])
233
- assert.hasProperty(response.data.providers, 'sendgrid', 'Should have SendGrid result');
234
- state.sendgridAdded = response.data?.providers?.sendgrid?.success;
235
- // Beehiiv not called since we only specified sendgrid
236
- }
237
- },
238
-
239
- async cleanup({ state, http }) {
240
- if (!process.env.TEST_EXTENDED_MODE || !state.testEmail) {
241
- return;
242
- }
243
-
244
- await http.delete('marketing/contact', { email: state.testEmail });
245
- },
246
- },
247
-
248
- // Test 8: ZeroBounce validation (only runs if TEST_EXTENDED_MODE and ZEROBOUNCE_API_KEY are set)
249
- {
250
- name: 'add-zerobounce-validation',
213
+ name: 'add-mailbox-validation',
251
214
  auth: 'admin',
252
215
  timeout: 30000,
253
216
  skip: !process.env.TEST_EXTENDED_MODE || !process.env.ZEROBOUNCE_API_KEY
@@ -261,7 +224,6 @@ module.exports = {
261
224
  const response = await http.post('marketing/contact', {
262
225
  email: testEmail,
263
226
  source: 'bem-test',
264
- // No firstName/lastName - should be inferred as "Rachel Greene"
265
227
  });
266
228
 
267
229
  assert.isSuccess(response, 'Add marketing contact should succeed');
@@ -270,17 +232,17 @@ module.exports = {
270
232
  assert.hasProperty(response, 'data.validation', 'Response should contain validation');
271
233
  assert.hasProperty(response, 'data.validation.checks', 'Validation should contain checks');
272
234
 
273
- // ZeroBounce should be in checks when key is set
274
- assert.hasProperty(response, 'data.validation.checks.zerobounce', 'Should have ZeroBounce check');
235
+ // Mailbox check should be in checks when key is set
236
+ assert.hasProperty(response, 'data.validation.checks.mailbox', 'Should have mailbox check');
275
237
 
276
- const zbResult = response.data.validation.checks.zerobounce;
238
+ const mbResult = response.data.validation.checks.mailbox;
277
239
 
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');
240
+ // If out of credits, skip test - not a failure
241
+ if (mbResult.error?.includes('out of credits')) {
242
+ skip('Mailbox verification out of credits');
281
243
  }
282
244
 
283
- assert.hasProperty(zbResult, 'status', 'ZeroBounce should return status');
245
+ assert.hasProperty(mbResult, 'status', 'Mailbox check should return status');
284
246
 
285
247
  state.sendgridAdded = response.data?.providers?.sendgrid?.success;
286
248
  state.beehiivAdded = response.data?.providers?.beehiiv?.success;
@@ -295,9 +257,9 @@ module.exports = {
295
257
  },
296
258
  },
297
259
 
298
- // Test 9: ZeroBounce rejects invalid email (only runs if TEST_EXTENDED_MODE and ZEROBOUNCE_API_KEY are set)
260
+ // Test 9: Mailbox verification rejects invalid email (only runs if TEST_EXTENDED_MODE and ZEROBOUNCE_API_KEY are set)
299
261
  {
300
- name: 'add-zerobounce-rejects-invalid',
262
+ name: 'add-mailbox-rejects-invalid',
301
263
  auth: 'admin',
302
264
  timeout: 30000,
303
265
  skip: !process.env.TEST_EXTENDED_MODE || !process.env.ZEROBOUNCE_API_KEY
@@ -305,30 +267,28 @@ module.exports = {
305
267
  : false,
306
268
 
307
269
  async run({ http, assert, skip }) {
308
- // Use fake email that ZeroBounce should flag as invalid
270
+ // Use fake email that mailbox verification should flag as invalid
309
271
  const testEmail = TEST_EMAILS.invalid();
310
272
 
311
273
  const response = await http.post('marketing/contact', {
312
274
  email: testEmail,
313
275
  source: 'bem-test',
314
- // No firstName/lastName - AI will try to infer from "test"
315
276
  });
316
277
 
317
- // Should still succeed (we fail open) but ZeroBounce should report invalid
278
+ // Should still succeed (we fail open) but mailbox should report invalid
318
279
  assert.isSuccess(response, 'Request should succeed even with invalid email');
319
280
 
320
- const zbResult = response.data?.validation?.checks?.zerobounce;
281
+ const mbResult = response.data?.validation?.checks?.mailbox;
321
282
 
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');
283
+ // If out of credits, skip test - not a failure
284
+ if (mbResult?.error?.includes('out of credits')) {
285
+ skip('Mailbox verification out of credits');
325
286
  }
326
287
 
327
- // ZeroBounce should return a status indicating the email is not valid
328
- if (zbResult) {
329
- assert.hasProperty(zbResult, 'status', 'Should have status');
330
- // Status should NOT be 'valid' for this fake email
331
- assert.notEqual(zbResult.status, 'valid', 'Fake email should not be marked valid');
288
+ // Mailbox should return a status indicating the email is not valid
289
+ if (mbResult) {
290
+ assert.hasProperty(mbResult, 'status', 'Should have status');
291
+ assert.notEqual(mbResult.status, 'valid', 'Fake email should not be marked valid');
332
292
  }
333
293
  },
334
294
  },
@@ -66,6 +66,18 @@ module.exports = {
66
66
  },
67
67
  },
68
68
 
69
+ {
70
+ name: 'rejects-subscription-younger-than-24-hours',
71
+ async run({ http, assert }) {
72
+ // cancel-too-young starts with startDate set to now (< 24 hours old)
73
+ const response = await http.as('cancel-too-young').post('payments/cancel', {
74
+ confirmed: true,
75
+ });
76
+
77
+ assert.isError(response, 400, 'Should reject subscription younger than 24 hours');
78
+ },
79
+ },
80
+
69
81
  {
70
82
  name: 'rejects-unknown-processor',
71
83
  async run({ http, assert }) {
@@ -101,6 +113,12 @@ module.exports = {
101
113
  && userDoc?.subscription?.status === 'active';
102
114
  }, 15000, 500);
103
115
 
116
+ // Backdate startDate so the 24-hour guard doesn't block cancellation
117
+ const twoDaysAgo = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000);
118
+ await firestore.set(`users/${uid}`, {
119
+ subscription: { payment: { startDate: { timestamp: twoDaysAgo.toISOString(), timestampUNIX: twoDaysAgo.getTime() } } },
120
+ }, { merge: true });
121
+
104
122
  // Step 2: Call the cancel endpoint
105
123
  const cancelResponse = await http.as('route-cancel-success').post('payments/cancel', {
106
124
  confirmed: true,
File without changes