@kernhq/module-inventory 0.2.0 → 0.4.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.
- package/README.md +106 -25
- package/dist/contract/capabilities.d.ts +4 -4
- package/dist/contract/capabilities.d.ts.map +1 -1
- package/dist/contract/capabilities.js +51 -9
- package/dist/contract/capabilities.js.map +1 -1
- package/dist/contract/events.d.ts +43 -0
- package/dist/contract/events.d.ts.map +1 -1
- package/dist/contract/events.js +40 -0
- package/dist/contract/events.js.map +1 -1
- package/dist/contract/index.d.ts +1 -0
- package/dist/contract/index.d.ts.map +1 -1
- package/dist/contract/index.js +1 -0
- package/dist/contract/index.js.map +1 -1
- package/dist/contract/models.d.ts +345 -0
- package/dist/contract/models.d.ts.map +1 -1
- package/dist/contract/models.js +245 -0
- package/dist/contract/models.js.map +1 -1
- package/dist/contract/notifications.d.ts +24 -0
- package/dist/contract/notifications.d.ts.map +1 -0
- package/dist/contract/notifications.js +72 -0
- package/dist/contract/notifications.js.map +1 -0
- package/dist/contract/permissions.d.ts +52 -2
- package/dist/contract/permissions.d.ts.map +1 -1
- package/dist/contract/permissions.js +55 -2
- package/dist/contract/permissions.js.map +1 -1
- package/dist/contract/router.d.ts +1108 -0
- package/dist/contract/router.d.ts.map +1 -1
- package/dist/contract/router.js +253 -1
- package/dist/contract/router.js.map +1 -1
- package/dist/contract/settings.d.ts +2 -0
- package/dist/contract/settings.d.ts.map +1 -1
- package/dist/contract/settings.js +24 -4
- package/dist/contract/settings.js.map +1 -1
- package/dist/server/index.d.ts +8 -4
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +289 -5
- package/dist/server/index.js.map +1 -1
- package/dist/server/jobs.d.ts +48 -0
- package/dist/server/jobs.d.ts.map +1 -0
- package/dist/server/jobs.js +358 -0
- package/dist/server/jobs.js.map +1 -0
- package/dist/server/router.d.ts +1392 -0
- package/dist/server/router.d.ts.map +1 -1
- package/dist/server/router.js +385 -7
- package/dist/server/router.js.map +1 -1
- package/dist/server/schema.d.ts +134 -1
- package/dist/server/schema.d.ts.map +1 -1
- package/dist/server/schema.js +169 -4
- package/dist/server/schema.js.map +1 -1
- package/dist/server/services/assets.d.ts +69 -3
- package/dist/server/services/assets.d.ts.map +1 -1
- package/dist/server/services/assets.js +199 -31
- package/dist/server/services/assets.js.map +1 -1
- package/dist/server/services/attachments.d.ts +80 -0
- package/dist/server/services/attachments.d.ts.map +1 -0
- package/dist/server/services/attachments.js +182 -0
- package/dist/server/services/attachments.js.map +1 -0
- package/dist/server/services/audience.d.ts +15 -0
- package/dist/server/services/audience.d.ts.map +1 -0
- package/dist/server/services/audience.js +64 -0
- package/dist/server/services/audience.js.map +1 -0
- package/dist/server/services/categories.d.ts +157 -0
- package/dist/server/services/categories.d.ts.map +1 -0
- package/dist/server/services/categories.js +309 -0
- package/dist/server/services/categories.js.map +1 -0
- package/dist/server/services/cursor.d.ts +68 -0
- package/dist/server/services/cursor.d.ts.map +1 -0
- package/dist/server/services/cursor.js +39 -0
- package/dist/server/services/cursor.js.map +1 -0
- package/dist/server/services/custody.d.ts +175 -0
- package/dist/server/services/custody.d.ts.map +1 -0
- package/dist/server/services/custody.js +367 -0
- package/dist/server/services/custody.js.map +1 -0
- package/dist/server/services/db-errors.d.ts +7 -0
- package/dist/server/services/db-errors.d.ts.map +1 -0
- package/dist/server/services/db-errors.js +32 -0
- package/dist/server/services/db-errors.js.map +1 -0
- package/dist/server/services/index.d.ts +16 -0
- package/dist/server/services/index.d.ts.map +1 -1
- package/dist/server/services/index.js +25 -1
- package/dist/server/services/index.js.map +1 -1
- package/dist/server/services/members.d.ts +27 -0
- package/dist/server/services/members.d.ts.map +1 -0
- package/dist/server/services/members.js +39 -0
- package/dist/server/services/members.js.map +1 -0
- package/dist/server/services/notify.d.ts +43 -1
- package/dist/server/services/notify.d.ts.map +1 -1
- package/dist/server/services/notify.js +47 -5
- package/dist/server/services/notify.js.map +1 -1
- package/dist/server/services/offboarding.d.ts +70 -0
- package/dist/server/services/offboarding.d.ts.map +1 -0
- package/dist/server/services/offboarding.js +116 -0
- package/dist/server/services/offboarding.js.map +1 -0
- package/dist/server/services/repairs.d.ts +204 -0
- package/dist/server/services/repairs.d.ts.map +1 -0
- package/dist/server/services/repairs.js +476 -0
- package/dist/server/services/repairs.js.map +1 -0
- package/dist/server/services/search.d.ts +85 -0
- package/dist/server/services/search.d.ts.map +1 -0
- package/dist/server/services/search.js +142 -0
- package/dist/server/services/search.js.map +1 -0
- package/dist/server/services/stats.d.ts +42 -0
- package/dist/server/services/stats.d.ts.map +1 -0
- package/dist/server/services/stats.js +80 -0
- package/dist/server/services/stats.js.map +1 -0
- package/dist/server/services/status.d.ts +102 -0
- package/dist/server/services/status.d.ts.map +1 -0
- package/dist/server/services/status.js +71 -0
- package/dist/server/services/status.js.map +1 -0
- package/migrations/0002_custody_and_categories.sql +23 -0
- package/migrations/0003_repairs.sql +23 -0
- package/migrations/0004_platform_surfaces.sql +51 -0
- package/migrations/0005_repair_dates.sql +35 -0
- package/migrations/0006_workspace_registry_read.sql +50 -0
- package/migrations/0007_history_sequence.sql +83 -0
- package/migrations/0008_category_order_unique.sql +71 -0
- package/migrations/meta/0002_snapshot.json +1054 -0
- package/migrations/meta/0003_snapshot.json +1070 -0
- package/migrations/meta/0004_snapshot.json +1130 -0
- package/migrations/meta/0005_snapshot.json +1135 -0
- package/migrations/meta/_journal.json +49 -0
- package/package.json +2 -1
- package/src/client/api-instance.ts +27 -2
- package/src/client/bidi.test.ts +148 -0
- package/src/client/bidi.ts +85 -0
- package/src/client/components/AssetDetailPanel.svelte +614 -0
- package/src/client/components/AssetFormDialog.svelte +71 -22
- package/src/client/components/AssetPhoto.svelte +178 -0
- package/src/client/components/AttachmentsSection.svelte +327 -0
- package/src/client/components/CustodyDialog.svelte +201 -0
- package/src/client/components/RepairDialog.svelte +271 -0
- package/src/client/components/RepairsSection.svelte +318 -0
- package/src/client/components/Timeline.svelte +347 -0
- package/src/client/components/TimelineText.svelte +124 -0
- package/src/client/core-api.ts +71 -0
- package/src/client/custody.test.ts +31 -0
- package/src/client/custody.ts +34 -0
- package/src/client/errors.test.ts +395 -0
- package/src/client/errors.ts +229 -0
- package/src/client/links.test.ts +74 -0
- package/src/client/links.ts +44 -0
- package/src/client/members.test.ts +132 -0
- package/src/client/members.ts +116 -0
- package/src/client/messages.test.ts +127 -0
- package/src/client/messages.ts +1106 -18
- package/src/client/mock.test.ts +465 -1
- package/src/client/mock.ts +1105 -79
- package/src/client/module.ts +73 -1
- package/src/client/pages/AssetsPage.svelte +317 -45
- package/src/client/permissions.ts +7 -0
- package/src/client/price.test.ts +151 -3
- package/src/client/price.ts +155 -11
- package/src/client/query.ts +36 -0
- package/src/client/reorder.test.ts +100 -0
- package/src/client/reorder.ts +79 -0
- package/src/client/repairs.test.ts +38 -0
- package/src/client/repairs.ts +38 -0
- package/src/client/sequence.test.ts +248 -0
- package/src/client/sequence.ts +185 -0
- package/src/client/settings/CategoriesSettings.svelte +746 -0
- package/src/client/settings/GeneralSettings.svelte +0 -0
- package/src/client/status.ts +29 -0
- package/src/client/timeline.test.ts +175 -0
- package/src/client/timeline.ts +206 -0
- package/src/client/widgets/OverviewWidget.svelte +127 -26
- package/src/client/widgets/RepairsWidget.svelte +124 -0
- package/src/contract/capabilities.ts +53 -9
- package/src/contract/events.ts +49 -0
- package/src/contract/index.ts +1 -0
- package/src/contract/models.ts +270 -0
- package/src/contract/notifications.ts +73 -0
- package/src/contract/permissions.ts +55 -2
- package/src/contract/router.ts +284 -1
- package/src/contract/settings.ts +24 -4
- package/src/module.test.ts +217 -4
- package/src/server/index.ts +311 -4
- package/src/server/inventory.int.test.ts +4109 -19
- package/src/server/jobs.ts +444 -0
- package/src/server/migrations.test.ts +253 -2
- package/src/server/router.ts +486 -6
- package/src/server/schema.ts +171 -3
- package/src/server/services/assets.ts +214 -54
- package/src/server/services/attachments.ts +215 -0
- package/src/server/services/audience.ts +77 -0
- package/src/server/services/categories.ts +337 -0
- package/src/server/services/cursor.ts +104 -0
- package/src/server/services/custody.ts +471 -0
- package/src/server/services/db-errors.ts +42 -0
- package/src/server/services/index.ts +34 -1
- package/src/server/services/members.ts +54 -0
- package/src/server/services/notify.ts +52 -7
- package/src/server/services/offboarding.ts +150 -0
- package/src/server/services/repairs.ts +567 -0
- package/src/server/services/search.ts +166 -0
- package/src/server/services/stats.ts +88 -0
- package/src/server/services/status.test.ts +34 -0
- package/src/server/services/status.ts +143 -0
- package/src/client/settings/core-api.ts +0 -32
package/src/client/mock.test.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { inventoryContract } from '../contract/index.js'
|
|
2
3
|
import { createMockInventoryApi } from './mock.js'
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -17,6 +18,31 @@ const WS = '01920000-0000-7000-8000-0000000000ff'
|
|
|
17
18
|
|
|
18
19
|
const codes = (items: { code: string }[]) => items.map((a) => a.code)
|
|
19
20
|
|
|
21
|
+
/**
|
|
22
|
+
* The mock is cast to the contract's client type, so **nothing type-checks it against the contract**
|
|
23
|
+
* — a procedure the mock does not implement is `undefined` at the call site and a demo that throws
|
|
24
|
+
* "not a function" on a screen that works everywhere else. `stats` was written as a bare function
|
|
25
|
+
* rather than `stats.summary` while this test was being added, and the cast said nothing.
|
|
26
|
+
*
|
|
27
|
+
* The contract is walked as data, the same way `src/module.test.ts` walks it against the router.
|
|
28
|
+
*/
|
|
29
|
+
const leaves = (node: unknown, path: string[] = []): string[] => {
|
|
30
|
+
if (typeof node === 'object' && node !== null && '~orpc' in node) return [path.join('.')]
|
|
31
|
+
if (typeof node !== 'object' || node === null) return []
|
|
32
|
+
return Object.entries(node).flatMap(([key, value]) => leaves(value, [...path, key]))
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const at = (root: unknown, path: string): unknown =>
|
|
36
|
+
path.split('.').reduce<unknown>((node, key) => (node as Record<string, unknown>)?.[key], root)
|
|
37
|
+
|
|
38
|
+
describe('the mock and the contract agree', () => {
|
|
39
|
+
it('implements every procedure the contract promises', () => {
|
|
40
|
+
const api = createMockInventoryApi()
|
|
41
|
+
const missing = leaves(inventoryContract).filter((name) => typeof at(api, name) !== 'function')
|
|
42
|
+
expect(missing, 'procedures the demo would throw on').toEqual([])
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
|
|
20
46
|
describe('mock assets.list', () => {
|
|
21
47
|
it('opens on what was added last, like the server’s default sort', async () => {
|
|
22
48
|
const api = createMockInventoryApi()
|
|
@@ -105,9 +131,15 @@ describe('mock assets.list', () => {
|
|
|
105
131
|
const first = await api.assets.list({ workspaceId: WS, limit: 2 })
|
|
106
132
|
const bookmarked = first.items.at(-1)
|
|
107
133
|
expect(bookmarked).toBeTruthy()
|
|
134
|
+
const id = (bookmarked as { id: string }).id
|
|
135
|
+
// An item somebody is holding cannot be archived — the server refuses it, and so does this — so
|
|
136
|
+
// it goes back into stock first. Two real calls rather than a workaround: the assertion is
|
|
137
|
+
// about the page after a bookmarked row, and both halves of getting there have to be legal.
|
|
138
|
+
if ((bookmarked as { custodianUserId: string | null }).custodianUserId)
|
|
139
|
+
await api.custody.return({ workspaceId: WS, assetId: id })
|
|
108
140
|
// Archiving the bookmarked row still leaves it findable by id, so the page continues; a row the
|
|
109
141
|
// mock genuinely cannot find is the "ends quietly" branch, and an unknown *id* is refused above.
|
|
110
|
-
await api.assets.archive({ assetId:
|
|
142
|
+
await api.assets.archive({ assetId: id })
|
|
111
143
|
const next = await api.assets.list({ workspaceId: WS, limit: 2, cursor: first.nextCursor as string })
|
|
112
144
|
expect(next.items.every((a) => !codes(first.items).includes(a.code))).toBe(true)
|
|
113
145
|
})
|
|
@@ -158,4 +190,436 @@ describe('mock assets.update and archive', () => {
|
|
|
158
190
|
const api = createMockInventoryApi()
|
|
159
191
|
await expect(api.assets.get({ assetId: 'nope' })).rejects.toMatchObject({ code: 'NOT_FOUND' })
|
|
160
192
|
})
|
|
193
|
+
|
|
194
|
+
it('refuses to archive something somebody is still holding, exactly as the server does', async () => {
|
|
195
|
+
const api = createMockInventoryApi()
|
|
196
|
+
const held = (await api.assets.list({ workspaceId: WS })).items.find((a) => a.custodianUserId)
|
|
197
|
+
expect(held).toBeTruthy()
|
|
198
|
+
await expect(
|
|
199
|
+
api.assets.archive({ workspaceId: WS, assetId: (held as { id: string }).id }),
|
|
200
|
+
).rejects.toMatchObject({ code: 'CONFLICT' })
|
|
201
|
+
})
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Categories, which the mock had none of at all.
|
|
206
|
+
*
|
|
207
|
+
* `assets.list` has taken a `categoryId` filter since the module existed, so a demo with no
|
|
208
|
+
* categories was a demo of a control with exactly one possible answer.
|
|
209
|
+
*/
|
|
210
|
+
describe('mock categories', () => {
|
|
211
|
+
it('hides archived categories unless they are asked for, and orders them for a picker', async () => {
|
|
212
|
+
const api = createMockInventoryApi()
|
|
213
|
+
const live = await api.categories.list({ workspaceId: WS })
|
|
214
|
+
expect(live.map((c) => c.name)).toEqual(['Laptops', 'Displays', 'Furniture', 'Cameras'])
|
|
215
|
+
expect((await api.categories.list({ workspaceId: WS, archived: true })).map((c) => c.name)).toContain(
|
|
216
|
+
'Phones',
|
|
217
|
+
)
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
it('leaves an asset filed under an archived category still naming it', async () => {
|
|
221
|
+
// The whole reason categories archive rather than delete: INV-0003 is seeded under "Phones".
|
|
222
|
+
const api = createMockInventoryApi()
|
|
223
|
+
const all = await api.categories.list({ workspaceId: WS, archived: true })
|
|
224
|
+
const phones = all.find((c) => c.name === 'Phones')
|
|
225
|
+
const { items } = await api.assets.list({ workspaceId: WS, categoryId: phones?.id })
|
|
226
|
+
expect(items.map((a) => a.code)).toEqual(['INV-0003'])
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
it('refuses a duplicate name with the sentence the server uses, not a 500', async () => {
|
|
230
|
+
const api = createMockInventoryApi()
|
|
231
|
+
await expect(api.categories.create({ workspaceId: WS, name: 'Laptops' })).rejects.toMatchObject({
|
|
232
|
+
code: 'CONFLICT',
|
|
233
|
+
})
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
it('creates, renames and archives without ever deleting', async () => {
|
|
237
|
+
const api = createMockInventoryApi()
|
|
238
|
+
const made = await api.categories.create({ workspaceId: WS, name: 'Tools' })
|
|
239
|
+
expect(made.name).toBe('Tools')
|
|
240
|
+
const renamed = await api.categories.update({ workspaceId: WS, categoryId: made.id, name: 'Hand tools' })
|
|
241
|
+
expect(renamed.name).toBe('Hand tools')
|
|
242
|
+
await api.categories.archive({ workspaceId: WS, categoryId: made.id })
|
|
243
|
+
expect((await api.categories.list({ workspaceId: WS })).map((c) => c.id)).not.toContain(made.id)
|
|
244
|
+
// Still there, which is the point — restoring is the same procedure.
|
|
245
|
+
await api.categories.archive({ workspaceId: WS, categoryId: made.id, archived: false })
|
|
246
|
+
expect((await api.categories.list({ workspaceId: WS })).map((c) => c.id)).toContain(made.id)
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* A new category joins the **end**, and so does a restored one.
|
|
251
|
+
*
|
|
252
|
+
* The demo has to agree with the server about this or the settings page contradicts itself in
|
|
253
|
+
* exactly the environment the product is shown in: a category added while somebody watches
|
|
254
|
+
* appearing in the middle of a list they just arranged is the defect the *Position* field caused.
|
|
255
|
+
*/
|
|
256
|
+
it('appends a new category, and a restored one, rather than dropping it into the middle', async () => {
|
|
257
|
+
const api = createMockInventoryApi()
|
|
258
|
+
const made = await api.categories.create({ workspaceId: WS, name: 'Tools' })
|
|
259
|
+
expect((await api.categories.list({ workspaceId: WS })).map((c) => c.name)).toEqual([
|
|
260
|
+
'Laptops',
|
|
261
|
+
'Displays',
|
|
262
|
+
'Furniture',
|
|
263
|
+
'Cameras',
|
|
264
|
+
'Tools',
|
|
265
|
+
])
|
|
266
|
+
|
|
267
|
+
const all = await api.categories.list({ workspaceId: WS, archived: true })
|
|
268
|
+
const phones = all.find((c) => c.name === 'Phones')
|
|
269
|
+
await api.categories.archive({ workspaceId: WS, categoryId: phones?.id ?? '', archived: false })
|
|
270
|
+
const live = await api.categories.list({ workspaceId: WS })
|
|
271
|
+
expect(live.at(-1)?.name, 'restored at the end, not back on its old number').toBe('Phones')
|
|
272
|
+
expect(new Set(live.map((c) => c.order)).size, 'and no two live categories share a place').toBe(
|
|
273
|
+
live.length,
|
|
274
|
+
)
|
|
275
|
+
expect(made.name).toBe('Tools')
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Reordering, including both refusals — a demo that can only show the happy path cannot exercise
|
|
280
|
+
* the settings page's rollback, which is the half of the feature nobody sees until it matters.
|
|
281
|
+
*/
|
|
282
|
+
it('reorders the sequence, and refuses a list that no longer describes the workspace', async () => {
|
|
283
|
+
const api = createMockInventoryApi()
|
|
284
|
+
const live = await api.categories.list({ workspaceId: WS })
|
|
285
|
+
const ids = live.map((c) => c.id)
|
|
286
|
+
|
|
287
|
+
const moved = await api.categories.reorder({
|
|
288
|
+
workspaceId: WS,
|
|
289
|
+
categoryIds: [ids[3] as string, ...ids.slice(0, 3)],
|
|
290
|
+
})
|
|
291
|
+
expect(moved.map((c) => c.name)).toEqual(['Cameras', 'Laptops', 'Displays', 'Furniture'])
|
|
292
|
+
expect((await api.categories.list({ workspaceId: WS })).map((c) => c.name)).toEqual([
|
|
293
|
+
'Cameras',
|
|
294
|
+
'Laptops',
|
|
295
|
+
'Displays',
|
|
296
|
+
'Furniture',
|
|
297
|
+
])
|
|
298
|
+
|
|
299
|
+
await expect(
|
|
300
|
+
api.categories.reorder({ workspaceId: WS, categoryIds: ids.slice(0, 2) }),
|
|
301
|
+
).rejects.toMatchObject({ code: 'CONFLICT', data: { reason: 'inventory.category.order_stale' } })
|
|
302
|
+
|
|
303
|
+
await expect(
|
|
304
|
+
api.categories.reorder({
|
|
305
|
+
workspaceId: WS,
|
|
306
|
+
categoryIds: [...ids, '01920000-0000-7000-8000-0000000000ff'],
|
|
307
|
+
}),
|
|
308
|
+
).rejects.toMatchObject({ code: 'NOT_FOUND' })
|
|
309
|
+
|
|
310
|
+
// Neither refusal wrote anything.
|
|
311
|
+
expect((await api.categories.list({ workspaceId: WS })).map((c) => c.name)).toEqual([
|
|
312
|
+
'Cameras',
|
|
313
|
+
'Laptops',
|
|
314
|
+
'Displays',
|
|
315
|
+
'Furniture',
|
|
316
|
+
])
|
|
317
|
+
})
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Custody, held to the same invariant the server keeps.
|
|
322
|
+
*
|
|
323
|
+
* A demo that lets you assign the same laptop twice teaches somebody the product does, and the
|
|
324
|
+
* three refusals below are the ones a person actually meets.
|
|
325
|
+
*/
|
|
326
|
+
describe('mock custody', () => {
|
|
327
|
+
const freeAsset = async (api: ReturnType<typeof createMockInventoryApi>) => {
|
|
328
|
+
const { items } = await api.assets.list({ workspaceId: WS })
|
|
329
|
+
const free = items.find((a) => !a.custodianUserId)
|
|
330
|
+
expect(free).toBeTruthy()
|
|
331
|
+
return free as (typeof items)[number]
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
it('moves the custodian, the date and the status together on assign', async () => {
|
|
335
|
+
const api = createMockInventoryApi()
|
|
336
|
+
const asset = await freeAsset(api)
|
|
337
|
+
const { asset: after, period } = await api.custody.assign({
|
|
338
|
+
workspaceId: WS,
|
|
339
|
+
assetId: asset.id,
|
|
340
|
+
userId: '01920000-0000-7000-8000-000000000002',
|
|
341
|
+
note: 'For the shoot',
|
|
342
|
+
})
|
|
343
|
+
expect(after.custodianUserId).toBe('01920000-0000-7000-8000-000000000002')
|
|
344
|
+
expect(after.status).toBe('assigned')
|
|
345
|
+
expect(after.custodySince).toBeTruthy()
|
|
346
|
+
expect(period?.effectiveTo).toBe(null)
|
|
347
|
+
expect(period?.note).toBe('For the shoot')
|
|
348
|
+
})
|
|
349
|
+
|
|
350
|
+
it('clears all three on return, and opens nothing', async () => {
|
|
351
|
+
const api = createMockInventoryApi()
|
|
352
|
+
const asset = await freeAsset(api)
|
|
353
|
+
await api.custody.assign({
|
|
354
|
+
workspaceId: WS,
|
|
355
|
+
assetId: asset.id,
|
|
356
|
+
userId: '01920000-0000-7000-8000-000000000002',
|
|
357
|
+
})
|
|
358
|
+
const { asset: after, period } = await api.custody.return({ workspaceId: WS, assetId: asset.id })
|
|
359
|
+
expect(after.custodianUserId).toBe(null)
|
|
360
|
+
expect(after.custodySince).toBe(null)
|
|
361
|
+
expect(after.status).toBe('in_stock')
|
|
362
|
+
expect(period).toBe(null)
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
it('hands on in one step, leaving no stretch during which nobody held it', async () => {
|
|
366
|
+
const api = createMockInventoryApi()
|
|
367
|
+
const asset = await freeAsset(api)
|
|
368
|
+
await api.custody.assign({
|
|
369
|
+
workspaceId: WS,
|
|
370
|
+
assetId: asset.id,
|
|
371
|
+
userId: '01920000-0000-7000-8000-000000000002',
|
|
372
|
+
})
|
|
373
|
+
await api.custody.transfer({
|
|
374
|
+
workspaceId: WS,
|
|
375
|
+
assetId: asset.id,
|
|
376
|
+
userId: '01920000-0000-7000-8000-000000000003',
|
|
377
|
+
})
|
|
378
|
+
const periods = await api.custody.history({ workspaceId: WS, assetId: asset.id })
|
|
379
|
+
expect(periods).toHaveLength(2)
|
|
380
|
+
const [open, closed] = periods
|
|
381
|
+
expect(open?.effectiveTo).toBe(null)
|
|
382
|
+
// A return followed by an assign would leave a gap here, and a return nobody performed in the
|
|
383
|
+
// timeline. One instant for both halves is what makes them abut.
|
|
384
|
+
expect(closed?.effectiveTo).toBe(open?.effectiveFrom)
|
|
385
|
+
})
|
|
386
|
+
|
|
387
|
+
it('refuses the three things the server refuses', async () => {
|
|
388
|
+
const api = createMockInventoryApi()
|
|
389
|
+
const asset = await freeAsset(api)
|
|
390
|
+
const dan = '01920000-0000-7000-8000-000000000002'
|
|
391
|
+
await expect(
|
|
392
|
+
api.custody.transfer({ workspaceId: WS, assetId: asset.id, userId: dan }),
|
|
393
|
+
).rejects.toMatchObject({ code: 'CONFLICT' })
|
|
394
|
+
await expect(api.custody.return({ workspaceId: WS, assetId: asset.id })).rejects.toMatchObject({
|
|
395
|
+
code: 'CONFLICT',
|
|
396
|
+
})
|
|
397
|
+
await api.custody.assign({ workspaceId: WS, assetId: asset.id, userId: dan })
|
|
398
|
+
await expect(
|
|
399
|
+
api.custody.assign({ workspaceId: WS, assetId: asset.id, userId: dan }),
|
|
400
|
+
).rejects.toMatchObject({ code: 'CONFLICT' })
|
|
401
|
+
})
|
|
402
|
+
|
|
403
|
+
it('answers what one person is holding', async () => {
|
|
404
|
+
const api = createMockInventoryApi()
|
|
405
|
+
const dan = '01920000-0000-7000-8000-000000000002'
|
|
406
|
+
const { items } = await api.custody.byUser({ workspaceId: WS, userId: dan })
|
|
407
|
+
expect(items.map((a) => a.code)).toEqual(['INV-0001'])
|
|
408
|
+
})
|
|
409
|
+
})
|
|
410
|
+
|
|
411
|
+
describe('mock assets.history', () => {
|
|
412
|
+
it('reads newest first and records what each verb did', async () => {
|
|
413
|
+
const api = createMockInventoryApi()
|
|
414
|
+
const first = (await api.assets.list({ workspaceId: WS, sort: 'code' })).items[0]
|
|
415
|
+
const { items } = await api.assets.history({ workspaceId: WS, assetId: (first as { id: string }).id })
|
|
416
|
+
// Newest first, and the two repair entries are part of it: the seeded asset was bought,
|
|
417
|
+
// handed over, edited, handed on, and had its battery replaced.
|
|
418
|
+
expect(items.map((e) => e.action)).toEqual([
|
|
419
|
+
'repair_completed',
|
|
420
|
+
'repair_logged',
|
|
421
|
+
'transferred',
|
|
422
|
+
'updated',
|
|
423
|
+
'assigned',
|
|
424
|
+
'created',
|
|
425
|
+
])
|
|
426
|
+
// The diff is a field, a before and an after — what `Timeline.svelte` turns into a sentence.
|
|
427
|
+
expect(items.find((e) => e.action === 'updated')?.changes[0]).toMatchObject({
|
|
428
|
+
field: 'warrantyUntil',
|
|
429
|
+
to: '2027-03-14',
|
|
430
|
+
})
|
|
431
|
+
})
|
|
432
|
+
|
|
433
|
+
it('pages by id with the cursor the server issues, and then stops', async () => {
|
|
434
|
+
const api = createMockInventoryApi()
|
|
435
|
+
const first = (await api.assets.list({ workspaceId: WS, sort: 'code' })).items[0]
|
|
436
|
+
const id = (first as { id: string }).id
|
|
437
|
+
const seen: string[] = []
|
|
438
|
+
let cursor: string | undefined
|
|
439
|
+
for (let guard = 0; guard < 10; guard++) {
|
|
440
|
+
const page = await api.assets.history({
|
|
441
|
+
workspaceId: WS,
|
|
442
|
+
assetId: id,
|
|
443
|
+
limit: 2,
|
|
444
|
+
...(cursor ? { cursor } : {}),
|
|
445
|
+
})
|
|
446
|
+
seen.push(...page.items.map((e) => e.id))
|
|
447
|
+
if (!page.nextCursor) break
|
|
448
|
+
cursor = page.nextCursor
|
|
449
|
+
}
|
|
450
|
+
expect(seen).toHaveLength(6)
|
|
451
|
+
expect(new Set(seen).size).toBe(seen.length)
|
|
452
|
+
})
|
|
453
|
+
|
|
454
|
+
it('refuses a page marker it did not issue', async () => {
|
|
455
|
+
const api = createMockInventoryApi()
|
|
456
|
+
const first = (await api.assets.list({ workspaceId: WS })).items[0]
|
|
457
|
+
await expect(
|
|
458
|
+
api.assets.history({ workspaceId: WS, assetId: (first as { id: string }).id, cursor: 'nope' }),
|
|
459
|
+
).rejects.toMatchObject({ code: 'BAD_REQUEST' })
|
|
460
|
+
})
|
|
461
|
+
})
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Repairs, and the one rule a demo gets wrong by accident: a repair is not custody.
|
|
465
|
+
*
|
|
466
|
+
* Every assertion here has a matching one in `src/server/inventory.int.test.ts`. A demo that shows
|
|
467
|
+
* an item as back in the office the moment its repair is completed — while somebody still holds it
|
|
468
|
+
* — teaches an audience that the product releases people from what they are answerable for.
|
|
469
|
+
*/
|
|
470
|
+
describe('mock repairs', () => {
|
|
471
|
+
const inStock = async (api: ReturnType<typeof createMockInventoryApi>) =>
|
|
472
|
+
(await api.assets.list({ workspaceId: WS, status: 'in_stock' })).items[0] as { id: string }
|
|
473
|
+
|
|
474
|
+
it('seeds an open repair for the item the seeds call under_repair', async () => {
|
|
475
|
+
const api = createMockInventoryApi()
|
|
476
|
+
const away = (await api.assets.list({ workspaceId: WS, status: 'under_repair' })).items
|
|
477
|
+
expect(away.map((a) => a.code)).toEqual(['INV-0003'])
|
|
478
|
+
const { items } = await api.repairs.list({ workspaceId: WS, open: true })
|
|
479
|
+
// A status column and a Repairs tab that disagree about the same item is the failure this
|
|
480
|
+
// seeding exists to prevent.
|
|
481
|
+
expect(items.map((r) => r.assetId)).toEqual([away[0]?.id])
|
|
482
|
+
expect(items[0]?.assetCode).toBe('INV-0003')
|
|
483
|
+
})
|
|
484
|
+
|
|
485
|
+
it('sends an item away, and refuses a second one while it is gone', async () => {
|
|
486
|
+
const api = createMockInventoryApi()
|
|
487
|
+
const asset = await inStock(api)
|
|
488
|
+
const { asset: away } = await api.repairs.create({
|
|
489
|
+
workspaceId: WS,
|
|
490
|
+
assetId: asset.id,
|
|
491
|
+
summary: 'Wobbly hinge',
|
|
492
|
+
})
|
|
493
|
+
expect(away.status).toBe('under_repair')
|
|
494
|
+
await expect(
|
|
495
|
+
api.repairs.create({ workspaceId: WS, assetId: asset.id, summary: 'Again' }),
|
|
496
|
+
).rejects.toMatchObject({ code: 'CONFLICT' })
|
|
497
|
+
})
|
|
498
|
+
|
|
499
|
+
it('puts an item back to whoever still holds it, not into stock', async () => {
|
|
500
|
+
const api = createMockInventoryApi()
|
|
501
|
+
const dan = '01920000-0000-7000-8000-000000000002'
|
|
502
|
+
const asset = await inStock(api)
|
|
503
|
+
await api.custody.assign({ workspaceId: WS, assetId: asset.id, userId: dan })
|
|
504
|
+
const { repair } = await api.repairs.create({
|
|
505
|
+
workspaceId: WS,
|
|
506
|
+
assetId: asset.id,
|
|
507
|
+
summary: 'New battery',
|
|
508
|
+
})
|
|
509
|
+
// Still Dan's while it is away — a repair moves where a thing is, not who answers for it.
|
|
510
|
+
const during = await api.assets.get({ workspaceId: WS, assetId: asset.id })
|
|
511
|
+
expect({ status: during.status, holder: during.custodianUserId }).toEqual({
|
|
512
|
+
status: 'under_repair',
|
|
513
|
+
holder: dan,
|
|
514
|
+
})
|
|
515
|
+
const { asset: back } = await api.repairs.complete({ workspaceId: WS, repairId: repair.id })
|
|
516
|
+
expect({ status: back.status, holder: back.custodianUserId }).toEqual({
|
|
517
|
+
status: 'assigned',
|
|
518
|
+
holder: dan,
|
|
519
|
+
})
|
|
520
|
+
})
|
|
521
|
+
|
|
522
|
+
it('refuses completing the same repair twice, and a return before the send', async () => {
|
|
523
|
+
const api = createMockInventoryApi()
|
|
524
|
+
const asset = await inStock(api)
|
|
525
|
+
const { repair } = await api.repairs.create({
|
|
526
|
+
workspaceId: WS,
|
|
527
|
+
assetId: asset.id,
|
|
528
|
+
summary: 'Cracked case',
|
|
529
|
+
sentOn: '2026-06-01',
|
|
530
|
+
})
|
|
531
|
+
await expect(
|
|
532
|
+
api.repairs.complete({ workspaceId: WS, repairId: repair.id, returnedOn: '2026-05-01' }),
|
|
533
|
+
).rejects.toMatchObject({ code: 'CONFLICT' })
|
|
534
|
+
await api.repairs.complete({ workspaceId: WS, repairId: repair.id, returnedOn: '2026-06-10' })
|
|
535
|
+
await expect(api.repairs.complete({ workspaceId: WS, repairId: repair.id })).rejects.toMatchObject({
|
|
536
|
+
code: 'CONFLICT',
|
|
537
|
+
})
|
|
538
|
+
})
|
|
539
|
+
|
|
540
|
+
it('refuses archiving something that is away for repair', async () => {
|
|
541
|
+
const api = createMockInventoryApi()
|
|
542
|
+
const asset = await inStock(api)
|
|
543
|
+
await api.repairs.create({ workspaceId: WS, assetId: asset.id, summary: 'Screen' })
|
|
544
|
+
await expect(
|
|
545
|
+
api.assets.archive({ workspaceId: WS, assetId: asset.id, archived: true }),
|
|
546
|
+
).rejects.toMatchObject({ code: 'CONFLICT' })
|
|
547
|
+
})
|
|
548
|
+
})
|
|
549
|
+
|
|
550
|
+
describe('mock attachments', () => {
|
|
551
|
+
it('keeps a repair’s paperwork under the repair and the rest under the asset', async () => {
|
|
552
|
+
const api = createMockInventoryApi()
|
|
553
|
+
const first = (await api.assets.list({ workspaceId: WS, sort: 'code' })).items[0] as { id: string }
|
|
554
|
+
const rows = await api.attachments.list({ workspaceId: WS, assetId: first.id })
|
|
555
|
+
expect(rows.map((r) => r.name)).toEqual(['Purchase receipt.pdf', 'Repair invoice.pdf'])
|
|
556
|
+
// One key, one query, grouped in the browser — which is what the panel does.
|
|
557
|
+
expect(rows.filter((r) => r.repairId === null)).toHaveLength(1)
|
|
558
|
+
expect(rows.filter((r) => r.repairId !== null)).toHaveLength(1)
|
|
559
|
+
})
|
|
560
|
+
|
|
561
|
+
it('adds a file once however many times the same id arrives', async () => {
|
|
562
|
+
const api = createMockInventoryApi()
|
|
563
|
+
const first = (await api.assets.list({ workspaceId: WS, sort: 'code' })).items[0] as { id: string }
|
|
564
|
+
const fileId = '01920000-0000-7000-8007-0000000000aa'
|
|
565
|
+
const added = await api.attachments.add({ workspaceId: WS, assetId: first.id, fileIds: [fileId] })
|
|
566
|
+
expect(added).toHaveLength(1)
|
|
567
|
+
// The server's `on conflict do nothing`: pressing the button twice adds nothing the second time
|
|
568
|
+
// rather than failing at somebody.
|
|
569
|
+
expect(await api.attachments.add({ workspaceId: WS, assetId: first.id, fileIds: [fileId] })).toEqual([])
|
|
570
|
+
})
|
|
571
|
+
|
|
572
|
+
it('detaches one file and leaves the rest', async () => {
|
|
573
|
+
const api = createMockInventoryApi()
|
|
574
|
+
const first = (await api.assets.list({ workspaceId: WS, sort: 'code' })).items[0] as { id: string }
|
|
575
|
+
const rows = await api.attachments.list({ workspaceId: WS, assetId: first.id })
|
|
576
|
+
const target = rows[0] as { id: string }
|
|
577
|
+
expect(await api.attachments.remove({ workspaceId: WS, attachmentId: target.id })).toEqual({
|
|
578
|
+
id: target.id,
|
|
579
|
+
})
|
|
580
|
+
const left = await api.attachments.list({ workspaceId: WS, assetId: first.id })
|
|
581
|
+
expect(left.map((r) => r.id)).not.toContain(target.id)
|
|
582
|
+
expect(left).toHaveLength(rows.length - 1)
|
|
583
|
+
})
|
|
584
|
+
})
|
|
585
|
+
|
|
586
|
+
describe('mock stats.summary', () => {
|
|
587
|
+
it('counts live rows, keeps archived ones beside them, and zero-fills every status', async () => {
|
|
588
|
+
const api = createMockInventoryApi()
|
|
589
|
+
const stats = await api.stats.summary({ workspaceId: WS })
|
|
590
|
+
// Five live seeds and one archived — the same split the default list shows.
|
|
591
|
+
expect({ total: stats.total, archived: stats.archived }).toEqual({ total: 5, archived: 1 })
|
|
592
|
+
expect(Object.keys(stats.byStatus).sort()).toEqual([
|
|
593
|
+
'assigned',
|
|
594
|
+
'in_stock',
|
|
595
|
+
'lost',
|
|
596
|
+
'reserved',
|
|
597
|
+
'retired',
|
|
598
|
+
'under_repair',
|
|
599
|
+
])
|
|
600
|
+
expect(stats.byStatus.lost).toBe(0)
|
|
601
|
+
})
|
|
602
|
+
|
|
603
|
+
it('agrees with the status column about what is away', async () => {
|
|
604
|
+
const api = createMockInventoryApi()
|
|
605
|
+
const stats = await api.stats.summary({ workspaceId: WS })
|
|
606
|
+
// Two ways of asking one question — the cached status column and the repair rows — and they
|
|
607
|
+
// agree because the status is derived from those rows rather than written beside them.
|
|
608
|
+
expect(stats.outForRepair).toBe(stats.byStatus.under_repair)
|
|
609
|
+
})
|
|
610
|
+
|
|
611
|
+
it('counts what nobody is holding', async () => {
|
|
612
|
+
const api = createMockInventoryApi()
|
|
613
|
+
const before = await api.stats.summary({ workspaceId: WS })
|
|
614
|
+
const free = (await api.assets.list({ workspaceId: WS, status: 'in_stock' })).items[0] as {
|
|
615
|
+
id: string
|
|
616
|
+
}
|
|
617
|
+
await api.custody.assign({
|
|
618
|
+
workspaceId: WS,
|
|
619
|
+
assetId: free.id,
|
|
620
|
+
userId: '01920000-0000-7000-8000-000000000002',
|
|
621
|
+
})
|
|
622
|
+
const after = await api.stats.summary({ workspaceId: WS })
|
|
623
|
+
expect(after.unassigned).toBe(before.unassigned - 1)
|
|
624
|
+
})
|
|
161
625
|
})
|