@kernhq/module-inventory 0.1.2 → 0.3.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 +134 -9
- package/dist/contract/capabilities.d.ts +49 -0
- package/dist/contract/capabilities.d.ts.map +1 -0
- package/dist/contract/capabilities.js +94 -0
- package/dist/contract/capabilities.js.map +1 -0
- package/dist/contract/events.d.ts +76 -0
- package/dist/contract/events.d.ts.map +1 -0
- package/dist/contract/events.js +62 -0
- package/dist/contract/events.js.map +1 -0
- package/dist/contract/index.d.ts +16 -0
- package/dist/contract/index.d.ts.map +1 -0
- package/dist/contract/index.js +16 -0
- package/dist/contract/index.js.map +1 -0
- package/dist/contract/models.d.ts +468 -0
- package/dist/contract/models.d.ts.map +1 -0
- package/dist/contract/models.js +320 -0
- package/dist/contract/models.js.map +1 -0
- 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 +72 -0
- package/dist/contract/permissions.d.ts.map +1 -0
- package/dist/contract/permissions.js +79 -0
- package/dist/contract/permissions.js.map +1 -0
- package/dist/contract/router.d.ts +1378 -0
- package/dist/contract/router.d.ts.map +1 -0
- package/dist/contract/router.js +266 -0
- package/dist/contract/router.js.map +1 -0
- package/dist/contract/settings.d.ts +20 -0
- package/dist/contract/settings.d.ts.map +1 -0
- package/dist/contract/settings.js +49 -0
- package/dist/contract/settings.js.map +1 -0
- package/dist/server/index.d.ts +8 -1
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +288 -8
- 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 +1789 -0
- package/dist/server/router.d.ts.map +1 -0
- package/dist/server/router.js +439 -0
- package/dist/server/router.js.map +1 -0
- package/dist/server/schema.d.ts +156 -9
- package/dist/server/schema.d.ts.map +1 -1
- package/dist/server/schema.js +183 -11
- package/dist/server/schema.js.map +1 -1
- package/dist/server/services/assets.d.ts +139 -0
- package/dist/server/services/assets.d.ts.map +1 -0
- package/dist/server/services/assets.js +429 -0
- package/dist/server/services/assets.js.map +1 -0
- 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 +57 -0
- package/dist/server/services/categories.d.ts.map +1 -0
- package/dist/server/services/categories.js +124 -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 +26 -0
- package/dist/server/services/index.d.ts.map +1 -0
- package/dist/server/services/index.js +39 -0
- package/dist/server/services/index.js.map +1 -0
- 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 +105 -0
- package/dist/server/services/notify.d.ts.map +1 -0
- package/dist/server/services/notify.js +147 -0
- package/dist/server/services/notify.js.map +1 -0
- 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/0000_init.sql +12 -3
- package/migrations/0001_rls.sql +24 -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/meta/0000_snapshot.json +40 -13
- 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 +44 -2
- package/package.json +5 -4
- package/src/client/api-instance.ts +27 -2
- package/src/client/api.ts +1 -1
- 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 +191 -59
- 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 +365 -0
- package/src/client/errors.ts +201 -0
- package/src/client/i18n.ts +11 -166
- package/src/client/index.ts +8 -1
- 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 +296 -0
- package/src/client/messages.ts +1424 -0
- package/src/client/mock.test.ts +555 -0
- package/src/client/mock.ts +1261 -52
- package/src/client/module.ts +76 -2
- package/src/client/pages/AssetsPage.svelte +638 -145
- package/src/client/permissions.ts +8 -1
- package/src/client/price.test.ts +254 -0
- package/src/client/price.ts +279 -0
- package/src/client/query.test.ts +58 -0
- package/src/client/query.ts +51 -2
- package/src/client/repairs.test.ts +38 -0
- package/src/client/repairs.ts +38 -0
- package/src/client/settings/CategoriesSettings.svelte +421 -0
- package/src/client/settings/GeneralSettings.svelte +403 -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 +140 -26
- package/src/client/widgets/RepairsWidget.svelte +124 -0
- package/src/contract/capabilities.ts +99 -0
- package/src/contract/events.ts +83 -0
- package/src/contract/index.ts +16 -0
- package/src/contract/models.ts +360 -0
- package/src/contract/notifications.ts +73 -0
- package/src/contract/permissions.ts +79 -0
- package/src/contract/router.ts +300 -0
- package/src/contract/settings.ts +50 -0
- package/src/module.test.ts +330 -7
- package/src/server/index.ts +318 -8
- package/src/server/inventory.int.test.ts +4374 -0
- package/src/server/jobs.ts +444 -0
- package/src/server/migrations.test.ts +251 -0
- package/src/server/router.ts +574 -0
- package/src/server/schema.ts +184 -10
- package/src/server/services/assets.ts +528 -0
- package/src/server/services/attachments.ts +215 -0
- package/src/server/services/audience.ts +77 -0
- package/src/server/services/categories.ts +136 -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 +56 -0
- package/src/server/services/members.ts +54 -0
- package/src/server/services/notify.ts +196 -0
- 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/tsconfig.base.json +22 -0
- package/tsconfig.client.json +1 -1
- package/tsconfig.json +1 -1
- package/vitest.config.ts +18 -3
- package/dist/contract.d.ts +0 -387
- package/dist/contract.d.ts.map +0 -1
- package/dist/contract.js +0 -119
- package/dist/contract.js.map +0 -1
- package/dist/server/_impl.d.ts +0 -427
- package/dist/server/_impl.d.ts.map +0 -1
- package/dist/server/_impl.js +0 -204
- package/dist/server/_impl.js.map +0 -1
- package/src/contract.ts +0 -143
- package/src/server/_impl.ts +0 -275
|
@@ -0,0 +1,555 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { inventoryContract } from '../contract/index.js'
|
|
3
|
+
import { createMockInventoryApi } from './mock.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The demo, held to the server's behaviour.
|
|
7
|
+
*
|
|
8
|
+
* A mock is the environment the product is *shown* in, so a mock that disagrees with the server is
|
|
9
|
+
* a demo that argues with its own controls — and every disagreement here was a real one: seed order
|
|
10
|
+
* where the server sorts newest first, `INV-0013` for the seventh asset, `sort`/`categoryId`/
|
|
11
|
+
* `custodianUserId` ignored, and an unrecognised cursor quietly serving page one for ever.
|
|
12
|
+
*
|
|
13
|
+
* The cursor assertions are the load-bearing ones. `src/server/services/assets.ts` answers a marker
|
|
14
|
+
* it did not issue with `BAD_REQUEST`; a mock that answers it with page one turns a bug into an
|
|
15
|
+
* endless "Load more" that nobody meets until production.
|
|
16
|
+
*/
|
|
17
|
+
const WS = '01920000-0000-7000-8000-0000000000ff'
|
|
18
|
+
|
|
19
|
+
const codes = (items: { code: string }[]) => items.map((a) => a.code)
|
|
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
|
+
|
|
46
|
+
describe('mock assets.list', () => {
|
|
47
|
+
it('opens on what was added last, like the server’s default sort', async () => {
|
|
48
|
+
const api = createMockInventoryApi()
|
|
49
|
+
const { items } = await api.assets.list({ workspaceId: WS })
|
|
50
|
+
// INV-0006 is archived and therefore absent; the rest are newest first.
|
|
51
|
+
expect(codes(items)).toEqual(['INV-0005', 'INV-0004', 'INV-0003', 'INV-0002', 'INV-0001'])
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('honours sort=code and sort=name, which it used to ignore', async () => {
|
|
55
|
+
const api = createMockInventoryApi()
|
|
56
|
+
expect(codes((await api.assets.list({ workspaceId: WS, sort: 'code' })).items)).toEqual([
|
|
57
|
+
'INV-0001',
|
|
58
|
+
'INV-0002',
|
|
59
|
+
'INV-0003',
|
|
60
|
+
'INV-0004',
|
|
61
|
+
'INV-0005',
|
|
62
|
+
])
|
|
63
|
+
const byName = (await api.assets.list({ workspaceId: WS, sort: 'name' })).items
|
|
64
|
+
expect(byName.map((a) => a.name)).toEqual([...byName.map((a) => a.name)].sort())
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('honours the archived and status filters', async () => {
|
|
68
|
+
const api = createMockInventoryApi()
|
|
69
|
+
expect(codes((await api.assets.list({ workspaceId: WS, archived: true })).items)).toContain('INV-0006')
|
|
70
|
+
const assigned = await api.assets.list({ workspaceId: WS, status: 'assigned' })
|
|
71
|
+
expect(assigned.items.every((a) => a.status === 'assigned')).toBe(true)
|
|
72
|
+
expect(assigned.items).toHaveLength(2)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('honours categoryId and custodianUserId, which it used to ignore', async () => {
|
|
76
|
+
const api = createMockInventoryApi()
|
|
77
|
+
const none = await api.assets.list({
|
|
78
|
+
workspaceId: WS,
|
|
79
|
+
categoryId: '01920000-0000-7000-8000-00000000aaaa',
|
|
80
|
+
})
|
|
81
|
+
expect(none.items).toEqual([])
|
|
82
|
+
const nobody = await api.assets.list({
|
|
83
|
+
workspaceId: WS,
|
|
84
|
+
custodianUserId: '01920000-0000-7000-8000-00000000bbbb',
|
|
85
|
+
})
|
|
86
|
+
expect(nobody.items).toEqual([])
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('pages through every row exactly once and then stops', async () => {
|
|
90
|
+
const api = createMockInventoryApi()
|
|
91
|
+
const seen: string[] = []
|
|
92
|
+
let cursor: string | undefined
|
|
93
|
+
for (let guard = 0; guard < 20; guard++) {
|
|
94
|
+
const page = await api.assets.list({ workspaceId: WS, limit: 2, ...(cursor ? { cursor } : {}) })
|
|
95
|
+
seen.push(...codes(page.items))
|
|
96
|
+
if (!page.nextCursor) break
|
|
97
|
+
cursor = page.nextCursor
|
|
98
|
+
}
|
|
99
|
+
expect(seen).toEqual(['INV-0005', 'INV-0004', 'INV-0003', 'INV-0002', 'INV-0001'])
|
|
100
|
+
expect(new Set(seen).size).toBe(seen.length)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('reports no next page when the last page is exactly full', async () => {
|
|
104
|
+
const api = createMockInventoryApi()
|
|
105
|
+
const page = await api.assets.list({ workspaceId: WS, limit: 5 })
|
|
106
|
+
expect(page.items).toHaveLength(5)
|
|
107
|
+
expect(page.nextCursor).toBe(null)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('refuses a page marker it did not issue, rather than serving page one for ever', async () => {
|
|
111
|
+
const api = createMockInventoryApi()
|
|
112
|
+
await expect(api.assets.list({ workspaceId: WS, cursor: 'not-a-cursor' })).rejects.toMatchObject({
|
|
113
|
+
code: 'BAD_REQUEST',
|
|
114
|
+
})
|
|
115
|
+
await expect(
|
|
116
|
+
api.assets.list({ workspaceId: WS, cursor: btoa(JSON.stringify({ i: 'nope', s: 'recent' })) }),
|
|
117
|
+
).rejects.toMatchObject({ code: 'BAD_REQUEST' })
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('refuses a marker issued under a different sort', async () => {
|
|
121
|
+
const api = createMockInventoryApi()
|
|
122
|
+
const first = await api.assets.list({ workspaceId: WS, limit: 2, sort: 'recent' })
|
|
123
|
+
expect(first.nextCursor).toBeTruthy()
|
|
124
|
+
await expect(
|
|
125
|
+
api.assets.list({ workspaceId: WS, limit: 2, sort: 'code', cursor: first.nextCursor as string }),
|
|
126
|
+
).rejects.toMatchObject({ code: 'BAD_REQUEST' })
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('ends the list when the bookmarked row is gone, rather than failing', async () => {
|
|
130
|
+
const api = createMockInventoryApi()
|
|
131
|
+
const first = await api.assets.list({ workspaceId: WS, limit: 2 })
|
|
132
|
+
const bookmarked = first.items.at(-1)
|
|
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 })
|
|
140
|
+
// Archiving the bookmarked row still leaves it findable by id, so the page continues; a row the
|
|
141
|
+
// mock genuinely cannot find is the "ends quietly" branch, and an unknown *id* is refused above.
|
|
142
|
+
await api.assets.archive({ assetId: id })
|
|
143
|
+
const next = await api.assets.list({ workspaceId: WS, limit: 2, cursor: first.nextCursor as string })
|
|
144
|
+
expect(next.items.every((a) => !codes(first.items).includes(a.code))).toBe(true)
|
|
145
|
+
})
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
describe('mock assets.create', () => {
|
|
149
|
+
it('continues the tag sequence from the seeds', async () => {
|
|
150
|
+
const api = createMockInventoryApi()
|
|
151
|
+
// It issued INV-0013 here: one counter was numbering both the ids and the tags.
|
|
152
|
+
expect((await api.assets.create({ workspaceId: WS, name: 'Standing desk' })).code).toBe('INV-0007')
|
|
153
|
+
expect((await api.assets.create({ workspaceId: WS, name: 'Label printer' })).code).toBe('INV-0008')
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('puts what was just created at the top of the default list', async () => {
|
|
157
|
+
const api = createMockInventoryApi()
|
|
158
|
+
await api.assets.create({ workspaceId: WS, name: 'Standing desk' })
|
|
159
|
+
const { items } = await api.assets.list({ workspaceId: WS })
|
|
160
|
+
expect(items[0]?.name).toBe('Standing desk')
|
|
161
|
+
})
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
describe('mock assets.update and archive', () => {
|
|
165
|
+
it('patches only what it was given', async () => {
|
|
166
|
+
const api = createMockInventoryApi()
|
|
167
|
+
const [first] = (await api.assets.list({ workspaceId: WS })).items
|
|
168
|
+
const updated = await api.assets.update({
|
|
169
|
+
workspaceId: WS,
|
|
170
|
+
assetId: (first as { id: string }).id,
|
|
171
|
+
name: 'Renamed',
|
|
172
|
+
})
|
|
173
|
+
expect(updated.name).toBe('Renamed')
|
|
174
|
+
expect(updated.code).toBe(first?.code)
|
|
175
|
+
// Routing, not a field of the asset: a patch must not be able to move a row between workspaces.
|
|
176
|
+
expect(updated.workspaceId).toBe(first?.workspaceId)
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it('archives and restores the same row', async () => {
|
|
180
|
+
const api = createMockInventoryApi()
|
|
181
|
+
const [first] = (await api.assets.list({ workspaceId: WS })).items
|
|
182
|
+
const id = (first as { id: string }).id
|
|
183
|
+
expect((await api.assets.archive({ assetId: id })).archivedAt).toBeTruthy()
|
|
184
|
+
expect(codes((await api.assets.list({ workspaceId: WS })).items)).not.toContain(first?.code)
|
|
185
|
+
expect((await api.assets.archive({ assetId: id, archived: false })).archivedAt).toBe(null)
|
|
186
|
+
expect(codes((await api.assets.list({ workspaceId: WS })).items)).toContain(first?.code)
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
it('reports a missing asset as NOT_FOUND', async () => {
|
|
190
|
+
const api = createMockInventoryApi()
|
|
191
|
+
await expect(api.assets.get({ assetId: 'nope' })).rejects.toMatchObject({ code: 'NOT_FOUND' })
|
|
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', order: 9 })
|
|
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
|
+
/**
|
|
251
|
+
* Custody, held to the same invariant the server keeps.
|
|
252
|
+
*
|
|
253
|
+
* A demo that lets you assign the same laptop twice teaches somebody the product does, and the
|
|
254
|
+
* three refusals below are the ones a person actually meets.
|
|
255
|
+
*/
|
|
256
|
+
describe('mock custody', () => {
|
|
257
|
+
const freeAsset = async (api: ReturnType<typeof createMockInventoryApi>) => {
|
|
258
|
+
const { items } = await api.assets.list({ workspaceId: WS })
|
|
259
|
+
const free = items.find((a) => !a.custodianUserId)
|
|
260
|
+
expect(free).toBeTruthy()
|
|
261
|
+
return free as (typeof items)[number]
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
it('moves the custodian, the date and the status together on assign', async () => {
|
|
265
|
+
const api = createMockInventoryApi()
|
|
266
|
+
const asset = await freeAsset(api)
|
|
267
|
+
const { asset: after, period } = await api.custody.assign({
|
|
268
|
+
workspaceId: WS,
|
|
269
|
+
assetId: asset.id,
|
|
270
|
+
userId: '01920000-0000-7000-8000-000000000002',
|
|
271
|
+
note: 'For the shoot',
|
|
272
|
+
})
|
|
273
|
+
expect(after.custodianUserId).toBe('01920000-0000-7000-8000-000000000002')
|
|
274
|
+
expect(after.status).toBe('assigned')
|
|
275
|
+
expect(after.custodySince).toBeTruthy()
|
|
276
|
+
expect(period?.effectiveTo).toBe(null)
|
|
277
|
+
expect(period?.note).toBe('For the shoot')
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
it('clears all three on return, and opens nothing', async () => {
|
|
281
|
+
const api = createMockInventoryApi()
|
|
282
|
+
const asset = await freeAsset(api)
|
|
283
|
+
await api.custody.assign({
|
|
284
|
+
workspaceId: WS,
|
|
285
|
+
assetId: asset.id,
|
|
286
|
+
userId: '01920000-0000-7000-8000-000000000002',
|
|
287
|
+
})
|
|
288
|
+
const { asset: after, period } = await api.custody.return({ workspaceId: WS, assetId: asset.id })
|
|
289
|
+
expect(after.custodianUserId).toBe(null)
|
|
290
|
+
expect(after.custodySince).toBe(null)
|
|
291
|
+
expect(after.status).toBe('in_stock')
|
|
292
|
+
expect(period).toBe(null)
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
it('hands on in one step, leaving no stretch during which nobody held it', async () => {
|
|
296
|
+
const api = createMockInventoryApi()
|
|
297
|
+
const asset = await freeAsset(api)
|
|
298
|
+
await api.custody.assign({
|
|
299
|
+
workspaceId: WS,
|
|
300
|
+
assetId: asset.id,
|
|
301
|
+
userId: '01920000-0000-7000-8000-000000000002',
|
|
302
|
+
})
|
|
303
|
+
await api.custody.transfer({
|
|
304
|
+
workspaceId: WS,
|
|
305
|
+
assetId: asset.id,
|
|
306
|
+
userId: '01920000-0000-7000-8000-000000000003',
|
|
307
|
+
})
|
|
308
|
+
const periods = await api.custody.history({ workspaceId: WS, assetId: asset.id })
|
|
309
|
+
expect(periods).toHaveLength(2)
|
|
310
|
+
const [open, closed] = periods
|
|
311
|
+
expect(open?.effectiveTo).toBe(null)
|
|
312
|
+
// A return followed by an assign would leave a gap here, and a return nobody performed in the
|
|
313
|
+
// timeline. One instant for both halves is what makes them abut.
|
|
314
|
+
expect(closed?.effectiveTo).toBe(open?.effectiveFrom)
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
it('refuses the three things the server refuses', async () => {
|
|
318
|
+
const api = createMockInventoryApi()
|
|
319
|
+
const asset = await freeAsset(api)
|
|
320
|
+
const dan = '01920000-0000-7000-8000-000000000002'
|
|
321
|
+
await expect(
|
|
322
|
+
api.custody.transfer({ workspaceId: WS, assetId: asset.id, userId: dan }),
|
|
323
|
+
).rejects.toMatchObject({ code: 'CONFLICT' })
|
|
324
|
+
await expect(api.custody.return({ workspaceId: WS, assetId: asset.id })).rejects.toMatchObject({
|
|
325
|
+
code: 'CONFLICT',
|
|
326
|
+
})
|
|
327
|
+
await api.custody.assign({ workspaceId: WS, assetId: asset.id, userId: dan })
|
|
328
|
+
await expect(
|
|
329
|
+
api.custody.assign({ workspaceId: WS, assetId: asset.id, userId: dan }),
|
|
330
|
+
).rejects.toMatchObject({ code: 'CONFLICT' })
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
it('answers what one person is holding', async () => {
|
|
334
|
+
const api = createMockInventoryApi()
|
|
335
|
+
const dan = '01920000-0000-7000-8000-000000000002'
|
|
336
|
+
const { items } = await api.custody.byUser({ workspaceId: WS, userId: dan })
|
|
337
|
+
expect(items.map((a) => a.code)).toEqual(['INV-0001'])
|
|
338
|
+
})
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
describe('mock assets.history', () => {
|
|
342
|
+
it('reads newest first and records what each verb did', async () => {
|
|
343
|
+
const api = createMockInventoryApi()
|
|
344
|
+
const first = (await api.assets.list({ workspaceId: WS, sort: 'code' })).items[0]
|
|
345
|
+
const { items } = await api.assets.history({ workspaceId: WS, assetId: (first as { id: string }).id })
|
|
346
|
+
// Newest first, and the two repair entries are part of it: the seeded asset was bought,
|
|
347
|
+
// handed over, edited, handed on, and had its battery replaced.
|
|
348
|
+
expect(items.map((e) => e.action)).toEqual([
|
|
349
|
+
'repair_completed',
|
|
350
|
+
'repair_logged',
|
|
351
|
+
'transferred',
|
|
352
|
+
'updated',
|
|
353
|
+
'assigned',
|
|
354
|
+
'created',
|
|
355
|
+
])
|
|
356
|
+
// The diff is a field, a before and an after — what `Timeline.svelte` turns into a sentence.
|
|
357
|
+
expect(items.find((e) => e.action === 'updated')?.changes[0]).toMatchObject({
|
|
358
|
+
field: 'warrantyUntil',
|
|
359
|
+
to: '2027-03-14',
|
|
360
|
+
})
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
it('pages by id with the cursor the server issues, and then stops', async () => {
|
|
364
|
+
const api = createMockInventoryApi()
|
|
365
|
+
const first = (await api.assets.list({ workspaceId: WS, sort: 'code' })).items[0]
|
|
366
|
+
const id = (first as { id: string }).id
|
|
367
|
+
const seen: string[] = []
|
|
368
|
+
let cursor: string | undefined
|
|
369
|
+
for (let guard = 0; guard < 10; guard++) {
|
|
370
|
+
const page = await api.assets.history({
|
|
371
|
+
workspaceId: WS,
|
|
372
|
+
assetId: id,
|
|
373
|
+
limit: 2,
|
|
374
|
+
...(cursor ? { cursor } : {}),
|
|
375
|
+
})
|
|
376
|
+
seen.push(...page.items.map((e) => e.id))
|
|
377
|
+
if (!page.nextCursor) break
|
|
378
|
+
cursor = page.nextCursor
|
|
379
|
+
}
|
|
380
|
+
expect(seen).toHaveLength(6)
|
|
381
|
+
expect(new Set(seen).size).toBe(seen.length)
|
|
382
|
+
})
|
|
383
|
+
|
|
384
|
+
it('refuses a page marker it did not issue', async () => {
|
|
385
|
+
const api = createMockInventoryApi()
|
|
386
|
+
const first = (await api.assets.list({ workspaceId: WS })).items[0]
|
|
387
|
+
await expect(
|
|
388
|
+
api.assets.history({ workspaceId: WS, assetId: (first as { id: string }).id, cursor: 'nope' }),
|
|
389
|
+
).rejects.toMatchObject({ code: 'BAD_REQUEST' })
|
|
390
|
+
})
|
|
391
|
+
})
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Repairs, and the one rule a demo gets wrong by accident: a repair is not custody.
|
|
395
|
+
*
|
|
396
|
+
* Every assertion here has a matching one in `src/server/inventory.int.test.ts`. A demo that shows
|
|
397
|
+
* an item as back in the office the moment its repair is completed — while somebody still holds it
|
|
398
|
+
* — teaches an audience that the product releases people from what they are answerable for.
|
|
399
|
+
*/
|
|
400
|
+
describe('mock repairs', () => {
|
|
401
|
+
const inStock = async (api: ReturnType<typeof createMockInventoryApi>) =>
|
|
402
|
+
(await api.assets.list({ workspaceId: WS, status: 'in_stock' })).items[0] as { id: string }
|
|
403
|
+
|
|
404
|
+
it('seeds an open repair for the item the seeds call under_repair', async () => {
|
|
405
|
+
const api = createMockInventoryApi()
|
|
406
|
+
const away = (await api.assets.list({ workspaceId: WS, status: 'under_repair' })).items
|
|
407
|
+
expect(away.map((a) => a.code)).toEqual(['INV-0003'])
|
|
408
|
+
const { items } = await api.repairs.list({ workspaceId: WS, open: true })
|
|
409
|
+
// A status column and a Repairs tab that disagree about the same item is the failure this
|
|
410
|
+
// seeding exists to prevent.
|
|
411
|
+
expect(items.map((r) => r.assetId)).toEqual([away[0]?.id])
|
|
412
|
+
expect(items[0]?.assetCode).toBe('INV-0003')
|
|
413
|
+
})
|
|
414
|
+
|
|
415
|
+
it('sends an item away, and refuses a second one while it is gone', async () => {
|
|
416
|
+
const api = createMockInventoryApi()
|
|
417
|
+
const asset = await inStock(api)
|
|
418
|
+
const { asset: away } = await api.repairs.create({
|
|
419
|
+
workspaceId: WS,
|
|
420
|
+
assetId: asset.id,
|
|
421
|
+
summary: 'Wobbly hinge',
|
|
422
|
+
})
|
|
423
|
+
expect(away.status).toBe('under_repair')
|
|
424
|
+
await expect(
|
|
425
|
+
api.repairs.create({ workspaceId: WS, assetId: asset.id, summary: 'Again' }),
|
|
426
|
+
).rejects.toMatchObject({ code: 'CONFLICT' })
|
|
427
|
+
})
|
|
428
|
+
|
|
429
|
+
it('puts an item back to whoever still holds it, not into stock', async () => {
|
|
430
|
+
const api = createMockInventoryApi()
|
|
431
|
+
const dan = '01920000-0000-7000-8000-000000000002'
|
|
432
|
+
const asset = await inStock(api)
|
|
433
|
+
await api.custody.assign({ workspaceId: WS, assetId: asset.id, userId: dan })
|
|
434
|
+
const { repair } = await api.repairs.create({
|
|
435
|
+
workspaceId: WS,
|
|
436
|
+
assetId: asset.id,
|
|
437
|
+
summary: 'New battery',
|
|
438
|
+
})
|
|
439
|
+
// Still Dan's while it is away — a repair moves where a thing is, not who answers for it.
|
|
440
|
+
const during = await api.assets.get({ workspaceId: WS, assetId: asset.id })
|
|
441
|
+
expect({ status: during.status, holder: during.custodianUserId }).toEqual({
|
|
442
|
+
status: 'under_repair',
|
|
443
|
+
holder: dan,
|
|
444
|
+
})
|
|
445
|
+
const { asset: back } = await api.repairs.complete({ workspaceId: WS, repairId: repair.id })
|
|
446
|
+
expect({ status: back.status, holder: back.custodianUserId }).toEqual({
|
|
447
|
+
status: 'assigned',
|
|
448
|
+
holder: dan,
|
|
449
|
+
})
|
|
450
|
+
})
|
|
451
|
+
|
|
452
|
+
it('refuses completing the same repair twice, and a return before the send', async () => {
|
|
453
|
+
const api = createMockInventoryApi()
|
|
454
|
+
const asset = await inStock(api)
|
|
455
|
+
const { repair } = await api.repairs.create({
|
|
456
|
+
workspaceId: WS,
|
|
457
|
+
assetId: asset.id,
|
|
458
|
+
summary: 'Cracked case',
|
|
459
|
+
sentOn: '2026-06-01',
|
|
460
|
+
})
|
|
461
|
+
await expect(
|
|
462
|
+
api.repairs.complete({ workspaceId: WS, repairId: repair.id, returnedOn: '2026-05-01' }),
|
|
463
|
+
).rejects.toMatchObject({ code: 'CONFLICT' })
|
|
464
|
+
await api.repairs.complete({ workspaceId: WS, repairId: repair.id, returnedOn: '2026-06-10' })
|
|
465
|
+
await expect(api.repairs.complete({ workspaceId: WS, repairId: repair.id })).rejects.toMatchObject({
|
|
466
|
+
code: 'CONFLICT',
|
|
467
|
+
})
|
|
468
|
+
})
|
|
469
|
+
|
|
470
|
+
it('refuses archiving something that is away for repair', async () => {
|
|
471
|
+
const api = createMockInventoryApi()
|
|
472
|
+
const asset = await inStock(api)
|
|
473
|
+
await api.repairs.create({ workspaceId: WS, assetId: asset.id, summary: 'Screen' })
|
|
474
|
+
await expect(
|
|
475
|
+
api.assets.archive({ workspaceId: WS, assetId: asset.id, archived: true }),
|
|
476
|
+
).rejects.toMatchObject({ code: 'CONFLICT' })
|
|
477
|
+
})
|
|
478
|
+
})
|
|
479
|
+
|
|
480
|
+
describe('mock attachments', () => {
|
|
481
|
+
it('keeps a repair’s paperwork under the repair and the rest under the asset', async () => {
|
|
482
|
+
const api = createMockInventoryApi()
|
|
483
|
+
const first = (await api.assets.list({ workspaceId: WS, sort: 'code' })).items[0] as { id: string }
|
|
484
|
+
const rows = await api.attachments.list({ workspaceId: WS, assetId: first.id })
|
|
485
|
+
expect(rows.map((r) => r.name)).toEqual(['Purchase receipt.pdf', 'Repair invoice.pdf'])
|
|
486
|
+
// One key, one query, grouped in the browser — which is what the panel does.
|
|
487
|
+
expect(rows.filter((r) => r.repairId === null)).toHaveLength(1)
|
|
488
|
+
expect(rows.filter((r) => r.repairId !== null)).toHaveLength(1)
|
|
489
|
+
})
|
|
490
|
+
|
|
491
|
+
it('adds a file once however many times the same id arrives', async () => {
|
|
492
|
+
const api = createMockInventoryApi()
|
|
493
|
+
const first = (await api.assets.list({ workspaceId: WS, sort: 'code' })).items[0] as { id: string }
|
|
494
|
+
const fileId = '01920000-0000-7000-8007-0000000000aa'
|
|
495
|
+
const added = await api.attachments.add({ workspaceId: WS, assetId: first.id, fileIds: [fileId] })
|
|
496
|
+
expect(added).toHaveLength(1)
|
|
497
|
+
// The server's `on conflict do nothing`: pressing the button twice adds nothing the second time
|
|
498
|
+
// rather than failing at somebody.
|
|
499
|
+
expect(await api.attachments.add({ workspaceId: WS, assetId: first.id, fileIds: [fileId] })).toEqual([])
|
|
500
|
+
})
|
|
501
|
+
|
|
502
|
+
it('detaches one file and leaves the rest', async () => {
|
|
503
|
+
const api = createMockInventoryApi()
|
|
504
|
+
const first = (await api.assets.list({ workspaceId: WS, sort: 'code' })).items[0] as { id: string }
|
|
505
|
+
const rows = await api.attachments.list({ workspaceId: WS, assetId: first.id })
|
|
506
|
+
const target = rows[0] as { id: string }
|
|
507
|
+
expect(await api.attachments.remove({ workspaceId: WS, attachmentId: target.id })).toEqual({
|
|
508
|
+
id: target.id,
|
|
509
|
+
})
|
|
510
|
+
const left = await api.attachments.list({ workspaceId: WS, assetId: first.id })
|
|
511
|
+
expect(left.map((r) => r.id)).not.toContain(target.id)
|
|
512
|
+
expect(left).toHaveLength(rows.length - 1)
|
|
513
|
+
})
|
|
514
|
+
})
|
|
515
|
+
|
|
516
|
+
describe('mock stats.summary', () => {
|
|
517
|
+
it('counts live rows, keeps archived ones beside them, and zero-fills every status', async () => {
|
|
518
|
+
const api = createMockInventoryApi()
|
|
519
|
+
const stats = await api.stats.summary({ workspaceId: WS })
|
|
520
|
+
// Five live seeds and one archived — the same split the default list shows.
|
|
521
|
+
expect({ total: stats.total, archived: stats.archived }).toEqual({ total: 5, archived: 1 })
|
|
522
|
+
expect(Object.keys(stats.byStatus).sort()).toEqual([
|
|
523
|
+
'assigned',
|
|
524
|
+
'in_stock',
|
|
525
|
+
'lost',
|
|
526
|
+
'reserved',
|
|
527
|
+
'retired',
|
|
528
|
+
'under_repair',
|
|
529
|
+
])
|
|
530
|
+
expect(stats.byStatus.lost).toBe(0)
|
|
531
|
+
})
|
|
532
|
+
|
|
533
|
+
it('agrees with the status column about what is away', async () => {
|
|
534
|
+
const api = createMockInventoryApi()
|
|
535
|
+
const stats = await api.stats.summary({ workspaceId: WS })
|
|
536
|
+
// Two ways of asking one question — the cached status column and the repair rows — and they
|
|
537
|
+
// agree because the status is derived from those rows rather than written beside them.
|
|
538
|
+
expect(stats.outForRepair).toBe(stats.byStatus.under_repair)
|
|
539
|
+
})
|
|
540
|
+
|
|
541
|
+
it('counts what nobody is holding', async () => {
|
|
542
|
+
const api = createMockInventoryApi()
|
|
543
|
+
const before = await api.stats.summary({ workspaceId: WS })
|
|
544
|
+
const free = (await api.assets.list({ workspaceId: WS, status: 'in_stock' })).items[0] as {
|
|
545
|
+
id: string
|
|
546
|
+
}
|
|
547
|
+
await api.custody.assign({
|
|
548
|
+
workspaceId: WS,
|
|
549
|
+
assetId: free.id,
|
|
550
|
+
userId: '01920000-0000-7000-8000-000000000002',
|
|
551
|
+
})
|
|
552
|
+
const after = await api.stats.summary({ workspaceId: WS })
|
|
553
|
+
expect(after.unassigned).toBe(before.unassigned - 1)
|
|
554
|
+
})
|
|
555
|
+
})
|