backend-manager 5.1.2 → 5.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +12 -0
- package/CHANGELOG.md +52 -0
- package/CLAUDE.md +2 -1
- package/README.md +30 -0
- package/docs/common-mistakes.md +1 -0
- package/docs/consent.md +333 -0
- package/docs/marketing-campaigns.md +41 -4
- package/docs/testing.md +81 -0
- package/package.json +1 -1
- package/src/cli/commands/emulator.js +62 -9
- package/src/cli/commands/serve.js +73 -7
- package/src/cli/commands/test.js +65 -1
- package/src/cli/commands/watch.js +15 -3
- package/src/defaults/CLAUDE.md +7 -5
- package/src/manager/events/cron/daily/marketing-newsletter-generate.js +24 -8
- package/src/manager/helpers/user.js +29 -0
- package/src/manager/index.js +111 -5
- package/src/manager/libraries/ai/index.js +21 -0
- package/src/manager/libraries/ai/providers/openai.js +75 -0
- package/src/manager/libraries/email/data/disposable-domains.json +20 -0
- package/src/manager/libraries/email/generators/lib/image-host.js +58 -6
- package/src/manager/libraries/email/generators/lib/markdown-renderer.js +285 -0
- package/src/manager/libraries/email/generators/lib/structure.js +19 -2
- package/src/manager/libraries/email/generators/lib/svg-illustrator.js +12 -3
- package/src/manager/libraries/email/generators/lib/templates/classic-schema.js +9 -13
- package/src/manager/libraries/email/generators/lib/templates/clean.js +1 -1
- package/src/manager/libraries/email/generators/lib/templates/editorial.js +1 -1
- package/src/manager/libraries/email/generators/lib/templates/field-report.js +10 -14
- package/src/manager/libraries/email/generators/newsletter.js +154 -7
- package/src/manager/libraries/email/providers/beehiiv.js +8 -1
- package/src/manager/libraries/payment/processors/stripe.js +12 -0
- package/src/manager/libraries/payment/processors/test.js +8 -1
- package/src/manager/routes/admin/infer-contact/post.js +3 -2
- package/src/manager/routes/admin/post/post.js +3 -3
- package/src/manager/routes/marketing/email-preferences/post.js +165 -37
- package/src/manager/routes/marketing/webhook/forward/post.js +168 -0
- package/src/manager/routes/marketing/webhook/post.js +180 -0
- package/src/manager/routes/marketing/webhook/processors/beehiiv.js +196 -0
- package/src/manager/routes/marketing/webhook/processors/sendgrid.js +171 -0
- package/src/manager/routes/payments/cancel/post.js +2 -2
- package/src/manager/routes/payments/cancel/processors/test.js +5 -2
- package/src/manager/routes/payments/intent/processors/test.js +7 -3
- package/src/manager/routes/payments/refund/processors/test.js +4 -1
- package/src/manager/routes/test/health/get.js +17 -0
- package/src/manager/routes/user/signup/post.js +65 -1
- package/src/manager/schemas/marketing/email-preferences/post.js +8 -4
- package/src/manager/schemas/marketing/webhook/forward/post.js +8 -0
- package/src/manager/schemas/marketing/webhook/post.js +7 -0
- package/src/manager/schemas/payments/cancel/post.js +5 -0
- package/src/manager/schemas/user/signup/post.js +5 -0
- package/src/test/run-tests.js +30 -0
- package/src/test/runner.js +72 -26
- package/src/test/test-accounts.js +94 -12
- package/src/test/utils/http-client.js +4 -3
- package/src/test/utils/test-mode-file.js +192 -0
- package/test/events/payments/journey-payments-cancel-endpoint.js +3 -12
- package/test/events/payments/journey-payments-cancel.js +4 -5
- package/test/events/payments/journey-payments-failure.js +0 -1
- package/test/events/payments/journey-payments-one-time-failure.js +6 -3
- package/test/events/payments/journey-payments-one-time.js +6 -3
- package/test/events/payments/journey-payments-plan-change.js +5 -5
- package/test/events/payments/journey-payments-refund-webhook.js +2 -3
- package/test/events/payments/journey-payments-suspend.js +4 -5
- package/test/events/payments/journey-payments-trial-cancel.js +3 -12
- package/test/events/payments/journey-payments-trial.js +2 -3
- package/test/events/payments/journey-payments-uid-resolution.js +2 -3
- package/test/functions/admin/database-read.js +0 -14
- package/test/functions/admin/database-write.js +0 -14
- package/test/functions/admin/firestore-query.js +0 -14
- package/test/functions/admin/firestore-read.js +0 -15
- package/test/functions/admin/firestore-write.js +0 -11
- package/test/functions/general/add-marketing-contact.js +16 -14
- package/test/helpers/email.js +1 -1
- package/test/helpers/infer-contact.js +3 -3
- package/test/helpers/user.js +241 -2
- package/test/helpers/webhook-forward.js +392 -0
- package/test/marketing/fixtures/clean.json +2 -3
- package/test/marketing/fixtures/editorial.json +2 -3
- package/test/marketing/fixtures/field-report.json +3 -4
- package/test/marketing/newsletter-generate.js +78 -54
- package/test/marketing/newsletter-templates.js +12 -33
- package/test/routes/admin/create-post.js +2 -2
- package/test/routes/admin/database.js +0 -13
- package/test/routes/admin/firestore-query.js +0 -13
- package/test/routes/admin/firestore.js +0 -14
- package/test/routes/admin/infer-contact.js +6 -3
- package/test/routes/admin/post.js +4 -2
- package/test/routes/marketing/contact.js +60 -26
- package/test/routes/marketing/email-preferences.js +145 -69
- package/test/routes/marketing/webhook-forward.js +54 -0
- package/test/routes/marketing/webhook.js +582 -0
- package/test/routes/payments/cancel.js +2 -7
- package/test/routes/payments/dispute-alert.js +0 -39
- package/test/routes/payments/refund.js +3 -1
- package/test/routes/payments/webhook.js +5 -26
- package/test/routes/test/usage.js +2 -2
- package/test/routes/user/signup.js +114 -0
|
@@ -105,9 +105,6 @@ module.exports = {
|
|
|
105
105
|
async run({ http, assert, firestore }) {
|
|
106
106
|
const alertId = '_test-dispute-valid';
|
|
107
107
|
|
|
108
|
-
// Clean up any existing doc
|
|
109
|
-
await firestore.delete(`payments-disputes/${alertId}`);
|
|
110
|
-
|
|
111
108
|
const response = await http.as('none').post(`payments/dispute-alert?key=${process.env.BACKEND_MANAGER_KEY}`, {
|
|
112
109
|
id: alertId,
|
|
113
110
|
card: '4242424242424242',
|
|
@@ -157,9 +154,6 @@ module.exports = {
|
|
|
157
154
|
// Verify raw payload is preserved
|
|
158
155
|
assert.ok(doc.raw, 'Raw payload should be preserved');
|
|
159
156
|
assert.equal(doc.raw.id, alertId, 'Raw id should match');
|
|
160
|
-
|
|
161
|
-
// Clean up
|
|
162
|
-
await firestore.delete(`payments-disputes/${alertId}`);
|
|
163
157
|
},
|
|
164
158
|
},
|
|
165
159
|
|
|
@@ -169,9 +163,6 @@ module.exports = {
|
|
|
169
163
|
async run({ http, assert, firestore }) {
|
|
170
164
|
const alertId = '_test-dispute-alertid-field';
|
|
171
165
|
|
|
172
|
-
// Clean up any existing doc
|
|
173
|
-
await firestore.delete(`payments-disputes/${alertId}`);
|
|
174
|
-
|
|
175
166
|
// Chargeblast alert.created events use alertId instead of id
|
|
176
167
|
const response = await http.as('none').post(`payments/dispute-alert?key=${process.env.BACKEND_MANAGER_KEY}`, {
|
|
177
168
|
alertId: alertId,
|
|
@@ -188,9 +179,6 @@ module.exports = {
|
|
|
188
179
|
assert.equal(doc.id, alertId, 'Doc ID should match alertId');
|
|
189
180
|
assert.equal(doc.alert.id, alertId, 'Alert id should be set from alertId');
|
|
190
181
|
assert.equal(doc.alert.card.last4, '5805', 'Should extract last4 from masked card');
|
|
191
|
-
|
|
192
|
-
// Clean up
|
|
193
|
-
await firestore.delete(`payments-disputes/${alertId}`);
|
|
194
182
|
},
|
|
195
183
|
},
|
|
196
184
|
|
|
@@ -200,9 +188,6 @@ module.exports = {
|
|
|
200
188
|
async run({ http, assert, firestore }) {
|
|
201
189
|
const alertId = '_test-dispute-minimal';
|
|
202
190
|
|
|
203
|
-
// Clean up any existing doc
|
|
204
|
-
await firestore.delete(`payments-disputes/${alertId}`);
|
|
205
|
-
|
|
206
191
|
// Send minimal alert (alert.created shape — no externalOrder, metadata, etc.)
|
|
207
192
|
const response = await http.as('none').post(`payments/dispute-alert?key=${process.env.BACKEND_MANAGER_KEY}`, {
|
|
208
193
|
id: alertId,
|
|
@@ -224,9 +209,6 @@ module.exports = {
|
|
|
224
209
|
assert.equal(doc.alert.reasonCode, null, 'Reason code should be null when not provided');
|
|
225
210
|
assert.equal(doc.alert.subprovider, null, 'Subprovider should be null when not provided');
|
|
226
211
|
assert.equal(doc.alert.isRefunded, false, 'isRefunded should default to false');
|
|
227
|
-
|
|
228
|
-
// Clean up
|
|
229
|
-
await firestore.delete(`payments-disputes/${alertId}`);
|
|
230
212
|
},
|
|
231
213
|
},
|
|
232
214
|
|
|
@@ -236,9 +218,6 @@ module.exports = {
|
|
|
236
218
|
async run({ http, assert, firestore }) {
|
|
237
219
|
const alertId = '_test-dispute-last4';
|
|
238
220
|
|
|
239
|
-
// Clean up any existing doc
|
|
240
|
-
await firestore.delete(`payments-disputes/${alertId}`);
|
|
241
|
-
|
|
242
221
|
const response = await http.as('none').post(`payments/dispute-alert?key=${process.env.BACKEND_MANAGER_KEY}`, {
|
|
243
222
|
id: alertId,
|
|
244
223
|
card: '1234',
|
|
@@ -251,9 +230,6 @@ module.exports = {
|
|
|
251
230
|
const doc = await firestore.get(`payments-disputes/${alertId}`);
|
|
252
231
|
assert.equal(doc.alert.card.last4, '1234', 'Should use card value as last4 when already 4 digits');
|
|
253
232
|
assert.equal(doc.alert.processor, 'stripe', 'Processor should default to stripe');
|
|
254
|
-
|
|
255
|
-
// Clean up
|
|
256
|
-
await firestore.delete(`payments-disputes/${alertId}`);
|
|
257
233
|
},
|
|
258
234
|
},
|
|
259
235
|
|
|
@@ -263,9 +239,6 @@ module.exports = {
|
|
|
263
239
|
async run({ http, assert, firestore }) {
|
|
264
240
|
const alertId = '_test-dispute-duplicate';
|
|
265
241
|
|
|
266
|
-
// Clean up any existing doc
|
|
267
|
-
await firestore.delete(`payments-disputes/${alertId}`);
|
|
268
|
-
|
|
269
242
|
// Send first alert
|
|
270
243
|
await http.as('none').post(`payments/dispute-alert?key=${process.env.BACKEND_MANAGER_KEY}`, {
|
|
271
244
|
id: alertId,
|
|
@@ -284,9 +257,6 @@ module.exports = {
|
|
|
284
257
|
|
|
285
258
|
assert.isSuccess(response, 'Duplicate should still return 200');
|
|
286
259
|
assert.equal(response.data.duplicate, true, 'Should indicate duplicate');
|
|
287
|
-
|
|
288
|
-
// Clean up
|
|
289
|
-
await firestore.delete(`payments-disputes/${alertId}`);
|
|
290
260
|
},
|
|
291
261
|
},
|
|
292
262
|
|
|
@@ -320,9 +290,6 @@ module.exports = {
|
|
|
320
290
|
doc.status === 'pending' || doc.status === 'processing',
|
|
321
291
|
'Status should be pending or processing after retry',
|
|
322
292
|
);
|
|
323
|
-
|
|
324
|
-
// Clean up
|
|
325
|
-
await firestore.delete(`payments-disputes/${alertId}`);
|
|
326
293
|
},
|
|
327
294
|
},
|
|
328
295
|
|
|
@@ -332,9 +299,6 @@ module.exports = {
|
|
|
332
299
|
async run({ http, assert, firestore }) {
|
|
333
300
|
const alertId = '_test-dispute-default-provider';
|
|
334
301
|
|
|
335
|
-
// Clean up any existing doc
|
|
336
|
-
await firestore.delete(`payments-disputes/${alertId}`);
|
|
337
|
-
|
|
338
302
|
// Send without provider query param
|
|
339
303
|
const response = await http.as('none').post(`payments/dispute-alert?key=${process.env.BACKEND_MANAGER_KEY}`, {
|
|
340
304
|
id: alertId,
|
|
@@ -347,9 +311,6 @@ module.exports = {
|
|
|
347
311
|
|
|
348
312
|
const doc = await firestore.get(`payments-disputes/${alertId}`);
|
|
349
313
|
assert.equal(doc.provider, 'chargeblast', 'Provider should default to chargeblast');
|
|
350
|
-
|
|
351
|
-
// Clean up
|
|
352
|
-
await firestore.delete(`payments-disputes/${alertId}`);
|
|
353
314
|
},
|
|
354
315
|
},
|
|
355
316
|
],
|
|
@@ -133,10 +133,12 @@ module.exports = {
|
|
|
133
133
|
&& userDoc?.subscription?.status === 'active';
|
|
134
134
|
}, 15000, 500);
|
|
135
135
|
|
|
136
|
-
// Step 2: Cancel the subscription first (refund requires cancellation)
|
|
136
|
+
// Step 2: Cancel the subscription first (refund requires cancellation).
|
|
137
|
+
// skipGuards bypasses the 24-hour subscription-age guard on the cancel route.
|
|
137
138
|
const cancelResponse = await http.as('route-refund-success').post('payments/cancel', {
|
|
138
139
|
confirmed: true,
|
|
139
140
|
reason: 'Too expensive',
|
|
141
|
+
skipGuards: true,
|
|
140
142
|
});
|
|
141
143
|
|
|
142
144
|
assert.isSuccess(cancelResponse, 'Cancel should succeed');
|
|
@@ -50,9 +50,6 @@ module.exports = {
|
|
|
50
50
|
async run({ http, assert, firestore }) {
|
|
51
51
|
const eventId = '_test-evt-valid-webhook';
|
|
52
52
|
|
|
53
|
-
// Clean up any existing doc
|
|
54
|
-
await firestore.delete(`payments-webhooks/${eventId}`);
|
|
55
|
-
|
|
56
53
|
const response = await http.as('none').post(`payments/webhook?processor=stripe&key=${process.env.BACKEND_MANAGER_KEY}`, {
|
|
57
54
|
id: eventId,
|
|
58
55
|
type: 'customer.subscription.updated',
|
|
@@ -76,9 +73,6 @@ module.exports = {
|
|
|
76
73
|
doc.status === 'pending' || doc.status === 'processing' || doc.status === 'completed' || doc.status === 'failed',
|
|
77
74
|
'Status should be pending, processing, completed, or failed',
|
|
78
75
|
);
|
|
79
|
-
|
|
80
|
-
// Clean up
|
|
81
|
-
await firestore.delete(`payments-webhooks/${eventId}`);
|
|
82
76
|
},
|
|
83
77
|
},
|
|
84
78
|
|
|
@@ -88,11 +82,9 @@ module.exports = {
|
|
|
88
82
|
async run({ http, assert, firestore }) {
|
|
89
83
|
const eventId = '_test-evt-duplicate';
|
|
90
84
|
|
|
91
|
-
//
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
// Send first webhook
|
|
95
|
-
await http.as('none').post(`payments/webhook?processor=stripe&key=${process.env.BACKEND_MANAGER_KEY}`, {
|
|
85
|
+
// Use the test processor so the on-write trigger doesn't require STRIPE_SECRET_KEY
|
|
86
|
+
// (a failed first webhook would let the dedup-retry branch fire instead of returning duplicate=true)
|
|
87
|
+
const send = () => http.as('none').post(`payments/webhook?processor=test&key=${process.env.BACKEND_MANAGER_KEY}`, {
|
|
96
88
|
id: eventId,
|
|
97
89
|
type: 'customer.subscription.updated',
|
|
98
90
|
data: {
|
|
@@ -104,24 +96,11 @@ module.exports = {
|
|
|
104
96
|
},
|
|
105
97
|
});
|
|
106
98
|
|
|
107
|
-
|
|
108
|
-
const response = await
|
|
109
|
-
id: eventId,
|
|
110
|
-
type: 'customer.subscription.updated',
|
|
111
|
-
data: {
|
|
112
|
-
object: {
|
|
113
|
-
id: 'sub_test_dup',
|
|
114
|
-
metadata: { uid: TEST_ACCOUNTS.basic.uid },
|
|
115
|
-
status: 'active',
|
|
116
|
-
},
|
|
117
|
-
},
|
|
118
|
-
});
|
|
99
|
+
await send();
|
|
100
|
+
const response = await send();
|
|
119
101
|
|
|
120
102
|
assert.isSuccess(response, 'Duplicate should still return 200');
|
|
121
103
|
assert.equal(response.data.duplicate, true, 'Should indicate duplicate');
|
|
122
|
-
|
|
123
|
-
// Clean up
|
|
124
|
-
await firestore.delete(`payments-webhooks/${eventId}`);
|
|
125
104
|
},
|
|
126
105
|
},
|
|
127
106
|
],
|
|
@@ -258,12 +258,12 @@ module.exports = {
|
|
|
258
258
|
const doc = await firestore.get(`users/${accounts.basic.uid}`);
|
|
259
259
|
return doc?.usage?.requests?.daily === 0;
|
|
260
260
|
},
|
|
261
|
-
|
|
261
|
+
15000,
|
|
262
262
|
500
|
|
263
263
|
);
|
|
264
264
|
assert.ok(true, 'Daily counter was reset to 0 by cron');
|
|
265
265
|
} catch (error) {
|
|
266
|
-
assert.fail('Daily counter should be reset to 0 within
|
|
266
|
+
assert.fail('Daily counter should be reset to 0 within 15s');
|
|
267
267
|
}
|
|
268
268
|
},
|
|
269
269
|
},
|
|
@@ -234,6 +234,120 @@ module.exports = {
|
|
|
234
234
|
},
|
|
235
235
|
},
|
|
236
236
|
|
|
237
|
+
// --- Consent capture tests ---
|
|
238
|
+
{
|
|
239
|
+
name: 'consent-granted-both-records-canonical-shape',
|
|
240
|
+
async run({ http, firestore, assert, accounts }) {
|
|
241
|
+
const consentText = {
|
|
242
|
+
legal: 'I agree to the Terms of Service and Privacy Policy.',
|
|
243
|
+
marketing: 'Send me product updates and newsletters. You can unsubscribe anytime.',
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
// Use absurdly-old client timestamp to prove server time wins (defense vs clock skew)
|
|
247
|
+
const beforeMs = Date.now();
|
|
248
|
+
|
|
249
|
+
const signupResponse = await http.as('consent-granted').post('user/signup', {
|
|
250
|
+
consent: {
|
|
251
|
+
legal: { granted: true, text: consentText.legal, timestamp: '2000-01-01T00:00:00.000Z' },
|
|
252
|
+
marketing: { granted: true, text: consentText.marketing, timestamp: '2000-01-01T00:00:00.000Z' },
|
|
253
|
+
},
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
assert.isSuccess(signupResponse, `Signup should succeed: ${JSON.stringify(signupResponse, null, 2)}`);
|
|
257
|
+
|
|
258
|
+
const afterMs = Date.now();
|
|
259
|
+
const userDoc = await firestore.get(`users/${accounts['consent-granted'].uid}`);
|
|
260
|
+
|
|
261
|
+
// Legal
|
|
262
|
+
assert.equal(userDoc?.consent?.legal?.status, 'granted', 'consent.legal.status should be granted');
|
|
263
|
+
assert.equal(userDoc?.consent?.legal?.grantedAt?.source, 'signup', 'legal grantedAt.source should be signup-form');
|
|
264
|
+
assert.equal(userDoc?.consent?.legal?.grantedAt?.text, consentText.legal, 'legal grantedAt.text should match client payload');
|
|
265
|
+
assert.ok(userDoc?.consent?.legal?.grantedAt?.timestamp, 'legal grantedAt.timestamp should be set');
|
|
266
|
+
assert.equal(typeof userDoc?.consent?.legal?.grantedAt?.timestampUNIX, 'number', 'legal grantedAt.timestampUNIX should be number');
|
|
267
|
+
|
|
268
|
+
// Server time MUST be used (the client-supplied 2000-01-01 should NOT appear)
|
|
269
|
+
const legalUNIX = userDoc.consent.legal.grantedAt.timestampUNIX;
|
|
270
|
+
const beforeUNIX = Math.floor(beforeMs / 1000);
|
|
271
|
+
const afterUNIX = Math.floor(afterMs / 1000);
|
|
272
|
+
assert.ok(
|
|
273
|
+
legalUNIX >= beforeUNIX && legalUNIX <= afterUNIX,
|
|
274
|
+
`legal grantedAt.timestampUNIX (${legalUNIX}) must be server time, not client time. expected between ${beforeUNIX} and ${afterUNIX}`
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
// Marketing
|
|
278
|
+
assert.equal(userDoc?.consent?.marketing?.status, 'granted', 'consent.marketing.status should be granted');
|
|
279
|
+
assert.equal(userDoc?.consent?.marketing?.grantedAt?.source, 'signup', 'marketing grantedAt.source should be signup-form');
|
|
280
|
+
assert.equal(userDoc?.consent?.marketing?.grantedAt?.text, consentText.marketing, 'marketing grantedAt.text should match client payload');
|
|
281
|
+
assert.equal(typeof userDoc?.consent?.marketing?.grantedAt?.timestampUNIX, 'number', 'marketing grantedAt.timestampUNIX should be number');
|
|
282
|
+
|
|
283
|
+
// revokedAt should be all-null sibling object (NOT undefined, NOT missing)
|
|
284
|
+
assert.ok(userDoc?.consent?.marketing?.revokedAt, 'marketing.revokedAt object should exist (not null)');
|
|
285
|
+
assert.equal(userDoc?.consent?.marketing?.revokedAt?.timestamp, null, 'marketing revokedAt.timestamp should be null');
|
|
286
|
+
assert.equal(userDoc?.consent?.marketing?.revokedAt?.source, null, 'marketing revokedAt.source should be null');
|
|
287
|
+
},
|
|
288
|
+
},
|
|
289
|
+
|
|
290
|
+
{
|
|
291
|
+
name: 'consent-marketing-declined-records-revokedAt',
|
|
292
|
+
async run({ http, firestore, assert, accounts }) {
|
|
293
|
+
const legalText = 'I agree to the Terms of Service and Privacy Policy.';
|
|
294
|
+
|
|
295
|
+
const signupResponse = await http.as('consent-declined').post('user/signup', {
|
|
296
|
+
consent: {
|
|
297
|
+
legal: { granted: true, text: legalText },
|
|
298
|
+
marketing: { granted: false, text: 'Send me updates.' },
|
|
299
|
+
},
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
assert.isSuccess(signupResponse, `Signup should succeed: ${JSON.stringify(signupResponse, null, 2)}`);
|
|
303
|
+
|
|
304
|
+
const userDoc = await firestore.get(`users/${accounts['consent-declined'].uid}`);
|
|
305
|
+
|
|
306
|
+
// Legal — granted normally
|
|
307
|
+
assert.equal(userDoc?.consent?.legal?.status, 'granted', 'legal.status should be granted');
|
|
308
|
+
assert.equal(userDoc?.consent?.legal?.grantedAt?.source, 'signup', 'legal grantedAt.source should be signup-form');
|
|
309
|
+
|
|
310
|
+
// Marketing — revoked at signup
|
|
311
|
+
assert.equal(userDoc?.consent?.marketing?.status, 'revoked', 'marketing.status should be revoked');
|
|
312
|
+
|
|
313
|
+
// grantedAt MUST be all-null (never granted, even though client passed text)
|
|
314
|
+
assert.equal(userDoc?.consent?.marketing?.grantedAt?.timestamp, null, 'marketing grantedAt.timestamp should be null');
|
|
315
|
+
assert.equal(userDoc?.consent?.marketing?.grantedAt?.source, null, 'marketing grantedAt.source should be null');
|
|
316
|
+
assert.equal(userDoc?.consent?.marketing?.grantedAt?.text, null, 'marketing grantedAt.text should be null (declined)');
|
|
317
|
+
|
|
318
|
+
// revokedAt MUST have signup-form-declined source + server time
|
|
319
|
+
assert.equal(userDoc?.consent?.marketing?.revokedAt?.source, 'signup', 'marketing revokedAt.source should be signup-form-declined');
|
|
320
|
+
assert.ok(userDoc?.consent?.marketing?.revokedAt?.timestamp, 'marketing revokedAt.timestamp should be set');
|
|
321
|
+
assert.equal(typeof userDoc?.consent?.marketing?.revokedAt?.timestampUNIX, 'number', 'marketing revokedAt.timestampUNIX should be number');
|
|
322
|
+
assert.equal(userDoc?.consent?.marketing?.revokedAt?.text, null, 'marketing revokedAt.text should be null (decline has no message)');
|
|
323
|
+
},
|
|
324
|
+
},
|
|
325
|
+
|
|
326
|
+
{
|
|
327
|
+
name: 'consent-missing-defaults-to-revoked',
|
|
328
|
+
async run({ http, firestore, assert, accounts }) {
|
|
329
|
+
// Client sends NO consent field at all (legacy or malformed payload).
|
|
330
|
+
// Expected: both legal + marketing default to revoked. No crash, no marketing sync.
|
|
331
|
+
const signupResponse = await http.as('consent-missing').post('user/signup', {});
|
|
332
|
+
|
|
333
|
+
assert.isSuccess(signupResponse, `Signup should succeed even with no consent: ${JSON.stringify(signupResponse, null, 2)}`);
|
|
334
|
+
|
|
335
|
+
const userDoc = await firestore.get(`users/${accounts['consent-missing'].uid}`);
|
|
336
|
+
|
|
337
|
+
assert.ok(userDoc?.consent, 'consent object should exist');
|
|
338
|
+
assert.equal(userDoc?.consent?.legal?.status, 'revoked', 'legal.status should default to revoked');
|
|
339
|
+
assert.equal(userDoc?.consent?.legal?.grantedAt?.timestamp, null, 'legal grantedAt.timestamp should be null');
|
|
340
|
+
assert.equal(userDoc?.consent?.legal?.grantedAt?.source, null, 'legal grantedAt.source should be null');
|
|
341
|
+
|
|
342
|
+
assert.equal(userDoc?.consent?.marketing?.status, 'revoked', 'marketing.status should default to revoked');
|
|
343
|
+
assert.equal(userDoc?.consent?.marketing?.grantedAt?.timestamp, null, 'marketing grantedAt.timestamp should be null');
|
|
344
|
+
|
|
345
|
+
// Even when consent is missing entirely, the revokedAt block gets stamped with signup-form-declined.
|
|
346
|
+
// This ensures the user doc has a recorded decline event for audit.
|
|
347
|
+
assert.equal(userDoc?.consent?.marketing?.revokedAt?.source, 'signup', 'marketing revokedAt.source should be signup-form-declined when consent missing');
|
|
348
|
+
},
|
|
349
|
+
},
|
|
350
|
+
|
|
237
351
|
// --- Auth rejection test (at end per convention) ---
|
|
238
352
|
{
|
|
239
353
|
name: 'unauthenticated-rejected',
|