backend-manager 5.2.3 → 5.2.5

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 CHANGED
@@ -14,6 +14,24 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
14
14
  - `Fixed` for any bug fixes.
15
15
  - `Security` in case of vulnerabilities.
16
16
 
17
+ # [5.2.5] - 2026-05-22
18
+
19
+ ### Added
20
+
21
+ - **`utilities.trim(input)`** in `src/manager/helpers/utilities.js`. Walks objects/arrays recursively and trims whitespace on every string. Does NOT strip HTML. Middleware uses it to clean up incoming request data without mangling URLs, Markdown, or any payload with `<`/`>`/`&`.
22
+ - **`findContact(email)`** on the SendGrid + Beehiiv providers, and **`findSubscriber(email, publicationId)`** on Beehiiv. Both extracted from the existing `removeContact`/`removeSubscriber` search-by-email logic so tests (and other call sites) can verify provider state without forcing a delete.
23
+ - **`test/marketing/consent-lifecycle.js`** — 5-phase live integration test (gated on `TEST_EXTENDED_MODE=true`) that walks two long-lived sentinel accounts through pre-check → sync → declined-stays-out → unsubscribe → validation-gate, asserting actual SendGrid + Beehiiv state at every step. Uses `pollProvider()` to handle SendGrid's async background jobs (upsert/delete can take 10-60s+ to surface).
24
+
25
+ ### Changed
26
+
27
+ - **Middleware no longer strips HTML by default.** The auto-`sanitize-html` pass on incoming request data was mangling legitimate input — URL query strings (`?a=1&b=2` → `?a=1&amp;b=2`), Markdown, and any payload containing `<`/`>`/`&`. The middleware now only trims whitespace by default. HTML sanitization is opt-in per route via `Manager.Middleware(req, res).run('route', { sanitize: true })`. Sanitize at the HTML-insertion site (`utilities.sanitize()` in your template/email render) rather than at the request boundary. The existing schema-level `sanitize: false` opt-out still works when route-level sanitize is on.
28
+ - **Test sentinel accounts `consent-granted` and `consent-declined` now use the `_test.allow_*` prefix** (`_test.allow_consent-granted@...`, `_test.allow_consent-declined@...`). The `_test.*` validation block from v5.2.3 also blocks the previous `_test.consent-*` names from reaching providers — `_test.allow_*` is the carved-out exception specifically for live-provider integration tests.
29
+ - **Beehiiv API timeouts bumped to 60s** on the two remaining stragglers (`findSubscriber`, `resolveSegmentIds`) to match the SendGrid+Beehiiv 60s convention from v5.2.3.
30
+
31
+ ### Removed
32
+
33
+ - **`cleanupMarketingProviders()` function and its pre-run/post-run hooks** in `src/test/test-accounts.js` + `src/test/runner.js`. No longer needed — the validation gate added in v5.2.3 blocks `_test.*` emails from reaching SendGrid + Beehiiv upstream, so there's nothing to clean up. The new `consent-lifecycle.js` test (which intentionally round-trips real contacts via `_test.allow_*`) manages its own pre-check force-clean inline.
34
+
17
35
  # [5.2.3] - 2026-05-22
18
36
 
19
37
  ### Added
package/CLAUDE.md CHANGED
@@ -76,7 +76,7 @@ See [docs/cli-firestore-auth.md](docs/cli-firestore-auth.md) and [docs/cli-logs.
76
76
  - **Firestore shorthand**: `admin.firestore().doc('users/abc123')` (path string) rather than `.collection('users').doc('abc123')`.
77
77
  - **Template strings for requires**: `` require(`${functionsDir}/node_modules/backend-manager`) `` rather than string concat.
78
78
  - **No backwards compatibility** unless explicitly requested.
79
- - **Routes receive sanitized data by default** see [docs/sanitization.md](docs/sanitization.md) for opt-out rules.
79
+ - **Routes receive whitespace-trimmed data; HTML is preserved.** Sanitize at the HTML-insertion site via `utilities.sanitize()`. Opt into middleware-level HTML strip per-route with `{ sanitize: true }`. See [docs/sanitization.md](docs/sanitization.md).
80
80
  - **Match schema names to route names** — if route is `myEndpoint`, schema is `myEndpoint`.
81
81
  - **Always use `assistant.respond()` for responses** — do NOT use `res.send()` directly.
82
82
  - **Add Firestore composite indexes** for any compound query (`where` + `orderBy`, or multiple `where`s) to `src/cli/commands/setup-tests/helpers/required-indexes.js` (the SSOT). Without the index, queries crash with `FAILED_PRECONDITION` in production.
@@ -113,7 +113,7 @@ Deep references live in `docs/`. **Whenever you make a behavioral change, update
113
113
 
114
114
  - [docs/routes.md](docs/routes.md) — recipes for new API commands, routes, event handlers, cron jobs (with code templates)
115
115
  - [docs/schemas.md](docs/schemas.md) — schema definition format, defaults vs premium overrides
116
- - [docs/sanitization.md](docs/sanitization.md) — automatic XSS sanitization, schema opt-out (`sanitize: false`), route-level opt-out, manual `utilities.sanitize()`
116
+ - [docs/sanitization.md](docs/sanitization.md) — middleware trim-only default; opt-in HTML strip (`{ sanitize: true }`) with per-field opt-out (`sanitize: false`); manual `utilities.sanitize()` for HTML-insertion sites
117
117
  - [docs/auth-hooks.md](docs/auth-hooks.md) — consumer hooks for `before-create`/`before-signin`/`on-create`/`on-delete` (blocking + non-blocking examples)
118
118
  - [docs/common-operations.md](docs/common-operations.md) — inside-the-handler patterns: authenticate, read/write Firestore, error handling, send response, `bm_api` hook
119
119
 
@@ -134,6 +134,6 @@ Deep references live in `docs/`. **Whenever you make a behavioral change, update
134
134
 
135
135
  ### Testing & CLI
136
136
 
137
- - [docs/testing.md](docs/testing.md) — running, filtering, log files, test types (standalone/suite/group), context object, assertions, auth levels. **For Firestore/Auth/local state, cleanup runs at the START of every run, never at the end** if you add a test that writes Firestore data, register the collection/namespace in the runner's pre-test wipe list, don't add a trailing cleanup step. **Exception:** third-party providers we can't wipe at start (e.g. SendGrid/Beehiiv contact lists) get a symmetric pre + post cleanup hook in the runner see `cleanupMarketingProviders` and [docs/consent.md](docs/consent.md). Don't pattern-match this exception for new Firestore code.
137
+ - [docs/testing.md](docs/testing.md) — running, filtering, log files, test types (standalone/suite/group), context object, assertions, auth levels. **All cleanup runs at the START of every run, never at the end.** If you add a test that writes Firestore data, register the collection/namespace in the runner's pre-test wipe list, don't add a trailing cleanup step. Marketing providers (SendGrid/Beehiiv) don't need a special exception `_test.*` emails are blocked at the validation layer so test signups never reach providers. The `_test.allow_*` carve-out exists only for the live-provider lifecycle test (`test/marketing/consent-lifecycle.js`), which manages its own teardown.
138
138
  - [docs/cli-firestore-auth.md](docs/cli-firestore-auth.md) — `npx mgr firestore:*` and `auth:*` commands, shared flags, examples
139
139
  - [docs/cli-logs.md](docs/cli-logs.md) — `npx mgr logs:read` / `logs:tail` with full flag reference and built-in Cloud Function names
package/docs/consent.md CHANGED
@@ -138,7 +138,7 @@ Each processor's `handleEvent` does the same shape of work:
138
138
  | SendGrid | `unsubscribe`, `group_unsubscribe`, `spamreport`, `bounce`, `dropped` |
139
139
  | Beehiiv | `subscription.unsubscribed`, `subscription.deleted`, `subscription.paused` |
140
140
 
141
- **Beehiiv publication filter.** Each Beehiiv event includes a `publication_id`. The processor compares this against the result of `beehiivProvider.getPublicationId()` (which reads `Manager.config.marketing.beehiiv.publicationId` or fuzzy-matches against the Beehiiv API by brand name). Mismatch → silent skip. This is how shared-publication events (e.g. devbeans shared by 6 brands) get routed correctly — each brand processes only events matching its own publication.
141
+ **Beehiiv publication filter.** Each Beehiiv event includes a `publication_id`. The processor compares this against `beehiivProvider.getPublicationId()`, which reads `Manager.config.marketing.beehiiv.publicationId` (populated at brand-onboarding time by OMEGA's `beehiiv/ensure/publication.js`). Mismatch → silent skip. This is how shared-publication events (e.g. devbeans shared by 6 brands) get routed correctly — each brand processes only events matching its own publication. Brands without `publicationId` in config silently skip all Beehiiv webhook events. The same convention applies to SendGrid: `marketing.sendgrid.listId` is populated by OMEGA's `sendgrid/ensure/list.js`.
142
142
 
143
143
  ## Parent forwarder (Phase E)
144
144
 
@@ -304,18 +304,13 @@ After the migration: optionally run a re-opt-in drip campaign to legally recover
304
304
 
305
305
  Run with `npx mgr test` (full suite) or `npx mgr test routes/marketing/webhook` (just the webhook tests).
306
306
 
307
- ### Provider-side cleanup (extended mode only)
307
+ ### Live-provider tests (extended mode only)
308
308
 
309
- Normally BEM test runs are self-contained because all data lives in the local emulator. Marketing tests are the exception when `TEST_EXTENDED_MODE=true` is set, the routes make real API calls to SendGrid + Beehiiv and leave real contacts behind.
309
+ Most BEM tests are self-contained against the local emulator. The marketing-consent system has one test that's an exception [test/marketing/consent-lifecycle.js](../test/marketing/consent-lifecycle.js) which makes real API calls to SendGrid + Beehiiv to verify the full round-trip works end-to-end.
310
310
 
311
- To keep this self-contained, the runner calls [`cleanupMarketingProviders`](../src/test/test-accounts.js) **twice** per run:
311
+ The validation pipeline (`src/manager/libraries/email/validation.js`) blocks all `_test.*` emails from reaching providers via the `/^_test\.(?!allow_)/` pattern in `blocked-local-patterns.js`. The two `_test.allow_*` sentinels (`_test.allow_consent-granted` and `_test.allow_consent-declined`) used by the lifecycle test bypass that gate intentionally, and the test cleans up after itself (phase-3 removes the granted contact via `Manager.Email().remove()`).
312
312
 
313
- 1. **Pre-run** scrubs leftovers from any previous run that crashed mid-suite.
314
- 2. **Post-run** — scrubs everything this run created. Best-effort (wrapped in try/catch — cleanup failures don't change the test result).
315
-
316
- Both fire ONLY when `TEST_EXTENDED_MODE=true`. In normal mode (the default), no provider calls happen and no cleanup is needed.
317
-
318
- This is the one exception to the "Firestore cleanup runs at start only" rule documented in [docs/testing.md](testing.md) — we can't wipe SendGrid/Beehiiv state in our own Firestore wipe, so we have to ask the providers to do it for us, twice for defense in depth.
313
+ The "all cleanup runs at start, never at the end" rule documented in [docs/testing.md](testing.md) applies to all test data, including third-party providers.
319
314
 
320
315
  ## Frontend pieces (cross-references)
321
316
 
@@ -1,16 +1,25 @@
1
1
  # Sanitization (XSS Prevention)
2
2
 
3
- BEM automatically sanitizes all incoming request data stripping HTML tags and trimming whitespace from every string field. This happens in the middleware pipeline before route handlers execute, so **routes receive clean data by default**.
3
+ BEM middleware always **trims** whitespace on incoming string fields (via `utilities.trim()`). HTML sanitization is **opt-in** it's not run by default because it mangles legitimate input like URL query strings (`&` `&amp;`) and Markdown.
4
+
5
+ The expectation is that you sanitize at the **HTML-insertion site** (in the template, in the email body, etc.) — not at the request boundary.
4
6
 
5
7
  ## How It Works
6
8
 
7
- 1. **Schema fields**: Sanitized per-field during the middleware pipeline. Fields can opt out with `sanitize: false` in the schema.
8
- 2. **Non-schema fields** (when `setupSettings: false` or `includeNonSchemaSettings: true`): All strings are sanitized with no opt-out.
9
- 3. The middleware uses `Manager.Utilities().sanitize()` under the hood.
9
+ 1. **Trimming**: Every string in `settings` is whitespace-trimmed by the middleware (objects + arrays walked recursively). Always on.
10
+ 2. **HTML sanitization**: Off by default. Opt in per-route with `{ sanitize: true }` on `Manager.Middleware(...).run(...)`.
11
+ 3. **Schemas** can mark individual fields with `sanitize: false` to skip the HTML strip for that field when route-level sanitize is enabled (used for fields that legitimately need raw HTML — rich-text editors, email templates).
12
+
13
+ ## Route-Level Opt-In
14
+
15
+ ```javascript
16
+ // In functions/index.js — enable HTML strip for a specific route
17
+ Manager.Middleware(req, res).run('my-route', { sanitize: true });
18
+ ```
10
19
 
11
- ## Schema Opt-Out
20
+ When enabled, every string in `settings` is run through `sanitize-html` (strip all tags) unless the schema marks the field with `sanitize: false`.
12
21
 
13
- For fields that legitimately need HTML (rich text, email templates, etc.), set `sanitize: false` in the schema:
22
+ ## Schema Field Opt-Out (when route-level sanitize is on)
14
23
 
15
24
  ```javascript
16
25
  // This field will NOT be sanitized — raw HTML is preserved
@@ -19,43 +28,42 @@ htmlContent: {
19
28
  default: '',
20
29
  sanitize: false,
21
30
  },
22
- // This field IS sanitized (default behavior, no flag needed)
31
+ // This field IS sanitized when route-level sanitize is enabled
23
32
  name: {
24
33
  types: ['string'],
25
34
  default: '',
26
35
  },
27
36
  ```
28
37
 
29
- ## Route-Level Opt-Out
38
+ ## Manual Sanitization (Recommended)
30
39
 
31
- Disable sanitization entirely for a route (rare only for routes that handle raw HTML everywhere):
32
-
33
- ```javascript
34
- // In functions/index.js
35
- Manager.Middleware(req, res).run('my-route', { sanitize: false });
36
- ```
37
-
38
- ## Manual Sanitization (Outside Middleware)
39
-
40
- For cron jobs, event handlers, or anywhere outside the request pipeline, use `utilities.sanitize()` directly:
40
+ For most use cases particularly anywhere you're inserting user-supplied content into HTML call `utilities.sanitize()` directly at the insertion site:
41
41
 
42
42
  ```javascript
43
43
  // Available in route context
44
- const clean = utilities.sanitize(untrustedData);
44
+ const safeHtml = utilities.sanitize(untrustedData);
45
45
 
46
46
  // Or via Manager
47
- const clean = Manager.Utilities().sanitize(untrustedData);
47
+ const safeHtml = Manager.Utilities().sanitize(untrustedData);
48
48
  ```
49
49
 
50
50
  Accepts any data type — strings, objects, arrays, primitives. Walks objects/arrays recursively, strips HTML from strings, passes everything else through unchanged.
51
51
 
52
- ## Route Handler Context
52
+ ## Why HTML Sanitization Is Not the Middleware Default
53
+
54
+ Stripping HTML from every incoming string at the request boundary is too aggressive — it corrupts legitimate input:
53
55
 
54
- The middleware injects these into every route handler:
56
+ - URL query strings: `https://example.com/?a=1&b=2` becomes `https://example.com/?a=1&amp;b=2`
57
+ - Markdown / code snippets with `<`, `>`, `&` characters get mangled
58
+ - API payloads round-tripped through the system get silently rewritten
59
+
60
+ Stored XSS comes from rendering, not from receiving. Sanitize at the render site (where you know the output context — HTML body, attribute, URL, JSON) instead of at the front door.
61
+
62
+ ## Route Handler Context
55
63
 
56
64
  ```javascript
57
65
  module.exports = async ({ Manager, assistant, analytics, usage, user, settings, libraries, utilities }) => {
58
- // settings — already sanitized by middleware
59
- // utilities — Manager.Utilities() instance for manual sanitization
66
+ // settings — whitespace-trimmed by middleware; HTML preserved unless route opts in via { sanitize: true }
67
+ // utilities — Manager.Utilities() instance for manual sanitize()/trim()
60
68
  };
61
69
  ```
package/docs/schemas.md CHANGED
@@ -36,4 +36,4 @@ module.exports = function (assistant, settings, options) {
36
36
 
37
37
  ## Field Sanitization
38
38
 
39
- By default, every string field in a schema is sanitized (HTML tags stripped, whitespace trimmed) during the middleware pipeline. To preserve raw HTML (rich text, email templates), set `sanitize: false` on the field. See [docs/sanitization.md](sanitization.md).
39
+ Middleware always trims whitespace on string fields. HTML sanitization is **opt-in per route** — `Manager.Middleware(req, res).run('my-route', { sanitize: true })`. When opted in, fields can individually opt back out with `sanitize: false`. See [docs/sanitization.md](sanitization.md).
package/docs/testing.md CHANGED
@@ -19,18 +19,19 @@ What the runner wipes pre-test (in [src/test/test-accounts.js](../src/test/test-
19
19
 
20
20
  1. **`meta/stats`** doc ensured (required for on-create batch writes).
21
21
  2. **`users/_test-*`** Firebase Auth users + Firestore docs (delete).
22
- 3. **Marketing providers** (SendGrid + Beehiiv) leftover test contacts removed. Runs only under `TEST_EXTENDED_MODE`. Runs **before** test users are recreated so a killed run can't leave a contact pinned to an about-to-be-recreated uid.
23
- 4. **Mixed Firestore collections** — `payments-orders`, `payments-webhooks`, `payments-intents`, `payments-disputes`, `marketing-webhooks`. Two-pass cleanup per collection:
22
+ 3. **Mixed Firestore collections** — `payments-orders`, `payments-webhooks`, `payments-intents`, `payments-disputes`, `marketing-webhooks`. Two-pass cleanup per collection:
24
23
  - Pass 1 — owner-keyed: `where('owner', 'in', [...testUids])` (batched at 30 uids per `in` query).
25
24
  - Pass 2 — id-keyed: any doc whose ID starts with `_test-` (catches ownerless test docs like dispute alerts and raw test webhooks).
26
- 5. **Test-only Firestore collections** — `_test`, `_test_query` — wiped in full.
27
- 6. **Realtime Database** — the `_test` namespace removed in full (`admin.database().ref('_test').remove()`).
25
+ 4. **Test-only Firestore collections** — `_test`, `_test_query` — wiped in full.
26
+ 5. **Realtime Database** — the `_test` namespace removed in full (`admin.database().ref('_test').remove()`).
28
27
 
29
- ### The one exception: third-party provider cleanup runs at start AND end
28
+ ### Marketing-provider cleanup
30
29
 
31
- The `cleanupMarketingProviders` call also fires **after** the suite completes (extended mode only). Reason: pre-run cleanup catches what a crashed previous run left behind, but during a normal-completion run we'd still leak real SendGrid/Beehiiv contacts until the NEXT run cleaned them up. By running cleanup post-suite too, each extended-mode run leaves the provider lists in the same state it found them.
30
+ Test signups never reach SendGrid + Beehiiv. The validation pipeline (`src/manager/libraries/email/validation.js`) blocks all `_test.*` emails at the marketing-library layer via the `/^_test\.(?!allow_)/` pattern in `blocked-local-patterns.js`.
32
31
 
33
- This is **specifically** for third-party state that lives outside the local emulator. **Do not use trailing cleanup for Firestore or Auth data** those follow the start-only rule because we control the local state and pre-run cleanup is enough.
32
+ The single exception is the `_test.allow_*` prefix. Two long-lived test accounts (`_test.allow_consent-granted@...` and `_test.allow_consent-declined@...`) intentionally round-trip through SendGrid + Beehiiv as the live-provider integration sentinels. They are exercised by `test/marketing/consent-lifecycle.js`, which manages its own setup, assertions, and teardown.
33
+
34
+ All cleanup follows the start-only rule. No trailing-cleanup exception.
34
35
 
35
36
  ### When adding a new test that writes data
36
37
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backend-manager",
3
- "version": "5.2.3",
3
+ "version": "5.2.5",
4
4
  "description": "Quick tools for developing Firebase functions",
5
5
  "main": "src/manager/index.js",
6
6
  "bin": {
@@ -35,7 +35,7 @@ Middleware.prototype.run = function (libPath, options) {
35
35
  options.setupAnalytics = typeof options.setupAnalytics === 'boolean' ? options.setupAnalytics : true;
36
36
  options.setupUsage = typeof options.setupUsage === 'boolean' ? options.setupUsage : true;
37
37
  options.setupSettings = typeof options.setupSettings === 'undefined' ? true : options.setupSettings;
38
- options.sanitize = typeof options.sanitize === 'undefined' ? true : options.sanitize;
38
+ options.sanitize = typeof options.sanitize === 'undefined' ? false : options.sanitize;
39
39
  options.includeNonSchemaSettings = typeof options.includeNonSchemaSettings === 'undefined' ? false : options.includeNonSchemaSettings;
40
40
  options.schema = typeof options.schema === 'undefined' ? libPath : options.schema;
41
41
  options.parseMultipartFormData = typeof options.parseMultipartFormData === 'undefined' ? true : options.parseMultipartFormData;
@@ -177,13 +177,16 @@ Middleware.prototype.run = function (libPath, options) {
177
177
  assistant.settings = data;
178
178
  }
179
179
 
180
- // Sanitize settings trim whitespace and strip HTML from all strings
181
- // Respects sanitize: false on individual schema fields
180
+ // Trim whitespace on all string settings (always on harmless and useful).
181
+ assistant.settings = Manager.Utilities().trim(assistant.settings);
182
+
183
+ // Optional HTML strip (off by default — opt in with `{ sanitize: true }`).
184
+ // Sanitize at the HTML-insertion site instead unless you need a belt-and-suspenders pass here.
185
+ // Respects sanitize: false on individual schema fields.
182
186
  if (options.sanitize) {
183
187
  const schema = options.setupSettings ? Manager.Settings().schema : null;
184
188
  const utilities = Manager.Utilities();
185
189
 
186
- // Walk settings, skipping fields the schema marks as sanitize: false
187
190
  assistant.settings = sanitizeWithSchema(utilities, assistant.settings, schema);
188
191
  }
189
192
 
@@ -528,4 +528,35 @@ Utilities.prototype.sanitize = function (input) {
528
528
  return input;
529
529
  };
530
530
 
531
+ /**
532
+ * Trim whitespace from all strings in input. Walks objects/arrays recursively.
533
+ * Does NOT strip HTML — use sanitize() for that.
534
+ *
535
+ * @param {*} input - The data to trim (string, object, array, or primitive)
536
+ * @returns {*} Trimmed copy (objects/arrays) or trimmed value (strings)
537
+ */
538
+ Utilities.prototype.trim = function (input) {
539
+ if (input == null) {
540
+ return input;
541
+ }
542
+
543
+ if (typeof input === 'string') {
544
+ return input.trim();
545
+ }
546
+
547
+ if (Array.isArray(input)) {
548
+ return input.map(item => this.trim(item));
549
+ }
550
+
551
+ if (typeof input === 'object') {
552
+ const result = {};
553
+ for (const [key, value] of Object.entries(input)) {
554
+ result[key] = this.trim(value);
555
+ }
556
+ return result;
557
+ }
558
+
559
+ return input;
560
+ };
561
+
531
562
  module.exports = Utilities;
@@ -79,39 +79,55 @@ async function addSubscriber({ email, firstName, lastName, source, publicationId
79
79
  }
80
80
 
81
81
  /**
82
- * Remove a subscriber from a Beehiiv publication by email.
82
+ * Look up a Beehiiv subscriber by email. Returns the subscription object
83
+ * (id, email, status, custom_fields, ...) or null if not found.
84
+ *
85
+ * Useful for tests that need to verify whether a subscriber landed in the
86
+ * publication after a marketing sync.
83
87
  *
84
88
  * @param {string} email
85
89
  * @param {string} publicationId
86
- * @returns {{ success: boolean, deleted?: boolean, skipped?: boolean, error?: string }}
90
+ * @returns {Promise<object|null>}
87
91
  */
88
- async function removeSubscriber(email, publicationId) {
92
+ async function findSubscriber(email, publicationId) {
89
93
  try {
90
94
  const encodedEmail = encodeURIComponent(email);
91
-
92
- // Step 1: Get subscription by email
93
- let searchData;
94
- try {
95
- searchData = await fetch(
96
- `${BASE_URL}/publications/${publicationId}/subscriptions/by_email/${encodedEmail}`,
97
- {
98
- response: 'json',
99
- headers: headers(),
100
- timeout: 10000,
101
- }
102
- );
103
- } catch (e) {
104
- if (e.status === 404) {
105
- return { success: true, skipped: true, reason: 'Subscriber not found' };
95
+ const searchData = await fetch(
96
+ `${BASE_URL}/publications/${publicationId}/subscriptions/by_email/${encodedEmail}`,
97
+ {
98
+ response: 'json',
99
+ headers: headers(),
100
+ timeout: 60000,
106
101
  }
107
- throw e;
102
+ );
103
+
104
+ return searchData.data || null;
105
+ } catch (e) {
106
+ if (e.status === 404) {
107
+ return null;
108
108
  }
109
+ console.error('Beehiiv findSubscriber error:', e);
110
+ return null;
111
+ }
112
+ }
109
113
 
110
- if (!searchData.data?.id) {
111
- return { success: true, skipped: true, reason: 'Subscription not found' };
114
+ /**
115
+ * Remove a subscriber from a Beehiiv publication by email.
116
+ *
117
+ * @param {string} email
118
+ * @param {string} publicationId
119
+ * @returns {{ success: boolean, deleted?: boolean, skipped?: boolean, error?: string }}
120
+ */
121
+ async function removeSubscriber(email, publicationId) {
122
+ try {
123
+ // Step 1: Look up the subscription
124
+ const subscription = await findSubscriber(email, publicationId);
125
+
126
+ if (!subscription?.id) {
127
+ return { success: true, skipped: true, reason: 'Subscriber not found' };
112
128
  }
113
129
 
114
- const subscriptionId = searchData.data.id;
130
+ const subscriptionId = subscription.id;
115
131
 
116
132
  // Step 2: Permanently delete the subscription
117
133
  await fetch(
@@ -119,7 +135,7 @@ async function removeSubscriber(email, publicationId) {
119
135
  {
120
136
  method: 'delete',
121
137
  headers: headers(),
122
- timeout: 10000,
138
+ timeout: 60000,
123
139
  }
124
140
  );
125
141
 
@@ -186,7 +202,7 @@ function getPublicationId() {
186
202
  // const data = await fetch(`${BASE_URL}/publications?limit=${limit}&page=${page}`, {
187
203
  // response: 'json',
188
204
  // headers: headers(),
189
- // timeout: 10000,
205
+ // timeout: 60000,
190
206
  // });
191
207
  //
192
208
  // if (!data.data || data.data.length === 0) {
@@ -270,6 +286,25 @@ async function removeContact(email) {
270
286
  return removeSubscriber(email, publicationId);
271
287
  }
272
288
 
289
+ /**
290
+ * Look up a contact in this brand's Beehiiv publication. Resolves the
291
+ * publicationId from config and calls findSubscriber. Mirrors SendGrid's
292
+ * findContact() surface so tests can use the same pattern across both
293
+ * providers.
294
+ *
295
+ * @param {string} email
296
+ * @returns {Promise<object|null>}
297
+ */
298
+ async function findContact(email) {
299
+ const publicationId = getPublicationId();
300
+
301
+ if (!publicationId) {
302
+ return null;
303
+ }
304
+
305
+ return findSubscriber(email, publicationId);
306
+ }
307
+
273
308
  /**
274
309
  * Build Beehiiv custom_fields array from a user doc.
275
310
  * Resolves all field values, then maps to display names for Beehiiv.
@@ -316,7 +351,7 @@ async function resolveSegmentIds() {
316
351
  const data = await fetch(`${BASE_URL}/publications/${publicationId}/segments?limit=100`, {
317
352
  response: 'json',
318
353
  headers: headers(),
319
- timeout: 10000,
354
+ timeout: 60000,
320
355
  });
321
356
 
322
357
  _segmentIdCache = {};
@@ -435,6 +470,8 @@ module.exports = {
435
470
 
436
471
  // Contacts
437
472
  addContact,
473
+ findContact,
474
+ findSubscriber,
438
475
  removeContact,
439
476
  buildFields,
440
477
 
@@ -131,14 +131,17 @@ async function upsertContacts({ contacts, listIds }) {
131
131
  }
132
132
 
133
133
  /**
134
- * Remove a contact from SendGrid by email address.
134
+ * Look up a SendGrid contact by email. Returns the contact object (id, email,
135
+ * list_ids, custom_fields, ...) or null if not found.
136
+ *
137
+ * Useful for tests that need to verify whether a contact landed in the list
138
+ * after a marketing sync.
135
139
  *
136
140
  * @param {string} email
137
- * @returns {{ success: boolean, jobId?: string, skipped?: boolean, error?: string }}
141
+ * @returns {Promise<object|null>}
138
142
  */
139
- async function removeContact(email) {
143
+ async function findContact(email) {
140
144
  try {
141
- // Step 1: Get contact ID by email
142
145
  const searchData = await fetch(`${BASE_URL}/marketing/contacts/search/emails`, {
143
146
  method: 'post',
144
147
  response: 'json',
@@ -147,11 +150,33 @@ async function removeContact(email) {
147
150
  body: { emails: [email] },
148
151
  });
149
152
 
150
- if (!searchData.result?.[email]?.contact?.id) {
153
+ return searchData.result?.[email]?.contact || null;
154
+ } catch (e) {
155
+ // 404 is the normal "not in contacts" response — return null silently.
156
+ if (e.status === 404) {
157
+ return null;
158
+ }
159
+ console.error('SendGrid findContact error:', e);
160
+ return null;
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Remove a contact from SendGrid by email address.
166
+ *
167
+ * @param {string} email
168
+ * @returns {{ success: boolean, jobId?: string, skipped?: boolean, error?: string }}
169
+ */
170
+ async function removeContact(email) {
171
+ try {
172
+ // Step 1: Get contact ID by email
173
+ const contact = await findContact(email);
174
+
175
+ if (!contact?.id) {
151
176
  return { success: true, skipped: true, reason: 'Contact not found' };
152
177
  }
153
178
 
154
- const contactId = searchData.result[email].contact.id;
179
+ const contactId = contact.id;
155
180
 
156
181
  // Step 2: Delete contact by ID
157
182
  const deleteData = await fetch(`${BASE_URL}/marketing/contacts?ids=${contactId}`, {
@@ -635,6 +660,7 @@ module.exports = {
635
660
 
636
661
  // Contacts
637
662
  addContact,
663
+ findContact,
638
664
  removeContact,
639
665
  getSegmentContacts,
640
666
  bulkDeleteContacts,
@@ -119,25 +119,6 @@ class TestRunner {
119
119
  await this.runTestsInDir(projectTestsDir, 'project');
120
120
  }
121
121
 
122
- // Post-run cleanup: scrub test accounts from third-party marketing providers
123
- // (SendGrid/Beehiiv) so each test run leaves the contact list in the same
124
- // state it found it. Pairs with the pre-run cleanup as defense in depth —
125
- // pre-run handles crashed previous runs, post-run handles the current run.
126
- // Only fires in extended mode (normal mode never touches real providers).
127
- if (process.env.TEST_EXTENDED_MODE) {
128
- process.stdout.write(chalk.gray('\n Cleaning up test accounts from marketing providers... '));
129
- try {
130
- const cleanupResult = await testAccounts.cleanupMarketingProviders(this.options.domain, {
131
- apiUrl: this.options.apiUrl,
132
- backendManagerKey: this.options.backendManagerKey,
133
- });
134
- console.log(chalk.green(`✓ (${cleanupResult.cleaned} cleaned)`));
135
- } catch (e) {
136
- // Post-run cleanup is best-effort — failures shouldn't change the test result
137
- console.log(chalk.yellow(`⚠ cleanup error: ${e.message}`));
138
- }
139
- }
140
-
141
122
  // Cleanup rules context
142
123
  if (this.rulesContext) {
143
124
  await this.rulesContext.cleanup();
@@ -229,18 +210,6 @@ class TestRunner {
229
210
  const deleteResult = await testAccounts.deleteTestUsers(this.options.admin);
230
211
  console.log(chalk.green(`✓ (${deleteResult.deleted} deleted, ${deleteResult.skipped} skipped)`));
231
212
 
232
- // Clean any leftover test accounts from third-party marketing providers
233
- // (SendGrid/Beehiiv). Runs BEFORE we create fresh users so a previously
234
- // killed run doesn't leave the contact list polluted.
235
- if (process.env.TEST_EXTENDED_MODE) {
236
- process.stdout.write(chalk.gray(' Cleaning test accounts from marketing providers... '));
237
- const cleanupResult = await testAccounts.cleanupMarketingProviders(this.options.domain, {
238
- apiUrl: this.options.apiUrl,
239
- backendManagerKey: this.options.backendManagerKey,
240
- });
241
- console.log(chalk.green(`✓ (${cleanupResult.cleaned} cleaned)`));
242
- }
243
-
244
213
  process.stdout.write(chalk.gray(' Creating test accounts... '));
245
214
 
246
215
  // Create fresh test accounts
@@ -162,10 +162,14 @@ const STATIC_ACCOUNTS = {
162
162
  subscription: { product: { id: 'basic' }, status: 'active' },
163
163
  },
164
164
  },
165
+ // The two `consent-*` accounts use the `_test.allow_*` prefix so they bypass
166
+ // the `_test.*` marketing-block in blocked-local-patterns.js. They're the
167
+ // live-provider integration sentinels — they intentionally round-trip through
168
+ // SendGrid + Beehiiv to verify the consent gate works end-to-end.
165
169
  'consent-granted': {
166
170
  id: 'consent-granted',
167
- uid: '_test-consent-granted',
168
- email: '_test.consent-granted@{domain}',
171
+ uid: '_test-allow-consent-granted',
172
+ email: '_test.allow_consent-granted@{domain}',
169
173
  properties: {
170
174
  roles: {},
171
175
  subscription: { product: { id: 'basic' }, status: 'active' },
@@ -173,8 +177,8 @@ const STATIC_ACCOUNTS = {
173
177
  },
174
178
  'consent-declined': {
175
179
  id: 'consent-declined',
176
- uid: '_test-consent-declined',
177
- email: '_test.consent-declined@{domain}',
180
+ uid: '_test-allow-consent-declined',
181
+ email: '_test.allow_consent-declined@{domain}',
178
182
  properties: {
179
183
  roles: {},
180
184
  subscription: { product: { id: 'basic' }, status: 'active' },
@@ -805,64 +809,6 @@ const TEST_DATA = {
805
809
  defaultProjectId: 'demo-test',
806
810
  };
807
811
 
808
- /**
809
- * Clean up test accounts from marketing providers (SendGrid + Beehiiv)
810
- * Called after account setup when TEST_EXTENDED_MODE is set to remove
811
- * contacts added by auth:on-create
812
- * @param {string} domain - Domain for email addresses
813
- * @param {object} options - Options with apiUrl and backendManagerKey
814
- * @returns {Promise<object>} Result with cleaned count
815
- */
816
- async function cleanupMarketingProviders(domain, options = {}) {
817
- const fetch = require('wonderful-fetch');
818
- const results = { cleaned: 0, errors: [] };
819
-
820
- const { apiUrl, backendManagerKey } = options;
821
- if (!apiUrl || !backendManagerKey) {
822
- console.error('cleanupMarketingProviders: Missing apiUrl or backendManagerKey');
823
- return results;
824
- }
825
-
826
- // Get all test account emails (test contacts like rachel.greene+bem cleaned up by their own tests)
827
- const definitions = getAccountDefinitions(domain);
828
- const emails = Object.values(definitions).map(acc => acc.email);
829
-
830
- // Clean up each email via the API endpoint (uses hosting port 5002)
831
- await Promise.all(
832
- emails.map(async (email) => {
833
- try {
834
- const response = await fetch(`${apiUrl}/backend-manager/marketing/contact`, {
835
- method: 'DELETE',
836
- response: 'json',
837
- timeout: 30000,
838
- body: {
839
- backendManagerKey,
840
- email,
841
- },
842
- });
843
-
844
- // Log the result for debugging
845
- if (response.providers?.beehiiv?.deleted) {
846
- results.cleaned++;
847
- } else if (response.providers?.beehiiv?.skipped) {
848
- // Skipped means not found - that's fine
849
- results.cleaned++;
850
- } else if (response.providers?.beehiiv?.error) {
851
- console.error(`Failed to delete ${email} from Beehiiv:`, response.providers.beehiiv.error);
852
- results.errors.push({ email, error: response.providers.beehiiv.error });
853
- } else {
854
- results.cleaned++;
855
- }
856
- } catch (error) {
857
- console.error(`Failed to cleanup ${email}:`, error.message);
858
- results.errors.push({ email, error: error.message });
859
- }
860
- })
861
- );
862
-
863
- return results;
864
- }
865
-
866
812
  module.exports = {
867
813
  STATIC_ACCOUNTS,
868
814
  JOURNEY_ACCOUNTS,
@@ -873,5 +819,4 @@ module.exports = {
873
819
  fetchPrivateKeys,
874
820
  deleteTestUsers,
875
821
  createTestAccounts,
876
- cleanupMarketingProviders,
877
822
  };
@@ -0,0 +1,255 @@
1
+ /**
2
+ * Test: Marketing Provider Lifecycle (end-to-end against live SendGrid + Beehiiv)
3
+ *
4
+ * Walks two long-lived test accounts through their full lifecycle and verifies
5
+ * provider state at every transition:
6
+ *
7
+ * 1. Pre-check — both accounts should be absent from SendGrid + Beehiiv
8
+ * 2. Sync — flip consent.marketing.status and call Marketing.sync()
9
+ * 3. Verify — granted account present in both, declined account absent from both
10
+ * 4. Unsubscribe — hit the email-preferences endpoint for the granted account
11
+ * 5. Verify — granted account now absent from both
12
+ *
13
+ * The two accounts use the `_test.allow_*` prefix so they bypass the
14
+ * blocked-local-patterns gate (which blocks plain `_test.*` from reaching
15
+ * providers). They are the only accounts intentionally allowed to round-trip
16
+ * through SendGrid + Beehiiv.
17
+ *
18
+ * Run with TEST_EXTENDED_MODE=true (no-op otherwise). Requires SENDGRID_API_KEY
19
+ * and BEEHIIV_API_KEY in env. Total runtime is ~60-90s — most of it spent waiting
20
+ * for SendGrid's async upsert/delete background jobs to surface.
21
+ */
22
+ const sendgridProvider = require('../../src/manager/libraries/email/providers/sendgrid.js');
23
+ const beehiivProvider = require('../../src/manager/libraries/email/providers/beehiiv.js');
24
+
25
+ const SETTLE_MS = 5000; // Beehiiv settles in 1-2s; SendGrid's background-job upsert can take 10-20s+
26
+ const POLL_INTERVAL_MS = 2000;
27
+ const POLL_MAX_MS = 90000; // SendGrid's delete-contact job can take 30-60s+ to surface
28
+
29
+ function sleep(ms) {
30
+ return new Promise((resolve) => setTimeout(resolve, ms));
31
+ }
32
+
33
+ /**
34
+ * Poll a provider lookup until it matches the expected state (present/absent).
35
+ * Returns the resolved value (contact object or null) once condition is met, or
36
+ * the last value seen after timing out.
37
+ *
38
+ * @param {Function} fetchFn - async function that returns contact or null
39
+ * @param {boolean} expectPresent - true = wait until present, false = wait until absent
40
+ */
41
+ async function pollProvider(fetchFn, expectPresent) {
42
+ const start = Date.now();
43
+ let lastValue = null;
44
+
45
+ while (Date.now() - start < POLL_MAX_MS) {
46
+ lastValue = await fetchFn();
47
+ const present = !!lastValue;
48
+ if (present === expectPresent) {
49
+ return lastValue;
50
+ }
51
+ await sleep(POLL_INTERVAL_MS);
52
+ }
53
+
54
+ return lastValue;
55
+ }
56
+
57
+ module.exports = {
58
+ description: 'Marketing provider lifecycle (live SendGrid + Beehiiv round-trip)',
59
+ type: 'group',
60
+ skip: !process.env.TEST_EXTENDED_MODE
61
+ ? 'TEST_EXTENDED_MODE not set (this test hits live SendGrid + Beehiiv APIs)'
62
+ : false,
63
+ tests: [
64
+ // ─────────────────────────────────────────────────────────────────────
65
+ // Phase 1 — Pre-check: both accounts should be absent from providers
66
+ // ─────────────────────────────────────────────────────────────────────
67
+ {
68
+ name: 'phase-1-pre-check-both-accounts-absent',
69
+ auth: 'admin',
70
+ timeout: 180000,
71
+
72
+ async run({ accounts, assert }) {
73
+ const granted = accounts['consent-granted'];
74
+ const declined = accounts['consent-declined'];
75
+
76
+ // Force-clean any leftovers from a prior run (e.g. the previous phase-2a
77
+ // left a contact in SendGrid that THIS run's phase-1 needs to start
78
+ // without). SendGrid's delete is an async job, so wait for absence.
79
+ await sendgridProvider.removeContact(granted.email);
80
+ await sendgridProvider.removeContact(declined.email);
81
+ await beehiivProvider.removeContact(granted.email);
82
+ await beehiivProvider.removeContact(declined.email);
83
+
84
+ const grantedSg = await pollProvider(() => sendgridProvider.findContact(granted.email), false);
85
+ const declinedSg = await pollProvider(() => sendgridProvider.findContact(declined.email), false);
86
+ const grantedBh = await pollProvider(() => beehiivProvider.findContact(granted.email), false);
87
+ const declinedBh = await pollProvider(() => beehiivProvider.findContact(declined.email), false);
88
+
89
+ assert.equal(grantedSg, null, 'granted account should be absent from SendGrid');
90
+ assert.equal(declinedSg, null, 'declined account should be absent from SendGrid');
91
+ assert.equal(grantedBh, null, 'granted account should be absent from Beehiiv');
92
+ assert.equal(declinedBh, null, 'declined account should be absent from Beehiiv');
93
+ },
94
+ },
95
+
96
+ // ─────────────────────────────────────────────────────────────────────
97
+ // Phase 2 — Sync granted account → both providers
98
+ // ─────────────────────────────────────────────────────────────────────
99
+ {
100
+ name: 'phase-2a-granted-account-syncs-to-both-providers',
101
+ auth: 'admin',
102
+ timeout: 180000,
103
+
104
+ async run({ accounts, assert, Manager, assistant }) {
105
+ const granted = accounts['consent-granted'];
106
+ const admin = Manager.libraries.admin;
107
+
108
+ // Set consent.marketing.status = 'granted' on the user doc
109
+ await admin.firestore().doc(`users/${granted.uid}`).set({
110
+ consent: {
111
+ marketing: {
112
+ status: 'granted',
113
+ grantedAt: {
114
+ timestamp: new Date().toISOString(),
115
+ timestampUNIX: Math.floor(Date.now() / 1000),
116
+ source: 'test',
117
+ ip: null,
118
+ text: 'consent-lifecycle test',
119
+ },
120
+ },
121
+ legal: {
122
+ status: 'granted',
123
+ grantedAt: {
124
+ timestamp: new Date().toISOString(),
125
+ timestampUNIX: Math.floor(Date.now() / 1000),
126
+ source: 'test',
127
+ ip: null,
128
+ text: 'consent-lifecycle test',
129
+ },
130
+ },
131
+ },
132
+ }, { merge: true });
133
+
134
+ // Trigger marketing sync via the Email() surface
135
+ const result = await Manager.Email(assistant).sync(granted.uid);
136
+ assert.ok(result, 'sync should return a result');
137
+ assert.notEqual(result.blocked, 'validation', 'sync should not be blocked by validation (uses _test.allow_*)');
138
+
139
+ // Poll providers until they reflect the upsert. SendGrid's upsert is
140
+ // an async background job (returns a job_id, not the inserted contact)
141
+ // so a single check 5s later isn't enough — it can take 10-20s to
142
+ // surface. Beehiiv is usually instant but uses the same poll for symmetry.
143
+ const sgContact = await pollProvider(() => sendgridProvider.findContact(granted.email), true);
144
+ const bhContact = await pollProvider(() => beehiivProvider.findContact(granted.email), true);
145
+
146
+ assert.ok(sgContact, 'granted account should now exist in SendGrid');
147
+ assert.ok(bhContact, 'granted account should now exist in Beehiiv');
148
+ },
149
+ },
150
+
151
+ // ─────────────────────────────────────────────────────────────────────
152
+ // Phase 2b — Declined account: verify it stays out of providers when we
153
+ // DON'T call sync (which is what the signup route does for declined
154
+ // marketing consent).
155
+ // ─────────────────────────────────────────────────────────────────────
156
+ {
157
+ name: 'phase-2b-declined-account-stays-out-of-providers',
158
+ auth: 'admin',
159
+ timeout: 180000,
160
+
161
+ async run({ accounts, assert, Manager, assistant }) {
162
+ const declined = accounts['consent-declined'];
163
+ const admin = Manager.libraries.admin;
164
+
165
+ // Set consent.marketing.status = 'revoked' on the user doc.
166
+ // We deliberately do NOT call sync — the production signup route gates
167
+ // on consent.marketing.status === 'granted' before calling sync, so a
168
+ // declined user simply never gets a sync call. We verify that contract
169
+ // by NOT calling sync and asserting the user stays absent.
170
+ await admin.firestore().doc(`users/${declined.uid}`).set({
171
+ consent: {
172
+ marketing: {
173
+ status: 'revoked',
174
+ grantedAt: { timestamp: null, timestampUNIX: null, source: null, ip: null, text: null },
175
+ revokedAt: {
176
+ timestamp: new Date().toISOString(),
177
+ timestampUNIX: Math.floor(Date.now() / 1000),
178
+ source: 'test',
179
+ ip: null,
180
+ text: null,
181
+ },
182
+ },
183
+ legal: {
184
+ status: 'granted',
185
+ grantedAt: {
186
+ timestamp: new Date().toISOString(),
187
+ timestampUNIX: Math.floor(Date.now() / 1000),
188
+ source: 'test',
189
+ ip: null,
190
+ text: 'consent-lifecycle test',
191
+ },
192
+ },
193
+ },
194
+ }, { merge: true });
195
+
196
+ await sleep(SETTLE_MS);
197
+
198
+ const sgContact = await sendgridProvider.findContact(declined.email);
199
+ const bhContact = await beehiivProvider.findContact(declined.email);
200
+
201
+ assert.equal(sgContact, null, 'declined account should remain absent from SendGrid');
202
+ assert.equal(bhContact, null, 'declined account should remain absent from Beehiiv');
203
+ },
204
+ },
205
+
206
+ // ─────────────────────────────────────────────────────────────────────
207
+ // Phase 3 — Unsubscribe: granted account is removed via Manager.Email().remove
208
+ // ─────────────────────────────────────────────────────────────────────
209
+ {
210
+ name: 'phase-3-granted-account-unsubscribe-removes-from-both',
211
+ auth: 'admin',
212
+ timeout: 180000,
213
+
214
+ async run({ accounts, assert, Manager, assistant }) {
215
+ const granted = accounts['consent-granted'];
216
+
217
+ // Trigger removal — simulates the email-preferences opt-out flow
218
+ const result = await Manager.Email(assistant).remove(granted.email);
219
+ assert.ok(result, 'remove should return a result');
220
+
221
+ // Poll for absence — SendGrid's contact delete is also an async job.
222
+ const sgContact = await pollProvider(() => sendgridProvider.findContact(granted.email), false);
223
+ const bhContact = await pollProvider(() => beehiivProvider.findContact(granted.email), false);
224
+
225
+ assert.equal(sgContact, null, 'granted account should now be absent from SendGrid');
226
+ assert.equal(bhContact, null, 'granted account should now be absent from Beehiiv');
227
+ },
228
+ },
229
+
230
+ // ─────────────────────────────────────────────────────────────────────
231
+ // Phase 4 — Validation gate: _test.* (non-allow) is blocked by validate()
232
+ // ─────────────────────────────────────────────────────────────────────
233
+ {
234
+ name: 'phase-4-non-allow-test-email-blocked-by-validation',
235
+ auth: 'admin',
236
+ timeout: 30000,
237
+
238
+ async run({ assert, Manager, assistant }) {
239
+ // _test.never-reaches-providers@... is NOT _test.allow_* → should be blocked
240
+ const blockedEmail = '_test.never-reaches-providers@somiibo.com';
241
+
242
+ const result = await Manager.Email(assistant).add({ email: blockedEmail });
243
+
244
+ assert.equal(result.blocked, 'validation', 'non-allow _test.* email should be blocked by validation');
245
+
246
+ // And the provider lookup should show nothing
247
+ const sgContact = await sendgridProvider.findContact(blockedEmail);
248
+ const bhContact = await beehiivProvider.findContact(blockedEmail);
249
+
250
+ assert.equal(sgContact, null, 'blocked email should never appear in SendGrid');
251
+ assert.equal(bhContact, null, 'blocked email should never appear in Beehiiv');
252
+ },
253
+ },
254
+ ],
255
+ };