backend-manager 5.1.4 → 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 +23 -0
- package/CLAUDE.md +2 -1
- package/README.md +15 -0
- package/docs/common-mistakes.md +1 -0
- package/docs/consent.md +333 -0
- package/docs/testing.md +36 -0
- package/package.json +1 -1
- package/src/cli/commands/emulator.js +44 -8
- package/src/cli/commands/serve.js +73 -7
- package/src/cli/commands/test.js +47 -1
- package/src/cli/commands/watch.js +15 -3
- package/src/manager/helpers/user.js +29 -0
- package/src/manager/index.js +29 -0
- package/src/manager/libraries/email/data/disposable-domains.json +8 -0
- package/src/manager/libraries/email/generators/newsletter.js +2 -2
- package/src/manager/libraries/email/providers/beehiiv.js +1 -0
- 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/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/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/runner.js +61 -18
- package/src/test/test-accounts.js +94 -12
- package/src/test/utils/http-client.js +4 -3
- 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/newsletter-generate.js +17 -7
- 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
|
@@ -203,19 +203,5 @@ module.exports = {
|
|
|
203
203
|
},
|
|
204
204
|
},
|
|
205
205
|
|
|
206
|
-
// Test 10: Cleanup
|
|
207
|
-
{
|
|
208
|
-
name: 'cleanup',
|
|
209
|
-
async run({ firestore }) {
|
|
210
|
-
// Clean up test documents
|
|
211
|
-
try {
|
|
212
|
-
await firestore.delete(`${TEST_COLLECTION}/doc1`);
|
|
213
|
-
await firestore.delete(`${TEST_COLLECTION}/doc2`);
|
|
214
|
-
await firestore.delete(`${TEST_COLLECTION}/doc3`);
|
|
215
|
-
} catch (error) {
|
|
216
|
-
// Ignore cleanup errors
|
|
217
|
-
}
|
|
218
|
-
},
|
|
219
|
-
},
|
|
220
206
|
],
|
|
221
207
|
};
|
|
@@ -125,20 +125,5 @@ module.exports = {
|
|
|
125
125
|
},
|
|
126
126
|
},
|
|
127
127
|
|
|
128
|
-
// Test 7: Cleanup
|
|
129
|
-
{
|
|
130
|
-
name: 'cleanup',
|
|
131
|
-
auth: 'admin',
|
|
132
|
-
timeout: 15000,
|
|
133
|
-
|
|
134
|
-
async run({ firestore }) {
|
|
135
|
-
// Clean up test document
|
|
136
|
-
try {
|
|
137
|
-
await firestore.delete(TEST_PATH);
|
|
138
|
-
} catch (error) {
|
|
139
|
-
// Ignore cleanup errors
|
|
140
|
-
}
|
|
141
|
-
},
|
|
142
|
-
},
|
|
143
128
|
],
|
|
144
129
|
};
|
|
@@ -64,17 +64,6 @@ module.exports = {
|
|
|
64
64
|
|
|
65
65
|
return { success: true };
|
|
66
66
|
},
|
|
67
|
-
|
|
68
|
-
async cleanup({ firestore }) {
|
|
69
|
-
// Clean up test documents using direct Firestore access if available
|
|
70
|
-
if (firestore) {
|
|
71
|
-
try {
|
|
72
|
-
await firestore.delete(TEST_PATH);
|
|
73
|
-
} catch (error) {
|
|
74
|
-
// Ignore cleanup errors
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
},
|
|
78
67
|
},
|
|
79
68
|
|
|
80
69
|
// Test 2: Unauthenticated should fail
|
|
@@ -6,11 +6,13 @@
|
|
|
6
6
|
* (requires SENDGRID_API_KEY and BEEHIIV_API_KEY env vars)
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
// Test email patterns - look like real emails but +bem suffix identifies them for cleanup
|
|
10
|
-
//
|
|
9
|
+
// Test email patterns - look like real emails but +bem suffix identifies them for cleanup.
|
|
10
|
+
// Fixed `acme.com` test domain — deterministic across brands. Avoids cross-brand
|
|
11
|
+
// state contamination in SendGrid/Beehiiv when the same test runs under different brands.
|
|
12
|
+
const TEST_DOMAIN = 'acme.com';
|
|
11
13
|
const TEST_EMAILS = {
|
|
12
|
-
valid: (
|
|
13
|
-
invalid: () => `
|
|
14
|
+
valid: () => `sarah.martinez+bem@${TEST_DOMAIN}`, // Should infer: Sarah Martinez
|
|
15
|
+
invalid: () => `nonexistent.user+bem@${TEST_DOMAIN}`, // No such mailbox — ZeroBounce should flag invalid
|
|
14
16
|
};
|
|
15
17
|
|
|
16
18
|
module.exports = {
|
|
@@ -23,8 +25,8 @@ module.exports = {
|
|
|
23
25
|
auth: 'admin',
|
|
24
26
|
timeout: 30000,
|
|
25
27
|
|
|
26
|
-
async run({ http, assert,
|
|
27
|
-
const testEmail = TEST_EMAILS.valid(
|
|
28
|
+
async run({ http, assert, state }) {
|
|
29
|
+
const testEmail = TEST_EMAILS.valid();
|
|
28
30
|
state.testEmail = testEmail;
|
|
29
31
|
|
|
30
32
|
const response = await http.command('general:add-marketing-contact', {
|
|
@@ -135,9 +137,9 @@ module.exports = {
|
|
|
135
137
|
auth: 'admin',
|
|
136
138
|
timeout: 30000,
|
|
137
139
|
|
|
138
|
-
async run({ http, assert,
|
|
140
|
+
async run({ http, assert, state }) {
|
|
139
141
|
// Use valid email without providing name - should infer "Rachel Greene"
|
|
140
|
-
const testEmail = TEST_EMAILS.valid(
|
|
142
|
+
const testEmail = TEST_EMAILS.valid();
|
|
141
143
|
state.testEmail = testEmail;
|
|
142
144
|
|
|
143
145
|
const response = await http.command('general:add-marketing-contact', {
|
|
@@ -216,8 +218,8 @@ module.exports = {
|
|
|
216
218
|
? 'TEST_EXTENDED_MODE or ZEROBOUNCE_API_KEY not set'
|
|
217
219
|
: false,
|
|
218
220
|
|
|
219
|
-
async run({ http, assert,
|
|
220
|
-
const testEmail = TEST_EMAILS.valid(
|
|
221
|
+
async run({ http, assert, state, skip }) {
|
|
222
|
+
const testEmail = TEST_EMAILS.valid();
|
|
221
223
|
state.testEmail = testEmail;
|
|
222
224
|
|
|
223
225
|
const response = await http.command('general:add-marketing-contact', {
|
|
@@ -299,10 +301,10 @@ module.exports = {
|
|
|
299
301
|
auth: 'none',
|
|
300
302
|
timeout: 15000,
|
|
301
303
|
|
|
302
|
-
async run({ http, assert
|
|
304
|
+
async run({ http, assert }) {
|
|
303
305
|
// Public request without reCAPTCHA should fail
|
|
304
306
|
const response = await http.command('general:add-marketing-contact', {
|
|
305
|
-
email: TEST_EMAILS.valid(
|
|
307
|
+
email: TEST_EMAILS.valid(),
|
|
306
308
|
source: 'bem-test',
|
|
307
309
|
});
|
|
308
310
|
|
|
@@ -318,9 +320,9 @@ module.exports = {
|
|
|
318
320
|
timeout: 30000,
|
|
319
321
|
skip: !process.env.TEST_EXTENDED_MODE ? 'TEST_EXTENDED_MODE not set' : false,
|
|
320
322
|
|
|
321
|
-
async run({ http, assert
|
|
323
|
+
async run({ http, assert }) {
|
|
322
324
|
// Clean up the rachel.greene+bem test contact from marketing providers
|
|
323
|
-
const testEmail = TEST_EMAILS.valid(
|
|
325
|
+
const testEmail = TEST_EMAILS.valid();
|
|
324
326
|
|
|
325
327
|
const response = await http.command('general:remove-marketing-contact', {
|
|
326
328
|
email: testEmail,
|
package/test/helpers/email.js
CHANGED
|
@@ -64,7 +64,7 @@ module.exports = {
|
|
|
64
64
|
|
|
65
65
|
assert.isSuccess(response, 'Should succeed with default template');
|
|
66
66
|
assert.equal(response.data.status, 'sent', 'Status should be sent');
|
|
67
|
-
assert.equal(response.data.options.templateId, 'd-
|
|
67
|
+
assert.equal(response.data.options.templateId, 'd-1cd2eee44b6340268c964cd7971d49b9', 'Should use default template (core/card)');
|
|
68
68
|
},
|
|
69
69
|
},
|
|
70
70
|
|
|
@@ -95,9 +95,9 @@ module.exports = {
|
|
|
95
95
|
timeout: 30000,
|
|
96
96
|
|
|
97
97
|
async run({ assert, Manager }) {
|
|
98
|
-
//
|
|
99
|
-
if (!process.env.OPENAI_API_KEY) {
|
|
100
|
-
return assert.fail('
|
|
98
|
+
// The library reads BACKEND_MANAGER_OPENAI_API_KEY; OPENAI_API_KEY is also accepted as a fallback.
|
|
99
|
+
if (!process.env.BACKEND_MANAGER_OPENAI_API_KEY && !process.env.OPENAI_API_KEY) {
|
|
100
|
+
return assert.fail('BACKEND_MANAGER_OPENAI_API_KEY not set');
|
|
101
101
|
}
|
|
102
102
|
|
|
103
103
|
const assistant = Manager.Assistant();
|
package/test/helpers/user.js
CHANGED
|
@@ -406,7 +406,7 @@ module.exports = {
|
|
|
406
406
|
const user = createUser({});
|
|
407
407
|
const expectedKeys = [
|
|
408
408
|
'auth', 'subscription', 'roles', 'flags', 'affiliate',
|
|
409
|
-
'activity', 'api', 'usage', 'personal', 'oauth2', 'attribution', 'metadata',
|
|
409
|
+
'activity', 'api', 'usage', 'personal', 'oauth2', 'attribution', 'consent', 'metadata',
|
|
410
410
|
];
|
|
411
411
|
|
|
412
412
|
for (const key of expectedKeys) {
|
|
@@ -421,7 +421,7 @@ module.exports = {
|
|
|
421
421
|
const user = createUser({});
|
|
422
422
|
const expectedKeys = [
|
|
423
423
|
'auth', 'subscription', 'roles', 'flags', 'affiliate',
|
|
424
|
-
'activity', 'api', 'usage', 'personal', 'oauth2', 'attribution', 'metadata',
|
|
424
|
+
'activity', 'api', 'usage', 'personal', 'oauth2', 'attribution', 'consent', 'metadata',
|
|
425
425
|
];
|
|
426
426
|
|
|
427
427
|
for (const key of Object.keys(user)) {
|
|
@@ -430,6 +430,245 @@ module.exports = {
|
|
|
430
430
|
},
|
|
431
431
|
},
|
|
432
432
|
|
|
433
|
+
// ─── Consent (legal + marketing) ───
|
|
434
|
+
|
|
435
|
+
{
|
|
436
|
+
name: 'consent-defaults-revoked-with-null-leaves',
|
|
437
|
+
async run({ assert }) {
|
|
438
|
+
const user = createUser({});
|
|
439
|
+
|
|
440
|
+
// Legal
|
|
441
|
+
assert.equal(user.consent.legal.status, 'revoked', 'consent.legal.status defaults to revoked');
|
|
442
|
+
assert.equal(user.consent.legal.grantedAt.timestamp, null, 'legal.grantedAt.timestamp defaults to null');
|
|
443
|
+
assert.equal(user.consent.legal.grantedAt.timestampUNIX, null, 'legal.grantedAt.timestampUNIX defaults to null');
|
|
444
|
+
assert.equal(user.consent.legal.grantedAt.source, null, 'legal.grantedAt.source defaults to null');
|
|
445
|
+
assert.equal(user.consent.legal.grantedAt.ip, null, 'legal.grantedAt.ip defaults to null');
|
|
446
|
+
assert.equal(user.consent.legal.grantedAt.text, null, 'legal.grantedAt.text defaults to null');
|
|
447
|
+
|
|
448
|
+
// Marketing
|
|
449
|
+
assert.equal(user.consent.marketing.status, 'revoked', 'consent.marketing.status defaults to revoked');
|
|
450
|
+
assert.equal(user.consent.marketing.grantedAt.timestamp, null, 'marketing.grantedAt.timestamp defaults to null');
|
|
451
|
+
assert.equal(user.consent.marketing.grantedAt.timestampUNIX, null, 'marketing.grantedAt.timestampUNIX defaults to null');
|
|
452
|
+
assert.equal(user.consent.marketing.grantedAt.source, null, 'marketing.grantedAt.source defaults to null');
|
|
453
|
+
assert.equal(user.consent.marketing.grantedAt.ip, null, 'marketing.grantedAt.ip defaults to null');
|
|
454
|
+
assert.equal(user.consent.marketing.grantedAt.text, null, 'marketing.grantedAt.text defaults to null');
|
|
455
|
+
assert.equal(user.consent.marketing.revokedAt.timestamp, null, 'marketing.revokedAt.timestamp defaults to null');
|
|
456
|
+
assert.equal(user.consent.marketing.revokedAt.timestampUNIX, null, 'marketing.revokedAt.timestampUNIX defaults to null');
|
|
457
|
+
assert.equal(user.consent.marketing.revokedAt.source, null, 'marketing.revokedAt.source defaults to null');
|
|
458
|
+
assert.equal(user.consent.marketing.revokedAt.ip, null, 'marketing.revokedAt.ip defaults to null');
|
|
459
|
+
assert.equal(user.consent.marketing.revokedAt.text, null, 'marketing.revokedAt.text defaults to null');
|
|
460
|
+
},
|
|
461
|
+
},
|
|
462
|
+
|
|
463
|
+
{
|
|
464
|
+
name: 'consent-objects-always-present-even-on-empty-input',
|
|
465
|
+
async run({ assert }) {
|
|
466
|
+
const user = createUser({});
|
|
467
|
+
|
|
468
|
+
assert.ok(user.consent, 'consent object exists');
|
|
469
|
+
assert.ok(user.consent.legal, 'consent.legal exists');
|
|
470
|
+
assert.ok(user.consent.legal.grantedAt, 'consent.legal.grantedAt object exists (not null)');
|
|
471
|
+
assert.ok(user.consent.marketing, 'consent.marketing exists');
|
|
472
|
+
assert.ok(user.consent.marketing.grantedAt, 'consent.marketing.grantedAt object exists (not null)');
|
|
473
|
+
assert.ok(user.consent.marketing.revokedAt, 'consent.marketing.revokedAt object exists (not null)');
|
|
474
|
+
},
|
|
475
|
+
},
|
|
476
|
+
|
|
477
|
+
{
|
|
478
|
+
name: 'consent-granted-marketing-preserves-fields',
|
|
479
|
+
async run({ assert }) {
|
|
480
|
+
const user = createUser({
|
|
481
|
+
consent: {
|
|
482
|
+
legal: {
|
|
483
|
+
status: 'granted',
|
|
484
|
+
grantedAt: {
|
|
485
|
+
timestamp: '2026-05-15T12:00:00.000Z',
|
|
486
|
+
timestampUNIX: 1779235200,
|
|
487
|
+
source: 'signup',
|
|
488
|
+
ip: '1.2.3.4',
|
|
489
|
+
text: 'I agree to the Terms of Service and Privacy Policy.',
|
|
490
|
+
},
|
|
491
|
+
},
|
|
492
|
+
marketing: {
|
|
493
|
+
status: 'granted',
|
|
494
|
+
grantedAt: {
|
|
495
|
+
timestamp: '2026-05-15T12:00:00.000Z',
|
|
496
|
+
timestampUNIX: 1779235200,
|
|
497
|
+
source: 'signup',
|
|
498
|
+
ip: '1.2.3.4',
|
|
499
|
+
text: 'Send me product updates and newsletters.',
|
|
500
|
+
},
|
|
501
|
+
},
|
|
502
|
+
},
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
assert.equal(user.consent.legal.status, 'granted', 'legal.status preserved');
|
|
506
|
+
assert.equal(user.consent.legal.grantedAt.timestamp, '2026-05-15T12:00:00.000Z', 'legal grantedAt.timestamp preserved');
|
|
507
|
+
assert.equal(user.consent.legal.grantedAt.timestampUNIX, 1779235200, 'legal grantedAt.timestampUNIX preserved');
|
|
508
|
+
assert.equal(user.consent.legal.grantedAt.source, 'signup', 'legal grantedAt.source preserved');
|
|
509
|
+
assert.equal(user.consent.legal.grantedAt.ip, '1.2.3.4', 'legal grantedAt.ip preserved');
|
|
510
|
+
assert.equal(
|
|
511
|
+
user.consent.legal.grantedAt.text,
|
|
512
|
+
'I agree to the Terms of Service and Privacy Policy.',
|
|
513
|
+
'legal grantedAt.text preserved'
|
|
514
|
+
);
|
|
515
|
+
|
|
516
|
+
assert.equal(user.consent.marketing.status, 'granted', 'marketing.status preserved');
|
|
517
|
+
assert.equal(user.consent.marketing.grantedAt.source, 'signup', 'marketing grantedAt.source preserved');
|
|
518
|
+
assert.equal(user.consent.marketing.grantedAt.text, 'Send me product updates and newsletters.', 'marketing grantedAt.text preserved');
|
|
519
|
+
|
|
520
|
+
// revokedAt still defaults to all nulls (not touched by input)
|
|
521
|
+
assert.equal(user.consent.marketing.revokedAt.timestamp, null, 'marketing revokedAt.timestamp defaults to null');
|
|
522
|
+
assert.equal(user.consent.marketing.revokedAt.source, null, 'marketing revokedAt.source defaults to null');
|
|
523
|
+
},
|
|
524
|
+
},
|
|
525
|
+
|
|
526
|
+
{
|
|
527
|
+
name: 'consent-marketing-declined-at-signup',
|
|
528
|
+
async run({ assert }) {
|
|
529
|
+
const user = createUser({
|
|
530
|
+
consent: {
|
|
531
|
+
legal: {
|
|
532
|
+
status: 'granted',
|
|
533
|
+
grantedAt: {
|
|
534
|
+
timestamp: '2026-05-15T12:00:00.000Z',
|
|
535
|
+
timestampUNIX: 1779235200,
|
|
536
|
+
source: 'signup',
|
|
537
|
+
ip: '1.2.3.4',
|
|
538
|
+
text: 'I agree to the Terms of Service and Privacy Policy.',
|
|
539
|
+
},
|
|
540
|
+
},
|
|
541
|
+
marketing: {
|
|
542
|
+
status: 'revoked',
|
|
543
|
+
revokedAt: {
|
|
544
|
+
timestamp: '2026-05-15T12:00:00.000Z',
|
|
545
|
+
timestampUNIX: 1779235200,
|
|
546
|
+
source: 'signup',
|
|
547
|
+
ip: '1.2.3.4',
|
|
548
|
+
text: null,
|
|
549
|
+
},
|
|
550
|
+
},
|
|
551
|
+
},
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
assert.equal(user.consent.legal.status, 'granted', 'legal.status granted');
|
|
555
|
+
assert.equal(user.consent.marketing.status, 'revoked', 'marketing.status revoked');
|
|
556
|
+
assert.equal(user.consent.marketing.grantedAt.timestamp, null, 'marketing grantedAt.timestamp is null (never granted)');
|
|
557
|
+
assert.equal(user.consent.marketing.grantedAt.source, null, 'marketing grantedAt.source is null');
|
|
558
|
+
assert.equal(user.consent.marketing.revokedAt.timestamp, '2026-05-15T12:00:00.000Z', 'marketing revokedAt.timestamp preserved');
|
|
559
|
+
assert.equal(user.consent.marketing.revokedAt.source, 'signup', 'marketing revokedAt.source preserved');
|
|
560
|
+
assert.equal(user.consent.marketing.revokedAt.ip, '1.2.3.4', 'marketing revokedAt.ip preserved');
|
|
561
|
+
assert.equal(user.consent.marketing.revokedAt.text, null, 'marketing revokedAt.text is null for decline');
|
|
562
|
+
},
|
|
563
|
+
},
|
|
564
|
+
|
|
565
|
+
{
|
|
566
|
+
name: 'consent-revoked-then-regranted-keeps-prior-revokedAt',
|
|
567
|
+
async run({ assert }) {
|
|
568
|
+
const user = createUser({
|
|
569
|
+
consent: {
|
|
570
|
+
marketing: {
|
|
571
|
+
status: 'granted',
|
|
572
|
+
grantedAt: {
|
|
573
|
+
timestamp: '2026-07-01T00:00:00.000Z',
|
|
574
|
+
timestampUNIX: 1783785600,
|
|
575
|
+
source: 'account-page',
|
|
576
|
+
ip: '5.6.7.8',
|
|
577
|
+
text: 'Send me product updates and newsletters.',
|
|
578
|
+
},
|
|
579
|
+
revokedAt: {
|
|
580
|
+
timestamp: '2026-06-12T00:00:00.000Z',
|
|
581
|
+
timestampUNIX: 1781251200,
|
|
582
|
+
source: 'sendgrid-webhook',
|
|
583
|
+
ip: null,
|
|
584
|
+
text: null,
|
|
585
|
+
},
|
|
586
|
+
},
|
|
587
|
+
},
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
assert.equal(user.consent.marketing.status, 'granted', 'status is granted (latest action)');
|
|
591
|
+
assert.equal(user.consent.marketing.grantedAt.timestamp, '2026-07-01T00:00:00.000Z', 'grantedAt reflects most recent grant');
|
|
592
|
+
assert.equal(user.consent.marketing.grantedAt.source, 'account-page', 'grantedAt.source is account-page');
|
|
593
|
+
// revokedAt still reflects the prior revoke — informational
|
|
594
|
+
assert.equal(user.consent.marketing.revokedAt.timestamp, '2026-06-12T00:00:00.000Z', 'revokedAt preserves prior revoke');
|
|
595
|
+
assert.equal(user.consent.marketing.revokedAt.source, 'sendgrid-webhook', 'revokedAt.source preserves prior source');
|
|
596
|
+
},
|
|
597
|
+
},
|
|
598
|
+
|
|
599
|
+
{
|
|
600
|
+
name: 'consent-partial-input-fills-missing-leaves-with-null',
|
|
601
|
+
async run({ assert }) {
|
|
602
|
+
const user = createUser({
|
|
603
|
+
consent: {
|
|
604
|
+
marketing: {
|
|
605
|
+
status: 'granted',
|
|
606
|
+
grantedAt: {
|
|
607
|
+
timestamp: '2026-05-15T12:00:00.000Z',
|
|
608
|
+
source: 'signup',
|
|
609
|
+
},
|
|
610
|
+
},
|
|
611
|
+
},
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
assert.equal(user.consent.marketing.status, 'granted', 'marketing.status preserved');
|
|
615
|
+
assert.equal(user.consent.marketing.grantedAt.timestamp, '2026-05-15T12:00:00.000Z', 'grantedAt.timestamp preserved');
|
|
616
|
+
assert.equal(user.consent.marketing.grantedAt.source, 'signup', 'grantedAt.source preserved');
|
|
617
|
+
// Missing leaves default to null
|
|
618
|
+
assert.equal(user.consent.marketing.grantedAt.timestampUNIX, null, 'missing grantedAt.timestampUNIX defaults to null');
|
|
619
|
+
assert.equal(user.consent.marketing.grantedAt.ip, null, 'missing grantedAt.ip defaults to null');
|
|
620
|
+
assert.equal(user.consent.marketing.grantedAt.text, null, 'missing grantedAt.text defaults to null');
|
|
621
|
+
// Legal not provided — gets full defaults
|
|
622
|
+
assert.equal(user.consent.legal.status, 'revoked', 'untouched legal defaults to revoked');
|
|
623
|
+
assert.equal(user.consent.legal.grantedAt.timestamp, null, 'untouched legal grantedAt.timestamp is null');
|
|
624
|
+
},
|
|
625
|
+
},
|
|
626
|
+
|
|
627
|
+
{
|
|
628
|
+
name: 'consent-round-trip-preserves-shape',
|
|
629
|
+
async run({ assert }) {
|
|
630
|
+
const grantedDoc = {
|
|
631
|
+
consent: {
|
|
632
|
+
legal: {
|
|
633
|
+
status: 'granted',
|
|
634
|
+
grantedAt: {
|
|
635
|
+
timestamp: '2026-05-15T12:00:00.000Z',
|
|
636
|
+
timestampUNIX: 1779235200,
|
|
637
|
+
source: 'signup',
|
|
638
|
+
ip: '1.2.3.4',
|
|
639
|
+
text: 'I agree to the Terms of Service.',
|
|
640
|
+
},
|
|
641
|
+
},
|
|
642
|
+
marketing: {
|
|
643
|
+
status: 'granted',
|
|
644
|
+
grantedAt: {
|
|
645
|
+
timestamp: '2026-05-15T12:00:00.000Z',
|
|
646
|
+
timestampUNIX: 1779235200,
|
|
647
|
+
source: 'signup',
|
|
648
|
+
ip: '1.2.3.4',
|
|
649
|
+
text: 'Send me updates.',
|
|
650
|
+
},
|
|
651
|
+
revokedAt: {
|
|
652
|
+
timestamp: null,
|
|
653
|
+
timestampUNIX: null,
|
|
654
|
+
source: null,
|
|
655
|
+
ip: null,
|
|
656
|
+
text: null,
|
|
657
|
+
},
|
|
658
|
+
},
|
|
659
|
+
},
|
|
660
|
+
};
|
|
661
|
+
|
|
662
|
+
// First pass — resolve from raw input
|
|
663
|
+
const user1 = createUser(grantedDoc);
|
|
664
|
+
|
|
665
|
+
// Second pass — resolve from the resolved doc (simulates Firestore read-back)
|
|
666
|
+
const user2 = createUser(user1);
|
|
667
|
+
|
|
668
|
+
assert.deepEqual(user1.consent, user2.consent, 'consent round-trips identically');
|
|
669
|
+
},
|
|
670
|
+
},
|
|
671
|
+
|
|
433
672
|
// ─── Unique/generated values ───
|
|
434
673
|
|
|
435
674
|
{
|