@kernhq/module-inventory 0.2.0 → 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.
Files changed (193) hide show
  1. package/README.md +104 -25
  2. package/dist/contract/capabilities.d.ts +4 -4
  3. package/dist/contract/capabilities.d.ts.map +1 -1
  4. package/dist/contract/capabilities.js +51 -9
  5. package/dist/contract/capabilities.js.map +1 -1
  6. package/dist/contract/events.d.ts +43 -0
  7. package/dist/contract/events.d.ts.map +1 -1
  8. package/dist/contract/events.js +40 -0
  9. package/dist/contract/events.js.map +1 -1
  10. package/dist/contract/index.d.ts +1 -0
  11. package/dist/contract/index.d.ts.map +1 -1
  12. package/dist/contract/index.js +1 -0
  13. package/dist/contract/index.js.map +1 -1
  14. package/dist/contract/models.d.ts +322 -0
  15. package/dist/contract/models.d.ts.map +1 -1
  16. package/dist/contract/models.js +213 -0
  17. package/dist/contract/models.js.map +1 -1
  18. package/dist/contract/notifications.d.ts +24 -0
  19. package/dist/contract/notifications.d.ts.map +1 -0
  20. package/dist/contract/notifications.js +72 -0
  21. package/dist/contract/notifications.js.map +1 -0
  22. package/dist/contract/permissions.d.ts +52 -2
  23. package/dist/contract/permissions.d.ts.map +1 -1
  24. package/dist/contract/permissions.js +55 -2
  25. package/dist/contract/permissions.js.map +1 -1
  26. package/dist/contract/router.d.ts +1052 -0
  27. package/dist/contract/router.d.ts.map +1 -1
  28. package/dist/contract/router.js +225 -1
  29. package/dist/contract/router.js.map +1 -1
  30. package/dist/contract/settings.d.ts +2 -0
  31. package/dist/contract/settings.d.ts.map +1 -1
  32. package/dist/contract/settings.js +24 -4
  33. package/dist/contract/settings.js.map +1 -1
  34. package/dist/server/index.d.ts +8 -4
  35. package/dist/server/index.d.ts.map +1 -1
  36. package/dist/server/index.js +289 -5
  37. package/dist/server/index.js.map +1 -1
  38. package/dist/server/jobs.d.ts +48 -0
  39. package/dist/server/jobs.d.ts.map +1 -0
  40. package/dist/server/jobs.js +358 -0
  41. package/dist/server/jobs.js.map +1 -0
  42. package/dist/server/router.d.ts +1337 -0
  43. package/dist/server/router.d.ts.map +1 -1
  44. package/dist/server/router.js +363 -7
  45. package/dist/server/router.js.map +1 -1
  46. package/dist/server/schema.d.ts +134 -1
  47. package/dist/server/schema.d.ts.map +1 -1
  48. package/dist/server/schema.js +158 -4
  49. package/dist/server/schema.js.map +1 -1
  50. package/dist/server/services/assets.d.ts +69 -3
  51. package/dist/server/services/assets.d.ts.map +1 -1
  52. package/dist/server/services/assets.js +199 -31
  53. package/dist/server/services/assets.js.map +1 -1
  54. package/dist/server/services/attachments.d.ts +80 -0
  55. package/dist/server/services/attachments.d.ts.map +1 -0
  56. package/dist/server/services/attachments.js +182 -0
  57. package/dist/server/services/attachments.js.map +1 -0
  58. package/dist/server/services/audience.d.ts +15 -0
  59. package/dist/server/services/audience.d.ts.map +1 -0
  60. package/dist/server/services/audience.js +64 -0
  61. package/dist/server/services/audience.js.map +1 -0
  62. package/dist/server/services/categories.d.ts +57 -0
  63. package/dist/server/services/categories.d.ts.map +1 -0
  64. package/dist/server/services/categories.js +124 -0
  65. package/dist/server/services/categories.js.map +1 -0
  66. package/dist/server/services/cursor.d.ts +68 -0
  67. package/dist/server/services/cursor.d.ts.map +1 -0
  68. package/dist/server/services/cursor.js +39 -0
  69. package/dist/server/services/cursor.js.map +1 -0
  70. package/dist/server/services/custody.d.ts +175 -0
  71. package/dist/server/services/custody.d.ts.map +1 -0
  72. package/dist/server/services/custody.js +367 -0
  73. package/dist/server/services/custody.js.map +1 -0
  74. package/dist/server/services/db-errors.d.ts +7 -0
  75. package/dist/server/services/db-errors.d.ts.map +1 -0
  76. package/dist/server/services/db-errors.js +32 -0
  77. package/dist/server/services/db-errors.js.map +1 -0
  78. package/dist/server/services/index.d.ts +16 -0
  79. package/dist/server/services/index.d.ts.map +1 -1
  80. package/dist/server/services/index.js +25 -1
  81. package/dist/server/services/index.js.map +1 -1
  82. package/dist/server/services/members.d.ts +27 -0
  83. package/dist/server/services/members.d.ts.map +1 -0
  84. package/dist/server/services/members.js +39 -0
  85. package/dist/server/services/members.js.map +1 -0
  86. package/dist/server/services/notify.d.ts +43 -1
  87. package/dist/server/services/notify.d.ts.map +1 -1
  88. package/dist/server/services/notify.js +47 -5
  89. package/dist/server/services/notify.js.map +1 -1
  90. package/dist/server/services/offboarding.d.ts +70 -0
  91. package/dist/server/services/offboarding.d.ts.map +1 -0
  92. package/dist/server/services/offboarding.js +116 -0
  93. package/dist/server/services/offboarding.js.map +1 -0
  94. package/dist/server/services/repairs.d.ts +204 -0
  95. package/dist/server/services/repairs.d.ts.map +1 -0
  96. package/dist/server/services/repairs.js +476 -0
  97. package/dist/server/services/repairs.js.map +1 -0
  98. package/dist/server/services/search.d.ts +85 -0
  99. package/dist/server/services/search.d.ts.map +1 -0
  100. package/dist/server/services/search.js +142 -0
  101. package/dist/server/services/search.js.map +1 -0
  102. package/dist/server/services/stats.d.ts +42 -0
  103. package/dist/server/services/stats.d.ts.map +1 -0
  104. package/dist/server/services/stats.js +80 -0
  105. package/dist/server/services/stats.js.map +1 -0
  106. package/dist/server/services/status.d.ts +102 -0
  107. package/dist/server/services/status.d.ts.map +1 -0
  108. package/dist/server/services/status.js +71 -0
  109. package/dist/server/services/status.js.map +1 -0
  110. package/migrations/0002_custody_and_categories.sql +23 -0
  111. package/migrations/0003_repairs.sql +23 -0
  112. package/migrations/0004_platform_surfaces.sql +51 -0
  113. package/migrations/0005_repair_dates.sql +35 -0
  114. package/migrations/0006_workspace_registry_read.sql +50 -0
  115. package/migrations/0007_history_sequence.sql +83 -0
  116. package/migrations/meta/0002_snapshot.json +1054 -0
  117. package/migrations/meta/0003_snapshot.json +1070 -0
  118. package/migrations/meta/0004_snapshot.json +1130 -0
  119. package/migrations/meta/0005_snapshot.json +1135 -0
  120. package/migrations/meta/_journal.json +42 -0
  121. package/package.json +1 -1
  122. package/src/client/api-instance.ts +27 -2
  123. package/src/client/bidi.test.ts +148 -0
  124. package/src/client/bidi.ts +85 -0
  125. package/src/client/components/AssetDetailPanel.svelte +614 -0
  126. package/src/client/components/AssetFormDialog.svelte +71 -22
  127. package/src/client/components/AssetPhoto.svelte +178 -0
  128. package/src/client/components/AttachmentsSection.svelte +327 -0
  129. package/src/client/components/CustodyDialog.svelte +201 -0
  130. package/src/client/components/RepairDialog.svelte +271 -0
  131. package/src/client/components/RepairsSection.svelte +318 -0
  132. package/src/client/components/Timeline.svelte +347 -0
  133. package/src/client/components/TimelineText.svelte +124 -0
  134. package/src/client/core-api.ts +71 -0
  135. package/src/client/custody.test.ts +31 -0
  136. package/src/client/custody.ts +34 -0
  137. package/src/client/errors.test.ts +365 -0
  138. package/src/client/errors.ts +201 -0
  139. package/src/client/links.test.ts +74 -0
  140. package/src/client/links.ts +44 -0
  141. package/src/client/members.test.ts +132 -0
  142. package/src/client/members.ts +116 -0
  143. package/src/client/messages.test.ts +127 -0
  144. package/src/client/messages.ts +1043 -18
  145. package/src/client/mock.test.ts +395 -1
  146. package/src/client/mock.ts +1066 -79
  147. package/src/client/module.ts +54 -0
  148. package/src/client/pages/AssetsPage.svelte +317 -45
  149. package/src/client/permissions.ts +7 -0
  150. package/src/client/price.test.ts +151 -3
  151. package/src/client/price.ts +155 -11
  152. package/src/client/query.ts +36 -0
  153. package/src/client/repairs.test.ts +38 -0
  154. package/src/client/repairs.ts +38 -0
  155. package/src/client/settings/CategoriesSettings.svelte +421 -0
  156. package/src/client/settings/GeneralSettings.svelte +0 -0
  157. package/src/client/status.ts +29 -0
  158. package/src/client/timeline.test.ts +175 -0
  159. package/src/client/timeline.ts +206 -0
  160. package/src/client/widgets/OverviewWidget.svelte +127 -26
  161. package/src/client/widgets/RepairsWidget.svelte +124 -0
  162. package/src/contract/capabilities.ts +53 -9
  163. package/src/contract/events.ts +49 -0
  164. package/src/contract/index.ts +1 -0
  165. package/src/contract/models.ts +237 -0
  166. package/src/contract/notifications.ts +73 -0
  167. package/src/contract/permissions.ts +55 -2
  168. package/src/contract/router.ts +255 -1
  169. package/src/contract/settings.ts +24 -4
  170. package/src/module.test.ts +194 -4
  171. package/src/server/index.ts +311 -4
  172. package/src/server/inventory.int.test.ts +3573 -18
  173. package/src/server/jobs.ts +444 -0
  174. package/src/server/migrations.test.ts +113 -0
  175. package/src/server/router.ts +462 -6
  176. package/src/server/schema.ts +160 -3
  177. package/src/server/services/assets.ts +214 -54
  178. package/src/server/services/attachments.ts +215 -0
  179. package/src/server/services/audience.ts +77 -0
  180. package/src/server/services/categories.ts +136 -0
  181. package/src/server/services/cursor.ts +104 -0
  182. package/src/server/services/custody.ts +471 -0
  183. package/src/server/services/db-errors.ts +42 -0
  184. package/src/server/services/index.ts +34 -1
  185. package/src/server/services/members.ts +54 -0
  186. package/src/server/services/notify.ts +52 -7
  187. package/src/server/services/offboarding.ts +150 -0
  188. package/src/server/services/repairs.ts +567 -0
  189. package/src/server/services/search.ts +166 -0
  190. package/src/server/services/stats.ts +88 -0
  191. package/src/server/services/status.test.ts +34 -0
  192. package/src/server/services/status.ts +143 -0
  193. package/src/client/settings/core-api.ts +0 -32
@@ -1,14 +1,25 @@
1
1
  import { randomUUID } from 'node:crypto'
2
- import { Cursor, type Principal, type WorkspaceId } from '@kernhq/contracts'
3
- import { createKernel, type Kernel, type RequestContext, type Tx } from '@kernhq/kernel'
2
+ import {
3
+ Cursor,
4
+ type core,
5
+ defineEvent,
6
+ type EntityChange,
7
+ type Principal,
8
+ type WorkspaceId,
9
+ } from '@kernhq/contracts'
10
+ import { CAPABILITIES_KEY, createKernel, type Kernel, type RequestContext, type Tx } from '@kernhq/kernel'
4
11
  import { call } from '@orpc/server'
5
- import { and, asc, eq } from 'drizzle-orm'
12
+ import { and, asc, eq, sql } from 'drizzle-orm'
13
+ import { drizzle } from 'drizzle-orm/node-postgres'
6
14
  import pg from 'pg'
7
15
  import { afterAll, beforeAll, describe, expect, it } from 'vitest'
16
+ import { z } from 'zod'
8
17
  import type { Asset } from '../contract/models.js'
9
18
  import { inventoryModule } from './index.js'
19
+ import { activeWorkspaces, reconcileStatuses } from './jobs.js'
10
20
  import { inventoryRouter } from './router.js'
11
- import { assetHistory, assets, custodyPeriods, TENANT_TABLES } from './schema.js'
21
+ import { assetHistory, assets, custodyPeriods, repairs, TENANT_TABLES, workspaces } from './schema.js'
22
+ import { inventoryServices } from './services/index.js'
12
23
 
13
24
  /**
14
25
  * Inventory against a real Postgres.
@@ -39,9 +50,44 @@ const WS_B = workspace()
39
50
  const WS_CODES = workspace()
40
51
  const WS_PAGE = workspace()
41
52
  const WS_FILTER = workspace()
53
+ const WS_CUSTODY = workspace()
54
+ const WS_CAT = workspace()
55
+ const WS_REPAIR = workspace()
56
+ const WS_FILES = workspace()
57
+ const WS_STATS = workspace()
58
+ /** Its own workspace, because a capability is switched off for a whole workspace at a time. */
59
+ const WS_CAP = workspace()
60
+ /**
61
+ * Its own again, and separate from `WS_CAP` on purpose.
62
+ *
63
+ * `WS_CAP` proves the gate — every repairs procedure answers 404 — and this one proves what the
64
+ * workspace can still *do* while the gate is shut, which needs assets to be created, handed over and
65
+ * archived under the switch rather than only refused. Mixing the two would leave one block's leftover
66
+ * repairs deciding the other block's statuses.
67
+ */
68
+ const WS_STRAND = workspace()
69
+ /** Its own, because the sweeps read every registered workspace and must not see anybody else's. */
70
+ const WS_SWEEP = workspace()
71
+ /** Its own, because `onWorkspaceEnabled` only seeds a workspace that has no categories at all. */
72
+ const WS_SEED = workspace()
73
+ /** A registered workspace with nobody in it, so a sweep finds a row and nobody to tell about it. */
74
+ const WS_SILENT = workspace()
75
+ /** Its own, and never registered in `mod_inventory.workspaces`, so the sweeps skip it. */
76
+ const WS_LEAVER = workspace()
77
+ /**
78
+ * Registered, and deliberately empty: the scheduler's enumeration is what it is here to prove, and a
79
+ * workspace with no assets and no repairs gives every sweep nothing to say about it.
80
+ */
81
+ const WS_REGISTRY = workspace()
82
+ /** A workspace with HR but with Inventory switched off, which must hear nothing. */
83
+ const WS_NO_INVENTORY = workspace()
84
+ const WS_SEARCH = workspace()
42
85
 
43
86
  const ALICE = randomUUID()
44
87
  const BOB = randomUUID()
88
+ /** Somebody who leaves, and the office manager who has to collect their laptop. */
89
+ const DANA = randomUUID()
90
+ const OLIVE = randomUUID()
45
91
 
46
92
  const principal = (userId: string, workspaceId: string): Principal =>
47
93
  // `unknown` first: `userId` is branded on Principal and a plain string does not overlap it, which
@@ -65,23 +111,228 @@ const inWs =
65
111
 
66
112
  const run = inWs(WS_A)
67
113
 
114
+ /**
115
+ * What core says this workspace has switched on, when a test wants to say something.
116
+ *
117
+ * `null` means "a workspace that has never touched the switchboard" — empty settings, which is what
118
+ * every test outside the capability block runs against, and which resolves to `repairs` and
119
+ * `attachments` **on**, because both are `defaultEnabled`. `withCapabilities` below is how the 404
120
+ * tests switch one off.
121
+ */
122
+ let capabilityOverride: Record<string, boolean> | null = null
123
+
124
+ /**
125
+ * What this module asked core to do, recorded rather than discarded.
126
+ *
127
+ * The stubs used to answer `{ ok: true }` and forget, which is enough for a test that only cares
128
+ * that a mutation did not throw — and useless for the four surfaces that exist *entirely* to send
129
+ * something out of the module. A notification nobody can assert on is a notification nobody can
130
+ * prove is addressed to the right person.
131
+ */
132
+ interface SentNotification {
133
+ userId: string
134
+ workspaceId: string
135
+ type: string
136
+ title: string
137
+ body: string | null
138
+ url: string | null
139
+ data: Record<string, unknown>
140
+ }
141
+ const NOTIFICATIONS: SentNotification[] = []
142
+
143
+ /**
144
+ * Core refusing to write a notification, on demand.
145
+ *
146
+ * Every side effect this module has is best-effort — a failed `core.notifications.create` is logged
147
+ * and swallowed — and the two nightly sweeps write a permanent "told them" marker beside it. Whether
148
+ * that marker is honest is only answerable by making the call fail, so the stub can be made to.
149
+ */
150
+ let notificationsFail = false
151
+
152
+ /**
153
+ * Core refusing to write a notification **for some people and not others**.
154
+ *
155
+ * The all-or-nothing switch above cannot reach the defect this exists for: a sweep sends to an
156
+ * audience, and `core.notifications.create` is one call per recipient, so "the notice went out" and
157
+ * "the notice went out to everybody" are different facts and the marker column can only hold one of
158
+ * them. Failing exactly one recipient is the only way to tell which one the sweep is recording.
159
+ */
160
+ const NOTIFICATIONS_FAIL_FOR = new Set<string>()
161
+
162
+ /**
163
+ * Every realtime change this module announced, in order.
164
+ *
165
+ * `kernel.realtime.change` is the only thing that redraws a screen somebody is looking at, and what
166
+ * it carries — the entity and the id — is a claim about which row moved. Nothing could assert on it
167
+ * before, so a change naming the wrong noun was invisible to the whole suite.
168
+ */
169
+ const CHANGES: Array<{ workspaceId: string } & EntityChange> = []
170
+
171
+ const INDEXED: core.SearchDocument[] = []
172
+ const UNINDEXED: Array<{ workspaceId: string; object: { module: string; type: string; id: string } }> = []
173
+
174
+ /** Workspaces this instance is pretending have Inventory switched **off**. */
175
+ const MODULE_OFF = new Set<string>()
176
+
177
+ /** Membership, as core would report it — the input to every "who should be told" question. */
178
+ const MEMBERS = new Map<string, Array<{ userId: string; role: string }>>()
179
+ /** Names, so a notification can say who somebody is rather than printing a uuid. */
180
+ const USERS = new Map<string, { id: string; name: string | null; email: string }>()
181
+
182
+ /** HR, when a test is pretending the workspace has it. `null` means nothing hosts `hr.*`. */
183
+ const PEOPLE = new Map<string, { id: string; userId: string | null; status: string }>()
184
+
185
+ const seedMember = (workspaceId: string, userId: string, role: string, name: string) => {
186
+ MEMBERS.set(workspaceId, [...(MEMBERS.get(workspaceId) ?? []), { userId, role }])
187
+ USERS.set(userId, { id: userId, name, email: `${name.toLowerCase().replace(/\W+/g, '.')}@example.test` })
188
+ }
189
+
190
+ /**
191
+ * Alice and Bob belong to the workspaces this suite hands things over in.
192
+ *
193
+ * `custody.assign` and `custody.transfer` ask core whether the person being handed the item is
194
+ * really a member before they write it — an id in a request is a claim, and it used to be believed.
195
+ * So a workspace where a handover is expected to *succeed* has to have members, and the fixture is
196
+ * the same map every audience question is answered from rather than a second one that could
197
+ * disagree with it.
198
+ *
199
+ * Seeded here rather than in a `beforeAll`, so the blocks that are *about* an audience build their
200
+ * own rolls untouched: `WS_SWEEP` and `WS_LEAVER` name exactly who they mean, and `WS_SILENT` is a
201
+ * registered workspace with nobody in it at all.
202
+ */
203
+ for (const workspaceId of [
204
+ WS_A,
205
+ WS_B,
206
+ WS_CUSTODY,
207
+ WS_REPAIR,
208
+ WS_STATS,
209
+ WS_SEARCH,
210
+ WS_CAP,
211
+ WS_STRAND,
212
+ WS_FILES,
213
+ ]) {
214
+ seedMember(workspaceId, ALICE, 'admin', 'Alice Ng')
215
+ seedMember(workspaceId, BOB, 'member', 'Bob Ito')
216
+ }
217
+
218
+ const notificationsOfType = (type: string) => NOTIFICATIONS.filter((n) => n.type === type)
219
+
68
220
  function registerCoreStubs(k: Kernel) {
69
221
  k.broker.register('core', {
70
222
  'activity.record': { handler: async () => ({ ok: true }) },
71
- 'notifications.create': { handler: async () => ({ ok: true }) },
72
- 'search.index': { handler: async () => ({ ok: true }) },
73
- 'search.remove': { handler: async () => ({ ok: true }) },
74
- 'modules.isEnabled': { handler: async () => true },
223
+ 'notifications.create': {
224
+ handler: async (input: SentNotification) => {
225
+ if (notificationsFail || NOTIFICATIONS_FAIL_FOR.has(input.userId))
226
+ throw new Error('core is having a bad night')
227
+ NOTIFICATIONS.push(input)
228
+ return { ok: true }
229
+ },
230
+ },
231
+ 'search.index': {
232
+ handler: async (input: { documents: core.SearchDocument[] }) => {
233
+ INDEXED.push(...input.documents)
234
+ return { ok: true }
235
+ },
236
+ },
237
+ 'search.remove': {
238
+ handler: async (input: { refs: (typeof UNINDEXED)[number][] }) => {
239
+ UNINDEXED.push(...input.refs)
240
+ return { ok: true }
241
+ },
242
+ },
243
+ 'modules.isEnabled': {
244
+ handler: async (input: { workspaceId: string }) => !MODULE_OFF.has(input.workspaceId),
245
+ },
75
246
  'authz.customRolePermissions': { handler: async () => [] },
76
247
  'authz.bindings': { handler: async () => [] },
77
- // Inventory declares one capability, `core`, and it is `required` so nothing here has to
78
- // switch anything on. Empty settings is what a workspace that never opened the module has, and
79
- // `InventorySettings` fills in `INV-`/4 from its own defaults.
80
- 'settings.getModule': { handler: async () => ({}) },
248
+ /** Everybody the workspace has, which is what an audience is worked out from. */
249
+ 'workspaces.members': {
250
+ handler: async (input: { workspaceId: string }) =>
251
+ (MEMBERS.get(input.workspaceId) ?? []).map((m) => ({ ...m, roleIds: [], groupIds: [] })),
252
+ },
253
+ /**
254
+ * A principal per member, because `membersWithPermission` asks the real `kernel.authz` rather
255
+ * than guessing from a role — so the audience test is a test of the permission system and not
256
+ * of a lookup table.
257
+ */
258
+ 'users.principal': {
259
+ handler: async (input: { userId: string }) => {
260
+ const memberships = [...MEMBERS].flatMap(([workspaceId, rows]) =>
261
+ rows
262
+ .filter((row) => row.userId === input.userId)
263
+ .map((row) => ({ workspaceId, role: row.role, roleIds: [], groupIds: [], status: 'active' })),
264
+ )
265
+ return {
266
+ kind: 'user',
267
+ userId: input.userId,
268
+ email: USERS.get(input.userId)?.email ?? null,
269
+ name: USERS.get(input.userId)?.name ?? null,
270
+ locale: 'en',
271
+ instanceAdmin: false,
272
+ service: null,
273
+ memberships,
274
+ permissionVersion: 0,
275
+ }
276
+ },
277
+ },
278
+ 'users.get': { handler: async (input: { id: string }) => USERS.get(input.id) ?? null },
279
+ // Empty settings is what a workspace that never opened the module has: `InventorySettings`
280
+ // fills in `INV-`/4 from its own defaults, and `resolveCapabilities` fills in the two switches
281
+ // from theirs.
282
+ 'settings.getModule': {
283
+ handler: async () => (capabilityOverride ? { [CAPABILITIES_KEY]: capabilityOverride } : {}),
284
+ },
81
285
  'settings.setModule': { handler: async () => ({ ok: true }) },
286
+ // What `AttachmentService.describe` asks before recording a file. The real answer is core's
287
+ // file record; this is the smallest thing that carries the two fields the module checks —
288
+ // the owning workspace and whether the upload finished.
289
+ 'files.get': {
290
+ handler: async (input: { id: string }) => FILES.get(input.id) ?? null,
291
+ },
82
292
  })
83
293
  }
84
294
 
295
+ /**
296
+ * The files core is pretending to hold, keyed by id.
297
+ *
298
+ * A module records that an asset has a file and copies its name and size; it never sees a byte. The
299
+ * two rows a test needs are a normal one and a wrong one — a file belonging to another workspace,
300
+ * which is the check that stops one workspace reading another's file names through this module.
301
+ */
302
+ const FILES = new Map<
303
+ string,
304
+ { id: string; workspaceId: string; name: string; mimeType: string; size: number; status: string }
305
+ >()
306
+
307
+ const seedFile = (workspaceId: string, name: string, status = 'ready') => {
308
+ const id = randomUUID()
309
+ FILES.set(id, { id, workspaceId, name, mimeType: 'application/pdf', size: 12_345, status })
310
+ return id
311
+ }
312
+
313
+ /**
314
+ * Run one block with a different set of capabilities switched on.
315
+ *
316
+ * The kernel caches module settings for fifteen seconds, so flipping the stub is not enough —
317
+ * `invalidate` is what makes the next call actually ask again. Restored in a `finally`, because a
318
+ * test that leaves `repairs` off makes every later test in the file 404 for reasons it never
319
+ * mentions.
320
+ */
321
+ async function withCapabilities(
322
+ on: Record<string, boolean>,
323
+ fn: () => Promise<void>,
324
+ workspaceIds: string[] = [WS_CAP],
325
+ ): Promise<void> {
326
+ capabilityOverride = on
327
+ for (const id of workspaceIds) kernel.settings.invalidate(id)
328
+ try {
329
+ await fn()
330
+ } finally {
331
+ capabilityOverride = null
332
+ for (const id of workspaceIds) kernel.settings.invalidate(id)
333
+ }
334
+ }
335
+
85
336
  /**
86
337
  * A request context, as the HTTP layer would build one.
87
338
  *
@@ -118,6 +369,15 @@ beforeAll(async () => {
118
369
  },
119
370
  })
120
371
  registerCoreStubs(kernel)
372
+
373
+ // Recorded on the way through rather than replaced: the real publisher still runs, so this cannot
374
+ // make a broken announcement look like a working one.
375
+ const announced = kernel.realtime.change.bind(kernel.realtime)
376
+ kernel.realtime.change = async (workspaceId: string, change: EntityChange) => {
377
+ CHANGES.push({ workspaceId, ...change })
378
+ return announced(workspaceId, change)
379
+ }
380
+
121
381
  await kernel.start()
122
382
  inv = inventoryRouter(kernel)
123
383
  }, 180_000)
@@ -135,9 +395,11 @@ afterAll(async () => {
135
395
  interface NewAsset {
136
396
  name: string
137
397
  description?: string
398
+ categoryId?: string | null
138
399
  serialNumber?: string | null
139
400
  location?: string | null
140
401
  purchasedOn?: string | null
402
+ warrantyUntil?: string | null
141
403
  priceMinor?: number | null
142
404
  currency?: string | null
143
405
  }
@@ -159,21 +421,58 @@ const listAssets = (
159
421
  * failure — a typo in the input included — which is the whole thing these tests are trying to rule
160
422
  * out.
161
423
  */
424
+ function codeOf(err: unknown): string | null {
425
+ let cursor: unknown = err
426
+ for (let depth = 0; depth < 5 && cursor; depth++) {
427
+ const code = (cursor as { code?: unknown }).code
428
+ if (typeof code === 'string') return code
429
+ cursor = (cursor as { cause?: unknown }).cause
430
+ }
431
+ return null
432
+ }
433
+
434
+ /** The sentence a refusal carried, which is the half a person actually reads. */
435
+ function messageOf(err: unknown): string {
436
+ return err instanceof Error ? err.message : String(err)
437
+ }
438
+
162
439
  async function refusedWith(fn: () => Promise<unknown>): Promise<string> {
163
440
  try {
164
441
  await fn()
165
442
  } catch (err) {
166
- let cursor: unknown = err
167
- for (let depth = 0; depth < 5 && cursor; depth++) {
168
- const code = (cursor as { code?: unknown }).code
169
- if (typeof code === 'string') return code
170
- cursor = (cursor as { cause?: unknown }).cause
171
- }
443
+ const code = codeOf(err)
444
+ if (code) return code
172
445
  throw new Error(`Rejected, but with no error code: ${String(err)}`)
173
446
  }
174
447
  throw new Error('Expected the call to be refused, but it succeeded')
175
448
  }
176
449
 
450
+ /**
451
+ * Wait until Postgres says a backend on this database is blocked on a lock.
452
+ *
453
+ * The one honest way to know that a second transaction has reached its insert and is queued behind
454
+ * the first. A `setTimeout` guesses at it, and guesses wrong under load — which does not merely make
455
+ * the test slow, it makes the *other* transaction the winner and the assertions nonsense. Polling
456
+ * the database's own view of who is waiting is deterministic on any machine.
457
+ *
458
+ * A blocked insert on an exclusion constraint waits on the other transaction's id, so
459
+ * `wait_event_type` is `Lock`.
460
+ */
461
+ async function waitForBlockedBackend(timeoutMs = 10_000): Promise<void> {
462
+ const deadline = Date.now() + timeoutMs
463
+ while (Date.now() < deadline) {
464
+ const { rows } = await kernel.database.pool.query<{ n: number }>(
465
+ `select count(*)::int as n
466
+ from pg_stat_activity
467
+ where datname = $1 and state = 'active' and wait_event_type = 'Lock'`,
468
+ [DB_NAME],
469
+ )
470
+ if ((rows[0]?.n ?? 0) > 0) return
471
+ await new Promise((resolve) => setTimeout(resolve, 10))
472
+ }
473
+ throw new Error('No backend ever blocked on a lock — the second transaction never reached its insert')
474
+ }
475
+
177
476
  /**
178
477
  * Postgres reports which constraint refused a write; drizzle wraps that in a "Failed query" error
179
478
  * whose message does not carry the name. Asserting on the message alone would pass for *any*
@@ -194,6 +493,18 @@ async function constraintViolated(fn: () => Promise<unknown>): Promise<string> {
194
493
  throw new Error('Expected the write to be refused, but it succeeded')
195
494
  }
196
495
 
496
+ /**
497
+ * The `repairs` capability, as the router resolves it before opening a transaction.
498
+ *
499
+ * The custody verbs and `assets.archive` take it as an argument rather than reading it, because
500
+ * `kernel.capabilities` is a settings read over the broker and this module never awaits one while
501
+ * holding a pooled connection. Every test that drives those services **directly** — the ones holding
502
+ * two transactions open at once, which cannot go through the router — has to supply the same answer
503
+ * the router would have. Every one of them runs in a workspace that has never touched the
504
+ * switchboard, so the answer is `true`: `repairs` is `defaultEnabled`.
505
+ */
506
+ const RECORDS_REPAIRS = true
507
+
197
508
  // ---------------------------------------------------------------------------------------------
198
509
 
199
510
  /**
@@ -359,6 +670,9 @@ describe('row-level security, as a role that cannot bypass it', () => {
359
670
  beforeAll(async () => {
360
671
  await createAsset(WS_A, { name: 'Visible to A' })
361
672
  await createAsset(WS_B, { name: 'Visible to B' }, BOB)
673
+ // Through the module's own path, so what the enumeration finds is what a workspace switching
674
+ // Inventory on actually writes.
675
+ await inventoryModule.onWorkspaceEnabled?.(WS_REGISTRY, kernel)
362
676
 
363
677
  const scratch = new pg.Client({ connectionString: databaseUrl })
364
678
  await scratch.connect()
@@ -404,6 +718,43 @@ describe('row-level security, as a role that cannot bypass it', () => {
404
718
  1,
405
719
  )
406
720
  })
721
+
722
+ /**
723
+ * The scheduler's enumeration, run as the role a deployment actually uses.
724
+ *
725
+ * `mod_inventory.workspaces` is the registry both nightly sweeps start from, and a cron handler is
726
+ * woken by a clock — so it has no workspace and `app.workspace_id` is unset for it by definition.
727
+ * The table's per-workspace policy therefore matched nothing, and `force row level security`
728
+ * subjects the schema's **owner** to its policies as well: only a superuser is exempt. So this read
729
+ * answered zero rows on any ordinary deployment. No error, no warning — both sweeps simply found
730
+ * nothing to do, every night, and the development database is a superuser, which is exactly why
731
+ * nothing noticed.
732
+ *
733
+ * The real `activeWorkspaces` is called rather than a query that resembles it, against a drizzle
734
+ * handle over this role's own connection. A test that reproduced the SQL by hand would pass on a
735
+ * job that had since started reading something else.
736
+ */
737
+ it('lets the scheduler enumerate every registered workspace, as a role that cannot bypass RLS', async () => {
738
+ await plain.query('reset app.workspace_id')
739
+ const asPlainRole = { database: { db: drizzle({ client: plain }) } } as unknown as Kernel
740
+
741
+ const ids = await activeWorkspaces(asPlainRole)
742
+ expect(ids, 'a sweep that finds no workspaces has nothing to sweep, silently and for ever').toContain(
743
+ WS_REGISTRY,
744
+ )
745
+
746
+ // And nothing else opened up: the same unbound session still sees no tenant data at all.
747
+ expect(await count('select count(*) as n from mod_inventory.assets')).toBe(0)
748
+ expect(await count('select count(*) as n from mod_inventory.custody_periods')).toBe(0)
749
+ })
750
+
751
+ it('still shows a workspace-bound session only its own registry row', async () => {
752
+ await plain.query(`set app.workspace_id = '${WS_A}'`)
753
+ expect(
754
+ await count(`select count(*) as n from mod_inventory.workspaces where workspace_id = '${WS_REGISTRY}'`),
755
+ 'the extra policy is for a session with no workspace, and widens nothing for one that has one',
756
+ ).toBe(0)
757
+ })
407
758
  })
408
759
 
409
760
  /**
@@ -817,3 +1168,3207 @@ describe('patching one field', () => {
817
1168
  expect(patched.description, 'a rename must not wipe the description').toBe('Second floor, bolted')
818
1169
  })
819
1170
  })
1171
+
1172
+ /**
1173
+ * Custody, the invariant this phase exists for.
1174
+ *
1175
+ * Three verbs, and every one of them has to leave four things in step inside **one** transaction:
1176
+ * the period rows, `assets.custodian_user_id`, `assets.custody_since` and `assets.status`, plus an
1177
+ * `asset_history` entry. Any test that checks one of the four and not the others would pass against
1178
+ * a service that had come apart, which is precisely the failure the design is guarding against.
1179
+ */
1180
+ describe('handing an item over', () => {
1181
+ /**
1182
+ * Built on call, not once at the top of the block.
1183
+ *
1184
+ * A `describe` body runs at collection time — before `beforeAll` — so a context captured there
1185
+ * holds `kernel: undefined`, and every procedure fails inside `workspaceScoped` with
1186
+ * "Cannot read properties of undefined (reading 'authz')". That is not the module refusing the
1187
+ * call; it is the test never reaching it, and it looks identical from the assertion.
1188
+ */
1189
+ const ctx = () => ({ context: asUser(ALICE, WS_CUSTODY) })
1190
+
1191
+ const assign = (assetId: string, userId: string, note?: string) =>
1192
+ call(inv.custody.assign, { workspaceId: WS_CUSTODY, assetId, userId, note }, ctx())
1193
+ const transfer = (assetId: string, userId: string, note?: string) =>
1194
+ call(inv.custody.transfer, { workspaceId: WS_CUSTODY, assetId, userId, note }, ctx())
1195
+ const take = (assetId: string, note?: string) =>
1196
+ call(inv.custody.return, { workspaceId: WS_CUSTODY, assetId, note }, ctx())
1197
+ const periodsOf = (assetId: string) =>
1198
+ call(inv.custody.history, { workspaceId: WS_CUSTODY, assetId }, ctx())
1199
+ const actionsOf = async (assetId: string) =>
1200
+ (await call(inv.assets.history, { workspaceId: WS_CUSTODY, assetId }, ctx())).items.map((e) => e.action)
1201
+
1202
+ it('opens a period and brings the three columns on the asset into step', async () => {
1203
+ const item = await createAsset(WS_CUSTODY, { name: 'Projector' })
1204
+ expect(item.status).toBe('in_stock')
1205
+
1206
+ const { asset, period } = await assign(item.id, BOB, 'For the all-hands')
1207
+ expect(asset.custodianUserId).toBe(BOB)
1208
+ expect(asset.custodySince).toBeTruthy()
1209
+ expect(asset.status, 'status follows custody, in the same transaction').toBe('assigned')
1210
+ expect(period?.effectiveTo, 'the open period is the one with no end').toBeNull()
1211
+ expect(period?.note).toBe('For the all-hands')
1212
+ // Written by the same transaction, not by a job afterwards.
1213
+ expect(await actionsOf(item.id)).toEqual(['assigned', 'created'])
1214
+ })
1215
+
1216
+ it('closes the period and clears all three columns on return', async () => {
1217
+ const item = await createAsset(WS_CUSTODY, { name: 'Label printer' })
1218
+ await assign(item.id, BOB)
1219
+ const { asset, period } = await take(item.id)
1220
+ expect(asset.custodianUserId).toBeNull()
1221
+ expect(asset.custodySince).toBeNull()
1222
+ expect(asset.status).toBe('in_stock')
1223
+ expect(period, 'a return closes something and opens nothing').toBeNull()
1224
+
1225
+ const rows = await periodsOf(item.id)
1226
+ expect(rows).toHaveLength(1)
1227
+ expect(rows[0]?.effectiveTo).toBeTruthy()
1228
+ expect(await actionsOf(item.id)).toEqual(['returned', 'assigned', 'created'])
1229
+ })
1230
+
1231
+ it('hands on in one transaction, leaving no stretch during which nobody held it', async () => {
1232
+ /**
1233
+ * The reason `transfer` is a procedure rather than a return followed by an assign.
1234
+ *
1235
+ * Two calls would leave the asset `in_stock` with no custodian in between — visible to anybody
1236
+ * reading the list at that instant, and permanently visible in the timeline as a return nobody
1237
+ * performed. One instant for both halves is what makes the periods abut: `[…, at)` and
1238
+ * `[at, …)` do not overlap and leave no gap.
1239
+ */
1240
+ const item = await createAsset(WS_CUSTODY, { name: 'Camera body' })
1241
+ await assign(item.id, ALICE)
1242
+ const { asset } = await transfer(item.id, BOB, 'Handing it to Bob')
1243
+ expect(asset.custodianUserId).toBe(BOB)
1244
+ expect(asset.status).toBe('assigned')
1245
+
1246
+ const rows = await periodsOf(item.id)
1247
+ expect(rows).toHaveLength(2)
1248
+ const [open, closed] = rows
1249
+ expect(open?.userId).toBe(BOB)
1250
+ expect(open?.effectiveTo).toBeNull()
1251
+ expect(closed?.userId).toBe(ALICE)
1252
+ expect(closed?.effectiveTo, 'the periods abut exactly').toBe(open?.effectiveFrom)
1253
+ expect(await actionsOf(item.id)).toEqual(['transferred', 'assigned', 'created'])
1254
+ })
1255
+
1256
+ it('records who received it and who gave it up, as ids', async () => {
1257
+ const item = await createAsset(WS_CUSTODY, { name: 'Tripod' })
1258
+ await assign(item.id, ALICE)
1259
+ await transfer(item.id, BOB)
1260
+ await take(item.id)
1261
+ const { items } = await call(inv.assets.history, { workspaceId: WS_CUSTODY, assetId: item.id }, ctx())
1262
+ const byAction = new Map(items.map((e) => [e.action, e.data]))
1263
+ expect(byAction.get('assigned')).toMatchObject({ userId: ALICE })
1264
+ expect(byAction.get('transferred')).toMatchObject({ userId: BOB, previousUserId: ALICE })
1265
+ // A return stores no `userId` at all — the client reads `previousUserId` for that sentence.
1266
+ expect(byAction.get('returned')).toMatchObject({ previousUserId: BOB })
1267
+ })
1268
+
1269
+ it('refuses assigning something somebody already holds, with a sentence rather than a 500', async () => {
1270
+ const item = await createAsset(WS_CUSTODY, { name: 'Contested drill' })
1271
+ await assign(item.id, ALICE)
1272
+ await expect(assign(item.id, BOB)).rejects.toSatisfy(
1273
+ (err: unknown) => codeOf(err) === 'CONFLICT' && /holding this item/i.test(messageOf(err)),
1274
+ )
1275
+ })
1276
+
1277
+ it('refuses handing on and taking back something nobody holds', async () => {
1278
+ const item = await createAsset(WS_CUSTODY, { name: 'Spare monitor' })
1279
+ expect(await refusedWith(() => transfer(item.id, BOB))).toBe('CONFLICT')
1280
+ expect(await refusedWith(() => take(item.id))).toBe('CONFLICT')
1281
+ })
1282
+
1283
+ it('refuses handing over an archived item', async () => {
1284
+ const item = await createAsset(WS_CUSTODY, { name: 'Retired scanner' })
1285
+ await call(inv.assets.archive, { workspaceId: WS_CUSTODY, assetId: item.id, archived: true }, ctx())
1286
+ expect(await refusedWith(() => assign(item.id, BOB))).toBe('CONFLICT')
1287
+ })
1288
+
1289
+ it('refuses archiving something somebody is still holding', async () => {
1290
+ // Somebody is answerable for the thing; taking it out of the register does not change that, it
1291
+ // only stops anybody being able to find out — and "what does Bob still have?" excludes archived
1292
+ // rows, so the item would quietly stop being counted while still being in his bag.
1293
+ const item = await createAsset(WS_CUSTODY, { name: 'Held laptop' })
1294
+ await assign(item.id, BOB)
1295
+ expect(
1296
+ await refusedWith(() =>
1297
+ call(inv.assets.archive, { workspaceId: WS_CUSTODY, assetId: item.id, archived: true }, ctx()),
1298
+ ),
1299
+ ).toBe('CONFLICT')
1300
+ })
1301
+
1302
+ it('answers what one person is holding, and stops counting it once it comes back', async () => {
1303
+ const held = await createAsset(WS_CUSTODY, { name: 'Bob’s headset' })
1304
+ await assign(held.id, BOB)
1305
+ const before = await call(inv.custody.byUser, { workspaceId: WS_CUSTODY, userId: BOB }, ctx())
1306
+ expect(before.items.map((a) => a.id)).toContain(held.id)
1307
+ await take(held.id)
1308
+ const after = await call(inv.custody.byUser, { workspaceId: WS_CUSTODY, userId: BOB }, ctx())
1309
+ expect(after.items.map((a) => a.id)).not.toContain(held.id)
1310
+ })
1311
+
1312
+ it('does not show one workspace another’s custody trail', async () => {
1313
+ const mine = await createAsset(WS_CUSTODY, { name: 'Ours alone' })
1314
+ await assign(mine.id, BOB)
1315
+ expect(
1316
+ await refusedWith(() =>
1317
+ call(inv.custody.history, { workspaceId: WS_B, assetId: mine.id }, { context: asUser(BOB, WS_B) }),
1318
+ ),
1319
+ 'an empty list would read as "nothing has ever happened to it", which is not the truth',
1320
+ ).toBe('NOT_FOUND')
1321
+ })
1322
+ })
1323
+
1324
+ /**
1325
+ * The race the exclusion constraint exists for.
1326
+ *
1327
+ * `inventory_custody_no_overlap` makes two open periods for one asset impossible in the database
1328
+ * rather than merely unlikely in the service — there is deliberately no `select … for update` on the
1329
+ * asset row, because locking would serialise two people pressing *Hand over* into two successful
1330
+ * handovers, which is a worse answer wearing the clothes of a safer one.
1331
+ *
1332
+ * What the service owes the loser is a sentence they can act on. Without the translation in
1333
+ * `db-errors.ts` the loser is shown drizzle's own "Failed query: insert into
1334
+ * mod_inventory.custody_periods …", which tells a person nothing and reads as the product breaking
1335
+ * rather than as somebody else getting there first.
1336
+ */
1337
+ describe('two people handing the same item over at once', () => {
1338
+ it('lets exactly one win, and tells the other what happened', async () => {
1339
+ /**
1340
+ * Two requests, launched together.
1341
+ *
1342
+ * Which of the two guards catches the loser is **timing**, and deliberately not asserted: on a
1343
+ * laptop the first transaction usually commits before the second one reads, so the loser meets
1344
+ * the service's own "somebody else is holding this" check; under real concurrency it gets past
1345
+ * that read and the constraint catches it instead. Both are correct, and pinning the message
1346
+ * here would make the test fail on a slower machine for no defect. What must hold either way is
1347
+ * that exactly one period opens and the loser is told something a person can act on — the
1348
+ * constraint's own path is proven on its own below.
1349
+ */
1350
+ const item = await createAsset(WS_CUSTODY, { name: 'One projector, two hands' })
1351
+ const attempt = (userId: string) =>
1352
+ call(
1353
+ inv.custody.assign,
1354
+ { workspaceId: WS_CUSTODY, assetId: item.id, userId },
1355
+ { context: asUser(userId, WS_CUSTODY) },
1356
+ )
1357
+
1358
+ const results = await Promise.allSettled([attempt(ALICE), attempt(BOB)])
1359
+ const won = results.filter((r) => r.status === 'fulfilled')
1360
+ const lost = results.filter((r) => r.status === 'rejected')
1361
+ expect(won, 'exactly one of the two may open a period').toHaveLength(1)
1362
+ expect(lost).toHaveLength(1)
1363
+
1364
+ const reason = (lost[0] as PromiseRejectedResult).reason
1365
+ expect(codeOf(reason), 'a lost race is a conflict, not an unhandled 500').toBe('CONFLICT')
1366
+ expect(
1367
+ messageOf(reason),
1368
+ 'the loser must not be shown drizzle’s "Failed query: insert into …"',
1369
+ ).not.toMatch(/failed query/i)
1370
+ expect(messageOf(reason), 'and it has to be a sentence, not a constraint name').toMatch(/\s/)
1371
+
1372
+ // And the database agrees with the winner: one open period, one custodian, one status.
1373
+ const rows = await call(
1374
+ inv.custody.history,
1375
+ { workspaceId: WS_CUSTODY, assetId: item.id },
1376
+ {
1377
+ context: asUser(ALICE, WS_CUSTODY),
1378
+ },
1379
+ )
1380
+ expect(rows.filter((p) => p.effectiveTo === null)).toHaveLength(1)
1381
+ const after = await call(
1382
+ inv.assets.get,
1383
+ { workspaceId: WS_CUSTODY, assetId: item.id },
1384
+ {
1385
+ context: asUser(ALICE, WS_CUSTODY),
1386
+ },
1387
+ )
1388
+ expect(after.status).toBe('assigned')
1389
+ expect(after.custodianUserId).toBe(rows.find((p) => p.effectiveTo === null)?.userId)
1390
+ })
1391
+
1392
+ it('turns the exclusion violation itself into a sentence, not a driver dump', async () => {
1393
+ /**
1394
+ * The constraint's own path, forced rather than raced for.
1395
+ *
1396
+ * The test above cannot guarantee it reaches here: two requests launched together usually
1397
+ * serialise on a laptop, and the loser meets the service's read-first check instead. So this one
1398
+ * drives `CustodyService` through two transactions held open at once, which is exactly the
1399
+ * interleaving a busy instance produces and a `Promise.all` on one event loop does not:
1400
+ *
1401
+ * 1. A inserts its period and **does not commit**;
1402
+ * 2. B reads — A's row is invisible, so B correctly believes nobody holds the item — and
1403
+ * inserts, where Postgres blocks it on `inventory_custody_no_overlap`;
1404
+ * 3. A commits, B's insert is refused with SQLSTATE 23P01.
1405
+ *
1406
+ * Without `db-errors.ts` walking `cause` to find the driver's `constraint`, step 3 surfaces as
1407
+ * drizzle's "Failed query: insert into mod_inventory.custody_periods …" — an unhandled 500 that
1408
+ * reads as the product breaking rather than as somebody else getting there first.
1409
+ *
1410
+ * It calls the service rather than the router on purpose: the router would open its own
1411
+ * transaction and there would be nothing to hold. The gates the router adds are checked by
1412
+ * `module.test.ts`, and the subject here is the database.
1413
+ *
1414
+ * **Both handovers are waited for rather than slept through**, and that is the difference
1415
+ * between a test and a coin toss. Sleeping 100ms between the steps failed about one run in ten
1416
+ * on a loaded machine, in the worst way: if B got to its insert before A's landed, *B* won and
1417
+ * *A* was the one refused, so the test failed on `await a` with an error about the wrong
1418
+ * transaction. Step 1 is signalled from inside A's own transaction, and step 2 is read out of
1419
+ * `pg_stat_activity` — B is only unblocked once Postgres says it is waiting on a lock.
1420
+ */
1421
+ const svc = inventoryServices(kernel)
1422
+ const item = await createAsset(WS_CUSTODY, { name: 'Held open on purpose' })
1423
+
1424
+ let aHasInserted!: () => void
1425
+ const inserted = new Promise<void>((resolve) => {
1426
+ aHasInserted = resolve
1427
+ })
1428
+ let commitA!: () => void
1429
+ const holdA = new Promise<void>((resolve) => {
1430
+ commitA = resolve
1431
+ })
1432
+
1433
+ const a = kernel.database.withWorkspace(
1434
+ WS_CUSTODY,
1435
+ async (tx) => {
1436
+ await svc.custody.assign(tx, WS_CUSTODY, ALICE, item.id, ALICE, null, RECORDS_REPAIRS)
1437
+ aHasInserted()
1438
+ await holdA
1439
+ },
1440
+ { userId: ALICE },
1441
+ )
1442
+ // A's period row exists and is uncommitted: its lock is held, so B cannot get in front of it.
1443
+ await inserted
1444
+
1445
+ const b = kernel.database.withWorkspace(
1446
+ WS_CUSTODY,
1447
+ (tx) => svc.custody.assign(tx, WS_CUSTODY, BOB, item.id, BOB, null, RECORDS_REPAIRS),
1448
+ { userId: BOB },
1449
+ )
1450
+ /**
1451
+ * Attached now, so the rejection this test is *about* is never an unhandled one while the poll
1452
+ * below is running — vitest fails a suite on those, and it would point at the wrong thing.
1453
+ */
1454
+ const bSettled = b.then(
1455
+ () => null,
1456
+ (err: unknown) => err,
1457
+ )
1458
+
1459
+ await waitForBlockedBackend()
1460
+ commitA()
1461
+ await a
1462
+
1463
+ const reason = await bSettled
1464
+ expect(reason, 'B must lose: A inserted first and held the lock').not.toBeNull()
1465
+ expect(codeOf(reason), 'an exclusion violation is a conflict').toBe('CONFLICT')
1466
+ expect(messageOf(reason)).not.toMatch(/failed query/i)
1467
+ expect(messageOf(reason), 'and it says what to do about it').toMatch(/reload/i)
1468
+
1469
+ // One winner, and the asset agrees with it.
1470
+ const rows = await call(
1471
+ inv.custody.history,
1472
+ { workspaceId: WS_CUSTODY, assetId: item.id },
1473
+ { context: asUser(ALICE, WS_CUSTODY) },
1474
+ )
1475
+ expect(rows.filter((p) => p.effectiveTo === null)).toHaveLength(1)
1476
+ expect(rows[0]?.userId).toBe(ALICE)
1477
+ })
1478
+ })
1479
+
1480
+ /**
1481
+ * The instants a custody trail is made of.
1482
+ *
1483
+ * Both of these were written by `max(now(), open.effectiveFrom)`, and both of them are what happens
1484
+ * when that expression's two arguments stop being ordered the way it assumes. A JS `Date` resolves
1485
+ * to the millisecond, so "two handovers in the same millisecond" is not exotic, and a clock that
1486
+ * steps backwards — an NTP correction, a VM resuming, two `core` replicas disagreeing — puts a
1487
+ * timestamp already in the table ahead of `now()` for as long as the correction lasts.
1488
+ *
1489
+ * The rows are written directly here rather than raced for, because the mechanism is arithmetic and
1490
+ * not concurrency: a period whose start is ahead of the clock is exactly the state either cause
1491
+ * leaves behind, and it is the state the code has to survive.
1492
+ */
1493
+ describe('the clock a handover is dated by', () => {
1494
+ const ctx = () => ({ context: asUser(ALICE, WS_CUSTODY) })
1495
+
1496
+ it('never records a period nobody held the item for', async () => {
1497
+ const item = await createAsset(WS_CUSTODY, { name: 'Handed on within a millisecond' })
1498
+ // A period that started "now" as far as the next change is concerned. `max(now, from)` collapsed
1499
+ // the closing instant onto the opening one, and `[t, t)` is an *empty* range — which is why the
1500
+ // exclusion constraint waved it through instead of catching it: an empty range overlaps nothing.
1501
+ // What it leaves behind is a row saying somebody held the item for no time at all.
1502
+ const started = new Date(Date.now() + 5_000)
1503
+ await inWs(WS_CUSTODY)((tx) =>
1504
+ tx.insert(custodyPeriods).values({
1505
+ workspaceId: WS_CUSTODY,
1506
+ assetId: item.id,
1507
+ userId: ALICE,
1508
+ effectiveFrom: started,
1509
+ }),
1510
+ )
1511
+
1512
+ await call(inv.custody.transfer, { workspaceId: WS_CUSTODY, assetId: item.id, userId: BOB }, ctx())
1513
+
1514
+ const periods = await call(inv.custody.history, { workspaceId: WS_CUSTODY, assetId: item.id }, ctx())
1515
+ const closed = periods.find((p) => p.userId === ALICE)
1516
+ const open = periods.find((p) => p.effectiveTo === null)
1517
+ expect(
1518
+ new Date(closed?.effectiveTo ?? 0).getTime(),
1519
+ 'a period that ends when it began is a name nobody can act on in "who had this in March"',
1520
+ ).toBeGreaterThan(new Date(closed?.effectiveFrom ?? 0).getTime())
1521
+ // And still abutting, so the trail has no gap during which the item was held by nobody.
1522
+ expect(open?.effectiveFrom).toBe(closed?.effectiveTo)
1523
+ expect(open?.userId).toBe(BOB)
1524
+ })
1525
+
1526
+ it('opens the next period after the last one ended, rather than refusing the handover', async () => {
1527
+ const item = await createAsset(WS_CUSTODY, { name: 'After the clock stepped back' })
1528
+ const from = new Date(Date.now() + 10_000)
1529
+ const to = new Date(Date.now() + 20_000)
1530
+ await inWs(WS_CUSTODY)((tx) =>
1531
+ tx.insert(custodyPeriods).values({
1532
+ workspaceId: WS_CUSTODY,
1533
+ assetId: item.id,
1534
+ userId: ALICE,
1535
+ effectiveFrom: from,
1536
+ effectiveTo: to,
1537
+ }),
1538
+ )
1539
+
1540
+ /**
1541
+ * Nobody is holding it — the period is closed — so this is an ordinary handover and it has to
1542
+ * work. `assign` dated it `now()`, which is *behind* that close, so the new period overlapped a
1543
+ * finished one, the exclusion constraint refused it, and the person was told "somebody changed
1544
+ * who is holding this a moment before you did. Reload." Nobody had, and reloading changes
1545
+ * nothing: the handover is refused again every time, until the clock catches up.
1546
+ */
1547
+ const { asset, period } = await call(
1548
+ inv.custody.assign,
1549
+ { workspaceId: WS_CUSTODY, assetId: item.id, userId: BOB },
1550
+ ctx(),
1551
+ )
1552
+ expect(asset.custodianUserId).toBe(BOB)
1553
+ expect(
1554
+ new Date(period?.effectiveFrom ?? 0).getTime(),
1555
+ 'the trail decides where the next period starts, not the clock',
1556
+ ).toBeGreaterThan(to.getTime())
1557
+ })
1558
+ })
1559
+
1560
+ /**
1561
+ * Somebody who is not there.
1562
+ *
1563
+ * `custody.assign` and `custody.transfer` write a uuid into the two columns that answer "who is
1564
+ * answerable for this" for the rest of the item's life, and then send that person a notification.
1565
+ * Nothing looked the id up: any uuid was accepted, so a workspace could record a stranger — or a
1566
+ * typo — as holding company property, and the register would look entirely plausible, because every
1567
+ * screen renders an id it cannot resolve as "a former member".
1568
+ */
1569
+ describe('handing an item to somebody who is not a member', () => {
1570
+ it('refuses the assignment, and writes nothing', async () => {
1571
+ const item = await createAsset(WS_CUSTODY, { name: 'Not going to a stranger' })
1572
+ const stranger = randomUUID()
1573
+
1574
+ const code = await refusedWith(() =>
1575
+ call(
1576
+ inv.custody.assign,
1577
+ { workspaceId: WS_CUSTODY, assetId: item.id, userId: stranger },
1578
+ { context: asUser(ALICE, WS_CUSTODY) },
1579
+ ),
1580
+ )
1581
+ expect(code, 'naming somebody who is not there is a bad request, not a conflict').toBe('BAD_REQUEST')
1582
+
1583
+ const periods = await call(
1584
+ inv.custody.history,
1585
+ { workspaceId: WS_CUSTODY, assetId: item.id },
1586
+ { context: asUser(ALICE, WS_CUSTODY) },
1587
+ )
1588
+ expect(periods, 'and no period was opened on the way to being refused').toEqual([])
1589
+ const after = await call(
1590
+ inv.assets.get,
1591
+ { workspaceId: WS_CUSTODY, assetId: item.id },
1592
+ { context: asUser(ALICE, WS_CUSTODY) },
1593
+ )
1594
+ expect({ status: after.status, holder: after.custodianUserId }).toEqual({
1595
+ status: 'in_stock',
1596
+ holder: null,
1597
+ })
1598
+ })
1599
+
1600
+ it('refuses handing it on to one, and leaves it with whoever has it', async () => {
1601
+ const item = await createAsset(WS_CUSTODY, { name: 'Staying with Bob' })
1602
+ await call(
1603
+ inv.custody.assign,
1604
+ { workspaceId: WS_CUSTODY, assetId: item.id, userId: BOB },
1605
+ { context: asUser(ALICE, WS_CUSTODY) },
1606
+ )
1607
+
1608
+ const code = await refusedWith(() =>
1609
+ call(
1610
+ inv.custody.transfer,
1611
+ { workspaceId: WS_CUSTODY, assetId: item.id, userId: randomUUID() },
1612
+ { context: asUser(ALICE, WS_CUSTODY) },
1613
+ ),
1614
+ )
1615
+ expect(code).toBe('BAD_REQUEST')
1616
+
1617
+ const after = await call(
1618
+ inv.assets.get,
1619
+ { workspaceId: WS_CUSTODY, assetId: item.id },
1620
+ { context: asUser(ALICE, WS_CUSTODY) },
1621
+ )
1622
+ expect(after.custodianUserId, 'a refused handover does not release the person holding it').toBe(BOB)
1623
+ })
1624
+
1625
+ it('tells a stranger nothing, because there was nothing to tell them about', async () => {
1626
+ const item = await createAsset(WS_CUSTODY, { name: 'No notification for a stranger' })
1627
+ const stranger = randomUUID()
1628
+ NOTIFICATIONS.length = 0
1629
+ await refusedWith(() =>
1630
+ call(
1631
+ inv.custody.assign,
1632
+ { workspaceId: WS_CUSTODY, assetId: item.id, userId: stranger },
1633
+ { context: asUser(ALICE, WS_CUSTODY) },
1634
+ ),
1635
+ )
1636
+ expect(NOTIFICATIONS.filter((n) => n.userId === stranger)).toEqual([])
1637
+ })
1638
+ })
1639
+
1640
+ /**
1641
+ * Two transactions, one `assets.status`.
1642
+ *
1643
+ * `status` is stored rather than computed, and it is derived from two facts written by two different
1644
+ * services — who is holding the item, and whether a repair is open against it. Each service read the
1645
+ * *other's* fact without a lock, so the two derivations ran against snapshots taken before the other
1646
+ * had committed and the second write silently discarded the first. A plain lost update, and the
1647
+ * result is a status matching neither fact.
1648
+ *
1649
+ * Both tests drive the services through two transactions held open at once, which is the
1650
+ * interleaving a busy instance produces and a `Promise.all` on one event loop does not. Each step is
1651
+ * *waited for* rather than slept through: `waitForBlockedBackend` reads Postgres's own view of who
1652
+ * is queued, so which transaction wins is decided by the test rather than by machine load.
1653
+ */
1654
+ describe('a handover and a repair at the same instant', () => {
1655
+ it('does not report an item as back in the office while it is at the workshop', async () => {
1656
+ const svc = inventoryServices(kernel)
1657
+ const item = await createAsset(WS_CUSTODY, { name: 'Assigned as it left for the workshop' })
1658
+
1659
+ let repairWritten!: () => void
1660
+ const written = new Promise<void>((resolve) => {
1661
+ repairWritten = resolve
1662
+ })
1663
+ let commitRepair!: () => void
1664
+ const holdRepair = new Promise<void>((resolve) => {
1665
+ commitRepair = resolve
1666
+ })
1667
+
1668
+ const sending = kernel.database.withWorkspace(
1669
+ WS_CUSTODY,
1670
+ async (tx) => {
1671
+ await svc.repairs.create(tx, WS_CUSTODY, ALICE, item.id, { summary: 'Cracked screen' })
1672
+ repairWritten()
1673
+ await holdRepair
1674
+ },
1675
+ { userId: ALICE },
1676
+ )
1677
+ await written
1678
+
1679
+ // The handover reads "no repair is open" — the insert above is uncommitted, so it genuinely is
1680
+ // invisible — and used to write `assigned` over the `under_repair` the repair had just decided.
1681
+ const handing = kernel.database.withWorkspace(
1682
+ WS_CUSTODY,
1683
+ (tx) => svc.custody.assign(tx, WS_CUSTODY, ALICE, item.id, BOB, null, RECORDS_REPAIRS),
1684
+ { userId: ALICE },
1685
+ )
1686
+ const handed = handing.then(
1687
+ () => null,
1688
+ (err: unknown) => err,
1689
+ )
1690
+
1691
+ await waitForBlockedBackend()
1692
+ commitRepair()
1693
+ await sending
1694
+ expect(await handed, 'a repair never refuses a handover; this one is legitimate').toBeNull()
1695
+
1696
+ const after = await call(
1697
+ inv.assets.get,
1698
+ { workspaceId: WS_CUSTODY, assetId: item.id },
1699
+ { context: asUser(ALICE, WS_CUSTODY) },
1700
+ )
1701
+ // Both facts are true at once, and the status is the one that answers *where is it*.
1702
+ expect({ status: after.status, holder: after.custodianUserId }).toEqual({
1703
+ status: 'under_repair',
1704
+ holder: BOB,
1705
+ })
1706
+ })
1707
+
1708
+ it('does not leave an item under repair after the repair it was under has been logged back', async () => {
1709
+ const svc = inventoryServices(kernel)
1710
+ const item = await createAsset(WS_CUSTODY, { name: 'Came back as it was handed back' })
1711
+ await call(
1712
+ inv.custody.assign,
1713
+ { workspaceId: WS_CUSTODY, assetId: item.id, userId: BOB },
1714
+ { context: asUser(ALICE, WS_CUSTODY) },
1715
+ )
1716
+ const { repair } = await call(
1717
+ inv.repairs.create,
1718
+ { workspaceId: WS_CUSTODY, assetId: item.id, summary: 'Hinge' },
1719
+ { context: asUser(ALICE, WS_CUSTODY) },
1720
+ )
1721
+
1722
+ let completed!: () => void
1723
+ const done = new Promise<void>((resolve) => {
1724
+ completed = resolve
1725
+ })
1726
+ let commitComplete!: () => void
1727
+ const holdComplete = new Promise<void>((resolve) => {
1728
+ commitComplete = resolve
1729
+ })
1730
+
1731
+ const finishing = kernel.database.withWorkspace(
1732
+ WS_CUSTODY,
1733
+ async (tx) => {
1734
+ await svc.repairs.complete(tx, WS_CUSTODY, ALICE, repair.id, {})
1735
+ completed()
1736
+ await holdComplete
1737
+ },
1738
+ { userId: ALICE },
1739
+ )
1740
+ await done
1741
+
1742
+ // The return reads "a repair is open" — the completion above is uncommitted — and used to write
1743
+ // `under_repair` on an item that had just come home, with nobody holding it either.
1744
+ const taking = kernel.database.withWorkspace(
1745
+ WS_CUSTODY,
1746
+ (tx) => svc.custody.return(tx, WS_CUSTODY, ALICE, item.id, null, RECORDS_REPAIRS),
1747
+ { userId: ALICE },
1748
+ )
1749
+ const taken = taking.then(
1750
+ () => null,
1751
+ (err: unknown) => err,
1752
+ )
1753
+
1754
+ await waitForBlockedBackend()
1755
+ commitComplete()
1756
+ await finishing
1757
+ expect(await taken).toBeNull()
1758
+
1759
+ const after = await call(
1760
+ inv.assets.get,
1761
+ { workspaceId: WS_CUSTODY, assetId: item.id },
1762
+ { context: asUser(ALICE, WS_CUSTODY) },
1763
+ )
1764
+ expect({ status: after.status, holder: after.custodianUserId }).toEqual({
1765
+ status: 'in_stock',
1766
+ holder: null,
1767
+ })
1768
+ })
1769
+ })
1770
+
1771
+ /**
1772
+ * Archiving, against the two things it says are impossible.
1773
+ *
1774
+ * `assets.archive` refuses to retire an item somebody is holding or one that is away for repair, and
1775
+ * both refusals used to read their fact with a plain select — so doing the two things at once
1776
+ * reached exactly the state the refusal calls impossible: an archived asset with a custody period
1777
+ * that never ends, which "what is Ada still holding?" stops counting because that question excludes
1778
+ * archived rows. A check whose answer another transaction may already have changed is not a check.
1779
+ */
1780
+ describe('archiving an item somebody is reaching for', () => {
1781
+ it('refuses once the handover it raced has committed', async () => {
1782
+ const svc = inventoryServices(kernel)
1783
+ const item = await createAsset(WS_CUSTODY, { name: 'Archived out from under Bob' })
1784
+
1785
+ let handed!: () => void
1786
+ const done = new Promise<void>((resolve) => {
1787
+ handed = resolve
1788
+ })
1789
+ let commitHandover!: () => void
1790
+ const holdHandover = new Promise<void>((resolve) => {
1791
+ commitHandover = resolve
1792
+ })
1793
+
1794
+ const handing = kernel.database.withWorkspace(
1795
+ WS_CUSTODY,
1796
+ async (tx) => {
1797
+ await svc.custody.assign(tx, WS_CUSTODY, ALICE, item.id, BOB, null, RECORDS_REPAIRS)
1798
+ handed()
1799
+ await holdHandover
1800
+ },
1801
+ { userId: ALICE },
1802
+ )
1803
+ await done
1804
+
1805
+ const archiving = kernel.database.withWorkspace(
1806
+ WS_CUSTODY,
1807
+ (tx) => svc.assets.archive(tx, WS_CUSTODY, ALICE, item.id, true, RECORDS_REPAIRS),
1808
+ { userId: ALICE },
1809
+ )
1810
+ const archived = archiving.then(
1811
+ () => null,
1812
+ (err: unknown) => err,
1813
+ )
1814
+
1815
+ await waitForBlockedBackend()
1816
+ commitHandover()
1817
+ await handing
1818
+
1819
+ const refusal = await archived
1820
+ expect(refusal, 'the archive must lose: Bob is holding it by the time it decides').not.toBeNull()
1821
+ expect(codeOf(refusal)).toBe('CONFLICT')
1822
+ expect(messageOf(refusal)).toMatch(/still holding/i)
1823
+
1824
+ const after = await call(
1825
+ inv.assets.get,
1826
+ { workspaceId: WS_CUSTODY, assetId: item.id },
1827
+ { context: asUser(ALICE, WS_CUSTODY) },
1828
+ )
1829
+ expect({ archivedAt: after.archivedAt, holder: after.custodianUserId }).toEqual({
1830
+ archivedAt: null,
1831
+ holder: BOB,
1832
+ })
1833
+ })
1834
+
1835
+ it('makes the handover it raced lose when the archive got there first', async () => {
1836
+ const svc = inventoryServices(kernel)
1837
+ const item = await createAsset(WS_CUSTODY, { name: 'Handed over as it was retired' })
1838
+
1839
+ let retired!: () => void
1840
+ const done = new Promise<void>((resolve) => {
1841
+ retired = resolve
1842
+ })
1843
+ let commitArchive!: () => void
1844
+ const holdArchive = new Promise<void>((resolve) => {
1845
+ commitArchive = resolve
1846
+ })
1847
+
1848
+ const archiving = kernel.database.withWorkspace(
1849
+ WS_CUSTODY,
1850
+ async (tx) => {
1851
+ await svc.assets.archive(tx, WS_CUSTODY, ALICE, item.id, true, RECORDS_REPAIRS)
1852
+ retired()
1853
+ await holdArchive
1854
+ },
1855
+ { userId: ALICE },
1856
+ )
1857
+ await done
1858
+
1859
+ // The handover reads an asset that is not archived yet — it genuinely is not, from here — and
1860
+ // used to insert its period and stamp the columns over the top of the archive.
1861
+ const handing = kernel.database.withWorkspace(
1862
+ WS_CUSTODY,
1863
+ (tx) => svc.custody.assign(tx, WS_CUSTODY, ALICE, item.id, BOB, null, RECORDS_REPAIRS),
1864
+ { userId: ALICE },
1865
+ )
1866
+ const handed = handing.then(
1867
+ () => null,
1868
+ (err: unknown) => err,
1869
+ )
1870
+
1871
+ await waitForBlockedBackend()
1872
+ commitArchive()
1873
+ await archiving
1874
+
1875
+ const refusal = await handed
1876
+ expect(refusal, 'the handover must lose: the item left the register before it got there').not.toBeNull()
1877
+ expect(codeOf(refusal)).toBe('CONFLICT')
1878
+ expect(messageOf(refusal)).toMatch(/archived/i)
1879
+
1880
+ // And the rolled-back transaction took its period with it, which is the state the refusal exists
1881
+ // to prevent: an archived asset nobody can find out is still in somebody's bag.
1882
+ const periods = await call(
1883
+ inv.custody.history,
1884
+ { workspaceId: WS_CUSTODY, assetId: item.id },
1885
+ { context: asUser(ALICE, WS_CUSTODY) },
1886
+ )
1887
+ expect(periods).toEqual([])
1888
+ })
1889
+ })
1890
+
1891
+ /**
1892
+ * Reading the history back.
1893
+ *
1894
+ * The rows have been written since 0.2.0 and nothing could read them — a trail nobody can see is a
1895
+ * table, not a feature. The paging is the same keyset discipline `assets.list` uses and the same
1896
+ * cursor codec, because a second bookmark format would be a second set of the three bugs the first
1897
+ * one had.
1898
+ */
1899
+ describe('reading an asset’s timeline', () => {
1900
+ // Lazy, for the reason spelled out above: a describe body runs before `beforeAll`.
1901
+ const ctx = () => ({ context: asUser(ALICE) })
1902
+
1903
+ it('comes back newest first', async () => {
1904
+ const item = await createAsset(WS_A, { name: 'Trail' })
1905
+ await call(inv.assets.update, { workspaceId: WS_A, assetId: item.id, location: 'Desk 1' }, ctx())
1906
+ await call(inv.assets.update, { workspaceId: WS_A, assetId: item.id, location: 'Desk 2' }, ctx())
1907
+ const { items } = await call(inv.assets.history, { workspaceId: WS_A, assetId: item.id }, ctx())
1908
+ expect(items.map((e) => e.action)).toEqual(['updated', 'updated', 'created'])
1909
+ expect(items[0]?.changes).toEqual([{ field: 'location', from: 'Desk 1', to: 'Desk 2' }])
1910
+ })
1911
+
1912
+ it('pages by id, so two entries written in one transaction cannot straddle a boundary', async () => {
1913
+ /**
1914
+ * `create` writes the asset and its `created` entry in one transaction, so `now()` is the same
1915
+ * for both — and any ordering by `occurred_at` would have no order between two entries that
1916
+ * share it. Ordering by the id, which is a uuidv7 and therefore already the clock, is unique.
1917
+ */
1918
+ const item = await createAsset(WS_A, { name: 'Paged trail' })
1919
+ for (let i = 0; i < 5; i++)
1920
+ await call(inv.assets.update, { workspaceId: WS_A, assetId: item.id, location: `Desk ${i}` }, ctx())
1921
+
1922
+ const seen: string[] = []
1923
+ let cursor: string | undefined
1924
+ for (let guard = 0; guard < 10; guard++) {
1925
+ const page = await call(
1926
+ inv.assets.history,
1927
+ { workspaceId: WS_A, assetId: item.id, limit: 2, ...(cursor ? { cursor } : {}) },
1928
+ ctx(),
1929
+ )
1930
+ seen.push(...page.items.map((e) => e.id))
1931
+ if (!page.nextCursor) break
1932
+ cursor = page.nextCursor
1933
+ }
1934
+ expect(seen, 'one create plus five updates').toHaveLength(6)
1935
+ expect(new Set(seen).size, 'no entry repeated across a page boundary').toBe(seen.length)
1936
+ expect([...seen].sort().reverse(), 'and still newest first end to end').toEqual(seen)
1937
+ })
1938
+
1939
+ it('issues a cursor the contract can carry', async () => {
1940
+ const item = await createAsset(WS_A, { name: 'Cursor size' })
1941
+ await call(inv.assets.update, { workspaceId: WS_A, assetId: item.id, location: 'Anywhere' }, ctx())
1942
+ const page = await call(inv.assets.history, { workspaceId: WS_A, assetId: item.id, limit: 1 }, ctx())
1943
+ expect(page.nextCursor).toBeTruthy()
1944
+ expect(() => Cursor.parse(page.nextCursor)).not.toThrow()
1945
+ })
1946
+
1947
+ it('refuses a marker it did not issue rather than handing it to Postgres', async () => {
1948
+ const item = await createAsset(WS_A, { name: 'Bad marker' })
1949
+ expect(
1950
+ await refusedWith(() =>
1951
+ call(
1952
+ inv.assets.history,
1953
+ {
1954
+ workspaceId: WS_A,
1955
+ assetId: item.id,
1956
+ cursor: Buffer.from('{"i":"nope","s":"recent"}').toString('base64url'),
1957
+ },
1958
+ ctx(),
1959
+ ),
1960
+ ),
1961
+ ).toBe('BAD_REQUEST')
1962
+ })
1963
+
1964
+ it('answers "not yours" rather than an empty timeline', async () => {
1965
+ const mine = await createAsset(WS_A, { name: 'Private trail' })
1966
+ expect(
1967
+ await refusedWith(() =>
1968
+ call(inv.assets.history, { workspaceId: WS_B, assetId: mine.id }, { context: asUser(BOB, WS_B) }),
1969
+ ),
1970
+ ).toBe('NOT_FOUND')
1971
+ })
1972
+ })
1973
+
1974
+ /**
1975
+ * Categories, and the filter that had nothing to filter by.
1976
+ *
1977
+ * `assets.list` has taken a `categoryId` since the module existed and nothing could create one, so
1978
+ * the filter had exactly one possible answer. Nothing here deletes: `assets.category_id` carries no
1979
+ * foreign key, so a delete would leave every asset filed under it pointing at a row that is not
1980
+ * there — a blank column, and a timeline entry that loses the name it recorded.
1981
+ */
1982
+ describe('categories', () => {
1983
+ // Lazy, for the reason spelled out above: a describe body runs before `beforeAll`.
1984
+ const ctx = () => ({ context: asUser(ALICE, WS_CAT) })
1985
+ const create = (name: string, order?: number) =>
1986
+ call(inv.categories.create, { workspaceId: WS_CAT, name, order }, ctx())
1987
+ const list = (archived = false) => call(inv.categories.list, { workspaceId: WS_CAT, archived }, ctx())
1988
+
1989
+ it('orders by position and then by name, so a workspace that never reorders still gets a list', async () => {
1990
+ // Every row has order 0 unless somebody says otherwise, so the tiebreak is doing all the work —
1991
+ // and it has to be the name rather than the id, or the picker is in insertion order.
1992
+ await create('Furniture')
1993
+ await create('Cameras')
1994
+ await create('Laptops', 0)
1995
+ await create('Consumables', 5)
1996
+ expect((await list()).map((c) => c.name)).toEqual(['Cameras', 'Furniture', 'Laptops', 'Consumables'])
1997
+ })
1998
+
1999
+ it('refuses a duplicate name with a sentence naming it, not a 500', async () => {
2000
+ await expect(create('Furniture')).rejects.toSatisfy(
2001
+ (err: unknown) => codeOf(err) === 'CONFLICT' && messageOf(err).includes('Furniture'),
2002
+ )
2003
+ })
2004
+
2005
+ it('renames without disturbing anything else', async () => {
2006
+ const made = await create('Toolz')
2007
+ const renamed = await call(
2008
+ inv.categories.update,
2009
+ { workspaceId: WS_CAT, categoryId: made.id, name: 'Tools' },
2010
+ ctx(),
2011
+ )
2012
+ expect(renamed.name).toBe('Tools')
2013
+ expect(renamed.order).toBe(made.order)
2014
+ })
2015
+
2016
+ it('archives and restores, and never deletes', async () => {
2017
+ const made = await create('Seasonal')
2018
+ const archived = await call(
2019
+ inv.categories.archive,
2020
+ { workspaceId: WS_CAT, categoryId: made.id, archived: true },
2021
+ ctx(),
2022
+ )
2023
+ expect(archived.archivedAt).toBeTruthy()
2024
+ expect(
2025
+ (await list()).map((c) => c.id),
2026
+ 'gone from the picker',
2027
+ ).not.toContain(made.id)
2028
+ expect(
2029
+ (await list(true)).map((c) => c.id),
2030
+ 'and still there',
2031
+ ).toContain(made.id)
2032
+
2033
+ const back = await call(
2034
+ inv.categories.archive,
2035
+ { workspaceId: WS_CAT, categoryId: made.id, archived: false },
2036
+ ctx(),
2037
+ )
2038
+ expect(back.archivedAt).toBeNull()
2039
+ })
2040
+
2041
+ it('leaves an asset filed under an archived category still naming it', async () => {
2042
+ // The whole reason this archives rather than deletes: the row goes on being able to say what
2043
+ // it is, and the timeline entry that recorded the move keeps the name it recorded.
2044
+ const made = await create('Retired kit')
2045
+ const item = await createAsset(WS_CAT, { name: 'Old switch', categoryId: made.id })
2046
+ await call(inv.categories.archive, { workspaceId: WS_CAT, categoryId: made.id, archived: true }, ctx())
2047
+ const after = await call(inv.assets.get, { workspaceId: WS_CAT, assetId: item.id }, ctx())
2048
+ expect(after.categoryId).toBe(made.id)
2049
+ })
2050
+
2051
+ it('makes the asset list’s category filter mean something', async () => {
2052
+ const laptops = (await list(true)).find((c) => c.name === 'Laptops')
2053
+ expect(laptops).toBeTruthy()
2054
+ const filed = await createAsset(WS_CAT, { name: 'ThinkPad', categoryId: laptops?.id })
2055
+ await createAsset(WS_CAT, { name: 'Unfiled kettle' })
2056
+ const narrowed = await listAssets(WS_CAT, { categoryId: laptops?.id }, ALICE)
2057
+ expect(narrowed.items.map((a) => a.id)).toEqual([filed.id])
2058
+ })
2059
+
2060
+ it('does not let one workspace see another’s categories', async () => {
2061
+ const mine = await create('Only ours')
2062
+ const theirs = await call(
2063
+ inv.categories.list,
2064
+ { workspaceId: WS_B, archived: true },
2065
+ { context: asUser(BOB, WS_B) },
2066
+ )
2067
+ expect(theirs.map((c) => c.id)).not.toContain(mine.id)
2068
+ })
2069
+
2070
+ it('reports a category from another workspace as missing rather than editing it', async () => {
2071
+ const mine = await create('Untouchable')
2072
+ expect(
2073
+ await refusedWith(() =>
2074
+ call(
2075
+ inv.categories.update,
2076
+ { workspaceId: WS_B, categoryId: mine.id, name: 'Stolen' },
2077
+ { context: asUser(BOB, WS_B) },
2078
+ ),
2079
+ ),
2080
+ ).toBe('NOT_FOUND')
2081
+ })
2082
+
2083
+ /**
2084
+ * What a change event *says*, which nothing could assert on before.
2085
+ *
2086
+ * `update` and `archive` announced a second change as `entity: 'asset'` carrying the category's
2087
+ * id — a row that does not exist, described to every subscriber and to every other service. It
2088
+ * looked harmless because the client's blunt `['inventory', 'asset']` invalidation fires on any
2089
+ * asset change whatever the id, so the screens refreshed anyway; the id-scoped invalidation beside
2090
+ * it matched nothing, and anything that acted on `{entity, id}` acted on a fiction.
2091
+ *
2092
+ * Nothing replaced it, because nothing needs to: a category's name is resolved on the client from
2093
+ * the categories query itself, and archiving one leaves `assets.category_id` exactly where it was.
2094
+ */
2095
+ it('announces the row that changed, and never an asset id that is not one', async () => {
2096
+ const row = await create('Announced correctly')
2097
+
2098
+ CHANGES.length = 0
2099
+ await call(inv.categories.update, { workspaceId: WS_CAT, categoryId: row.id, name: 'Renamed' }, ctx())
2100
+ expect(CHANGES.map((c) => ({ entity: c.entity, id: c.id, op: c.op }))).toEqual([
2101
+ { entity: 'category', id: row.id, op: 'updated' },
2102
+ ])
2103
+
2104
+ CHANGES.length = 0
2105
+ await call(inv.categories.archive, { workspaceId: WS_CAT, categoryId: row.id, archived: true }, ctx())
2106
+ expect(CHANGES.map((c) => ({ entity: c.entity, id: c.id, op: c.op }))).toEqual([
2107
+ { entity: 'category', id: row.id, op: 'updated' },
2108
+ ])
2109
+
2110
+ CHANGES.length = 0
2111
+ await create('Also announced correctly')
2112
+ expect(CHANGES.map((c) => c.entity)).toEqual(['category'])
2113
+ })
2114
+ })
2115
+
2116
+ // ---------------------------------------------------------------------------------------------
2117
+
2118
+ const sendForRepair = (
2119
+ workspaceId: WorkspaceId,
2120
+ assetId: string,
2121
+ input: { summary: string; vendor?: string; costMinor?: number; currency?: string; sentOn?: string },
2122
+ userId = ALICE,
2123
+ ) => call(inv.repairs.create, { workspaceId, assetId, ...input }, { context: asUser(userId, workspaceId) })
2124
+
2125
+ const completeRepair = (
2126
+ workspaceId: WorkspaceId,
2127
+ repairId: string,
2128
+ input: { returnedOn?: string; costMinor?: number; currency?: string } = {},
2129
+ userId = ALICE,
2130
+ ) => call(inv.repairs.complete, { workspaceId, repairId, ...input }, { context: asUser(userId, workspaceId) })
2131
+
2132
+ const statusOf = async (workspaceId: WorkspaceId, assetId: string) =>
2133
+ (await call(inv.assets.get, { workspaceId, assetId }, { context: asUser(ALICE, workspaceId) })).status
2134
+
2135
+ /**
2136
+ * Repairs, and the rule that makes them more than another table: **a repair is not custody**.
2137
+ *
2138
+ * An item at a repairer is still whoever's it was. Everything below is one of the two halves of
2139
+ * that — the status moves and the custodian does not — or one of the refusals that keeps the two
2140
+ * counts of "what is away" from being able to disagree.
2141
+ */
2142
+ describe('sending an item for repair', () => {
2143
+ const anAsset = (name: string) => createAsset(WS_REPAIR, { name })
2144
+
2145
+ it('puts it under repair and leaves the custodian alone', async () => {
2146
+ const asset = await anAsset('Handed-out laptop')
2147
+ await call(
2148
+ inv.custody.assign,
2149
+ { workspaceId: WS_REPAIR, assetId: asset.id, userId: BOB },
2150
+ { context: asUser(ALICE, WS_REPAIR) },
2151
+ )
2152
+ const { asset: away } = await sendForRepair(WS_REPAIR, asset.id, { summary: 'Cracked screen' })
2153
+ // The half that is easy to get wrong: Bob is still answerable for it.
2154
+ expect({ status: away.status, holder: away.custodianUserId }).toEqual({
2155
+ status: 'under_repair',
2156
+ holder: BOB,
2157
+ })
2158
+ })
2159
+
2160
+ it('gives it back to whoever still holds it, not to stock', async () => {
2161
+ const asset = await anAsset('Battery swap')
2162
+ await call(
2163
+ inv.custody.assign,
2164
+ { workspaceId: WS_REPAIR, assetId: asset.id, userId: BOB },
2165
+ { context: asUser(ALICE, WS_REPAIR) },
2166
+ )
2167
+ const { repair } = await sendForRepair(WS_REPAIR, asset.id, { summary: 'Battery' })
2168
+ const { asset: back } = await completeRepair(WS_REPAIR, repair.id)
2169
+ // Completing straight to `in_stock` would release Bob from something nobody decided to take
2170
+ // off him — silently, and only visible the next time somebody went looking for the laptop.
2171
+ expect({ status: back.status, holder: back.custodianUserId }).toEqual({
2172
+ status: 'assigned',
2173
+ holder: BOB,
2174
+ })
2175
+ })
2176
+
2177
+ it('goes to stock when nobody was holding it', async () => {
2178
+ const asset = await anAsset('Spare monitor')
2179
+ const { repair } = await sendForRepair(WS_REPAIR, asset.id, { summary: 'Dead pixel' })
2180
+ const { asset: back } = await completeRepair(WS_REPAIR, repair.id)
2181
+ expect({ status: back.status, holder: back.custodianUserId }).toEqual({
2182
+ status: 'in_stock',
2183
+ holder: null,
2184
+ })
2185
+ })
2186
+
2187
+ it('lets an item change hands while it is away, and keeps it under repair', async () => {
2188
+ const asset = await anAsset('Handed on mid-repair')
2189
+ await call(
2190
+ inv.custody.assign,
2191
+ { workspaceId: WS_REPAIR, assetId: asset.id, userId: ALICE },
2192
+ { context: asUser(ALICE, WS_REPAIR) },
2193
+ )
2194
+ const { repair } = await sendForRepair(WS_REPAIR, asset.id, { summary: 'Keyboard' })
2195
+ // Refusing custody during a repair would mean refusing `return` too, and somebody leaving the
2196
+ // company while their laptop is in a workshop has to be able to hand it back.
2197
+ const handed = await call(
2198
+ inv.custody.transfer,
2199
+ { workspaceId: WS_REPAIR, assetId: asset.id, userId: BOB },
2200
+ { context: asUser(ALICE, WS_REPAIR) },
2201
+ )
2202
+ expect({ status: handed.asset.status, holder: handed.asset.custodianUserId }).toEqual({
2203
+ status: 'under_repair',
2204
+ holder: BOB,
2205
+ })
2206
+ const taken = await call(
2207
+ inv.custody.return,
2208
+ { workspaceId: WS_REPAIR, assetId: asset.id },
2209
+ { context: asUser(ALICE, WS_REPAIR) },
2210
+ )
2211
+ // Nobody holds it and it is still at the repairer, which is exactly true.
2212
+ expect({ status: taken.asset.status, holder: taken.asset.custodianUserId }).toEqual({
2213
+ status: 'under_repair',
2214
+ holder: null,
2215
+ })
2216
+ await completeRepair(WS_REPAIR, repair.id)
2217
+ expect(await statusOf(WS_REPAIR, asset.id)).toBe('in_stock')
2218
+ })
2219
+
2220
+ it('refuses a second repair while the first is open, with a sentence rather than a 500', async () => {
2221
+ const asset = await anAsset('Contested repair')
2222
+ await sendForRepair(WS_REPAIR, asset.id, { summary: 'First' })
2223
+ try {
2224
+ await sendForRepair(WS_REPAIR, asset.id, { summary: 'Second' })
2225
+ throw new Error('Expected the second repair to be refused')
2226
+ } catch (err) {
2227
+ expect(codeOf(err)).toBe('CONFLICT')
2228
+ expect(messageOf(err)).toContain('already away for repair')
2229
+ }
2230
+ })
2231
+
2232
+ it('has the database refuse it too, not only the service', async () => {
2233
+ // The service reads before it inserts, so the check it makes is a race. The unique index is
2234
+ // what actually decides, and a test that only exercised the read would pass against a schema
2235
+ // with no index at all.
2236
+ const asset = await anAsset('Two at once')
2237
+ await inWs(WS_REPAIR)((tx) =>
2238
+ tx
2239
+ .insert(repairs)
2240
+ .values({ workspaceId: WS_REPAIR, assetId: asset.id, summary: 'A', sentOn: '2026-01-01' }),
2241
+ )
2242
+ const name = await constraintViolated(() =>
2243
+ inWs(WS_REPAIR)((tx) =>
2244
+ tx
2245
+ .insert(repairs)
2246
+ .values({ workspaceId: WS_REPAIR, assetId: asset.id, summary: 'B', sentOn: '2026-01-02' }),
2247
+ ),
2248
+ )
2249
+ expect(name).toBe('inventory_repairs_one_open_uq')
2250
+ })
2251
+
2252
+ it('allows the next repair once the previous one is closed', async () => {
2253
+ // The other half: an index that refused this would make a second repair impossible, and a test
2254
+ // that only proves rejection cannot tell the two apart.
2255
+ const asset = await anAsset('Repeat offender')
2256
+ const { repair } = await sendForRepair(WS_REPAIR, asset.id, { summary: 'Once' })
2257
+ await completeRepair(WS_REPAIR, repair.id)
2258
+ const again = await sendForRepair(WS_REPAIR, asset.id, { summary: 'Twice' })
2259
+ expect(again.repair.returnedOn).toBeNull()
2260
+ })
2261
+
2262
+ it('refuses completing the same repair twice', async () => {
2263
+ const asset = await anAsset('Finished once')
2264
+ const { repair } = await sendForRepair(WS_REPAIR, asset.id, { summary: 'Done' })
2265
+ await completeRepair(WS_REPAIR, repair.id)
2266
+ expect(await refusedWith(() => completeRepair(WS_REPAIR, repair.id))).toBe('CONFLICT')
2267
+ })
2268
+
2269
+ it('refuses a return date before the send date', async () => {
2270
+ // Postgres has no opinion about this one, and stored it makes every "how long was it away"
2271
+ // answer negative for ever.
2272
+ const asset = await anAsset('Time traveller')
2273
+ const { repair } = await sendForRepair(WS_REPAIR, asset.id, {
2274
+ summary: 'Hinge',
2275
+ sentOn: '2026-06-01',
2276
+ })
2277
+ expect(await refusedWith(() => completeRepair(WS_REPAIR, repair.id, { returnedOn: '2026-05-31' }))).toBe(
2278
+ 'CONFLICT',
2279
+ )
2280
+ })
2281
+
2282
+ /**
2283
+ * The same invariant, from the other end — and the end nothing was checking.
2284
+ *
2285
+ * `complete` has refused a return date before the send date since it was written. `update` took
2286
+ * `sentOn` straight out of the patch, so correcting the send date of a repair that had *already
2287
+ * come back* could move it past the day it came back: a repair that ended before it started, with
2288
+ * nothing to say which of the two dates is the wrong one. Every "how long was it away" answer goes
2289
+ * negative, and the overdue sweep starts measuring from a date in the future.
2290
+ */
2291
+ it('refuses re-dating a finished repair to after it came back, and changes nothing', async () => {
2292
+ const asset = await anAsset('Invoice arrived a week later')
2293
+ const { repair } = await sendForRepair(WS_REPAIR, asset.id, {
2294
+ summary: 'Fan',
2295
+ sentOn: '2026-03-01',
2296
+ })
2297
+ await completeRepair(WS_REPAIR, repair.id, { returnedOn: '2026-03-10' })
2298
+
2299
+ const code = await refusedWith(() =>
2300
+ call(
2301
+ inv.repairs.update,
2302
+ { workspaceId: WS_REPAIR, repairId: repair.id, sentOn: '2026-04-01' },
2303
+ { context: asUser(ALICE, WS_REPAIR) },
2304
+ ),
2305
+ )
2306
+ expect(code).toBe('CONFLICT')
2307
+
2308
+ const [row] = await inWs(WS_REPAIR)((tx) =>
2309
+ tx
2310
+ .select({ sentOn: repairs.sentOn, returnedOn: repairs.returnedOn })
2311
+ .from(repairs)
2312
+ .where(eq(repairs.id, repair.id)),
2313
+ )
2314
+ expect(row, 'a refused edit leaves both dates exactly where they were').toEqual({
2315
+ sentOn: '2026-03-01',
2316
+ returnedOn: '2026-03-10',
2317
+ })
2318
+ })
2319
+
2320
+ it('still allows re-dating one that is still away, and one that stays in order', async () => {
2321
+ // The other half: a check that refused these would make correcting a send date impossible, and a
2322
+ // test that only proves rejection cannot tell the two apart.
2323
+ const away = await anAsset('Still at the workshop')
2324
+ const open = await sendForRepair(WS_REPAIR, away.id, { summary: 'Screen', sentOn: '2026-03-01' })
2325
+ const moved = await call(
2326
+ inv.repairs.update,
2327
+ { workspaceId: WS_REPAIR, repairId: open.repair.id, sentOn: '2026-05-01' },
2328
+ { context: asUser(ALICE, WS_REPAIR) },
2329
+ )
2330
+ expect(moved.repair.sentOn).toBe('2026-05-01')
2331
+
2332
+ const back = await anAsset('Home again')
2333
+ const finished = await sendForRepair(WS_REPAIR, back.id, { summary: 'Battery', sentOn: '2026-03-01' })
2334
+ await completeRepair(WS_REPAIR, finished.repair.id, { returnedOn: '2026-03-20' })
2335
+ const corrected = await call(
2336
+ inv.repairs.update,
2337
+ { workspaceId: WS_REPAIR, repairId: finished.repair.id, sentOn: '2026-03-05' },
2338
+ { context: asUser(ALICE, WS_REPAIR) },
2339
+ )
2340
+ expect(corrected.repair.sentOn).toBe('2026-03-05')
2341
+ })
2342
+
2343
+ it('has the database refuse the pair too, not only the service', async () => {
2344
+ // The service checks first so an ordinary mistake gets a sentence — but two transactions can
2345
+ // each pass that check and still commit a pair that fails it, one moving `sent_on` while the
2346
+ // other logs the item back. The constraint is what makes the rule true of the table.
2347
+ const asset = await anAsset('Straight into the table')
2348
+ const name = await constraintViolated(() =>
2349
+ inWs(WS_REPAIR)((tx) =>
2350
+ tx.insert(repairs).values({
2351
+ workspaceId: WS_REPAIR,
2352
+ assetId: asset.id,
2353
+ summary: 'Written round the wrong way',
2354
+ sentOn: '2026-06-10',
2355
+ returnedOn: '2026-06-01',
2356
+ }),
2357
+ ),
2358
+ )
2359
+ expect(name).toBe('inventory_repairs_returned_after_sent')
2360
+ })
2361
+
2362
+ it('refuses sending an archived item for repair', async () => {
2363
+ const asset = await anAsset('Retired thing')
2364
+ await call(
2365
+ inv.assets.archive,
2366
+ { workspaceId: WS_REPAIR, assetId: asset.id, archived: true },
2367
+ { context: asUser(ALICE, WS_REPAIR) },
2368
+ )
2369
+ expect(await refusedWith(() => sendForRepair(WS_REPAIR, asset.id, { summary: 'Too late' }))).toBe(
2370
+ 'CONFLICT',
2371
+ )
2372
+ })
2373
+
2374
+ it('refuses archiving something that is away for repair', async () => {
2375
+ // Which is what keeps `byStatus.under_repair` (live rows, cached column) and `outForRepair`
2376
+ // (repair rows) from being able to disagree.
2377
+ const asset = await anAsset('Still at the workshop')
2378
+ await sendForRepair(WS_REPAIR, asset.id, { summary: 'Screen' })
2379
+ const code = await refusedWith(() =>
2380
+ call(
2381
+ inv.assets.archive,
2382
+ { workspaceId: WS_REPAIR, assetId: asset.id, archived: true },
2383
+ { context: asUser(ALICE, WS_REPAIR) },
2384
+ ),
2385
+ )
2386
+ expect(code).toBe('CONFLICT')
2387
+ })
2388
+
2389
+ it('inherits the asset’s currency for a cost that arrives without one', async () => {
2390
+ const asset = await createAsset(WS_REPAIR, { name: 'Priced in euros', currency: 'EUR' })
2391
+ const { repair } = await sendForRepair(WS_REPAIR, asset.id, { summary: 'Fan', costMinor: 4500 })
2392
+ // A number with no unit is not a cost.
2393
+ expect({ cost: repair.costMinor, currency: repair.currency }).toEqual({
2394
+ cost: 4500,
2395
+ currency: 'EUR',
2396
+ })
2397
+ })
2398
+
2399
+ it('never inherits over a currency the repair already carries', async () => {
2400
+ // Recording an amount in dollars against an asset priced in euros must not quietly relabel it.
2401
+ const asset = await createAsset(WS_REPAIR, { name: 'Euro asset, dollar repair', currency: 'EUR' })
2402
+ const { repair } = await sendForRepair(WS_REPAIR, asset.id, {
2403
+ summary: 'Imported part',
2404
+ costMinor: 1000,
2405
+ currency: 'USD',
2406
+ })
2407
+ const raised = await call(
2408
+ inv.repairs.update,
2409
+ { workspaceId: WS_REPAIR, repairId: repair.id, costMinor: 2000 },
2410
+ { context: asUser(ALICE, WS_REPAIR) },
2411
+ )
2412
+ expect({ cost: raised.repair.costMinor, currency: raised.repair.currency }).toEqual({
2413
+ cost: 2000,
2414
+ currency: 'USD',
2415
+ })
2416
+ })
2417
+
2418
+ it('does not give a cleared currency back on an unrelated edit', async () => {
2419
+ // The silent version of the same bug: inheriting whenever a cost *exists* rather than whenever
2420
+ // one *arrives* means correcting a vendor undoes somebody's deliberate `null`.
2421
+ const asset = await createAsset(WS_REPAIR, { name: 'No currency, please', currency: 'EUR' })
2422
+ const { repair } = await sendForRepair(WS_REPAIR, asset.id, { summary: 'Unpriced' })
2423
+ const cleared = await call(
2424
+ inv.repairs.update,
2425
+ { workspaceId: WS_REPAIR, repairId: repair.id, costMinor: 500, currency: null },
2426
+ { context: asUser(ALICE, WS_REPAIR) },
2427
+ )
2428
+ expect(cleared.repair.currency).toBeNull()
2429
+ const renamed = await call(
2430
+ inv.repairs.update,
2431
+ { workspaceId: WS_REPAIR, repairId: repair.id, vendor: 'Somebody' },
2432
+ { context: asUser(ALICE, WS_REPAIR) },
2433
+ )
2434
+ expect({ cost: renamed.repair.costMinor, currency: renamed.repair.currency }).toEqual({
2435
+ cost: 500,
2436
+ currency: null,
2437
+ })
2438
+ })
2439
+
2440
+ it('records going away and coming back, and nothing for an edit', async () => {
2441
+ const asset = await anAsset('Timeline of a repair')
2442
+ const { repair } = await sendForRepair(WS_REPAIR, asset.id, { summary: 'Trackpad' })
2443
+ await call(
2444
+ inv.repairs.update,
2445
+ { workspaceId: WS_REPAIR, repairId: repair.id, vendor: 'Somebody else' },
2446
+ { context: asUser(ALICE, WS_REPAIR) },
2447
+ )
2448
+ await completeRepair(WS_REPAIR, repair.id, { costMinor: 9900, currency: 'EUR' })
2449
+ const { items } = await call(
2450
+ inv.assets.history,
2451
+ { workspaceId: WS_REPAIR, assetId: asset.id },
2452
+ { context: asUser(ALICE, WS_REPAIR) },
2453
+ )
2454
+ // Correcting a vendor did not happen *to the asset*; the two entries that matter are not
2455
+ // buried under the paperwork around them.
2456
+ expect(items.map((e) => e.action)).toEqual(['repair_completed', 'repair_logged', 'created'])
2457
+ expect(items[1]?.data).toMatchObject({ repairId: repair.id, summary: 'Trackpad' })
2458
+ })
2459
+
2460
+ it('leaves the return date to `complete`, whatever a patch asks for', async () => {
2461
+ const asset = await anAsset('Not patchable')
2462
+ const { repair } = await sendForRepair(WS_REPAIR, asset.id, { summary: 'Case' })
2463
+ await call(
2464
+ inv.repairs.update,
2465
+ { workspaceId: WS_REPAIR, repairId: repair.id, summary: 'Case, cracked' },
2466
+ { context: asUser(ALICE, WS_REPAIR) },
2467
+ )
2468
+ // One column decides the derived status, so exactly one procedure moves it.
2469
+ expect(await statusOf(WS_REPAIR, asset.id)).toBe('under_repair')
2470
+ })
2471
+
2472
+ it('answers what is away across the workspace, with the asset each one belongs to', async () => {
2473
+ const asset = await createAsset(WS_STATS, { name: 'Away and named' })
2474
+ await sendForRepair(WS_STATS, asset.id, { summary: 'Power supply' })
2475
+ const { items } = await call(
2476
+ inv.repairs.list,
2477
+ { workspaceId: WS_STATS, open: true },
2478
+ { context: asUser(ALICE, WS_STATS) },
2479
+ )
2480
+ // A workspace-wide list of ids would be unreadable; the tag and name are joined at read time,
2481
+ // so renaming the asset renames it here at once.
2482
+ expect(items.map((r) => r.assetName)).toContain('Away and named')
2483
+ expect(items[0]?.assetCode).toMatch(/^INV-/)
2484
+ })
2485
+
2486
+ it('narrows to one asset, and to finished repairs', async () => {
2487
+ const asset = await createAsset(WS_STATS, { name: 'Twice mended' })
2488
+ const first = await sendForRepair(WS_STATS, asset.id, { summary: 'One' })
2489
+ await completeRepair(WS_STATS, first.repair.id)
2490
+ await sendForRepair(WS_STATS, asset.id, { summary: 'Two' })
2491
+ const mine = await call(
2492
+ inv.repairs.list,
2493
+ { workspaceId: WS_STATS, assetId: asset.id },
2494
+ { context: asUser(ALICE, WS_STATS) },
2495
+ )
2496
+ expect(mine.items.map((r) => r.summary)).toEqual(['Two', 'One'])
2497
+ const finished = await call(
2498
+ inv.repairs.list,
2499
+ { workspaceId: WS_STATS, assetId: asset.id, open: false },
2500
+ { context: asUser(ALICE, WS_STATS) },
2501
+ )
2502
+ expect(finished.items.map((r) => r.summary)).toEqual(['One'])
2503
+ })
2504
+
2505
+ it('answers "not yours" rather than an empty list for another workspace’s asset', async () => {
2506
+ const mine = await createAsset(WS_REPAIR, { name: 'Ours alone' })
2507
+ const code = await refusedWith(() =>
2508
+ call(inv.repairs.list, { workspaceId: WS_B, assetId: mine.id }, { context: asUser(BOB, WS_B) }),
2509
+ )
2510
+ // An empty list reads as "it has never been repaired", which is a different sentence.
2511
+ expect(code).toBe('NOT_FOUND')
2512
+ })
2513
+
2514
+ it('refuses a repair from another workspace as missing rather than editing it', async () => {
2515
+ const asset = await createAsset(WS_REPAIR, { name: 'Untouchable repair' })
2516
+ const { repair } = await sendForRepair(WS_REPAIR, asset.id, { summary: 'Ours' })
2517
+ expect(
2518
+ await refusedWith(() =>
2519
+ call(
2520
+ inv.repairs.update,
2521
+ { workspaceId: WS_B, repairId: repair.id, summary: 'Stolen' },
2522
+ { context: asUser(BOB, WS_B) },
2523
+ ),
2524
+ ),
2525
+ ).toBe('NOT_FOUND')
2526
+ })
2527
+ })
2528
+
2529
+ /**
2530
+ * Files, which this module records and never holds.
2531
+ *
2532
+ * The load-bearing test is the workspace check: the file id arrives in the request, so without it a
2533
+ * member of one workspace could attach another workspace's file and read its name and size back out
2534
+ * of the list. The module boundary does not help — core answers this module as a service.
2535
+ */
2536
+ describe('attaching files', () => {
2537
+ it('records what core says a file is, and lists it back', async () => {
2538
+ const asset = await createAsset(WS_FILES, { name: 'Filed away' })
2539
+ const fileId = seedFile(WS_FILES, 'Purchase receipt.pdf')
2540
+ const added = await call(
2541
+ inv.attachments.add,
2542
+ { workspaceId: WS_FILES, assetId: asset.id, fileIds: [fileId] },
2543
+ { context: asUser(ALICE, WS_FILES) },
2544
+ )
2545
+ expect(added.map((a) => a.name)).toEqual(['Purchase receipt.pdf'])
2546
+ const listed = await call(
2547
+ inv.attachments.list,
2548
+ { workspaceId: WS_FILES, assetId: asset.id },
2549
+ { context: asUser(ALICE, WS_FILES) },
2550
+ )
2551
+ // Name and size are copied at attach time, so a list is one query rather than one query plus a
2552
+ // call to core per row.
2553
+ expect(listed[0]).toMatchObject({ name: 'Purchase receipt.pdf', size: 12_345, repairId: null })
2554
+ })
2555
+
2556
+ it('refuses a file belonging to another workspace', async () => {
2557
+ const asset = await createAsset(WS_FILES, { name: 'Not yours' })
2558
+ const theirs = seedFile(WS_B, 'Somebody else’s contract.pdf')
2559
+ expect(
2560
+ await refusedWith(() =>
2561
+ call(
2562
+ inv.attachments.add,
2563
+ { workspaceId: WS_FILES, assetId: asset.id, fileIds: [theirs] },
2564
+ { context: asUser(ALICE, WS_FILES) },
2565
+ ),
2566
+ ),
2567
+ ).toBe('BAD_REQUEST')
2568
+ })
2569
+
2570
+ it('refuses a file whose upload never finished', async () => {
2571
+ const asset = await createAsset(WS_FILES, { name: 'Half uploaded' })
2572
+ const pending = seedFile(WS_FILES, 'Half a scan.pdf', 'pending')
2573
+ expect(
2574
+ await refusedWith(() =>
2575
+ call(
2576
+ inv.attachments.add,
2577
+ { workspaceId: WS_FILES, assetId: asset.id, fileIds: [pending] },
2578
+ { context: asUser(ALICE, WS_FILES) },
2579
+ ),
2580
+ ),
2581
+ ).toBe('BAD_REQUEST')
2582
+ })
2583
+
2584
+ it('adds the same file once, however many times it arrives', async () => {
2585
+ const asset = await createAsset(WS_FILES, { name: 'Double dropped' })
2586
+ const fileId = seedFile(WS_FILES, 'Warranty.pdf')
2587
+ const once = await call(
2588
+ inv.attachments.add,
2589
+ { workspaceId: WS_FILES, assetId: asset.id, fileIds: [fileId, fileId] },
2590
+ { context: asUser(ALICE, WS_FILES) },
2591
+ )
2592
+ expect(once).toHaveLength(1)
2593
+ // Somebody pressed the button twice; that is not an error worth refusing.
2594
+ const again = await call(
2595
+ inv.attachments.add,
2596
+ { workspaceId: WS_FILES, assetId: asset.id, fileIds: [fileId] },
2597
+ { context: asUser(ALICE, WS_FILES) },
2598
+ )
2599
+ expect(again).toEqual([])
2600
+ })
2601
+
2602
+ it('files a repair’s paperwork under that repair, and refuses another asset’s', async () => {
2603
+ const asset = await createAsset(WS_FILES, { name: 'With an invoice' })
2604
+ const other = await createAsset(WS_FILES, { name: 'Somebody else’s repair' })
2605
+ const { repair } = await sendForRepair(WS_FILES, other.id, { summary: 'Elsewhere' })
2606
+ const fileId = seedFile(WS_FILES, 'Invoice.pdf')
2607
+ expect(
2608
+ await refusedWith(() =>
2609
+ call(
2610
+ inv.attachments.add,
2611
+ { workspaceId: WS_FILES, assetId: asset.id, fileIds: [fileId], repairId: repair.id },
2612
+ { context: asUser(ALICE, WS_FILES) },
2613
+ ),
2614
+ ),
2615
+ ).toBe('NOT_FOUND')
2616
+
2617
+ const mine = await sendForRepair(WS_FILES, asset.id, { summary: 'Ours' })
2618
+ const filed = await call(
2619
+ inv.attachments.add,
2620
+ { workspaceId: WS_FILES, assetId: asset.id, fileIds: [fileId], repairId: mine.repair.id },
2621
+ { context: asUser(ALICE, WS_FILES) },
2622
+ )
2623
+ expect(filed[0]?.repairId).toBe(mine.repair.id)
2624
+ })
2625
+
2626
+ it('detaches one file and records it, leaving core’s copy alone', async () => {
2627
+ const asset = await createAsset(WS_FILES, { name: 'Detachable' })
2628
+ const fileId = seedFile(WS_FILES, 'Manual.pdf')
2629
+ const [added] = await call(
2630
+ inv.attachments.add,
2631
+ { workspaceId: WS_FILES, assetId: asset.id, fileIds: [fileId] },
2632
+ { context: asUser(ALICE, WS_FILES) },
2633
+ )
2634
+ const removed = await call(
2635
+ inv.attachments.remove,
2636
+ { workspaceId: WS_FILES, attachmentId: added!.id },
2637
+ { context: asUser(ALICE, WS_FILES) },
2638
+ )
2639
+ expect(removed).toEqual({ id: added!.id })
2640
+ // The file itself is core's and is untouched — this module never owned it.
2641
+ expect(FILES.get(fileId)?.name).toBe('Manual.pdf')
2642
+ const { items } = await call(
2643
+ inv.assets.history,
2644
+ { workspaceId: WS_FILES, assetId: asset.id },
2645
+ { context: asUser(ALICE, WS_FILES) },
2646
+ )
2647
+ expect(items.map((e) => e.action)).toEqual(['attachment_removed', 'attachment_added', 'created'])
2648
+ })
2649
+
2650
+ it('answers "not yours" for another workspace’s asset', async () => {
2651
+ const mine = await createAsset(WS_FILES, { name: 'Private paperwork' })
2652
+ expect(
2653
+ await refusedWith(() =>
2654
+ call(inv.attachments.list, { workspaceId: WS_B, assetId: mine.id }, { context: asUser(BOB, WS_B) }),
2655
+ ),
2656
+ ).toBe('NOT_FOUND')
2657
+ })
2658
+ })
2659
+
2660
+ /**
2661
+ * The one file id the module used to take on trust.
2662
+ *
2663
+ * `attachments.add` has always asked core whose file an id is before recording it. `assets.create`
2664
+ * and `assets.update` did not, so `photoFileId` went into the row exactly as it arrived — and a
2665
+ * member of one workspace could point an asset at another workspace's file and read its name back
2666
+ * out through the panel that renders it. The module boundary is no help: core answers this module as
2667
+ * a service, so the id is the only thing between the two workspaces.
2668
+ */
2669
+ describe('an asset’s photo', () => {
2670
+ const ctx = () => ({ context: asUser(ALICE, WS_FILES) })
2671
+
2672
+ it('refuses a file belonging to another workspace, exactly as an attachment does', async () => {
2673
+ const foreign = seedFile(WS_B, 'someone-elses-laptop.jpg')
2674
+ expect(
2675
+ await refusedWith(() =>
2676
+ call(
2677
+ inv.assets.create,
2678
+ { workspaceId: WS_FILES, name: 'Borrowed photo', photoFileId: foreign },
2679
+ ctx(),
2680
+ ),
2681
+ ),
2682
+ ).toBe('BAD_REQUEST')
2683
+ })
2684
+
2685
+ it('refuses one on an edit as well, and leaves the photo alone', async () => {
2686
+ const own = seedFile(WS_FILES, 'ours.jpg')
2687
+ const asset = await call(
2688
+ inv.assets.create,
2689
+ { workspaceId: WS_FILES, name: 'Has its own photo', photoFileId: own },
2690
+ ctx(),
2691
+ )
2692
+ const foreign = seedFile(WS_B, 'not-ours.jpg')
2693
+ expect(
2694
+ await refusedWith(() =>
2695
+ call(inv.assets.update, { workspaceId: WS_FILES, assetId: asset.id, photoFileId: foreign }, ctx()),
2696
+ ),
2697
+ ).toBe('BAD_REQUEST')
2698
+
2699
+ const after = await call(inv.assets.get, { workspaceId: WS_FILES, assetId: asset.id }, ctx())
2700
+ expect(after.photoFileId).toBe(own)
2701
+ })
2702
+
2703
+ it('refuses one whose upload never finished, and accepts one of this workspace’s own', async () => {
2704
+ const unfinished = seedFile(WS_FILES, 'still-uploading.jpg', 'uploading')
2705
+ expect(
2706
+ await refusedWith(() =>
2707
+ call(
2708
+ inv.assets.create,
2709
+ { workspaceId: WS_FILES, name: 'Half a photo', photoFileId: unfinished },
2710
+ ctx(),
2711
+ ),
2712
+ ),
2713
+ ).toBe('BAD_REQUEST')
2714
+
2715
+ // The other half: a check that refused everything would hide behind the same assertions.
2716
+ const own = seedFile(WS_FILES, 'finished.jpg')
2717
+ const asset = await call(
2718
+ inv.assets.create,
2719
+ { workspaceId: WS_FILES, name: 'A photo of its own', photoFileId: own },
2720
+ ctx(),
2721
+ )
2722
+ expect(asset.photoFileId).toBe(own)
2723
+
2724
+ // And clearing it is not a file to check, so it is not refused.
2725
+ const cleared = await call(
2726
+ inv.assets.update,
2727
+ { workspaceId: WS_FILES, assetId: asset.id, photoFileId: null },
2728
+ ctx(),
2729
+ )
2730
+ expect(cleared.photoFileId).toBeNull()
2731
+ })
2732
+ })
2733
+
2734
+ /**
2735
+ * A capability that is off answers **404, not 403**.
2736
+ *
2737
+ * 403 says "this exists and you may not have it", which is false for a workspace that never bought
2738
+ * the feature — and it contradicts a panel that has already hidden the tab. This is the module's
2739
+ * first switchable capability, so this block is the first proof that the mechanism does anything at
2740
+ * all: without it, `requiresCapability` could be missing from every procedure and every other test
2741
+ * in this file would still pass.
2742
+ */
2743
+ describe('a workspace with a capability switched off', () => {
2744
+ it('answers NOT_FOUND from every repairs procedure, and not FORBIDDEN', async () => {
2745
+ const asset = await createAsset(WS_CAP, { name: 'In a workspace without repairs' })
2746
+ // Sent while the capability is still on, so what is being tested afterwards is the gate rather
2747
+ // than a missing row.
2748
+ const { repair } = await sendForRepair(WS_CAP, asset.id, { summary: 'Before the switch' })
2749
+
2750
+ await withCapabilities({ repairs: false }, async () => {
2751
+ const user = () => ({ context: asUser(ALICE, WS_CAP) })
2752
+ const refusals = await Promise.all([
2753
+ refusedWith(() => call(inv.repairs.list, { workspaceId: WS_CAP }, user())),
2754
+ refusedWith(() =>
2755
+ call(inv.repairs.create, { workspaceId: WS_CAP, assetId: asset.id, summary: 'No' }, user()),
2756
+ ),
2757
+ refusedWith(() =>
2758
+ call(inv.repairs.update, { workspaceId: WS_CAP, repairId: repair.id, summary: 'No' }, user()),
2759
+ ),
2760
+ refusedWith(() => call(inv.repairs.complete, { workspaceId: WS_CAP, repairId: repair.id }, user())),
2761
+ ])
2762
+ expect(refusals).toEqual(['NOT_FOUND', 'NOT_FOUND', 'NOT_FOUND', 'NOT_FOUND'])
2763
+ })
2764
+ })
2765
+
2766
+ it('answers NOT_FOUND from every attachments procedure', async () => {
2767
+ const asset = await createAsset(WS_CAP, { name: 'In a workspace without files' })
2768
+ const fileId = seedFile(WS_CAP, 'Receipt.pdf')
2769
+ const [added] = await call(
2770
+ inv.attachments.add,
2771
+ { workspaceId: WS_CAP, assetId: asset.id, fileIds: [fileId] },
2772
+ { context: asUser(ALICE, WS_CAP) },
2773
+ )
2774
+
2775
+ await withCapabilities({ attachments: false }, async () => {
2776
+ const user = () => ({ context: asUser(ALICE, WS_CAP) })
2777
+ const refusals = await Promise.all([
2778
+ refusedWith(() => call(inv.attachments.list, { workspaceId: WS_CAP, assetId: asset.id }, user())),
2779
+ refusedWith(() =>
2780
+ call(inv.attachments.add, { workspaceId: WS_CAP, assetId: asset.id, fileIds: [fileId] }, user()),
2781
+ ),
2782
+ refusedWith(() =>
2783
+ call(inv.attachments.remove, { workspaceId: WS_CAP, attachmentId: added!.id }, user()),
2784
+ ),
2785
+ ])
2786
+ expect(refusals).toEqual(['NOT_FOUND', 'NOT_FOUND', 'NOT_FOUND'])
2787
+ })
2788
+ })
2789
+
2790
+ it('leaves the rest of the module working, and says it has no opinion about repairs', async () => {
2791
+ await withCapabilities({ repairs: false }, async () => {
2792
+ const listed = await listAssets(WS_CAP)
2793
+ expect(listed.items.length).toBeGreaterThan(0)
2794
+ const stats = await call(inv.stats.summary, { workspaceId: WS_CAP }, { context: asUser(ALICE, WS_CAP) })
2795
+ // Null, not 0: zero would be a claim ("nothing is away") that this workspace has not made.
2796
+ // And the count line must not disappear because a *different* feature is switched off.
2797
+ expect(stats.outForRepair).toBeNull()
2798
+ expect(stats.total).toBeGreaterThan(0)
2799
+ })
2800
+ })
2801
+
2802
+ it('switches back on without anything having been destroyed', async () => {
2803
+ // A capability is a flag in settings; the rows stay exactly where they were. Anything needing
2804
+ // a migration to reverse does not belong behind one.
2805
+ const { items } = await call(
2806
+ inv.repairs.list,
2807
+ { workspaceId: WS_CAP },
2808
+ { context: asUser(ALICE, WS_CAP) },
2809
+ )
2810
+ expect(items.map((r) => r.summary)).toContain('Before the switch')
2811
+ })
2812
+ })
2813
+
2814
+ describe('the register in numbers', () => {
2815
+ it('counts live rows, keeps archived ones beside them and zero-fills every status', async () => {
2816
+ const kept = await createAsset(WS_STATS, { name: 'Counted' })
2817
+ const gone = await createAsset(WS_STATS, { name: 'Archived' })
2818
+ await call(
2819
+ inv.assets.archive,
2820
+ { workspaceId: WS_STATS, assetId: gone.id, archived: true },
2821
+ { context: asUser(ALICE, WS_STATS) },
2822
+ )
2823
+ const stats = await call(
2824
+ inv.stats.summary,
2825
+ { workspaceId: WS_STATS },
2826
+ { context: asUser(ALICE, WS_STATS) },
2827
+ )
2828
+ expect(stats.archived).toBeGreaterThan(0)
2829
+ // A count line that silently included archived rows would disagree with the list under it.
2830
+ const live = await listAssets(WS_STATS, { limit: 200 })
2831
+ expect(stats.total).toBe(live.items.length)
2832
+ expect(Object.keys(stats.byStatus).sort()).toEqual([
2833
+ 'assigned',
2834
+ 'in_stock',
2835
+ 'lost',
2836
+ 'reserved',
2837
+ 'retired',
2838
+ 'under_repair',
2839
+ ])
2840
+ // Nothing writes `lost`, and a missing key and a zero are not the same thing to a `Record`.
2841
+ expect(stats.byStatus.lost).toBe(0)
2842
+ expect(stats.byStatus.in_stock).toBeGreaterThan(0)
2843
+ expect(kept.archivedAt).toBeNull()
2844
+ })
2845
+
2846
+ it('agrees with itself about what is away', async () => {
2847
+ // Two ways of asking one question: the cached `status` column that every list filters on, and
2848
+ // the repair rows the column is derived from. `deriveStatus` is what keeps them equal — and
2849
+ // `assets.archive` refusing an item that is away is what keeps them equal for archived rows.
2850
+ const stats = await call(
2851
+ inv.stats.summary,
2852
+ { workspaceId: WS_STATS },
2853
+ { context: asUser(ALICE, WS_STATS) },
2854
+ )
2855
+ expect(stats.outForRepair).toBe(stats.byStatus.under_repair)
2856
+ })
2857
+
2858
+ it('counts what nobody is holding', async () => {
2859
+ const before = await call(
2860
+ inv.stats.summary,
2861
+ { workspaceId: WS_STATS },
2862
+ { context: asUser(ALICE, WS_STATS) },
2863
+ )
2864
+ const free = await createAsset(WS_STATS, { name: 'About to be handed over' })
2865
+ await call(
2866
+ inv.custody.assign,
2867
+ { workspaceId: WS_STATS, assetId: free.id, userId: BOB },
2868
+ { context: asUser(ALICE, WS_STATS) },
2869
+ )
2870
+ const after = await call(
2871
+ inv.stats.summary,
2872
+ { workspaceId: WS_STATS },
2873
+ { context: asUser(ALICE, WS_STATS) },
2874
+ )
2875
+ // One more asset, and the same number of unheld ones.
2876
+ expect({ total: after.total, unassigned: after.unassigned }).toEqual({
2877
+ total: before.total + 1,
2878
+ unassigned: before.unassigned,
2879
+ })
2880
+ })
2881
+
2882
+ it('does not count another workspace’s assets', async () => {
2883
+ const stats = await call(inv.stats.summary, { workspaceId: WS_B }, { context: asUser(BOB, WS_B) })
2884
+ const theirs = await listAssets(WS_B, { limit: 200 }, BOB)
2885
+ expect(stats.total).toBe(theirs.items.length)
2886
+ })
2887
+ })
2888
+
2889
+ // =================================================================================================
2890
+ // The platform surfaces: search, references, scheduled work, service calls and offboarding.
2891
+ //
2892
+ // Everything below leaves the module. The suite above proves the register is *correct*; this proves
2893
+ // the rest of the product can find it, point at it, and be told about it — which is a different
2894
+ // class of bug, and one that type-checking cannot see at all: a `SearchIndexer` with the wrong
2895
+ // document shape compiles, a resolver nobody guards compiles, a job that enumerates no workspaces
2896
+ // compiles and runs to completion every night doing nothing.
2897
+ // =================================================================================================
2898
+
2899
+ /** `YYYY-MM-DD`, `n` days from today in UTC — the same arithmetic `jobs.ts` does. */
2900
+ const inDays = (days: number): string => {
2901
+ const d = new Date()
2902
+ d.setUTCDate(d.getUTCDate() + days)
2903
+ return d.toISOString().slice(0, 10)
2904
+ }
2905
+
2906
+ const jobNamed = (name: string) => {
2907
+ const job = inventoryModule.jobs?.find((j) => j.name === name)
2908
+ if (!job) throw new Error(`No job called ${name}`)
2909
+ return () => job.handler({}, { kernel, id: randomUUID(), attempt: 0 })
2910
+ }
2911
+
2912
+ /** The indexed document for one asset, or undefined — the last one written, which is the live one. */
2913
+ const documentFor = (assetId: string) => INDEXED.filter((d) => d.object.id === assetId).at(-1)
2914
+
2915
+ describe('an asset in the workspace-wide search index', () => {
2916
+ let assetId: string
2917
+ let categoryId: string
2918
+
2919
+ beforeAll(async () => {
2920
+ const category = await call(
2921
+ inv.categories.create,
2922
+ { workspaceId: WS_SEARCH, name: 'Cameras' },
2923
+ { context: asUser(ALICE, WS_SEARCH) },
2924
+ )
2925
+ categoryId = category.id
2926
+ const asset = await createAsset(WS_SEARCH, {
2927
+ name: 'Blackmagic Pocket 6K',
2928
+ description: 'The one with the dented cage',
2929
+ serialNumber: 'BM-99120',
2930
+ location: 'Studio cupboard',
2931
+ categoryId,
2932
+ })
2933
+ assetId = asset.id
2934
+ }, 60_000)
2935
+
2936
+ it('leads with the tag, because that is what is printed on the sticker', () => {
2937
+ const doc = documentFor(assetId)
2938
+ expect(doc?.title).toMatch(/^INV-\d+ Blackmagic Pocket 6K$/)
2939
+ })
2940
+
2941
+ it('indexes the serial, the location and the category name, not only the name', () => {
2942
+ const body = documentFor(assetId)?.body ?? ''
2943
+ for (const word of ['BM-99120', 'Studio cupboard', 'Cameras', 'dented cage'])
2944
+ expect({ word, found: body.includes(word) }).toEqual({ word, found: true })
2945
+ })
2946
+
2947
+ it('points at the panel the shell actually opens', () => {
2948
+ const doc = documentFor(assetId)
2949
+ expect(doc?.url).toBe(`/inventory?asset=${assetId}`)
2950
+ expect(doc?.object).toEqual({ module: 'inventory', type: 'asset', id: assetId })
2951
+ expect(doc?.icon).toBe('briefcase')
2952
+ // Workspace-wide visibility: `inventory.asset.view` is not a per-row permission, so naming a
2953
+ // narrower audience would be a claim this module cannot keep.
2954
+ expect(doc?.acl).toBeNull()
2955
+ })
2956
+
2957
+ it('carries the attributes a filter would need, and no row of its own', () => {
2958
+ expect(documentFor(assetId)?.attributes).toMatchObject({
2959
+ status: 'in_stock',
2960
+ categoryId,
2961
+ custodianUserId: null,
2962
+ })
2963
+ })
2964
+
2965
+ it('takes an archived asset back out of the index rather than leaving a dead link', async () => {
2966
+ const asset = await createAsset(WS_SEARCH, { name: 'Broken tripod' })
2967
+ expect(documentFor(asset.id), 'indexed when created').toBeDefined()
2968
+
2969
+ await call(
2970
+ inv.assets.archive,
2971
+ { workspaceId: WS_SEARCH, assetId: asset.id, archived: true },
2972
+ { context: asUser(ALICE, WS_SEARCH) },
2973
+ )
2974
+ expect(UNINDEXED).toContainEqual({
2975
+ workspaceId: WS_SEARCH,
2976
+ object: { module: 'inventory', type: 'asset', id: asset.id },
2977
+ })
2978
+ // And the indexer agrees, which is what `core.search.reindex` would act on.
2979
+ expect(await inventoryServices(kernel).search.load(WS_SEARCH, asset.id)).toBeNull()
2980
+
2981
+ await call(
2982
+ inv.assets.archive,
2983
+ { workspaceId: WS_SEARCH, assetId: asset.id, archived: false },
2984
+ { context: asUser(ALICE, WS_SEARCH) },
2985
+ )
2986
+ expect(await inventoryServices(kernel).search.load(WS_SEARCH, asset.id)).not.toBeNull()
2987
+ })
2988
+
2989
+ it('reindexes when custody moves, because the document carries the custodian', async () => {
2990
+ await call(
2991
+ inv.custody.assign,
2992
+ { workspaceId: WS_SEARCH, assetId, userId: BOB },
2993
+ { context: asUser(ALICE, WS_SEARCH) },
2994
+ )
2995
+ expect(documentFor(assetId)?.attributes).toMatchObject({
2996
+ status: 'assigned',
2997
+ custodianUserId: BOB,
2998
+ })
2999
+ await call(inv.custody.return, { workspaceId: WS_SEARCH, assetId }, { context: asUser(ALICE, WS_SEARCH) })
3000
+ })
3001
+
3002
+ it('scans every live asset of the workspace and nobody else’s', async () => {
3003
+ const scanned: core.SearchDocument[] = []
3004
+ for await (const doc of inventoryServices(kernel).search.scan(WS_SEARCH)) scanned.push(doc)
3005
+
3006
+ const live = await listAssets(WS_SEARCH, { limit: 200 })
3007
+ expect(scanned.map((d) => d.object.id).sort()).toEqual(live.items.map((a) => a.id).sort())
3008
+ for (const doc of scanned) expect(doc.workspaceId).toBe(WS_SEARCH)
3009
+ })
3010
+ })
3011
+
3012
+ describe('an `inventory:asset:<id>` reference rendered somewhere else', () => {
3013
+ const resolver = () => {
3014
+ const found = inventoryModule.resolvers?.find((r) => r.type === 'asset')
3015
+ if (!found) throw new Error('the module declares an `asset` object type with no resolver')
3016
+ return found
3017
+ }
3018
+
3019
+ it('is declared as an object type, so the reference can be written in the first place', () => {
3020
+ expect(inventoryModule.definition.objectTypes).toEqual([
3021
+ { type: 'asset', label: 'Asset', icon: 'briefcase', channelable: false },
3022
+ ])
3023
+ })
3024
+
3025
+ it('answers with a title, a url and an icon, and null for one that is not there', async () => {
3026
+ const asset = await createAsset(WS_A, { name: 'Meeting room screen' })
3027
+ const missing = randomUUID()
3028
+ const resolved = await resolver().resolve(WS_A, [asset.id, missing], principal(ALICE, WS_A), kernel)
3029
+ expect(resolved[0]).toEqual({
3030
+ id: asset.id,
3031
+ title: `${asset.code} Meeting room screen`,
3032
+ url: `/inventory?asset=${asset.id}`,
3033
+ icon: 'briefcase',
3034
+ subtitle: 'in_stock',
3035
+ })
3036
+ expect(resolved[1], 'an id this workspace does not have').toBeNull()
3037
+ })
3038
+
3039
+ it('still resolves an archived asset, where search drops it', async () => {
3040
+ const asset = await createAsset(WS_A, { name: 'Retired printer' })
3041
+ await call(
3042
+ inv.assets.archive,
3043
+ { workspaceId: WS_A, assetId: asset.id, archived: true },
3044
+ { context: asUser(ALICE, WS_A) },
3045
+ )
3046
+ const [resolved] = await resolver().resolve(WS_A, [asset.id], principal(ALICE, WS_A), kernel)
3047
+ // A link written last year must not turn into nothing; it says what it is instead — in the
3048
+ // module's own status vocabulary rather than an English sentence a resolver cannot translate.
3049
+ expect(resolved?.subtitle).toBe('archived')
3050
+ expect(resolved?.title).toContain('Retired printer')
3051
+ })
3052
+
3053
+ it('tells somebody outside the workspace nothing at all', async () => {
3054
+ const asset = await createAsset(WS_A, { name: 'Confidential prototype' })
3055
+ // A principal with no membership here: `authz.can` is what refuses, which is the same check the
3056
+ // router makes, rather than a second rule written into the resolver.
3057
+ const outsider = principal(BOB, WS_B)
3058
+ const resolved = await resolver().resolve(WS_A, [asset.id], outsider, kernel)
3059
+ expect(resolved).toEqual([null])
3060
+ })
3061
+ })
3062
+
3063
+ describe('the procedures other services call', () => {
3064
+ it('refuses a person, however many workspaces they are an admin of', async () => {
3065
+ const asset = await createAsset(WS_A, { name: 'Service-guarded laptop' })
3066
+ for (const name of ['inventory.asset.byId', 'inventory.assets.byCustodian']) {
3067
+ const code = await refusedWith(() =>
3068
+ kernel.call(name, { workspaceId: WS_A, assetId: asset.id, userId: ALICE }, principal(ALICE, WS_A)),
3069
+ )
3070
+ expect({ name, code }).toEqual({ name, code: 'FORBIDDEN' })
3071
+ }
3072
+ })
3073
+
3074
+ it('answers a service with the asset, and with what one person is holding', async () => {
3075
+ const asset = await createAsset(WS_A, { name: 'Loaned monitor' })
3076
+ await call(
3077
+ inv.custody.assign,
3078
+ { workspaceId: WS_A, assetId: asset.id, userId: BOB },
3079
+ { context: asUser(ALICE, WS_A) },
3080
+ )
3081
+
3082
+ const one = await kernel.call<Asset>('inventory.asset.byId', {
3083
+ workspaceId: WS_A,
3084
+ assetId: asset.id,
3085
+ })
3086
+ expect(one.code).toBe(asset.code)
3087
+ expect(one.custodianUserId).toBe(BOB)
3088
+
3089
+ const held = await kernel.call<{ items: Asset[] }>('inventory.assets.byCustodian', {
3090
+ workspaceId: WS_A,
3091
+ userId: BOB,
3092
+ })
3093
+ expect(held.items.map((a) => a.id)).toContain(asset.id)
3094
+ // Answered through the one list path, so it inherits the archived-row rule rather than a
3095
+ // second query's opinion of it.
3096
+ for (const item of held.items) expect(item.archivedAt).toBeNull()
3097
+ })
3098
+ })
3099
+
3100
+ describe('switching the module on for a workspace', () => {
3101
+ const enable = () => inventoryModule.onWorkspaceEnabled?.(WS_SEED, kernel)
3102
+
3103
+ it('seeds a filing system, so the asset form’s picker is not empty on day one', async () => {
3104
+ await enable()
3105
+ const list = await call(
3106
+ inv.categories.list,
3107
+ { workspaceId: WS_SEED },
3108
+ { context: asUser(ALICE, WS_SEED) },
3109
+ )
3110
+ expect(list.map((c) => c.name)).toEqual(['Laptops', 'Phones', 'Monitors', 'Furniture', 'Vehicles'])
3111
+ })
3112
+
3113
+ it('registers the workspace so the sweeps can find it', async () => {
3114
+ const rows = await kernel.database.withWorkspace(WS_SEED, (tx) =>
3115
+ tx.select().from(workspaces).where(eq(workspaces.workspaceId, WS_SEED)),
3116
+ )
3117
+ expect(rows).toHaveLength(1)
3118
+ })
3119
+
3120
+ it('runs again without seeding a second time', async () => {
3121
+ // Somebody switches the module off and back on. Both halves have to survive it: a second
3122
+ // registry row is impossible by the primary key, and a second set of categories is what the
3123
+ // "has none at all" guard exists to prevent.
3124
+ await enable()
3125
+ await enable()
3126
+ const list = await call(
3127
+ inv.categories.list,
3128
+ { workspaceId: WS_SEED },
3129
+ { context: asUser(ALICE, WS_SEED) },
3130
+ )
3131
+ expect(list).toHaveLength(5)
3132
+ const rows = await kernel.database.withWorkspace(WS_SEED, (tx) =>
3133
+ tx.select().from(workspaces).where(eq(workspaces.workspaceId, WS_SEED)),
3134
+ )
3135
+ expect(rows).toHaveLength(1)
3136
+ })
3137
+
3138
+ it('does not re-create a default somebody renamed', async () => {
3139
+ // The failure the name-keyed version would have: rename "Laptops" to "Notebooks", toggle the
3140
+ // module, and an admin is handed a duplicate of their own category under the name they rejected.
3141
+ const list = await call(
3142
+ inv.categories.list,
3143
+ { workspaceId: WS_SEED },
3144
+ { context: asUser(ALICE, WS_SEED) },
3145
+ )
3146
+ const laptops = list.find((c) => c.name === 'Laptops')!
3147
+ await call(
3148
+ inv.categories.update,
3149
+ { workspaceId: WS_SEED, categoryId: laptops.id, name: 'Notebooks' },
3150
+ { context: asUser(ALICE, WS_SEED) },
3151
+ )
3152
+ await enable()
3153
+ const after = await call(
3154
+ inv.categories.list,
3155
+ { workspaceId: WS_SEED },
3156
+ { context: asUser(ALICE, WS_SEED) },
3157
+ )
3158
+ expect(after.map((c) => c.name).sort()).toEqual(
3159
+ ['Furniture', 'Monitors', 'Notebooks', 'Phones', 'Vehicles'].sort(),
3160
+ )
3161
+ })
3162
+ })
3163
+
3164
+ describe('the nightly sweeps', () => {
3165
+ let expiring: Asset
3166
+ let spare: Asset
3167
+ let away: Asset
3168
+
3169
+ beforeAll(async () => {
3170
+ // OLIVE looks after the register; DANA is handed things. Both are what an audience is worked
3171
+ // out from, through the real permission engine rather than a lookup table.
3172
+ seedMember(WS_SWEEP, OLIVE, 'admin', 'Olive Ferrer')
3173
+ seedMember(WS_SWEEP, DANA, 'member', 'Dana Okoro')
3174
+ await inventoryModule.onWorkspaceEnabled?.(WS_SWEEP, kernel)
3175
+
3176
+ expiring = await createAsset(WS_SWEEP, { name: 'Dana’s laptop', warrantyUntil: inDays(10) }, OLIVE)
3177
+ await call(
3178
+ inv.custody.assign,
3179
+ { workspaceId: WS_SWEEP, assetId: expiring.id, userId: DANA },
3180
+ { context: asUser(OLIVE, WS_SWEEP) },
3181
+ )
3182
+ spare = await createAsset(WS_SWEEP, { name: 'Spare projector', warrantyUntil: inDays(3) }, OLIVE)
3183
+ // Well outside the 30-day default window, so a sweep that ignored the window would be caught.
3184
+ await createAsset(WS_SWEEP, { name: 'New printer', warrantyUntil: inDays(400) }, OLIVE)
3185
+
3186
+ away = await createAsset(WS_SWEEP, { name: 'Cracked monitor' }, OLIVE)
3187
+ await call(
3188
+ inv.repairs.create,
3189
+ { workspaceId: WS_SWEEP, assetId: away.id, summary: 'Cracked panel', sentOn: inDays(-30) },
3190
+ { context: asUser(OLIVE, WS_SWEEP) },
3191
+ )
3192
+ }, 60_000)
3193
+
3194
+ it('tells whoever is holding the item, and whoever may replace one nobody holds', async () => {
3195
+ NOTIFICATIONS.length = 0
3196
+ await jobNamed('warranty-sweep')()
3197
+
3198
+ const sent = notificationsOfType('inventory.warranty.expiring')
3199
+ const forDanas = sent.filter((n) => n.url === `/inventory?asset=${expiring.id}`)
3200
+ expect(
3201
+ forDanas.map((n) => n.userId),
3202
+ 'the holder, and only the holder',
3203
+ ).toEqual([DANA])
3204
+ expect(forDanas[0]?.title).toContain(expiring.code)
3205
+
3206
+ const forSpare = sent.filter((n) => n.url === `/inventory?asset=${spare.id}`)
3207
+ // Nobody is holding it, so it falls to whoever holds `inventory.asset.manage` — which by
3208
+ // default is every member, capped and ordered by role.
3209
+ expect(forSpare.map((n) => n.userId).sort()).toEqual([DANA, OLIVE].sort())
3210
+ })
3211
+
3212
+ it('leaves the one outside the window alone', () => {
3213
+ const codes = notificationsOfType('inventory.warranty.expiring').map((n) => n.title)
3214
+ expect(codes.some((title) => title.includes('New printer'))).toBe(false)
3215
+ })
3216
+
3217
+ it('says it once and then stops, however many mornings it runs', async () => {
3218
+ const [row] = await kernel.database.withWorkspace(WS_SWEEP, (tx) =>
3219
+ tx.select({ at: assets.warrantyNotifiedAt }).from(assets).where(eq(assets.id, expiring.id)),
3220
+ )
3221
+ expect(row?.at, 'the marker that makes the sweep idempotent').not.toBeNull()
3222
+
3223
+ NOTIFICATIONS.length = 0
3224
+ await jobNamed('warranty-sweep')()
3225
+ expect(notificationsOfType('inventory.warranty.expiring')).toEqual([])
3226
+ })
3227
+
3228
+ it('re-arms when the warranty date itself moves, and not when anything else does', async () => {
3229
+ NOTIFICATIONS.length = 0
3230
+ await call(
3231
+ inv.assets.update,
3232
+ { workspaceId: WS_SWEEP, assetId: expiring.id, location: 'Second desk' },
3233
+ { context: asUser(OLIVE, WS_SWEEP) },
3234
+ )
3235
+ await jobNamed('warranty-sweep')()
3236
+ expect(notificationsOfType('inventory.warranty.expiring'), 'an edit is not a new warranty').toEqual([])
3237
+
3238
+ await call(
3239
+ inv.assets.update,
3240
+ { workspaceId: WS_SWEEP, assetId: expiring.id, warrantyUntil: inDays(20) },
3241
+ { context: asUser(OLIVE, WS_SWEEP) },
3242
+ )
3243
+ await jobNamed('warranty-sweep')()
3244
+ expect(notificationsOfType('inventory.warranty.expiring').map((n) => n.userId)).toEqual([DANA])
3245
+ })
3246
+
3247
+ /**
3248
+ * A marker that says "told them" has to mean somebody was told.
3249
+ *
3250
+ * Everything this module sends is best-effort: `NotifyService` swallows a failed
3251
+ * `core.notifications.create` and logs it, which is right — an inventory mutation must not fail
3252
+ * because core is briefly away. The sweep then wrote `warranty_notified_at` regardless, and the
3253
+ * notice is sent **once per asset ever**. So one bad night did not delay the notice, it cancelled
3254
+ * it: for the life of the row, nobody would ever hear that the warranty was running out.
3255
+ *
3256
+ * Left unmarked, the sweep simply says it again in the morning, which is the whole reason it runs
3257
+ * every day.
3258
+ */
3259
+ it('marks nothing when the notice never left, and says it again the next morning', async () => {
3260
+ const flaky = await createAsset(
3261
+ WS_SWEEP,
3262
+ { name: 'Nobody heard about this one', warrantyUntil: inDays(5) },
3263
+ OLIVE,
3264
+ )
3265
+
3266
+ NOTIFICATIONS.length = 0
3267
+ notificationsFail = true
3268
+ try {
3269
+ await jobNamed('warranty-sweep')()
3270
+ } finally {
3271
+ notificationsFail = false
3272
+ }
3273
+ expect(notificationsOfType('inventory.warranty.expiring')).toEqual([])
3274
+
3275
+ const [before] = await kernel.database.withWorkspace(WS_SWEEP, (tx) =>
3276
+ tx.select({ at: assets.warrantyNotifiedAt }).from(assets).where(eq(assets.id, flaky.id)),
3277
+ )
3278
+ expect(before?.at, 'a notice that never left is not a notice that was sent').toBeNull()
3279
+
3280
+ // The next morning, with core back.
3281
+ await jobNamed('warranty-sweep')()
3282
+ expect(notificationsOfType('inventory.warranty.expiring').map((n) => n.url)).toContain(
3283
+ `/inventory?asset=${flaky.id}`,
3284
+ )
3285
+ const [after] = await kernel.database.withWorkspace(WS_SWEEP, (tx) =>
3286
+ tx.select({ at: assets.warrantyNotifiedAt }).from(assets).where(eq(assets.id, flaky.id)),
3287
+ )
3288
+ expect(after?.at, 'and once it has actually been sent, it is marked and stops').not.toBeNull()
3289
+ })
3290
+
3291
+ /**
3292
+ * A notice that reached one of two people is not a notice that was sent.
3293
+ *
3294
+ * The marker column holds one answer for a row whose audience is a *set*, and `notify` is one call
3295
+ * to core per recipient. The sweep used to mark the row whenever at least one of those calls landed
3296
+ * — so a spare with two managers behind it needed one write to succeed for the row to be stamped
3297
+ * *told*, and the other manager was never told and never would be: the notice is sent once per row
3298
+ * ever and nothing clears the marker.
3299
+ *
3300
+ * One column cannot hold two answers, so it holds the pessimistic one. Whoever did hear it hears it
3301
+ * again in the morning, which `groupKey` collapses where a client groups, and nobody is left out.
3302
+ */
3303
+ it('marks nothing when only some of the people were told', async () => {
3304
+ const partly = await createAsset(
3305
+ WS_SWEEP,
3306
+ { name: 'Heard by half the office', warrantyUntil: inDays(6) },
3307
+ OLIVE,
3308
+ )
3309
+
3310
+ NOTIFICATIONS.length = 0
3311
+ // Nobody is holding it, so the audience is everybody who may replace one — Dana and Olive. Core
3312
+ // takes Dana's and refuses Olive's, which is exactly the case a single count cannot describe.
3313
+ NOTIFICATIONS_FAIL_FOR.add(OLIVE)
3314
+ try {
3315
+ await jobNamed('warranty-sweep')()
3316
+ } finally {
3317
+ NOTIFICATIONS_FAIL_FOR.clear()
3318
+ }
3319
+ const partial = notificationsOfType('inventory.warranty.expiring').filter(
3320
+ (n) => n.url === `/inventory?asset=${partly.id}`,
3321
+ )
3322
+ expect(
3323
+ partial.map((n) => n.userId),
3324
+ 'one of the two really was told',
3325
+ ).toEqual([DANA])
3326
+
3327
+ const [before] = await kernel.database.withWorkspace(WS_SWEEP, (tx) =>
3328
+ tx.select({ at: assets.warrantyNotifiedAt }).from(assets).where(eq(assets.id, partly.id)),
3329
+ )
3330
+ expect(before?.at, 'and the row is not stamped told, because Olive was not').toBeNull()
3331
+
3332
+ // The next morning, with core back: both of them, and only then is it marked.
3333
+ NOTIFICATIONS.length = 0
3334
+ await jobNamed('warranty-sweep')()
3335
+ expect(
3336
+ notificationsOfType('inventory.warranty.expiring')
3337
+ .filter((n) => n.url === `/inventory?asset=${partly.id}`)
3338
+ .map((n) => n.userId)
3339
+ .sort(),
3340
+ ).toEqual([DANA, OLIVE].sort())
3341
+ const [after] = await kernel.database.withWorkspace(WS_SWEEP, (tx) =>
3342
+ tx.select({ at: assets.warrantyNotifiedAt }).from(assets).where(eq(assets.id, partly.id)),
3343
+ )
3344
+ expect(after?.at).not.toBeNull()
3345
+
3346
+ // And it stops: the third morning says nothing at all about it.
3347
+ NOTIFICATIONS.length = 0
3348
+ await jobNamed('warranty-sweep')()
3349
+ expect(
3350
+ notificationsOfType('inventory.warranty.expiring').filter(
3351
+ (n) => n.url === `/inventory?asset=${partly.id}`,
3352
+ ),
3353
+ ).toEqual([])
3354
+ })
3355
+
3356
+ it('chases a repair nobody has logged back, once', async () => {
3357
+ NOTIFICATIONS.length = 0
3358
+ await jobNamed('repair-overdue')()
3359
+
3360
+ const sent = notificationsOfType('inventory.repair.overdue')
3361
+ // The person who logged it — they committed the company to the money and have the number.
3362
+ expect(sent.map((n) => n.userId)).toEqual([OLIVE])
3363
+ expect(sent[0]?.url).toBe(`/inventory?asset=${away.id}`)
3364
+
3365
+ NOTIFICATIONS.length = 0
3366
+ await jobNamed('repair-overdue')()
3367
+ expect(notificationsOfType('inventory.repair.overdue')).toEqual([])
3368
+ })
3369
+
3370
+ /** The same rule for the other sweep, and the other marker. See the warranty version above. */
3371
+ it('leaves an overdue repair unmarked when the chase never left', async () => {
3372
+ const stuck = await createAsset(WS_SWEEP, { name: 'Chased into the void' }, OLIVE)
3373
+ const { repair } = await call(
3374
+ inv.repairs.create,
3375
+ { workspaceId: WS_SWEEP, assetId: stuck.id, summary: 'Power supply', sentOn: inDays(-45) },
3376
+ { context: asUser(OLIVE, WS_SWEEP) },
3377
+ )
3378
+
3379
+ NOTIFICATIONS.length = 0
3380
+ notificationsFail = true
3381
+ try {
3382
+ await jobNamed('repair-overdue')()
3383
+ } finally {
3384
+ notificationsFail = false
3385
+ }
3386
+ expect(notificationsOfType('inventory.repair.overdue')).toEqual([])
3387
+
3388
+ const [before] = await kernel.database.withWorkspace(WS_SWEEP, (tx) =>
3389
+ tx.select({ at: repairs.overdueNotifiedAt }).from(repairs).where(eq(repairs.id, repair.id)),
3390
+ )
3391
+ expect(before?.at, 'a chase nobody received is not a chase that happened').toBeNull()
3392
+
3393
+ await jobNamed('repair-overdue')()
3394
+ expect(notificationsOfType('inventory.repair.overdue').map((n) => n.url)).toContain(
3395
+ `/inventory?asset=${stuck.id}`,
3396
+ )
3397
+ const [after] = await kernel.database.withWorkspace(WS_SWEEP, (tx) =>
3398
+ tx.select({ at: repairs.overdueNotifiedAt }).from(repairs).where(eq(repairs.id, repair.id)),
3399
+ )
3400
+ expect(after?.at).not.toBeNull()
3401
+ })
3402
+
3403
+ it('says nothing at all in a workspace that does not record repairs', async () => {
3404
+ // Re-arm the marker so the only thing that can keep the sweep quiet is the capability.
3405
+ await kernel.database.withWorkspace(WS_SWEEP, (tx) =>
3406
+ tx.update(repairs).set({ overdueNotifiedAt: null }).where(eq(repairs.assetId, away.id)),
3407
+ )
3408
+ NOTIFICATIONS.length = 0
3409
+ await withCapabilities({ repairs: false }, async () => {
3410
+ await jobNamed('repair-overdue')()
3411
+ expect(notificationsOfType('inventory.repair.overdue')).toEqual([])
3412
+ }, [WS_SWEEP])
3413
+
3414
+ // And it is genuinely the capability doing it: switched back on, the same run speaks.
3415
+ await jobNamed('repair-overdue')()
3416
+ expect(notificationsOfType('inventory.repair.overdue').map((n) => n.userId)).toEqual([OLIVE])
3417
+ })
3418
+
3419
+ it('never touches a workspace that has not been registered', async () => {
3420
+ const rows = await kernel.database.db.select({ id: workspaces.workspaceId }).from(workspaces)
3421
+ const registered = new Set(rows.map((r) => r.id))
3422
+ expect(registered.has(WS_SWEEP)).toBe(true)
3423
+ // WS_A is where most of this suite lives and has assets with warranties; it is deliberately not
3424
+ // registered, and this is what proves the sweep is bounded by the registry rather than by luck.
3425
+ expect(registered.has(WS_A)).toBe(false)
3426
+ })
3427
+
3428
+ /**
3429
+ * Declared last on purpose: it registers a workspace of its own, and every `warranty-sweep` after
3430
+ * this point would sweep it too.
3431
+ */
3432
+ it('does not mark a notice nobody could be told about', async () => {
3433
+ // A workspace with no members at all, which is what "nobody holds `inventory.asset.manage`"
3434
+ // looks like from here — the audience comes from core, and core says there is nobody.
3435
+ await inventoryModule.onWorkspaceEnabled?.(WS_SILENT, kernel)
3436
+ const orphan = await createAsset(WS_SILENT, { name: 'Nobody’s scanner', warrantyUntil: inDays(7) })
3437
+
3438
+ NOTIFICATIONS.length = 0
3439
+ await jobNamed('warranty-sweep')()
3440
+ expect(notificationsOfType('inventory.warranty.expiring')).toEqual([])
3441
+
3442
+ const before = await kernel.database.withWorkspace(WS_SILENT, (tx) =>
3443
+ tx.select({ at: assets.warrantyNotifiedAt }).from(assets).where(eq(assets.id, orphan.id)),
3444
+ )
3445
+ // Marking it would lose the notice for ever, including for whoever is given the permission
3446
+ // tomorrow — which is what happens next.
3447
+ expect(before[0]?.at, 'nobody to tell is not the same as told').toBeNull()
3448
+
3449
+ seedMember(WS_SILENT, OLIVE, 'admin', 'Olive Ferrer')
3450
+ await jobNamed('warranty-sweep')()
3451
+ expect(notificationsOfType('inventory.warranty.expiring').map((n) => n.userId)).toEqual([OLIVE])
3452
+ const after = await kernel.database.withWorkspace(WS_SILENT, (tx) =>
3453
+ tx.select({ at: assets.warrantyNotifiedAt }).from(assets).where(eq(assets.id, orphan.id)),
3454
+ )
3455
+ expect(after[0]?.at).not.toBeNull()
3456
+ })
3457
+ })
3458
+
3459
+ describe('somebody leaving with company property', () => {
3460
+ const memberRemoved = defineEvent(
3461
+ 'core.member.removed',
3462
+ z.object({ workspaceId: z.uuid(), userId: z.uuid() }),
3463
+ )
3464
+ const personStatusChanged = defineEvent(
3465
+ 'hr.person.status_changed',
3466
+ z.object({
3467
+ workspaceId: z.uuid(),
3468
+ personId: z.uuid(),
3469
+ from: z.string(),
3470
+ to: z.string(),
3471
+ on: z.iso.date(),
3472
+ }),
3473
+ )
3474
+
3475
+ let held: Asset
3476
+
3477
+ beforeAll(async () => {
3478
+ seedMember(WS_LEAVER, OLIVE, 'admin', 'Olive Ferrer')
3479
+ seedMember(WS_LEAVER, DANA, 'member', 'Dana Okoro')
3480
+ held = await createAsset(WS_LEAVER, { name: 'Dana’s MacBook' }, OLIVE)
3481
+ await call(
3482
+ inv.custody.assign,
3483
+ { workspaceId: WS_LEAVER, assetId: held.id, userId: DANA },
3484
+ { context: asUser(OLIVE, WS_LEAVER) },
3485
+ )
3486
+ }, 60_000)
3487
+
3488
+ /**
3489
+ * The HR-absent case runs first, and says so out loud.
3490
+ *
3491
+ * "Inert without HR" is a claim about a procedure nothing hosts, so the honest test is one where
3492
+ * nothing hosts it — not one where a stub pretends to fail. The assertion below is what stops
3493
+ * this passing vacuously if the file is ever reordered and `hr.*` is registered before it.
3494
+ */
3495
+ it('does nothing when HR is not installed anywhere on the instance', async () => {
3496
+ expect(kernel.broker.has('hr.person.get'), 'nothing hosts hr.* yet').toBe(false)
3497
+ NOTIFICATIONS.length = 0
3498
+ await kernel.emit(
3499
+ personStatusChanged,
3500
+ {
3501
+ workspaceId: WS_LEAVER,
3502
+ personId: randomUUID(),
3503
+ from: 'active',
3504
+ to: 'offboarding',
3505
+ on: inDays(0),
3506
+ },
3507
+ { workspaceId: WS_LEAVER },
3508
+ )
3509
+ expect(notificationsOfType('inventory.custody.return_due')).toEqual([])
3510
+ })
3511
+
3512
+ it('raises the return list when core says a member was removed', async () => {
3513
+ NOTIFICATIONS.length = 0
3514
+ await kernel.emit(memberRemoved, { workspaceId: WS_LEAVER, userId: DANA }, { workspaceId: WS_LEAVER })
3515
+
3516
+ const sent = notificationsOfType('inventory.custody.return_due')
3517
+ // Everybody who may take an item back, which by default is every member — and never the person
3518
+ // being chased, who is no longer in the workspace at all.
3519
+ expect(sent.map((n) => n.userId)).toEqual([OLIVE])
3520
+ // The person's name, never their uuid: a sentence with one in the middle is the product
3521
+ // admitting it does not know who it is talking about.
3522
+ expect(sent[0]?.title).toContain('Dana Okoro')
3523
+ expect(sent[0]?.title).not.toContain(DANA)
3524
+ expect(sent[0]?.body).toContain(held.code)
3525
+ expect(sent[0]?.url).toBe(`/inventory?asset=${held.id}`)
3526
+ expect(sent[0]?.data).toMatchObject({ userId: DANA, departure: 'removed' })
3527
+ })
3528
+
3529
+ it('moves nothing: the item is still recorded as theirs, and the timeline is untouched', async () => {
3530
+ const asset = await call(
3531
+ inv.assets.get,
3532
+ { workspaceId: WS_LEAVER, assetId: held.id },
3533
+ { context: asUser(OLIVE, WS_LEAVER) },
3534
+ )
3535
+ expect(asset.custodianUserId, 'a hook must never write a handover nobody performed').toBe(DANA)
3536
+ expect(asset.status).toBe('assigned')
3537
+
3538
+ const open = await kernel.database.withWorkspace(WS_LEAVER, (tx) =>
3539
+ tx.select().from(custodyPeriods).where(eq(custodyPeriods.assetId, held.id)),
3540
+ )
3541
+ expect(open.filter((p) => p.effectiveTo === null)).toHaveLength(1)
3542
+
3543
+ const timeline = await call(
3544
+ inv.assets.history,
3545
+ { workspaceId: WS_LEAVER, assetId: held.id },
3546
+ { context: asUser(OLIVE, WS_LEAVER) },
3547
+ )
3548
+ expect(timeline.items.map((e) => e.action).sort()).toEqual(['assigned', 'created'])
3549
+ })
3550
+
3551
+ it('raises it from HR’s own event once HR is running', async () => {
3552
+ const personId = randomUUID()
3553
+ PEOPLE.set(personId, { id: personId, userId: DANA, status: 'offboarding' })
3554
+ kernel.broker.register('hr', {
3555
+ 'person.get': {
3556
+ handler: async (input: { personId: string }) => PEOPLE.get(input.personId) ?? null,
3557
+ },
3558
+ })
3559
+
3560
+ NOTIFICATIONS.length = 0
3561
+ await kernel.emit(
3562
+ personStatusChanged,
3563
+ { workspaceId: WS_LEAVER, personId, from: 'active', to: 'offboarding', on: inDays(0) },
3564
+ { workspaceId: WS_LEAVER },
3565
+ )
3566
+ const sent = notificationsOfType('inventory.custody.return_due')
3567
+ expect(sent.map((n) => n.userId)).toEqual([OLIVE])
3568
+ expect(sent[0]?.data).toMatchObject({ userId: DANA, departure: 'leaving' })
3569
+ })
3570
+
3571
+ it('ignores a status change that is not somebody leaving', async () => {
3572
+ const personId = randomUUID()
3573
+ PEOPLE.set(personId, { id: personId, userId: DANA, status: 'active' })
3574
+ NOTIFICATIONS.length = 0
3575
+ await kernel.emit(
3576
+ personStatusChanged,
3577
+ { workspaceId: WS_LEAVER, personId, from: 'onboarding', to: 'active', on: inDays(0) },
3578
+ { workspaceId: WS_LEAVER },
3579
+ )
3580
+ expect(notificationsOfType('inventory.custody.return_due')).toEqual([])
3581
+ })
3582
+
3583
+ it('ignores a person with no Kern account behind them', async () => {
3584
+ const personId = randomUUID()
3585
+ PEOPLE.set(personId, { id: personId, userId: null, status: 'terminated' })
3586
+ NOTIFICATIONS.length = 0
3587
+ await kernel.emit(
3588
+ personStatusChanged,
3589
+ { workspaceId: WS_LEAVER, personId, from: 'active', to: 'terminated', on: inDays(0) },
3590
+ { workspaceId: WS_LEAVER },
3591
+ )
3592
+ expect(notificationsOfType('inventory.custody.return_due')).toEqual([])
3593
+ })
3594
+
3595
+ it('says nothing in a workspace that has HR and not Inventory', async () => {
3596
+ // The event bus is instance-wide, so this handler is entered for every workspace on the
3597
+ // instance. One workspace having HR says nothing about another having Inventory.
3598
+ seedMember(WS_NO_INVENTORY, OLIVE, 'admin', 'Olive Ferrer')
3599
+ MODULE_OFF.add(WS_NO_INVENTORY)
3600
+ kernel.settings.invalidate(WS_NO_INVENTORY)
3601
+ try {
3602
+ const personId = randomUUID()
3603
+ PEOPLE.set(personId, { id: personId, userId: DANA, status: 'terminated' })
3604
+ NOTIFICATIONS.length = 0
3605
+ await kernel.emit(
3606
+ personStatusChanged,
3607
+ { workspaceId: WS_NO_INVENTORY, personId, from: 'active', to: 'terminated', on: inDays(0) },
3608
+ { workspaceId: WS_NO_INVENTORY },
3609
+ )
3610
+ expect(notificationsOfType('inventory.custody.return_due')).toEqual([])
3611
+ } finally {
3612
+ MODULE_OFF.delete(WS_NO_INVENTORY)
3613
+ kernel.settings.invalidate(WS_NO_INVENTORY)
3614
+ }
3615
+ })
3616
+
3617
+ it('says nothing about somebody who was holding nothing', async () => {
3618
+ NOTIFICATIONS.length = 0
3619
+ await kernel.emit(memberRemoved, { workspaceId: WS_LEAVER, userId: OLIVE }, { workspaceId: WS_LEAVER })
3620
+ expect(notificationsOfType('inventory.custody.return_due')).toEqual([])
3621
+ })
3622
+
3623
+ it('names every item when somebody holds several', async () => {
3624
+ const second = await createAsset(WS_LEAVER, { name: 'Docking station' }, OLIVE)
3625
+ await call(
3626
+ inv.custody.assign,
3627
+ { workspaceId: WS_LEAVER, assetId: second.id, userId: DANA },
3628
+ { context: asUser(OLIVE, WS_LEAVER) },
3629
+ )
3630
+ NOTIFICATIONS.length = 0
3631
+ await kernel.emit(memberRemoved, { workspaceId: WS_LEAVER, userId: DANA }, { workspaceId: WS_LEAVER })
3632
+ const [sent] = notificationsOfType('inventory.custody.return_due')
3633
+ expect(sent?.body).toContain(held.code)
3634
+ expect(sent?.body).toContain(second.code)
3635
+ // Several items have no single object worth pointing at, so the link is the return list.
3636
+ expect(sent?.url).toBe(`/inventory?custodian=${DANA}`)
3637
+
3638
+ // And that list is a real answer: the same filter the page puts on the URL.
3639
+ const list = await call(
3640
+ inv.custody.byUser,
3641
+ { workspaceId: WS_LEAVER, userId: DANA },
3642
+ { context: asUser(OLIVE, WS_LEAVER) },
3643
+ )
3644
+ expect(list.items.map((a) => a.id).sort()).toEqual([held.id, second.id].sort())
3645
+ })
3646
+ })
3647
+
3648
+ /**
3649
+ * The order a timeline is read in, and the ten random bytes that used to decide it.
3650
+ *
3651
+ * `asset_history` is newest-first, and it used to be ordered by its primary key on the reasoning
3652
+ * `assets.list` still gives for `sort: 'recent'` — a uuidv7 carries the clock, and it is unique
3653
+ * where `created_at` is not. Unique it is. Ordered it is only **to the millisecond**: the kernel's
3654
+ * `uuidv7()` fills bytes 6-15 from `randomUUID()` with no intra-millisecond counter, so two entries
3655
+ * written inside one millisecond sort by chance.
3656
+ *
3657
+ * It never broke paging, which is why it survived so long — the order is *stable* for a given set of
3658
+ * rows, so nothing was ever dropped or repeated. What it broke is the thing the record is for: a
3659
+ * timeline could render "Bruno removed the file" above "Bruno added the file", and a register whose
3660
+ * whole value is saying what happened in the order it happened was quietly lying about half of it.
3661
+ * The suite saw it as `attaching files > detaches one file and records it` failing about one run in
3662
+ * eight; a person would have seen it as the product being wrong.
3663
+ *
3664
+ * `asset_history.seq` is the fix — a sequence, exact rather than probabilistic — and these are the
3665
+ * tests that would have caught it. Twelve entries in one transaction share `created_at` exactly and
3666
+ * are written in a known order; the odds of ten random bytes reproducing that order twelve deep are
3667
+ * about one in half a billion, so this is not a flaky test pointed the other way.
3668
+ */
3669
+ describe('the order an asset’s timeline is written in', () => {
3670
+ const ctx = () => ({ context: asUser(ALICE) })
3671
+ const STEPS = 12
3672
+
3673
+ /**
3674
+ * Twelve entries, one transaction, one `now()`.
3675
+ *
3676
+ * Written through `NotifyService.history` — the same call every mutation in this module makes to
3677
+ * append to the trail — rather than through twelve procedures, because twelve procedures are
3678
+ * twelve transactions and the whole subject here is entries that share a timestamp.
3679
+ */
3680
+ const writeBurst = async (assetId: string) =>
3681
+ run(async (tx) => {
3682
+ const svc = inventoryServices(kernel)
3683
+ for (let step = 0; step < STEPS; step++)
3684
+ await svc.notify.history(tx, {
3685
+ workspaceId: WS_A,
3686
+ assetId,
3687
+ actorId: ALICE,
3688
+ action: `step_${String(step).padStart(2, '0')}`,
3689
+ })
3690
+ })
3691
+
3692
+ const expected = Array.from({ length: STEPS }, (_, i) => `step_${String(STEPS - 1 - i).padStart(2, '0')}`)
3693
+
3694
+ it('reads back newest first when every entry shares a timestamp', async () => {
3695
+ const item = await createAsset(WS_A, { name: 'Twelve things at once' })
3696
+ await writeBurst(item.id)
3697
+
3698
+ const { items } = await call(
3699
+ inv.assets.history,
3700
+ { workspaceId: WS_A, assetId: item.id, limit: 50 },
3701
+ ctx(),
3702
+ )
3703
+ // The `created` entry the asset was born with is last, and everything above it is in reverse
3704
+ // write order — not in an order ten random bytes happened to agree on.
3705
+ expect(items.map((e) => e.action)).toEqual([...expected, 'created'])
3706
+ })
3707
+
3708
+ it('keeps that order across page boundaries, and still repeats nothing', async () => {
3709
+ const item = await createAsset(WS_A, { name: 'Twelve things, two at a time' })
3710
+ await writeBurst(item.id)
3711
+
3712
+ const seen: string[] = []
3713
+ const ids: string[] = []
3714
+ let cursor: string | undefined
3715
+ for (let guard = 0; guard < 20; guard++) {
3716
+ const page = await call(
3717
+ inv.assets.history,
3718
+ { workspaceId: WS_A, assetId: item.id, limit: 2, ...(cursor ? { cursor } : {}) },
3719
+ ctx(),
3720
+ )
3721
+ seen.push(...page.items.map((e) => e.action))
3722
+ ids.push(...page.items.map((e) => e.id))
3723
+ if (!page.nextCursor) break
3724
+ cursor = page.nextCursor
3725
+ }
3726
+ expect(seen, 'twelve steps plus the creation').toEqual([...expected, 'created'])
3727
+ expect(new Set(ids).size, 'no entry repeated across a boundary').toBe(ids.length)
3728
+ })
3729
+
3730
+ it('is a sequence rather than a clock: one transaction, twelve strictly increasing values', async () => {
3731
+ const item = await createAsset(WS_A, { name: 'Twelve sequence numbers' })
3732
+ await writeBurst(item.id)
3733
+
3734
+ const rows = await run((tx) =>
3735
+ tx
3736
+ .select({ seq: assetHistory.seq, at: assetHistory.occurredAt, action: assetHistory.action })
3737
+ .from(assetHistory)
3738
+ .where(and(eq(assetHistory.workspaceId, WS_A), eq(assetHistory.assetId, item.id)))
3739
+ .orderBy(asc(assetHistory.seq)),
3740
+ )
3741
+ const burst = rows.filter((row) => row.action.startsWith('step_'))
3742
+ expect(burst).toHaveLength(STEPS)
3743
+ // The timestamps are identical — `now()` is the transaction's, which is exactly why they cannot
3744
+ // order anything — and the sequence numbers are consecutive and increasing.
3745
+ expect(new Set(burst.map((row) => row.at.toISOString())).size, 'one transaction, one now()').toBe(1)
3746
+ expect(burst.map((row) => row.action)).toEqual([...expected].reverse().map((action) => action))
3747
+ for (let i = 1; i < burst.length; i++)
3748
+ expect(burst[i]!.seq, 'strictly increasing').toBeGreaterThan(burst[i - 1]!.seq)
3749
+ })
3750
+
3751
+ it('orders two separate transactions in the same millisecond, which is the flake that started this', async () => {
3752
+ /**
3753
+ * The reported failure, reduced.
3754
+ *
3755
+ * `attachments.add` and `attachments.remove` are two transactions, and on a warm connection they
3756
+ * land inside one millisecond often enough to fail about one run in eight. Ten attach/detach
3757
+ * pairs make that certain rather than likely: under the old ordering at least one pair would
3758
+ * come back the wrong way round with overwhelming probability.
3759
+ */
3760
+ const asset = await createAsset(WS_FILES, { name: 'Attached and detached ten times' })
3761
+ const user = () => ({ context: asUser(ALICE, WS_FILES) })
3762
+ for (let i = 0; i < 10; i++) {
3763
+ const fileId = seedFile(WS_FILES, `Receipt ${i}.pdf`)
3764
+ const [added] = await call(
3765
+ inv.attachments.add,
3766
+ { workspaceId: WS_FILES, assetId: asset.id, fileIds: [fileId] },
3767
+ user(),
3768
+ )
3769
+ await call(inv.attachments.remove, { workspaceId: WS_FILES, attachmentId: added!.id }, user())
3770
+ }
3771
+
3772
+ const { items } = await call(
3773
+ inv.assets.history,
3774
+ { workspaceId: WS_FILES, assetId: asset.id, limit: 50 },
3775
+ user(),
3776
+ )
3777
+ const actions = items.map((e) => e.action)
3778
+ expect(actions).toEqual([
3779
+ ...Array.from({ length: 10 }, () => ['attachment_removed', 'attachment_added']).flat(),
3780
+ 'created',
3781
+ ])
3782
+ })
3783
+ })
3784
+
3785
+ /**
3786
+ * The timeline's page marker, which is a number rather than a row id now.
3787
+ *
3788
+ * It keeps the three properties the uuid bookmark has and for the same reasons — bounded, checked
3789
+ * before it reaches SQL, and bound to the sort it was issued under — and each of those is a defect
3790
+ * the *first* cursor format in this module actually shipped. A second bookmark format that only
3791
+ * looked validated would be a second set of them.
3792
+ */
3793
+ describe('a page marker into a timeline', () => {
3794
+ const ctx = () => ({ context: asUser(ALICE) })
3795
+ const marker = (value: unknown) => Buffer.from(JSON.stringify(value)).toString('base64url')
3796
+
3797
+ const paged = async (assetId: string, cursor: string) =>
3798
+ refusedWith(() => call(inv.assets.history, { workspaceId: WS_A, assetId, limit: 2, cursor }, ctx()))
3799
+
3800
+ it('refuses everything that is not a sequence number this list issued', async () => {
3801
+ const item = await createAsset(WS_A, { name: 'Marker checks' })
3802
+ await call(inv.assets.update, { workspaceId: WS_A, assetId: item.id, location: 'Desk' }, ctx())
3803
+
3804
+ const refusals = await Promise.all([
3805
+ // A row id, which is what the marker used to be — a cursor already in somebody's browser tab.
3806
+ paged(item.id, marker({ i: randomUUID(), s: 'recent' })),
3807
+ // A number wearing a string, which would reach a `bigint` comparison as a 22P02 nobody caught.
3808
+ paged(item.id, marker({ n: '3', s: 'recent' })),
3809
+ // Not an integer, not positive, and beyond what a double can represent exactly.
3810
+ paged(item.id, marker({ n: 2.5, s: 'recent' })),
3811
+ paged(item.id, marker({ n: 0, s: 'recent' })),
3812
+ paged(item.id, marker({ n: -1, s: 'recent' })),
3813
+ paged(item.id, marker({ n: 1e40, s: 'recent' })),
3814
+ // Issued under an ordering this list does not have.
3815
+ paged(item.id, marker({ n: 3, s: 'oldest' })),
3816
+ // Not a marker at all.
3817
+ paged(item.id, 'not-base64-json'),
3818
+ ])
3819
+ expect(refusals).toEqual(Array.from({ length: 8 }, () => 'BAD_REQUEST'))
3820
+ })
3821
+
3822
+ it('issues one the contract can carry, and one that actually pages', async () => {
3823
+ const item = await createAsset(WS_A, { name: 'Marker size' })
3824
+ await call(inv.assets.update, { workspaceId: WS_A, assetId: item.id, location: 'Desk' }, ctx())
3825
+
3826
+ const first = await call(inv.assets.history, { workspaceId: WS_A, assetId: item.id, limit: 1 }, ctx())
3827
+ expect(first.nextCursor).toBeTruthy()
3828
+ // `Cursor` is `max(512)` in the contract, and the old sort-key cursor broke that on a long
3829
+ // Persian name. This one is the same handful of bytes whatever the row.
3830
+ expect(() => Cursor.parse(first.nextCursor)).not.toThrow()
3831
+ expect(first.nextCursor!.length).toBeLessThan(64)
3832
+
3833
+ const second = await call(
3834
+ inv.assets.history,
3835
+ { workspaceId: WS_A, assetId: item.id, limit: 1, cursor: first.nextCursor! },
3836
+ ctx(),
3837
+ )
3838
+ expect(second.items.map((e) => e.action)).toEqual(['created'])
3839
+ expect(second.nextCursor, 'and it ends rather than looping').toBeNull()
3840
+ })
3841
+ })
3842
+
3843
+ /**
3844
+ * The other foreign id an asset carries, and the one that was still taken on trust.
3845
+ *
3846
+ * `photoFileId` was checked against core; `categoryId` was not checked against anything, so any uuid
3847
+ * at all went into the column. Two ways that goes wrong and neither needs an attacker: an id from
3848
+ * **another workspace** files an asset under a category this one cannot see, name or unfile — the
3849
+ * picker builds its `id → name` map from this workspace's own `categories.list`, so the field renders
3850
+ * blank and the filter behind it offers nothing that matches — and an id belonging to **nobody** does
3851
+ * the same with no second workspace involved. Either way the register holds a reference to a row that
3852
+ * is not there, which is the exact state `categories.archive` exists to prevent from the other end.
3853
+ */
3854
+ describe('filing an asset under a category', () => {
3855
+ const ctx = () => ({ context: asUser(ALICE, WS_CAT) })
3856
+
3857
+ it('refuses one belonging to another workspace, and one belonging to nobody', async () => {
3858
+ const theirs = await call(
3859
+ inv.categories.create,
3860
+ { workspaceId: WS_A, name: 'Somebody else’s filing' },
3861
+ { context: asUser(ALICE, WS_A) },
3862
+ )
3863
+ const refusals = await Promise.all([
3864
+ refusedWith(() =>
3865
+ call(
3866
+ inv.assets.create,
3867
+ { workspaceId: WS_CAT, name: 'Borrowed filing', categoryId: theirs.id },
3868
+ ctx(),
3869
+ ),
3870
+ ),
3871
+ refusedWith(() =>
3872
+ call(
3873
+ inv.assets.create,
3874
+ { workspaceId: WS_CAT, name: 'Filed under nothing', categoryId: randomUUID() },
3875
+ ctx(),
3876
+ ),
3877
+ ),
3878
+ ])
3879
+ expect(refusals).toEqual(['BAD_REQUEST', 'BAD_REQUEST'])
3880
+ })
3881
+
3882
+ it('refuses one on an edit as well, and leaves the asset filed where it was', async () => {
3883
+ const ours = await call(inv.categories.create, { workspaceId: WS_CAT, name: 'Ours to file under' }, ctx())
3884
+ const asset = await call(
3885
+ inv.assets.create,
3886
+ { workspaceId: WS_CAT, name: 'Correctly filed', categoryId: ours.id },
3887
+ ctx(),
3888
+ )
3889
+ const theirs = await call(
3890
+ inv.categories.create,
3891
+ { workspaceId: WS_A, name: 'Not for this workspace' },
3892
+ { context: asUser(ALICE, WS_A) },
3893
+ )
3894
+
3895
+ expect(
3896
+ await refusedWith(() =>
3897
+ call(inv.assets.update, { workspaceId: WS_CAT, assetId: asset.id, categoryId: theirs.id }, ctx()),
3898
+ ),
3899
+ ).toBe('BAD_REQUEST')
3900
+ expect(
3901
+ await refusedWith(() =>
3902
+ call(inv.assets.update, { workspaceId: WS_CAT, assetId: asset.id, categoryId: randomUUID() }, ctx()),
3903
+ ),
3904
+ ).toBe('BAD_REQUEST')
3905
+
3906
+ const after = await call(inv.assets.get, { workspaceId: WS_CAT, assetId: asset.id }, ctx())
3907
+ expect(after.categoryId, 'a refused edit changes nothing').toBe(ours.id)
3908
+ })
3909
+
3910
+ it('takes an archived one of its own, because archiving is not deleting', async () => {
3911
+ const retired = await call(inv.categories.create, { workspaceId: WS_CAT, name: 'Tidied away' }, ctx())
3912
+ await call(inv.categories.archive, { workspaceId: WS_CAT, categoryId: retired.id, archived: true }, ctx())
3913
+ // The row still exists and every asset already filed under it still names it, so refusing here
3914
+ // would mean an edit to an asset's location failing over a category somebody tidied last year.
3915
+ const asset = await call(
3916
+ inv.assets.create,
3917
+ { workspaceId: WS_CAT, name: 'Filed under something archived', categoryId: retired.id },
3918
+ ctx(),
3919
+ )
3920
+ expect(asset.categoryId).toBe(retired.id)
3921
+ })
3922
+
3923
+ it('still lets a patch that never mentions a category through, and one that clears it', async () => {
3924
+ const ours = await call(inv.categories.create, { workspaceId: WS_CAT, name: 'Left alone' }, ctx())
3925
+ const asset = await call(
3926
+ inv.assets.create,
3927
+ { workspaceId: WS_CAT, name: 'Renamed, not refiled', categoryId: ours.id },
3928
+ ctx(),
3929
+ )
3930
+ const renamed = await call(
3931
+ inv.assets.update,
3932
+ { workspaceId: WS_CAT, assetId: asset.id, name: 'Renamed properly' },
3933
+ ctx(),
3934
+ )
3935
+ expect(renamed.categoryId, '`undefined` is "not mentioned", not "check this"').toBe(ours.id)
3936
+
3937
+ const cleared = await call(
3938
+ inv.assets.update,
3939
+ { workspaceId: WS_CAT, assetId: asset.id, categoryId: null },
3940
+ ctx(),
3941
+ )
3942
+ expect(cleared.categoryId, 'and `null` is "unfile it", which is not an id to check').toBeNull()
3943
+ })
3944
+ })
3945
+
3946
+ /**
3947
+ * A date somebody types that quietly switches a safety net off.
3948
+ *
3949
+ * `sentOn` had no upper bound, and the overdue sweep looks for `sent_on <= today - repairOverdueDays`
3950
+ * — so a repair dated 2030 is outside that window *for ever*. Not chased late: never chased, silently,
3951
+ * because a sweep that finds nothing looks exactly like a sweep with nothing to do. It is also the
3952
+ * one field that decides whether anybody is ever reminded that a vendor still has the company's
3953
+ * laptop, which is what makes a typo here expensive and a deliberate one worse.
3954
+ *
3955
+ * The bound is a day past UTC-today, because `RepairService.today()` is UTC and a workspace in
3956
+ * Auckland spends much of its working day in UTC's tomorrow.
3957
+ */
3958
+ describe('the day a repair says it was sent', () => {
3959
+ const ctx = () => ({ context: asUser(ALICE, WS_REPAIR) })
3960
+ const inDaysFromToday = (days: number): string => {
3961
+ const d = new Date()
3962
+ d.setUTCDate(d.getUTCDate() + days)
3963
+ return d.toISOString().slice(0, 10)
3964
+ }
3965
+
3966
+ it('refuses a send date in the future', async () => {
3967
+ const asset = await createAsset(WS_REPAIR, { name: 'Sent next decade' })
3968
+ expect(
3969
+ await refusedWith(() =>
3970
+ call(
3971
+ inv.repairs.create,
3972
+ { workspaceId: WS_REPAIR, assetId: asset.id, summary: 'Screen', sentOn: '2030-01-01' },
3973
+ ctx(),
3974
+ ),
3975
+ ),
3976
+ ).toBe('BAD_REQUEST')
3977
+ // And nothing was written, so the asset is still where it was.
3978
+ expect(await statusOf(WS_REPAIR, asset.id)).toBe('in_stock')
3979
+ })
3980
+
3981
+ it('allows the one day of grace a workspace ahead of UTC needs, and refuses the next', async () => {
3982
+ const ahead = await createAsset(WS_REPAIR, { name: 'Sent in Auckland' })
3983
+ const { repair } = await sendForRepair(WS_REPAIR, ahead.id, {
3984
+ summary: 'Keyboard',
3985
+ sentOn: inDaysFromToday(1),
3986
+ })
3987
+ expect(repair.sentOn).toBe(inDaysFromToday(1))
3988
+
3989
+ const beyond = await createAsset(WS_REPAIR, { name: 'Sent the day after that' })
3990
+ expect(
3991
+ await refusedWith(() =>
3992
+ call(
3993
+ inv.repairs.create,
3994
+ { workspaceId: WS_REPAIR, assetId: beyond.id, summary: 'Fan', sentOn: inDaysFromToday(2) },
3995
+ ctx(),
3996
+ ),
3997
+ ),
3998
+ ).toBe('BAD_REQUEST')
3999
+ })
4000
+
4001
+ it('refuses an edit that moves the date into the future, and leaves the row alone', async () => {
4002
+ const asset = await createAsset(WS_REPAIR, { name: 'Re-dated into 2030' })
4003
+ const { repair } = await sendForRepair(WS_REPAIR, asset.id, {
4004
+ summary: 'Hinge',
4005
+ sentOn: inDaysFromToday(-40),
4006
+ })
4007
+ expect(
4008
+ await refusedWith(() =>
4009
+ call(
4010
+ inv.repairs.update,
4011
+ { workspaceId: WS_REPAIR, repairId: repair.id, sentOn: '2030-06-01' },
4012
+ ctx(),
4013
+ ),
4014
+ ),
4015
+ ).toBe('BAD_REQUEST')
4016
+
4017
+ const [row] = await kernel.database.withWorkspace(WS_REPAIR, (tx) =>
4018
+ tx
4019
+ .select({ sentOn: repairs.sentOn })
4020
+ .from(repairs)
4021
+ .where(and(eq(repairs.workspaceId, WS_REPAIR), eq(repairs.id, repair.id))),
4022
+ )
4023
+ expect(row?.sentOn, 'a refused edit writes nothing').toBe(inDaysFromToday(-40))
4024
+ })
4025
+
4026
+ it('leaves the overdue sweep able to find one that was dated properly', async () => {
4027
+ // The point of the bound, stated as the behaviour it protects rather than as the refusal.
4028
+ const asset = await createAsset(WS_REPAIR, { name: 'Findable by the sweep' })
4029
+ const { repair } = await sendForRepair(WS_REPAIR, asset.id, {
4030
+ summary: 'Battery',
4031
+ sentOn: inDaysFromToday(-60),
4032
+ })
4033
+ const found = await kernel.database.withWorkspace(WS_REPAIR, (tx) =>
4034
+ tx
4035
+ .select({ id: repairs.id })
4036
+ .from(repairs)
4037
+ .where(
4038
+ and(
4039
+ eq(repairs.workspaceId, WS_REPAIR),
4040
+ eq(repairs.id, repair.id),
4041
+ sql`${repairs.sentOn} <= ${inDaysFromToday(-14)}::date`,
4042
+ ),
4043
+ ),
4044
+ )
4045
+ expect(found).toHaveLength(1)
4046
+ })
4047
+ })
4048
+
4049
+ /**
4050
+ * Switching a capability off must not trap what is behind it.
4051
+ *
4052
+ * A capability is a boolean in module settings: switching it off hides a surface and answers 404, and
4053
+ * switching it back on has to find everything exactly where it was. `repairs` broke the second half
4054
+ * of that. An item that was away when the switch went off stayed `under_repair` for ever — the only
4055
+ * procedure that can close a repair answers 404 — and `assets.archive` refused to retire it *because*
4056
+ * it was away, with a sentence telling the person to go and use the procedure that answers 404. Two
4057
+ * refusals pointing at each other, and an asset the workspace could not get out of either.
4058
+ *
4059
+ * The rule now is that a capability which is off makes its facts inert: `under_repair` belongs to
4060
+ * `repairs`, so a workspace without it has no asset in that status, and no refusal may cite a repair
4061
+ * the workspace cannot reach. Nothing is destroyed to achieve it — the repair rows sit untouched, and
4062
+ * switching back on brings the status back with them.
4063
+ */
4064
+ describe('a workspace that switches repairs off with one still open', () => {
4065
+ const on = () => ({ context: asUser(ALICE, WS_STRAND) })
4066
+ const withRepairsOff = (fn: () => Promise<void>) => withCapabilities({ repairs: false }, fn, [WS_STRAND])
4067
+
4068
+ it('lets the item be archived, where the refusal used to point at a 404', async () => {
4069
+ const asset = await createAsset(WS_STRAND, { name: 'Away when the switch went off' })
4070
+ await sendForRepair(WS_STRAND, asset.id, { summary: 'Cracked panel' })
4071
+ expect(await statusOf(WS_STRAND, asset.id)).toBe('under_repair')
4072
+
4073
+ // While repairs are still on, the refusal stands and means something: the two-step instruction
4074
+ // it gives is one the workspace can actually follow.
4075
+ expect(
4076
+ await refusedWith(() =>
4077
+ call(inv.assets.archive, { workspaceId: WS_STRAND, assetId: asset.id, archived: true }, on()),
4078
+ ),
4079
+ ).toBe('CONFLICT')
4080
+
4081
+ await withRepairsOff(async () => {
4082
+ const archived = await call(
4083
+ inv.assets.archive,
4084
+ { workspaceId: WS_STRAND, assetId: asset.id, archived: true },
4085
+ { context: asUser(ALICE, WS_STRAND) },
4086
+ )
4087
+ expect(archived.archivedAt).not.toBeNull()
4088
+ })
4089
+
4090
+ // Switched back on: the repair is still open, still says what it said, and can still be closed.
4091
+ const { items } = await call(inv.repairs.list, { workspaceId: WS_STRAND, assetId: asset.id }, on())
4092
+ expect(items.map((r) => ({ summary: r.summary, returnedOn: r.returnedOn }))).toEqual([
4093
+ { summary: 'Cracked panel', returnedOn: null },
4094
+ ])
4095
+ const { asset: back } = await completeRepair(WS_STRAND, items[0]!.id)
4096
+ expect(back.status, 'and completing it still derives a status').toBe('in_stock')
4097
+ })
4098
+
4099
+ it('takes the item out of `under_repair` the next time anybody touches it', async () => {
4100
+ const asset = await createAsset(WS_STRAND, { name: 'Handed over while repairs were off' })
4101
+ await sendForRepair(WS_STRAND, asset.id, { summary: 'Fan' })
4102
+ expect(await statusOf(WS_STRAND, asset.id)).toBe('under_repair')
4103
+
4104
+ await withRepairsOff(async () => {
4105
+ const { asset: handed } = await call(
4106
+ inv.custody.assign,
4107
+ { workspaceId: WS_STRAND, assetId: asset.id, userId: BOB },
4108
+ { context: asUser(ALICE, WS_STRAND) },
4109
+ )
4110
+ // `under_repair` is a status the `repairs` capability owns. Without it the item is simply
4111
+ // Bob's — which is the whole truth this workspace still records about where it is.
4112
+ expect({ status: handed.status, holder: handed.custodianUserId }).toEqual({
4113
+ status: 'assigned',
4114
+ holder: BOB,
4115
+ })
4116
+ })
4117
+
4118
+ // And on again: the repair is still open, so the next thing that touches the asset says so.
4119
+ const { asset: after } = await call(
4120
+ inv.custody.return,
4121
+ { workspaceId: WS_STRAND, assetId: asset.id },
4122
+ on(),
4123
+ )
4124
+ expect({ status: after.status, holder: after.custodianUserId }).toEqual({
4125
+ status: 'under_repair',
4126
+ holder: null,
4127
+ })
4128
+ })
4129
+
4130
+ it('reconciles the spare in the cupboard that nobody touches, in both directions', async () => {
4131
+ /**
4132
+ * `reconcileStatuses` called directly rather than through `repair-overdue`, and deliberately.
4133
+ *
4134
+ * The job resolves the capability per workspace from core, and the stub answering core here is
4135
+ * instance-wide — so running the whole job with `repairs: false` in force would restamp every
4136
+ * *other* workspace in this suite as a side effect of testing this one. The function takes the
4137
+ * switch as an argument for exactly the reason the services do, which also makes both directions
4138
+ * assertable without touching the switchboard at all.
4139
+ */
4140
+ const asset = await createAsset(WS_STRAND, { name: 'Left in the cupboard' })
4141
+ await sendForRepair(WS_STRAND, asset.id, { summary: 'Power supply' })
4142
+ expect(await statusOf(WS_STRAND, asset.id)).toBe('under_repair')
4143
+
4144
+ expect(await reconcileStatuses(kernel, WS_STRAND, false), 'one row was wrong').toBeGreaterThan(0)
4145
+ expect(await statusOf(WS_STRAND, asset.id)).toBe('in_stock')
4146
+ expect(await reconcileStatuses(kernel, WS_STRAND, false), 'and now nothing is').toBe(0)
4147
+
4148
+ // Back on, without anybody having touched the asset in between.
4149
+ expect(await reconcileStatuses(kernel, WS_STRAND, true)).toBeGreaterThan(0)
4150
+ expect(await statusOf(WS_STRAND, asset.id)).toBe('under_repair')
4151
+ expect(await reconcileStatuses(kernel, WS_STRAND, true)).toBe(0)
4152
+ })
4153
+
4154
+ it('never overwrites a status this module does not derive', async () => {
4155
+ // `reserved`, `lost` and `retired` are set by hand by features that do not exist yet. A nightly
4156
+ // reconciliation that stamped over one would silently undo somebody's decision.
4157
+ const asset = await createAsset(WS_STRAND, { name: 'Written off by hand' })
4158
+ await kernel.database.withWorkspace(WS_STRAND, (tx) =>
4159
+ tx
4160
+ .update(assets)
4161
+ .set({ status: 'lost' })
4162
+ .where(and(eq(assets.workspaceId, WS_STRAND), eq(assets.id, asset.id))),
4163
+ )
4164
+ // Both directions, and `true` last so this test leaves the workspace as it found it — the
4165
+ // reconciliation is workspace-wide by design, so running it with `repairs` off would release
4166
+ // every other asset in here and the count test below would be asserting about its own leftovers.
4167
+ await reconcileStatuses(kernel, WS_STRAND, false)
4168
+ await reconcileStatuses(kernel, WS_STRAND, true)
4169
+ expect(await statusOf(WS_STRAND, asset.id)).toBe('lost')
4170
+ })
4171
+
4172
+ it('keeps the two counts of what is away in step once an archived row can have one', async () => {
4173
+ /**
4174
+ * `stats.byStatus.under_repair` counts live rows by their cached status; `outForRepair` counts
4175
+ * repair rows. They were kept equal by `assets.archive` refusing to retire an item that is away —
4176
+ * the refusal that has just been withdrawn while the capability is off. So `away()` joins to the
4177
+ * asset now, and this is the state that would otherwise have made them disagree.
4178
+ */
4179
+ const stats = await call(inv.stats.summary, { workspaceId: WS_STRAND }, on())
4180
+ expect(stats.outForRepair, 'the archived one from the first test is not counted').toBe(
4181
+ stats.byStatus.under_repair,
4182
+ )
4183
+ })
4184
+ })
4185
+
4186
+ /**
4187
+ * The other half of the lost-update fix, which nothing was holding.
4188
+ *
4189
+ * `assets.status` is derived from two facts written by two different services: custody, and the open
4190
+ * repair. Each used to read the other's fact without a lock, so a handover and a repair committing at
4191
+ * the same instant interleaved into a status matching neither — the second writer derived from a
4192
+ * snapshot that had already stopped being true and wrote it over the first one's answer.
4193
+ *
4194
+ * `CustodyService.stamp` takes the lock and three tests fail without it. `RepairService.restamp`
4195
+ * takes the same lock and **nothing failed without it**, because every existing test that races the
4196
+ * two puts the repair first: a repair that is open dominates the derivation, so a stale custodian
4197
+ * cannot change the answer. The interleaving that exposes it is the other way round — a repair being
4198
+ * *completed* while a handover commits, where the status the repair writes is decided entirely by a
4199
+ * custodian it read before the handover existed.
4200
+ */
4201
+ describe('a repair coming back as the item changes hands', () => {
4202
+ it('does not put an item in stock that somebody has just been handed', async () => {
4203
+ const svc = inventoryServices(kernel)
4204
+ const item = await createAsset(WS_CUSTODY, { name: 'Back from the workshop, into Bob’s hands' })
4205
+ const { repair } = await call(
4206
+ inv.repairs.create,
4207
+ { workspaceId: WS_CUSTODY, assetId: item.id, summary: 'Screen' },
4208
+ { context: asUser(ALICE, WS_CUSTODY) },
4209
+ )
4210
+
4211
+ let handed!: () => void
4212
+ const done = new Promise<void>((resolve) => {
4213
+ handed = resolve
4214
+ })
4215
+ let commitHandover!: () => void
4216
+ const holdHandover = new Promise<void>((resolve) => {
4217
+ commitHandover = resolve
4218
+ })
4219
+
4220
+ // The handover goes first and holds: it has written `custodian_user_id = BOB` and holds the
4221
+ // asset row's lock, uncommitted.
4222
+ const handing = kernel.database.withWorkspace(
4223
+ WS_CUSTODY,
4224
+ async (tx) => {
4225
+ await svc.custody.assign(tx, WS_CUSTODY, ALICE, item.id, BOB, null, RECORDS_REPAIRS)
4226
+ handed()
4227
+ await holdHandover
4228
+ },
4229
+ { userId: ALICE },
4230
+ )
4231
+ await done
4232
+
4233
+ /**
4234
+ * The completion reads the asset with a plain select before it writes — so it sees the custodian
4235
+ * as it was *before* the handover, which is nobody. Then `restamp` asks for the lock and waits.
4236
+ *
4237
+ * Without that lock it would derive `in_stock` from the stale row and write it over the
4238
+ * handover's `assigned`: Bob holding a laptop the register says is in the cupboard, which is the
4239
+ * one question the register exists to answer.
4240
+ */
4241
+ const finishing = kernel.database.withWorkspace(
4242
+ WS_CUSTODY,
4243
+ (tx) => svc.repairs.complete(tx, WS_CUSTODY, ALICE, repair.id, {}),
4244
+ { userId: ALICE },
4245
+ )
4246
+ const finished = finishing.then(
4247
+ () => null,
4248
+ (err: unknown) => err,
4249
+ )
4250
+
4251
+ await waitForBlockedBackend()
4252
+ commitHandover()
4253
+ await handing
4254
+ expect(await finished, 'a handover never refuses a completion').toBeNull()
4255
+
4256
+ const after = await call(
4257
+ inv.assets.get,
4258
+ { workspaceId: WS_CUSTODY, assetId: item.id },
4259
+ { context: asUser(ALICE, WS_CUSTODY) },
4260
+ )
4261
+ expect({ status: after.status, holder: after.custodianUserId }).toEqual({
4262
+ status: 'assigned',
4263
+ holder: BOB,
4264
+ })
4265
+ })
4266
+ })
4267
+
4268
+ /**
4269
+ * Two people taking the same item back at once.
4270
+ *
4271
+ * `CustodyService.close` writes `effective_to` with `and effective_to is null` in its predicate, and
4272
+ * that clause is the optimistic guard: under READ COMMITTED the second transaction blocks on the row
4273
+ * lock, and when it resumes the row no longer matches, so zero rows come back rather than a second
4274
+ * close silently overwriting the first one's timestamp. It was real and nothing tested it — removing
4275
+ * the clause broke no test at all, which is the same shape of gap as the lock above.
4276
+ *
4277
+ * What it prevents is not an exotic state: a second `returned` entry in the timeline for a return
4278
+ * that happened once, and a period whose end is whichever of two transactions finished last.
4279
+ */
4280
+ describe('two people taking the same item back at once', () => {
4281
+ it('lets exactly one close the period, and tells the other to look again', async () => {
4282
+ const svc = inventoryServices(kernel)
4283
+ const item = await createAsset(WS_CUSTODY, { name: 'Handed back twice' })
4284
+ await call(
4285
+ inv.custody.assign,
4286
+ { workspaceId: WS_CUSTODY, assetId: item.id, userId: BOB },
4287
+ { context: asUser(ALICE, WS_CUSTODY) },
4288
+ )
4289
+
4290
+ let closed!: () => void
4291
+ const done = new Promise<void>((resolve) => {
4292
+ closed = resolve
4293
+ })
4294
+ let commitFirst!: () => void
4295
+ const holdFirst = new Promise<void>((resolve) => {
4296
+ commitFirst = resolve
4297
+ })
4298
+
4299
+ const first = kernel.database.withWorkspace(
4300
+ WS_CUSTODY,
4301
+ async (tx) => {
4302
+ await svc.custody.return(tx, WS_CUSTODY, ALICE, item.id, null, RECORDS_REPAIRS)
4303
+ closed()
4304
+ await holdFirst
4305
+ },
4306
+ { userId: ALICE },
4307
+ )
4308
+ await done
4309
+
4310
+ // The second one reads the period as still open — the close above is uncommitted, so it
4311
+ // genuinely is — and blocks on the row when it tries to close it too.
4312
+ const second = kernel.database.withWorkspace(
4313
+ WS_CUSTODY,
4314
+ (tx) => svc.custody.return(tx, WS_CUSTODY, OLIVE, item.id, null, RECORDS_REPAIRS),
4315
+ { userId: OLIVE },
4316
+ )
4317
+ const settled = second.then(
4318
+ () => null,
4319
+ (err: unknown) => err,
4320
+ )
4321
+
4322
+ await waitForBlockedBackend()
4323
+ commitFirst()
4324
+ await first
4325
+
4326
+ const reason = await settled
4327
+ expect(reason, 'the second close must lose').not.toBeNull()
4328
+ expect(codeOf(reason), 'a lost race is a conflict, not an unhandled 500').toBe('CONFLICT')
4329
+ expect(messageOf(reason)).not.toMatch(/failed query/i)
4330
+ expect(messageOf(reason), 'and it says what to do about it').toMatch(/reload/i)
4331
+
4332
+ // One return, one closed period, one timeline entry saying so.
4333
+ const periods = await call(
4334
+ inv.custody.history,
4335
+ { workspaceId: WS_CUSTODY, assetId: item.id },
4336
+ { context: asUser(ALICE, WS_CUSTODY) },
4337
+ )
4338
+ expect(periods).toHaveLength(1)
4339
+ expect(periods[0]?.effectiveTo).not.toBeNull()
4340
+ const { items } = await call(
4341
+ inv.assets.history,
4342
+ { workspaceId: WS_CUSTODY, assetId: item.id, limit: 10 },
4343
+ { context: asUser(ALICE, WS_CUSTODY) },
4344
+ )
4345
+ expect(
4346
+ items.filter((e) => e.action === 'returned'),
4347
+ 'one return, not two',
4348
+ ).toHaveLength(1)
4349
+ })
4350
+
4351
+ it('refuses a return on an archived item, so there is no back door to undo one', async () => {
4352
+ /**
4353
+ * `CustodyService.stamp`'s comment used to say the opposite — that a return is deliberately let
4354
+ * past the archived check so an item that had reached the impossible state could be handed back.
4355
+ * It is not, and it never was: `return` calls `asset()` first, and `asset()` refuses an archived
4356
+ * row whichever verb asked. The narrowing in `stamp` is about a *handover* racing an archive, and
4357
+ * this is the test that stops the comment drifting away from the code again.
4358
+ */
4359
+ const item = await createAsset(WS_CUSTODY, { name: 'Retired and then handed back' })
4360
+ await call(
4361
+ inv.assets.archive,
4362
+ { workspaceId: WS_CUSTODY, assetId: item.id, archived: true },
4363
+ { context: asUser(ALICE, WS_CUSTODY) },
4364
+ )
4365
+ const refusal = await refusedWith(() =>
4366
+ call(
4367
+ inv.custody.return,
4368
+ { workspaceId: WS_CUSTODY, assetId: item.id },
4369
+ { context: asUser(ALICE, WS_CUSTODY) },
4370
+ ),
4371
+ )
4372
+ expect(refusal).toBe('CONFLICT')
4373
+ })
4374
+ })