backend-manager 5.2.2 → 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.
@@ -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
+ }
113
+
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);
109
125
 
110
- if (!searchData.data?.id) {
111
- return { success: true, skipped: true, reason: 'Subscription not found' };
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
 
@@ -131,80 +147,96 @@ async function removeSubscriber(email, publicationId) {
131
147
  }
132
148
 
133
149
  /**
134
- * Get a Beehiiv publication ID by brand name (fuzzy match).
150
+ * Get this brand's Beehiiv publication ID.
135
151
  *
136
- * @param {string} brandName
137
- * @returns {string|null} Publication ID or null
152
+ * Reads `Manager.config.marketing.beehiiv.publicationId` — populated by
153
+ * OMEGA's `beehiiv/ensure/publication.js` at brand-onboarding time. No
154
+ * runtime API call, no fuzzy-match fragility.
155
+ *
156
+ * If the brand hasn't been onboarded yet (publicationId missing/empty), logs
157
+ * a warning and returns null — the marketing sync will skip Beehiiv for this
158
+ * brand. Fix: run OMEGA's beehiiv service to populate.
159
+ *
160
+ * @returns {string|null} Publication ID or null if not configured
138
161
  */
139
- let _publicationIdCache = null;
140
-
141
- async function getPublicationId() {
142
- if (_publicationIdCache) {
143
- return _publicationIdCache;
144
- }
145
-
146
- // Use publicationId from config if set (skips API call)
147
- const configPubId = Manager.config?.marketing?.beehiiv?.publicationId;
148
-
149
- if (configPubId) {
150
- _publicationIdCache = configPubId;
151
- return configPubId;
152
- }
153
-
154
- // Fuzzy-match by brand name (guard against uninitialized Manager singleton —
155
- // happens in test stubs that build their own Manager without init()).
156
- const brandName = Manager.config?.brand?.name;
162
+ function getPublicationId() {
163
+ const publicationId = Manager.config?.marketing?.beehiiv?.publicationId;
157
164
 
158
- if (!brandName) {
159
- console.error('Beehiiv: Brand name is required to find publication');
165
+ if (!publicationId) {
166
+ console.warn(
167
+ 'Beehiiv: marketing.beehiiv.publicationId is not set in config. '
168
+ + 'Subscriber will NOT be added to a publication. '
169
+ + 'Run OMEGA to populate.',
170
+ );
160
171
  return null;
161
172
  }
162
173
 
163
- const brandNameLower = brandName.toLowerCase();
164
- const allPublications = [];
165
- let page = 1;
166
- const limit = 100;
167
-
168
- try {
169
- while (true) {
170
- const data = await fetch(`${BASE_URL}/publications?limit=${limit}&page=${page}`, {
171
- response: 'json',
172
- headers: headers(),
173
- timeout: 10000,
174
- });
175
-
176
- if (!data.data || data.data.length === 0) {
177
- break;
178
- }
179
-
180
- const matchedPub = data.data.find(pub =>
181
- pub.name.toLowerCase() === brandNameLower
182
- || pub.name.toLowerCase().includes(brandNameLower)
183
- || brandNameLower.includes(pub.name.toLowerCase())
184
- );
185
-
186
- if (matchedPub) {
187
- _publicationIdCache = matchedPub.id;
188
- return matchedPub.id;
189
- }
190
-
191
- allPublications.push(...data.data);
192
-
193
- if (data.data.length < limit) {
194
- break;
195
- }
196
-
197
- page++;
198
- }
199
-
200
- console.error(`Beehiiv: No publication matched brand "${brandName}". Available: ${allPublications.map(p => p.name).join(', ')}`);
201
- } catch (e) {
202
- console.error('Beehiiv publication lookup error:', e);
203
- }
204
-
205
- return null;
174
+ return publicationId;
206
175
  }
207
176
 
177
+ // LEGACY: Fuzzy-match-by-brand-name fallback. Kept commented out as a backstop
178
+ // in case the config-based approach has an edge case we haven't seen yet.
179
+ // Delete once we've verified the config-based approach works across all brands.
180
+ //
181
+ // let _publicationIdCache = null;
182
+ //
183
+ // async function getPublicationIdByFuzzyMatch() {
184
+ // if (_publicationIdCache) {
185
+ // return _publicationIdCache;
186
+ // }
187
+ //
188
+ // const brandName = Manager.config?.brand?.name;
189
+ //
190
+ // if (!brandName) {
191
+ // console.error('Beehiiv: Brand name is required to find publication');
192
+ // return null;
193
+ // }
194
+ //
195
+ // const brandNameLower = brandName.toLowerCase();
196
+ // const allPublications = [];
197
+ // let page = 1;
198
+ // const limit = 100;
199
+ //
200
+ // try {
201
+ // while (true) {
202
+ // const data = await fetch(`${BASE_URL}/publications?limit=${limit}&page=${page}`, {
203
+ // response: 'json',
204
+ // headers: headers(),
205
+ // timeout: 60000,
206
+ // });
207
+ //
208
+ // if (!data.data || data.data.length === 0) {
209
+ // break;
210
+ // }
211
+ //
212
+ // const matchedPub = data.data.find(pub =>
213
+ // pub.name.toLowerCase() === brandNameLower
214
+ // || pub.name.toLowerCase().includes(brandNameLower)
215
+ // || brandNameLower.includes(pub.name.toLowerCase())
216
+ // );
217
+ //
218
+ // if (matchedPub) {
219
+ // _publicationIdCache = matchedPub.id;
220
+ // return matchedPub.id;
221
+ // }
222
+ //
223
+ // allPublications.push(...data.data);
224
+ //
225
+ // if (data.data.length < limit) {
226
+ // break;
227
+ // }
228
+ //
229
+ // page++;
230
+ // }
231
+ //
232
+ // console.error(`Beehiiv: No publication matched brand "${brandName}". Available: ${allPublications.map(p => p.name).join(', ')}`);
233
+ // } catch (e) {
234
+ // console.error('Beehiiv publication lookup error:', e);
235
+ // }
236
+ //
237
+ // return null;
238
+ // }
239
+
208
240
  /**
209
241
  * Add a contact to Beehiiv — resolves publication, adds subscriber with optional custom fields.
210
242
  *
@@ -217,7 +249,7 @@ async function getPublicationId() {
217
249
  * @returns {{ success: boolean, id?: string, error?: string }}
218
250
  */
219
251
  async function addContact({ email, firstName, lastName, company, source, customFields }) {
220
- const publicationId = await getPublicationId();
252
+ const publicationId = getPublicationId();
221
253
 
222
254
  if (!publicationId) {
223
255
  return { success: false, error: 'Publication not found' };
@@ -245,7 +277,7 @@ async function addContact({ email, firstName, lastName, company, source, customF
245
277
  * @returns {{ success: boolean, deleted?: boolean, skipped?: boolean, error?: string }}
246
278
  */
247
279
  async function removeContact(email) {
248
- const publicationId = await getPublicationId();
280
+ const publicationId = getPublicationId();
249
281
 
250
282
  if (!publicationId) {
251
283
  return { success: false, error: 'Publication not found' };
@@ -254,6 +286,25 @@ async function removeContact(email) {
254
286
  return removeSubscriber(email, publicationId);
255
287
  }
256
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
+
257
308
  /**
258
309
  * Build Beehiiv custom_fields array from a user doc.
259
310
  * Resolves all field values, then maps to display names for Beehiiv.
@@ -290,7 +341,7 @@ async function resolveSegmentIds() {
290
341
  return _segmentIdCache;
291
342
  }
292
343
 
293
- const publicationId = await getPublicationId();
344
+ const publicationId = getPublicationId();
294
345
 
295
346
  if (!publicationId) {
296
347
  return {};
@@ -300,7 +351,7 @@ async function resolveSegmentIds() {
300
351
  const data = await fetch(`${BASE_URL}/publications/${publicationId}/segments?limit=100`, {
301
352
  response: 'json',
302
353
  headers: headers(),
303
- timeout: 10000,
354
+ timeout: 60000,
304
355
  });
305
356
 
306
357
  _segmentIdCache = {};
@@ -337,7 +388,7 @@ async function resolveSegmentIds() {
337
388
  * @returns {{ success: boolean, id?: string, scheduled?: boolean, error?: string }}
338
389
  */
339
390
  async function createPost(options) {
340
- const publicationId = options.publicationId || await getPublicationId();
391
+ const publicationId = options.publicationId || getPublicationId();
341
392
 
342
393
  if (!publicationId) {
343
394
  return { success: false, error: 'Publication not found' };
@@ -419,6 +470,8 @@ module.exports = {
419
470
 
420
471
  // Contacts
421
472
  addContact,
473
+ findContact,
474
+ findSubscriber,
422
475
  removeContact,
423
476
  buildFields,
424
477
 
@@ -9,6 +9,12 @@ const { resolveFieldValues } = require('../constants.js');
9
9
 
10
10
  const BASE_URL = 'https://api.sendgrid.com/v3';
11
11
 
12
+ // SendGrid's API is normally fast (<2s) but spikes past 10s during their
13
+ // hiccups, dropping signups silently. 60s is generous but harmless — the
14
+ // metadata calls (resolveFieldIds, getListId) are cached for the process
15
+ // lifetime so a slow first call costs nothing in steady state.
16
+ const SENDGRID_TIMEOUT_MS = 60000;
17
+
12
18
  // --- Internal helpers ---
13
19
 
14
20
  function headers() {
@@ -35,7 +41,7 @@ async function resolveFieldIds() {
35
41
  const data = await fetch(`${BASE_URL}/marketing/field_definitions`, {
36
42
  response: 'json',
37
43
  headers: headers(),
38
- timeout: 10000,
44
+ timeout: SENDGRID_TIMEOUT_MS,
39
45
  });
40
46
 
41
47
  _fieldIdCache = {};
@@ -70,7 +76,7 @@ async function resolveSegmentIds() {
70
76
  const data = await fetch(`${BASE_URL}/marketing/segments/2.0`, {
71
77
  response: 'json',
72
78
  headers: headers(),
73
- timeout: 10000,
79
+ timeout: SENDGRID_TIMEOUT_MS,
74
80
  });
75
81
 
76
82
  _segmentIdCache = {};
@@ -125,34 +131,59 @@ async function upsertContacts({ contacts, listIds }) {
125
131
  }
126
132
 
127
133
  /**
128
- * 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.
129
139
  *
130
140
  * @param {string} email
131
- * @returns {{ success: boolean, jobId?: string, skipped?: boolean, error?: string }}
141
+ * @returns {Promise<object|null>}
132
142
  */
133
- async function removeContact(email) {
143
+ async function findContact(email) {
134
144
  try {
135
- // Step 1: Get contact ID by email
136
145
  const searchData = await fetch(`${BASE_URL}/marketing/contacts/search/emails`, {
137
146
  method: 'post',
138
147
  response: 'json',
139
148
  headers: headers(),
140
- timeout: 10000,
149
+ timeout: SENDGRID_TIMEOUT_MS,
141
150
  body: { emails: [email] },
142
151
  });
143
152
 
144
- 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) {
145
176
  return { success: true, skipped: true, reason: 'Contact not found' };
146
177
  }
147
178
 
148
- const contactId = searchData.result[email].contact.id;
179
+ const contactId = contact.id;
149
180
 
150
181
  // Step 2: Delete contact by ID
151
182
  const deleteData = await fetch(`${BASE_URL}/marketing/contacts?ids=${contactId}`, {
152
183
  method: 'delete',
153
184
  response: 'json',
154
185
  headers: headers(),
155
- timeout: 10000,
186
+ timeout: SENDGRID_TIMEOUT_MS,
156
187
  });
157
188
 
158
189
  if (deleteData.job_id) {
@@ -167,69 +198,96 @@ async function removeContact(email) {
167
198
  }
168
199
 
169
200
  /**
170
- * Get a SendGrid list ID by brand name (fuzzy match).
201
+ * Get this brand's SendGrid Marketing list ID.
202
+ *
203
+ * Reads `Manager.config.marketing.sendgrid.listId` — populated by OMEGA's
204
+ * `sendgrid/ensure/list.js` at brand-onboarding time, same as how Beehiiv's
205
+ * `publicationId` works. No runtime API call, no fuzzy-match fragility.
206
+ *
207
+ * If the brand hasn't been onboarded yet (listId missing/empty), logs a
208
+ * warning and returns null — the marketing sync will still succeed, but the
209
+ * contact lands in SendGrid's global pool instead of the brand's list. Fix:
210
+ * run OMEGA's sendgrid service to populate the config.
171
211
  *
172
- * @param {string} brandName
173
- * @returns {string|null} List ID or null
212
+ * @returns {string|null} List ID or null if not configured
174
213
  */
175
- async function getListId() {
176
- const brandName = Manager.config.brand?.name;
177
- const brandNameLower = (brandName || '').toLowerCase();
178
- const allLists = [];
179
- let pageToken = '';
180
- const pageSize = 1000;
181
-
182
- try {
183
- while (true) {
184
- const url = `${BASE_URL}/marketing/lists?page_size=${pageSize}${pageToken ? `&page_token=${pageToken}` : ''}`;
185
- const data = await fetch(url, {
186
- response: 'json',
187
- headers: headers(),
188
- timeout: 10000,
189
- });
190
-
191
- if (!data.result || data.result.length === 0) {
192
- break;
193
- }
194
-
195
- const matchedList = data.result.find(list =>
196
- list.name.toLowerCase() === brandNameLower
197
- || list.name.toLowerCase().includes(brandNameLower)
198
- || brandNameLower.includes(list.name.toLowerCase())
199
- );
200
-
201
- if (matchedList) {
202
- return matchedList.id;
203
- }
204
-
205
- allLists.push(...data.result);
206
-
207
- if (!data._metadata?.next) {
208
- break;
209
- }
210
-
211
- const nextUrl = new URL(data._metadata.next);
212
- pageToken = nextUrl.searchParams.get('page_token');
213
-
214
- if (!pageToken) {
215
- break;
216
- }
217
- }
218
-
219
- if (allLists.length === 1) {
220
- return allLists[0].id;
221
- }
222
-
223
- if (allLists.length > 0) {
224
- console.error(`SendGrid: No list matched brand "${brandName}". Available: ${allLists.map(l => l.name).join(', ')}`);
225
- }
226
- } catch (e) {
227
- console.error('SendGrid list lookup error:', e);
214
+ function getListId() {
215
+ const listId = Manager.config.marketing?.sendgrid?.listId;
216
+
217
+ if (!listId) {
218
+ console.warn(
219
+ 'SendGrid: marketing.sendgrid.listId is not set in config. '
220
+ + 'Contact will be added to All Contacts only, not the brand list. '
221
+ + 'Run OMEGA to populate.',
222
+ );
223
+ return null;
228
224
  }
229
225
 
230
- return null;
226
+ return listId;
231
227
  }
232
228
 
229
+ // LEGACY: Fuzzy-match-by-brand-name fallback. Kept commented out as a backstop
230
+ // in case the config-based approach has an edge case we haven't seen yet.
231
+ // Delete once we've verified the config-based approach works across all brands.
232
+ //
233
+ // async function getListIdByFuzzyMatch() {
234
+ // const brandName = Manager.config.brand?.name;
235
+ // const brandNameLower = (brandName || '').toLowerCase();
236
+ // const allLists = [];
237
+ // let pageToken = '';
238
+ // const pageSize = 1000;
239
+ //
240
+ // try {
241
+ // while (true) {
242
+ // const url = `${BASE_URL}/marketing/lists?page_size=${pageSize}${pageToken ? `&page_token=${pageToken}` : ''}`;
243
+ // const data = await fetch(url, {
244
+ // response: 'json',
245
+ // headers: headers(),
246
+ // timeout: SENDGRID_TIMEOUT_MS,
247
+ // });
248
+ //
249
+ // if (!data.result || data.result.length === 0) {
250
+ // break;
251
+ // }
252
+ //
253
+ // const matchedList = data.result.find(list =>
254
+ // list.name.toLowerCase() === brandNameLower
255
+ // || list.name.toLowerCase().includes(brandNameLower)
256
+ // || brandNameLower.includes(list.name.toLowerCase())
257
+ // );
258
+ //
259
+ // if (matchedList) {
260
+ // return matchedList.id;
261
+ // }
262
+ //
263
+ // allLists.push(...data.result);
264
+ //
265
+ // if (!data._metadata?.next) {
266
+ // break;
267
+ // }
268
+ //
269
+ // const nextUrl = new URL(data._metadata.next);
270
+ // pageToken = nextUrl.searchParams.get('page_token');
271
+ //
272
+ // if (!pageToken) {
273
+ // break;
274
+ // }
275
+ // }
276
+ //
277
+ // if (allLists.length === 1) {
278
+ // return allLists[0].id;
279
+ // }
280
+ //
281
+ // if (allLists.length > 0) {
282
+ // console.error(`SendGrid: No list matched brand "${brandName}". Available: ${allLists.map(l => l.name).join(', ')}`);
283
+ // }
284
+ // } catch (e) {
285
+ // console.error('SendGrid list lookup error:', e);
286
+ // }
287
+ //
288
+ // return null;
289
+ // }
290
+
233
291
  // --- Single Sends (Campaigns) ---
234
292
 
235
293
  /**
@@ -354,7 +412,7 @@ async function cancelSingleSend(singleSendId) {
354
412
  method: 'delete',
355
413
  response: 'json',
356
414
  headers: headers(),
357
- timeout: 10000,
415
+ timeout: SENDGRID_TIMEOUT_MS,
358
416
  });
359
417
 
360
418
  return { success: true };
@@ -375,7 +433,7 @@ async function getSingleSend(singleSendId) {
375
433
  const data = await fetch(`${BASE_URL}/marketing/singlesends/${singleSendId}`, {
376
434
  response: 'json',
377
435
  headers: headers(),
378
- timeout: 10000,
436
+ timeout: SENDGRID_TIMEOUT_MS,
379
437
  });
380
438
 
381
439
  return data.id ? data : null;
@@ -400,7 +458,7 @@ async function listSingleSends(options) {
400
458
  const data = await fetch(url, {
401
459
  response: 'json',
402
460
  headers: headers(),
403
- timeout: 10000,
461
+ timeout: SENDGRID_TIMEOUT_MS,
404
462
  });
405
463
 
406
464
  return data.result || [];
@@ -437,7 +495,7 @@ async function addContact({ email, firstName, lastName, company, customFields })
437
495
  }
438
496
  }
439
497
 
440
- const listId = await getListId();
498
+ const listId = getListId();
441
499
  const result = await upsertContacts({
442
500
  contacts: [contact],
443
501
  listIds: listId ? [listId] : [],
@@ -508,7 +566,7 @@ async function getSegmentContacts(segmentId, maxWaitMs = 60000) {
508
566
  const statusData = await fetch(`${BASE_URL}/marketing/contacts/exports/${exportData.id}`, {
509
567
  response: 'json',
510
568
  headers: headers(),
511
- timeout: 10000,
569
+ timeout: SENDGRID_TIMEOUT_MS,
512
570
  });
513
571
 
514
572
  console.log(`SendGrid getSegmentContacts: Poll #${pollCount} — ${statusData.status} (${Date.now() - startTime}ms)`);
@@ -602,6 +660,7 @@ module.exports = {
602
660
 
603
661
  // Contacts
604
662
  addContact,
663
+ findContact,
605
664
  removeContact,
606
665
  getSegmentContacts,
607
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