backend-manager 5.9.7 → 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 (209) hide show
  1. package/package.json +5 -3
  2. package/templates/_.env +42 -0
  3. package/templates/_.gitignore +60 -0
  4. package/templates/backend-manager-config.json +249 -0
  5. package/templates/database.rules.json +83 -0
  6. package/templates/firebase.json +66 -0
  7. package/templates/firestore.indexes.json +4 -0
  8. package/templates/firestore.rules +112 -0
  9. package/templates/public/404.html +26 -0
  10. package/templates/public/index.html +24 -0
  11. package/templates/remoteconfig.template.json +1 -0
  12. package/templates/storage-lifecycle-config-1-day.json +9 -0
  13. package/templates/storage-lifecycle-config-30-days.json +9 -0
  14. package/templates/storage.rules +8 -0
  15. package/test/_init/accounts-validation.js +58 -0
  16. package/test/_legacy/ai/index.js +231 -0
  17. package/test/_legacy/ai/test.jpg +0 -0
  18. package/test/_legacy/ai/test.pdf +0 -0
  19. package/test/_legacy/index.js +31 -0
  20. package/test/_legacy/payment-resolver/chargebee/orders/refunded.json +0 -0
  21. package/test/_legacy/payment-resolver/chargebee/orders/unpaid.json +98 -0
  22. package/test/_legacy/payment-resolver/chargebee/subscriptions/active.json +578 -0
  23. package/test/_legacy/payment-resolver/chargebee/subscriptions/trial-in-trial.json +38 -0
  24. package/test/_legacy/payment-resolver/chargebee/subscriptions/trial-skipped-to-active.json +135 -0
  25. package/test/_legacy/payment-resolver/chargebee/subscriptions/trial-to-active-to-cancelled.json +42 -0
  26. package/test/_legacy/payment-resolver/chargebee/subscriptions/trial-to-active.json +44 -0
  27. package/test/_legacy/payment-resolver/chargebee/subscriptions/trial-to-cancelled.json +39 -0
  28. package/test/_legacy/payment-resolver/chargebee/subscriptions/trial-to-refund.json +143 -0
  29. package/test/_legacy/payment-resolver/chargebee/subscriptions/trial-to-suspended.json +48 -0
  30. package/test/_legacy/payment-resolver/coinbase/orders/regular.json +204 -0
  31. package/test/_legacy/payment-resolver/coinbase/subscriptions/cancelled.json +204 -0
  32. package/test/_legacy/payment-resolver/coinbase/subscriptions/paid-2.json +125 -0
  33. package/test/_legacy/payment-resolver/index.js +1558 -0
  34. package/test/_legacy/payment-resolver/paypal/orders/refunded.json +0 -0
  35. package/test/_legacy/payment-resolver/paypal/orders/regular.json +120 -0
  36. package/test/_legacy/payment-resolver/paypal/subscriptions/active-refund-previous-stmnt.json +323 -0
  37. package/test/_legacy/payment-resolver/paypal/subscriptions/active.json +192 -0
  38. package/test/_legacy/payment-resolver/paypal/subscriptions/trial-in-trial.json +125 -0
  39. package/test/_legacy/payment-resolver/paypal/subscriptions/trial-payment-not-complete.json +76 -0
  40. package/test/_legacy/payment-resolver/paypal/subscriptions/trial-payment-overdue-2.json +114 -0
  41. package/test/_legacy/payment-resolver/paypal/subscriptions/trial-payment-overdue.json +114 -0
  42. package/test/_legacy/payment-resolver/paypal/subscriptions/trial-to-active-to-cancelled.json +111 -0
  43. package/test/_legacy/payment-resolver/paypal/subscriptions/trial-to-active.json +132 -0
  44. package/test/_legacy/payment-resolver/paypal/subscriptions/trial-to-cancelled.json +107 -0
  45. package/test/_legacy/payment-resolver/paypal/subscriptions/trial-to-expired.json +91 -0
  46. package/test/_legacy/payment-resolver/paypal/subscriptions/trial-to-refund.json +147 -0
  47. package/test/_legacy/payment-resolver/paypal/subscriptions/trial-to-suspended.json +120 -0
  48. package/test/_legacy/payment-resolver/stripe/orders/refunded.json +0 -0
  49. package/test/_legacy/payment-resolver/stripe/orders/regular.json +91 -0
  50. package/test/_legacy/payment-resolver/stripe/subscriptions/active.json +421 -0
  51. package/test/_legacy/payment-resolver/stripe/subscriptions/trial-in-trial.json +349 -0
  52. package/test/_legacy/payment-resolver/stripe/subscriptions/trial-to-active-failed-authorization.json +430 -0
  53. package/test/_legacy/payment-resolver/stripe/subscriptions/trial-to-active.json +319 -0
  54. package/test/_legacy/payment-resolver/stripe/subscriptions/trial-to-cancelled.json +319 -0
  55. package/test/_legacy/payment-resolver/stripe/subscriptions/trial-to-refund.json +414 -0
  56. package/test/_legacy/payment-resolver/stripe/subscriptions/trial-to-suspended.json +319 -0
  57. package/test/_legacy/payment-resolver/stripe/subscriptions/unsure.json +339 -0
  58. package/test/_legacy/test-new +1178 -0
  59. package/test/_legacy/test.md +89 -0
  60. package/test/_legacy/usage.js +175 -0
  61. package/test/_legacy/user.js +31 -0
  62. package/test/ai/tools-live.js +170 -0
  63. package/test/boot/emulator-boots.js +37 -0
  64. package/test/content/blog-generate.js +160 -0
  65. package/test/email/campaign-send.js +45 -0
  66. package/test/email/consent-lifecycle.js +257 -0
  67. package/test/email/feedback-and-plain-send.js +52 -0
  68. package/test/email/fixtures/clean.json +30 -0
  69. package/test/email/fixtures/editorial.json +30 -0
  70. package/test/email/fixtures/field-report.json +53 -0
  71. package/test/email/marketing-lifecycle.js +132 -0
  72. package/test/email/newsletter-generate.js +819 -0
  73. package/test/email/newsletter-templates.js +491 -0
  74. package/test/email/templates.js +207 -0
  75. package/test/email/transactional-send.js +34 -0
  76. package/test/email/transactional.js +560 -0
  77. package/test/email/validation.js +710 -0
  78. package/test/events/auth-delete-race.js +201 -0
  79. package/test/events/payments/journey-payments-cancel-endpoint.js +100 -0
  80. package/test/events/payments/journey-payments-cancel.js +182 -0
  81. package/test/events/payments/journey-payments-discount.js +89 -0
  82. package/test/events/payments/journey-payments-failure.js +146 -0
  83. package/test/events/payments/journey-payments-legacy-product.js +149 -0
  84. package/test/events/payments/journey-payments-one-time-failure.js +111 -0
  85. package/test/events/payments/journey-payments-one-time.js +136 -0
  86. package/test/events/payments/journey-payments-plan-change.js +144 -0
  87. package/test/events/payments/journey-payments-refund-webhook.js +180 -0
  88. package/test/events/payments/journey-payments-suspend.js +181 -0
  89. package/test/events/payments/journey-payments-trial-cancel.js +130 -0
  90. package/test/events/payments/journey-payments-trial.js +171 -0
  91. package/test/events/payments/journey-payments-uid-resolution.js +132 -0
  92. package/test/events/payments/journey-payments-upgrade.js +135 -0
  93. package/test/fixtures/chargebee/invoice-one-time.json +27 -0
  94. package/test/fixtures/chargebee/subscription-active.json +44 -0
  95. package/test/fixtures/chargebee/subscription-cancelled.json +42 -0
  96. package/test/fixtures/chargebee/subscription-in-trial.json +41 -0
  97. package/test/fixtures/chargebee/subscription-legacy-plan.json +41 -0
  98. package/test/fixtures/chargebee/subscription-non-renewing.json +41 -0
  99. package/test/fixtures/chargebee/subscription-paused.json +42 -0
  100. package/test/fixtures/chargebee/webhook-payment-failed.json +51 -0
  101. package/test/fixtures/chargebee/webhook-subscription-created.json +47 -0
  102. package/test/fixtures/paypal/order-approved.json +62 -0
  103. package/test/fixtures/paypal/order-completed.json +110 -0
  104. package/test/fixtures/paypal/subscription-active.json +76 -0
  105. package/test/fixtures/paypal/subscription-cancelled.json +50 -0
  106. package/test/fixtures/paypal/subscription-suspended.json +65 -0
  107. package/test/fixtures/stripe/checkout-session-completed.json +130 -0
  108. package/test/fixtures/stripe/invoice-payment-failed.json +148 -0
  109. package/test/fixtures/stripe/invoice-subscription-payment-failed.json +28 -0
  110. package/test/fixtures/stripe/subscription-active.json +161 -0
  111. package/test/fixtures/stripe/subscription-canceled.json +161 -0
  112. package/test/fixtures/stripe/subscription-trialing.json +161 -0
  113. package/test/functions/admin/database-read.js +134 -0
  114. package/test/functions/admin/database-write.js +159 -0
  115. package/test/functions/admin/edit-post.js +366 -0
  116. package/test/functions/admin/firestore-query.js +207 -0
  117. package/test/functions/admin/firestore-read.js +129 -0
  118. package/test/functions/admin/firestore-write.js +105 -0
  119. package/test/functions/admin/get-stats.js +115 -0
  120. package/test/functions/admin/send-email.js +119 -0
  121. package/test/functions/admin/send-notification.js +208 -0
  122. package/test/functions/admin/write-repo-content.js +223 -0
  123. package/test/functions/general/add-marketing-contact.js +335 -0
  124. package/test/functions/general/fetch-post.js +54 -0
  125. package/test/functions/general/generate-uuid.js +114 -0
  126. package/test/functions/test/authenticate.js +64 -0
  127. package/test/functions/user/create-custom-token.js +73 -0
  128. package/test/functions/user/delete.js +135 -0
  129. package/test/functions/user/get-active-sessions.js +111 -0
  130. package/test/functions/user/get-subscription-info.js +99 -0
  131. package/test/functions/user/regenerate-api-keys.js +156 -0
  132. package/test/functions/user/resolve.js +52 -0
  133. package/test/functions/user/sign-out-all-sessions.js +94 -0
  134. package/test/functions/user/sign-up.js +252 -0
  135. package/test/functions/user/submit-feedback.js +104 -0
  136. package/test/functions/user/validate-settings.js +82 -0
  137. package/test/helpers/ai-request-payload.js +300 -0
  138. package/test/helpers/ai-test-provider.js +202 -0
  139. package/test/helpers/ai-tools-format.js +383 -0
  140. package/test/helpers/content/blog-auto-publisher.js +484 -0
  141. package/test/helpers/content/feed-parser.js +528 -0
  142. package/test/helpers/content/ghostii-blocks.js +134 -0
  143. package/test/helpers/content/ghostii-feed-integration.js +404 -0
  144. package/test/helpers/content/ghostii-write-article.js +243 -0
  145. package/test/helpers/environment.js +230 -0
  146. package/test/helpers/infer-contact.js +113 -0
  147. package/test/helpers/merge-line-files.js +271 -0
  148. package/test/helpers/payment/chargebee/parse-webhook.js +413 -0
  149. package/test/helpers/payment/chargebee/to-unified-one-time.js +147 -0
  150. package/test/helpers/payment/chargebee/to-unified-subscription.js +648 -0
  151. package/test/helpers/payment/paypal/parse-webhook.js +539 -0
  152. package/test/helpers/payment/paypal/to-unified-one-time.js +382 -0
  153. package/test/helpers/payment/paypal/to-unified-subscription.js +820 -0
  154. package/test/helpers/payment/stripe/parse-webhook.js +447 -0
  155. package/test/helpers/payment/stripe/to-unified-one-time.js +306 -0
  156. package/test/helpers/payment/stripe/to-unified-subscription.js +708 -0
  157. package/test/helpers/sanitize.js +222 -0
  158. package/test/helpers/slugify.js +394 -0
  159. package/test/helpers/storage.js +194 -0
  160. package/test/helpers/user.js +755 -0
  161. package/test/helpers/webhook-forward.js +418 -0
  162. package/test/mcp/discovery.js +53 -0
  163. package/test/mcp/oauth.js +161 -0
  164. package/test/mcp/protocol.js +268 -0
  165. package/test/mcp/roles.js +188 -0
  166. package/test/mcp/utils.js +245 -0
  167. package/test/routes/admin/create-post.js +364 -0
  168. package/test/routes/admin/database.js +131 -0
  169. package/test/routes/admin/deduplicate-image-alts.js +190 -0
  170. package/test/routes/admin/email.js +114 -0
  171. package/test/routes/admin/firestore-query.js +204 -0
  172. package/test/routes/admin/firestore.js +127 -0
  173. package/test/routes/admin/infer-contact.js +218 -0
  174. package/test/routes/admin/notification.js +198 -0
  175. package/test/routes/admin/post-resize-image.js +184 -0
  176. package/test/routes/admin/post.js +368 -0
  177. package/test/routes/admin/repo-content.js +223 -0
  178. package/test/routes/admin/stats.js +112 -0
  179. package/test/routes/content/post.js +54 -0
  180. package/test/routes/general/uuid.js +115 -0
  181. package/test/routes/marketing/campaign.js +125 -0
  182. package/test/routes/marketing/contact.js +370 -0
  183. package/test/routes/marketing/email-preferences.js +292 -0
  184. package/test/routes/marketing/push-send.js +32 -0
  185. package/test/routes/marketing/webhook-forward.js +61 -0
  186. package/test/routes/marketing/webhook.js +639 -0
  187. package/test/routes/payments/cancel.js +163 -0
  188. package/test/routes/payments/discount.js +80 -0
  189. package/test/routes/payments/dispute-alert.js +320 -0
  190. package/test/routes/payments/intent.js +336 -0
  191. package/test/routes/payments/portal.js +92 -0
  192. package/test/routes/payments/refund.js +179 -0
  193. package/test/routes/payments/trial-eligibility.js +71 -0
  194. package/test/routes/payments/webhook.js +107 -0
  195. package/test/routes/test/authenticate.js +59 -0
  196. package/test/routes/test/schema.js +554 -0
  197. package/test/routes/test/usage.js +342 -0
  198. package/test/routes/user/api-keys.js +156 -0
  199. package/test/routes/user/delete.js +136 -0
  200. package/test/routes/user/feedback.js +104 -0
  201. package/test/routes/user/sessions.js +199 -0
  202. package/test/routes/user/settings-validate.js +82 -0
  203. package/test/routes/user/signup.js +542 -0
  204. package/test/routes/user/subscription.js +99 -0
  205. package/test/routes/user/token.js +73 -0
  206. package/test/routes/user/user.js +157 -0
  207. package/test/rules/notifications.js +410 -0
  208. package/test/rules/payments-carts.js +371 -0
  209. package/test/rules/user.js +396 -0
@@ -0,0 +1,755 @@
1
+ /**
2
+ * Test: helpers/user
3
+ * Unit tests for Manager.User() schema-driven normalization
4
+ *
5
+ * Tests the declarative schema resolver: defaults, passthrough, templates, type coercion
6
+ */
7
+ const User = require('../../src/manager/helpers/user.js');
8
+
9
+ // Mock Manager with minimal Utilities
10
+ const Manager = {
11
+ Utilities: () => ({
12
+ randomId: ({ size }) => 'test123',
13
+ }),
14
+ };
15
+
16
+ function createUser(settings) {
17
+ return new User(Manager, settings).properties;
18
+ }
19
+
20
+ module.exports = {
21
+ description: 'User() schema resolver',
22
+ type: 'group',
23
+
24
+ tests: [
25
+ // ─── Empty / new user ───
26
+
27
+ {
28
+ name: 'empty-settings-gets-all-defaults',
29
+ async run({ assert }) {
30
+ const user = createUser({});
31
+
32
+ // Auth
33
+ assert.equal(user.auth.uid, null, 'auth.uid should be null');
34
+ assert.equal(user.auth.email, null, 'auth.email should be null');
35
+ assert.equal(user.auth.temporary, false, 'auth.temporary should be false');
36
+
37
+ // Subscription
38
+ assert.equal(user.subscription.product.id, 'basic', 'subscription.product.id should be basic');
39
+ assert.equal(user.subscription.product.name, 'Basic', 'subscription.product.name should be Basic');
40
+ assert.equal(user.subscription.status, 'active', 'subscription.status should be active');
41
+ assert.equal(user.subscription.trial.claimed, false, 'subscription.trial.claimed should be false');
42
+ assert.equal(user.subscription.cancellation.pending, false, 'subscription.cancellation.pending should be false');
43
+
44
+ // Timestamps should exist
45
+ assert.ok(user.subscription.expires.timestamp, 'subscription.expires.timestamp should exist');
46
+ assert.equal(typeof user.subscription.expires.timestampUNIX, 'number', 'subscription.expires.timestampUNIX should be number');
47
+
48
+ // Roles
49
+ assert.equal(user.roles.admin, false, 'roles.admin should be false');
50
+ assert.equal(user.roles.betaTester, false, 'roles.betaTester should be false');
51
+ assert.equal(user.roles.developer, false, 'roles.developer should be false');
52
+
53
+ // Flags
54
+ assert.equal(user.flags.signupProcessed, false, 'flags.signupProcessed should be false');
55
+
56
+ // Affiliate
57
+ assert.equal(typeof user.affiliate.code, 'string', 'affiliate.code should be string');
58
+ assert.ok(user.affiliate.code.length > 0, 'affiliate.code should not be empty');
59
+ assert.ok(Array.isArray(user.affiliate.referrals), 'affiliate.referrals should be array');
60
+
61
+ // Metadata
62
+ assert.ok(user.metadata.updated.timestamp, 'metadata.updated.timestamp should exist');
63
+ assert.ok(user.metadata.created.timestamp, 'metadata.created.timestamp should exist');
64
+ assert.equal(user.activity.geolocation.latitude, 0, 'geolocation.latitude should be 0');
65
+ assert.equal(user.activity.geolocation.longitude, 0, 'geolocation.longitude should be 0');
66
+ assert.equal(user.activity.client.mobile, false, 'client.mobile should be false');
67
+
68
+ // API keys
69
+ assert.equal(typeof user.api.clientId, 'string', 'api.clientId should be string');
70
+ assert.ok(user.api.clientId.length > 0, 'api.clientId should not be empty');
71
+ assert.equal(typeof user.api.privateKey, 'string', 'api.privateKey should be string');
72
+ assert.ok(user.api.privateKey.length > 0, 'api.privateKey should not be empty');
73
+
74
+ // Usage
75
+ assert.deepEqual(user.usage, {}, 'usage should be empty object by default');
76
+
77
+ // Personal
78
+ assert.equal(user.personal.name.first, null, 'personal.name.first should be null');
79
+ assert.equal(user.personal.name.last, null, 'personal.name.last should be null');
80
+ assert.equal(user.personal.telephone.countryCode, 0, 'telephone.countryCode should be 0');
81
+
82
+ // OAuth2
83
+ assert.deepEqual(user.oauth2, {}, 'oauth2 should be empty object');
84
+
85
+ // Attribution
86
+ assert.equal(user.attribution.affiliate.code, null, 'attribution.affiliate.code should be null');
87
+ assert.deepEqual(user.attribution.utm.tags, {}, 'attribution.utm.tags should be empty object');
88
+ },
89
+ },
90
+
91
+ {
92
+ name: 'undefined-settings-gets-all-defaults',
93
+ async run({ assert }) {
94
+ const user = new User(Manager).properties;
95
+
96
+ assert.equal(user.auth.uid, null, 'auth.uid should be null');
97
+ assert.equal(user.subscription.product.id, 'basic', 'subscription.product.id should be basic');
98
+ assert.equal(user.roles.admin, false, 'roles.admin should be false');
99
+ },
100
+ },
101
+
102
+ // ─── Preserving real user data ───
103
+
104
+ {
105
+ name: 'real-data-takes-precedence-over-defaults',
106
+ async run({ assert }) {
107
+ const user = createUser({
108
+ auth: { uid: 'user123', email: 'test@test.com', temporary: true },
109
+ subscription: { product: { id: 'pro', name: 'Pro' }, status: 'cancelled' },
110
+ roles: { admin: true, betaTester: true, developer: false },
111
+ personal: { name: { first: 'Ian', last: 'W' } },
112
+ });
113
+
114
+ assert.equal(user.auth.uid, 'user123', 'auth.uid should be preserved');
115
+ assert.equal(user.auth.email, 'test@test.com', 'auth.email should be preserved');
116
+ assert.equal(user.auth.temporary, true, 'auth.temporary should be preserved');
117
+ assert.equal(user.subscription.product.id, 'pro', 'subscription.product.id should be preserved');
118
+ assert.equal(user.subscription.status, 'cancelled', 'subscription.status should be preserved');
119
+ assert.equal(user.roles.admin, true, 'roles.admin should be preserved');
120
+ assert.equal(user.personal.name.first, 'Ian', 'personal.name.first should be preserved');
121
+ },
122
+ },
123
+
124
+ // ─── $passthrough: oauth2 ───
125
+
126
+ {
127
+ name: 'oauth2-passthrough-preserves-provider-data',
128
+ async run({ assert }) {
129
+ const googleToken = {
130
+ access_token: 'ya29.xxx',
131
+ refresh_token: '1//xxx',
132
+ expiry_date: 1700000000,
133
+ };
134
+ const msToken = {
135
+ access_token: 'eyJ.xxx',
136
+ refresh_token: 'M.xxx',
137
+ };
138
+
139
+ const user = createUser({
140
+ oauth2: {
141
+ google: {
142
+ token: googleToken,
143
+ identity: { email: 'g@gmail.com', name: 'Test' },
144
+ },
145
+ microsoft: {
146
+ token: msToken,
147
+ },
148
+ },
149
+ });
150
+
151
+ assert.equal(user.oauth2.google.token.access_token, 'ya29.xxx', 'google access_token preserved');
152
+ assert.equal(user.oauth2.google.token.refresh_token, '1//xxx', 'google refresh_token preserved');
153
+ assert.equal(user.oauth2.google.identity.email, 'g@gmail.com', 'google identity preserved');
154
+ assert.equal(user.oauth2.microsoft.token.access_token, 'eyJ.xxx', 'microsoft token preserved');
155
+ },
156
+ },
157
+
158
+ {
159
+ name: 'oauth2-empty-when-not-provided',
160
+ async run({ assert }) {
161
+ const user = createUser({});
162
+ assert.deepEqual(user.oauth2, {}, 'oauth2 should be empty object when not provided');
163
+ },
164
+ },
165
+
166
+ // ─── $passthrough: roles (with defined defaults) ───
167
+
168
+ {
169
+ name: 'roles-passthrough-preserves-custom-roles',
170
+ async run({ assert }) {
171
+ const user = createUser({
172
+ roles: { admin: true, customRole: true, moderator: false },
173
+ });
174
+
175
+ assert.equal(user.roles.admin, true, 'admin should be true');
176
+ assert.equal(user.roles.customRole, true, 'customRole should be preserved');
177
+ assert.equal(user.roles.moderator, false, 'moderator should be preserved');
178
+ assert.equal(user.roles.betaTester, false, 'betaTester should get default');
179
+ assert.equal(user.roles.developer, false, 'developer should get default');
180
+ },
181
+ },
182
+
183
+ // ─── $passthrough: flags (with defined defaults) ───
184
+
185
+ {
186
+ name: 'flags-passthrough-preserves-custom-flags',
187
+ async run({ assert }) {
188
+ const user = createUser({
189
+ flags: { signupProcessed: true, featureX: true, betaOptIn: false },
190
+ });
191
+
192
+ assert.equal(user.flags.signupProcessed, true, 'signupProcessed preserved');
193
+ assert.equal(user.flags.featureX, true, 'featureX preserved');
194
+ assert.equal(user.flags.betaOptIn, false, 'betaOptIn preserved');
195
+ },
196
+ },
197
+
198
+ // ─── $template: usage with dynamic keys ───
199
+
200
+ {
201
+ name: 'usage-template-preserves-dynamic-keys',
202
+ async run({ assert }) {
203
+ const user = createUser({
204
+ usage: {
205
+ requests: { monthly: 10, total: 100, last: { id: 'r1', timestamp: '2025-01-01T00:00:00.000Z', timestampUNIX: 1735689600 } },
206
+ emails: { monthly: 5, total: 50, last: { id: 'e1', timestamp: '2025-01-02T00:00:00.000Z', timestampUNIX: 1735776000 } },
207
+ sends: { monthly: 3, total: 30 },
208
+ },
209
+ });
210
+
211
+ // Defined key (requests)
212
+ assert.equal(user.usage.requests.monthly, 10, 'requests.monthly preserved');
213
+ assert.equal(user.usage.requests.total, 100, 'requests.total preserved');
214
+ assert.equal(user.usage.requests.last.id, 'r1', 'requests.last.id preserved');
215
+
216
+ // Dynamic key (emails) — full data
217
+ assert.equal(user.usage.emails.monthly, 5, 'emails.monthly preserved');
218
+ assert.equal(user.usage.emails.total, 50, 'emails.total preserved');
219
+ assert.equal(user.usage.emails.last.id, 'e1', 'emails.last.id preserved');
220
+
221
+ // Dynamic key (sends) — partial data, template fills in missing
222
+ assert.equal(user.usage.sends.monthly, 3, 'sends.monthly preserved');
223
+ assert.equal(user.usage.sends.total, 30, 'sends.total preserved');
224
+ assert.equal(user.usage.sends.last.id, null, 'sends.last.id defaulted to null');
225
+ assert.ok(user.usage.sends.last.timestamp, 'sends.last.timestamp defaulted');
226
+ assert.equal(typeof user.usage.sends.last.timestampUNIX, 'number', 'sends.last.timestampUNIX defaulted to number');
227
+ },
228
+ },
229
+
230
+ {
231
+ name: 'usage-empty-when-no-keys-provided',
232
+ async run({ assert }) {
233
+ const user = createUser({});
234
+
235
+ assert.deepEqual(user.usage, {}, 'usage should be empty object when no keys provided');
236
+ },
237
+ },
238
+
239
+ // ─── $passthrough: utm.tags ───
240
+
241
+ {
242
+ name: 'utm-tags-passthrough-preserves-all-tags',
243
+ async run({ assert }) {
244
+ const user = createUser({
245
+ attribution: {
246
+ utm: {
247
+ tags: { source: 'google', medium: 'cpc', campaign: 'summer' },
248
+ timestamp: '2025-06-01T00:00:00.000Z',
249
+ },
250
+ },
251
+ });
252
+
253
+ assert.equal(user.attribution.utm.tags.source, 'google', 'utm source preserved');
254
+ assert.equal(user.attribution.utm.tags.medium, 'cpc', 'utm medium preserved');
255
+ assert.equal(user.attribution.utm.tags.campaign, 'summer', 'utm campaign preserved');
256
+ assert.equal(user.attribution.utm.timestamp, '2025-06-01T00:00:00.000Z', 'utm timestamp preserved');
257
+ },
258
+ },
259
+
260
+ // ─── Type coercion ───
261
+
262
+ {
263
+ name: 'coerces-number-from-string',
264
+ async run({ assert }) {
265
+ const user = createUser({
266
+ activity: { geolocation: { latitude: '42.5', longitude: '-73.2' } },
267
+ personal: { telephone: { countryCode: '44', national: '7911123456' } },
268
+ });
269
+
270
+ assert.equal(user.activity.geolocation.latitude, 42.5, 'string "42.5" coerced to number');
271
+ assert.equal(user.activity.geolocation.longitude, -73.2, 'string "-73.2" coerced to number');
272
+ assert.equal(user.personal.telephone.countryCode, 44, 'string "44" coerced to number');
273
+ },
274
+ },
275
+
276
+ {
277
+ name: 'coerces-boolean-from-number',
278
+ async run({ assert }) {
279
+ const user = createUser({
280
+ auth: { temporary: 1 },
281
+ activity: { client: { mobile: 0 } },
282
+ });
283
+
284
+ assert.equal(user.auth.temporary, true, '1 coerced to true');
285
+ assert.equal(user.activity.client.mobile, false, '0 coerced to false');
286
+ },
287
+ },
288
+
289
+ {
290
+ name: 'coerces-boolean-from-string',
291
+ async run({ assert }) {
292
+ const user = createUser({
293
+ auth: { temporary: 'true' },
294
+ roles: { admin: 'false' },
295
+ });
296
+
297
+ assert.equal(user.auth.temporary, true, '"true" coerced to true');
298
+ assert.equal(user.roles.admin, false, '"false" coerced to false');
299
+ },
300
+ },
301
+
302
+ {
303
+ name: 'invalid-coercion-falls-back-to-default',
304
+ async run({ assert }) {
305
+ const user = createUser({
306
+ activity: { geolocation: { latitude: 'not-a-number', longitude: undefined } },
307
+ personal: { telephone: { national: 'abc' } },
308
+ });
309
+
310
+ assert.equal(user.activity.geolocation.latitude, 0, 'invalid string falls back to default 0');
311
+ assert.equal(user.activity.geolocation.longitude, 0, 'undefined falls back to default 0');
312
+ assert.equal(user.personal.telephone.national, 0, 'non-numeric string falls back to default 0');
313
+ },
314
+ },
315
+
316
+ // ─── Nullable fields ───
317
+
318
+ {
319
+ name: 'nullable-fields-preserve-null',
320
+ async run({ assert }) {
321
+ const user = createUser({
322
+ auth: { uid: null, email: null },
323
+ personal: { name: { first: null, last: null }, gender: null },
324
+ activity: { geolocation: { ip: null, country: null } },
325
+ });
326
+
327
+ assert.equal(user.auth.uid, null, 'null uid preserved');
328
+ assert.equal(user.auth.email, null, 'null email preserved');
329
+ assert.equal(user.personal.name.first, null, 'null first name preserved');
330
+ assert.equal(user.personal.gender, null, 'null gender preserved');
331
+ assert.equal(user.activity.geolocation.ip, null, 'null ip preserved');
332
+ },
333
+ },
334
+
335
+ {
336
+ name: 'non-nullable-fields-replace-null-with-default',
337
+ async run({ assert }) {
338
+ const user = createUser({
339
+ auth: { temporary: null },
340
+ roles: { admin: null },
341
+ activity: { geolocation: { latitude: null } },
342
+ });
343
+
344
+ // These are non-nullable, so null should be replaced with the schema default
345
+ assert.equal(user.auth.temporary, false, 'null temporary replaced with false');
346
+ assert.equal(user.roles.admin, false, 'null admin replaced with false');
347
+ assert.equal(user.activity.geolocation.latitude, 0, 'null latitude replaced with 0');
348
+ },
349
+ },
350
+
351
+ // ─── Fragmented / incomplete users ───
352
+
353
+ {
354
+ name: 'partial-subscription-fills-missing-fields',
355
+ async run({ assert }) {
356
+ const user = createUser({
357
+ subscription: { product: { id: 'premium' } },
358
+ });
359
+
360
+ assert.equal(user.subscription.product.id, 'premium', 'provided id preserved');
361
+ assert.equal(user.subscription.product.name, 'Basic', 'missing name gets default');
362
+ assert.equal(user.subscription.status, 'active', 'missing status gets default');
363
+ assert.equal(user.subscription.trial.claimed, false, 'missing trial.claimed gets default');
364
+ assert.ok(user.subscription.expires.timestamp, 'missing expires gets default timestamp');
365
+ },
366
+ },
367
+
368
+ {
369
+ name: 'partial-activity-fills-missing-fields',
370
+ async run({ assert }) {
371
+ const user = createUser({
372
+ activity: { geolocation: { country: 'US' } },
373
+ });
374
+
375
+ assert.equal(user.activity.geolocation.country, 'US', 'provided country preserved');
376
+ assert.equal(user.activity.geolocation.ip, null, 'missing ip defaults to null');
377
+ assert.equal(user.activity.geolocation.latitude, 0, 'missing latitude defaults to 0');
378
+ assert.ok(user.metadata.updated.timestamp, 'missing updated gets default');
379
+ assert.ok(user.metadata.created.timestamp, 'missing created gets default');
380
+ },
381
+ },
382
+
383
+ {
384
+ name: 'deeply-nested-partial-payment-fills-correctly',
385
+ async run({ assert }) {
386
+ const user = createUser({
387
+ subscription: {
388
+ payment: { processor: 'stripe', resourceId: 'sub_123' },
389
+ },
390
+ });
391
+
392
+ assert.equal(user.subscription.payment.processor, 'stripe', 'processor preserved');
393
+ assert.equal(user.subscription.payment.orderId, null, 'missing orderId defaults to null');
394
+ assert.equal(user.subscription.payment.resourceId, 'sub_123', 'resourceId preserved');
395
+ assert.equal(user.subscription.payment.frequency, null, 'missing frequency defaults to null');
396
+ assert.ok(user.subscription.payment.startDate.timestamp, 'missing startDate gets default');
397
+ assert.equal(user.subscription.payment.updatedBy.event.name, null, 'missing event.name defaults to null');
398
+ },
399
+ },
400
+
401
+ // ─── Top-level structure ───
402
+
403
+ {
404
+ name: 'all-top-level-keys-present',
405
+ async run({ assert }) {
406
+ const user = createUser({});
407
+ const expectedKeys = [
408
+ 'auth', 'subscription', 'roles', 'flags', 'affiliate',
409
+ 'activity', 'api', 'usage', 'personal', 'oauth2', 'attribution', 'consent', 'metadata',
410
+ ];
411
+
412
+ for (const key of expectedKeys) {
413
+ assert.ok(key in user, `Top-level key "${key}" should exist`);
414
+ }
415
+ },
416
+ },
417
+
418
+ {
419
+ name: 'no-extra-top-level-keys',
420
+ async run({ assert }) {
421
+ const user = createUser({});
422
+ const expectedKeys = [
423
+ 'auth', 'subscription', 'roles', 'flags', 'affiliate',
424
+ 'activity', 'api', 'usage', 'personal', 'oauth2', 'attribution', 'consent', 'metadata',
425
+ ];
426
+
427
+ for (const key of Object.keys(user)) {
428
+ assert.ok(expectedKeys.includes(key), `Unexpected top-level key "${key}"`);
429
+ }
430
+ },
431
+ },
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
+
672
+ // ─── Unique/generated values ───
673
+
674
+ {
675
+ name: 'api-keys-are-unique-per-call',
676
+ async run({ assert }) {
677
+ const u1 = createUser({});
678
+ const u2 = createUser({});
679
+
680
+ // privateKey uses UIDGenerator which should produce unique values
681
+ assert.notEqual(u1.api.privateKey, u2.api.privateKey, 'privateKey should be unique per call');
682
+ assert.notEqual(u1.api.clientId, u2.api.clientId, 'clientId should be unique per call');
683
+ },
684
+ },
685
+
686
+ {
687
+ name: 'existing-api-keys-not-overwritten',
688
+ async run({ assert }) {
689
+ const user = createUser({
690
+ api: { clientId: 'my-client-id', privateKey: 'my-secret-key' },
691
+ });
692
+
693
+ assert.equal(user.api.clientId, 'my-client-id', 'existing clientId preserved');
694
+ assert.equal(user.api.privateKey, 'my-secret-key', 'existing privateKey preserved');
695
+ },
696
+ },
697
+
698
+ // ─── Complex real-world user ───
699
+
700
+ {
701
+ name: 'full-real-world-user-preserves-everything',
702
+ async run({ assert }) {
703
+ const user = createUser({
704
+ auth: { uid: 'V4U9wR0AiLUQRxpcP7WhgA4FX9H2', email: 'ian@example.com', temporary: false },
705
+ subscription: {
706
+ product: { id: 'premium', name: 'Premium' },
707
+ status: 'active',
708
+ expires: { timestamp: '2026-12-31T00:00:00.000Z', timestampUNIX: 1798761600 },
709
+ trial: { claimed: true, expires: { timestamp: '2024-01-01T00:00:00.000Z', timestampUNIX: 1704067200 } },
710
+ payment: { processor: 'stripe', resourceId: 'sub_abc', frequency: 'annually' },
711
+ },
712
+ roles: { admin: true, betaTester: true, developer: true, superAdmin: true },
713
+ flags: { signupProcessed: true, onboarded: true },
714
+ affiliate: { code: 'IAN7', referrals: ['ref1', 'ref2'] },
715
+ api: { clientId: 'uuid-123', privateKey: 'key-456' },
716
+ oauth2: {
717
+ google: {
718
+ token: { access_token: 'ya29.real', refresh_token: '1//real', expiry_date: 1700000000 },
719
+ identity: { email: 'ian@gmail.com', name: 'Ian W', picture: 'https://photo.url' },
720
+ },
721
+ },
722
+ usage: {
723
+ requests: { monthly: 100, total: 5000, last: { id: 'req-z', timestamp: '2025-12-01T00:00:00.000Z', timestampUNIX: 1764633600 } },
724
+ emails: { monthly: 42, total: 2100, last: { id: 'em-z', timestamp: '2025-12-01T00:00:00.000Z', timestampUNIX: 1764633600 } },
725
+ },
726
+ personal: {
727
+ name: { first: 'Ian', last: 'Wiedenman' },
728
+ company: { name: 'ITW Creative Works', position: 'CEO' },
729
+ birthday: { timestamp: '1990-05-15T00:00:00.000Z', timestampUNIX: 642988800 },
730
+ },
731
+ attribution: {
732
+ affiliate: { code: 'PARTNER1', timestamp: '2024-06-01T00:00:00.000Z' },
733
+ utm: { tags: { source: 'twitter', campaign: 'launch' }, url: 'https://example.com' },
734
+ },
735
+ });
736
+
737
+ // Everything should be preserved exactly
738
+ assert.equal(user.auth.uid, 'V4U9wR0AiLUQRxpcP7WhgA4FX9H2', 'uid preserved');
739
+ assert.equal(user.subscription.product.id, 'premium', 'product preserved');
740
+ assert.equal(user.roles.superAdmin, true, 'custom role preserved');
741
+ assert.equal(user.flags.onboarded, true, 'custom flag preserved');
742
+ assert.equal(user.affiliate.code, 'IAN7', 'affiliate code preserved');
743
+ assert.deepEqual(user.affiliate.referrals, ['ref1', 'ref2'], 'referrals preserved');
744
+ assert.equal(user.api.clientId, 'uuid-123', 'api clientId preserved');
745
+ assert.equal(user.oauth2.google.token.access_token, 'ya29.real', 'oauth2 token preserved');
746
+ assert.equal(user.oauth2.google.identity.email, 'ian@gmail.com', 'oauth2 identity preserved');
747
+ assert.equal(user.usage.emails.monthly, 42, 'usage emails preserved');
748
+ assert.equal(user.personal.name.first, 'Ian', 'name preserved');
749
+ assert.equal(user.personal.company.name, 'ITW Creative Works', 'company preserved');
750
+ assert.equal(user.personal.birthday.timestampUNIX, 642988800, 'birthday preserved');
751
+ assert.equal(user.attribution.utm.tags.source, 'twitter', 'utm tags preserved');
752
+ },
753
+ },
754
+ ],
755
+ };