@tellescope/sdk 1.236.1 → 1.236.2

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 (32) hide show
  1. package/lib/cjs/enduser.d.ts +20 -0
  2. package/lib/cjs/enduser.d.ts.map +1 -1
  3. package/lib/cjs/sdk.d.ts +42 -0
  4. package/lib/cjs/sdk.d.ts.map +1 -1
  5. package/lib/cjs/sdk.js +1 -0
  6. package/lib/cjs/sdk.js.map +1 -1
  7. package/lib/cjs/tests/api_tests/auto_merge_form_submission.test.d.ts +9 -0
  8. package/lib/cjs/tests/api_tests/auto_merge_form_submission.test.d.ts.map +1 -0
  9. package/lib/cjs/tests/api_tests/auto_merge_form_submission.test.js +1399 -0
  10. package/lib/cjs/tests/api_tests/auto_merge_form_submission.test.js.map +1 -0
  11. package/lib/cjs/tests/tests.d.ts.map +1 -1
  12. package/lib/cjs/tests/tests.js +111 -106
  13. package/lib/cjs/tests/tests.js.map +1 -1
  14. package/lib/esm/enduser.d.ts +20 -0
  15. package/lib/esm/enduser.d.ts.map +1 -1
  16. package/lib/esm/sdk.d.ts +42 -0
  17. package/lib/esm/sdk.d.ts.map +1 -1
  18. package/lib/esm/sdk.js +1 -0
  19. package/lib/esm/sdk.js.map +1 -1
  20. package/lib/esm/tests/api_tests/auto_merge_form_submission.test.d.ts +9 -0
  21. package/lib/esm/tests/api_tests/auto_merge_form_submission.test.d.ts.map +1 -0
  22. package/lib/esm/tests/api_tests/auto_merge_form_submission.test.js +1372 -0
  23. package/lib/esm/tests/api_tests/auto_merge_form_submission.test.js.map +1 -0
  24. package/lib/esm/tests/tests.d.ts.map +1 -1
  25. package/lib/esm/tests/tests.js +111 -106
  26. package/lib/esm/tests/tests.js.map +1 -1
  27. package/lib/tsconfig.tsbuildinfo +1 -1
  28. package/package.json +10 -10
  29. package/src/sdk.ts +2 -1
  30. package/src/tests/api_tests/auto_merge_form_submission.test.ts +876 -0
  31. package/src/tests/tests.ts +4 -1
  32. package/test_generated.pdf +0 -0
@@ -0,0 +1,876 @@
1
+ require('source-map-support').install();
2
+
3
+ import * as buffer from 'buffer'
4
+ import { Session, EnduserSession } from "../../sdk"
5
+ import {
6
+ async_test,
7
+ log_header,
8
+ } from "@tellescope/testing"
9
+ import { setup_tests } from "../setup"
10
+ import { Form, FormField } from "@tellescope/types-client"
11
+
12
+ const host = process.env.API_URL || 'http://localhost:8080' as const
13
+
14
+ /**
15
+ * Helper: Create a form with auto-merge enabled and intake fields
16
+ */
17
+ const createAutoMergeForm = async (sdk: Session, autoMergeOnSubmission = true) => {
18
+ const form = await sdk.api.forms.createOne({
19
+ title: 'Auto Merge Test Form',
20
+ allowPublicURL: true,
21
+ autoMergeOnSubmission,
22
+ })
23
+
24
+ // Add intake fields - must create sequentially due to previousFields dependencies
25
+ const fnameField = await sdk.api.form_fields.createOne({
26
+ formId: form.id,
27
+ title: 'First Name',
28
+ type: 'string',
29
+ intakeField: 'fname',
30
+ previousFields: [{ type: 'root', info: {} }]
31
+ })
32
+ const lnameField = await sdk.api.form_fields.createOne({
33
+ formId: form.id,
34
+ title: 'Last Name',
35
+ type: 'string',
36
+ intakeField: 'lname',
37
+ previousFields: [{ type: 'after', info: { fieldId: fnameField.id } }]
38
+ })
39
+ const emailField = await sdk.api.form_fields.createOne({
40
+ formId: form.id,
41
+ title: 'Email',
42
+ type: 'email',
43
+ intakeField: 'email',
44
+ previousFields: [{ type: 'after', info: { fieldId: lnameField.id } }]
45
+ })
46
+ const phoneField = await sdk.api.form_fields.createOne({
47
+ formId: form.id,
48
+ title: 'Phone',
49
+ type: 'phone',
50
+ intakeField: 'phone',
51
+ previousFields: [{ type: 'after', info: { fieldId: emailField.id } }]
52
+ })
53
+ const dobField = await sdk.api.form_fields.createOne({
54
+ formId: form.id,
55
+ title: 'Date of Birth',
56
+ type: 'dateString',
57
+ intakeField: 'dateOfBirth',
58
+ previousFields: [{ type: 'after', info: { fieldId: phoneField.id } }]
59
+ })
60
+
61
+ const fields = [fnameField, lnameField, emailField, phoneField, dobField]
62
+ return { form, fields }
63
+ }
64
+
65
+ /**
66
+ * Helper: Submit a public form with skipMatch and get the created enduser ID
67
+ */
68
+ const submitPublicFormWithSkipMatch = async (
69
+ form: Form,
70
+ fields: FormField[],
71
+ values: { fname?: string, lname?: string, email?: string, phone?: string, dateOfBirth?: string }
72
+ ) => {
73
+ const enduserSDK = new EnduserSession({ host, businessId: form.businessId })
74
+
75
+ const { authToken, accessCode, enduserId } = await enduserSDK.api.form_responses.session_for_public_form({
76
+ formId: form.id,
77
+ businessId: form.businessId,
78
+ skipMatch: true,
79
+ })
80
+
81
+ const authedSDK = new EnduserSession({ host, businessId: form.businessId, authToken })
82
+
83
+ const responses = []
84
+ const fnameField = fields.find(f => f.intakeField === 'fname')
85
+ const lnameField = fields.find(f => f.intakeField === 'lname')
86
+ const emailField = fields.find(f => f.intakeField === 'email')
87
+ const phoneField = fields.find(f => f.intakeField === 'phone')
88
+ const dobField = fields.find(f => f.intakeField === 'dateOfBirth')
89
+
90
+ if (values.fname && fnameField) {
91
+ responses.push({ fieldId: fnameField.id, fieldTitle: fnameField.title, answer: { type: 'string' as const, value: values.fname } })
92
+ }
93
+ if (values.lname && lnameField) {
94
+ responses.push({ fieldId: lnameField.id, fieldTitle: lnameField.title, answer: { type: 'string' as const, value: values.lname } })
95
+ }
96
+ if (values.email && emailField) {
97
+ responses.push({ fieldId: emailField.id, fieldTitle: emailField.title, answer: { type: 'email' as const, value: values.email } })
98
+ }
99
+ if (values.phone && phoneField) {
100
+ responses.push({ fieldId: phoneField.id, fieldTitle: phoneField.title, answer: { type: 'phone' as const, value: values.phone } })
101
+ }
102
+ if (values.dateOfBirth && dobField) {
103
+ responses.push({ fieldId: dobField.id, fieldTitle: dobField.title, answer: { type: 'dateString' as const, value: values.dateOfBirth } })
104
+ }
105
+
106
+ await authedSDK.api.form_responses.submit_form_response({
107
+ accessCode,
108
+ responses,
109
+ })
110
+
111
+ return { enduserId, accessCode, authedSDK }
112
+ }
113
+
114
+ /**
115
+ * Helper: Check if enduser has been deleted (immediate check, no polling)
116
+ * Since auto-merge is now synchronous, we don't need to poll
117
+ */
118
+ const isEnduserDeleted = async (sdk: Session, enduserId: string): Promise<boolean> => {
119
+ try {
120
+ await sdk.api.endusers.getOne(enduserId)
121
+ return false // Still exists
122
+ } catch {
123
+ return true // Deleted
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Main test function that can be called independently or as part of the test suite
129
+ */
130
+ export const auto_merge_form_submission_tests = async ({ sdk, sdkNonAdmin }: { sdk: Session, sdkNonAdmin: Session }) => {
131
+ log_header("Auto-Merge Form Submission Tests")
132
+
133
+ // Test 1: Happy Path - Merge by Email Match
134
+ await async_test(
135
+ "Auto-merge: Merge occurs when matching by email",
136
+ async () => {
137
+ const testEmail = `automerge.email.${Date.now()}@test.com`
138
+ const destination = await sdk.api.endusers.createOne({
139
+ fname: 'John',
140
+ lname: 'Doe',
141
+ email: testEmail
142
+ })
143
+ const { form, fields } = await createAutoMergeForm(sdk, true)
144
+
145
+ const { enduserId: sourceId } = await submitPublicFormWithSkipMatch(form, fields, {
146
+ fname: 'John',
147
+ lname: 'Doe',
148
+ email: testEmail,
149
+ })
150
+
151
+ // Merge is synchronous - source should be deleted immediately after submission
152
+ const sourceDeleted = await isEnduserDeleted(sdk, sourceId)
153
+ const updatedDestination = await sdk.api.endusers.getOne(destination.id)
154
+ const formResponses = await sdk.api.form_responses.getSome({ filter: { enduserId: destination.id } })
155
+
156
+ // Cleanup
157
+ await sdk.api.forms.deleteOne(form.id)
158
+ await sdk.api.endusers.deleteOne(destination.id)
159
+
160
+ return sourceDeleted
161
+ && updatedDestination.mergedIds?.includes(sourceId)
162
+ && formResponses.length === 1
163
+ },
164
+ { expectedResult: true }
165
+ )
166
+
167
+ // Test 2: Happy Path - Merge by Phone Match
168
+ await async_test(
169
+ "Auto-merge: Merge occurs when matching by phone",
170
+ async () => {
171
+ const testPhone = '+15555551234'
172
+ const destination = await sdk.api.endusers.createOne({
173
+ fname: 'Jane',
174
+ lname: 'Smith',
175
+ phone: testPhone
176
+ })
177
+ const { form, fields } = await createAutoMergeForm(sdk, true)
178
+
179
+ const { enduserId: sourceId } = await submitPublicFormWithSkipMatch(form, fields, {
180
+ fname: 'Jane',
181
+ lname: 'Smith',
182
+ phone: testPhone,
183
+ })
184
+
185
+ // Merge is synchronous - source should be deleted immediately after submission
186
+ const sourceDeleted = await isEnduserDeleted(sdk, sourceId)
187
+ const updatedDestination = await sdk.api.endusers.getOne(destination.id)
188
+
189
+ // Cleanup
190
+ await sdk.api.forms.deleteOne(form.id)
191
+ await sdk.api.endusers.deleteOne(destination.id)
192
+
193
+ return sourceDeleted && updatedDestination.mergedIds?.includes(sourceId)
194
+ },
195
+ { expectedResult: true }
196
+ )
197
+
198
+ // Test 3: Happy Path - Merge by DateOfBirth Match
199
+ await async_test(
200
+ "Auto-merge: Merge occurs when matching by dateOfBirth",
201
+ async () => {
202
+ const testDOB = '1990-05-15'
203
+ const destination = await sdk.api.endusers.createOne({
204
+ fname: 'Bob',
205
+ lname: 'Johnson',
206
+ dateOfBirth: testDOB
207
+ })
208
+ const { form, fields } = await createAutoMergeForm(sdk, true)
209
+
210
+ const { enduserId: sourceId } = await submitPublicFormWithSkipMatch(form, fields, {
211
+ fname: 'Bob',
212
+ lname: 'Johnson',
213
+ dateOfBirth: testDOB,
214
+ })
215
+
216
+ // Merge is synchronous - source should be deleted immediately after submission
217
+ const sourceDeleted = await isEnduserDeleted(sdk, sourceId)
218
+ const updatedDestination = await sdk.api.endusers.getOne(destination.id)
219
+
220
+ // Cleanup
221
+ await sdk.api.forms.deleteOne(form.id)
222
+ await sdk.api.endusers.deleteOne(destination.id)
223
+
224
+ return sourceDeleted && updatedDestination.mergedIds?.includes(sourceId)
225
+ },
226
+ { expectedResult: true }
227
+ )
228
+
229
+ // Test 4: No Merge - Multiple Matches
230
+ await async_test(
231
+ "Auto-merge: No merge when multiple matches found",
232
+ async () => {
233
+ // Use dateOfBirth for matching since email/phone have uniqueness constraints
234
+ const testDOB = '1975-01-15'
235
+ const destination1 = await sdk.api.endusers.createOne({
236
+ fname: 'Multi',
237
+ lname: 'Match',
238
+ dateOfBirth: testDOB
239
+ })
240
+ const destination2 = await sdk.api.endusers.createOne({
241
+ fname: 'Multi',
242
+ lname: 'Match',
243
+ dateOfBirth: testDOB
244
+ })
245
+ const { form, fields } = await createAutoMergeForm(sdk, true)
246
+
247
+ const { enduserId: sourceId } = await submitPublicFormWithSkipMatch(form, fields, {
248
+ fname: 'Multi',
249
+ lname: 'Match',
250
+ dateOfBirth: testDOB,
251
+ })
252
+
253
+ // Merge is synchronous - no need to wait, source should still exist
254
+ const sourceDeleted = await isEnduserDeleted(sdk, sourceId)
255
+ const dest1 = await sdk.api.endusers.getOne(destination1.id)
256
+ const dest2 = await sdk.api.endusers.getOne(destination2.id)
257
+
258
+ // Cleanup
259
+ await sdk.api.forms.deleteOne(form.id)
260
+ await sdk.api.endusers.deleteOne(destination1.id)
261
+ await sdk.api.endusers.deleteOne(destination2.id)
262
+ if (!sourceDeleted) await sdk.api.endusers.deleteOne(sourceId)
263
+
264
+ return !sourceDeleted // Source should NOT be deleted
265
+ && !dest1.mergedIds?.includes(sourceId)
266
+ && !dest2.mergedIds?.includes(sourceId)
267
+ },
268
+ { expectedResult: true }
269
+ )
270
+
271
+ // Test 5: No Merge - autoMergeOnSubmission Disabled
272
+ await async_test(
273
+ "Auto-merge: No merge when autoMergeOnSubmission is disabled",
274
+ async () => {
275
+ const testEmail = `automerge.disabled.${Date.now()}@test.com`
276
+ const destination = await sdk.api.endusers.createOne({
277
+ fname: 'Disabled',
278
+ lname: 'Test',
279
+ email: testEmail
280
+ })
281
+ const { form, fields } = await createAutoMergeForm(sdk, false) // Disabled
282
+
283
+ const { enduserId: sourceId } = await submitPublicFormWithSkipMatch(form, fields, {
284
+ fname: 'Disabled',
285
+ lname: 'Test',
286
+ email: testEmail,
287
+ })
288
+
289
+ // Merge is synchronous - no need to wait, source should still exist
290
+ const sourceDeleted = await isEnduserDeleted(sdk, sourceId)
291
+
292
+ // Cleanup
293
+ await sdk.api.forms.deleteOne(form.id)
294
+ await sdk.api.endusers.deleteOne(destination.id)
295
+ if (!sourceDeleted) await sdk.api.endusers.deleteOne(sourceId)
296
+
297
+ return !sourceDeleted // Source should NOT be deleted
298
+ },
299
+ { expectedResult: true }
300
+ )
301
+
302
+ // Test 6: No Merge - No Matching Enduser
303
+ await async_test(
304
+ "Auto-merge: No merge when no matching enduser exists",
305
+ async () => {
306
+ const { form, fields } = await createAutoMergeForm(sdk, true)
307
+
308
+ const { enduserId: sourceId } = await submitPublicFormWithSkipMatch(form, fields, {
309
+ fname: 'NoMatch',
310
+ lname: 'Person',
311
+ email: `nomatch.${Date.now()}@test.com`,
312
+ })
313
+
314
+ // Merge is synchronous - no need to wait, source should still exist (no match found)
315
+ const sourceDeleted = await isEnduserDeleted(sdk, sourceId)
316
+
317
+ // Cleanup
318
+ await sdk.api.forms.deleteOne(form.id)
319
+ if (!sourceDeleted) await sdk.api.endusers.deleteOne(sourceId)
320
+
321
+ return !sourceDeleted // Source should NOT be deleted
322
+ },
323
+ { expectedResult: true }
324
+ )
325
+
326
+ // Test 7: Case Sensitive Matching - No Merge When Case Differs
327
+ await async_test(
328
+ "Auto-merge: No merge when case differs (case-sensitive matching)",
329
+ async () => {
330
+ const testEmail = `automerge.case.${Date.now()}@test.com`
331
+ const destination = await sdk.api.endusers.createOne({
332
+ fname: 'Case',
333
+ lname: 'Test',
334
+ email: testEmail
335
+ })
336
+ const { form, fields } = await createAutoMergeForm(sdk, true)
337
+
338
+ // Submit with different case - should NOT match due to case-sensitive matching
339
+ const { enduserId: sourceId } = await submitPublicFormWithSkipMatch(form, fields, {
340
+ fname: 'CASE', // Different case
341
+ lname: 'TEST', // Different case
342
+ email: testEmail.toUpperCase(), // Different case
343
+ })
344
+
345
+ // Merge is case-sensitive - source should still exist (no match found)
346
+ const sourceDeleted = await isEnduserDeleted(sdk, sourceId)
347
+ const updatedDestination = await sdk.api.endusers.getOne(destination.id)
348
+
349
+ // Cleanup
350
+ await sdk.api.forms.deleteOne(form.id)
351
+ await sdk.api.endusers.deleteOne(destination.id)
352
+ if (!sourceDeleted) await sdk.api.endusers.deleteOne(sourceId)
353
+
354
+ return !sourceDeleted // Source should NOT be deleted (no merge)
355
+ && !updatedDestination.mergedIds?.includes(sourceId)
356
+ },
357
+ { expectedResult: true }
358
+ )
359
+
360
+ // Test 8: eligibleForAutoMerge Flag Verification
361
+ await async_test(
362
+ "Auto-merge: eligibleForAutoMerge flag is set correctly",
363
+ async () => {
364
+ // Form with autoMergeOnSubmission: true
365
+ const { form: formEnabled, fields: fieldsEnabled } = await createAutoMergeForm(sdk, true)
366
+ const enduserSDKEnabled = new EnduserSession({ host, businessId: formEnabled.businessId })
367
+ const { enduserId: enabledEnduserId } = await enduserSDKEnabled.api.form_responses.session_for_public_form({
368
+ formId: formEnabled.id,
369
+ businessId: formEnabled.businessId,
370
+ skipMatch: true,
371
+ })
372
+ const enabledEnduser = await sdk.api.endusers.getOne(enabledEnduserId)
373
+
374
+ // Form with autoMergeOnSubmission: false
375
+ const { form: formDisabled, fields: fieldsDisabled } = await createAutoMergeForm(sdk, false)
376
+ const enduserSDKDisabled = new EnduserSession({ host, businessId: formDisabled.businessId })
377
+ const { enduserId: disabledEnduserId } = await enduserSDKDisabled.api.form_responses.session_for_public_form({
378
+ formId: formDisabled.id,
379
+ businessId: formDisabled.businessId,
380
+ skipMatch: true,
381
+ })
382
+ const disabledEnduser = await sdk.api.endusers.getOne(disabledEnduserId)
383
+
384
+ // Cleanup
385
+ await sdk.api.forms.deleteOne(formEnabled.id)
386
+ await sdk.api.forms.deleteOne(formDisabled.id)
387
+ await sdk.api.endusers.deleteOne(enabledEnduserId)
388
+ await sdk.api.endusers.deleteOne(disabledEnduserId)
389
+
390
+ return enabledEnduser.eligibleForAutoMerge === true
391
+ && disabledEnduser.eligibleForAutoMerge !== true
392
+ },
393
+ { expectedResult: true }
394
+ )
395
+
396
+ // Test 9: Files Transfer on Merge
397
+ await async_test(
398
+ "Auto-merge: Files are transferred to destination enduser",
399
+ async () => {
400
+ const testEmail = `automerge.files.${Date.now()}@test.com`
401
+ const destination = await sdk.api.endusers.createOne({
402
+ fname: 'Files',
403
+ lname: 'Test',
404
+ email: testEmail
405
+ })
406
+ const { form, fields } = await createAutoMergeForm(sdk, true)
407
+
408
+ // Create public session to get source enduser
409
+ const enduserSDK = new EnduserSession({ host, businessId: form.businessId })
410
+ const { authToken, accessCode, enduserId: sourceId } = await enduserSDK.api.form_responses.session_for_public_form({
411
+ formId: form.id,
412
+ businessId: form.businessId,
413
+ skipMatch: true,
414
+ })
415
+
416
+ // Create a file for the source enduser using prepare_file_upload + UPLOAD
417
+ const buff = buffer.Buffer.from('test file data for auto-merge')
418
+ const { presignedUpload, file } = await sdk.api.files.prepare_file_upload({
419
+ name: 'test-file.txt',
420
+ type: 'text/plain',
421
+ size: buff.byteLength,
422
+ enduserId: sourceId,
423
+ })
424
+ await sdk.UPLOAD(presignedUpload as any, buff)
425
+
426
+ // Now submit the form to trigger merge
427
+ const authedSDK = new EnduserSession({ host, businessId: form.businessId, authToken })
428
+ const fnameField = fields.find(f => f.intakeField === 'fname')!
429
+ const lnameField = fields.find(f => f.intakeField === 'lname')!
430
+ const emailField = fields.find(f => f.intakeField === 'email')!
431
+
432
+ await authedSDK.api.form_responses.submit_form_response({
433
+ accessCode,
434
+ responses: [
435
+ { fieldId: fnameField.id, fieldTitle: fnameField.title, answer: { type: 'string', value: 'Files' } },
436
+ { fieldId: lnameField.id, fieldTitle: lnameField.title, answer: { type: 'string', value: 'Test' } },
437
+ { fieldId: emailField.id, fieldTitle: emailField.title, answer: { type: 'email', value: testEmail } },
438
+ ],
439
+ })
440
+
441
+ // Merge is synchronous - source should be deleted immediately after submission
442
+ const sourceDeleted = await isEnduserDeleted(sdk, sourceId)
443
+ const updatedFile = await sdk.api.files.getOne(file.id)
444
+
445
+ // Cleanup
446
+ await sdk.api.forms.deleteOne(form.id)
447
+ await sdk.api.files.deleteOne(file.id)
448
+ await sdk.api.endusers.deleteOne(destination.id)
449
+
450
+ return sourceDeleted && updatedFile.enduserId === destination.id
451
+ },
452
+ { expectedResult: true }
453
+ )
454
+
455
+ // Test 10: Calendar Events Transfer on Merge
456
+ await async_test(
457
+ "Auto-merge: Calendar events are transferred to destination enduser",
458
+ async () => {
459
+ const testEmail = `automerge.events.${Date.now()}@test.com`
460
+ const destination = await sdk.api.endusers.createOne({
461
+ fname: 'Events',
462
+ lname: 'Test',
463
+ email: testEmail
464
+ })
465
+ const { form, fields } = await createAutoMergeForm(sdk, true)
466
+
467
+ // Create public session to get source enduser
468
+ const enduserSDK = new EnduserSession({ host, businessId: form.businessId })
469
+ const { authToken, accessCode, enduserId: sourceId } = await enduserSDK.api.form_responses.session_for_public_form({
470
+ formId: form.id,
471
+ businessId: form.businessId,
472
+ skipMatch: true,
473
+ })
474
+
475
+ // Create a calendar event with source enduser as attendee
476
+ const event = await sdk.api.calendar_events.createOne({
477
+ title: 'Test Event',
478
+ startTimeInMS: Date.now() + 86400000, // Tomorrow
479
+ durationInMinutes: 30,
480
+ attendees: [{ id: sourceId, type: 'enduser' }],
481
+ })
482
+
483
+ // Now submit the form to trigger merge
484
+ const authedSDK = new EnduserSession({ host, businessId: form.businessId, authToken })
485
+ const fnameField = fields.find(f => f.intakeField === 'fname')!
486
+ const lnameField = fields.find(f => f.intakeField === 'lname')!
487
+ const emailField = fields.find(f => f.intakeField === 'email')!
488
+
489
+ await authedSDK.api.form_responses.submit_form_response({
490
+ accessCode,
491
+ responses: [
492
+ { fieldId: fnameField.id, fieldTitle: fnameField.title, answer: { type: 'string', value: 'Events' } },
493
+ { fieldId: lnameField.id, fieldTitle: lnameField.title, answer: { type: 'string', value: 'Test' } },
494
+ { fieldId: emailField.id, fieldTitle: emailField.title, answer: { type: 'email', value: testEmail } },
495
+ ],
496
+ })
497
+
498
+ // Merge is synchronous - source should be deleted immediately after submission
499
+ const sourceDeleted = await isEnduserDeleted(sdk, sourceId)
500
+ const updatedEvent = await sdk.api.calendar_events.getOne(event.id)
501
+
502
+ // Cleanup
503
+ await sdk.api.forms.deleteOne(form.id)
504
+ await sdk.api.calendar_events.deleteOne(event.id)
505
+ await sdk.api.endusers.deleteOne(destination.id)
506
+
507
+ return sourceDeleted && updatedEvent.attendees?.some(a => a.id === destination.id)
508
+ },
509
+ { expectedResult: true }
510
+ )
511
+
512
+ // Test 11: Form response enduserId is updated to destination (placeholder is updated before submission completes)
513
+ await async_test(
514
+ "Auto-merge: Form response enduserId is updated to destination",
515
+ async () => {
516
+ const testEmail = `automerge.directfr.${Date.now()}@test.com`
517
+ const destination = await sdk.api.endusers.createOne({
518
+ fname: 'Direct',
519
+ lname: 'Response',
520
+ email: testEmail
521
+ })
522
+ const { form, fields } = await createAutoMergeForm(sdk, true)
523
+
524
+ const { enduserId: sourceId, accessCode } = await submitPublicFormWithSkipMatch(form, fields, {
525
+ fname: 'Direct',
526
+ lname: 'Response',
527
+ email: testEmail,
528
+ })
529
+
530
+ // Merge is synchronous - verify form response was created with destination ID
531
+ const sourceDeleted = await isEnduserDeleted(sdk, sourceId)
532
+
533
+ // Get form responses by accessCode to find the one we created
534
+ const formResponses = await sdk.api.form_responses.getSome({ filter: { accessCode } })
535
+ const createdFormResponse = formResponses[0]
536
+
537
+ // Cleanup
538
+ await sdk.api.forms.deleteOne(form.id)
539
+ await sdk.api.endusers.deleteOne(destination.id)
540
+
541
+ // The form response should have been created directly with destination enduser ID
542
+ // (not transferred after creation)
543
+ return sourceDeleted
544
+ && createdFormResponse !== undefined
545
+ && createdFormResponse.enduserId === destination.id
546
+ },
547
+ { expectedResult: true }
548
+ )
549
+
550
+ // Test 12: Intake fields update destination enduser directly
551
+ await async_test(
552
+ "Auto-merge: Intake fields update destination enduser directly",
553
+ async () => {
554
+ const testEmail = `automerge.intake.${Date.now()}@test.com`
555
+ const destination = await sdk.api.endusers.createOne({
556
+ fname: 'Intake',
557
+ lname: 'Test',
558
+ email: testEmail,
559
+ // No phone or DOB set initially
560
+ })
561
+ const { form, fields } = await createAutoMergeForm(sdk, true)
562
+
563
+ const newPhone = '+15555559876'
564
+ const newDOB = '1985-03-20'
565
+
566
+ const { enduserId: sourceId } = await submitPublicFormWithSkipMatch(form, fields, {
567
+ fname: 'Intake',
568
+ lname: 'Test',
569
+ email: testEmail,
570
+ phone: newPhone,
571
+ dateOfBirth: newDOB,
572
+ })
573
+
574
+ // Merge is synchronous - verify intake fields updated destination directly
575
+ const sourceDeleted = await isEnduserDeleted(sdk, sourceId)
576
+ const updatedDestination = await sdk.api.endusers.getOne(destination.id)
577
+
578
+ // Cleanup
579
+ await sdk.api.forms.deleteOne(form.id)
580
+ await sdk.api.endusers.deleteOne(destination.id)
581
+
582
+ // Intake fields should have been applied to the destination enduser
583
+ return sourceDeleted
584
+ && updatedDestination.phone === newPhone
585
+ && updatedDestination.dateOfBirth === newDOB
586
+ },
587
+ { expectedResult: true }
588
+ )
589
+
590
+ // Test 13: eligibleForAutoMerge is unset after submission (no merge case)
591
+ await async_test(
592
+ "Auto-merge: eligibleForAutoMerge is unset after submission when no merge occurs",
593
+ async () => {
594
+ const { form, fields } = await createAutoMergeForm(sdk, true)
595
+
596
+ // Submit form - no match exists, so no merge will happen
597
+ const { enduserId: sourceId } = await submitPublicFormWithSkipMatch(form, fields, {
598
+ fname: 'Unset',
599
+ lname: 'Flag',
600
+ email: `unset.flag.${Date.now()}@test.com`,
601
+ })
602
+
603
+ // Verify enduser still exists and eligibleForAutoMerge is unset
604
+ const updatedEnduser = await sdk.api.endusers.getOne(sourceId)
605
+
606
+ // Cleanup
607
+ await sdk.api.forms.deleteOne(form.id)
608
+ await sdk.api.endusers.deleteOne(sourceId)
609
+
610
+ // eligibleForAutoMerge should be unset (undefined/falsy) after submission
611
+ return updatedEnduser.eligibleForAutoMerge !== true
612
+ },
613
+ { expectedResult: true }
614
+ )
615
+
616
+ // ============================================
617
+ // BACKWARDS COMPATIBILITY & EDGE CASE TESTS
618
+ // ============================================
619
+
620
+ // Test 14: No merge when source enduser has multiple form responses
621
+ await async_test(
622
+ "Auto-merge: No merge when source enduser already has multiple form responses",
623
+ async () => {
624
+ const testEmail = `automerge.multiresponse.${Date.now()}@test.com`
625
+ const destination = await sdk.api.endusers.createOne({
626
+ fname: 'Multi',
627
+ lname: 'Response',
628
+ email: testEmail
629
+ })
630
+ const { form: form1, fields: fields1 } = await createAutoMergeForm(sdk, true)
631
+ const { form: form2, fields: fields2 } = await createAutoMergeForm(sdk, true)
632
+
633
+ // Create first public session and submit (this creates first form response)
634
+ const enduserSDK1 = new EnduserSession({ host, businessId: form1.businessId })
635
+ const session1 = await enduserSDK1.api.form_responses.session_for_public_form({
636
+ formId: form1.id,
637
+ businessId: form1.businessId,
638
+ skipMatch: true,
639
+ })
640
+ const sourceId = session1.enduserId
641
+
642
+ // Submit first form with non-matching data (no merge should happen)
643
+ const authedSDK1 = new EnduserSession({ host, businessId: form1.businessId, authToken: session1.authToken })
644
+ const fnameField1 = fields1.find(f => f.intakeField === 'fname')!
645
+ const lnameField1 = fields1.find(f => f.intakeField === 'lname')!
646
+ const emailField1 = fields1.find(f => f.intakeField === 'email')!
647
+ await authedSDK1.api.form_responses.submit_form_response({
648
+ accessCode: session1.accessCode,
649
+ responses: [
650
+ { fieldId: fnameField1.id, fieldTitle: fnameField1.title, answer: { type: 'string', value: 'Different' } },
651
+ { fieldId: lnameField1.id, fieldTitle: lnameField1.title, answer: { type: 'string', value: 'Person' } },
652
+ { fieldId: emailField1.id, fieldTitle: emailField1.title, answer: { type: 'email', value: `different.${Date.now()}@test.com` } },
653
+ ],
654
+ })
655
+
656
+ // Now create a second form response for the SAME source enduser via admin SDK
657
+ await sdk.api.form_responses.createOne({
658
+ formId: form2.id,
659
+ formTitle: 'Auto Merge Test Form',
660
+ enduserId: sourceId,
661
+ })
662
+
663
+ // Re-set eligibleForAutoMerge manually to simulate another attempt
664
+ await sdk.api.endusers.updateOne(sourceId, { eligibleForAutoMerge: true })
665
+
666
+ // Create another public session that would match - but source now has 2+ form responses
667
+ const enduserSDK2 = new EnduserSession({ host, businessId: form2.businessId })
668
+ const session2 = await enduserSDK2.api.form_responses.session_for_public_form({
669
+ formId: form2.id,
670
+ businessId: form2.businessId,
671
+ skipMatch: true,
672
+ })
673
+ const sourceId2 = session2.enduserId
674
+
675
+ // Manually add another form response to sourceId2 to trigger the >1 check
676
+ await sdk.api.form_responses.createOne({
677
+ formId: form1.id,
678
+ formTitle: 'Auto Merge Test Form',
679
+ enduserId: sourceId2,
680
+ })
681
+
682
+ // Submit with matching data - should NOT merge because source has >1 form responses
683
+ const authedSDK2 = new EnduserSession({ host, businessId: form2.businessId, authToken: session2.authToken })
684
+ const fnameField2 = fields2.find(f => f.intakeField === 'fname')!
685
+ const lnameField2 = fields2.find(f => f.intakeField === 'lname')!
686
+ const emailField2 = fields2.find(f => f.intakeField === 'email')!
687
+ await authedSDK2.api.form_responses.submit_form_response({
688
+ accessCode: session2.accessCode,
689
+ responses: [
690
+ { fieldId: fnameField2.id, fieldTitle: fnameField2.title, answer: { type: 'string', value: 'Multi' } },
691
+ { fieldId: lnameField2.id, fieldTitle: lnameField2.title, answer: { type: 'string', value: 'Response' } },
692
+ { fieldId: emailField2.id, fieldTitle: emailField2.title, answer: { type: 'email', value: testEmail } },
693
+ ],
694
+ })
695
+
696
+ // Source should NOT be deleted because it had multiple form responses
697
+ const sourceStillExists = !(await isEnduserDeleted(sdk, sourceId2))
698
+
699
+ // Cleanup
700
+ await sdk.api.forms.deleteOne(form1.id)
701
+ await sdk.api.forms.deleteOne(form2.id)
702
+ await sdk.api.endusers.deleteOne(destination.id)
703
+ await sdk.api.endusers.deleteOne(sourceId)
704
+ if (sourceStillExists) await sdk.api.endusers.deleteOne(sourceId2)
705
+
706
+ return sourceStillExists
707
+ },
708
+ { expectedResult: true }
709
+ )
710
+
711
+ // Test 15: Backwards compat - skipMatch=false does NOT set eligibleForAutoMerge
712
+ await async_test(
713
+ "Backwards compat: skipMatch=false does not set eligibleForAutoMerge even with autoMergeOnSubmission=true",
714
+ async () => {
715
+ const { form, fields } = await createAutoMergeForm(sdk, true)
716
+ const testPhone = `+1555555${Date.now().toString().slice(-4)}`
717
+
718
+ // Create public session WITHOUT skipMatch (normal flow - requires phone)
719
+ const enduserSDK = new EnduserSession({ host, businessId: form.businessId })
720
+ const { enduserId } = await enduserSDK.api.form_responses.session_for_public_form({
721
+ formId: form.id,
722
+ businessId: form.businessId,
723
+ phone: testPhone, // Phone is required when skipMatch is not set
724
+ // skipMatch is NOT set (defaults to false)
725
+ })
726
+
727
+ const enduser = await sdk.api.endusers.getOne(enduserId)
728
+
729
+ // Cleanup
730
+ await sdk.api.forms.deleteOne(form.id)
731
+ await sdk.api.endusers.deleteOne(enduserId)
732
+
733
+ // eligibleForAutoMerge should NOT be set when skipMatch is false
734
+ return enduser.eligibleForAutoMerge !== true
735
+ },
736
+ { expectedResult: true }
737
+ )
738
+
739
+ // Test 16: Backwards compat - Private form submission doesn't trigger auto-merge
740
+ await async_test(
741
+ "Backwards compat: Private form submission does not trigger auto-merge",
742
+ async () => {
743
+ const testEmail = `backcompat.private.${Date.now()}@test.com`
744
+ const destination = await sdk.api.endusers.createOne({
745
+ fname: 'Private',
746
+ lname: 'Test',
747
+ email: testEmail
748
+ })
749
+
750
+ // Create source enduser with eligibleForAutoMerge manually set
751
+ const source = await sdk.api.endusers.createOne({
752
+ fname: 'Private',
753
+ lname: 'Test',
754
+ email: testEmail + '.source',
755
+ eligibleForAutoMerge: true, // Manually set to test that private submission ignores it
756
+ })
757
+
758
+ const { form, fields } = await createAutoMergeForm(sdk, true)
759
+
760
+ // Create form response via admin SDK (private/non-public submission)
761
+ const fnameField = fields.find(f => f.intakeField === 'fname')!
762
+ const lnameField = fields.find(f => f.intakeField === 'lname')!
763
+ const emailField = fields.find(f => f.intakeField === 'email')!
764
+
765
+ await sdk.api.form_responses.createOne({
766
+ formId: form.id,
767
+ formTitle: form.title,
768
+ enduserId: source.id,
769
+ responses: [
770
+ { fieldId: fnameField.id, fieldTitle: fnameField.title, answer: { type: 'string', value: 'Private' } },
771
+ { fieldId: lnameField.id, fieldTitle: lnameField.title, answer: { type: 'string', value: 'Test' } },
772
+ { fieldId: emailField.id, fieldTitle: emailField.title, answer: { type: 'email', value: testEmail } },
773
+ ],
774
+ })
775
+
776
+ // Source should NOT be deleted because this was a private submission (not publicSubmit)
777
+ const sourceStillExists = !(await isEnduserDeleted(sdk, source.id))
778
+
779
+ // Cleanup
780
+ await sdk.api.forms.deleteOne(form.id)
781
+ await sdk.api.endusers.deleteOne(destination.id)
782
+ if (sourceStillExists) await sdk.api.endusers.deleteOne(source.id)
783
+
784
+ return sourceStillExists
785
+ },
786
+ { expectedResult: true }
787
+ )
788
+
789
+ // Test 17: OR logic - matches on email even when phone differs
790
+ await async_test(
791
+ "Auto-merge: Merge occurs when email matches even if phone differs (OR logic)",
792
+ async () => {
793
+ const testEmail = `automerge.orlogic.${Date.now()}@test.com`
794
+ const destination = await sdk.api.endusers.createOne({
795
+ fname: 'OrLogic',
796
+ lname: 'Test',
797
+ email: testEmail,
798
+ phone: '+15555550001', // Different phone
799
+ })
800
+ const { form, fields } = await createAutoMergeForm(sdk, true)
801
+
802
+ // Submit with matching email but DIFFERENT phone
803
+ const { enduserId: sourceId } = await submitPublicFormWithSkipMatch(form, fields, {
804
+ fname: 'OrLogic',
805
+ lname: 'Test',
806
+ email: testEmail,
807
+ phone: '+15555550002', // Different phone than destination
808
+ })
809
+
810
+ // Should merge because email matches (OR logic, not AND)
811
+ const sourceDeleted = await isEnduserDeleted(sdk, sourceId)
812
+ const updatedDestination = await sdk.api.endusers.getOne(destination.id)
813
+
814
+ // Cleanup
815
+ await sdk.api.forms.deleteOne(form.id)
816
+ await sdk.api.endusers.deleteOne(destination.id)
817
+
818
+ return sourceDeleted && updatedDestination.mergedIds?.includes(sourceId)
819
+ },
820
+ { expectedResult: true }
821
+ )
822
+
823
+ // Test 18: Partial name mismatch - fname matches but lname differs
824
+ await async_test(
825
+ "Auto-merge: No merge when fname matches but lname differs",
826
+ async () => {
827
+ const testEmail = `automerge.partial.${Date.now()}@test.com`
828
+ const destination = await sdk.api.endusers.createOne({
829
+ fname: 'Partial',
830
+ lname: 'Match',
831
+ email: testEmail
832
+ })
833
+ const { form, fields } = await createAutoMergeForm(sdk, true)
834
+
835
+ // Submit with same fname but DIFFERENT lname
836
+ const { enduserId: sourceId } = await submitPublicFormWithSkipMatch(form, fields, {
837
+ fname: 'Partial', // Same
838
+ lname: 'Different', // Different!
839
+ email: testEmail, // Same email
840
+ })
841
+
842
+ // Should NOT merge because lname differs
843
+ const sourceDeleted = await isEnduserDeleted(sdk, sourceId)
844
+
845
+ // Cleanup
846
+ await sdk.api.forms.deleteOne(form.id)
847
+ await sdk.api.endusers.deleteOne(destination.id)
848
+ if (!sourceDeleted) await sdk.api.endusers.deleteOne(sourceId)
849
+
850
+ return !sourceDeleted // Source should NOT be deleted
851
+ },
852
+ { expectedResult: true }
853
+ )
854
+ }
855
+
856
+ // Allow running this test file independently
857
+ if (require.main === module) {
858
+ console.log(`Using API URL: ${host}`)
859
+ const sdk = new Session({ host })
860
+ const sdkNonAdmin = new Session({ host })
861
+
862
+ const runTests = async () => {
863
+ await setup_tests(sdk, sdkNonAdmin)
864
+ await auto_merge_form_submission_tests({ sdk, sdkNonAdmin })
865
+ }
866
+
867
+ runTests()
868
+ .then(() => {
869
+ console.log("Auto-merge form submission test suite completed successfully")
870
+ process.exit(0)
871
+ })
872
+ .catch((error) => {
873
+ console.error("Auto-merge form submission test suite failed:", error)
874
+ process.exit(1)
875
+ })
876
+ }