@tellescope/sdk 1.246.2 → 1.248.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/lib/cjs/sdk.d.ts +7 -1
  2. package/lib/cjs/sdk.d.ts.map +1 -1
  3. package/lib/cjs/sdk.js +2 -0
  4. package/lib/cjs/sdk.js.map +1 -1
  5. package/lib/cjs/tests/api_tests/date_string_validation.test.d.ts +6 -0
  6. package/lib/cjs/tests/api_tests/date_string_validation.test.d.ts.map +1 -0
  7. package/lib/cjs/tests/api_tests/date_string_validation.test.js +142 -0
  8. package/lib/cjs/tests/api_tests/date_string_validation.test.js.map +1 -0
  9. package/lib/cjs/tests/api_tests/enduser_session_invalidation.test.d.ts +6 -0
  10. package/lib/cjs/tests/api_tests/enduser_session_invalidation.test.d.ts.map +1 -0
  11. package/lib/cjs/tests/api_tests/enduser_session_invalidation.test.js +243 -0
  12. package/lib/cjs/tests/api_tests/enduser_session_invalidation.test.js.map +1 -0
  13. package/lib/cjs/tests/api_tests/field_redaction.test.d.ts +13 -0
  14. package/lib/cjs/tests/api_tests/field_redaction.test.d.ts.map +1 -0
  15. package/lib/cjs/tests/api_tests/field_redaction.test.js +818 -0
  16. package/lib/cjs/tests/api_tests/field_redaction.test.js.map +1 -0
  17. package/lib/cjs/tests/api_tests/form_submitted_trigger.test.d.ts +6 -0
  18. package/lib/cjs/tests/api_tests/form_submitted_trigger.test.d.ts.map +1 -0
  19. package/lib/cjs/tests/api_tests/form_submitted_trigger.test.js +429 -0
  20. package/lib/cjs/tests/api_tests/form_submitted_trigger.test.js.map +1 -0
  21. package/lib/cjs/tests/api_tests/integrations_redacted.test.d.ts +6 -0
  22. package/lib/cjs/tests/api_tests/integrations_redacted.test.d.ts.map +1 -0
  23. package/lib/cjs/tests/api_tests/integrations_redacted.test.js +273 -0
  24. package/lib/cjs/tests/api_tests/integrations_redacted.test.js.map +1 -0
  25. package/lib/cjs/tests/api_tests/mdb_sort.test.d.ts +6 -0
  26. package/lib/cjs/tests/api_tests/mdb_sort.test.d.ts.map +1 -0
  27. package/lib/cjs/tests/api_tests/mdb_sort.test.js +370 -0
  28. package/lib/cjs/tests/api_tests/mdb_sort.test.js.map +1 -0
  29. package/lib/cjs/tests/api_tests/openloop_webhooks.test.d.ts.map +1 -1
  30. package/lib/cjs/tests/api_tests/openloop_webhooks.test.js +108 -24
  31. package/lib/cjs/tests/api_tests/openloop_webhooks.test.js.map +1 -1
  32. package/lib/cjs/tests/tests.d.ts.map +1 -1
  33. package/lib/cjs/tests/tests.js +303 -180
  34. package/lib/cjs/tests/tests.js.map +1 -1
  35. package/lib/esm/sdk.d.ts +7 -1
  36. package/lib/esm/sdk.d.ts.map +1 -1
  37. package/lib/esm/sdk.js +2 -0
  38. package/lib/esm/sdk.js.map +1 -1
  39. package/lib/esm/tests/api_tests/date_string_validation.test.d.ts +6 -0
  40. package/lib/esm/tests/api_tests/date_string_validation.test.d.ts.map +1 -0
  41. package/lib/esm/tests/api_tests/date_string_validation.test.js +138 -0
  42. package/lib/esm/tests/api_tests/date_string_validation.test.js.map +1 -0
  43. package/lib/esm/tests/api_tests/enduser_session_invalidation.test.d.ts +6 -0
  44. package/lib/esm/tests/api_tests/enduser_session_invalidation.test.d.ts.map +1 -0
  45. package/lib/esm/tests/api_tests/enduser_session_invalidation.test.js +239 -0
  46. package/lib/esm/tests/api_tests/enduser_session_invalidation.test.js.map +1 -0
  47. package/lib/esm/tests/api_tests/field_redaction.test.d.ts +13 -0
  48. package/lib/esm/tests/api_tests/field_redaction.test.d.ts.map +1 -0
  49. package/lib/esm/tests/api_tests/field_redaction.test.js +814 -0
  50. package/lib/esm/tests/api_tests/field_redaction.test.js.map +1 -0
  51. package/lib/esm/tests/api_tests/form_submitted_trigger.test.d.ts +6 -0
  52. package/lib/esm/tests/api_tests/form_submitted_trigger.test.d.ts.map +1 -0
  53. package/lib/esm/tests/api_tests/form_submitted_trigger.test.js +425 -0
  54. package/lib/esm/tests/api_tests/form_submitted_trigger.test.js.map +1 -0
  55. package/lib/esm/tests/api_tests/integrations_redacted.test.d.ts +6 -0
  56. package/lib/esm/tests/api_tests/integrations_redacted.test.d.ts.map +1 -0
  57. package/lib/esm/tests/api_tests/integrations_redacted.test.js +269 -0
  58. package/lib/esm/tests/api_tests/integrations_redacted.test.js.map +1 -0
  59. package/lib/esm/tests/api_tests/mdb_sort.test.d.ts +6 -0
  60. package/lib/esm/tests/api_tests/mdb_sort.test.d.ts.map +1 -0
  61. package/lib/esm/tests/api_tests/mdb_sort.test.js +366 -0
  62. package/lib/esm/tests/api_tests/mdb_sort.test.js.map +1 -0
  63. package/lib/esm/tests/api_tests/openloop_webhooks.test.d.ts.map +1 -1
  64. package/lib/esm/tests/api_tests/openloop_webhooks.test.js +108 -24
  65. package/lib/esm/tests/api_tests/openloop_webhooks.test.js.map +1 -1
  66. package/lib/esm/tests/tests.d.ts.map +1 -1
  67. package/lib/esm/tests/tests.js +303 -180
  68. package/lib/esm/tests/tests.js.map +1 -1
  69. package/lib/tsconfig.tsbuildinfo +1 -1
  70. package/package.json +10 -10
  71. package/src/sdk.ts +11 -0
  72. package/src/tests/api_tests/calendar_events_bulk_update.test.ts +418 -0
  73. package/src/tests/api_tests/date_string_validation.test.ts +107 -0
  74. package/src/tests/api_tests/enduser_session_invalidation.test.ts +138 -0
  75. package/src/tests/api_tests/field_redaction.test.ts +669 -0
  76. package/src/tests/api_tests/form_started_trigger.test.ts +1 -1
  77. package/src/tests/api_tests/form_submitted_trigger.test.ts +281 -0
  78. package/src/tests/api_tests/integrations_redacted.test.ts +245 -0
  79. package/src/tests/api_tests/mdb_sort.test.ts +259 -0
  80. package/src/tests/api_tests/openloop_webhooks.test.ts +64 -0
  81. package/src/tests/api_tests/organization_settings_duplicates.test.ts +201 -0
  82. package/src/tests/tests.ts +92 -6
  83. package/test_generated.pdf +0 -0
@@ -0,0 +1,259 @@
1
+ require('source-map-support').install();
2
+
3
+ import { Session } from "../../sdk"
4
+ import {
5
+ assert,
6
+ async_test,
7
+ log_header,
8
+ } from "@tellescope/testing"
9
+ import { setup_tests } from "../setup"
10
+
11
+ const host = process.env.API_URL || 'http://localhost:8080' as const
12
+
13
+ // Main test function that can be called independently
14
+ export const mdb_sort_tests = async ({ sdk, sdkNonAdmin } : { sdk: Session, sdkNonAdmin: Session }) => {
15
+ log_header("mdbSort Custom Sorting Support")
16
+
17
+ // Create test endusers with known field values for sorting
18
+ const testEndusers = await Promise.all([
19
+ sdk.api.endusers.createOne({ fname: 'Alice', lname: 'Smith', email: 'alice-mdbsort@tellescope.com' }),
20
+ sdk.api.endusers.createOne({ fname: 'Bob', lname: 'Jones', email: 'bob-mdbsort@tellescope.com' }),
21
+ sdk.api.endusers.createOne({ fname: 'Charlie', lname: 'Adams', email: 'charlie-mdbsort@tellescope.com' }),
22
+ sdk.api.endusers.createOne({ fname: 'Alice', lname: 'Zeta', email: 'alice2-mdbsort@tellescope.com' }), // Same fname for multi-field test
23
+ ])
24
+
25
+ const enduserIds = testEndusers.map(e => e.id)
26
+
27
+ try {
28
+ // Test 1: Sort by fname ascending (alphabetical order)
29
+ await async_test(
30
+ 'mdbSort-fname-ascending',
31
+ async () => {
32
+ const results = await sdk.api.endusers.getSome({
33
+ filter: { id: { _in: enduserIds } },
34
+ mdbSort: { fname: 1 },
35
+ })
36
+ assert(results.length === 4, 'Expected 4 endusers', `Got ${results.length}`)
37
+
38
+ // Verify alphabetical order: Alice, Alice, Bob, Charlie
39
+ assert(results[0].fname === 'Alice', 'First should be Alice')
40
+ assert(results[1].fname === 'Alice', 'Second should be Alice')
41
+ assert(results[2].fname === 'Bob', 'Third should be Bob')
42
+ assert(results[3].fname === 'Charlie', 'Fourth should be Charlie')
43
+
44
+ return results
45
+ },
46
+ { onResult: () => true },
47
+ )
48
+
49
+ // Test 2: Sort by fname descending (reverse alphabetical order)
50
+ await async_test(
51
+ 'mdbSort-fname-descending',
52
+ async () => {
53
+ const results = await sdk.api.endusers.getSome({
54
+ filter: { id: { _in: enduserIds } },
55
+ mdbSort: { fname: -1 },
56
+ })
57
+ assert(results.length === 4, 'Expected 4 endusers')
58
+
59
+ // Verify reverse alphabetical order: Charlie, Bob, Alice, Alice
60
+ assert(results[0].fname === 'Charlie', 'First should be Charlie')
61
+ assert(results[1].fname === 'Bob', 'Second should be Bob')
62
+ assert(results[2].fname === 'Alice', 'Third should be Alice')
63
+ assert(results[3].fname === 'Alice', 'Fourth should be Alice')
64
+
65
+ return results
66
+ },
67
+ { onResult: () => true },
68
+ )
69
+
70
+ // Test 3: Multi-field sort (fname ascending, then lname ascending for ties)
71
+ await async_test(
72
+ 'mdbSort-multi-field',
73
+ async () => {
74
+ const results = await sdk.api.endusers.getSome({
75
+ filter: { id: { _in: enduserIds } },
76
+ mdbSort: { fname: 1, lname: 1 },
77
+ })
78
+ assert(results.length === 4, 'Expected 4 endusers')
79
+
80
+ // Verify multi-field sort:
81
+ // Alice Smith (fname: Alice, lname: Smith)
82
+ // Alice Zeta (fname: Alice, lname: Zeta)
83
+ // Bob Jones (fname: Bob)
84
+ // Charlie Adams (fname: Charlie)
85
+ assert(results[0].fname === 'Alice' && results[0].lname === 'Smith', 'First should be Alice Smith')
86
+ assert(results[1].fname === 'Alice' && results[1].lname === 'Zeta', 'Second should be Alice Zeta')
87
+ assert(results[2].fname === 'Bob', 'Third should be Bob')
88
+ assert(results[3].fname === 'Charlie', 'Fourth should be Charlie')
89
+
90
+ return results
91
+ },
92
+ { onResult: () => true },
93
+ )
94
+
95
+ // Test 4: mdbSort combined with mdbFilter
96
+ await async_test(
97
+ 'mdbSort-with-mdbFilter',
98
+ async () => {
99
+ const results = await sdk.api.endusers.getSome({
100
+ mdbFilter: {
101
+ email: { $in: ['alice-mdbsort@tellescope.com', 'alice2-mdbsort@tellescope.com'] },
102
+ fname: 'Alice', // Only get Alice endusers
103
+ },
104
+ mdbSort: { lname: 1 }, // Sort by last name
105
+ })
106
+ assert(results.length === 2, 'Expected 2 Alice endusers', `Got ${results.length}`)
107
+
108
+ // Verify both are Alice and sorted by lname: Smith, then Zeta
109
+ assert(results[0].fname === 'Alice' && results[0].lname === 'Smith', 'First Alice should be Smith')
110
+ assert(results[1].fname === 'Alice' && results[1].lname === 'Zeta', 'Second Alice should be Zeta')
111
+
112
+ return results
113
+ },
114
+ { onResult: () => true },
115
+ )
116
+
117
+ // Test 5: mdbSort keyset pagination via mdbFilter $or
118
+ // Note: filter is ignored when mdbFilter is present, so both id scoping and keyset cursor
119
+ // must be expressed in mdbFilter.
120
+ await async_test(
121
+ 'mdbSort-keyset-pagination',
122
+ async () => {
123
+ const testEmails = [
124
+ 'alice-mdbsort@tellescope.com',
125
+ 'alice2-mdbsort@tellescope.com',
126
+ 'bob-mdbsort@tellescope.com',
127
+ 'charlie-mdbsort@tellescope.com',
128
+ ]
129
+
130
+ // Page 1: first 2 results sorted by fname ascending → both Alices
131
+ const page1 = await sdk.api.endusers.getSome({
132
+ mdbFilter: { email: { $in: testEmails } },
133
+ mdbSort: { fname: 1 },
134
+ limit: 2,
135
+ })
136
+ assert(page1.length === 2, 'Expected 2 endusers on page 1')
137
+ assert(page1[0].fname === 'Alice', 'Page 1 first should be Alice')
138
+ assert(page1[1].fname === 'Alice', 'Page 1 second should be Alice')
139
+
140
+ // Page 2: keyset cursor — fname > last seen fname ('Alice')
141
+ const lastFname = page1[page1.length - 1].fname
142
+ const page2 = await sdk.api.endusers.getSome({
143
+ mdbFilter: {
144
+ email: { $in: testEmails },
145
+ fname: { $gt: lastFname },
146
+ },
147
+ mdbSort: { fname: 1 },
148
+ limit: 2,
149
+ })
150
+ assert(page2.length === 2, 'Expected 2 endusers on page 2')
151
+ assert(page2[0].fname === 'Bob', 'Page 2 first should be Bob')
152
+ assert(page2[1].fname === 'Charlie', 'Page 2 second should be Charlie')
153
+
154
+ return [...page1, ...page2]
155
+ },
156
+ { onResult: () => true },
157
+ )
158
+
159
+ // Test 6: mdbSort with projection (ensure both work together)
160
+ await async_test(
161
+ 'mdbSort-with-projection',
162
+ async () => {
163
+ const results = await sdk.api.endusers.getSome({
164
+ filter: { id: { _in: enduserIds } },
165
+ mdbSort: { fname: -1 },
166
+ projection: { fname: 1, lname: 1 },
167
+ })
168
+ assert(results.length === 4, 'Expected 4 endusers')
169
+
170
+ // Verify sort order (descending)
171
+ assert(results[0].fname === 'Charlie', 'First should be Charlie')
172
+
173
+ // Verify projection (only fname, lname, plus id and createdAt)
174
+ assert(results[0].fname !== undefined, 'fname should be present')
175
+ assert(results[0].lname !== undefined, 'lname should be present')
176
+ assert((results[0] as any).email === undefined, 'email should NOT be present')
177
+
178
+ return results
179
+ },
180
+ { onResult: () => true },
181
+ )
182
+
183
+ // Test 7: Non-admin access with mdbSort (RBA still applies)
184
+ await async_test(
185
+ 'non-admin-mdbSort',
186
+ async () => {
187
+ const results = await sdkNonAdmin.api.endusers.getSome({
188
+ filter: { id: { _in: enduserIds } },
189
+ mdbSort: { fname: 1 },
190
+ })
191
+
192
+ // Non-admin should still get results (RBA should apply)
193
+ assert(Array.isArray(results), 'Non-admin should receive an array response')
194
+
195
+ // Verify sort order
196
+ if (results.length >= 2) {
197
+ const fnames = results.map(e => e.fname)
198
+ // Should be sorted alphabetically
199
+ assert(fnames[0]! <= fnames[1]!, 'Non-admin results should be sorted')
200
+ }
201
+
202
+ return results
203
+ },
204
+ { onResult: () => true },
205
+ )
206
+
207
+ // Test 8: mdbSort fallback behavior (no mdbSort uses sortBy default)
208
+ await async_test(
209
+ 'no-mdbSort-fallback',
210
+ async () => {
211
+ // Without mdbSort, should fall back to default sorting by _id
212
+ const results = await sdk.api.endusers.getSome({
213
+ filter: { id: { _in: enduserIds } },
214
+ sortBy: 'updatedAt',
215
+ sort: 'oldFirst',
216
+ })
217
+ assert(results.length === 4, 'Expected 4 endusers')
218
+
219
+ // Verify traditional sortBy still works when mdbSort not provided
220
+ assert(results[0].id !== undefined, 'Should return valid endusers')
221
+
222
+ return results
223
+ },
224
+ { onResult: () => true },
225
+ )
226
+
227
+ } finally {
228
+ // Cleanup: Delete test resources
229
+ try {
230
+ for (const enduserId of enduserIds) {
231
+ await sdk.api.endusers.deleteOne(enduserId)
232
+ }
233
+ } catch (error) {
234
+ console.error('Cleanup error:', error)
235
+ }
236
+ }
237
+ }
238
+
239
+ // Allow running this test file independently
240
+ if (require.main === module) {
241
+ console.log(`🌐 Using API URL: ${host}`)
242
+ const sdk = new Session({ host })
243
+ const sdkNonAdmin = new Session({ host })
244
+
245
+ const runTests = async () => {
246
+ await setup_tests(sdk, sdkNonAdmin)
247
+ await mdb_sort_tests({ sdk, sdkNonAdmin })
248
+ }
249
+
250
+ runTests()
251
+ .then(() => {
252
+ console.log("✅ mdbSort test suite completed successfully")
253
+ process.exit(0)
254
+ })
255
+ .catch((error) => {
256
+ console.error("❌ mdbSort test suite failed:", error)
257
+ process.exit(1)
258
+ })
259
+ }
@@ -144,6 +144,27 @@ export const openloop_webhooks_tests = async ({ sdk, sdkNonAdmin }: { sdk: Sessi
144
144
  { onResult: (r: boolean) => r === true }
145
145
  )
146
146
 
147
+ await async_test(
148
+ 'V1: order_confirmation maps program_code to protocol field',
149
+ async () => {
150
+ const orderNum = `ol-conf-protocol-${uid()}`
151
+ const res = await postV1(makeV1Confirmation({
152
+ patientID: healthieId1,
153
+ orderNumber: orderNum,
154
+ program_code: 'Weight-Loss',
155
+ }))
156
+ assert(res.status === 200, `Expected 200, got ${res.status}`)
157
+
158
+ const orders = await sdk.api.enduser_orders.getSome({
159
+ filter: { source: 'OpenLoop', externalId: orderNum }
160
+ })
161
+ assert(orders.length === 1, `Expected 1 order, got ${orders.length}`)
162
+ assert(orders[0].protocol === 'Weight-Loss', `protocol mismatch: ${orders[0].protocol}`)
163
+ return true
164
+ },
165
+ { onResult: (r: boolean) => r === true }
166
+ )
167
+
147
168
  await async_test(
148
169
  'V1: order_confirmation idempotency - same order not duplicated',
149
170
  async () => {
@@ -314,6 +335,28 @@ export const openloop_webhooks_tests = async ({ sdk, sdkNonAdmin }: { sdk: Sessi
314
335
  { onResult: (r: boolean) => r === true }
315
336
  )
316
337
 
338
+ await async_test(
339
+ 'V1: order_shipped maps program_code to protocol field',
340
+ async () => {
341
+ const orderNum = `ol-ship-protocol-${uid()}`
342
+ await postV1(makeV1Confirmation({ patientID: healthieId1, orderNumber: orderNum }))
343
+ const res = await postV1(makeV1Shipped({
344
+ patientID: healthieId1,
345
+ orderNumber: orderNum,
346
+ program_code: 'Diabetes',
347
+ }))
348
+ assert(res.status === 200, `Expected 200, got ${res.status}`)
349
+
350
+ const orders = await sdk.api.enduser_orders.getSome({
351
+ filter: { source: 'OpenLoop', externalId: orderNum }
352
+ })
353
+ assert(orders.length === 1, `Expected 1 order, got ${orders.length}`)
354
+ assert(orders[0].protocol === 'Diabetes', `protocol mismatch: ${orders[0].protocol}`)
355
+ return true
356
+ },
357
+ { onResult: (r: boolean) => r === true }
358
+ )
359
+
317
360
  // ===== SECTION D: V1 enduserId Isolation =====
318
361
  log_header("V1 enduserId Isolation")
319
362
 
@@ -414,6 +457,27 @@ export const openloop_webhooks_tests = async ({ sdk, sdkNonAdmin }: { sdk: Sessi
414
457
  { onResult: (r: boolean) => r === true }
415
458
  )
416
459
 
460
+ await async_test(
461
+ 'V2: prescription-created maps program_code to protocol field',
462
+ async () => {
463
+ const orderNum = `v2-protocol-${uid()}`
464
+ const res = await postV2(makeV2Payload('prescription-created', {
465
+ id: orderNum,
466
+ patientId: healthieId1,
467
+ program_code: 'GLP-1',
468
+ }))
469
+ assert(res.status === 200, `Expected 200, got ${res.status}`)
470
+
471
+ const orders = await sdk.api.enduser_orders.getSome({
472
+ filter: { source: 'OpenLoop', externalId: orderNum }
473
+ })
474
+ assert(orders.length === 1, `Expected 1 order, got ${orders.length}`)
475
+ assert(orders[0].protocol === 'GLP-1', `protocol mismatch: ${orders[0].protocol}`)
476
+ return true
477
+ },
478
+ { onResult: (r: boolean) => r === true }
479
+ )
480
+
417
481
  await async_test(
418
482
  'V2: prescription-shipped updates with carrier and tracking',
419
483
  async () => {
@@ -0,0 +1,201 @@
1
+ require('source-map-support').install();
2
+
3
+ import { Session } from "../../sdk"
4
+ import {
5
+ async_test,
6
+ log_header,
7
+ } from "@tellescope/testing"
8
+ import { setup_tests } from "../setup"
9
+
10
+ const host = process.env.API_URL || 'http://localhost:8080' as const
11
+
12
+ export const organization_settings_duplicates_tests = async ({ sdk, sdkNonAdmin } : { sdk: Session, sdkNonAdmin: Session }) => {
13
+ log_header("Organization Settings Duplicate Validation Tests")
14
+
15
+ const orgId = sdk.userInfo.businessId
16
+
17
+ // === A. replaceObjectFields: false (merge/push behavior) ===
18
+
19
+ // A1. Duplicate tags via merge
20
+ await sdk.api.organizations.updateOne(orgId, {
21
+ settings: { endusers: { tags: ['tag1', 'tag2'] } }
22
+ }, { replaceObjectFields: true })
23
+
24
+ await async_test(
25
+ "Merge tags rejects duplicates (tag2 appears in both old and new)",
26
+ () => sdk.api.organizations.updateOne(orgId, {
27
+ settings: { endusers: { tags: ['tag2', 'tag3'] } }
28
+ }),
29
+ { shouldError: true, onError: (e: { message: string }) => e.message.includes('Duplicate value in settings.endusers.tags') }
30
+ )
31
+
32
+ // A2. Duplicate customFields via merge
33
+ await sdk.api.organizations.updateOne(orgId, {
34
+ settings: { endusers: { customFields: [{ type: 'Text' as const, field: 'myField', info: {} }] } }
35
+ }, { replaceObjectFields: true })
36
+
37
+ await async_test(
38
+ "Merge customFields rejects duplicate field name",
39
+ () => sdk.api.organizations.updateOne(orgId, {
40
+ settings: { endusers: { customFields: [{ type: 'Text' as const, field: 'myField', info: {} }] } }
41
+ }),
42
+ { shouldError: true, onError: (e: { message: string }) => e.message.includes('Duplicate field in settings.endusers.customFields') }
43
+ )
44
+
45
+ // A3. Duplicate builtinFields via merge
46
+ await sdk.api.organizations.updateOne(orgId, {
47
+ settings: { endusers: { builtinFields: [{ field: 'fname', label: 'First Name' }] } }
48
+ }, { replaceObjectFields: true })
49
+
50
+ await async_test(
51
+ "Merge builtinFields rejects duplicate field name",
52
+ () => sdk.api.organizations.updateOne(orgId, {
53
+ settings: { endusers: { builtinFields: [{ field: 'fname', label: 'First Name Copy' }] } }
54
+ }),
55
+ { shouldError: true, onError: (e: { message: string }) => e.message.includes('Duplicate field in settings.endusers.builtinFields') }
56
+ )
57
+
58
+ // A4. Duplicate dontRecordCallsToPhone via merge
59
+ await sdk.api.organizations.updateOne(orgId, {
60
+ settings: { endusers: { dontRecordCallsToPhone: ['+15551234567'] } }
61
+ }, { replaceObjectFields: true })
62
+
63
+ await async_test(
64
+ "Merge dontRecordCallsToPhone rejects duplicates",
65
+ () => sdk.api.organizations.updateOne(orgId, {
66
+ settings: { endusers: { dontRecordCallsToPhone: ['+15551234567'] } }
67
+ }),
68
+ { shouldError: true, onError: (e: { message: string }) => e.message.includes('Duplicate value in settings.endusers.dontRecordCallsToPhone') }
69
+ )
70
+
71
+ // A5. Duplicate cancelReasons via merge
72
+ await sdk.api.organizations.updateOne(orgId, {
73
+ settings: { calendar: { cancelReasons: ['No show'] } }
74
+ }, { replaceObjectFields: true })
75
+
76
+ await async_test(
77
+ "Merge cancelReasons rejects duplicates",
78
+ () => sdk.api.organizations.updateOne(orgId, {
79
+ settings: { calendar: { cancelReasons: ['No show'] } }
80
+ }),
81
+ { shouldError: true, onError: (e: { message: string }) => e.message.includes('Duplicate value in settings.calendar.cancelReasons') }
82
+ )
83
+
84
+ // === B. replaceObjectFields: true (full replacement) ===
85
+
86
+ // B1. Replace that grows the array with dupes should be rejected
87
+ await sdk.api.organizations.updateOne(orgId, {
88
+ settings: { endusers: { tags: ['tag1'] } }
89
+ }, { replaceObjectFields: true })
90
+
91
+ await async_test(
92
+ "Replace tags rejects duplicates when array grows",
93
+ () => sdk.api.organizations.updateOne(orgId, {
94
+ settings: { endusers: { tags: ['tag1', 'tag1', 'tag2'] } }
95
+ }, { replaceObjectFields: true }),
96
+ { shouldError: true, onError: (e: { message: string }) => e.message.includes('Duplicate value in settings.endusers.tags') }
97
+ )
98
+
99
+ // B2. Replace with dupes that shrinks the array should be allowed
100
+ await sdk.api.organizations.updateOne(orgId, {
101
+ settings: { endusers: { tags: ['a', 'b', 'c'] } }
102
+ }, { replaceObjectFields: true })
103
+
104
+ await async_test(
105
+ "Replace with dupes that shrinks array succeeds",
106
+ () => sdk.api.organizations.updateOne(orgId, {
107
+ settings: { endusers: { tags: ['a', 'a'] } }
108
+ }, { replaceObjectFields: true }),
109
+ { shouldError: false, onResult: () => true }
110
+ )
111
+
112
+ // B3. Replace with unique values always succeeds
113
+ await async_test(
114
+ "Replace tags succeeds with unique values",
115
+ () => sdk.api.organizations.updateOne(orgId, {
116
+ settings: { endusers: { tags: ['tag1', 'tag2'] } }
117
+ }, { replaceObjectFields: true }),
118
+ { shouldError: false, onResult: () => true }
119
+ )
120
+
121
+ // === C. Non-duplicate updates still succeed ===
122
+
123
+ // C1. Set initial tags then add different tags via merge
124
+ await sdk.api.organizations.updateOne(orgId, {
125
+ settings: { endusers: { tags: ['tagA', 'tagB'] } }
126
+ }, { replaceObjectFields: true })
127
+
128
+ await async_test(
129
+ "Merge with unique new tags succeeds",
130
+ () => sdk.api.organizations.updateOne(orgId, {
131
+ settings: { endusers: { tags: ['tagC', 'tagD'] } }
132
+ }),
133
+ { shouldError: false, onResult: () => true }
134
+ )
135
+
136
+ // C2. Replace with unique values
137
+ await async_test(
138
+ "Replace customFields with unique values succeeds",
139
+ () => sdk.api.organizations.updateOne(orgId, {
140
+ settings: { endusers: { customFields: [
141
+ { type: 'Text' as const, field: 'field1', info: {} },
142
+ { type: 'Text' as const, field: 'field2', info: {} },
143
+ ] } }
144
+ }, { replaceObjectFields: true }),
145
+ { shouldError: false, onResult: () => true }
146
+ )
147
+
148
+ // C3. Updating another settings field preserves pre-existing duplicates
149
+ // First set tags to a longer array, then shrink to a dupe array (shrinking is allowed)
150
+ await sdk.api.organizations.updateOne(orgId, {
151
+ settings: { endusers: { tags: ['dupeTag', 'otherTag', 'anotherTag'] } }
152
+ }, { replaceObjectFields: true })
153
+ await sdk.api.organizations.updateOne(orgId, {
154
+ settings: { endusers: { tags: ['dupeTag', 'dupeTag'] } }
155
+ }, { replaceObjectFields: true })
156
+
157
+ await async_test(
158
+ "Updating cancelReasons succeeds even when tags has pre-existing duplicates",
159
+ () => sdk.api.organizations.updateOne(orgId, {
160
+ settings: { calendar: { cancelReasons: ['new reason'] } }
161
+ }, { replaceObjectFields: true }),
162
+ { shouldError: false, onResult: () => true }
163
+ )
164
+
165
+ // Clean up settings to avoid affecting other tests
166
+ await sdk.api.organizations.updateOne(orgId, {
167
+ settings: {
168
+ endusers: {
169
+ tags: [],
170
+ customFields: [],
171
+ builtinFields: [],
172
+ dontRecordCallsToPhone: [],
173
+ },
174
+ calendar: {
175
+ cancelReasons: [],
176
+ },
177
+ }
178
+ }, { replaceObjectFields: true })
179
+ }
180
+
181
+ // Allow running this test file independently
182
+ if (require.main === module) {
183
+ console.log(`Using API URL: ${host}`)
184
+ const sdk = new Session({ host })
185
+ const sdkNonAdmin = new Session({ host })
186
+
187
+ const runTests = async () => {
188
+ await setup_tests(sdk, sdkNonAdmin)
189
+ await organization_settings_duplicates_tests({ sdk, sdkNonAdmin })
190
+ }
191
+
192
+ runTests()
193
+ .then(() => {
194
+ console.log("✅ Organization settings duplicate validation tests completed successfully")
195
+ process.exit(0)
196
+ })
197
+ .catch((error) => {
198
+ console.error("❌ Organization settings duplicate validation tests failed:", error)
199
+ process.exit(1)
200
+ })
201
+ }