@tellescope/sdk 2.0.0-beta.0 → 2.0.0-beta.1
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/lib/cjs/sdk.d.ts +3 -0
- package/lib/cjs/sdk.d.ts.map +1 -1
- package/lib/cjs/sdk.js +1 -0
- package/lib/cjs/sdk.js.map +1 -1
- package/lib/cjs/tests/api_tests/mdi_case_offerings.test.d.ts.map +1 -1
- package/lib/cjs/tests/api_tests/mdi_case_offerings.test.js +139 -9
- package/lib/cjs/tests/api_tests/mdi_case_offerings.test.js.map +1 -1
- package/lib/cjs/tests/api_tests/security/add-to-journey-foreign-enduserid.test.d.ts.map +1 -1
- package/lib/cjs/tests/api_tests/security/add-to-journey-foreign-enduserid.test.js +55 -47
- package/lib/cjs/tests/api_tests/security/add-to-journey-foreign-enduserid.test.js.map +1 -1
- package/lib/cjs/tests/tests.d.ts.map +1 -1
- package/lib/cjs/tests/tests.js +231 -217
- package/lib/cjs/tests/tests.js.map +1 -1
- package/lib/cjs/tsconfig.cjs.tsbuildinfo +1 -1
- package/lib/esm/sdk.d.ts +3 -0
- package/lib/esm/sdk.d.ts.map +1 -1
- package/lib/esm/sdk.js +1 -0
- package/lib/esm/sdk.js.map +1 -1
- package/lib/esm/tests/api_tests/mdi_case_offerings.test.d.ts.map +1 -1
- package/lib/esm/tests/api_tests/mdi_case_offerings.test.js +139 -9
- package/lib/esm/tests/api_tests/mdi_case_offerings.test.js.map +1 -1
- package/lib/esm/tests/api_tests/security/add-to-journey-foreign-enduserid.test.d.ts.map +1 -1
- package/lib/esm/tests/api_tests/security/add-to-journey-foreign-enduserid.test.js +55 -47
- package/lib/esm/tests/api_tests/security/add-to-journey-foreign-enduserid.test.js.map +1 -1
- package/lib/esm/tests/tests.d.ts.map +1 -1
- package/lib/esm/tests/tests.js +231 -217
- package/lib/esm/tests/tests.js.map +1 -1
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/package.json +10 -10
- package/src/sdk.ts +6 -0
- package/src/tests/api_tests/enduser_fields_sync.test.ts +150 -0
- package/src/tests/api_tests/load_threads_care_team.test.ts +210 -0
- package/src/tests/api_tests/mdi_case_offerings.test.ts +138 -0
- package/src/tests/api_tests/security/add-to-journey-foreign-enduserid.test.ts +33 -26
- package/src/tests/api_tests/security/appointment-reschedule-ownership.test.ts +432 -0
- package/src/tests/api_tests/trigger_contains_matching.test.ts +345 -0
- package/src/tests/api_tests/user_logs_restore_deleted.test.ts +415 -0
- package/src/tests/tests.ts +12 -0
- package/src/tests/unit_tests/trigger_title_partials.test.ts +174 -0
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
require('source-map-support').install();
|
|
2
|
+
|
|
3
|
+
import { AUTOMATED_ACTION_CANCEL_REASONS } from "@tellescope/constants"
|
|
4
|
+
import {
|
|
5
|
+
assert_checks,
|
|
6
|
+
Check,
|
|
7
|
+
setup_isolated_tenant,
|
|
8
|
+
} from "../setup_isolated"
|
|
9
|
+
|
|
10
|
+
const host = process.env.API_URL || 'http://localhost:8080' as const
|
|
11
|
+
|
|
12
|
+
const RAND = () => Math.random().toString(36).slice(2, 10)
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Coverage for POST /v1/user-logs/restore-deleted — re-creating a deleted contact, and the records
|
|
16
|
+
* cascade-deleted with it, by replaying user_logs rows.
|
|
17
|
+
*
|
|
18
|
+
* The check that most justifies the handler's shape is B. handleDeletionForDependencies runs BEFORE
|
|
19
|
+
* the parent `delete` row is inserted, so cascade rows have SMALLER _ids than the parent row. If the
|
|
20
|
+
* `_id` range is ever written the intuitive way round (forwards from the parent), the contact still
|
|
21
|
+
* comes back and every "contact restored" check still passes — only the dependent counts go to zero.
|
|
22
|
+
* B is what fails in that case.
|
|
23
|
+
*
|
|
24
|
+
* H is the other half of that: the window is scoped by enduserId, which the delete path stamps on
|
|
25
|
+
* every cascade row for every model. Deleting contact B's ticket inside the same five-minute window
|
|
26
|
+
* must not bring it back when contact A is restored.
|
|
27
|
+
*
|
|
28
|
+
* The suite MUST poll for audit rows: the delete endpoint responds 204 *before* writing them, so
|
|
29
|
+
* reading immediately after a delete is a race.
|
|
30
|
+
*
|
|
31
|
+
* Restores are deliberately not idempotent in the "refuse if present" sense — they RESUME. D and E
|
|
32
|
+
* pin that: a second press duplicates nothing, and a dependent deleted since comes back.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
const poll_for = async <T,>(get: () => Promise<T>, done: (v: T) => boolean, attempts = 40): Promise<T> => {
|
|
36
|
+
let value = await get()
|
|
37
|
+
for (let i = 0; i < attempts && !done(value); i++) {
|
|
38
|
+
await new Promise(resolve => setTimeout(resolve, 250))
|
|
39
|
+
value = await get()
|
|
40
|
+
}
|
|
41
|
+
return value
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The delete endpoint returns before writing its audit rows, so every read of one polls. */
|
|
45
|
+
const poll_for_delete_log = async (sdk: any, resource: string, resourceId: string) => {
|
|
46
|
+
const log = await poll_for(
|
|
47
|
+
async () => (
|
|
48
|
+
await sdk.api.user_logs.getSome({ filter: { resourceId } })
|
|
49
|
+
).find((l: any) => l.action === 'delete' && l.resource === resource),
|
|
50
|
+
l => !!l,
|
|
51
|
+
)
|
|
52
|
+
if (!log) throw new Error(`no delete user_log appeared for ${resource} ${resourceId}`)
|
|
53
|
+
return log
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const sum = (counts: Record<string, number> = {}) => Object.values(counts).reduce((a, b) => a + b, 0)
|
|
57
|
+
|
|
58
|
+
const rejection_of = async (run: () => Promise<any>) => {
|
|
59
|
+
try {
|
|
60
|
+
await run()
|
|
61
|
+
return undefined
|
|
62
|
+
} catch (err: any) {
|
|
63
|
+
return err
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export const user_logs_restore_deleted_checks = async (): Promise<Check[]> => {
|
|
68
|
+
const checks: Check[] = []
|
|
69
|
+
const record = (label: string, pass: boolean, detail: string) => checks.push({ label, pass, detail })
|
|
70
|
+
|
|
71
|
+
let tenant: Awaited<ReturnType<typeof setup_isolated_tenant>> | undefined
|
|
72
|
+
let otherTenant: Awaited<ReturnType<typeof setup_isolated_tenant>> | undefined
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
;[tenant, otherTenant] = await Promise.all([
|
|
76
|
+
setup_isolated_tenant({
|
|
77
|
+
name: `RestoreDeleted ${RAND()}`,
|
|
78
|
+
// A non-admin is provisioned deliberately: without one, sdkNonAdmin is an unauthenticated
|
|
79
|
+
// stub and the permission check (G) would pass for the wrong reason.
|
|
80
|
+
users: [{ isAdmin: true }, { roles: ['Non-Admin'] }],
|
|
81
|
+
apiKeys: 0,
|
|
82
|
+
}),
|
|
83
|
+
setup_isolated_tenant({ name: `RestoreDeletedOther ${RAND()}`, users: [{ isAdmin: true }], apiKeys: 0 }),
|
|
84
|
+
])
|
|
85
|
+
|
|
86
|
+
const { sdk, sdkNonAdmin } = tenant
|
|
87
|
+
|
|
88
|
+
/* ============================== fixtures ============================== */
|
|
89
|
+
|
|
90
|
+
const queue = await sdk.api.ticket_queues.createOne({
|
|
91
|
+
title: `Restore Queue ${RAND()}`,
|
|
92
|
+
userIds: [sdk.userInfo.id],
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
const journey = await sdk.api.journeys.createOne({ title: `Restore Journey ${RAND()}` })
|
|
96
|
+
const step = await sdk.api.automation_steps.createOne({
|
|
97
|
+
journeyId: journey.id,
|
|
98
|
+
action: { type: 'setEnduserStatus', info: { status: 'Restored' } },
|
|
99
|
+
events: [{ type: 'onJourneyStart', info: {} }],
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
const emailA = `restore-a-${RAND()}@example.com`
|
|
103
|
+
const externalIdA = `restore-ext-${RAND()}`
|
|
104
|
+
|
|
105
|
+
const contactA = await sdk.api.endusers.createOne({
|
|
106
|
+
fname: 'Restore', lname: 'Target', email: emailA, externalId: externalIdA,
|
|
107
|
+
})
|
|
108
|
+
const contactB = await sdk.api.endusers.createOne({
|
|
109
|
+
fname: 'Bystander', lname: 'Contact', email: `restore-b-${RAND()}@example.com`,
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
const noteA = await sdk.api.notes.createOne({ enduserId: contactA.id, text: 'A note that must come back' })
|
|
113
|
+
const ticketA = await sdk.api.tickets.createOne({
|
|
114
|
+
title: `Ticket for A ${RAND()}`, enduserId: contactA.id, queueId: queue.id,
|
|
115
|
+
})
|
|
116
|
+
const actionA = await sdk.api.automated_actions.createOne({
|
|
117
|
+
journeyId: journey.id,
|
|
118
|
+
automationStepId: step.id,
|
|
119
|
+
enduserId: contactA.id,
|
|
120
|
+
processAfter: Date.now() + 100000000, // far future: the worker must not process it during the test
|
|
121
|
+
status: 'active',
|
|
122
|
+
event: { type: 'onJourneyStart', info: {} },
|
|
123
|
+
action: { type: 'setEnduserStatus', info: { status: 'Restored' } },
|
|
124
|
+
})
|
|
125
|
+
const ticketB = await sdk.api.tickets.createOne({
|
|
126
|
+
title: `Ticket for B ${RAND()}`, enduserId: contactB.id,
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
// ticket_queues.count is maintained by asynchronous side effects, so every read of it polls
|
|
130
|
+
const queueCountAfterCreate = (await poll_for(
|
|
131
|
+
() => sdk.api.ticket_queues.getOne(queue.id),
|
|
132
|
+
q => (q.count ?? 0) >= 1,
|
|
133
|
+
)).count ?? 0
|
|
134
|
+
|
|
135
|
+
/* ====================== delete A (and one of B's tickets) ====================== */
|
|
136
|
+
|
|
137
|
+
// H: deleted INSIDE the same window as A's delete, to prove enduserId scoping
|
|
138
|
+
await sdk.api.tickets.deleteOne(ticketB.id)
|
|
139
|
+
await poll_for_delete_log(sdk, 'tickets', ticketB.id)
|
|
140
|
+
|
|
141
|
+
await sdk.api.endusers.deleteOne(contactA.id)
|
|
142
|
+
const deleteLogA = await poll_for_delete_log(sdk, 'endusers', contactA.id)
|
|
143
|
+
|
|
144
|
+
const queueCountAfterDelete = (await poll_for(
|
|
145
|
+
() => sdk.api.ticket_queues.getOne(queue.id),
|
|
146
|
+
q => (q.count ?? 0) < queueCountAfterCreate,
|
|
147
|
+
)).count ?? 0
|
|
148
|
+
|
|
149
|
+
record(
|
|
150
|
+
'delete removes the contact and decrements the queue',
|
|
151
|
+
!(await sdk.api.endusers.getSome({ filter: { } })).some(e => e.id === contactA.id)
|
|
152
|
+
&& queueCountAfterDelete === queueCountAfterCreate - 1,
|
|
153
|
+
`queue ${queueCountAfterCreate} -> ${queueCountAfterDelete}`,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
const auditRowsBefore = (await sdk.api.user_logs.getSome({ filter: { resourceId: contactA.id } })).length
|
|
157
|
+
|
|
158
|
+
/* ============================== A: the restore ============================== */
|
|
159
|
+
|
|
160
|
+
const result = await sdk.api.user_logs.restore_deleted({ userLogId: deleteLogA.id })
|
|
161
|
+
|
|
162
|
+
record(
|
|
163
|
+
'A1: restore returns the contact resource and its original id',
|
|
164
|
+
result.resource === 'endusers' && result.resourceId === contactA.id,
|
|
165
|
+
`resource=${result.resource} resourceId=${result.resourceId} (expected ${contactA.id})`,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
const restoredContact = await sdk.api.endusers.getOne(contactA.id)
|
|
169
|
+
record(
|
|
170
|
+
'A2: the contact is back at its original id with its fields intact',
|
|
171
|
+
restoredContact.id === contactA.id
|
|
172
|
+
&& restoredContact.fname === 'Restore'
|
|
173
|
+
&& restoredContact.lname === 'Target'
|
|
174
|
+
&& restoredContact.email === emailA
|
|
175
|
+
&& restoredContact.externalId === externalIdA,
|
|
176
|
+
`id=${restoredContact.id} fname=${restoredContact.fname} email=${restoredContact.email} externalId=${restoredContact.externalId}`,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
record(
|
|
180
|
+
'A3: the contact keeps its original creation timestamp',
|
|
181
|
+
new Date(restoredContact.createdAt).getTime() === new Date(contactA.createdAt).getTime(),
|
|
182
|
+
`restored=${restoredContact.createdAt} original=${contactA.createdAt}`,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
/* ---- B: the cascade. This is what fails if the _id range runs the wrong way. ---- */
|
|
186
|
+
|
|
187
|
+
const dependentModels = Object.keys(result.restoredCounts ?? {}).filter(m => m !== 'endusers')
|
|
188
|
+
record(
|
|
189
|
+
'B1: restoredCounts covers at least two dependent models',
|
|
190
|
+
dependentModels.length >= 2,
|
|
191
|
+
`restoredCounts=${JSON.stringify(result.restoredCounts)} (the _id range runs BACKWARDS from the parent row)`,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
const restoredNote = await sdk.api.notes.getOne(noteA.id)
|
|
195
|
+
const restoredTicket = await sdk.api.tickets.getOne(ticketA.id)
|
|
196
|
+
record(
|
|
197
|
+
'B2: dependents come back at their original ids',
|
|
198
|
+
restoredNote?.id === noteA.id && restoredTicket?.id === ticketA.id,
|
|
199
|
+
`note=${restoredNote?.id} ticket=${restoredTicket?.id}`,
|
|
200
|
+
)
|
|
201
|
+
record(
|
|
202
|
+
'B3: dependents keep their original authorship and content',
|
|
203
|
+
restoredNote.creator === noteA.creator
|
|
204
|
+
&& restoredNote.text === 'A note that must come back'
|
|
205
|
+
&& restoredTicket.creator === ticketA.creator
|
|
206
|
+
&& restoredTicket.title === ticketA.title,
|
|
207
|
+
`note.creator=${restoredNote.creator} (orig ${noteA.creator}); ticket.creator=${restoredTicket.creator} (orig ${ticketA.creator})`,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
/* ---- C: journey actions come back CANCELLED, never active ---- */
|
|
211
|
+
|
|
212
|
+
const expectedCancelReason = AUTOMATED_ACTION_CANCEL_REASONS.indexOf('Restored from Deletion')
|
|
213
|
+
const restoredAction = await sdk.api.automated_actions.getOne(actionA.id)
|
|
214
|
+
record(
|
|
215
|
+
'C: the pending journey action is restored cancelled with the new reason',
|
|
216
|
+
restoredAction?.status === 'cancelled' && restoredAction?.cancelReason === expectedCancelReason,
|
|
217
|
+
`status=${restoredAction?.status} cancelReason=${restoredAction?.cancelReason} (expected ${expectedCancelReason})`,
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
/* ---- H: cross-contact isolation ---- */
|
|
221
|
+
|
|
222
|
+
const bTicketBack = await rejection_of(() => sdk.api.tickets.getOne(ticketB.id))
|
|
223
|
+
record(
|
|
224
|
+
"H: restoring A does not bring back contact B's separately-deleted ticket",
|
|
225
|
+
bTicketBack !== undefined,
|
|
226
|
+
`enduserId scoping; getOne(ticketB) ${bTicketBack === undefined ? 'UNEXPECTEDLY SUCCEEDED' : 'rejected as expected'}`,
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
/* ---- queue count round-trips ---- */
|
|
230
|
+
|
|
231
|
+
const queueCountAfterRestore = (await poll_for(
|
|
232
|
+
() => sdk.api.ticket_queues.getOne(queue.id),
|
|
233
|
+
q => (q.count ?? 0) > queueCountAfterDelete,
|
|
234
|
+
)).count ?? 0
|
|
235
|
+
record(
|
|
236
|
+
'F1: the ticket queue count round-trips on restore',
|
|
237
|
+
queueCountAfterRestore === queueCountAfterCreate,
|
|
238
|
+
`${queueCountAfterCreate} -> ${queueCountAfterDelete} -> ${queueCountAfterRestore}`,
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
/* ---- audit: exactly one new row per restore ---- */
|
|
242
|
+
|
|
243
|
+
const auditRowsAfter = await poll_for(
|
|
244
|
+
async () => await sdk.api.user_logs.getSome({ filter: { resourceId: contactA.id } }),
|
|
245
|
+
rows => rows.length > auditRowsBefore,
|
|
246
|
+
)
|
|
247
|
+
const restoreRows = auditRowsAfter.filter(r => r.action === 'create' && r.note === 'Restored from deletion')
|
|
248
|
+
record(
|
|
249
|
+
'I1: the restore writes exactly one audit row',
|
|
250
|
+
auditRowsAfter.length === auditRowsBefore + 1 && restoreRows.length === 1,
|
|
251
|
+
`rows ${auditRowsBefore} -> ${auditRowsAfter.length}; restore rows=${restoreRows.length}`,
|
|
252
|
+
)
|
|
253
|
+
record(
|
|
254
|
+
'I2: the restore audit row records the source log id and the counts',
|
|
255
|
+
(restoreRows[0]?.info as any)?.sourceUserLogId === deleteLogA.id
|
|
256
|
+
&& !!(restoreRows[0]?.info as any)?.restoredCounts,
|
|
257
|
+
`info=${JSON.stringify(restoreRows[0]?.info)?.slice(0, 200)}`,
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
/* ---- D: a second restore resumes, and duplicates nothing ---- */
|
|
261
|
+
|
|
262
|
+
const second = await sdk.api.user_logs.restore_deleted({ userLogId: deleteLogA.id })
|
|
263
|
+
record(
|
|
264
|
+
'D1: a second restore restores nothing and skips everything',
|
|
265
|
+
sum(second.restoredCounts) === 0 && sum(second.skippedCounts) > 0,
|
|
266
|
+
`restored=${JSON.stringify(second.restoredCounts)} skipped=${JSON.stringify(second.skippedCounts)}`,
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
const allContacts = await sdk.api.endusers.getSome({ filter: { } })
|
|
270
|
+
const allNotes = await sdk.api.notes.getSome({ filter: { enduserId: contactA.id } })
|
|
271
|
+
record(
|
|
272
|
+
'D2: a second restore duplicates neither the contact nor its records',
|
|
273
|
+
allContacts.filter(e => e.id === contactA.id).length === 1 && allNotes.length === 1,
|
|
274
|
+
`contacts with that id=${allContacts.filter(e => e.id === contactA.id).length} notes=${allNotes.length}`,
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
const queueCountAfterSecond = (await sdk.api.ticket_queues.getOne(queue.id)).count ?? 0
|
|
278
|
+
record(
|
|
279
|
+
'F2: a second restore does not inflate the queue count',
|
|
280
|
+
queueCountAfterSecond === queueCountAfterCreate,
|
|
281
|
+
`expected ${queueCountAfterCreate}, got ${queueCountAfterSecond}`,
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
/* ---- E: resume brings back a dependent deleted since ---- */
|
|
285
|
+
|
|
286
|
+
await sdk.api.notes.deleteOne(noteA.id)
|
|
287
|
+
await poll_for_delete_log(sdk, 'notes', noteA.id)
|
|
288
|
+
|
|
289
|
+
const third = await sdk.api.user_logs.restore_deleted({ userLogId: deleteLogA.id })
|
|
290
|
+
const noteBackAgain = await sdk.api.notes.getOne(noteA.id)
|
|
291
|
+
record(
|
|
292
|
+
'E: a repeat restore brings back exactly the record deleted since',
|
|
293
|
+
(third.restoredCounts as any)?.notes === 1
|
|
294
|
+
&& sum(third.restoredCounts) === 1
|
|
295
|
+
&& noteBackAgain?.id === noteA.id,
|
|
296
|
+
`restored=${JSON.stringify(third.restoredCounts)} note back=${noteBackAgain?.id}`,
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
/* ============================== error cases ============================== */
|
|
300
|
+
|
|
301
|
+
// G: a non-admin is refused. NOTE: for the default Non-Admin role the role-permission layer fires
|
|
302
|
+
// FIRST with 403 "Inaccessible" (the role has no user_logs access at all), so the schema's
|
|
303
|
+
// adminOnly gate — 401 "Admin access only" — is never reached. Both refuse; this asserts only
|
|
304
|
+
// that the call is rejected, so it stays green whichever of the two gates fires.
|
|
305
|
+
const nonAdminErr = await rejection_of(() => sdkNonAdmin.api.user_logs.restore_deleted({ userLogId: deleteLogA.id }))
|
|
306
|
+
record(
|
|
307
|
+
'G: a non-admin is refused',
|
|
308
|
+
nonAdminErr !== undefined,
|
|
309
|
+
`error=${JSON.stringify(nonAdminErr)?.slice(0, 200)}`,
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
// another tenant's log id is not found (indistinguishable from nonexistent)
|
|
313
|
+
const otherContact = await otherTenant.sdk.api.endusers.createOne({ fname: 'Other', lname: 'Tenant' })
|
|
314
|
+
await otherTenant.sdk.api.endusers.deleteOne(otherContact.id)
|
|
315
|
+
const otherLog = await poll_for_delete_log(otherTenant.sdk, 'endusers', otherContact.id)
|
|
316
|
+
|
|
317
|
+
const crossTenantErr = await rejection_of(() => sdk.api.user_logs.restore_deleted({ userLogId: otherLog.id }))
|
|
318
|
+
record(
|
|
319
|
+
"J1: another tenant's log id is not found",
|
|
320
|
+
crossTenantErr !== undefined,
|
|
321
|
+
`error=${JSON.stringify(crossTenantErr)?.slice(0, 200)}`,
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
// a non-delete row is not found
|
|
325
|
+
const anyCreateRow = (await sdk.api.user_logs.getSome({ filter: { } })).find(l => l.action !== 'delete')
|
|
326
|
+
const nonDeleteErr = anyCreateRow
|
|
327
|
+
? await rejection_of(() => sdk.api.user_logs.restore_deleted({ userLogId: anyCreateRow.id }))
|
|
328
|
+
: undefined
|
|
329
|
+
record(
|
|
330
|
+
'J2: a non-delete audit row is not found',
|
|
331
|
+
!anyCreateRow || nonDeleteErr !== undefined,
|
|
332
|
+
anyCreateRow ? `error=${JSON.stringify(nonDeleteErr)?.slice(0, 200)}` : 'no non-delete row available to test',
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
// K: a standalone ticket's delete row is refused as unsupported_resource, a 400 (not a 404)
|
|
336
|
+
const standaloneTicket = await sdk.api.tickets.createOne({ title: `Standalone ${RAND()}` })
|
|
337
|
+
await sdk.api.tickets.deleteOne(standaloneTicket.id)
|
|
338
|
+
const ticketLog = await poll_for_delete_log(sdk, 'tickets', standaloneTicket.id)
|
|
339
|
+
|
|
340
|
+
const unsupportedErr: any = await rejection_of(() => sdk.api.user_logs.restore_deleted({ userLogId: ticketLog.id }))
|
|
341
|
+
record(
|
|
342
|
+
'K: a non-contact delete row is refused with unsupported_resource',
|
|
343
|
+
unsupportedErr?.info?.reason === 'unsupported_resource',
|
|
344
|
+
`error=${JSON.stringify(unsupportedErr)?.slice(0, 300)}`,
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
// L: a merge's delete row carries the Profile merge marker and is refused
|
|
348
|
+
const mergeSource = await sdk.api.endusers.createOne({ fname: 'Merge', lname: 'Source', email: `merge-src-${RAND()}@example.com` })
|
|
349
|
+
const mergeDest = await sdk.api.endusers.createOne({ fname: 'Merge', lname: 'Dest', email: `merge-dst-${RAND()}@example.com` })
|
|
350
|
+
await sdk.api.endusers.merge({ sourceEnduserId: mergeSource.id, destinationEnduserId: mergeDest.id })
|
|
351
|
+
const mergeLog = await poll_for_delete_log(sdk, 'endusers', mergeSource.id)
|
|
352
|
+
|
|
353
|
+
record(
|
|
354
|
+
"L1: the merge's delete row is marked 'Profile merge'",
|
|
355
|
+
mergeLog.note === 'Profile merge',
|
|
356
|
+
`note=${mergeLog.note}`,
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
const mergeErr: any = await rejection_of(() => sdk.api.user_logs.restore_deleted({ userLogId: mergeLog.id }))
|
|
360
|
+
record(
|
|
361
|
+
'L2: a merge delete row is refused with merge_row',
|
|
362
|
+
mergeErr?.info?.reason === 'merge_row',
|
|
363
|
+
`error=${JSON.stringify(mergeErr)?.slice(0, 300)}`,
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
// M: reusing a deleted contact's email yields unique_conflict
|
|
367
|
+
const conflictEmail = `restore-conflict-${RAND()}@example.com`
|
|
368
|
+
const conflictContact = await sdk.api.endusers.createOne({ fname: 'Conflict', lname: 'Target', email: conflictEmail })
|
|
369
|
+
await sdk.api.endusers.deleteOne(conflictContact.id)
|
|
370
|
+
const conflictLog = await poll_for_delete_log(sdk, 'endusers', conflictContact.id)
|
|
371
|
+
|
|
372
|
+
const replacement = await sdk.api.endusers.createOne({ fname: 'Replacement', lname: 'Contact', email: conflictEmail })
|
|
373
|
+
const conflictErr: any = await rejection_of(() => sdk.api.user_logs.restore_deleted({ userLogId: conflictLog.id }))
|
|
374
|
+
|
|
375
|
+
record(
|
|
376
|
+
'M1: restoring into a reused email yields unique_conflict',
|
|
377
|
+
conflictErr?.info?.reason === 'unique_conflict',
|
|
378
|
+
`error=${JSON.stringify(conflictErr)?.slice(0, 300)}`,
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
const conflictStillGone = await rejection_of(() => sdk.api.endusers.getOne(conflictContact.id))
|
|
382
|
+
const replacementUntouched = await sdk.api.endusers.getOne(replacement.id)
|
|
383
|
+
record(
|
|
384
|
+
'M2: a unique conflict restores nothing and leaves the new contact untouched',
|
|
385
|
+
conflictStillGone !== undefined && replacementUntouched?.email === conflictEmail,
|
|
386
|
+
`deleted contact still absent=${conflictStillGone !== undefined}; replacement email=${replacementUntouched?.email}`,
|
|
387
|
+
)
|
|
388
|
+
} finally {
|
|
389
|
+
// Teardown BEFORE anything asserts — assert calls process.exit, which would strand these tenants.
|
|
390
|
+
for (const t of [tenant, otherTenant]) {
|
|
391
|
+
if (t) { try { await t.teardown() } catch (e) { console.error('teardown failed', e) } }
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
return checks
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** Asserting wrapper, for standalone runs and for sequential use in the suite. */
|
|
399
|
+
export const user_logs_restore_deleted_tests = async () => {
|
|
400
|
+
assert_checks('user_logs restore_deleted', await user_logs_restore_deleted_checks())
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Allow running this test file independently
|
|
404
|
+
if (require.main === module) {
|
|
405
|
+
console.log(`🌐 Using API URL: ${host}`)
|
|
406
|
+
user_logs_restore_deleted_tests()
|
|
407
|
+
.then(() => {
|
|
408
|
+
console.log("✅ user_logs restore_deleted test suite completed successfully")
|
|
409
|
+
process.exit(0)
|
|
410
|
+
})
|
|
411
|
+
.catch((error) => {
|
|
412
|
+
console.error("❌ user_logs restore_deleted test suite failed:", error)
|
|
413
|
+
process.exit(1)
|
|
414
|
+
})
|
|
415
|
+
}
|
package/src/tests/tests.ts
CHANGED
|
@@ -163,12 +163,18 @@ import { conditional_logic_medication_unit_tests } from "./unit_tests/conditiona
|
|
|
163
163
|
import { phone_validation_unit_tests } from "./unit_tests/phone_validation.test";
|
|
164
164
|
import { openapi_spec_freshness_unit_tests } from "./unit_tests/openapi_spec_freshness.test";
|
|
165
165
|
import { do_not_send_address_unit_tests } from "./unit_tests/do_not_send_address.test";
|
|
166
|
+
import { trigger_title_partials_unit_tests } from "./unit_tests/trigger_title_partials.test";
|
|
167
|
+
import { trigger_contains_matching_checks } from "./api_tests/trigger_contains_matching.test";
|
|
166
168
|
import { organization_signup_checks } from "./api_tests/organization_signup.test";
|
|
167
169
|
import { add_to_journey_foreign_enduserid_checks } from "./api_tests/security/add-to-journey-foreign-enduserid.test";
|
|
168
170
|
import { tickets_bulk_assign_from_queue_checks } from "./api_tests/tickets_bulk_assign_from_queue.test";
|
|
169
171
|
import { enduser_observations_auto_review_checks } from "./api_tests/enduser_observations_auto_review.test";
|
|
170
172
|
import { file_link_session_confinement_checks } from "./api_tests/security/file-link-session-confinement.test";
|
|
173
|
+
import { appointment_reschedule_ownership_checks } from "./api_tests/security/appointment-reschedule-ownership.test";
|
|
174
|
+
import { load_threads_care_team_checks } from "./api_tests/load_threads_care_team.test";
|
|
171
175
|
import { run_isolated_in_parallel } from "./setup_isolated";
|
|
176
|
+
import { enduser_fields_sync_checks } from "./api_tests/enduser_fields_sync.test";
|
|
177
|
+
import { user_logs_restore_deleted_checks } from "./api_tests/user_logs_restore_deleted.test";
|
|
172
178
|
import { elation_user_id_tests } from "./api_tests/elation_user_id.test";
|
|
173
179
|
import { organization_settings_duplicates_tests } from "./api_tests/organization_settings_duplicates.test";
|
|
174
180
|
import { stedi_approval_tests } from "./api_tests/stedi_approval.test";
|
|
@@ -15395,6 +15401,7 @@ const ip_address_form_tests = async () => {
|
|
|
15395
15401
|
await phone_validation_unit_tests()
|
|
15396
15402
|
await openapi_spec_freshness_unit_tests()
|
|
15397
15403
|
await do_not_send_address_unit_tests()
|
|
15404
|
+
await trigger_title_partials_unit_tests()
|
|
15398
15405
|
await replace_enduser_template_values_tests()
|
|
15399
15406
|
await safe_json_parse_template_tests()
|
|
15400
15407
|
await replace_form_field_template_values_tests()
|
|
@@ -15412,6 +15419,11 @@ const ip_address_form_tests = async () => {
|
|
|
15412
15419
|
{ name: '$JS in stored conditional logic', run: form_logic_js_execution_checks },
|
|
15413
15420
|
{ name: 'Rich Text answers sanitized on write', run: rich_text_answer_sanitized_on_write_checks },
|
|
15414
15421
|
{ name: 'Shared file link session confinement', run: file_link_session_confinement_checks },
|
|
15422
|
+
{ name: 'book_appointment reschedule ownership', run: appointment_reschedule_ownership_checks },
|
|
15423
|
+
{ name: 'Enduser fields sync schema round-trip', run: enduser_fields_sync_checks },
|
|
15424
|
+
{ name: 'Trigger contains title matching', run: trigger_contains_matching_checks },
|
|
15425
|
+
{ name: 'user_logs restore_deleted', run: user_logs_restore_deleted_checks },
|
|
15426
|
+
{ name: 'Load threads care team', run: load_threads_care_team_checks },
|
|
15415
15427
|
])
|
|
15416
15428
|
await embeddables_auth_token_tests({ sdk, sdkNonAdmin })
|
|
15417
15429
|
await proxy_image_ssrf_tests({ sdk, sdkNonAdmin })
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { title_matches_partials } from "@tellescope/utilities"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Unit coverage for the shared "contains" matcher behind the Problem Created / Order Created title
|
|
5
|
+
* filters. The backend call sites (enduser_problems.ts, enduser_orders.ts, handle_order_created)
|
|
6
|
+
* all delegate here, so the semantics are pinned in one place:
|
|
7
|
+
*
|
|
8
|
+
* - each list is OR internally, the two lists AND together
|
|
9
|
+
* - the case-sensitive list is `titlePartials`, the case-insensitive one `titlePartialsIgnoreCase`
|
|
10
|
+
* - entries are trimmed, and blank-after-trim entries are dropped rather than matching everything
|
|
11
|
+
* - no list set (or every entry blank) means "leave blank for all"
|
|
12
|
+
* - a missing/empty title never satisfies a non-empty list
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
let failures = 0
|
|
16
|
+
let passed = 0
|
|
17
|
+
|
|
18
|
+
const assert = (name: string, actual: boolean, expected: boolean) => {
|
|
19
|
+
if (actual === expected) {
|
|
20
|
+
passed++
|
|
21
|
+
console.log(` ✓ ${name}`)
|
|
22
|
+
} else {
|
|
23
|
+
failures++
|
|
24
|
+
console.error(` ✗ ${name} — expected ${expected}, got ${actual}`)
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const run_unset_tests = () => {
|
|
29
|
+
console.log("\n[title_matches_partials — unset lists mean no filter]")
|
|
30
|
+
|
|
31
|
+
assert("both lists undefined", title_matches_partials('Endometriosis, unspecified'), true)
|
|
32
|
+
assert("both lists empty arrays", title_matches_partials('Endometriosis', [], []), true)
|
|
33
|
+
assert("undefined title with no lists set", title_matches_partials(undefined), true)
|
|
34
|
+
assert("empty title with no lists set", title_matches_partials(''), true)
|
|
35
|
+
assert("null title with no lists set", title_matches_partials(null), true)
|
|
36
|
+
assert("all-blank case-sensitive list counts as unset", title_matches_partials('Common cold', [' ']), true)
|
|
37
|
+
assert("all-blank case-insensitive list counts as unset", title_matches_partials('Common cold', [], ['', ' ']), true)
|
|
38
|
+
assert("all-blank lists on an empty title", title_matches_partials('', [' '], ['\t']), true)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const run_case_sensitive_tests = () => {
|
|
42
|
+
console.log("\n[title_matches_partials — case sensitive list]")
|
|
43
|
+
|
|
44
|
+
assert("substring match", title_matches_partials('Endometriosis, unspecified', ['Endometriosis']), true)
|
|
45
|
+
assert("prefix fragment match", title_matches_partials('Endometriosis, unspecified', ['Endo']), true)
|
|
46
|
+
assert("mid-string fragment match", title_matches_partials('Chronic Endometriosis pain', ['metrios']), true)
|
|
47
|
+
assert("case mismatch does not match", title_matches_partials('Chronic endometriosis pain', ['Endometriosis']), false)
|
|
48
|
+
assert("uppercase entry does not match lowercase title", title_matches_partials('endometriosis', ['ENDO']), false)
|
|
49
|
+
assert("exact-equal title matches", title_matches_partials('Endometriosis', ['Endometriosis']), true)
|
|
50
|
+
assert("unrelated title does not match", title_matches_partials('Common cold', ['Endo']), false)
|
|
51
|
+
|
|
52
|
+
// OR within the list
|
|
53
|
+
assert("OR within list — second entry matches", title_matches_partials('Type 2 Diabetes', ['Endo', 'Diabetes']), true)
|
|
54
|
+
assert("OR within list — no entry matches", title_matches_partials('Common cold', ['Endo', 'Diabetes']), false)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const run_case_insensitive_tests = () => {
|
|
58
|
+
console.log("\n[title_matches_partials — case insensitive list]")
|
|
59
|
+
|
|
60
|
+
assert("lowercase entry matches capitalized title", title_matches_partials('Semaglutide 0.25mg', [], ['semaglutide']), true)
|
|
61
|
+
assert("lowercase entry matches uppercase title", title_matches_partials('SEMAGLUTIDE injection', [], ['semaglutide']), true)
|
|
62
|
+
assert("uppercase entry matches lowercase title", title_matches_partials('semaglutide injection', [], ['SEMAGLUTIDE']), true)
|
|
63
|
+
assert("mixed-case entry matches", title_matches_partials('Chronic endometriosis pain', [], ['EnDoMeTrIoSiS']), true)
|
|
64
|
+
assert("unrelated title does not match", title_matches_partials('Tirzepatide 5mg', [], ['semaglutide']), false)
|
|
65
|
+
assert("OR within list — second entry matches", title_matches_partials('Tirzepatide 5mg', [], ['sema', 'TIRZ']), true)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const run_trimming_tests = () => {
|
|
69
|
+
console.log("\n[title_matches_partials — trimming and blank entries]")
|
|
70
|
+
|
|
71
|
+
assert("padded entry is trimmed before matching", title_matches_partials('Endometriosis, unspecified', [' Endo ']), true)
|
|
72
|
+
assert("padded case-insensitive entry is trimmed", title_matches_partials('SEMAGLUTIDE injection', [], [' semaglutide ']), true)
|
|
73
|
+
assert(
|
|
74
|
+
"blank sibling entry is ignored, real entry still matches",
|
|
75
|
+
title_matches_partials('Endometriosis', ['Endo', ' ']),
|
|
76
|
+
true,
|
|
77
|
+
)
|
|
78
|
+
assert(
|
|
79
|
+
"blank sibling entry does not make a non-matching title match",
|
|
80
|
+
title_matches_partials('Common cold', ['Endo', ' ']),
|
|
81
|
+
false,
|
|
82
|
+
)
|
|
83
|
+
assert(
|
|
84
|
+
"a list of only blanks counts as unset rather than as a match-everything entry",
|
|
85
|
+
title_matches_partials('Common cold', ['', ' ', '\t']),
|
|
86
|
+
true, // unset — and per the next check it cannot rescue a failing sibling list either
|
|
87
|
+
)
|
|
88
|
+
assert(
|
|
89
|
+
"blank case-sensitive list does not rescue a failing case-insensitive list",
|
|
90
|
+
title_matches_partials('Common cold', [' '], ['semaglutide']),
|
|
91
|
+
false,
|
|
92
|
+
)
|
|
93
|
+
assert(
|
|
94
|
+
"whitespace-only title against a real entry",
|
|
95
|
+
title_matches_partials(' ', ['Endo']),
|
|
96
|
+
false,
|
|
97
|
+
)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const run_both_lists_tests = () => {
|
|
101
|
+
console.log("\n[title_matches_partials — the two lists AND together]")
|
|
102
|
+
|
|
103
|
+
assert(
|
|
104
|
+
"both lists match",
|
|
105
|
+
title_matches_partials('Endometriosis, unspecified', ['Endo'], ['unspecified']),
|
|
106
|
+
true,
|
|
107
|
+
)
|
|
108
|
+
assert(
|
|
109
|
+
"case-sensitive list matches, case-insensitive does not",
|
|
110
|
+
title_matches_partials('Endometriosis, unspecified', ['Endo'], ['diabetes']),
|
|
111
|
+
false,
|
|
112
|
+
)
|
|
113
|
+
assert(
|
|
114
|
+
"case-insensitive list matches, case-sensitive does not",
|
|
115
|
+
title_matches_partials('Endometriosis, unspecified', ['ENDO'], ['endometriosis']),
|
|
116
|
+
false,
|
|
117
|
+
)
|
|
118
|
+
assert(
|
|
119
|
+
"neither list matches",
|
|
120
|
+
title_matches_partials('Common cold', ['Endo'], ['semaglutide']),
|
|
121
|
+
false,
|
|
122
|
+
)
|
|
123
|
+
assert(
|
|
124
|
+
"same fragment in both lists, case differing — case-sensitive one decides",
|
|
125
|
+
title_matches_partials('chronic endometriosis', ['Endometriosis'], ['endometriosis']),
|
|
126
|
+
false,
|
|
127
|
+
)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const run_missing_title_tests = () => {
|
|
131
|
+
console.log("\n[title_matches_partials — absent or empty title with a filter set]")
|
|
132
|
+
|
|
133
|
+
assert("undefined title vs case-sensitive list", title_matches_partials(undefined, ['Endo']), false)
|
|
134
|
+
assert("undefined title vs case-insensitive list", title_matches_partials(undefined, [], ['endo']), false)
|
|
135
|
+
assert("null title vs a set list", title_matches_partials(null, ['Endo']), false)
|
|
136
|
+
assert("empty-string title vs a set list", title_matches_partials('', ['Endo']), false)
|
|
137
|
+
assert("empty-string title vs both lists set", title_matches_partials('', ['Endo'], ['endo']), false)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const run_literal_matching_tests = () => {
|
|
141
|
+
console.log("\n[title_matches_partials — entries are literal, never regex]")
|
|
142
|
+
|
|
143
|
+
assert("hyphenated fragment matches literally", title_matches_partials('Diabetes Type-2', ['Type-2']), true)
|
|
144
|
+
assert("dosage fragment matches literally", title_matches_partials('Semaglutide 10mg', ['10mg']), true)
|
|
145
|
+
assert("dot is not a wildcard", title_matches_partials('Semaglutide 0x25mg', ['0.25']), false)
|
|
146
|
+
assert("dot matches itself", title_matches_partials('Semaglutide 0.25mg', ['0.25']), true)
|
|
147
|
+
assert("regex metacharacters are literal", title_matches_partials('Order (urgent)', ['(urgent)']), true)
|
|
148
|
+
assert("regex alternation is literal, not alternation", title_matches_partials('Endometriosis', ['Endo|Diabetes']), false)
|
|
149
|
+
assert("star is literal", title_matches_partials('a*b', ['a*b']), true)
|
|
150
|
+
assert("very long entry simply does not match", title_matches_partials('Endometriosis', ['E'.repeat(250)]), false)
|
|
151
|
+
|
|
152
|
+
// non-string entries from bad stored data must not throw
|
|
153
|
+
assert("non-string entries are ignored, not crashed on", title_matches_partials('Endometriosis', [null as any, 'Endo']), true)
|
|
154
|
+
assert("only non-string entries counts as unset", title_matches_partials('Common cold', [undefined as any]), true)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const run_all = () => {
|
|
158
|
+
console.log("Running trigger_title_partials unit tests")
|
|
159
|
+
run_unset_tests()
|
|
160
|
+
run_case_sensitive_tests()
|
|
161
|
+
run_case_insensitive_tests()
|
|
162
|
+
run_trimming_tests()
|
|
163
|
+
run_both_lists_tests()
|
|
164
|
+
run_missing_title_tests()
|
|
165
|
+
run_literal_matching_tests()
|
|
166
|
+
console.log(`\nResults: ${passed} passed, ${failures} failed`)
|
|
167
|
+
if (failures > 0) process.exit(1)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (require.main === module) {
|
|
171
|
+
run_all()
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export { run_all as trigger_title_partials_unit_tests }
|