backend-manager 5.0.80 → 5.0.81
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/package.json +1 -1
- package/src/manager/functions/core/actions/api/admin/send-email.js +1 -1
- package/src/manager/functions/core/actions/api/test/health.js +1 -0
- package/src/manager/helpers/user.js +308 -189
- package/src/manager/index.js +4 -0
- package/src/manager/routes/admin/email/post.js +1 -1
- package/src/manager/routes/test/health/get.js +1 -0
- package/test/helpers/user.js +543 -0
- package/.claude/settings.local.json +0 -9
package/package.json
CHANGED
|
@@ -192,7 +192,7 @@ Module.prototype.defaultize = function () {
|
|
|
192
192
|
email.dynamicTemplateData.signoff.urlText = options?.data?.signoff?.urlText || '@ianwieds';
|
|
193
193
|
}
|
|
194
194
|
|
|
195
|
-
email.dynamicTemplateData.user = Manager.User(options.user
|
|
195
|
+
email.dynamicTemplateData.user = Manager.User(options.user).properties;
|
|
196
196
|
|
|
197
197
|
// Get app configuration from Manager.config.brand (backend-manager-config.json)
|
|
198
198
|
const brand = Manager.config?.brand;
|
|
@@ -13,6 +13,7 @@ Module.prototype.main = function () {
|
|
|
13
13
|
timestamp: new Date().toISOString(),
|
|
14
14
|
environment: assistant.meta?.environment || 'unknown',
|
|
15
15
|
version: Manager.package?.version || 'unknown',
|
|
16
|
+
bemVersion: Manager.version || 'unknown',
|
|
16
17
|
};
|
|
17
18
|
|
|
18
19
|
assistant.log('Health check', response);
|
|
@@ -1,225 +1,344 @@
|
|
|
1
|
-
const _ = require('lodash');
|
|
2
1
|
const uuid4 = require('uuid').v4;
|
|
3
|
-
const shortid = require('shortid');
|
|
4
2
|
const powertools = require('node-powertools');
|
|
5
3
|
const UIDGenerator = require('uid-generator');
|
|
6
4
|
const uidgen = new UIDGenerator(256);
|
|
7
5
|
|
|
8
6
|
/**
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
7
|
+
* User schema definition
|
|
8
|
+
*
|
|
9
|
+
* Each leaf field is { type, default, nullable }
|
|
10
|
+
* Special keys:
|
|
11
|
+
* $passthrough — preserve all existing keys from input, don't strip unknowns
|
|
12
|
+
* $template — shape applied to every dynamic key in a $passthrough object
|
|
13
|
+
* '$template' — (string value) reference to parent's $template
|
|
14
|
+
* '$timestamp' — shorthand for { timestamp, timestampUNIX } defaulting to epoch
|
|
15
|
+
* '$timestamp:now' — same but defaults to current time
|
|
16
|
+
* '$uuid', '$randomId', '$apiKey', '$oldDate' — resolved at runtime
|
|
14
17
|
*/
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
options = options || {};
|
|
26
|
-
|
|
27
|
-
options.defaults = typeof options.defaults === 'undefined' ? true : options.defaults;
|
|
28
|
-
options.prune = typeof options.prune === 'undefined' ? false : options.prune;
|
|
29
|
-
|
|
30
|
-
const now = powertools.timestamp(new Date(), {output: 'string'});
|
|
31
|
-
const nowUNIX = powertools.timestamp(now, {output: 'unix'});
|
|
32
|
-
const oldDate = powertools.timestamp(new Date(0), {output: 'string'})
|
|
33
|
-
const oldDateUNIX = powertools.timestamp(oldDate, {output: 'unix'});
|
|
34
|
-
|
|
35
|
-
const defaults = options.defaults;
|
|
36
|
-
|
|
37
|
-
self.properties = {
|
|
38
|
-
auth: {
|
|
39
|
-
uid: settings?.auth?.uid ?? null,
|
|
40
|
-
email: settings?.auth?.email ?? null,
|
|
41
|
-
temporary: getWithDefault(settings?.auth?.temporary, false, defaults),
|
|
18
|
+
const SCHEMA = {
|
|
19
|
+
auth: {
|
|
20
|
+
uid: { type: 'string', default: null, nullable: true },
|
|
21
|
+
email: { type: 'string', default: null, nullable: true },
|
|
22
|
+
temporary: { type: 'boolean', default: false },
|
|
23
|
+
},
|
|
24
|
+
subscription: {
|
|
25
|
+
product: {
|
|
26
|
+
id: { type: 'string', default: 'basic' },
|
|
27
|
+
name: { type: 'string', default: 'Basic' },
|
|
42
28
|
},
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
timestamp: getWithDefault(settings?.subscription?.cancellation?.date?.timestamp, oldDate, defaults),
|
|
64
|
-
timestampUNIX: getWithDefault(settings?.subscription?.cancellation?.date?.timestampUNIX, oldDateUNIX, defaults),
|
|
65
|
-
},
|
|
66
|
-
},
|
|
67
|
-
limits: {
|
|
68
|
-
// devices: settings?.subscription?.limits?.devices ?? null,
|
|
69
|
-
},
|
|
70
|
-
payment: {
|
|
71
|
-
processor: settings?.subscription?.payment?.processor ?? null, // 'stripe' | 'paypal' | etc
|
|
72
|
-
resourceId: settings?.subscription?.payment?.resourceId ?? null, // subscription ID from provider (e.g., 'sub_xxx')
|
|
73
|
-
frequency: settings?.subscription?.payment?.frequency ?? null, // 'monthly' | 'annually'
|
|
74
|
-
startDate: {
|
|
75
|
-
timestamp: getWithDefault(settings?.subscription?.payment?.startDate?.timestamp, oldDate, defaults),
|
|
76
|
-
timestampUNIX: getWithDefault(settings?.subscription?.payment?.startDate?.timestampUNIX, oldDateUNIX, defaults),
|
|
77
|
-
},
|
|
78
|
-
updatedBy: {
|
|
79
|
-
event: {
|
|
80
|
-
name: settings?.subscription?.payment?.updatedBy?.event?.name ?? null,
|
|
81
|
-
id: settings?.subscription?.payment?.updatedBy?.event?.id ?? null,
|
|
82
|
-
},
|
|
83
|
-
date: {
|
|
84
|
-
timestamp: getWithDefault(settings?.subscription?.payment?.updatedBy?.date?.timestamp, oldDate, defaults),
|
|
85
|
-
timestampUNIX: getWithDefault(settings?.subscription?.payment?.updatedBy?.date?.timestampUNIX, oldDateUNIX, defaults),
|
|
86
|
-
},
|
|
29
|
+
status: { type: 'string', default: 'active' },
|
|
30
|
+
expires: '$timestamp',
|
|
31
|
+
trial: {
|
|
32
|
+
claimed: { type: 'boolean', default: false },
|
|
33
|
+
expires: '$timestamp',
|
|
34
|
+
},
|
|
35
|
+
cancellation: {
|
|
36
|
+
pending: { type: 'boolean', default: false },
|
|
37
|
+
date: '$timestamp',
|
|
38
|
+
},
|
|
39
|
+
limits: { $passthrough: true },
|
|
40
|
+
payment: {
|
|
41
|
+
processor: { type: 'string', default: null, nullable: true },
|
|
42
|
+
resourceId: { type: 'string', default: null, nullable: true },
|
|
43
|
+
frequency: { type: 'string', default: null, nullable: true },
|
|
44
|
+
startDate: '$timestamp',
|
|
45
|
+
updatedBy: {
|
|
46
|
+
event: {
|
|
47
|
+
name: { type: 'string', default: null, nullable: true },
|
|
48
|
+
id: { type: 'string', default: null, nullable: true },
|
|
87
49
|
},
|
|
50
|
+
date: '$timestamp',
|
|
88
51
|
},
|
|
89
52
|
},
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
},
|
|
95
|
-
|
|
96
|
-
|
|
53
|
+
},
|
|
54
|
+
roles: {
|
|
55
|
+
$passthrough: true,
|
|
56
|
+
admin: { type: 'boolean', default: false },
|
|
57
|
+
betaTester: { type: 'boolean', default: false },
|
|
58
|
+
developer: { type: 'boolean', default: false },
|
|
59
|
+
},
|
|
60
|
+
flags: {
|
|
61
|
+
$passthrough: true,
|
|
62
|
+
signupProcessed: { type: 'boolean', default: false },
|
|
63
|
+
},
|
|
64
|
+
affiliate: {
|
|
65
|
+
code: { type: 'string', default: '$randomId' },
|
|
66
|
+
referrals: { type: 'array', default: [] },
|
|
67
|
+
},
|
|
68
|
+
activity: {
|
|
69
|
+
lastActivity: '$timestamp:now',
|
|
70
|
+
created: '$timestamp:now',
|
|
71
|
+
geolocation: {
|
|
72
|
+
ip: { type: 'string', default: null, nullable: true },
|
|
73
|
+
continent: { type: 'string', default: null, nullable: true },
|
|
74
|
+
country: { type: 'string', default: null, nullable: true },
|
|
75
|
+
region: { type: 'string', default: null, nullable: true },
|
|
76
|
+
city: { type: 'string', default: null, nullable: true },
|
|
77
|
+
latitude: { type: 'number', default: 0 },
|
|
78
|
+
longitude: { type: 'number', default: 0 },
|
|
97
79
|
},
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
80
|
+
client: {
|
|
81
|
+
language: { type: 'string', default: null, nullable: true },
|
|
82
|
+
mobile: { type: 'boolean', default: false },
|
|
83
|
+
device: { type: 'string', default: null, nullable: true },
|
|
84
|
+
platform: { type: 'string', default: null, nullable: true },
|
|
85
|
+
browser: { type: 'string', default: null, nullable: true },
|
|
86
|
+
vendor: { type: 'string', default: null, nullable: true },
|
|
87
|
+
runtime: { type: 'string', default: null, nullable: true },
|
|
88
|
+
userAgent: { type: 'string', default: null, nullable: true },
|
|
89
|
+
url: { type: 'string', default: null, nullable: true },
|
|
101
90
|
},
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
},
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
city: settings?.activity?.geolocation?.city ?? null,
|
|
117
|
-
latitude: getWithDefault(settings?.activity?.geolocation?.latitude, 0, defaults),
|
|
118
|
-
longitude: getWithDefault(settings?.activity?.geolocation?.longitude, 0, defaults),
|
|
119
|
-
},
|
|
120
|
-
client: {
|
|
121
|
-
language: settings?.activity?.client?.language ?? null,
|
|
122
|
-
mobile: getWithDefault(settings?.activity?.client?.mobile, false, defaults),
|
|
123
|
-
device: settings?.activity?.client?.device ?? null,
|
|
124
|
-
platform: settings?.activity?.client?.platform ?? null,
|
|
125
|
-
browser: settings?.activity?.client?.browser ?? null,
|
|
126
|
-
vendor: settings?.activity?.client?.vendor ?? null,
|
|
127
|
-
runtime: settings?.activity?.client?.runtime ?? null,
|
|
128
|
-
userAgent: settings?.activity?.client?.userAgent ?? null,
|
|
129
|
-
url: settings?.activity?.client?.url ?? null,
|
|
91
|
+
},
|
|
92
|
+
api: {
|
|
93
|
+
clientId: { type: 'string', default: '$uuid' },
|
|
94
|
+
privateKey: { type: 'string', default: '$apiKey' },
|
|
95
|
+
},
|
|
96
|
+
usage: {
|
|
97
|
+
$passthrough: true,
|
|
98
|
+
$template: {
|
|
99
|
+
period: { type: 'number', default: 0 },
|
|
100
|
+
total: { type: 'number', default: 0 },
|
|
101
|
+
last: {
|
|
102
|
+
id: { type: 'string', default: null, nullable: true },
|
|
103
|
+
timestamp: { type: 'string', default: '$oldDate' },
|
|
104
|
+
timestampUNIX: { type: 'number', default: 0 },
|
|
130
105
|
},
|
|
131
106
|
},
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
107
|
+
requests: '$template',
|
|
108
|
+
},
|
|
109
|
+
personal: {
|
|
110
|
+
birthday: '$timestamp',
|
|
111
|
+
gender: { type: 'string', default: null, nullable: true },
|
|
112
|
+
location: {
|
|
113
|
+
country: { type: 'string', default: null, nullable: true },
|
|
114
|
+
region: { type: 'string', default: null, nullable: true },
|
|
115
|
+
city: { type: 'string', default: null, nullable: true },
|
|
135
116
|
},
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
total: getWithDefault(settings?.usage?.requests?.total, 0, defaults),
|
|
140
|
-
last: {
|
|
141
|
-
id: settings?.usage?.requests?.last?.id ?? null,
|
|
142
|
-
timestamp: getWithDefault(settings?.usage?.requests?.last?.timestamp, oldDate, defaults),
|
|
143
|
-
timestampUNIX: getWithDefault(settings?.usage?.requests?.last?.timestampUNIX, oldDateUNIX, defaults),
|
|
144
|
-
},
|
|
145
|
-
},
|
|
117
|
+
name: {
|
|
118
|
+
first: { type: 'string', default: null, nullable: true },
|
|
119
|
+
last: { type: 'string', default: null, nullable: true },
|
|
146
120
|
},
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
timestampUNIX: getWithDefault(settings?.personal?.birthday?.timestampUNIX, oldDateUNIX, defaults),
|
|
151
|
-
},
|
|
152
|
-
gender: settings?.personal?.gender ?? null,
|
|
153
|
-
location: {
|
|
154
|
-
country: settings?.personal?.location?.country ?? null,
|
|
155
|
-
region: settings?.personal?.location?.region ?? null,
|
|
156
|
-
city: settings?.personal?.location?.city ?? null,
|
|
157
|
-
},
|
|
158
|
-
name: {
|
|
159
|
-
first: settings?.personal?.name?.first ?? null,
|
|
160
|
-
last: settings?.personal?.name?.last ?? null,
|
|
161
|
-
},
|
|
162
|
-
company: {
|
|
163
|
-
name: settings?.personal?.company?.name ?? null,
|
|
164
|
-
position: settings?.personal?.company?.position ?? null,
|
|
165
|
-
},
|
|
166
|
-
telephone: {
|
|
167
|
-
countryCode: getWithDefault(settings?.personal?.telephone?.countryCode, 0, defaults),
|
|
168
|
-
national: getWithDefault(settings?.personal?.telephone?.national, 0, defaults),
|
|
169
|
-
},
|
|
121
|
+
company: {
|
|
122
|
+
name: { type: 'string', default: null, nullable: true },
|
|
123
|
+
position: { type: 'string', default: null, nullable: true },
|
|
170
124
|
},
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
// timestampUNIX: getWithDefault(settings?.oauth2?.updated?.timestampUNIX, oldDateUNIX, defaults),
|
|
175
|
-
// },
|
|
125
|
+
telephone: {
|
|
126
|
+
countryCode: { type: 'number', default: 0 },
|
|
127
|
+
national: { type: 'number', default: 0 },
|
|
176
128
|
},
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
},
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
url: settings?.attribution?.utm?.url ?? null,
|
|
188
|
-
page: settings?.attribution?.utm?.page ?? null,
|
|
189
|
-
},
|
|
129
|
+
},
|
|
130
|
+
oauth2: {
|
|
131
|
+
$passthrough: true,
|
|
132
|
+
},
|
|
133
|
+
attribution: {
|
|
134
|
+
affiliate: {
|
|
135
|
+
code: { type: 'string', default: null, nullable: true },
|
|
136
|
+
timestamp: { type: 'string', default: null, nullable: true },
|
|
137
|
+
url: { type: 'string', default: null, nullable: true },
|
|
138
|
+
page: { type: 'string', default: null, nullable: true },
|
|
190
139
|
},
|
|
140
|
+
utm: {
|
|
141
|
+
tags: { $passthrough: true },
|
|
142
|
+
timestamp: { type: 'string', default: null, nullable: true },
|
|
143
|
+
url: { type: 'string', default: null, nullable: true },
|
|
144
|
+
page: { type: 'string', default: null, nullable: true },
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Check if a schema node is a leaf field definition (has 'type' and 'default')
|
|
151
|
+
*/
|
|
152
|
+
function isLeaf(node) {
|
|
153
|
+
return node !== null
|
|
154
|
+
&& typeof node === 'object'
|
|
155
|
+
&& typeof node.type === 'string'
|
|
156
|
+
&& 'default' in node;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Coerce a value to the expected type. Returns the coerced value or undefined if coercion fails.
|
|
161
|
+
*/
|
|
162
|
+
function coerce(value, type) {
|
|
163
|
+
if (typeof value === type) {
|
|
164
|
+
return value;
|
|
191
165
|
}
|
|
192
166
|
|
|
193
|
-
|
|
194
|
-
|
|
167
|
+
switch (type) {
|
|
168
|
+
case 'number': {
|
|
169
|
+
const n = Number(value);
|
|
170
|
+
return Number.isNaN(n) ? undefined : n;
|
|
171
|
+
}
|
|
172
|
+
case 'boolean': {
|
|
173
|
+
if (value === 'true' || value === 1) return true;
|
|
174
|
+
if (value === 'false' || value === 0) return false;
|
|
175
|
+
return Boolean(value);
|
|
176
|
+
}
|
|
177
|
+
case 'string': {
|
|
178
|
+
return String(value);
|
|
179
|
+
}
|
|
180
|
+
default: {
|
|
181
|
+
return undefined;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Resolve a single leaf field value
|
|
188
|
+
*/
|
|
189
|
+
function resolveLeaf(leaf, value, ctx) {
|
|
190
|
+
// Null handling
|
|
191
|
+
if (value === null) {
|
|
192
|
+
return leaf.nullable ? null : resolveDefault(leaf.default, ctx);
|
|
195
193
|
}
|
|
196
194
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
return
|
|
195
|
+
// Undefined → apply default
|
|
196
|
+
if (value === undefined) {
|
|
197
|
+
return resolveDefault(leaf.default, ctx);
|
|
200
198
|
}
|
|
201
199
|
|
|
202
|
-
|
|
200
|
+
// Array type — just check it's an array, don't coerce
|
|
201
|
+
if (leaf.type === 'array') {
|
|
202
|
+
return Array.isArray(value) ? value : resolveDefault(leaf.default, ctx);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Type coercion
|
|
206
|
+
if (typeof value !== leaf.type) {
|
|
207
|
+
const coerced = coerce(value, leaf.type);
|
|
208
|
+
return coerced !== undefined ? coerced : resolveDefault(leaf.default, ctx);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return value;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Resolve a default value, handling special tokens
|
|
216
|
+
*/
|
|
217
|
+
function resolveDefault(def, ctx) {
|
|
218
|
+
if (typeof def !== 'string' || !def.startsWith('$')) {
|
|
219
|
+
// For arrays, return a fresh copy to avoid shared references
|
|
220
|
+
if (Array.isArray(def)) {
|
|
221
|
+
return [...def];
|
|
222
|
+
}
|
|
223
|
+
return def;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
switch (def) {
|
|
227
|
+
case '$uuid':
|
|
228
|
+
return `${uuid4()}`;
|
|
229
|
+
case '$randomId':
|
|
230
|
+
return ctx.Manager.Utilities().randomId({ size: 7 });
|
|
231
|
+
case '$apiKey':
|
|
232
|
+
return `${uidgen.generateSync()}`;
|
|
233
|
+
case '$oldDate':
|
|
234
|
+
return ctx.oldDate;
|
|
235
|
+
case '$oldDateUNIX':
|
|
236
|
+
return ctx.oldDateUNIX;
|
|
237
|
+
case '$now':
|
|
238
|
+
return ctx.now;
|
|
239
|
+
case '$nowUNIX':
|
|
240
|
+
return ctx.nowUNIX;
|
|
241
|
+
default:
|
|
242
|
+
return def;
|
|
243
|
+
}
|
|
203
244
|
}
|
|
204
245
|
|
|
246
|
+
/**
|
|
247
|
+
* Expand $timestamp shorthand into a schema branch
|
|
248
|
+
*/
|
|
249
|
+
function expandTimestamp(variant) {
|
|
250
|
+
const useNow = variant === '$timestamp:now';
|
|
205
251
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
(_.isObject(value) && _.isEmpty(prune(value)))) {
|
|
252
|
+
return {
|
|
253
|
+
timestamp: { type: 'string', default: useNow ? '$now' : '$oldDate' },
|
|
254
|
+
timestampUNIX: { type: 'number', default: useNow ? '$nowUNIX' : 0 },
|
|
255
|
+
};
|
|
256
|
+
}
|
|
212
257
|
|
|
213
|
-
|
|
258
|
+
/**
|
|
259
|
+
* Recursively resolve a schema node against input data
|
|
260
|
+
*/
|
|
261
|
+
function resolve(schema, data, ctx) {
|
|
262
|
+
data = data || {};
|
|
263
|
+
const result = {};
|
|
264
|
+
|
|
265
|
+
// If $passthrough, start by copying all existing keys from data
|
|
266
|
+
const isPassthrough = schema.$passthrough === true;
|
|
267
|
+
const template = schema.$template || null;
|
|
268
|
+
|
|
269
|
+
if (isPassthrough) {
|
|
270
|
+
// Copy all data keys first (they'll be overwritten by defined schema fields below)
|
|
271
|
+
for (const key of Object.keys(data)) {
|
|
272
|
+
if (template && !key.startsWith('$') && !(key in schema)) {
|
|
273
|
+
// Dynamic key — resolve against template
|
|
274
|
+
result[key] = resolve(template, data[key], ctx);
|
|
275
|
+
} else if (!(key in schema) || key.startsWith('$')) {
|
|
276
|
+
// Unknown key not in schema — passthrough as-is
|
|
277
|
+
result[key] = data[key];
|
|
214
278
|
}
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
// operation on an array
|
|
218
|
-
if (_.isArray(current)) _.pull(current, undefined);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
219
281
|
|
|
220
|
-
|
|
282
|
+
// Now resolve each defined schema field
|
|
283
|
+
for (const [key, node] of Object.entries(schema)) {
|
|
284
|
+
// Skip meta keys
|
|
285
|
+
if (key.startsWith('$')) {
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Handle string shorthands
|
|
290
|
+
if (typeof node === 'string') {
|
|
291
|
+
if (node === '$template') {
|
|
292
|
+
// Resolve against parent's $template
|
|
293
|
+
result[key] = resolve(template, data[key], ctx);
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
if (node.startsWith('$timestamp')) {
|
|
297
|
+
// Expand timestamp shorthand and recurse
|
|
298
|
+
result[key] = resolve(expandTimestamp(node), data[key], ctx);
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Leaf field
|
|
304
|
+
if (isLeaf(node)) {
|
|
305
|
+
result[key] = resolveLeaf(node, data[key], ctx);
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Nested branch (plain object)
|
|
310
|
+
if (node !== null && typeof node === 'object') {
|
|
311
|
+
result[key] = resolve(node, data[key], ctx);
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
return result;
|
|
317
|
+
}
|
|
221
318
|
|
|
222
|
-
|
|
319
|
+
// ─── User constructor ───
|
|
320
|
+
|
|
321
|
+
function User(Manager, settings) {
|
|
322
|
+
const self = this;
|
|
323
|
+
|
|
324
|
+
self.Manager = Manager;
|
|
325
|
+
|
|
326
|
+
settings = settings || {};
|
|
327
|
+
|
|
328
|
+
// Build resolver context
|
|
329
|
+
const now = powertools.timestamp(new Date(), { output: 'string' });
|
|
330
|
+
const ctx = {
|
|
331
|
+
Manager,
|
|
332
|
+
now: now,
|
|
333
|
+
nowUNIX: powertools.timestamp(now, { output: 'unix' }),
|
|
334
|
+
oldDate: powertools.timestamp(new Date(0), { output: 'string' }),
|
|
335
|
+
oldDateUNIX: powertools.timestamp(new Date(0), { output: 'unix' }),
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
// Resolve
|
|
339
|
+
self.properties = resolve(SCHEMA, settings, ctx);
|
|
340
|
+
|
|
341
|
+
return self;
|
|
223
342
|
}
|
|
224
343
|
|
|
225
344
|
module.exports = User;
|
package/src/manager/index.js
CHANGED
|
@@ -18,10 +18,14 @@ const cron = path.resolve(__dirname, './cron');
|
|
|
18
18
|
const events = path.resolve(__dirname, './events');
|
|
19
19
|
|
|
20
20
|
const BEM_CONFIG_TEMPLATE_PATH = path.resolve(__dirname, '../../templates/backend-manager-config.json');
|
|
21
|
+
const BEM_PACKAGE = require('../../package.json');
|
|
21
22
|
|
|
22
23
|
function Manager() {
|
|
23
24
|
const self = this;
|
|
24
25
|
|
|
26
|
+
// BEM library version
|
|
27
|
+
self.version = BEM_PACKAGE.version;
|
|
28
|
+
|
|
25
29
|
// Constants
|
|
26
30
|
self.SERVER_UUID = '11111111-1111-1111-1111-111111111111';
|
|
27
31
|
|
|
@@ -171,7 +171,7 @@ async function buildEmail(Manager, assistant, settings) {
|
|
|
171
171
|
email.dynamicTemplateData.signoff.urlText = settings?.data?.signoff?.urlText || '@ianwieds';
|
|
172
172
|
}
|
|
173
173
|
|
|
174
|
-
email.dynamicTemplateData.user = Manager.User(settings.user
|
|
174
|
+
email.dynamicTemplateData.user = Manager.User(settings.user).properties;
|
|
175
175
|
|
|
176
176
|
// Get app configuration from Manager.config.brand
|
|
177
177
|
const brand = Manager.config?.brand;
|
|
@@ -5,6 +5,7 @@ module.exports = async ({ assistant, Manager }) => {
|
|
|
5
5
|
timestamp: new Date().toISOString(),
|
|
6
6
|
environment: assistant.meta?.environment || 'unknown',
|
|
7
7
|
version: Manager.package?.version || 'unknown',
|
|
8
|
+
bemVersion: Manager.version || 'unknown',
|
|
8
9
|
};
|
|
9
10
|
|
|
10
11
|
assistant.log('Health check', response);
|
|
@@ -0,0 +1,543 @@
|
|
|
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
|
+
// Activity
|
|
62
|
+
assert.ok(user.activity.lastActivity.timestamp, 'activity.lastActivity.timestamp should exist');
|
|
63
|
+
assert.ok(user.activity.created.timestamp, 'activity.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.equal(user.usage.requests.period, 0, 'usage.requests.period should be 0');
|
|
76
|
+
assert.equal(user.usage.requests.total, 0, 'usage.requests.total should be 0');
|
|
77
|
+
assert.equal(user.usage.requests.last.id, null, 'usage.requests.last.id should be null');
|
|
78
|
+
|
|
79
|
+
// Personal
|
|
80
|
+
assert.equal(user.personal.name.first, null, 'personal.name.first should be null');
|
|
81
|
+
assert.equal(user.personal.name.last, null, 'personal.name.last should be null');
|
|
82
|
+
assert.equal(user.personal.telephone.countryCode, 0, 'telephone.countryCode should be 0');
|
|
83
|
+
|
|
84
|
+
// OAuth2
|
|
85
|
+
assert.deepEqual(user.oauth2, {}, 'oauth2 should be empty object');
|
|
86
|
+
|
|
87
|
+
// Attribution
|
|
88
|
+
assert.equal(user.attribution.affiliate.code, null, 'attribution.affiliate.code should be null');
|
|
89
|
+
assert.deepEqual(user.attribution.utm.tags, {}, 'attribution.utm.tags should be empty object');
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
|
|
93
|
+
{
|
|
94
|
+
name: 'undefined-settings-gets-all-defaults',
|
|
95
|
+
async run({ assert }) {
|
|
96
|
+
const user = new User(Manager).properties;
|
|
97
|
+
|
|
98
|
+
assert.equal(user.auth.uid, null, 'auth.uid should be null');
|
|
99
|
+
assert.equal(user.subscription.product.id, 'basic', 'subscription.product.id should be basic');
|
|
100
|
+
assert.equal(user.roles.admin, false, 'roles.admin should be false');
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
// ─── Preserving real user data ───
|
|
105
|
+
|
|
106
|
+
{
|
|
107
|
+
name: 'real-data-takes-precedence-over-defaults',
|
|
108
|
+
async run({ assert }) {
|
|
109
|
+
const user = createUser({
|
|
110
|
+
auth: { uid: 'user123', email: 'test@test.com', temporary: true },
|
|
111
|
+
subscription: { product: { id: 'pro', name: 'Pro' }, status: 'cancelled' },
|
|
112
|
+
roles: { admin: true, betaTester: true, developer: false },
|
|
113
|
+
personal: { name: { first: 'Ian', last: 'W' } },
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
assert.equal(user.auth.uid, 'user123', 'auth.uid should be preserved');
|
|
117
|
+
assert.equal(user.auth.email, 'test@test.com', 'auth.email should be preserved');
|
|
118
|
+
assert.equal(user.auth.temporary, true, 'auth.temporary should be preserved');
|
|
119
|
+
assert.equal(user.subscription.product.id, 'pro', 'subscription.product.id should be preserved');
|
|
120
|
+
assert.equal(user.subscription.status, 'cancelled', 'subscription.status should be preserved');
|
|
121
|
+
assert.equal(user.roles.admin, true, 'roles.admin should be preserved');
|
|
122
|
+
assert.equal(user.personal.name.first, 'Ian', 'personal.name.first should be preserved');
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
|
|
126
|
+
// ─── $passthrough: oauth2 ───
|
|
127
|
+
|
|
128
|
+
{
|
|
129
|
+
name: 'oauth2-passthrough-preserves-provider-data',
|
|
130
|
+
async run({ assert }) {
|
|
131
|
+
const googleToken = {
|
|
132
|
+
access_token: 'ya29.xxx',
|
|
133
|
+
refresh_token: '1//xxx',
|
|
134
|
+
expiry_date: 1700000000,
|
|
135
|
+
};
|
|
136
|
+
const msToken = {
|
|
137
|
+
access_token: 'eyJ.xxx',
|
|
138
|
+
refresh_token: 'M.xxx',
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const user = createUser({
|
|
142
|
+
oauth2: {
|
|
143
|
+
google: {
|
|
144
|
+
token: googleToken,
|
|
145
|
+
identity: { email: 'g@gmail.com', name: 'Test' },
|
|
146
|
+
},
|
|
147
|
+
microsoft: {
|
|
148
|
+
token: msToken,
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
assert.equal(user.oauth2.google.token.access_token, 'ya29.xxx', 'google access_token preserved');
|
|
154
|
+
assert.equal(user.oauth2.google.token.refresh_token, '1//xxx', 'google refresh_token preserved');
|
|
155
|
+
assert.equal(user.oauth2.google.identity.email, 'g@gmail.com', 'google identity preserved');
|
|
156
|
+
assert.equal(user.oauth2.microsoft.token.access_token, 'eyJ.xxx', 'microsoft token preserved');
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
|
|
160
|
+
{
|
|
161
|
+
name: 'oauth2-empty-when-not-provided',
|
|
162
|
+
async run({ assert }) {
|
|
163
|
+
const user = createUser({});
|
|
164
|
+
assert.deepEqual(user.oauth2, {}, 'oauth2 should be empty object when not provided');
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
|
|
168
|
+
// ─── $passthrough: subscription.limits ───
|
|
169
|
+
|
|
170
|
+
{
|
|
171
|
+
name: 'subscription-limits-passthrough-preserves-all-keys',
|
|
172
|
+
async run({ assert }) {
|
|
173
|
+
const user = createUser({
|
|
174
|
+
subscription: { limits: { devices: 5, seats: 10, storage: 1024 } },
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
assert.equal(user.subscription.limits.devices, 5, 'limits.devices preserved');
|
|
178
|
+
assert.equal(user.subscription.limits.seats, 10, 'limits.seats preserved');
|
|
179
|
+
assert.equal(user.subscription.limits.storage, 1024, 'limits.storage preserved');
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
|
|
183
|
+
{
|
|
184
|
+
name: 'subscription-limits-empty-when-not-provided',
|
|
185
|
+
async run({ assert }) {
|
|
186
|
+
const user = createUser({});
|
|
187
|
+
assert.deepEqual(user.subscription.limits, {}, 'limits should be empty when not provided');
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
|
|
191
|
+
// ─── $passthrough: roles (with defined defaults) ───
|
|
192
|
+
|
|
193
|
+
{
|
|
194
|
+
name: 'roles-passthrough-preserves-custom-roles',
|
|
195
|
+
async run({ assert }) {
|
|
196
|
+
const user = createUser({
|
|
197
|
+
roles: { admin: true, customRole: true, moderator: false },
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
assert.equal(user.roles.admin, true, 'admin should be true');
|
|
201
|
+
assert.equal(user.roles.customRole, true, 'customRole should be preserved');
|
|
202
|
+
assert.equal(user.roles.moderator, false, 'moderator should be preserved');
|
|
203
|
+
assert.equal(user.roles.betaTester, false, 'betaTester should get default');
|
|
204
|
+
assert.equal(user.roles.developer, false, 'developer should get default');
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
|
|
208
|
+
// ─── $passthrough: flags (with defined defaults) ───
|
|
209
|
+
|
|
210
|
+
{
|
|
211
|
+
name: 'flags-passthrough-preserves-custom-flags',
|
|
212
|
+
async run({ assert }) {
|
|
213
|
+
const user = createUser({
|
|
214
|
+
flags: { signupProcessed: true, featureX: true, betaOptIn: false },
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
assert.equal(user.flags.signupProcessed, true, 'signupProcessed preserved');
|
|
218
|
+
assert.equal(user.flags.featureX, true, 'featureX preserved');
|
|
219
|
+
assert.equal(user.flags.betaOptIn, false, 'betaOptIn preserved');
|
|
220
|
+
},
|
|
221
|
+
},
|
|
222
|
+
|
|
223
|
+
// ─── $template: usage with dynamic keys ───
|
|
224
|
+
|
|
225
|
+
{
|
|
226
|
+
name: 'usage-template-preserves-dynamic-keys',
|
|
227
|
+
async run({ assert }) {
|
|
228
|
+
const user = createUser({
|
|
229
|
+
usage: {
|
|
230
|
+
requests: { period: 10, total: 100, last: { id: 'r1', timestamp: '2025-01-01T00:00:00.000Z', timestampUNIX: 1735689600 } },
|
|
231
|
+
emails: { period: 5, total: 50, last: { id: 'e1', timestamp: '2025-01-02T00:00:00.000Z', timestampUNIX: 1735776000 } },
|
|
232
|
+
sends: { period: 3, total: 30 },
|
|
233
|
+
},
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
// Defined key (requests)
|
|
237
|
+
assert.equal(user.usage.requests.period, 10, 'requests.period preserved');
|
|
238
|
+
assert.equal(user.usage.requests.total, 100, 'requests.total preserved');
|
|
239
|
+
assert.equal(user.usage.requests.last.id, 'r1', 'requests.last.id preserved');
|
|
240
|
+
|
|
241
|
+
// Dynamic key (emails) — full data
|
|
242
|
+
assert.equal(user.usage.emails.period, 5, 'emails.period preserved');
|
|
243
|
+
assert.equal(user.usage.emails.total, 50, 'emails.total preserved');
|
|
244
|
+
assert.equal(user.usage.emails.last.id, 'e1', 'emails.last.id preserved');
|
|
245
|
+
|
|
246
|
+
// Dynamic key (sends) — partial data, template fills in missing
|
|
247
|
+
assert.equal(user.usage.sends.period, 3, 'sends.period preserved');
|
|
248
|
+
assert.equal(user.usage.sends.total, 30, 'sends.total preserved');
|
|
249
|
+
assert.equal(user.usage.sends.last.id, null, 'sends.last.id defaulted to null');
|
|
250
|
+
assert.ok(user.usage.sends.last.timestamp, 'sends.last.timestamp defaulted');
|
|
251
|
+
assert.equal(typeof user.usage.sends.last.timestampUNIX, 'number', 'sends.last.timestampUNIX defaulted to number');
|
|
252
|
+
},
|
|
253
|
+
},
|
|
254
|
+
|
|
255
|
+
{
|
|
256
|
+
name: 'usage-only-requests-when-no-extra-keys',
|
|
257
|
+
async run({ assert }) {
|
|
258
|
+
const user = createUser({});
|
|
259
|
+
|
|
260
|
+
assert.ok(user.usage.requests, 'usage.requests should exist');
|
|
261
|
+
assert.equal(Object.keys(user.usage).length, 1, 'usage should only have requests key');
|
|
262
|
+
},
|
|
263
|
+
},
|
|
264
|
+
|
|
265
|
+
// ─── $passthrough: utm.tags ───
|
|
266
|
+
|
|
267
|
+
{
|
|
268
|
+
name: 'utm-tags-passthrough-preserves-all-tags',
|
|
269
|
+
async run({ assert }) {
|
|
270
|
+
const user = createUser({
|
|
271
|
+
attribution: {
|
|
272
|
+
utm: {
|
|
273
|
+
tags: { source: 'google', medium: 'cpc', campaign: 'summer' },
|
|
274
|
+
timestamp: '2025-06-01T00:00:00.000Z',
|
|
275
|
+
},
|
|
276
|
+
},
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
assert.equal(user.attribution.utm.tags.source, 'google', 'utm source preserved');
|
|
280
|
+
assert.equal(user.attribution.utm.tags.medium, 'cpc', 'utm medium preserved');
|
|
281
|
+
assert.equal(user.attribution.utm.tags.campaign, 'summer', 'utm campaign preserved');
|
|
282
|
+
assert.equal(user.attribution.utm.timestamp, '2025-06-01T00:00:00.000Z', 'utm timestamp preserved');
|
|
283
|
+
},
|
|
284
|
+
},
|
|
285
|
+
|
|
286
|
+
// ─── Type coercion ───
|
|
287
|
+
|
|
288
|
+
{
|
|
289
|
+
name: 'coerces-number-from-string',
|
|
290
|
+
async run({ assert }) {
|
|
291
|
+
const user = createUser({
|
|
292
|
+
activity: { geolocation: { latitude: '42.5', longitude: '-73.2' } },
|
|
293
|
+
personal: { telephone: { countryCode: '44', national: '7911123456' } },
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
assert.equal(user.activity.geolocation.latitude, 42.5, 'string "42.5" coerced to number');
|
|
297
|
+
assert.equal(user.activity.geolocation.longitude, -73.2, 'string "-73.2" coerced to number');
|
|
298
|
+
assert.equal(user.personal.telephone.countryCode, 44, 'string "44" coerced to number');
|
|
299
|
+
},
|
|
300
|
+
},
|
|
301
|
+
|
|
302
|
+
{
|
|
303
|
+
name: 'coerces-boolean-from-number',
|
|
304
|
+
async run({ assert }) {
|
|
305
|
+
const user = createUser({
|
|
306
|
+
auth: { temporary: 1 },
|
|
307
|
+
activity: { client: { mobile: 0 } },
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
assert.equal(user.auth.temporary, true, '1 coerced to true');
|
|
311
|
+
assert.equal(user.activity.client.mobile, false, '0 coerced to false');
|
|
312
|
+
},
|
|
313
|
+
},
|
|
314
|
+
|
|
315
|
+
{
|
|
316
|
+
name: 'coerces-boolean-from-string',
|
|
317
|
+
async run({ assert }) {
|
|
318
|
+
const user = createUser({
|
|
319
|
+
auth: { temporary: 'true' },
|
|
320
|
+
roles: { admin: 'false' },
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
assert.equal(user.auth.temporary, true, '"true" coerced to true');
|
|
324
|
+
assert.equal(user.roles.admin, false, '"false" coerced to false');
|
|
325
|
+
},
|
|
326
|
+
},
|
|
327
|
+
|
|
328
|
+
{
|
|
329
|
+
name: 'invalid-coercion-falls-back-to-default',
|
|
330
|
+
async run({ assert }) {
|
|
331
|
+
const user = createUser({
|
|
332
|
+
activity: { geolocation: { latitude: 'not-a-number', longitude: undefined } },
|
|
333
|
+
personal: { telephone: { national: 'abc' } },
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
assert.equal(user.activity.geolocation.latitude, 0, 'invalid string falls back to default 0');
|
|
337
|
+
assert.equal(user.activity.geolocation.longitude, 0, 'undefined falls back to default 0');
|
|
338
|
+
assert.equal(user.personal.telephone.national, 0, 'non-numeric string falls back to default 0');
|
|
339
|
+
},
|
|
340
|
+
},
|
|
341
|
+
|
|
342
|
+
// ─── Nullable fields ───
|
|
343
|
+
|
|
344
|
+
{
|
|
345
|
+
name: 'nullable-fields-preserve-null',
|
|
346
|
+
async run({ assert }) {
|
|
347
|
+
const user = createUser({
|
|
348
|
+
auth: { uid: null, email: null },
|
|
349
|
+
personal: { name: { first: null, last: null }, gender: null },
|
|
350
|
+
activity: { geolocation: { ip: null, country: null } },
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
assert.equal(user.auth.uid, null, 'null uid preserved');
|
|
354
|
+
assert.equal(user.auth.email, null, 'null email preserved');
|
|
355
|
+
assert.equal(user.personal.name.first, null, 'null first name preserved');
|
|
356
|
+
assert.equal(user.personal.gender, null, 'null gender preserved');
|
|
357
|
+
assert.equal(user.activity.geolocation.ip, null, 'null ip preserved');
|
|
358
|
+
},
|
|
359
|
+
},
|
|
360
|
+
|
|
361
|
+
{
|
|
362
|
+
name: 'non-nullable-fields-replace-null-with-default',
|
|
363
|
+
async run({ assert }) {
|
|
364
|
+
const user = createUser({
|
|
365
|
+
auth: { temporary: null },
|
|
366
|
+
roles: { admin: null },
|
|
367
|
+
activity: { geolocation: { latitude: null } },
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
// These are non-nullable, so null should be replaced with the schema default
|
|
371
|
+
assert.equal(user.auth.temporary, false, 'null temporary replaced with false');
|
|
372
|
+
assert.equal(user.roles.admin, false, 'null admin replaced with false');
|
|
373
|
+
assert.equal(user.activity.geolocation.latitude, 0, 'null latitude replaced with 0');
|
|
374
|
+
},
|
|
375
|
+
},
|
|
376
|
+
|
|
377
|
+
// ─── Fragmented / incomplete users ───
|
|
378
|
+
|
|
379
|
+
{
|
|
380
|
+
name: 'partial-subscription-fills-missing-fields',
|
|
381
|
+
async run({ assert }) {
|
|
382
|
+
const user = createUser({
|
|
383
|
+
subscription: { product: { id: 'premium' } },
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
assert.equal(user.subscription.product.id, 'premium', 'provided id preserved');
|
|
387
|
+
assert.equal(user.subscription.product.name, 'Basic', 'missing name gets default');
|
|
388
|
+
assert.equal(user.subscription.status, 'active', 'missing status gets default');
|
|
389
|
+
assert.equal(user.subscription.trial.claimed, false, 'missing trial.claimed gets default');
|
|
390
|
+
assert.ok(user.subscription.expires.timestamp, 'missing expires gets default timestamp');
|
|
391
|
+
},
|
|
392
|
+
},
|
|
393
|
+
|
|
394
|
+
{
|
|
395
|
+
name: 'partial-activity-fills-missing-fields',
|
|
396
|
+
async run({ assert }) {
|
|
397
|
+
const user = createUser({
|
|
398
|
+
activity: { geolocation: { country: 'US' } },
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
assert.equal(user.activity.geolocation.country, 'US', 'provided country preserved');
|
|
402
|
+
assert.equal(user.activity.geolocation.ip, null, 'missing ip defaults to null');
|
|
403
|
+
assert.equal(user.activity.geolocation.latitude, 0, 'missing latitude defaults to 0');
|
|
404
|
+
assert.ok(user.activity.lastActivity.timestamp, 'missing lastActivity gets default');
|
|
405
|
+
assert.ok(user.activity.created.timestamp, 'missing created gets default');
|
|
406
|
+
},
|
|
407
|
+
},
|
|
408
|
+
|
|
409
|
+
{
|
|
410
|
+
name: 'deeply-nested-partial-payment-fills-correctly',
|
|
411
|
+
async run({ assert }) {
|
|
412
|
+
const user = createUser({
|
|
413
|
+
subscription: {
|
|
414
|
+
payment: { processor: 'stripe', resourceId: 'sub_123' },
|
|
415
|
+
},
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
assert.equal(user.subscription.payment.processor, 'stripe', 'processor preserved');
|
|
419
|
+
assert.equal(user.subscription.payment.resourceId, 'sub_123', 'resourceId preserved');
|
|
420
|
+
assert.equal(user.subscription.payment.frequency, null, 'missing frequency defaults to null');
|
|
421
|
+
assert.ok(user.subscription.payment.startDate.timestamp, 'missing startDate gets default');
|
|
422
|
+
assert.equal(user.subscription.payment.updatedBy.event.name, null, 'missing event.name defaults to null');
|
|
423
|
+
},
|
|
424
|
+
},
|
|
425
|
+
|
|
426
|
+
// ─── Top-level structure ───
|
|
427
|
+
|
|
428
|
+
{
|
|
429
|
+
name: 'all-top-level-keys-present',
|
|
430
|
+
async run({ assert }) {
|
|
431
|
+
const user = createUser({});
|
|
432
|
+
const expectedKeys = [
|
|
433
|
+
'auth', 'subscription', 'roles', 'flags', 'affiliate',
|
|
434
|
+
'activity', 'api', 'usage', 'personal', 'oauth2', 'attribution',
|
|
435
|
+
];
|
|
436
|
+
|
|
437
|
+
for (const key of expectedKeys) {
|
|
438
|
+
assert.ok(key in user, `Top-level key "${key}" should exist`);
|
|
439
|
+
}
|
|
440
|
+
},
|
|
441
|
+
},
|
|
442
|
+
|
|
443
|
+
{
|
|
444
|
+
name: 'no-extra-top-level-keys',
|
|
445
|
+
async run({ assert }) {
|
|
446
|
+
const user = createUser({});
|
|
447
|
+
const expectedKeys = [
|
|
448
|
+
'auth', 'subscription', 'roles', 'flags', 'affiliate',
|
|
449
|
+
'activity', 'api', 'usage', 'personal', 'oauth2', 'attribution',
|
|
450
|
+
];
|
|
451
|
+
|
|
452
|
+
for (const key of Object.keys(user)) {
|
|
453
|
+
assert.ok(expectedKeys.includes(key), `Unexpected top-level key "${key}"`);
|
|
454
|
+
}
|
|
455
|
+
},
|
|
456
|
+
},
|
|
457
|
+
|
|
458
|
+
// ─── Unique/generated values ───
|
|
459
|
+
|
|
460
|
+
{
|
|
461
|
+
name: 'api-keys-are-unique-per-call',
|
|
462
|
+
async run({ assert }) {
|
|
463
|
+
const u1 = createUser({});
|
|
464
|
+
const u2 = createUser({});
|
|
465
|
+
|
|
466
|
+
// privateKey uses UIDGenerator which should produce unique values
|
|
467
|
+
assert.notEqual(u1.api.privateKey, u2.api.privateKey, 'privateKey should be unique per call');
|
|
468
|
+
assert.notEqual(u1.api.clientId, u2.api.clientId, 'clientId should be unique per call');
|
|
469
|
+
},
|
|
470
|
+
},
|
|
471
|
+
|
|
472
|
+
{
|
|
473
|
+
name: 'existing-api-keys-not-overwritten',
|
|
474
|
+
async run({ assert }) {
|
|
475
|
+
const user = createUser({
|
|
476
|
+
api: { clientId: 'my-client-id', privateKey: 'my-secret-key' },
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
assert.equal(user.api.clientId, 'my-client-id', 'existing clientId preserved');
|
|
480
|
+
assert.equal(user.api.privateKey, 'my-secret-key', 'existing privateKey preserved');
|
|
481
|
+
},
|
|
482
|
+
},
|
|
483
|
+
|
|
484
|
+
// ─── Complex real-world user ───
|
|
485
|
+
|
|
486
|
+
{
|
|
487
|
+
name: 'full-real-world-user-preserves-everything',
|
|
488
|
+
async run({ assert }) {
|
|
489
|
+
const user = createUser({
|
|
490
|
+
auth: { uid: 'V4U9wR0AiLUQRxpcP7WhgA4FX9H2', email: 'ian@example.com', temporary: false },
|
|
491
|
+
subscription: {
|
|
492
|
+
product: { id: 'premium', name: 'Premium' },
|
|
493
|
+
status: 'active',
|
|
494
|
+
expires: { timestamp: '2026-12-31T00:00:00.000Z', timestampUNIX: 1798761600 },
|
|
495
|
+
trial: { claimed: true, expires: { timestamp: '2024-01-01T00:00:00.000Z', timestampUNIX: 1704067200 } },
|
|
496
|
+
limits: { emailsPerDay: 500, contacts: 10000 },
|
|
497
|
+
payment: { processor: 'stripe', resourceId: 'sub_abc', frequency: 'annually' },
|
|
498
|
+
},
|
|
499
|
+
roles: { admin: true, betaTester: true, developer: true, superAdmin: true },
|
|
500
|
+
flags: { signupProcessed: true, onboarded: true },
|
|
501
|
+
affiliate: { code: 'IAN7', referrals: ['ref1', 'ref2'] },
|
|
502
|
+
api: { clientId: 'uuid-123', privateKey: 'key-456' },
|
|
503
|
+
oauth2: {
|
|
504
|
+
google: {
|
|
505
|
+
token: { access_token: 'ya29.real', refresh_token: '1//real', expiry_date: 1700000000 },
|
|
506
|
+
identity: { email: 'ian@gmail.com', name: 'Ian W', picture: 'https://photo.url' },
|
|
507
|
+
},
|
|
508
|
+
},
|
|
509
|
+
usage: {
|
|
510
|
+
requests: { period: 100, total: 5000, last: { id: 'req-z', timestamp: '2025-12-01T00:00:00.000Z', timestampUNIX: 1764633600 } },
|
|
511
|
+
emails: { period: 42, total: 2100, last: { id: 'em-z', timestamp: '2025-12-01T00:00:00.000Z', timestampUNIX: 1764633600 } },
|
|
512
|
+
},
|
|
513
|
+
personal: {
|
|
514
|
+
name: { first: 'Ian', last: 'Wiedenman' },
|
|
515
|
+
company: { name: 'ITW Creative Works', position: 'CEO' },
|
|
516
|
+
birthday: { timestamp: '1990-05-15T00:00:00.000Z', timestampUNIX: 642988800 },
|
|
517
|
+
},
|
|
518
|
+
attribution: {
|
|
519
|
+
affiliate: { code: 'PARTNER1', timestamp: '2024-06-01T00:00:00.000Z' },
|
|
520
|
+
utm: { tags: { source: 'twitter', campaign: 'launch' }, url: 'https://example.com' },
|
|
521
|
+
},
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
// Everything should be preserved exactly
|
|
525
|
+
assert.equal(user.auth.uid, 'V4U9wR0AiLUQRxpcP7WhgA4FX9H2', 'uid preserved');
|
|
526
|
+
assert.equal(user.subscription.product.id, 'premium', 'product preserved');
|
|
527
|
+
assert.equal(user.subscription.limits.emailsPerDay, 500, 'custom limits preserved');
|
|
528
|
+
assert.equal(user.roles.superAdmin, true, 'custom role preserved');
|
|
529
|
+
assert.equal(user.flags.onboarded, true, 'custom flag preserved');
|
|
530
|
+
assert.equal(user.affiliate.code, 'IAN7', 'affiliate code preserved');
|
|
531
|
+
assert.deepEqual(user.affiliate.referrals, ['ref1', 'ref2'], 'referrals preserved');
|
|
532
|
+
assert.equal(user.api.clientId, 'uuid-123', 'api clientId preserved');
|
|
533
|
+
assert.equal(user.oauth2.google.token.access_token, 'ya29.real', 'oauth2 token preserved');
|
|
534
|
+
assert.equal(user.oauth2.google.identity.email, 'ian@gmail.com', 'oauth2 identity preserved');
|
|
535
|
+
assert.equal(user.usage.emails.period, 42, 'usage emails preserved');
|
|
536
|
+
assert.equal(user.personal.name.first, 'Ian', 'name preserved');
|
|
537
|
+
assert.equal(user.personal.company.name, 'ITW Creative Works', 'company preserved');
|
|
538
|
+
assert.equal(user.personal.birthday.timestampUNIX, 642988800, 'birthday preserved');
|
|
539
|
+
assert.equal(user.attribution.utm.tags.source, 'twitter', 'utm tags preserved');
|
|
540
|
+
},
|
|
541
|
+
},
|
|
542
|
+
],
|
|
543
|
+
};
|