backend-manager 5.1.2 → 5.2.0

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 (97) hide show
  1. package/.claude/settings.local.json +12 -0
  2. package/CHANGELOG.md +52 -0
  3. package/CLAUDE.md +2 -1
  4. package/README.md +30 -0
  5. package/docs/common-mistakes.md +1 -0
  6. package/docs/consent.md +333 -0
  7. package/docs/marketing-campaigns.md +41 -4
  8. package/docs/testing.md +81 -0
  9. package/package.json +1 -1
  10. package/src/cli/commands/emulator.js +62 -9
  11. package/src/cli/commands/serve.js +73 -7
  12. package/src/cli/commands/test.js +65 -1
  13. package/src/cli/commands/watch.js +15 -3
  14. package/src/defaults/CLAUDE.md +7 -5
  15. package/src/manager/events/cron/daily/marketing-newsletter-generate.js +24 -8
  16. package/src/manager/helpers/user.js +29 -0
  17. package/src/manager/index.js +111 -5
  18. package/src/manager/libraries/ai/index.js +21 -0
  19. package/src/manager/libraries/ai/providers/openai.js +75 -0
  20. package/src/manager/libraries/email/data/disposable-domains.json +20 -0
  21. package/src/manager/libraries/email/generators/lib/image-host.js +58 -6
  22. package/src/manager/libraries/email/generators/lib/markdown-renderer.js +285 -0
  23. package/src/manager/libraries/email/generators/lib/structure.js +19 -2
  24. package/src/manager/libraries/email/generators/lib/svg-illustrator.js +12 -3
  25. package/src/manager/libraries/email/generators/lib/templates/classic-schema.js +9 -13
  26. package/src/manager/libraries/email/generators/lib/templates/clean.js +1 -1
  27. package/src/manager/libraries/email/generators/lib/templates/editorial.js +1 -1
  28. package/src/manager/libraries/email/generators/lib/templates/field-report.js +10 -14
  29. package/src/manager/libraries/email/generators/newsletter.js +154 -7
  30. package/src/manager/libraries/email/providers/beehiiv.js +8 -1
  31. package/src/manager/libraries/payment/processors/stripe.js +12 -0
  32. package/src/manager/libraries/payment/processors/test.js +8 -1
  33. package/src/manager/routes/admin/infer-contact/post.js +3 -2
  34. package/src/manager/routes/admin/post/post.js +3 -3
  35. package/src/manager/routes/marketing/email-preferences/post.js +165 -37
  36. package/src/manager/routes/marketing/webhook/forward/post.js +168 -0
  37. package/src/manager/routes/marketing/webhook/post.js +180 -0
  38. package/src/manager/routes/marketing/webhook/processors/beehiiv.js +196 -0
  39. package/src/manager/routes/marketing/webhook/processors/sendgrid.js +171 -0
  40. package/src/manager/routes/payments/cancel/post.js +2 -2
  41. package/src/manager/routes/payments/cancel/processors/test.js +5 -2
  42. package/src/manager/routes/payments/intent/processors/test.js +7 -3
  43. package/src/manager/routes/payments/refund/processors/test.js +4 -1
  44. package/src/manager/routes/test/health/get.js +17 -0
  45. package/src/manager/routes/user/signup/post.js +65 -1
  46. package/src/manager/schemas/marketing/email-preferences/post.js +8 -4
  47. package/src/manager/schemas/marketing/webhook/forward/post.js +8 -0
  48. package/src/manager/schemas/marketing/webhook/post.js +7 -0
  49. package/src/manager/schemas/payments/cancel/post.js +5 -0
  50. package/src/manager/schemas/user/signup/post.js +5 -0
  51. package/src/test/run-tests.js +30 -0
  52. package/src/test/runner.js +72 -26
  53. package/src/test/test-accounts.js +94 -12
  54. package/src/test/utils/http-client.js +4 -3
  55. package/src/test/utils/test-mode-file.js +192 -0
  56. package/test/events/payments/journey-payments-cancel-endpoint.js +3 -12
  57. package/test/events/payments/journey-payments-cancel.js +4 -5
  58. package/test/events/payments/journey-payments-failure.js +0 -1
  59. package/test/events/payments/journey-payments-one-time-failure.js +6 -3
  60. package/test/events/payments/journey-payments-one-time.js +6 -3
  61. package/test/events/payments/journey-payments-plan-change.js +5 -5
  62. package/test/events/payments/journey-payments-refund-webhook.js +2 -3
  63. package/test/events/payments/journey-payments-suspend.js +4 -5
  64. package/test/events/payments/journey-payments-trial-cancel.js +3 -12
  65. package/test/events/payments/journey-payments-trial.js +2 -3
  66. package/test/events/payments/journey-payments-uid-resolution.js +2 -3
  67. package/test/functions/admin/database-read.js +0 -14
  68. package/test/functions/admin/database-write.js +0 -14
  69. package/test/functions/admin/firestore-query.js +0 -14
  70. package/test/functions/admin/firestore-read.js +0 -15
  71. package/test/functions/admin/firestore-write.js +0 -11
  72. package/test/functions/general/add-marketing-contact.js +16 -14
  73. package/test/helpers/email.js +1 -1
  74. package/test/helpers/infer-contact.js +3 -3
  75. package/test/helpers/user.js +241 -2
  76. package/test/helpers/webhook-forward.js +392 -0
  77. package/test/marketing/fixtures/clean.json +2 -3
  78. package/test/marketing/fixtures/editorial.json +2 -3
  79. package/test/marketing/fixtures/field-report.json +3 -4
  80. package/test/marketing/newsletter-generate.js +78 -54
  81. package/test/marketing/newsletter-templates.js +12 -33
  82. package/test/routes/admin/create-post.js +2 -2
  83. package/test/routes/admin/database.js +0 -13
  84. package/test/routes/admin/firestore-query.js +0 -13
  85. package/test/routes/admin/firestore.js +0 -14
  86. package/test/routes/admin/infer-contact.js +6 -3
  87. package/test/routes/admin/post.js +4 -2
  88. package/test/routes/marketing/contact.js +60 -26
  89. package/test/routes/marketing/email-preferences.js +145 -69
  90. package/test/routes/marketing/webhook-forward.js +54 -0
  91. package/test/routes/marketing/webhook.js +582 -0
  92. package/test/routes/payments/cancel.js +2 -7
  93. package/test/routes/payments/dispute-alert.js +0 -39
  94. package/test/routes/payments/refund.js +3 -1
  95. package/test/routes/payments/webhook.js +5 -26
  96. package/test/routes/test/usage.js +2 -2
  97. package/test/routes/user/signup.js +114 -0
@@ -0,0 +1,171 @@
1
+ /**
2
+ * SendGrid webhook processor
3
+ *
4
+ * SendGrid sends an array of events per POST. This module parses the array,
5
+ * decides which events represent an unsubscribe (revocation of marketing consent),
6
+ * and exposes a per-event handler that:
7
+ * 1. Looks up the user by email in this brand's Firestore (silent skip if not found)
8
+ * 2. Writes consent.marketing.status = 'revoked' to the user doc, source: 'sendgrid'
9
+ * 3. Calls Beehiiv to remove the contact too (cross-provider sync — idempotent on 404)
10
+ *
11
+ * Supported event types (anything else is silently ignored):
12
+ * - 'unsubscribe' — user clicked the unified unsubscribe link
13
+ * - 'group_unsubscribe' — user unsubscribed from a specific ASM group
14
+ * - 'spamreport' — user marked email as spam
15
+ * - 'bounce' — hard bounce (treat as revoke to avoid future sends)
16
+ * - 'dropped' — SendGrid dropped the message (often due to suppression)
17
+ *
18
+ * Note: 'group_unsubscribe' is the most common one (matches our ASM-link flow), and since
19
+ * GROUPS.marketing (25928) is account-global across all brands, an unsub from group 25928
20
+ * legitimately removes the user from marketing across the entire SendGrid account.
21
+ *
22
+ * Idempotency is enforced by the dispatcher via marketing-webhooks/{eventId} doc.
23
+ */
24
+
25
+ const REVOKE_EVENT_TYPES = new Set([
26
+ 'unsubscribe',
27
+ 'group_unsubscribe',
28
+ 'spamreport',
29
+ 'bounce',
30
+ 'dropped',
31
+ ]);
32
+
33
+ /**
34
+ * Parse the raw webhook request into a normalized array of events.
35
+ * SendGrid sends an array of events as the body. Some HTTP clients (including
36
+ * BEM's own test client) JSON-encode arrays as objects with numeric keys —
37
+ * we tolerate both shapes plus the rare single-event object form.
38
+ */
39
+ function parseWebhook(req) {
40
+ const body = req.body;
41
+
42
+ if (!body) {
43
+ throw new Error('Empty webhook body');
44
+ }
45
+
46
+ let events;
47
+ if (Array.isArray(body)) {
48
+ // Real SendGrid: array body
49
+ events = body;
50
+ } else if (body && typeof body === 'object' && typeof body['0'] === 'object' && body['0'] !== null) {
51
+ // Array serialized as object-with-numeric-keys (test clients, some proxies)
52
+ events = [];
53
+ let i = 0;
54
+ while (body[String(i)]) {
55
+ events.push(body[String(i)]);
56
+ i += 1;
57
+ }
58
+ } else {
59
+ // Single event sent as a bare object
60
+ events = [body];
61
+ }
62
+
63
+ return events.map((event) => {
64
+ // sg_event_id is SendGrid's per-event unique ID, used for idempotency.
65
+ // smtp-id is another stable identifier we fall back to.
66
+ const eventId = event.sg_event_id || event['smtp-id'] || event.smtpId || null;
67
+ const eventType = event.event;
68
+ const email = typeof event.email === 'string' ? event.email.trim().toLowerCase() : null;
69
+ const timestamp = typeof event.timestamp === 'number' ? event.timestamp : null;
70
+ const asmGroupId = event.asm_group_id || null;
71
+
72
+ return {
73
+ eventId,
74
+ eventType,
75
+ email,
76
+ timestamp,
77
+ asmGroupId,
78
+ raw: event,
79
+ };
80
+ });
81
+ }
82
+
83
+ /**
84
+ * Returns true if this event type represents a revocation we should act on.
85
+ */
86
+ function isSupported(eventType) {
87
+ return REVOKE_EVENT_TYPES.has(eventType);
88
+ }
89
+
90
+ /**
91
+ * Process a single parsed event. Called by the dispatcher AFTER idempotency check passes.
92
+ *
93
+ * Returns a result object summarizing what happened (for logging/response).
94
+ */
95
+ async function handleEvent({ Manager, assistant, parsed }) {
96
+ const { admin } = Manager.libraries;
97
+ const { eventId, eventType, email, timestamp } = parsed;
98
+
99
+ if (!email) {
100
+ assistant.log(`sendgrid webhook: event ${eventId} (${eventType}) missing email, skipping`);
101
+ return { handled: false, reason: 'missing-email' };
102
+ }
103
+
104
+ // Convert SendGrid's UNIX timestamp to our canonical { timestamp, timestampUNIX } shape.
105
+ // Fall back to server time if missing.
106
+ const startTime = assistant.meta.startTime;
107
+ const eventUNIX = typeof timestamp === 'number' ? timestamp : startTime.timestampUNIX;
108
+ const eventISO = new Date(eventUNIX * 1000).toISOString();
109
+
110
+ // Look up the user by email
111
+ const snapshot = await admin.firestore().collection('users')
112
+ .where('auth.email', '==', email)
113
+ .limit(1)
114
+ .get()
115
+ .catch((e) => {
116
+ assistant.error(`sendgrid webhook: user lookup failed for ${email}:`, e);
117
+ return null;
118
+ });
119
+
120
+ if (!snapshot || snapshot.empty) {
121
+ // Silent skip — this email may not map to a customer of THIS brand (shared SendGrid account).
122
+ assistant.log(`sendgrid webhook: no user found for ${email}, skipping doc update`);
123
+ return { handled: false, reason: 'user-not-found', email };
124
+ }
125
+
126
+ const userDoc = snapshot.docs[0];
127
+ const uid = userDoc.id;
128
+
129
+ // Write consent.marketing.status = 'revoked' (preserve grantedAt — informational audit trail)
130
+ await admin.firestore().doc(`users/${uid}`).set({
131
+ consent: {
132
+ marketing: {
133
+ status: 'revoked',
134
+ revokedAt: {
135
+ timestamp: eventISO,
136
+ timestampUNIX: eventUNIX,
137
+ source: 'sendgrid',
138
+ ip: null,
139
+ text: null,
140
+ },
141
+ },
142
+ },
143
+ metadata: Manager.Metadata().set({ tag: 'marketing/webhook:sendgrid' }),
144
+ }, { merge: true });
145
+
146
+ assistant.log(`sendgrid webhook: revoked consent.marketing for ${uid} (${email}) — eventType=${eventType}`);
147
+
148
+ // Cross-provider sync: also remove from Beehiiv (best-effort, idempotent on 404)
149
+ const shouldCallExternalAPIs = !assistant.isTesting() || process.env.TEST_EXTENDED_MODE;
150
+
151
+ if (shouldCallExternalAPIs) {
152
+ try {
153
+ const mailer = Manager.Email(assistant);
154
+ await mailer.remove(email);
155
+ assistant.log(`sendgrid webhook: cross-provider sync complete for ${email}`);
156
+ } catch (e) {
157
+ // Best-effort — user doc is already updated. Log + continue.
158
+ assistant.error(`sendgrid webhook: cross-provider sync failed for ${email}:`, e);
159
+ }
160
+ } else {
161
+ assistant.log('sendgrid webhook: skipping cross-provider sync (BEM_TESTING=true)');
162
+ }
163
+
164
+ return { handled: true, uid, email, eventType };
165
+ }
166
+
167
+ module.exports = {
168
+ parseWebhook,
169
+ isSupported,
170
+ handleEvent,
171
+ };
@@ -32,9 +32,9 @@ module.exports = async ({ assistant, user, settings }) => {
32
32
  return assistant.respond('No active paid subscription found', { code: 400 });
33
33
  }
34
34
 
35
- // Guard: subscription younger than 24 hours
35
+ // Guard: subscription younger than 24 hours (callers may bypass via skipGuards)
36
36
  const startDateUNIX = subscription.payment?.startDate?.timestampUNIX;
37
- if (startDateUNIX) {
37
+ if (!settings.skipGuards && startDateUNIX) {
38
38
  const ageMs = Date.now() - (startDateUNIX * 1000);
39
39
  const twentyFourHoursMs = 24 * 60 * 60 * 1000;
40
40
  if (ageMs < twentyFourHoursMs) {
@@ -23,7 +23,8 @@ module.exports = {
23
23
  const now = Math.floor(timestamp / 1000);
24
24
  const periodEnd = now + (30 * 86400);
25
25
 
26
- // Look up the Stripe product ID from the existing order so resolveProduct() can match
26
+ // Look up the Stripe product ID from the existing order so resolveProduct() can match.
27
+ // Falls back to the "_test_<id>" sentinel when no real Stripe product is configured.
27
28
  const orderId = subscription?.payment?.orderId;
28
29
  let stripeProductId = null;
29
30
 
@@ -34,7 +35,9 @@ module.exports = {
34
35
  const productId = orderData.unified?.product?.id;
35
36
  const products = assistant.Manager.config.payment?.products || [];
36
37
  const product = products.find(p => p.id === productId);
37
- stripeProductId = product?.stripe?.productId || null;
38
+ if (product) {
39
+ stripeProductId = product.stripe?.productId || `_test_${product.id}`;
40
+ }
38
41
  }
39
42
  }
40
43
 
@@ -57,14 +57,18 @@ async function createSubscriptionIntent({ uid, orderId, product, frequency, tria
57
57
  const now = Math.floor(timestamp / 1000);
58
58
  const periodEnd = now + (FREQUENCY_TO_PERIOD[frequency] || 30 * 86400);
59
59
 
60
- // Build Stripe-shaped subscription object
61
- // Uses product's Stripe product ID so resolveProduct() can match it
60
+ // Build Stripe-shaped subscription object.
61
+ // Prefer the real Stripe product ID if configured; otherwise use a sentinel of the
62
+ // form "_test_<id>" that the Stripe resolver recognizes and maps back to product.id.
63
+ // This lets the test processor work in brands that haven't wired up Stripe yet.
64
+ const planProductId = product.stripe?.productId || `_test_${product.id}`;
65
+
62
66
  const subscription = {
63
67
  id: subscriptionId,
64
68
  object: 'subscription',
65
69
  status: trial && product.trial?.days ? 'trialing' : 'active',
66
70
  metadata: { uid, orderId },
67
- plan: { product: product.stripe?.productId || null, interval },
71
+ plan: { product: planProductId, interval },
68
72
  current_period_end: periodEnd,
69
73
  current_period_start: now,
70
74
  start_date: now,
@@ -22,6 +22,7 @@ module.exports = {
22
22
 
23
23
  // Look up the Stripe product ID from the existing order so resolveProduct() can match
24
24
  const orderId = subscription?.payment?.orderId;
25
+ // Falls back to the "_test_<id>" sentinel when no real Stripe product is configured.
25
26
  let stripeProductId = null;
26
27
 
27
28
  if (orderId) {
@@ -31,7 +32,9 @@ module.exports = {
31
32
  const productId = orderData.unified?.product?.id;
32
33
  const products = assistant.Manager.config.payment?.products || [];
33
34
  const product = products.find(p => p.id === productId);
34
- stripeProductId = product?.stripe?.productId || null;
35
+ if (product) {
36
+ stripeProductId = product.stripe?.productId || `_test_${product.id}`;
37
+ }
35
38
  }
36
39
  }
37
40
 
@@ -1,5 +1,22 @@
1
+ const path = require('path');
2
+ const { readTestMode, applyEnvFromFile } = require('../../../../test/utils/test-mode-file.js');
3
+
1
4
  module.exports = async ({ assistant, Manager }) => {
2
5
 
6
+ // Belt-and-suspenders freshness check: re-read the test-mode file before
7
+ // reporting `testExtendedMode`. fs.watch installed in Manager.init usually
8
+ // catches changes within ~50ms, but this handler hits the disk directly to
9
+ // guarantee the runner sees the actual current value even if the watcher
10
+ // missed an event. ~1ms cost on a debug endpoint.
11
+ try {
12
+ const projectDir = path.dirname(Manager.cwd);
13
+ const data = readTestMode(projectDir);
14
+ applyEnvFromFile(data);
15
+ } catch (e) {
16
+ // Non-fatal — if the file can't be read, fall through to whatever
17
+ // process.env already has.
18
+ }
19
+
3
20
  const response = {
4
21
  status: 'healthy',
5
22
  timestamp: new Date().toISOString(),
@@ -76,7 +76,12 @@ module.exports = async ({ assistant, user, settings, libraries }) => {
76
76
  await processAffiliate(assistant, uid, email, settings);
77
77
 
78
78
  // 6. Send emails + marketing (non-blocking, fire-and-forget)
79
- syncMarketingContact(assistant, uid, email);
79
+ // Gate marketing sync on explicit consent — never add a user to marketing lists without it
80
+ if (userRecord.consent?.marketing?.status === 'granted') {
81
+ syncMarketingContact(assistant, uid, email);
82
+ } else {
83
+ assistant.log(`signup(): Skipping marketing sync — consent.marketing.status is "${userRecord.consent?.marketing?.status}"`);
84
+ }
80
85
  sendWelcomeEmails(assistant, uid, inferred?.firstName);
81
86
 
82
87
  return assistant.respond({ signedUp: true });
@@ -137,6 +142,7 @@ function buildUserRecord(assistant, settings, inferred) {
137
142
  },
138
143
  },
139
144
  attribution: attribution || {},
145
+ consent: buildConsentRecord(assistant, settings.consent),
140
146
  metadata: Manager.Metadata().set({ tag: 'user/signup' }),
141
147
  };
142
148
 
@@ -156,6 +162,64 @@ function buildUserRecord(assistant, settings, inferred) {
156
162
  return record;
157
163
  }
158
164
 
165
+ /**
166
+ * Translate the client's lightweight consent payload into the canonical user-doc shape.
167
+ *
168
+ * Client sends: { legal: { granted, text }, marketing: { granted, text } }
169
+ * Server writes: { legal: { status, grantedAt: {...} }, marketing: { status, grantedAt: {...}, revokedAt: {...} } }
170
+ *
171
+ * Server time (not client-supplied) is authoritative — defends against clock manipulation.
172
+ * IP is captured from request geolocation.
173
+ *
174
+ * Legal is REQUIRED — the client must send legal.granted=true. If missing/false we still
175
+ * record what the client sent, but the route will not have reached this point in practice
176
+ * (the signup-form HTML5-requires the legal checkbox).
177
+ */
178
+ function buildConsentRecord(assistant, clientConsent) {
179
+ const consent = clientConsent || {};
180
+ const ip = assistant.request.geolocation?.ip || null;
181
+ const timestamp = assistant.meta.startTime.timestamp;
182
+ const timestampUNIX = assistant.meta.startTime.timestampUNIX;
183
+
184
+ // Build empty leaf shape — used wherever grantedAt or revokedAt is "not set"
185
+ const emptyMeta = { timestamp: null, timestampUNIX: null, source: null, ip: null, text: null };
186
+
187
+ // --- Legal ---
188
+ const legalGranted = consent.legal?.granted === true;
189
+ const legalText = typeof consent.legal?.text === 'string' ? consent.legal.text : null;
190
+
191
+ const legal = legalGranted
192
+ ? {
193
+ status: 'granted',
194
+ grantedAt: { timestamp, timestampUNIX, source: 'signup', ip, text: legalText },
195
+ }
196
+ : {
197
+ status: 'revoked',
198
+ grantedAt: { ...emptyMeta },
199
+ };
200
+
201
+ // --- Marketing ---
202
+ const marketingGranted = consent.marketing?.granted === true;
203
+ const marketingText = typeof consent.marketing?.text === 'string' ? consent.marketing.text : null;
204
+
205
+ const marketing = marketingGranted
206
+ ? {
207
+ status: 'granted',
208
+ grantedAt: { timestamp, timestampUNIX, source: 'signup', ip, text: marketingText },
209
+ revokedAt: { ...emptyMeta },
210
+ }
211
+ : {
212
+ status: 'revoked',
213
+ grantedAt: { ...emptyMeta },
214
+ // Record the decline with source=signup-form-declined. text=null (no message shown).
215
+ revokedAt: { timestamp, timestampUNIX, source: 'signup', ip, text: null },
216
+ };
217
+
218
+ assistant.log(`buildConsentRecord: legal=${legal.status}, marketing=${marketing.status} (raw input legal.granted=${consent.legal?.granted}, marketing.granted=${consent.marketing?.granted})`);
219
+
220
+ return { legal, marketing };
221
+ }
222
+
159
223
  /**
160
224
  * Infer name/company from email using AI (or regex fallback)
161
225
  * Returns the inferred contact info, or null on failure
@@ -1,9 +1,13 @@
1
1
  /**
2
2
  * Schema for POST /marketing/email-preferences
3
+ *
4
+ * Two supported modes (route decides based on user.authenticated):
5
+ * - Authenticated (account page toggle): action ('subscribe' | 'unsubscribe'). Other fields ignored.
6
+ * - Anonymous (HMAC link from email footer): email + asmId + sig + action ('subscribe' | 'unsubscribe').
3
7
  */
4
8
  module.exports = () => ({
5
- email: { types: ['string'], default: undefined, required: true },
6
- asmId: { types: ['string', 'number'], default: undefined, required: true },
7
- action: { types: ['string'], default: 'unsubscribe' },
8
- sig: { types: ['string'], default: undefined, required: true },
9
+ email: { types: ['string'], default: undefined, required: false },
10
+ asmId: { types: ['string', 'number'], default: undefined, required: false },
11
+ action: { types: ['string'], default: 'unsubscribe', required: true },
12
+ sig: { types: ['string'], default: undefined, required: false },
9
13
  });
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Schema: POST /marketing/webhook/forward
3
+ *
4
+ * Empty by design — the body is the raw provider webhook payload that gets
5
+ * forwarded to every child BEM unchanged. Validation happens in the dispatcher
6
+ * (provider + key query params) and at each child's receiver.
7
+ */
8
+ module.exports = () => ({});
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Schema: POST /marketing/webhook
3
+ *
4
+ * Empty by design — webhook payloads are provider-defined and validated inside
5
+ * each processor module. Auth + provider come from query params, not the body.
6
+ */
7
+ module.exports = () => ({});
@@ -15,4 +15,9 @@ module.exports = () => ({
15
15
  types: ['boolean'],
16
16
  required: true,
17
17
  },
18
+ // Bypass route-level guards (e.g. 24-hour subscription age). Used by tests and internal callers.
19
+ skipGuards: {
20
+ types: ['boolean'],
21
+ default: false,
22
+ },
18
23
  });
@@ -19,4 +19,9 @@ module.exports = ({ user }) => ({
19
19
  default: {},
20
20
  required: false,
21
21
  },
22
+ consent: {
23
+ types: ['object'],
24
+ default: {},
25
+ required: false,
26
+ },
22
27
  });
@@ -6,6 +6,13 @@
6
6
  * It reads configuration from BEM_TEST_CONFIG environment variable and runs the test suite
7
7
  */
8
8
 
9
+ // Mark this process as the test runner BEFORE loading any BEM code. Manager.init()
10
+ // auto-detects this and skips Firebase Functions / server / Sentry wiring (which
11
+ // can't run outside a real Functions runtime). This is what lets tests receive a
12
+ // fully-wired Manager + assistant in their context — no per-test stub.
13
+ process.env.BEM_TEST_RUNNER = '1';
14
+
15
+ const path = require('path');
9
16
  const TestRunner = require('./runner.js');
10
17
 
11
18
  async function main() {
@@ -33,10 +40,33 @@ async function main() {
33
40
  console.error('Warning: Could not initialize Firebase Admin:', error.message);
34
41
  }
35
42
 
43
+ // Boot a real Manager. With BEM_TEST_RUNNER set, init() loads libraries +
44
+ // resolves project config but skips the parts that need a Functions runtime
45
+ // (handler wiring, server boot, Sentry, admin.initializeApp re-init).
46
+ // The resulting Manager + assistant are passed into every test context, so
47
+ // tests can call Manager.AI(), Manager.Email(), Manager.User(), etc. exactly
48
+ // like production code does — no hand-rolled stubs.
49
+ let Manager = null;
50
+ let assistant = null;
51
+ try {
52
+ const projectDir = testConfig.projectDir || process.cwd();
53
+ const BackendManager = require('../manager/index.js');
54
+ Manager = new BackendManager();
55
+ Manager.init(null, {
56
+ cwd: path.join(projectDir, 'functions'),
57
+ log: false,
58
+ });
59
+ assistant = Manager.Assistant({}, { functionName: 'bem-test-runner', accept: 'json' });
60
+ } catch (error) {
61
+ console.error('Warning: Could not initialize BEM Manager for tests:', error.message);
62
+ }
63
+
36
64
  // Create and run the test runner
37
65
  const runner = new TestRunner({
38
66
  ...testConfig,
39
67
  admin,
68
+ Manager,
69
+ assistant,
40
70
  });
41
71
 
42
72
  const results = await runner.run();
@@ -87,7 +87,7 @@ class TestRunner {
87
87
  // Health check (use basic http client without accounts)
88
88
  // Use hosting URL for all requests (rewrites to bm_api function)
89
89
  const healthHttp = new HttpClient({
90
- hostingUrl: this.options.hostingUrl,
90
+ apiUrl: this.options.apiUrl,
91
91
  timeout: this.options.timeout,
92
92
  });
93
93
 
@@ -119,23 +119,30 @@ 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
+
122
141
  // Cleanup rules context
123
142
  if (this.rulesContext) {
124
143
  await this.rulesContext.cleanup();
125
144
  }
126
145
 
127
- // Clean up test accounts from marketing providers (SendGrid/Beehiiv)
128
- // Run at end of tests so auth:on-create has time to complete
129
- if (process.env.TEST_EXTENDED_MODE) {
130
- console.log('');
131
- process.stdout.write(chalk.gray(' Cleaning test accounts from marketing providers... '));
132
- const cleanupResult = await testAccounts.cleanupMarketingProviders(this.options.domain, {
133
- apiUrl: this.options.apiUrl,
134
- backendManagerKey: this.options.backendManagerKey,
135
- });
136
- console.log(chalk.green(`✓ (${cleanupResult.cleaned} cleaned)`));
137
- }
138
-
139
146
  // Report results
140
147
  this.reportResults();
141
148
 
@@ -147,9 +154,9 @@ class TestRunner {
147
154
  * Validate configuration
148
155
  */
149
156
  validateConfig() {
150
- if (!this.options.hostingUrl) {
151
- console.log(chalk.red(' ✗ Missing hostingUrl'));
152
- console.log(chalk.gray(' Set BEM_HOSTING_URL environment variable or pass --url flag'));
157
+ if (!this.options.apiUrl) {
158
+ console.log(chalk.red(' ✗ Missing apiUrl'));
159
+ console.log(chalk.gray(' Set BEM_API_URL environment variable or pass --url flag'));
153
160
  return false;
154
161
  }
155
162
 
@@ -186,22 +193,20 @@ class TestRunner {
186
193
  if (response.success) {
187
194
  console.log(chalk.green('✓'));
188
195
 
189
- // Warn if TEST_EXTENDED_MODE mismatch between test runner and emulator
190
- const runnerExtended = !!process.env.TEST_EXTENDED_MODE;
196
+ // Report the live mode the emulator just confirmed. The test command
197
+ // writes `.temp/test-mode.json` before invoking us; the emulator's
198
+ // file-watcher mutates its `process.env.TEST_EXTENDED_MODE` to match;
199
+ // the health endpoint re-reads the file as a freshness guard. By
200
+ // construction these are equal — no mismatch warning needed.
191
201
  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
- }
202
+ console.log(chalk.gray(` Mode: ${emulatorExtended ? 'EXTENDED (real APIs)' : 'normal (mocked)'}`));
198
203
 
199
204
  return true;
200
205
  }
201
206
 
202
207
  console.log(chalk.red('✗'));
203
208
  console.log(chalk.red(` Server not responding: ${response.error}`));
204
- console.log(chalk.gray(` Make sure your functions are deployed and running at ${this.options.hostingUrl}`));
209
+ console.log(chalk.gray(` Make sure your functions are deployed and running at ${this.options.apiUrl}`));
205
210
  return false;
206
211
  } catch (error) {
207
212
  console.log(chalk.red('✗'));
@@ -224,6 +229,18 @@ class TestRunner {
224
229
  const deleteResult = await testAccounts.deleteTestUsers(this.options.admin);
225
230
  console.log(chalk.green(`✓ (${deleteResult.deleted} deleted, ${deleteResult.skipped} skipped)`));
226
231
 
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
+
227
244
  process.stdout.write(chalk.gray(' Creating test accounts... '));
228
245
 
229
246
  // Create fresh test accounts
@@ -534,6 +551,20 @@ class TestRunner {
534
551
  duration,
535
552
  suite: suiteDescription,
536
553
  });
554
+
555
+ // For suites (sequential, state-dependent tests), a skip on any step means
556
+ // subsequent steps can't run cleanly — propagate skip to the rest of the suite.
557
+ // Groups (independent tests) continue normally.
558
+ const shouldStopOnSkip = suite.type !== 'group' && suite.stopOnFailure !== false;
559
+ if (shouldStopOnSkip) {
560
+ const remaining = tests.length - i - 1;
561
+ if (remaining > 0) {
562
+ console.log(chalk.yellow(` Skipping ${remaining} remaining test(s) in suite (suite-level skip)`));
563
+ this.results.skipped += remaining;
564
+ }
565
+ break;
566
+ }
567
+
537
568
  continue;
538
569
  }
539
570
 
@@ -657,7 +688,7 @@ class TestRunner {
657
688
  // Create HTTP client with accounts for as() method
658
689
  // Use hosting URL for all requests (rewrites to bm_api function)
659
690
  const http = new HttpClient({
660
- hostingUrl: this.options.hostingUrl,
691
+ apiUrl: this.options.apiUrl,
661
692
  timeout: this.options.timeout,
662
693
  accounts: this.accounts,
663
694
  backendManagerKey: this.options.backendManagerKey,
@@ -696,6 +727,15 @@ class TestRunner {
696
727
  throw new SkipError(reason);
697
728
  };
698
729
 
730
+ // Precomputed map of BEM product id → Stripe-compatible product ID for tests.
731
+ // Falls back to the "_test_<id>" sentinel when no real Stripe product is configured,
732
+ // letting the Stripe resolver match it back to the BEM product. SSOT for tests that
733
+ // need to construct Stripe-shaped webhook payloads (cancel, refund, plan-change, etc.).
734
+ const products = this.config.payment?.products || [];
735
+ const stripeProductIds = Object.fromEntries(
736
+ products.map((p) => [p.id, p.stripe?.productId || `_test_${p.id}`])
737
+ );
738
+
699
739
  return {
700
740
  http,
701
741
  accounts: this.accounts,
@@ -706,8 +746,14 @@ class TestRunner {
706
746
  pubsub,
707
747
  skip,
708
748
  admin: this.config.admin,
749
+ // Real BEM Manager + assistant, booted by run-tests.js with BEM_TEST_RUNNER=1.
750
+ // Tests can call Manager.AI(), Manager.Email(), Manager.User(), etc. exactly
751
+ // like production code — no stubs.
752
+ Manager: this.config.Manager,
753
+ assistant: this.config.assistant,
709
754
  rules: this.rulesContext,
710
755
  config: this.config,
756
+ payments: { stripeProductIds },
711
757
  };
712
758
  }
713
759