backend-manager 5.0.89 → 5.0.92
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/CHANGELOG.md +2 -2
- package/CLAUDE.md +147 -8
- package/README.md +6 -6
- package/TODO-MARKETING.md +3 -0
- package/TODO-PAYMENT-v2.md +71 -0
- package/TODO.md +7 -0
- package/package.json +7 -5
- package/src/cli/commands/{emulators.js → emulator.js} +15 -15
- package/src/cli/commands/index.js +1 -1
- package/src/cli/commands/setup-tests/{emulators-config.js → emulator-config.js} +4 -4
- package/src/cli/commands/setup-tests/index.js +2 -2
- package/src/cli/commands/setup-tests/project-id-consistency.js +1 -1
- package/src/cli/commands/test.js +16 -16
- package/src/cli/index.js +15 -4
- package/src/manager/events/auth/on-create.js +5 -158
- package/src/manager/events/firestore/payments-webhooks/analytics.js +171 -0
- package/src/manager/events/firestore/payments-webhooks/on-write.js +95 -297
- package/src/manager/events/firestore/payments-webhooks/transitions/one-time/purchase-completed.js +19 -10
- package/src/manager/events/firestore/payments-webhooks/transitions/one-time/purchase-failed.js +4 -8
- package/src/manager/events/firestore/payments-webhooks/transitions/send-email.js +61 -0
- package/src/manager/events/firestore/payments-webhooks/transitions/subscription/cancellation-requested.js +22 -9
- package/src/manager/events/firestore/payments-webhooks/transitions/subscription/new-subscription.js +21 -8
- package/src/manager/events/firestore/payments-webhooks/transitions/subscription/payment-failed.js +18 -8
- package/src/manager/events/firestore/payments-webhooks/transitions/subscription/payment-recovered.js +18 -7
- package/src/manager/events/firestore/payments-webhooks/transitions/subscription/plan-changed.js +26 -8
- package/src/manager/events/firestore/payments-webhooks/transitions/subscription/subscription-cancelled.js +24 -9
- package/src/manager/functions/core/actions/api/admin/send-email.js +0 -131
- package/src/manager/functions/core/actions/api/general/add-marketing-contact.js +2 -137
- package/src/manager/functions/core/actions/api/user/sign-up.js +1 -1
- package/src/manager/helpers/user.js +1 -0
- package/src/manager/index.js +12 -0
- package/src/manager/libraries/email.js +483 -0
- package/src/manager/libraries/infer-contact.js +140 -0
- package/src/manager/libraries/payment-processors/resolve-price-id.js +19 -0
- package/src/manager/libraries/payment-processors/stripe.js +87 -48
- package/src/manager/libraries/payment-processors/test.js +4 -4
- package/src/manager/libraries/prompts/infer-contact.md +43 -0
- package/src/manager/routes/admin/backup/post.js +4 -3
- package/src/manager/routes/admin/email/post.js +11 -428
- package/src/manager/routes/admin/hook/post.js +3 -2
- package/src/manager/routes/admin/notification/post.js +14 -12
- package/src/manager/routes/admin/post/post.js +5 -6
- package/src/manager/routes/admin/post/put.js +3 -2
- package/src/manager/routes/admin/stats/get.js +19 -10
- package/src/manager/routes/general/email/post.js +8 -21
- package/src/manager/routes/marketing/contact/post.js +2 -100
- package/src/manager/routes/payments/intent/post.js +44 -2
- package/src/manager/routes/payments/intent/processors/stripe.js +10 -45
- package/src/manager/routes/payments/intent/processors/test.js +20 -25
- package/src/manager/routes/user/oauth2/_helpers.js +3 -2
- package/src/manager/routes/user/oauth2/delete.js +3 -3
- package/src/manager/routes/user/oauth2/get.js +2 -2
- package/src/manager/routes/user/oauth2/post.js +9 -9
- package/src/manager/routes/user/sessions/delete.js +4 -3
- package/src/manager/routes/user/signup/post.js +254 -54
- package/src/manager/schemas/admin/email/post.js +10 -5
- package/src/test/run-tests.js +1 -1
- package/src/test/runner.js +11 -0
- package/src/test/test-accounts.js +18 -0
- package/templates/backend-manager-config.json +31 -12
- package/test/events/payments/journey-payments-one-time-failure.js +105 -0
- package/test/events/payments/journey-payments-one-time.js +128 -0
- package/test/events/payments/journey-payments-plan-change.js +126 -0
- package/test/events/payments/journey-payments-upgrade.js +2 -2
- package/test/functions/admin/send-email.js +1 -88
- package/test/helpers/email.js +381 -0
- package/test/helpers/infer-contact.js +299 -0
- package/test/routes/admin/email.js +41 -90
- package/REFACTOR-BEM-API.md +0 -76
- package/REFACTOR-MIDDLEWARE.md +0 -62
- package/REFACTOR-PAYMENT.md +0 -66
- /package/bin/{bem → backend-manager} +0 -0
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test: Email library (libraries/email.js)
|
|
3
|
+
* Library-level tests: validation edge cases, recipient formats, deduplication, features
|
|
4
|
+
*
|
|
5
|
+
* These tests exercise the email library through the admin/email route to get a real
|
|
6
|
+
* SendGrid integration. Route-level tests (auth, permissions) are in test/routes/admin/email.js.
|
|
7
|
+
*/
|
|
8
|
+
module.exports = {
|
|
9
|
+
description: 'Email library',
|
|
10
|
+
type: 'group',
|
|
11
|
+
skip: !process.env.TEST_EXTENDED_MODE ? 'TEST_EXTENDED_MODE env var not set (skipping email tests)' : false,
|
|
12
|
+
tests: [
|
|
13
|
+
// --- Validation / Rejection ---
|
|
14
|
+
|
|
15
|
+
{
|
|
16
|
+
name: 'empty-to-array-rejected',
|
|
17
|
+
auth: 'admin',
|
|
18
|
+
timeout: 15000,
|
|
19
|
+
|
|
20
|
+
async run({ http, assert }) {
|
|
21
|
+
const response = await http.post('admin/email', {
|
|
22
|
+
subject: 'BEM Test Email - Empty To',
|
|
23
|
+
to: [],
|
|
24
|
+
copy: false,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
assert.isError(response, 400, 'Empty to array should return 400');
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
|
|
31
|
+
{
|
|
32
|
+
name: 'object-recipient-without-email-rejected',
|
|
33
|
+
auth: 'admin',
|
|
34
|
+
timeout: 15000,
|
|
35
|
+
|
|
36
|
+
async run({ http, assert }) {
|
|
37
|
+
const response = await http.post('admin/email', {
|
|
38
|
+
subject: 'BEM Test Email - Bad Object',
|
|
39
|
+
to: [{ name: 'No Email' }],
|
|
40
|
+
copy: false,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
assert.isError(response, 400, 'Object without email should return 400');
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
|
|
47
|
+
{
|
|
48
|
+
name: 'missing-template-and-html-rejected',
|
|
49
|
+
auth: 'admin',
|
|
50
|
+
timeout: 15000,
|
|
51
|
+
|
|
52
|
+
async run({ http, assert, config }) {
|
|
53
|
+
const response = await http.post('admin/email', {
|
|
54
|
+
subject: 'BEM Test Email - No Template',
|
|
55
|
+
to: [{ email: `_test-receiver@${config.domain}` }],
|
|
56
|
+
template: '',
|
|
57
|
+
copy: false,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
assert.isError(response, 400, 'Missing template and html should return 400');
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
{
|
|
65
|
+
name: 'nonexistent-uid-rejected',
|
|
66
|
+
auth: 'admin',
|
|
67
|
+
timeout: 15000,
|
|
68
|
+
|
|
69
|
+
async run({ http, assert }) {
|
|
70
|
+
const response = await http.post('admin/email', {
|
|
71
|
+
subject: 'BEM Test Email - Bad UID',
|
|
72
|
+
to: 'uid:nonexistent_uid_12345',
|
|
73
|
+
copy: false,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
assert.isError(response, 400, 'Nonexistent UID should return 400');
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
// --- Subject Fallback ---
|
|
81
|
+
|
|
82
|
+
{
|
|
83
|
+
name: 'subject-from-data-fallback',
|
|
84
|
+
auth: 'admin',
|
|
85
|
+
timeout: 30000,
|
|
86
|
+
|
|
87
|
+
async run({ http, assert, config }) {
|
|
88
|
+
const response = await http.post('admin/email', {
|
|
89
|
+
to: [{ email: `_test-receiver@${config.domain}` }],
|
|
90
|
+
copy: false,
|
|
91
|
+
data: {
|
|
92
|
+
email: {
|
|
93
|
+
subject: 'BEM Test Email - Fallback Subject',
|
|
94
|
+
body: 'Testing subject fallback from data.email.subject.',
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
assert.isSuccess(response, 'Should use subject from data.email.subject');
|
|
100
|
+
assert.equal(response.data.status, 'sent', 'Status should be sent');
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
// --- Recipient Formats ---
|
|
105
|
+
|
|
106
|
+
{
|
|
107
|
+
name: 'string-email-recipient',
|
|
108
|
+
auth: 'admin',
|
|
109
|
+
timeout: 30000,
|
|
110
|
+
|
|
111
|
+
async run({ http, assert, config }) {
|
|
112
|
+
const response = await http.post('admin/email', {
|
|
113
|
+
subject: 'BEM Test Email - String Email',
|
|
114
|
+
to: `_test-receiver@${config.domain}`,
|
|
115
|
+
copy: false,
|
|
116
|
+
data: {
|
|
117
|
+
email: {
|
|
118
|
+
subject: 'BEM Test Email - String Email',
|
|
119
|
+
body: 'Testing string email format.',
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
assert.isSuccess(response, 'Should send email to string address');
|
|
125
|
+
assert.equal(response.data.status, 'sent', 'Status should be sent');
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
|
|
129
|
+
{
|
|
130
|
+
name: 'uid-recipient',
|
|
131
|
+
auth: 'admin',
|
|
132
|
+
timeout: 30000,
|
|
133
|
+
|
|
134
|
+
async run({ http, assert, accounts }) {
|
|
135
|
+
const response = await http.post('admin/email', {
|
|
136
|
+
subject: 'BEM Test Email - UID Recipient',
|
|
137
|
+
to: `uid:${accounts.admin.uid}`,
|
|
138
|
+
copy: false,
|
|
139
|
+
data: {
|
|
140
|
+
email: {
|
|
141
|
+
subject: 'BEM Test Email - UID Recipient',
|
|
142
|
+
body: 'Testing UID resolution.',
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
assert.isSuccess(response, 'Should send email to UID');
|
|
148
|
+
assert.equal(response.data.status, 'sent', 'Status should be sent');
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
|
|
152
|
+
{
|
|
153
|
+
name: 'mixed-recipients',
|
|
154
|
+
auth: 'admin',
|
|
155
|
+
timeout: 30000,
|
|
156
|
+
|
|
157
|
+
async run({ http, assert, accounts, config }) {
|
|
158
|
+
const response = await http.post('admin/email', {
|
|
159
|
+
subject: 'BEM Test Email - Mixed Recipients',
|
|
160
|
+
to: [
|
|
161
|
+
`_test-receiver@${config.domain}`,
|
|
162
|
+
{ email: `_test-receiver-2@${config.domain}`, name: 'Receiver 2' },
|
|
163
|
+
`uid:${accounts.admin.uid}`,
|
|
164
|
+
],
|
|
165
|
+
copy: false,
|
|
166
|
+
data: {
|
|
167
|
+
email: {
|
|
168
|
+
subject: 'BEM Test Email - Mixed Recipients',
|
|
169
|
+
body: 'Testing mixed recipient formats.',
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
assert.isSuccess(response, 'Should send email to mixed recipients');
|
|
175
|
+
assert.equal(response.data.status, 'sent', 'Status should be sent');
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
|
|
179
|
+
{
|
|
180
|
+
name: 'object-recipient-with-name',
|
|
181
|
+
auth: 'admin',
|
|
182
|
+
timeout: 30000,
|
|
183
|
+
|
|
184
|
+
async run({ http, assert, config }) {
|
|
185
|
+
const response = await http.post('admin/email', {
|
|
186
|
+
subject: 'BEM Test Email - Object Recipient',
|
|
187
|
+
to: { email: `_test-receiver@${config.domain}`, name: 'Named Recipient' },
|
|
188
|
+
copy: false,
|
|
189
|
+
data: {
|
|
190
|
+
email: {
|
|
191
|
+
subject: 'BEM Test Email - Object Recipient',
|
|
192
|
+
body: 'Testing single object recipient with name.',
|
|
193
|
+
},
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
assert.isSuccess(response, 'Should send email to object recipient');
|
|
198
|
+
assert.equal(response.data.status, 'sent', 'Status should be sent');
|
|
199
|
+
},
|
|
200
|
+
},
|
|
201
|
+
|
|
202
|
+
{
|
|
203
|
+
name: 'cc-bcc-recipients-accepted',
|
|
204
|
+
auth: 'admin',
|
|
205
|
+
timeout: 30000,
|
|
206
|
+
|
|
207
|
+
async run({ http, assert, config }) {
|
|
208
|
+
const response = await http.post('admin/email', {
|
|
209
|
+
subject: 'BEM Test Email - CC/BCC',
|
|
210
|
+
to: `_test-receiver@${config.domain}`,
|
|
211
|
+
cc: `_test-cc@${config.domain}`,
|
|
212
|
+
bcc: { email: `_test-bcc@${config.domain}`, name: 'BCC Receiver' },
|
|
213
|
+
copy: false,
|
|
214
|
+
data: {
|
|
215
|
+
email: {
|
|
216
|
+
subject: 'BEM Test Email - CC/BCC',
|
|
217
|
+
body: 'Testing cc and bcc recipients.',
|
|
218
|
+
},
|
|
219
|
+
},
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
assert.isSuccess(response, 'Should send email with cc and bcc');
|
|
223
|
+
assert.equal(response.data.status, 'sent', 'Status should be sent');
|
|
224
|
+
},
|
|
225
|
+
},
|
|
226
|
+
|
|
227
|
+
// --- Deduplication ---
|
|
228
|
+
|
|
229
|
+
{
|
|
230
|
+
name: 'dedup-same-email-in-to',
|
|
231
|
+
auth: 'admin',
|
|
232
|
+
timeout: 30000,
|
|
233
|
+
|
|
234
|
+
async run({ http, assert, config }) {
|
|
235
|
+
const email = `_test-dedup@${config.domain}`;
|
|
236
|
+
|
|
237
|
+
const response = await http.post('admin/email', {
|
|
238
|
+
subject: 'BEM Test Email - Dedup To',
|
|
239
|
+
to: [email, email],
|
|
240
|
+
copy: false,
|
|
241
|
+
data: {
|
|
242
|
+
email: {
|
|
243
|
+
subject: 'BEM Test Email - Dedup To',
|
|
244
|
+
body: 'Testing deduplication of same email in to.',
|
|
245
|
+
},
|
|
246
|
+
},
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
assert.isSuccess(response, 'Should send despite duplicate to');
|
|
250
|
+
assert.equal(response.data.status, 'sent', 'Status should be sent');
|
|
251
|
+
assert.equal(response.data.options.to.length, 1, 'Duplicate should be removed from to');
|
|
252
|
+
},
|
|
253
|
+
},
|
|
254
|
+
|
|
255
|
+
{
|
|
256
|
+
name: 'dedup-to-removes-from-cc',
|
|
257
|
+
auth: 'admin',
|
|
258
|
+
timeout: 30000,
|
|
259
|
+
|
|
260
|
+
async run({ http, assert, config }) {
|
|
261
|
+
const email = `_test-dedup-cc@${config.domain}`;
|
|
262
|
+
|
|
263
|
+
const response = await http.post('admin/email', {
|
|
264
|
+
subject: 'BEM Test Email - Dedup CC',
|
|
265
|
+
to: email,
|
|
266
|
+
cc: email,
|
|
267
|
+
copy: false,
|
|
268
|
+
data: {
|
|
269
|
+
email: {
|
|
270
|
+
subject: 'BEM Test Email - Dedup CC',
|
|
271
|
+
body: 'Testing cross-list dedup (to removes from cc).',
|
|
272
|
+
},
|
|
273
|
+
},
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
assert.isSuccess(response, 'Should send despite duplicate in cc');
|
|
277
|
+
assert.equal(response.data.status, 'sent', 'Status should be sent');
|
|
278
|
+
assert.equal(response.data.options.cc.length, 0, 'Email in to should be removed from cc');
|
|
279
|
+
},
|
|
280
|
+
},
|
|
281
|
+
|
|
282
|
+
{
|
|
283
|
+
name: 'dedup-case-insensitive',
|
|
284
|
+
auth: 'admin',
|
|
285
|
+
timeout: 30000,
|
|
286
|
+
|
|
287
|
+
async run({ http, assert, config }) {
|
|
288
|
+
const response = await http.post('admin/email', {
|
|
289
|
+
subject: 'BEM Test Email - Case Dedup',
|
|
290
|
+
to: [`_TEST-DEDUP@${config.domain}`, `_test-dedup@${config.domain}`],
|
|
291
|
+
copy: false,
|
|
292
|
+
data: {
|
|
293
|
+
email: {
|
|
294
|
+
subject: 'BEM Test Email - Case Dedup',
|
|
295
|
+
body: 'Testing case-insensitive deduplication.',
|
|
296
|
+
},
|
|
297
|
+
},
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
assert.isSuccess(response, 'Should send despite case-different duplicates');
|
|
301
|
+
assert.equal(response.data.status, 'sent', 'Status should be sent');
|
|
302
|
+
assert.equal(response.data.options.to.length, 1, 'Case-insensitive duplicate should be removed');
|
|
303
|
+
},
|
|
304
|
+
},
|
|
305
|
+
|
|
306
|
+
// --- Features ---
|
|
307
|
+
|
|
308
|
+
{
|
|
309
|
+
name: 'copy-false-no-carbon-copies',
|
|
310
|
+
auth: 'admin',
|
|
311
|
+
timeout: 30000,
|
|
312
|
+
|
|
313
|
+
async run({ http, assert, config }) {
|
|
314
|
+
const response = await http.post('admin/email', {
|
|
315
|
+
subject: 'BEM Test Email - No Copy',
|
|
316
|
+
to: `_test-receiver@${config.domain}`,
|
|
317
|
+
copy: false,
|
|
318
|
+
data: {
|
|
319
|
+
email: {
|
|
320
|
+
subject: 'BEM Test Email - No Copy',
|
|
321
|
+
body: 'Testing that copy:false produces no cc/bcc.',
|
|
322
|
+
},
|
|
323
|
+
},
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
assert.isSuccess(response, 'Should send email without carbon copies');
|
|
327
|
+
assert.equal(response.data.status, 'sent', 'Status should be sent');
|
|
328
|
+
assert.equal(response.data.options.cc.length, 0, 'cc should be empty with copy:false');
|
|
329
|
+
assert.equal(response.data.options.bcc.length, 0, 'bcc should be empty with copy:false');
|
|
330
|
+
},
|
|
331
|
+
},
|
|
332
|
+
|
|
333
|
+
{
|
|
334
|
+
name: 'html-override-replaces-template',
|
|
335
|
+
auth: 'admin',
|
|
336
|
+
timeout: 30000,
|
|
337
|
+
|
|
338
|
+
async run({ http, assert, config }) {
|
|
339
|
+
const response = await http.post('admin/email', {
|
|
340
|
+
subject: 'BEM Test Email - HTML Override',
|
|
341
|
+
to: `_test-receiver@${config.domain}`,
|
|
342
|
+
html: '<p>This is raw HTML content.</p>',
|
|
343
|
+
copy: false,
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
assert.isSuccess(response, 'Should send email with HTML override');
|
|
347
|
+
assert.equal(response.data.status, 'sent', 'Status should be sent');
|
|
348
|
+
assert.ok(response.data.options.content, 'Should have content array for HTML override');
|
|
349
|
+
assert.equal(response.data.options.templateId, undefined, 'templateId should be removed for HTML override');
|
|
350
|
+
},
|
|
351
|
+
},
|
|
352
|
+
|
|
353
|
+
{
|
|
354
|
+
name: 'sendat-iso-string-accepted',
|
|
355
|
+
auth: 'admin',
|
|
356
|
+
timeout: 30000,
|
|
357
|
+
|
|
358
|
+
async run({ http, assert, config }) {
|
|
359
|
+
// Use a time 1 hour from now (well within the 71h limit)
|
|
360
|
+
const sendAtDate = new Date(Date.now() + (60 * 60 * 1000)).toISOString();
|
|
361
|
+
|
|
362
|
+
const response = await http.post('admin/email', {
|
|
363
|
+
subject: 'BEM Test Email - ISO SendAt',
|
|
364
|
+
to: `_test-receiver@${config.domain}`,
|
|
365
|
+
sendAt: sendAtDate,
|
|
366
|
+
copy: false,
|
|
367
|
+
data: {
|
|
368
|
+
email: {
|
|
369
|
+
subject: 'BEM Test Email - ISO SendAt',
|
|
370
|
+
body: 'Testing ISO string sendAt.',
|
|
371
|
+
},
|
|
372
|
+
},
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
assert.isSuccess(response, 'Should send email with ISO sendAt');
|
|
376
|
+
assert.equal(response.data.status, 'sent', 'Status should be sent (within 71h)');
|
|
377
|
+
assert.isType(response.data.options.sendAt, 'number', 'sendAt should be normalized to unix timestamp');
|
|
378
|
+
},
|
|
379
|
+
},
|
|
380
|
+
],
|
|
381
|
+
};
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test: libraries/infer-contact.js
|
|
3
|
+
* Unit tests for contact inference from email addresses
|
|
4
|
+
*
|
|
5
|
+
* Tests capitalize, inferContactFromEmail (regex), and inferContact (AI fallback).
|
|
6
|
+
* AI tests only run when TEST_EXTENDED_MODE is set.
|
|
7
|
+
*/
|
|
8
|
+
const { inferContact, inferContactFromEmail, capitalize } = require('../../src/manager/libraries/infer-contact.js');
|
|
9
|
+
|
|
10
|
+
module.exports = {
|
|
11
|
+
description: 'Infer contact from email',
|
|
12
|
+
type: 'group',
|
|
13
|
+
|
|
14
|
+
tests: [
|
|
15
|
+
// ─── capitalize ───
|
|
16
|
+
|
|
17
|
+
{
|
|
18
|
+
name: 'capitalize-single-word',
|
|
19
|
+
async run({ assert }) {
|
|
20
|
+
assert.equal(capitalize('john'), 'John', 'Should capitalize first letter');
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
|
|
24
|
+
{
|
|
25
|
+
name: 'capitalize-multiple-words',
|
|
26
|
+
async run({ assert }) {
|
|
27
|
+
assert.equal(capitalize('john doe'), 'John Doe', 'Should capitalize each word');
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
|
|
31
|
+
{
|
|
32
|
+
name: 'capitalize-all-uppercase',
|
|
33
|
+
async run({ assert }) {
|
|
34
|
+
assert.equal(capitalize('JOHN'), 'John', 'Should lowercase after first letter');
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
|
|
38
|
+
{
|
|
39
|
+
name: 'capitalize-mixed-case',
|
|
40
|
+
async run({ assert }) {
|
|
41
|
+
assert.equal(capitalize('jOHN dOE'), 'John Doe', 'Should normalize mixed case');
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
|
|
45
|
+
{
|
|
46
|
+
name: 'capitalize-empty-string',
|
|
47
|
+
async run({ assert }) {
|
|
48
|
+
assert.equal(capitalize(''), '', 'Empty string should return empty');
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
|
|
52
|
+
{
|
|
53
|
+
name: 'capitalize-null',
|
|
54
|
+
async run({ assert }) {
|
|
55
|
+
assert.equal(capitalize(null), '', 'Null should return empty');
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
|
|
59
|
+
{
|
|
60
|
+
name: 'capitalize-undefined',
|
|
61
|
+
async run({ assert }) {
|
|
62
|
+
assert.equal(capitalize(undefined), '', 'Undefined should return empty');
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
|
|
66
|
+
// ─── inferContactFromEmail: name parsing ───
|
|
67
|
+
|
|
68
|
+
{
|
|
69
|
+
name: 'regex-first-dot-last',
|
|
70
|
+
async run({ assert }) {
|
|
71
|
+
const result = inferContactFromEmail('john.doe@gmail.com');
|
|
72
|
+
|
|
73
|
+
assert.equal(result.firstName, 'John', 'First name from local part before dot');
|
|
74
|
+
assert.equal(result.lastName, 'Doe', 'Last name from local part after dot');
|
|
75
|
+
assert.equal(result.method, 'regex', 'Method should be regex');
|
|
76
|
+
assert.equal(result.confidence, 0.5, 'Two-part name should have 0.5 confidence');
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
{
|
|
81
|
+
name: 'regex-first-underscore-last',
|
|
82
|
+
async run({ assert }) {
|
|
83
|
+
const result = inferContactFromEmail('jane_smith@yahoo.com');
|
|
84
|
+
|
|
85
|
+
assert.equal(result.firstName, 'Jane', 'First name from local part before underscore');
|
|
86
|
+
assert.equal(result.lastName, 'Smith', 'Last name from local part after underscore');
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
{
|
|
91
|
+
name: 'regex-first-hyphen-last',
|
|
92
|
+
async run({ assert }) {
|
|
93
|
+
const result = inferContactFromEmail('bob-jones@hotmail.com');
|
|
94
|
+
|
|
95
|
+
assert.equal(result.firstName, 'Bob', 'First name from local part before hyphen');
|
|
96
|
+
assert.equal(result.lastName, 'Jones', 'Last name from local part after hyphen');
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
|
|
100
|
+
{
|
|
101
|
+
name: 'regex-three-part-name',
|
|
102
|
+
async run({ assert }) {
|
|
103
|
+
const result = inferContactFromEmail('mary.jane.watson@example.com');
|
|
104
|
+
|
|
105
|
+
assert.equal(result.firstName, 'Mary', 'First name is first part');
|
|
106
|
+
assert.equal(result.lastName, 'Jane Watson', 'Last name joins remaining parts');
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
|
|
110
|
+
{
|
|
111
|
+
name: 'regex-single-word-local-part',
|
|
112
|
+
async run({ assert }) {
|
|
113
|
+
const result = inferContactFromEmail('admin@example.com');
|
|
114
|
+
|
|
115
|
+
assert.equal(result.firstName, 'Admin', 'Single word becomes first name');
|
|
116
|
+
assert.equal(result.lastName, '', 'No last name for single word');
|
|
117
|
+
assert.equal(result.confidence, 0.25, 'Single-part name should have 0.25 confidence');
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
|
|
121
|
+
{
|
|
122
|
+
name: 'regex-strips-trailing-numbers',
|
|
123
|
+
async run({ assert }) {
|
|
124
|
+
const result = inferContactFromEmail('john.doe42@gmail.com');
|
|
125
|
+
|
|
126
|
+
assert.equal(result.firstName, 'John', 'Trailing numbers stripped before parsing');
|
|
127
|
+
assert.equal(result.lastName, 'Doe', 'Name parsed correctly after stripping');
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
|
|
131
|
+
{
|
|
132
|
+
name: 'regex-only-numbers-after-name',
|
|
133
|
+
async run({ assert }) {
|
|
134
|
+
const result = inferContactFromEmail('user123@gmail.com');
|
|
135
|
+
|
|
136
|
+
assert.equal(result.firstName, 'User', 'Numbers stripped, name capitalized');
|
|
137
|
+
assert.equal(result.lastName, '', 'No last name');
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
|
|
141
|
+
// ─── inferContactFromEmail: company inference ───
|
|
142
|
+
|
|
143
|
+
{
|
|
144
|
+
name: 'regex-company-from-custom-domain',
|
|
145
|
+
async run({ assert }) {
|
|
146
|
+
const result = inferContactFromEmail('john@acme.com');
|
|
147
|
+
|
|
148
|
+
assert.equal(result.company, 'Acme', 'Company from custom domain');
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
|
|
152
|
+
{
|
|
153
|
+
name: 'regex-company-from-hyphenated-domain',
|
|
154
|
+
async run({ assert }) {
|
|
155
|
+
const result = inferContactFromEmail('john@my-company.com');
|
|
156
|
+
|
|
157
|
+
assert.equal(result.company, 'My Company', 'Hyphens replaced with spaces');
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
|
|
161
|
+
{
|
|
162
|
+
name: 'regex-company-from-underscored-domain',
|
|
163
|
+
async run({ assert }) {
|
|
164
|
+
const result = inferContactFromEmail('john@my_company.com');
|
|
165
|
+
|
|
166
|
+
assert.equal(result.company, 'My Company', 'Underscores replaced with spaces');
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
|
|
170
|
+
{
|
|
171
|
+
name: 'regex-no-company-from-gmail',
|
|
172
|
+
async run({ assert }) {
|
|
173
|
+
const result = inferContactFromEmail('john@gmail.com');
|
|
174
|
+
|
|
175
|
+
assert.equal(result.company, '', 'Gmail is generic, no company');
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
|
|
179
|
+
{
|
|
180
|
+
name: 'regex-no-company-from-yahoo',
|
|
181
|
+
async run({ assert }) {
|
|
182
|
+
const result = inferContactFromEmail('john@yahoo.com');
|
|
183
|
+
|
|
184
|
+
assert.equal(result.company, '', 'Yahoo is generic, no company');
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
|
|
188
|
+
{
|
|
189
|
+
name: 'regex-no-company-from-outlook',
|
|
190
|
+
async run({ assert }) {
|
|
191
|
+
const result = inferContactFromEmail('john@outlook.com');
|
|
192
|
+
|
|
193
|
+
assert.equal(result.company, '', 'Outlook is generic, no company');
|
|
194
|
+
},
|
|
195
|
+
},
|
|
196
|
+
|
|
197
|
+
{
|
|
198
|
+
name: 'regex-no-company-from-protonmail',
|
|
199
|
+
async run({ assert }) {
|
|
200
|
+
const result = inferContactFromEmail('john@protonmail.com');
|
|
201
|
+
|
|
202
|
+
assert.equal(result.company, '', 'Protonmail is generic, no company');
|
|
203
|
+
},
|
|
204
|
+
},
|
|
205
|
+
|
|
206
|
+
{
|
|
207
|
+
name: 'regex-no-company-from-icloud',
|
|
208
|
+
async run({ assert }) {
|
|
209
|
+
const result = inferContactFromEmail('john@icloud.com');
|
|
210
|
+
|
|
211
|
+
assert.equal(result.company, '', 'iCloud is generic, no company');
|
|
212
|
+
},
|
|
213
|
+
},
|
|
214
|
+
|
|
215
|
+
{
|
|
216
|
+
name: 'regex-generic-domain-case-insensitive',
|
|
217
|
+
async run({ assert }) {
|
|
218
|
+
const result = inferContactFromEmail('john@GMAIL.COM');
|
|
219
|
+
|
|
220
|
+
assert.equal(result.company, '', 'Generic domain check should be case-insensitive');
|
|
221
|
+
},
|
|
222
|
+
},
|
|
223
|
+
|
|
224
|
+
// ─── inferContactFromEmail: combined name + company ───
|
|
225
|
+
|
|
226
|
+
{
|
|
227
|
+
name: 'regex-full-result-custom-domain',
|
|
228
|
+
async run({ assert }) {
|
|
229
|
+
const result = inferContactFromEmail('sarah.connor@skynet.io');
|
|
230
|
+
|
|
231
|
+
assert.equal(result.firstName, 'Sarah', 'First name parsed');
|
|
232
|
+
assert.equal(result.lastName, 'Connor', 'Last name parsed');
|
|
233
|
+
assert.equal(result.company, 'Skynet', 'Company from domain');
|
|
234
|
+
assert.equal(result.method, 'regex', 'Method is regex');
|
|
235
|
+
assert.equal(result.confidence, 0.5, 'Confidence 0.5 for two-part name');
|
|
236
|
+
},
|
|
237
|
+
},
|
|
238
|
+
|
|
239
|
+
{
|
|
240
|
+
name: 'regex-single-name-custom-domain',
|
|
241
|
+
async run({ assert }) {
|
|
242
|
+
const result = inferContactFromEmail('info@acme.com');
|
|
243
|
+
|
|
244
|
+
assert.equal(result.firstName, 'Info', 'Single word capitalized');
|
|
245
|
+
assert.equal(result.lastName, '', 'No last name');
|
|
246
|
+
assert.equal(result.company, 'Acme', 'Company inferred');
|
|
247
|
+
assert.equal(result.confidence, 0.25, 'Lower confidence for single name');
|
|
248
|
+
},
|
|
249
|
+
},
|
|
250
|
+
|
|
251
|
+
// ─── inferContact: regex fallback (no OPENAI_API_KEY) ───
|
|
252
|
+
|
|
253
|
+
{
|
|
254
|
+
name: 'infer-contact-regex-fallback',
|
|
255
|
+
async run({ assert }) {
|
|
256
|
+
// Temporarily clear OPENAI_API_KEY to force regex path
|
|
257
|
+
const originalKey = process.env.OPENAI_API_KEY;
|
|
258
|
+
delete process.env.OPENAI_API_KEY;
|
|
259
|
+
|
|
260
|
+
try {
|
|
261
|
+
const result = await inferContact('alice.wonderland@example.com');
|
|
262
|
+
|
|
263
|
+
assert.equal(result.firstName, 'Alice', 'Regex fallback first name');
|
|
264
|
+
assert.equal(result.lastName, 'Wonderland', 'Regex fallback last name');
|
|
265
|
+
assert.equal(result.company, 'Example', 'Regex fallback company');
|
|
266
|
+
assert.equal(result.method, 'regex', 'Should use regex when no API key');
|
|
267
|
+
} finally {
|
|
268
|
+
// Restore
|
|
269
|
+
if (originalKey) {
|
|
270
|
+
process.env.OPENAI_API_KEY = originalKey;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
},
|
|
274
|
+
},
|
|
275
|
+
|
|
276
|
+
// ─── inferContact: AI path (requires TEST_EXTENDED_MODE) ───
|
|
277
|
+
|
|
278
|
+
{
|
|
279
|
+
name: 'infer-contact-ai',
|
|
280
|
+
skip: !process.env.TEST_EXTENDED_MODE ? 'TEST_EXTENDED_MODE not set (skipping AI inference test)' : false,
|
|
281
|
+
timeout: 30000,
|
|
282
|
+
|
|
283
|
+
async run({ assert, Manager }) {
|
|
284
|
+
// This test requires a real OPENAI_API_KEY and running Manager
|
|
285
|
+
if (!process.env.OPENAI_API_KEY) {
|
|
286
|
+
return assert.fail('OPENAI_API_KEY not set');
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const assistant = Manager.Assistant();
|
|
290
|
+
const result = await inferContact('john.smith@microsoft.com', assistant);
|
|
291
|
+
|
|
292
|
+
assert.ok(result, 'Should return a result');
|
|
293
|
+
assert.ok(result.firstName, 'Should infer a first name');
|
|
294
|
+
assert.equal(result.method, 'ai', 'Should use AI method');
|
|
295
|
+
assert.ok(typeof result.confidence === 'number', 'Confidence should be a number');
|
|
296
|
+
},
|
|
297
|
+
},
|
|
298
|
+
],
|
|
299
|
+
};
|