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.
@@ -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
  };
@@ -138,10 +138,11 @@
138
138
  marketing: {
139
139
  sendgrid: {
140
140
  enabled: true,
141
+ listId: '', // SendGrid Marketing list UUID. Populated by OMEGA's sendgrid/ensure/list.js. Skips runtime list-discovery API call.
141
142
  },
142
143
  beehiiv: {
143
144
  enabled: false,
144
- // publicationId: 'pub_xxxxx', // Set to skip fuzzy-match API call
145
+ publicationId: '', // Beehiiv publication ID (e.g., 'pub_xxxxx'). Populated by OMEGA's beehiiv/ensure/publication.js. Required for marketing sync to Beehiiv.
145
146
  // Content pipeline. Lives under the provider that publishes the result —
146
147
  // Beehiiv for newsletters, eventually SendGrid for promo blasts. The
147
148
  // shape is the same regardless of provider: sources, tone, template,
@@ -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
+ };