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.
Files changed (75) hide show
  1. package/.claude/settings.local.json +12 -0
  2. package/CHANGELOG.md +23 -0
  3. package/CLAUDE.md +2 -1
  4. package/README.md +15 -0
  5. package/docs/common-mistakes.md +1 -0
  6. package/docs/consent.md +333 -0
  7. package/docs/testing.md +36 -0
  8. package/package.json +1 -1
  9. package/src/cli/commands/emulator.js +44 -8
  10. package/src/cli/commands/serve.js +73 -7
  11. package/src/cli/commands/test.js +47 -1
  12. package/src/cli/commands/watch.js +15 -3
  13. package/src/manager/helpers/user.js +29 -0
  14. package/src/manager/index.js +29 -0
  15. package/src/manager/libraries/email/data/disposable-domains.json +8 -0
  16. package/src/manager/libraries/email/generators/newsletter.js +2 -2
  17. package/src/manager/libraries/email/providers/beehiiv.js +1 -0
  18. package/src/manager/libraries/payment/processors/stripe.js +12 -0
  19. package/src/manager/libraries/payment/processors/test.js +8 -1
  20. package/src/manager/routes/admin/infer-contact/post.js +3 -2
  21. package/src/manager/routes/marketing/email-preferences/post.js +165 -37
  22. package/src/manager/routes/marketing/webhook/forward/post.js +168 -0
  23. package/src/manager/routes/marketing/webhook/post.js +180 -0
  24. package/src/manager/routes/marketing/webhook/processors/beehiiv.js +196 -0
  25. package/src/manager/routes/marketing/webhook/processors/sendgrid.js +171 -0
  26. package/src/manager/routes/payments/cancel/post.js +2 -2
  27. package/src/manager/routes/payments/cancel/processors/test.js +5 -2
  28. package/src/manager/routes/payments/intent/processors/test.js +7 -3
  29. package/src/manager/routes/payments/refund/processors/test.js +4 -1
  30. package/src/manager/routes/user/signup/post.js +65 -1
  31. package/src/manager/schemas/marketing/email-preferences/post.js +8 -4
  32. package/src/manager/schemas/marketing/webhook/forward/post.js +8 -0
  33. package/src/manager/schemas/marketing/webhook/post.js +7 -0
  34. package/src/manager/schemas/payments/cancel/post.js +5 -0
  35. package/src/manager/schemas/user/signup/post.js +5 -0
  36. package/src/test/runner.js +61 -18
  37. package/src/test/test-accounts.js +94 -12
  38. package/src/test/utils/http-client.js +4 -3
  39. package/test/events/payments/journey-payments-cancel-endpoint.js +3 -12
  40. package/test/events/payments/journey-payments-cancel.js +4 -5
  41. package/test/events/payments/journey-payments-failure.js +0 -1
  42. package/test/events/payments/journey-payments-one-time-failure.js +6 -3
  43. package/test/events/payments/journey-payments-one-time.js +6 -3
  44. package/test/events/payments/journey-payments-plan-change.js +5 -5
  45. package/test/events/payments/journey-payments-refund-webhook.js +2 -3
  46. package/test/events/payments/journey-payments-suspend.js +4 -5
  47. package/test/events/payments/journey-payments-trial-cancel.js +3 -12
  48. package/test/events/payments/journey-payments-trial.js +2 -3
  49. package/test/events/payments/journey-payments-uid-resolution.js +2 -3
  50. package/test/functions/admin/database-read.js +0 -14
  51. package/test/functions/admin/database-write.js +0 -14
  52. package/test/functions/admin/firestore-query.js +0 -14
  53. package/test/functions/admin/firestore-read.js +0 -15
  54. package/test/functions/admin/firestore-write.js +0 -11
  55. package/test/functions/general/add-marketing-contact.js +16 -14
  56. package/test/helpers/email.js +1 -1
  57. package/test/helpers/infer-contact.js +3 -3
  58. package/test/helpers/user.js +241 -2
  59. package/test/helpers/webhook-forward.js +392 -0
  60. package/test/marketing/newsletter-generate.js +17 -7
  61. package/test/routes/admin/database.js +0 -13
  62. package/test/routes/admin/firestore-query.js +0 -13
  63. package/test/routes/admin/firestore.js +0 -14
  64. package/test/routes/admin/infer-contact.js +6 -3
  65. package/test/routes/admin/post.js +4 -2
  66. package/test/routes/marketing/contact.js +60 -26
  67. package/test/routes/marketing/email-preferences.js +145 -69
  68. package/test/routes/marketing/webhook-forward.js +54 -0
  69. package/test/routes/marketing/webhook.js +582 -0
  70. package/test/routes/payments/cancel.js +2 -7
  71. package/test/routes/payments/dispute-alert.js +0 -39
  72. package/test/routes/payments/refund.js +3 -1
  73. package/test/routes/payments/webhook.js +5 -26
  74. package/test/routes/test/usage.js +2 -2
  75. 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
- // Names should be inferred by AI from the email local part
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: (domain) => `rachel.greene+bem@${domain}`, // Should infer: Rachel Greene
13
- invalid: () => `test+bem@test.com`, // Guaranteed to fail ZeroBounce (fake domain)
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, config, state }) {
27
- const testEmail = TEST_EMAILS.valid(config.domain);
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, config, state }) {
140
+ async run({ http, assert, state }) {
139
141
  // Use valid email without providing name - should infer "Rachel Greene"
140
- const testEmail = TEST_EMAILS.valid(config.domain);
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, config, state, skip }) {
220
- const testEmail = TEST_EMAILS.valid(config.domain);
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, config }) {
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(config.domain),
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, config }) {
323
+ async run({ http, assert }) {
322
324
  // Clean up the rachel.greene+bem test contact from marketing providers
323
- const testEmail = TEST_EMAILS.valid(config.domain);
325
+ const testEmail = TEST_EMAILS.valid();
324
326
 
325
327
  const response = await http.command('general:remove-marketing-contact', {
326
328
  email: testEmail,
@@ -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-b7f8da3c98ad49a2ad1e187f3a67b546', 'Should use default template');
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
- // This test requires a real OPENAI_API_KEY and running Manager
99
- if (!process.env.OPENAI_API_KEY) {
100
- return assert.fail('OPENAI_API_KEY not set');
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();
@@ -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
  {