backend-manager 5.9.6 → 5.9.8

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 (215) hide show
  1. package/package.json +5 -3
  2. package/src/manager/events/cron/runner.js +0 -1
  3. package/src/manager/libraries/email/data/disposable-domains.json +21 -0
  4. package/src/test/fixtures/firebase-project/.temp/test-mode.json +1 -1
  5. package/src/test/fixtures/firebase-project/database-debug.log +8 -8
  6. package/src/test/fixtures/firebase-project/firestore-debug.log +55 -59
  7. package/src/test/fixtures/firebase-project/pubsub-debug.log +3 -3
  8. package/templates/_.env +42 -0
  9. package/templates/_.gitignore +60 -0
  10. package/templates/backend-manager-config.json +249 -0
  11. package/templates/database.rules.json +83 -0
  12. package/templates/firebase.json +66 -0
  13. package/templates/firestore.indexes.json +4 -0
  14. package/templates/firestore.rules +112 -0
  15. package/templates/public/404.html +26 -0
  16. package/templates/public/index.html +24 -0
  17. package/templates/remoteconfig.template.json +1 -0
  18. package/templates/storage-lifecycle-config-1-day.json +9 -0
  19. package/templates/storage-lifecycle-config-30-days.json +9 -0
  20. package/templates/storage.rules +8 -0
  21. package/test/_init/accounts-validation.js +58 -0
  22. package/test/_legacy/ai/index.js +231 -0
  23. package/test/_legacy/ai/test.jpg +0 -0
  24. package/test/_legacy/ai/test.pdf +0 -0
  25. package/test/_legacy/index.js +31 -0
  26. package/test/_legacy/payment-resolver/chargebee/orders/refunded.json +0 -0
  27. package/test/_legacy/payment-resolver/chargebee/orders/unpaid.json +98 -0
  28. package/test/_legacy/payment-resolver/chargebee/subscriptions/active.json +578 -0
  29. package/test/_legacy/payment-resolver/chargebee/subscriptions/trial-in-trial.json +38 -0
  30. package/test/_legacy/payment-resolver/chargebee/subscriptions/trial-skipped-to-active.json +135 -0
  31. package/test/_legacy/payment-resolver/chargebee/subscriptions/trial-to-active-to-cancelled.json +42 -0
  32. package/test/_legacy/payment-resolver/chargebee/subscriptions/trial-to-active.json +44 -0
  33. package/test/_legacy/payment-resolver/chargebee/subscriptions/trial-to-cancelled.json +39 -0
  34. package/test/_legacy/payment-resolver/chargebee/subscriptions/trial-to-refund.json +143 -0
  35. package/test/_legacy/payment-resolver/chargebee/subscriptions/trial-to-suspended.json +48 -0
  36. package/test/_legacy/payment-resolver/coinbase/orders/regular.json +204 -0
  37. package/test/_legacy/payment-resolver/coinbase/subscriptions/cancelled.json +204 -0
  38. package/test/_legacy/payment-resolver/coinbase/subscriptions/paid-2.json +125 -0
  39. package/test/_legacy/payment-resolver/index.js +1558 -0
  40. package/test/_legacy/payment-resolver/paypal/orders/refunded.json +0 -0
  41. package/test/_legacy/payment-resolver/paypal/orders/regular.json +120 -0
  42. package/test/_legacy/payment-resolver/paypal/subscriptions/active-refund-previous-stmnt.json +323 -0
  43. package/test/_legacy/payment-resolver/paypal/subscriptions/active.json +192 -0
  44. package/test/_legacy/payment-resolver/paypal/subscriptions/trial-in-trial.json +125 -0
  45. package/test/_legacy/payment-resolver/paypal/subscriptions/trial-payment-not-complete.json +76 -0
  46. package/test/_legacy/payment-resolver/paypal/subscriptions/trial-payment-overdue-2.json +114 -0
  47. package/test/_legacy/payment-resolver/paypal/subscriptions/trial-payment-overdue.json +114 -0
  48. package/test/_legacy/payment-resolver/paypal/subscriptions/trial-to-active-to-cancelled.json +111 -0
  49. package/test/_legacy/payment-resolver/paypal/subscriptions/trial-to-active.json +132 -0
  50. package/test/_legacy/payment-resolver/paypal/subscriptions/trial-to-cancelled.json +107 -0
  51. package/test/_legacy/payment-resolver/paypal/subscriptions/trial-to-expired.json +91 -0
  52. package/test/_legacy/payment-resolver/paypal/subscriptions/trial-to-refund.json +147 -0
  53. package/test/_legacy/payment-resolver/paypal/subscriptions/trial-to-suspended.json +120 -0
  54. package/test/_legacy/payment-resolver/stripe/orders/refunded.json +0 -0
  55. package/test/_legacy/payment-resolver/stripe/orders/regular.json +91 -0
  56. package/test/_legacy/payment-resolver/stripe/subscriptions/active.json +421 -0
  57. package/test/_legacy/payment-resolver/stripe/subscriptions/trial-in-trial.json +349 -0
  58. package/test/_legacy/payment-resolver/stripe/subscriptions/trial-to-active-failed-authorization.json +430 -0
  59. package/test/_legacy/payment-resolver/stripe/subscriptions/trial-to-active.json +319 -0
  60. package/test/_legacy/payment-resolver/stripe/subscriptions/trial-to-cancelled.json +319 -0
  61. package/test/_legacy/payment-resolver/stripe/subscriptions/trial-to-refund.json +414 -0
  62. package/test/_legacy/payment-resolver/stripe/subscriptions/trial-to-suspended.json +319 -0
  63. package/test/_legacy/payment-resolver/stripe/subscriptions/unsure.json +339 -0
  64. package/test/_legacy/test-new +1178 -0
  65. package/test/_legacy/test.md +89 -0
  66. package/test/_legacy/usage.js +175 -0
  67. package/test/_legacy/user.js +31 -0
  68. package/test/ai/tools-live.js +170 -0
  69. package/test/boot/emulator-boots.js +37 -0
  70. package/test/content/blog-generate.js +160 -0
  71. package/test/email/campaign-send.js +45 -0
  72. package/test/email/consent-lifecycle.js +257 -0
  73. package/test/email/feedback-and-plain-send.js +52 -0
  74. package/test/email/fixtures/clean.json +30 -0
  75. package/test/email/fixtures/editorial.json +30 -0
  76. package/test/email/fixtures/field-report.json +53 -0
  77. package/test/email/marketing-lifecycle.js +132 -0
  78. package/test/email/newsletter-generate.js +819 -0
  79. package/test/email/newsletter-templates.js +491 -0
  80. package/test/email/templates.js +207 -0
  81. package/test/email/transactional-send.js +34 -0
  82. package/test/email/transactional.js +560 -0
  83. package/test/email/validation.js +710 -0
  84. package/test/events/auth-delete-race.js +201 -0
  85. package/test/events/payments/journey-payments-cancel-endpoint.js +100 -0
  86. package/test/events/payments/journey-payments-cancel.js +182 -0
  87. package/test/events/payments/journey-payments-discount.js +89 -0
  88. package/test/events/payments/journey-payments-failure.js +146 -0
  89. package/test/events/payments/journey-payments-legacy-product.js +149 -0
  90. package/test/events/payments/journey-payments-one-time-failure.js +111 -0
  91. package/test/events/payments/journey-payments-one-time.js +136 -0
  92. package/test/events/payments/journey-payments-plan-change.js +144 -0
  93. package/test/events/payments/journey-payments-refund-webhook.js +180 -0
  94. package/test/events/payments/journey-payments-suspend.js +181 -0
  95. package/test/events/payments/journey-payments-trial-cancel.js +130 -0
  96. package/test/events/payments/journey-payments-trial.js +171 -0
  97. package/test/events/payments/journey-payments-uid-resolution.js +132 -0
  98. package/test/events/payments/journey-payments-upgrade.js +135 -0
  99. package/test/fixtures/chargebee/invoice-one-time.json +27 -0
  100. package/test/fixtures/chargebee/subscription-active.json +44 -0
  101. package/test/fixtures/chargebee/subscription-cancelled.json +42 -0
  102. package/test/fixtures/chargebee/subscription-in-trial.json +41 -0
  103. package/test/fixtures/chargebee/subscription-legacy-plan.json +41 -0
  104. package/test/fixtures/chargebee/subscription-non-renewing.json +41 -0
  105. package/test/fixtures/chargebee/subscription-paused.json +42 -0
  106. package/test/fixtures/chargebee/webhook-payment-failed.json +51 -0
  107. package/test/fixtures/chargebee/webhook-subscription-created.json +47 -0
  108. package/test/fixtures/paypal/order-approved.json +62 -0
  109. package/test/fixtures/paypal/order-completed.json +110 -0
  110. package/test/fixtures/paypal/subscription-active.json +76 -0
  111. package/test/fixtures/paypal/subscription-cancelled.json +50 -0
  112. package/test/fixtures/paypal/subscription-suspended.json +65 -0
  113. package/test/fixtures/stripe/checkout-session-completed.json +130 -0
  114. package/test/fixtures/stripe/invoice-payment-failed.json +148 -0
  115. package/test/fixtures/stripe/invoice-subscription-payment-failed.json +28 -0
  116. package/test/fixtures/stripe/subscription-active.json +161 -0
  117. package/test/fixtures/stripe/subscription-canceled.json +161 -0
  118. package/test/fixtures/stripe/subscription-trialing.json +161 -0
  119. package/test/functions/admin/database-read.js +134 -0
  120. package/test/functions/admin/database-write.js +159 -0
  121. package/test/functions/admin/edit-post.js +366 -0
  122. package/test/functions/admin/firestore-query.js +207 -0
  123. package/test/functions/admin/firestore-read.js +129 -0
  124. package/test/functions/admin/firestore-write.js +105 -0
  125. package/test/functions/admin/get-stats.js +115 -0
  126. package/test/functions/admin/send-email.js +119 -0
  127. package/test/functions/admin/send-notification.js +208 -0
  128. package/test/functions/admin/write-repo-content.js +223 -0
  129. package/test/functions/general/add-marketing-contact.js +335 -0
  130. package/test/functions/general/fetch-post.js +54 -0
  131. package/test/functions/general/generate-uuid.js +114 -0
  132. package/test/functions/test/authenticate.js +64 -0
  133. package/test/functions/user/create-custom-token.js +73 -0
  134. package/test/functions/user/delete.js +135 -0
  135. package/test/functions/user/get-active-sessions.js +111 -0
  136. package/test/functions/user/get-subscription-info.js +99 -0
  137. package/test/functions/user/regenerate-api-keys.js +156 -0
  138. package/test/functions/user/resolve.js +52 -0
  139. package/test/functions/user/sign-out-all-sessions.js +94 -0
  140. package/test/functions/user/sign-up.js +252 -0
  141. package/test/functions/user/submit-feedback.js +104 -0
  142. package/test/functions/user/validate-settings.js +82 -0
  143. package/test/helpers/ai-request-payload.js +300 -0
  144. package/test/helpers/ai-test-provider.js +202 -0
  145. package/test/helpers/ai-tools-format.js +383 -0
  146. package/test/helpers/content/blog-auto-publisher.js +484 -0
  147. package/test/helpers/content/feed-parser.js +528 -0
  148. package/test/helpers/content/ghostii-blocks.js +134 -0
  149. package/test/helpers/content/ghostii-feed-integration.js +404 -0
  150. package/test/helpers/content/ghostii-write-article.js +243 -0
  151. package/test/helpers/environment.js +230 -0
  152. package/test/helpers/infer-contact.js +113 -0
  153. package/test/helpers/merge-line-files.js +271 -0
  154. package/test/helpers/payment/chargebee/parse-webhook.js +413 -0
  155. package/test/helpers/payment/chargebee/to-unified-one-time.js +147 -0
  156. package/test/helpers/payment/chargebee/to-unified-subscription.js +648 -0
  157. package/test/helpers/payment/paypal/parse-webhook.js +539 -0
  158. package/test/helpers/payment/paypal/to-unified-one-time.js +382 -0
  159. package/test/helpers/payment/paypal/to-unified-subscription.js +820 -0
  160. package/test/helpers/payment/stripe/parse-webhook.js +447 -0
  161. package/test/helpers/payment/stripe/to-unified-one-time.js +306 -0
  162. package/test/helpers/payment/stripe/to-unified-subscription.js +708 -0
  163. package/test/helpers/sanitize.js +222 -0
  164. package/test/helpers/slugify.js +394 -0
  165. package/test/helpers/storage.js +194 -0
  166. package/test/helpers/user.js +755 -0
  167. package/test/helpers/webhook-forward.js +418 -0
  168. package/test/mcp/discovery.js +53 -0
  169. package/test/mcp/oauth.js +161 -0
  170. package/test/mcp/protocol.js +268 -0
  171. package/test/mcp/roles.js +188 -0
  172. package/test/mcp/utils.js +245 -0
  173. package/test/routes/admin/create-post.js +364 -0
  174. package/test/routes/admin/database.js +131 -0
  175. package/test/routes/admin/deduplicate-image-alts.js +190 -0
  176. package/test/routes/admin/email.js +114 -0
  177. package/test/routes/admin/firestore-query.js +204 -0
  178. package/test/routes/admin/firestore.js +127 -0
  179. package/test/routes/admin/infer-contact.js +218 -0
  180. package/test/routes/admin/notification.js +198 -0
  181. package/test/routes/admin/post-resize-image.js +184 -0
  182. package/test/routes/admin/post.js +368 -0
  183. package/test/routes/admin/repo-content.js +223 -0
  184. package/test/routes/admin/stats.js +112 -0
  185. package/test/routes/content/post.js +54 -0
  186. package/test/routes/general/uuid.js +115 -0
  187. package/test/routes/marketing/campaign.js +125 -0
  188. package/test/routes/marketing/contact.js +370 -0
  189. package/test/routes/marketing/email-preferences.js +292 -0
  190. package/test/routes/marketing/push-send.js +32 -0
  191. package/test/routes/marketing/webhook-forward.js +61 -0
  192. package/test/routes/marketing/webhook.js +639 -0
  193. package/test/routes/payments/cancel.js +163 -0
  194. package/test/routes/payments/discount.js +80 -0
  195. package/test/routes/payments/dispute-alert.js +320 -0
  196. package/test/routes/payments/intent.js +336 -0
  197. package/test/routes/payments/portal.js +92 -0
  198. package/test/routes/payments/refund.js +179 -0
  199. package/test/routes/payments/trial-eligibility.js +71 -0
  200. package/test/routes/payments/webhook.js +107 -0
  201. package/test/routes/test/authenticate.js +59 -0
  202. package/test/routes/test/schema.js +554 -0
  203. package/test/routes/test/usage.js +342 -0
  204. package/test/routes/user/api-keys.js +156 -0
  205. package/test/routes/user/delete.js +136 -0
  206. package/test/routes/user/feedback.js +104 -0
  207. package/test/routes/user/sessions.js +199 -0
  208. package/test/routes/user/settings-validate.js +82 -0
  209. package/test/routes/user/signup.js +542 -0
  210. package/test/routes/user/subscription.js +99 -0
  211. package/test/routes/user/token.js +73 -0
  212. package/test/routes/user/user.js +157 -0
  213. package/test/rules/notifications.js +410 -0
  214. package/test/rules/payments-carts.js +371 -0
  215. package/test/rules/user.js +396 -0
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Test: Single marketing campaign send
3
+ * Verifies the full marketing pipeline end-to-end: prepare → render → audience → SendGrid Single Send.
4
+ * Sends to test_admin segment only.
5
+ */
6
+ module.exports = {
7
+ description: 'Marketing campaign send',
8
+ auth: 'admin',
9
+ skip: !process.env.TEST_EXTENDED_MODE ? 'TEST_EXTENDED_MODE not set' : false,
10
+ timeout: 60000,
11
+
12
+ async run({ http, assert }) {
13
+ const response = await http.post('backend-manager/marketing/campaign', {
14
+ name: '[TEST] Summer Sale — Free Users',
15
+ subject: 'Summer Sale — {discount.percent}% Off Your First Month!',
16
+ preheader: 'Limited time offer — upgrade to Premium today',
17
+ template: 'card',
18
+ data: {
19
+ content: {
20
+ title: 'Summer Sale!',
21
+ message: [
22
+ 'You\'ve been using **{brand.name}** on our free plan — and we think you\'ll love what\'s on the other side.',
23
+ '',
24
+ 'For a limited time, upgrade to Premium and get **{discount.percent}% off** your first month.',
25
+ '',
26
+ 'Use code **{discount.code}** at checkout.',
27
+ ].join('\n'),
28
+ button: { text: 'Upgrade Now →', url: '{brand.url}/pricing' },
29
+ discountCode: 'UPGRADE15',
30
+ },
31
+ },
32
+ test: true,
33
+ sendAt: 'now',
34
+ });
35
+
36
+ assert.isSuccess(response, 'Should create and schedule campaign');
37
+ assert.ok(response.data.id, 'Should have campaign ID');
38
+
39
+ const sg = response.data?.providers?.campaigns;
40
+ assert.ok(sg, 'Should have campaigns result');
41
+ assert.equal(sg.success, true, 'Campaigns (SendGrid) should succeed');
42
+ assert.ok(sg.id, 'Should have Single Send ID');
43
+ assert.equal(sg.scheduled, true, 'Should be scheduled');
44
+ },
45
+ };
@@ -0,0 +1,257 @@
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, config }) {
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
+ if (config.marketing?.newsletter?.publicationId) {
148
+ assert.ok(bhContact, 'granted account should now exist in Beehiiv');
149
+ }
150
+ },
151
+ },
152
+
153
+ // ─────────────────────────────────────────────────────────────────────
154
+ // Phase 2b — Declined account: verify it stays out of providers when we
155
+ // DON'T call sync (which is what the signup route does for declined
156
+ // marketing consent).
157
+ // ─────────────────────────────────────────────────────────────────────
158
+ {
159
+ name: 'phase-2b-declined-account-stays-out-of-providers',
160
+ auth: 'admin',
161
+ timeout: 180000,
162
+
163
+ async run({ accounts, assert, Manager, assistant }) {
164
+ const declined = accounts['consent-declined'];
165
+ const admin = Manager.libraries.admin;
166
+
167
+ // Set consent.marketing.status = 'revoked' on the user doc.
168
+ // We deliberately do NOT call sync — the production signup route gates
169
+ // on consent.marketing.status === 'granted' before calling sync, so a
170
+ // declined user simply never gets a sync call. We verify that contract
171
+ // by NOT calling sync and asserting the user stays absent.
172
+ await admin.firestore().doc(`users/${declined.uid}`).set({
173
+ consent: {
174
+ marketing: {
175
+ status: 'revoked',
176
+ grantedAt: { timestamp: null, timestampUNIX: null, source: null, ip: null, text: null },
177
+ revokedAt: {
178
+ timestamp: new Date().toISOString(),
179
+ timestampUNIX: Math.floor(Date.now() / 1000),
180
+ source: 'test',
181
+ ip: null,
182
+ text: null,
183
+ },
184
+ },
185
+ legal: {
186
+ status: 'granted',
187
+ grantedAt: {
188
+ timestamp: new Date().toISOString(),
189
+ timestampUNIX: Math.floor(Date.now() / 1000),
190
+ source: 'test',
191
+ ip: null,
192
+ text: 'consent-lifecycle test',
193
+ },
194
+ },
195
+ },
196
+ }, { merge: true });
197
+
198
+ await sleep(SETTLE_MS);
199
+
200
+ const sgContact = await sendgridProvider.findContact(declined.email);
201
+ const bhContact = await beehiivProvider.findContact(declined.email);
202
+
203
+ assert.equal(sgContact, null, 'declined account should remain absent from SendGrid');
204
+ assert.equal(bhContact, null, 'declined account should remain absent from Beehiiv');
205
+ },
206
+ },
207
+
208
+ // ─────────────────────────────────────────────────────────────────────
209
+ // Phase 3 — Unsubscribe: granted account is removed via Manager.Email().remove
210
+ // ─────────────────────────────────────────────────────────────────────
211
+ {
212
+ name: 'phase-3-granted-account-unsubscribe-removes-from-both',
213
+ auth: 'admin',
214
+ timeout: 180000,
215
+
216
+ async run({ accounts, assert, Manager, assistant }) {
217
+ const granted = accounts['consent-granted'];
218
+
219
+ // Trigger removal — simulates the email-preferences opt-out flow
220
+ const result = await Manager.Email(assistant).remove(granted.email);
221
+ assert.ok(result, 'remove should return a result');
222
+
223
+ // Poll for absence — SendGrid's contact delete is also an async job.
224
+ const sgContact = await pollProvider(() => sendgridProvider.findContact(granted.email), false);
225
+ const bhContact = await pollProvider(() => beehiivProvider.findContact(granted.email), false);
226
+
227
+ assert.equal(sgContact, null, 'granted account should now be absent from SendGrid');
228
+ assert.equal(bhContact, null, 'granted account should now be absent from Beehiiv');
229
+ },
230
+ },
231
+
232
+ // ─────────────────────────────────────────────────────────────────────
233
+ // Phase 4 — Validation gate: _test.* (non-allow) is blocked by validate()
234
+ // ─────────────────────────────────────────────────────────────────────
235
+ {
236
+ name: 'phase-4-non-allow-test-email-blocked-by-validation',
237
+ auth: 'admin',
238
+ timeout: 30000,
239
+
240
+ async run({ assert, Manager, assistant }) {
241
+ // _test.never-reaches-providers@... is NOT _test.allow_* → should be blocked
242
+ const blockedEmail = '_test.never-reaches-providers@somiibo.com';
243
+
244
+ const result = await Manager.Email(assistant).add({ email: blockedEmail });
245
+
246
+ assert.equal(result.blocked, 'validation', 'non-allow _test.* email should be blocked by validation');
247
+
248
+ // And the provider lookup should show nothing
249
+ const sgContact = await sendgridProvider.findContact(blockedEmail);
250
+ const bhContact = await beehiivProvider.findContact(blockedEmail);
251
+
252
+ assert.equal(sgContact, null, 'blocked email should never appear in SendGrid');
253
+ assert.equal(bhContact, null, 'blocked email should never appear in Beehiiv');
254
+ },
255
+ },
256
+ ],
257
+ };
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Test: Send feedback + plain template emails
3
+ * Quick visual test — sends one of each to verify rendering.
4
+ */
5
+ module.exports = {
6
+ description: 'Feedback + plain template send',
7
+ type: 'group',
8
+ skip: !process.env.TEST_EXTENDED_MODE ? 'TEST_EXTENDED_MODE not set' : false,
9
+ tests: [
10
+ {
11
+ name: 'feedback-template',
12
+ auth: 'admin',
13
+ timeout: 30000,
14
+ async run({ http, assert, config }) {
15
+ const response = await http.post('backend-manager/admin/email', {
16
+ subject: '[TEST] Feedback template',
17
+ to: `_test-email-send@${config.domain}`,
18
+ template: 'feedback',
19
+ sender: 'hello',
20
+ copy: false,
21
+ data: { signoff: { type: 'personal' } },
22
+ });
23
+
24
+ assert.isSuccess(response, 'Should send feedback email');
25
+ assert.equal(response.data.status, 'sent', 'Status should be sent');
26
+ },
27
+ },
28
+ {
29
+ name: 'plain-template-with-markdown',
30
+ auth: 'admin',
31
+ timeout: 30000,
32
+ async run({ http, assert, config }) {
33
+ const response = await http.post('backend-manager/admin/email', {
34
+ subject: '[TEST] Plain template with markdown',
35
+ to: `_test-email-send@${config.domain}`,
36
+ template: 'plain',
37
+ sender: 'hello',
38
+ copy: false,
39
+ data: {
40
+ content: {
41
+ message: '# Quick Update\n\nHey Ian,\n\nJust wanted to let you know that your **account settings** have been updated successfully.\n\nHere is what changed:\n\n- Email notifications: **enabled**\n- Two-factor auth: **enabled**\n- API key: [regenerated](https://example.com/account)\n\nIf you didn\'t make these changes, please [contact support](https://example.com/support) immediately.\n\nThanks!',
42
+ },
43
+ signoff: { type: 'personal' },
44
+ },
45
+ });
46
+
47
+ assert.isSuccess(response, 'Should send plain email');
48
+ assert.equal(response.data.status, 'sent', 'Status should be sent');
49
+ },
50
+ },
51
+ ],
52
+ };
@@ -0,0 +1,30 @@
1
+ {
2
+ "_comment": "Predefined fixture for the `clean` template. Loaded via NEWSLETTER_FIXTURE=clean. Edit freely — no AI involved. Classic content shape: intro + sections[{title, body, cta, image_prompt}].",
3
+ "subject": "Identity is the new growth lever",
4
+ "preheader": "Three platform shifts to lock in this week.",
5
+ "summary": "LinkedIn, YouTube, and the broader creator stack are all tightening identity controls this week. The operators with documented attribution histories will outrun the rest. Documentation is no longer overhead — it's the moat.",
6
+ "tags": ["linkedin", "youtube", "platform-policy", "creator-economy", "operator-tools"],
7
+ "intro": "Identity matters because trust is the new growth lever. Teams that handle this well will spend less time cleaning up later.",
8
+ "sections": [
9
+ {
10
+ "title": "LinkedIn ships verified-profile gates for business accounts",
11
+ "body": "LinkedIn now requires verified identity attestations on any business profile claiming more than 500 followers. Accounts with documented attribution histories sail through verification in under twelve hours. The practical implication is to lock down your attribution log this week, not next.",
12
+ "image_prompt": "Abstract geometric illustration of a verification checkmark inside a layered profile card."
13
+ },
14
+ {
15
+ "title": "Documentation is the new operator moat",
16
+ "body": "Operator playbooks are now the difference between an account that recovers from a flag and one that gets permanently restricted. Teams running documented processes recover in days. The ones without burn weeks negotiating with support queues.",
17
+ "image_prompt": "Stack of geometric document layers casting clean shadows on a flat surface."
18
+ },
19
+ {
20
+ "title": "YouTube tests creator-attribution metadata on uploads",
21
+ "body": "A subset of channels saw a new upload checkbox this week asking whether content was assisted by automation. The checkbox is optional today. Reading the room: it will not stay optional. Creators with clear internal workflows will adapt in an afternoon.",
22
+ "image_prompt": "Minimalist play button morphing into a label tag, flat geometric style."
23
+ }
24
+ ],
25
+ "signoff": "Best,\nThe Somiibo Team",
26
+ "citations": [
27
+ { "note": "LinkedIn flagged 12,000 impersonation attempts in the verified-profile beta.", "source": "Reported by LinkedIn product team, May 2026" },
28
+ { "note": "Operator playbook adoption up 38% week-over-week in Q2.", "source": "Per platform analytics, May 2026" }
29
+ ]
30
+ }
@@ -0,0 +1,30 @@
1
+ {
2
+ "_comment": "Predefined fixture for the `editorial` template. Loaded via NEWSLETTER_FIXTURE=editorial. Same classic content shape as clean.json — both templates render the same data.",
3
+ "subject": "Identity is the new growth lever",
4
+ "preheader": "Three platform shifts to lock in this week.",
5
+ "summary": "LinkedIn, YouTube, and the broader creator stack are all tightening identity controls this week. The operators with documented attribution histories will outrun the rest. Documentation is no longer overhead — it's the moat.",
6
+ "tags": ["linkedin", "youtube", "platform-policy", "creator-economy", "operator-tools"],
7
+ "intro": "Identity matters because trust is the new growth lever. Teams that handle this well will spend less time cleaning up later, and the patterns emerging this week tell you exactly where to focus.",
8
+ "sections": [
9
+ {
10
+ "title": "LinkedIn ships verified-profile gates for business accounts",
11
+ "body": "LinkedIn now requires verified identity attestations on any business profile claiming more than 500 followers. The rollout is fast and uneven — some accounts hit the gate inside two days of profile activity, others sit unchallenged for weeks. The pattern that matters is this: accounts with documented attribution histories sail through verification in under twelve hours. Accounts without get queued. The practical implication is to lock down your attribution log this week, not next.",
12
+ "image_prompt": "Abstract geometric illustration of a verification checkmark inside a layered profile card."
13
+ },
14
+ {
15
+ "title": "Documentation is the new operator moat",
16
+ "body": "Operator playbooks — once dismissed as overhead — are now the difference between an account that recovers from a flag and one that gets permanently restricted. The teams already running on documented processes recover in days. The ones without burn weeks negotiating with support queues. Treat your playbook as a compliance artifact, not a knowledge-management nice-to-have.",
17
+ "image_prompt": "Stack of geometric document layers casting clean shadows on a flat surface."
18
+ },
19
+ {
20
+ "title": "YouTube tests creator-attribution metadata on uploads",
21
+ "body": "A subset of channels saw a new upload checkbox this week asking whether content was assisted by automation. The checkbox is optional today. Reading the room: it will not stay optional. Creators with clear internal workflows already labeled will adapt in an afternoon. Everyone else will spend a quarter retrofitting.",
22
+ "image_prompt": "Minimalist play button morphing into a label tag, flat geometric style."
23
+ }
24
+ ],
25
+ "signoff": "Best,\nThe Somiibo Team",
26
+ "citations": [
27
+ { "note": "LinkedIn flagged 12,000 impersonation attempts in the verified-profile beta.", "source": "Reported by LinkedIn product team, May 2026" },
28
+ { "note": "Operator playbook adoption up 38% week-over-week in Q2.", "source": "Per platform analytics, May 2026" }
29
+ ]
30
+ }
@@ -0,0 +1,53 @@
1
+ {
2
+ "_comment": "Predefined fixture for the `field-report` template. Loaded via NEWSLETTER_FIXTURE=field-report. Wire-service content shape: tldr + dateline + dispatches[{kicker, headline, byline, location, lede, dispatch, dataPoints, image_prompt}].",
3
+ "subject": "LinkedIn tightens identity — operators document everything",
4
+ "preheader": "Three filings from this week's platform front lines.",
5
+ "summary": "Wire from the platform front: LinkedIn is rolling identity verification across every business profile, YouTube is piloting upload-time attribution metadata, and the operators with clean documentation are outrunning everyone else.",
6
+ "tags": ["linkedin", "youtube", "platform-policy", "verification", "operator-playbook"],
7
+ "tldr": "LinkedIn is rolling out verified-account scrutiny across every business profile. Operators with paper trails will outrun the rest. Documentation is the new growth lever.",
8
+ "dateline": "OAKLAND",
9
+ "signoff": "— Stay sharp,\nThe Somiibo Desk",
10
+ "citations": [
11
+ { "note": "LinkedIn flagged 12,000 impersonation attempts in the verified-profile beta.", "source": "Reported by LinkedIn product team, May 2026" },
12
+ { "note": "Operator playbook adoption up 38% week-over-week in Q2.", "source": "Per platform analytics, May 2026" }
13
+ ],
14
+ "dispatches": [
15
+ {
16
+ "kicker": "LEAD DISPATCH",
17
+ "headline": "LinkedIn ships verified-profile gates for every business account",
18
+ "byline": "Filed by The Somiibo platform desk",
19
+ "location": "OAKLAND",
20
+ "lede": "A wave of identity-verification gates is rolling out across LinkedIn business profiles this week — and the platform is not asking politely.",
21
+ "dispatch": "LinkedIn now requires verified identity attestations on any business profile claiming more than 500 followers. The rollout is fast and uneven: some accounts hit the gate inside two days of profile activity, others sit unchallenged for weeks. The pattern that matters is this — accounts with documented attribution histories sail through verification in under twelve hours. Accounts without get queued. The practical implication is to lock down your attribution log this week, not next.",
22
+ "dataPoints": [
23
+ { "label": "PROFILES FLAGGED", "value": "12.4K" },
24
+ { "label": "AVG REVIEW TIME", "value": "14HR" },
25
+ { "label": "WoW APPROVAL", "value": "+38%" }
26
+ ],
27
+ "image_prompt": "Abstract geometric illustration of a verification checkmark inside a layered profile card."
28
+ },
29
+ {
30
+ "kicker": "FIELD NOTES",
31
+ "headline": "Documentation is the new operator moat",
32
+ "byline": "Filed by The Somiibo growth desk",
33
+ "location": "REMOTE",
34
+ "lede": "The teams surviving this round of platform scrutiny share one habit: they write everything down.",
35
+ "dispatch": "Operator playbooks — once dismissed as overhead — are now the difference between an account that recovers from a flag and one that gets permanently restricted. The teams already running on documented processes recover in days. The ones without burn weeks negotiating with support queues. Treat your playbook as a compliance artifact, not a knowledge-management nice-to-have.",
36
+ "dataPoints": [
37
+ { "label": "RECOVERY (DOCUMENTED)", "value": "4D" },
38
+ { "label": "RECOVERY (UNDOC)", "value": "21D" }
39
+ ],
40
+ "image_prompt": "Stack of geometric document layers casting clean shadows on a flat surface."
41
+ },
42
+ {
43
+ "kicker": "WATCH",
44
+ "headline": "YouTube tests creator-attribution metadata on uploads",
45
+ "byline": "Filed by The Somiibo signals desk",
46
+ "location": "NEW YORK",
47
+ "lede": "A small pilot suggests YouTube will soon ask creators to declare automated tooling and cross-posting workflows at upload time.",
48
+ "dispatch": "A subset of channels saw a new upload checkbox this week asking whether content was assisted by automation. The checkbox is optional today. Reading the room: it will not stay optional. Creators with clear internal workflows already labeled will adapt in an afternoon. Everyone else will spend a quarter retrofitting.",
49
+ "dataPoints": [],
50
+ "image_prompt": "Minimalist play button morphing into a label tag, flat geometric style."
51
+ }
52
+ ]
53
+ }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Test: Marketing lifecycle (add → sync → remove)
3
+ * End-to-end suite testing the full marketing contact flow via routes
4
+ *
5
+ * Requires TEST_EXTENDED_MODE=true and SENDGRID_API_KEY / BEEHIIV_API_KEY env vars.
6
+ * These tests hit real external APIs.
7
+ */
8
+ module.exports = {
9
+ description: 'Marketing lifecycle (add → sync → remove)',
10
+ type: 'suite',
11
+ timeout: 60000,
12
+ skip: !process.env.TEST_EXTENDED_MODE ? 'TEST_EXTENDED_MODE not set' : false,
13
+
14
+ tests: [
15
+ // Step 0: Pre-clean test contacts from providers (in case a previous run left them)
16
+ {
17
+ name: 'pre-clean-test-contacts',
18
+ auth: 'admin',
19
+
20
+ async run({ http, config, state }) {
21
+ const testEmail = `lifecycle.test+bem@${config.domain}`;
22
+ state.testEmail = testEmail;
23
+
24
+ // Delete from all providers — handles "not found" gracefully
25
+ await http.delete('backend-manager/marketing/contact', { email: testEmail }).catch(() => {});
26
+ },
27
+ },
28
+
29
+ // Step 1: Add a contact via POST /marketing/contact
30
+ {
31
+ name: 'add-contact',
32
+ auth: 'admin',
33
+
34
+ async run({ http, assert, state, config }) {
35
+ const response = await http.post('backend-manager/marketing/contact', {
36
+ email: state.testEmail,
37
+ firstName: 'Lifecycle',
38
+ lastName: 'Test',
39
+ source: 'bem-test-lifecycle',
40
+ });
41
+
42
+ assert.isSuccess(response, 'Add contact should succeed');
43
+ assert.propertyEquals(response, 'data.success', true, 'success should be true');
44
+
45
+ if (process.env.SENDGRID_API_KEY) {
46
+ assert.hasProperty(response, 'data.providers.campaigns', 'Should have SendGrid result');
47
+ assert.propertyEquals(response, 'data.providers.campaigns.success', true, 'SendGrid add should succeed');
48
+ }
49
+
50
+ if (process.env.BEEHIIV_API_KEY && config.marketing?.newsletter?.publicationId) {
51
+ assert.hasProperty(response, 'data.providers.newsletter', 'Should have Beehiiv result');
52
+ assert.propertyEquals(response, 'data.providers.newsletter.success', true, 'Beehiiv add should succeed');
53
+ }
54
+ },
55
+ },
56
+
57
+ // Step 2: Sync by UID via PUT /marketing/contact
58
+ // Tests the full sync pipeline: UID resolution → buildFields → upsert with custom fields
59
+ {
60
+ name: 'sync-contact-by-uid',
61
+ auth: 'admin',
62
+
63
+ async run({ http, assert, accounts, config, Manager }) {
64
+ // Dedicated journey account — its _test.allow_* prefix bypasses validation, and the
65
+ // cleanup step's DELETE revokes its doc consent, so it must not be a shared sentinel
66
+ // (consent-granted is used by the signup + consent-lifecycle suites).
67
+ const grantedUid = accounts['journey-marketing-sync'].uid;
68
+ const admin = Manager.libraries.admin;
69
+ await admin.firestore().doc(`users/${grantedUid}`).set({
70
+ consent: { marketing: { status: 'granted' } },
71
+ }, { merge: true });
72
+
73
+ const response = await http.put('backend-manager/marketing/contact', {
74
+ uid: grantedUid,
75
+ });
76
+
77
+ assert.isSuccess(response, 'Sync contact should succeed');
78
+ assert.propertyEquals(response, 'data.success', true, 'success should be true');
79
+
80
+ if (process.env.SENDGRID_API_KEY) {
81
+ assert.hasProperty(response, 'data.providers.campaigns', 'Should have SendGrid result');
82
+ assert.propertyEquals(response, 'data.providers.campaigns.success', true, 'SendGrid sync should succeed');
83
+ }
84
+
85
+ if (process.env.BEEHIIV_API_KEY && config.marketing?.newsletter?.publicationId) {
86
+ assert.hasProperty(response, 'data.providers.newsletter', 'Should have Beehiiv result');
87
+ assert.propertyEquals(response, 'data.providers.newsletter.success', true, 'Beehiiv sync should succeed');
88
+ }
89
+ },
90
+ },
91
+
92
+ // Step 3: Remove the contact via DELETE /marketing/contact
93
+ {
94
+ name: 'remove-contact',
95
+ auth: 'admin',
96
+
97
+ async run({ http, assert, state, config }) {
98
+ const response = await http.delete('backend-manager/marketing/contact', {
99
+ email: state.testEmail,
100
+ });
101
+
102
+ assert.isSuccess(response, 'Remove contact should succeed');
103
+ assert.propertyEquals(response, 'data.success', true, 'success should be true');
104
+
105
+ if (process.env.SENDGRID_API_KEY) {
106
+ assert.hasProperty(response, 'data.providers.campaigns', 'Should have SendGrid result');
107
+ assert.propertyEquals(response, 'data.providers.campaigns.success', true, 'SendGrid remove should succeed');
108
+ }
109
+
110
+ if (process.env.BEEHIIV_API_KEY && config.marketing?.newsletter?.publicationId) {
111
+ assert.hasProperty(response, 'data.providers.newsletter', 'Should have Beehiiv result');
112
+ assert.propertyEquals(response, 'data.providers.newsletter.success', true, 'Beehiiv remove should succeed');
113
+ }
114
+ },
115
+ },
116
+
117
+ // Step 4: Clean up the contact the sync step added to the live providers.
118
+ // DELETE also mirrors revoked consent to the matching user doc — which is why this
119
+ // targets the dedicated journey account (step 2 re-seeds granted before syncing, so
120
+ // the suite is self-healing across runs).
121
+ {
122
+ name: 'cleanup-synced-contact',
123
+ auth: 'admin',
124
+
125
+ async run({ http, accounts }) {
126
+ await http.delete('backend-manager/marketing/contact', {
127
+ email: accounts['journey-marketing-sync'].email,
128
+ }).catch(() => {});
129
+ },
130
+ },
131
+ ],
132
+ };