@kernhq/module-inventory 0.1.1 → 0.2.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 (103) hide show
  1. package/README.md +55 -9
  2. package/dist/contract/capabilities.d.ts +49 -0
  3. package/dist/contract/capabilities.d.ts.map +1 -0
  4. package/dist/contract/capabilities.js +52 -0
  5. package/dist/contract/capabilities.js.map +1 -0
  6. package/dist/contract/events.d.ts +33 -0
  7. package/dist/contract/events.d.ts.map +1 -0
  8. package/dist/contract/events.js +22 -0
  9. package/dist/contract/events.js.map +1 -0
  10. package/dist/contract/index.d.ts +15 -0
  11. package/dist/contract/index.d.ts.map +1 -0
  12. package/dist/contract/index.js +15 -0
  13. package/dist/contract/index.js.map +1 -0
  14. package/dist/contract/models.d.ts +146 -0
  15. package/dist/contract/models.d.ts.map +1 -0
  16. package/dist/contract/models.js +107 -0
  17. package/dist/contract/models.js.map +1 -0
  18. package/dist/contract/permissions.d.ts +22 -0
  19. package/dist/contract/permissions.d.ts.map +1 -0
  20. package/dist/contract/permissions.js +26 -0
  21. package/dist/contract/permissions.js.map +1 -0
  22. package/dist/{contract.d.ts → contract/router.d.ts} +24 -85
  23. package/dist/contract/router.d.ts.map +1 -0
  24. package/dist/contract/router.js +42 -0
  25. package/dist/contract/router.js.map +1 -0
  26. package/dist/contract/settings.d.ts +18 -0
  27. package/dist/contract/settings.d.ts.map +1 -0
  28. package/dist/contract/settings.js +29 -0
  29. package/dist/contract/settings.js.map +1 -0
  30. package/dist/server/index.d.ts +4 -1
  31. package/dist/server/index.d.ts.map +1 -1
  32. package/dist/server/index.js +9 -13
  33. package/dist/server/index.js.map +1 -1
  34. package/dist/server/{_impl.d.ts → router.d.ts} +33 -8
  35. package/dist/server/{_impl.d.ts.map → router.d.ts.map} +1 -1
  36. package/dist/server/router.js +83 -0
  37. package/dist/server/router.js.map +1 -0
  38. package/dist/server/schema.d.ts +25 -11
  39. package/dist/server/schema.d.ts.map +1 -1
  40. package/dist/server/schema.js +28 -10
  41. package/dist/server/schema.js.map +1 -1
  42. package/dist/server/services/assets.d.ts +73 -0
  43. package/dist/server/services/assets.d.ts.map +1 -0
  44. package/dist/server/services/assets.js +261 -0
  45. package/dist/server/services/assets.js.map +1 -0
  46. package/dist/server/services/index.d.ts +10 -0
  47. package/dist/server/services/index.d.ts.map +1 -0
  48. package/dist/server/services/index.js +15 -0
  49. package/dist/server/services/index.js.map +1 -0
  50. package/dist/server/services/notify.d.ts +63 -0
  51. package/dist/server/services/notify.d.ts.map +1 -0
  52. package/dist/server/services/notify.js +105 -0
  53. package/dist/server/services/notify.js.map +1 -0
  54. package/migrations/0000_init.sql +12 -3
  55. package/migrations/0001_rls.sql +24 -0
  56. package/migrations/meta/0000_snapshot.json +40 -13
  57. package/migrations/meta/_journal.json +2 -2
  58. package/package.json +12 -9
  59. package/src/client/api.ts +1 -1
  60. package/src/client/components/AssetFormDialog.svelte +130 -47
  61. package/src/client/i18n.ts +11 -166
  62. package/src/client/index.ts +8 -1
  63. package/src/client/messages.test.ts +169 -0
  64. package/src/client/messages.ts +399 -0
  65. package/src/client/mock.test.ts +161 -0
  66. package/src/client/mock.ts +267 -45
  67. package/src/client/module.ts +22 -2
  68. package/src/client/pages/AssetsPage.svelte +358 -137
  69. package/src/client/permissions.ts +1 -1
  70. package/src/client/price.test.ts +106 -0
  71. package/src/client/price.ts +135 -0
  72. package/src/client/query.test.ts +58 -0
  73. package/src/client/query.ts +15 -2
  74. package/src/client/settings/GeneralSettings.svelte +0 -0
  75. package/src/client/settings/core-api.ts +32 -0
  76. package/src/client/widgets/OverviewWidget.svelte +16 -3
  77. package/src/contract/capabilities.ts +55 -0
  78. package/src/contract/events.ts +34 -0
  79. package/src/contract/index.ts +15 -0
  80. package/src/contract/models.ts +123 -0
  81. package/src/contract/permissions.ts +26 -0
  82. package/src/contract/router.ts +46 -0
  83. package/src/contract/settings.ts +30 -0
  84. package/src/module.test.ts +140 -7
  85. package/src/server/index.ts +16 -13
  86. package/src/server/inventory.int.test.ts +819 -0
  87. package/src/server/migrations.test.ts +138 -0
  88. package/src/server/router.ts +118 -0
  89. package/src/server/schema.ts +27 -10
  90. package/src/server/services/assets.ts +368 -0
  91. package/src/server/services/index.ts +23 -0
  92. package/src/server/services/notify.ts +151 -0
  93. package/tsconfig.base.json +22 -0
  94. package/tsconfig.client.json +1 -1
  95. package/tsconfig.json +1 -1
  96. package/vitest.config.ts +18 -3
  97. package/dist/contract.d.ts.map +0 -1
  98. package/dist/contract.js +0 -119
  99. package/dist/contract.js.map +0 -1
  100. package/dist/server/_impl.js +0 -204
  101. package/dist/server/_impl.js.map +0 -1
  102. package/src/contract.ts +0 -143
  103. package/src/server/_impl.ts +0 -275
@@ -0,0 +1,819 @@
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'
4
+ import { call } from '@orpc/server'
5
+ import { and, asc, eq } from 'drizzle-orm'
6
+ import pg from 'pg'
7
+ import { afterAll, beforeAll, describe, expect, it } from 'vitest'
8
+ import type { Asset } from '../contract/models.js'
9
+ import { inventoryModule } from './index.js'
10
+ import { inventoryRouter } from './router.js'
11
+ import { assetHistory, assets, custodyPeriods, TENANT_TABLES } from './schema.js'
12
+
13
+ /**
14
+ * Inventory against a real Postgres.
15
+ *
16
+ * The unit tests walk the contract and the router as data; this proves the things only a database
17
+ * can answer — that both migrations apply on a database created from nothing, that the exclusion
18
+ * constraint refuses the write it claims to, that row-level security holds for a role that cannot
19
+ * bypass it, and that the keyset cursor pages through a list without dropping or repeating a row.
20
+ *
21
+ * A scratch database per run, dropped afterwards, so it never touches development data.
22
+ */
23
+
24
+ const BASE_URL = process.env.DATABASE_URL ?? 'postgres://kern:kern@localhost:5432/kern'
25
+ const DB_NAME = `kern_inventory_test_${Date.now().toString(36)}`
26
+ const RLS_ROLE = `kern_inv_rls_${Date.now().toString(36)}`
27
+
28
+ let kernel: Kernel
29
+ let inv: ReturnType<typeof inventoryRouter>
30
+ let admin: pg.Client
31
+ let databaseUrl: string
32
+
33
+ /** Branded in the contract, plain uuids here — the brand is a compile-time claim, not a value. */
34
+ const workspace = () => randomUUID() as WorkspaceId
35
+
36
+ const WS_A = workspace()
37
+ const WS_B = workspace()
38
+ /** Its own workspace, so the counter starts at 1 and the codes are predictable. */
39
+ const WS_CODES = workspace()
40
+ const WS_PAGE = workspace()
41
+ const WS_FILTER = workspace()
42
+
43
+ const ALICE = randomUUID()
44
+ const BOB = randomUUID()
45
+
46
+ const principal = (userId: string, workspaceId: string): Principal =>
47
+ // `unknown` first: `userId` is branded on Principal and a plain string does not overlap it, which
48
+ // is a real difference the test does not need to model.
49
+ ({
50
+ kind: 'user',
51
+ userId,
52
+ email: `${userId}@example.test`,
53
+ name: userId.slice(0, 8),
54
+ locale: 'en',
55
+ instanceAdmin: false,
56
+ service: null,
57
+ memberships: [{ workspaceId, role: 'admin', roleIds: [], groupIds: [], status: 'active' }],
58
+ permissionVersion: 0,
59
+ }) as unknown as Principal
60
+
61
+ const inWs =
62
+ (workspaceId: string) =>
63
+ <T>(fn: (tx: Tx) => Promise<T>): Promise<T> =>
64
+ kernel.database.withWorkspace(workspaceId, fn, { userId: ALICE })
65
+
66
+ const run = inWs(WS_A)
67
+
68
+ function registerCoreStubs(k: Kernel) {
69
+ k.broker.register('core', {
70
+ '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 },
75
+ 'authz.customRolePermissions': { handler: async () => [] },
76
+ '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 () => ({}) },
81
+ 'settings.setModule': { handler: async () => ({ ok: true }) },
82
+ })
83
+ }
84
+
85
+ /**
86
+ * A request context, as the HTTP layer would build one.
87
+ *
88
+ * Every procedure is called through `call()` rather than through its service, because the router is
89
+ * the only place `workspaceScoped` and `requires` live — calling `AssetService` directly would test
90
+ * the query and skip the two gates that decide whether anybody may reach it.
91
+ */
92
+ const asUser = (userId: string, workspaceId: string = WS_A): RequestContext => ({
93
+ kernel,
94
+ principal: principal(userId, workspaceId),
95
+ requestId: randomUUID(),
96
+ ip: '127.0.0.1',
97
+ headers: {},
98
+ })
99
+
100
+ beforeAll(async () => {
101
+ admin = new pg.Client({ connectionString: BASE_URL })
102
+ await admin.connect()
103
+ await admin.query(`create database "${DB_NAME}"`)
104
+ const url = new URL(BASE_URL)
105
+ url.pathname = `/${DB_NAME}`
106
+ databaseUrl = url.toString()
107
+
108
+ kernel = await createKernel({
109
+ service: 'inventory-test',
110
+ modules: [inventoryModule],
111
+ role: 'api',
112
+ env: {
113
+ DATABASE_URL: databaseUrl,
114
+ KERN_SECRET: 'test-secret-that-is-long-enough-for-kern',
115
+ NODE_ENV: 'test',
116
+ NATS_URL: undefined,
117
+ VALKEY_URL: undefined,
118
+ },
119
+ })
120
+ registerCoreStubs(kernel)
121
+ await kernel.start()
122
+ inv = inventoryRouter(kernel)
123
+ }, 180_000)
124
+
125
+ afterAll(async () => {
126
+ await kernel?.stop().catch(() => undefined)
127
+ await admin.query(`drop database if exists "${DB_NAME}" with (force)`).catch(() => undefined)
128
+ await admin.query(`drop role if exists "${RLS_ROLE}"`).catch(() => undefined)
129
+ await admin.end().catch(() => undefined)
130
+ })
131
+
132
+ // ---------------------------------------------------------------------------------------------
133
+ // helpers
134
+
135
+ interface NewAsset {
136
+ name: string
137
+ description?: string
138
+ serialNumber?: string | null
139
+ location?: string | null
140
+ purchasedOn?: string | null
141
+ priceMinor?: number | null
142
+ currency?: string | null
143
+ }
144
+
145
+ const createAsset = (workspaceId: WorkspaceId, input: NewAsset, userId = ALICE): Promise<Asset> =>
146
+ call(inv.assets.create, { workspaceId, ...input }, { context: asUser(userId, workspaceId) })
147
+
148
+ const listAssets = (
149
+ workspaceId: WorkspaceId,
150
+ input: Omit<Parameters<typeof inv.assets.list>[0], 'workspaceId'> = {},
151
+ userId = ALICE,
152
+ ) => call(inv.assets.list, { workspaceId, ...input }, { context: asUser(userId, workspaceId) })
153
+
154
+ /**
155
+ * The error a rejected call actually carried.
156
+ *
157
+ * `KernError` travels up through `call()` unwrapped, but oRPC is free to wrap it, so this reaches
158
+ * through `cause` rather than trusting the top frame. Asserting on the message would pass for any
159
+ * failure — a typo in the input included — which is the whole thing these tests are trying to rule
160
+ * out.
161
+ */
162
+ async function refusedWith(fn: () => Promise<unknown>): Promise<string> {
163
+ try {
164
+ await fn()
165
+ } 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
+ }
172
+ throw new Error(`Rejected, but with no error code: ${String(err)}`)
173
+ }
174
+ throw new Error('Expected the call to be refused, but it succeeded')
175
+ }
176
+
177
+ /**
178
+ * Postgres reports which constraint refused a write; drizzle wraps that in a "Failed query" error
179
+ * whose message does not carry the name. Asserting on the message alone would pass for *any*
180
+ * failure, so this reaches through to the driver's own field.
181
+ */
182
+ async function constraintViolated(fn: () => Promise<unknown>): Promise<string> {
183
+ try {
184
+ await fn()
185
+ } catch (err) {
186
+ let cursor: unknown = err
187
+ for (let depth = 0; depth < 5 && cursor; depth++) {
188
+ const name = (cursor as { constraint?: string }).constraint
189
+ if (name) return name
190
+ cursor = (cursor as { cause?: unknown }).cause
191
+ }
192
+ throw new Error(`Rejected, but not by a named constraint: ${String(err)}`)
193
+ }
194
+ throw new Error('Expected the write to be refused, but it succeeded')
195
+ }
196
+
197
+ // ---------------------------------------------------------------------------------------------
198
+
199
+ /**
200
+ * Booting the module is itself the first assertion: `kernel.start()` creates `mod_inventory` and
201
+ * runs both migrations, so a broken one fails here rather than on somebody's instance during an
202
+ * upgrade. Two of them have already shipped broken — a `gist` exclusion constraint with no
203
+ * `btree_gist`, and two column-level primary keys — and each one stops the *host service* booting,
204
+ * not merely this module.
205
+ */
206
+ describe('the module boots', () => {
207
+ it('created its schema and every table it declares', async () => {
208
+ const { rows } = await kernel.database.pool.query<{ table_name: string }>(
209
+ `select table_name from information_schema.tables where table_schema = 'mod_inventory' order by 1`,
210
+ )
211
+ const names = rows.map((r) => r.table_name)
212
+ for (const t of TENANT_TABLES) expect(names, `mod_inventory.${t}`).toContain(t)
213
+ })
214
+
215
+ it('put row-level security on every tenant table', async () => {
216
+ const { rows } = await kernel.database.pool.query<{ tablename: string; rowsecurity: boolean }>(
217
+ `select tablename, rowsecurity from pg_tables where schemaname = 'mod_inventory'`,
218
+ )
219
+ const secured = new Map(rows.map((r) => [r.tablename, r.rowsecurity]))
220
+ // Checked against TENANT_TABLES rather than "every table in the schema": drizzle's own
221
+ // `__migrations` bookkeeping lives here too and is not tenant data. Asserting over the declared
222
+ // list is also what makes a new table added without a policy fail — the whole reason the list
223
+ // exists next to the schema.
224
+ for (const t of TENANT_TABLES) expect(secured.get(t), `mod_inventory.${t} has RLS`).toBe(true)
225
+ })
226
+
227
+ it('leaves no table carrying workspace_id out of TENANT_TABLES', async () => {
228
+ /**
229
+ * Guards the other direction, and does it against the **database** rather than a count.
230
+ *
231
+ * A table added to `schema.ts` but left out of `TENANT_TABLES` passes the test above by simply
232
+ * never being asked about — which is exactly how a table ships without a policy. Comparing what
233
+ * actually exists to what is declared cannot go stale, where a hardcoded number goes stale the
234
+ * first time anybody adds a table.
235
+ *
236
+ * "Carries `workspace_id`" is the rule, not "is in the schema", because that is the rule
237
+ * `schema.ts` states at the top of the file: a column named `workspace_id` is the definition of
238
+ * a tenant table, and a tenant table without a policy is simply readable by anything that
239
+ * reaches the database another way.
240
+ */
241
+ const { rows } = await kernel.database.pool.query<{ table_name: string }>(
242
+ `select c.relname as table_name
243
+ from pg_class c
244
+ join pg_namespace n on n.oid = c.relnamespace
245
+ join pg_attribute a
246
+ on a.attrelid = c.oid
247
+ and a.attname = 'workspace_id'
248
+ and a.attnum > 0
249
+ and not a.attisdropped
250
+ where n.nspname = 'mod_inventory'
251
+ and c.relkind in ('r', 'p')
252
+ and not c.relispartition`,
253
+ )
254
+ const declared = new Set<string>(TENANT_TABLES)
255
+ const undeclared = rows
256
+ .map((r) => r.table_name)
257
+ // drizzle's own bookkeeping is not tenant data and correctly has no policy.
258
+ .filter((t) => !t.startsWith('__'))
259
+ .filter((t) => !declared.has(t))
260
+ expect(undeclared, 'tables carrying workspace_id but missing from TENANT_TABLES').toEqual([])
261
+ })
262
+ })
263
+
264
+ /**
265
+ * The two constraints the schema leans on, checked as facts about the database rather than as
266
+ * intentions in TypeScript.
267
+ */
268
+ describe('the schema the migrations actually produced', () => {
269
+ it('gives counters one composite primary key, not two', async () => {
270
+ // The regression test for SQLSTATE 42P16. Two column-level `.primaryKey()` calls read like a
271
+ // compound key and are not one — Postgres refuses the table, and because a module's migration
272
+ // is the first thing the kernel runs the symptom is a host service that never binds its port.
273
+ // By the time the suite gets here the table exists, so what is left to prove is that the key is
274
+ // the pair rather than one column with the other silently dropped.
275
+ const { rows } = await kernel.database.pool.query<{ attname: string }>(
276
+ `select a.attname
277
+ from pg_constraint c
278
+ join unnest(c.conkey) with ordinality as k(attnum, ord) on true
279
+ join pg_attribute a on a.attrelid = c.conrelid and a.attnum = k.attnum
280
+ where c.conrelid = 'mod_inventory.counters'::regclass
281
+ and c.contype = 'p'
282
+ order by k.ord`,
283
+ )
284
+ expect(rows.map((r) => r.attname)).toEqual(['workspace_id', 'key'])
285
+ })
286
+
287
+ it('installed btree_gist, without which the custody constraint cannot exist', async () => {
288
+ // Core creates pg_trgm, pgcrypto, ltree and vector; btree_gist is not among them. A module
289
+ // reaching for `uuid with =` inside a gist exclusion constraint declares it itself, or its
290
+ // migration dies on a clean database with "data type uuid has no default operator class for
291
+ // access method gist" — invisible on any machine whose database already had the extension.
292
+ const { rows } = await kernel.database.pool.query<{ extname: string }>(
293
+ `select extname from pg_extension where extname = 'btree_gist'`,
294
+ )
295
+ expect(rows.map((r) => r.extname)).toEqual(['btree_gist'])
296
+ })
297
+
298
+ it('carries the custody exclusion constraint', async () => {
299
+ const { rows } = await kernel.database.pool.query<{ contype: string }>(
300
+ `select contype from pg_constraint
301
+ where conrelid = 'mod_inventory.custody_periods'::regclass
302
+ and conname = 'inventory_custody_no_overlap'`,
303
+ )
304
+ expect(rows[0]?.contype, "'x' means exclusion").toBe('x')
305
+ })
306
+
307
+ it('refuses a second open custody period for the same asset', async () => {
308
+ // The constraint's whole purpose, and the half a schema dump cannot show: two concurrent
309
+ // transfers must not both win. `effective_to` null means "still open", which tstzrange treats
310
+ // as unbounded, so an open period overlaps everything that would follow it.
311
+ const asset = await createAsset(WS_A, { name: 'Contested laptop' })
312
+ await run((tx) =>
313
+ tx.insert(custodyPeriods).values({ workspaceId: WS_A, assetId: asset.id, userId: ALICE }),
314
+ )
315
+ const name = await constraintViolated(() =>
316
+ run((tx) => tx.insert(custodyPeriods).values({ workspaceId: WS_A, assetId: asset.id, userId: BOB })),
317
+ )
318
+ expect(name).toBe('inventory_custody_no_overlap')
319
+ })
320
+
321
+ it('still allows an open period once the previous one is closed', async () => {
322
+ // The other half: a constraint that refused this would make a transfer impossible, and a test
323
+ // that only proves rejection cannot tell the two apart.
324
+ const asset = await createAsset(WS_A, { name: 'Handed on' })
325
+ await run((tx) =>
326
+ tx.insert(custodyPeriods).values({
327
+ workspaceId: WS_A,
328
+ assetId: asset.id,
329
+ userId: ALICE,
330
+ effectiveFrom: new Date('2026-01-01T00:00:00Z'),
331
+ effectiveTo: new Date('2026-06-01T00:00:00Z'),
332
+ }),
333
+ )
334
+ await run((tx) =>
335
+ tx.insert(custodyPeriods).values({
336
+ workspaceId: WS_A,
337
+ assetId: asset.id,
338
+ userId: BOB,
339
+ effectiveFrom: new Date('2026-06-01T00:00:00Z'),
340
+ }),
341
+ )
342
+ const rows = await run((tx) =>
343
+ tx.select().from(custodyPeriods).where(eq(custodyPeriods.assetId, asset.id)),
344
+ )
345
+ expect(rows).toHaveLength(2)
346
+ })
347
+ })
348
+
349
+ /**
350
+ * Row-level security, proven as a role that cannot bypass it.
351
+ *
352
+ * The development user is a superuser, and superusers bypass RLS entirely — so the same assertions
353
+ * run as `kern` would pass against a table with no policy at all. This is the only version of the
354
+ * test that proves anything.
355
+ */
356
+ describe('row-level security, as a role that cannot bypass it', () => {
357
+ let plain: pg.Client
358
+
359
+ beforeAll(async () => {
360
+ await createAsset(WS_A, { name: 'Visible to A' })
361
+ await createAsset(WS_B, { name: 'Visible to B' }, BOB)
362
+
363
+ const scratch = new pg.Client({ connectionString: databaseUrl })
364
+ await scratch.connect()
365
+ await scratch.query(`create role "${RLS_ROLE}" login password 'probe'`)
366
+ await scratch.query(`grant usage on schema mod_inventory to "${RLS_ROLE}"`)
367
+ await scratch.query(`grant select on all tables in schema mod_inventory to "${RLS_ROLE}"`)
368
+ await scratch.end()
369
+
370
+ const url = new URL(databaseUrl)
371
+ url.username = RLS_ROLE
372
+ url.password = 'probe'
373
+ plain = new pg.Client({ connectionString: url.toString() })
374
+ await plain.connect()
375
+ }, 60_000)
376
+
377
+ afterAll(async () => {
378
+ await plain?.end().catch(() => undefined)
379
+ })
380
+
381
+ const count = async (sqlText: string) => {
382
+ const { rows } = await plain.query<{ n: string }>(sqlText)
383
+ return Number(rows[0]?.n ?? -1)
384
+ }
385
+
386
+ it('shows nothing at all when no workspace is set', async () => {
387
+ await plain.query('reset app.workspace_id')
388
+ expect(await count('select count(*) as n from mod_inventory.assets')).toBe(0)
389
+ expect(await count('select count(*) as n from mod_inventory.asset_history')).toBe(0)
390
+ })
391
+
392
+ it('shows one workspace its own rows', async () => {
393
+ await plain.query(`set app.workspace_id = '${WS_A}'`)
394
+ expect(await count('select count(*) as n from mod_inventory.assets')).toBeGreaterThan(0)
395
+ })
396
+
397
+ it('shows one workspace nothing of another', async () => {
398
+ await plain.query(`set app.workspace_id = '${WS_B}'`)
399
+ expect(await count(`select count(*) as n from mod_inventory.assets where workspace_id = '${WS_A}'`)).toBe(
400
+ 0,
401
+ )
402
+ // And the reverse, so a policy that simply hides everything cannot pass this.
403
+ expect(await count(`select count(*) as n from mod_inventory.assets where workspace_id = '${WS_B}'`)).toBe(
404
+ 1,
405
+ )
406
+ })
407
+ })
408
+
409
+ /**
410
+ * The counter row's conflict lock, under the only condition that can break it.
411
+ *
412
+ * `insert … on conflict do update … returning` increments under the row lock the insert already
413
+ * takes, so two concurrent creates each read the value their own statement returned. Run one at a
414
+ * time this is indistinguishable from a read-then-write, which is the version that hands two people
415
+ * the same asset tag.
416
+ */
417
+ describe('asset codes under concurrency', () => {
418
+ it('gives twenty simultaneous creates twenty different codes', async () => {
419
+ const created = await Promise.all(
420
+ Array.from({ length: 20 }, (_, i) => createAsset(WS_CODES, { name: `Laptop ${i}` })),
421
+ )
422
+ const codes = created.map((a) => a.code)
423
+ expect(new Set(codes).size, 'every code is distinct').toBe(20)
424
+ for (const code of codes) expect(code).toMatch(/^INV-\d{4}$/)
425
+ // A fresh workspace, so the counter started at 1 and the run is exactly 1..20 — which also
426
+ // catches a lock that let two transactions read the same value and then skip a number.
427
+ expect([...codes].sort()).toEqual(
428
+ Array.from({ length: 20 }, (_, i) => `INV-${String(i + 1).padStart(4, '0')}`),
429
+ )
430
+ })
431
+ })
432
+
433
+ /**
434
+ * Keyset pagination, checked as the property that actually matters: paging through a list must
435
+ * yield every row exactly once. An off-by-one in the cursor comparison repeats the boundary row; a
436
+ * cursor on a non-unique column drops the rows that share its value. Both look like a working list
437
+ * on page one, which is why the page used to filter in the browser and nobody noticed.
438
+ */
439
+ describe('paging through a list', () => {
440
+ const TOTAL = 25
441
+ /** Deliberately repeated: `name` is not unique, which is why the cursor carries the id too. */
442
+ const NAMES = ['Chair', 'Desk', 'Laptop', 'Monitor', 'Phone']
443
+ let everyId: string[] = []
444
+
445
+ beforeAll(async () => {
446
+ const created: Asset[] = []
447
+ /**
448
+ * Serially **and a clear millisecond apart**, so `recent` (uuidv7 order) really is creation
449
+ * order and a repeated row is visible.
450
+ *
451
+ * The wait is not padding. `uuidv7()` carries the clock in its first six bytes — to the
452
+ * millisecond — and fills the remaining ten from `randomUUID()`, with no counter: two rows
453
+ * created inside one millisecond sort in *random* order relative to each other. Serial creates
454
+ * alone do not buy the ordering this block's assertions rest on, they only make it likely, and
455
+ * anything that makes `create` quicker (taking the module-settings read out of the
456
+ * transaction did) raises the collision rate until the suite fails intermittently on an
457
+ * ordering the module never promised.
458
+ */
459
+ for (let i = 0; i < TOTAL; i++) {
460
+ created.push(await createAsset(WS_PAGE, { name: NAMES[i % NAMES.length]! }))
461
+ await new Promise((resolve) => setTimeout(resolve, 2))
462
+ }
463
+ everyId = created.map((a) => a.id)
464
+ }, 120_000)
465
+
466
+ async function pageThrough(sort: 'recent' | 'name' | 'code') {
467
+ const seen: Asset[] = []
468
+ let cursor: string | undefined
469
+ let pages = 0
470
+ do {
471
+ const page = await listAssets(WS_PAGE, { sort, limit: 10, ...(cursor ? { cursor } : {}) })
472
+ seen.push(...page.items)
473
+ cursor = page.nextCursor ?? undefined
474
+ pages += 1
475
+ if (pages > 10) throw new Error('paging did not terminate')
476
+ } while (cursor)
477
+ return { seen, pages }
478
+ }
479
+
480
+ it('returns every row exactly once, newest first', async () => {
481
+ const { seen, pages } = await pageThrough('recent')
482
+ expect(pages, '25 rows at 10 a page').toBe(3)
483
+ expect(seen).toHaveLength(TOTAL)
484
+ expect(new Set(seen.map((a) => a.id)).size, 'no row appears twice').toBe(TOTAL)
485
+ expect([...seen.map((a) => a.id)].sort()).toEqual([...everyId].sort())
486
+ expect(seen.map((a) => a.id)).toEqual([...everyId].reverse())
487
+ })
488
+
489
+ it('returns every row exactly once when the sort column is not unique', async () => {
490
+ const { seen, pages } = await pageThrough('name')
491
+ expect(pages).toBe(3)
492
+ expect(seen).toHaveLength(TOTAL)
493
+ expect(new Set(seen.map((a) => a.id)).size, 'no row appears twice').toBe(TOTAL)
494
+ expect([...seen.map((a) => a.id)].sort()).toEqual([...everyId].sort())
495
+ // Five rows share each name, so a cursor that carried only the name would skip four of them at
496
+ // every page boundary. The order must still be globally non-decreasing.
497
+ const names = seen.map((a) => a.name)
498
+ expect(names).toEqual([...names].sort())
499
+ })
500
+
501
+ it('refuses a page marker it did not issue rather than silently returning page one', async () => {
502
+ // The failure this rules out is the quiet one: a cursor that cannot be decoded, swallowed, and
503
+ // an infinite list that serves page one for ever.
504
+ expect(
505
+ await refusedWith(() => listAssets(WS_PAGE, { sort: 'name', limit: 10, cursor: 'nonsense' })),
506
+ ).toBe('BAD_REQUEST')
507
+ const wrongShape = Buffer.from(JSON.stringify({ nope: 1 }), 'utf8').toString('base64url')
508
+ expect(
509
+ await refusedWith(() => listAssets(WS_PAGE, { sort: 'name', limit: 10, cursor: wrongShape })),
510
+ ).toBe('BAD_REQUEST')
511
+ })
512
+
513
+ it('refuses a page marker whose row id is not a uuid, rather than handing it to Postgres', async () => {
514
+ // The cursor's id is interpolated into a `::uuid` cast. `decode` used to check only that it was
515
+ // a *string*, so this exact value reached the database as a 22P02 nobody caught: an unhandled
516
+ // 500 and an error-level log line per request, from anyone who could type in the address bar.
517
+ const notAUuid = Buffer.from(JSON.stringify({ i: 'not-a-uuid', s: 'name' }), 'utf8').toString('base64url')
518
+ expect(await refusedWith(() => listAssets(WS_PAGE, { sort: 'name', limit: 10, cursor: notAUuid }))).toBe(
519
+ 'BAD_REQUEST',
520
+ )
521
+ })
522
+
523
+ it('refuses a page marker issued under a different sort', async () => {
524
+ // Replayed under another ordering, a bookmark cannot be read at all: page two came back equal
525
+ // to page one, so "Load more" served the same ten rows for ever. A cursor names its sort now,
526
+ // and a list that is not that sort refuses it.
527
+ const first = await listAssets(WS_PAGE, { sort: 'recent', limit: 10 })
528
+ expect(first.nextCursor, 'a 25-row list at 10 a page has a second page').not.toBeNull()
529
+ expect(
530
+ await refusedWith(() => listAssets(WS_PAGE, { sort: 'code', limit: 10, cursor: first.nextCursor! })),
531
+ ).toBe('BAD_REQUEST')
532
+ })
533
+
534
+ it('issues a cursor the contract can carry, however long the name is', async () => {
535
+ // `Cursor` is `max(512)`. The bookmark used to carry the sort key itself, so a 200-character
536
+ // Persian name — the longest `name` the contract allows — encoded to 602 characters and broke
537
+ // "Load more" with a validation error, in the locales least likely to be tested. The bookmark
538
+ // carries the row id and the sort now, so its size does not depend on the data at all.
539
+ const ws = workspace()
540
+ const long = 'صندلی اداری چرخ‌دار با پشتی مشبک و تنظیم ارتفاع '.repeat(5).slice(0, 200)
541
+ expect(long, 'the longest name the contract allows').toHaveLength(200)
542
+ await createAsset(ws, { name: long })
543
+ await createAsset(ws, { name: `${long.slice(0, 199)}ی` })
544
+
545
+ const page = await listAssets(ws, { sort: 'name', limit: 1 })
546
+ expect(page.nextCursor).not.toBeNull()
547
+ expect(
548
+ Cursor.safeParse(page.nextCursor).success,
549
+ 'the server must not issue a cursor its own contract will reject on the way back',
550
+ ).toBe(true)
551
+
552
+ // And it still pages: a cursor that validates but does not work is the same bug wearing a hat.
553
+ const second = await listAssets(ws, { sort: 'name', limit: 1, cursor: page.nextCursor! })
554
+ expect(second.items.map((a) => a.id)).not.toEqual(page.items.map((a) => a.id))
555
+ })
556
+
557
+ it('ends the list when the row a cursor points at has since been deleted', async () => {
558
+ /**
559
+ * Deliberately an empty page rather than a 400.
560
+ *
561
+ * Somebody archiving or deleting a row while another person reads the list is an ordinary race,
562
+ * not a malformed request — an error on "Load more" would blame the reader for it. The row
563
+ * comparison is against a subquery, so a missing anchor makes it NULL and the page comes back
564
+ * empty with no cursor: the list ends where it was, and the next refresh is correct.
565
+ */
566
+ const ws = workspace()
567
+ for (let i = 0; i < 3; i++) await createAsset(ws, { name: `Vanishing ${i}` })
568
+
569
+ const first = await listAssets(ws, { sort: 'recent', limit: 1 })
570
+ expect(first.nextCursor).not.toBeNull()
571
+ const anchor = first.items[0]!.id
572
+ await kernel.database.withWorkspace(ws, (tx) =>
573
+ tx.delete(assets).where(and(eq(assets.workspaceId, ws), eq(assets.id, anchor))),
574
+ )
575
+
576
+ const second = await listAssets(ws, { sort: 'recent', limit: 1, cursor: first.nextCursor! })
577
+ expect(second.items, 'the page the deleted row bookmarked').toEqual([])
578
+ expect(second.nextCursor, 'and nothing to keep asking for').toBeNull()
579
+ })
580
+ })
581
+
582
+ /**
583
+ * A search box takes text, not a pattern.
584
+ *
585
+ * `%`, `_` and `\` are `ilike` syntax, and interpolated straight into the pattern they turned a
586
+ * search into a wildcard the person did not ask for: `50%` matched every row in the workspace and
587
+ * `_hair` matched "Chair". Both read as a broken search rather than as a clever one.
588
+ */
589
+ describe('searching for text that looks like a pattern', () => {
590
+ const WS_LIKE = workspace()
591
+ let chair: Asset
592
+ let throw_: Asset
593
+
594
+ beforeAll(async () => {
595
+ chair = await createAsset(WS_LIKE, { name: 'Chair' })
596
+ throw_ = await createAsset(WS_LIKE, { name: '50% cotton throw' })
597
+ }, 60_000)
598
+
599
+ it('treats _ as a character, not as "any character"', async () => {
600
+ const page = await listAssets(WS_LIKE, { limit: 50, q: '_hair' })
601
+ expect(
602
+ page.items.map((a) => a.id),
603
+ 'unescaped, this matches Chair',
604
+ ).toEqual([])
605
+ })
606
+
607
+ it('treats % as a character, not as "anything at all"', async () => {
608
+ const wild = await listAssets(WS_LIKE, { limit: 50, q: '%' })
609
+ expect(
610
+ wild.items.map((a) => a.id),
611
+ 'unescaped, this matches every row',
612
+ ).toEqual([throw_.id])
613
+
614
+ const literal = await listAssets(WS_LIKE, { limit: 50, q: '50%' })
615
+ expect(literal.items.map((a) => a.id)).toEqual([throw_.id])
616
+ })
617
+
618
+ it('still finds the plain text beside them', async () => {
619
+ // The other half: escaping that stripped the characters would break an honest search too.
620
+ const page = await listAssets(WS_LIKE, { limit: 50, q: 'chair' })
621
+ expect(page.items.map((a) => a.id)).toEqual([chair.id])
622
+ })
623
+ })
624
+
625
+ /**
626
+ * Filters, applied by the server.
627
+ *
628
+ * Every one of these used to be — or could plausibly be — a filter the browser applies to the page
629
+ * it was handed, which is wrong the moment there is more than one page: the first twenty rows come
630
+ * back, half are dropped, and the list looks short rather than paged.
631
+ */
632
+ describe('narrowing a list', () => {
633
+ let thinkpad: Asset
634
+ let archived: Asset
635
+ let broken: Asset
636
+
637
+ beforeAll(async () => {
638
+ thinkpad = await createAsset(WS_FILTER, { name: 'Thinkpad X1', serialNumber: 'SN-QQQ-1' })
639
+ archived = await createAsset(WS_FILTER, { name: 'Retired projector' })
640
+ broken = await createAsset(WS_FILTER, { name: 'Cracked monitor' })
641
+ await call(
642
+ inv.assets.archive,
643
+ { workspaceId: WS_FILTER, assetId: archived.id, archived: true },
644
+ { context: asUser(ALICE, WS_FILTER) },
645
+ )
646
+ // `status` and `custodian_user_id` follow custody and repairs, neither of which has procedures
647
+ // yet, so they are set the way those procedures will set them — directly, in the module's own
648
+ // transaction. The filter is what is under test, not the writer.
649
+ await kernel.database.withWorkspace(WS_FILTER, (tx) =>
650
+ tx
651
+ .update(assets)
652
+ .set({ status: 'under_repair', custodianUserId: BOB })
653
+ .where(and(eq(assets.workspaceId, WS_FILTER), eq(assets.id, broken.id))),
654
+ )
655
+ }, 60_000)
656
+
657
+ const ids = (page: { items: Asset[] }) => page.items.map((a) => a.id).sort()
658
+
659
+ it('leaves archived rows out unless they are asked for', async () => {
660
+ const live = await listAssets(WS_FILTER, { limit: 50 })
661
+ expect(live.items.map((a) => a.id)).not.toContain(archived.id)
662
+ expect(ids(live)).toEqual([thinkpad.id, broken.id].sort())
663
+
664
+ const all = await listAssets(WS_FILTER, { limit: 50, archived: true })
665
+ expect(all.items.map((a) => a.id)).toContain(archived.id)
666
+ expect(ids(all)).toEqual([thinkpad.id, archived.id, broken.id].sort())
667
+ })
668
+
669
+ it('narrows by status', async () => {
670
+ const page = await listAssets(WS_FILTER, { limit: 50, status: 'under_repair' })
671
+ expect(ids(page)).toEqual([broken.id])
672
+ })
673
+
674
+ it('narrows by custodian', async () => {
675
+ const page = await listAssets(WS_FILTER, { limit: 50, custodianUserId: BOB })
676
+ expect(ids(page)).toEqual([broken.id])
677
+ })
678
+
679
+ it('matches a query against the name, the code and the serial', async () => {
680
+ // Three separate reads, because a search that covers only the name is the one somebody notices
681
+ // while standing in front of the machine reading its sticker out loud.
682
+ const byName = await listAssets(WS_FILTER, { limit: 50, q: 'thinkpad' })
683
+ expect(ids(byName)).toEqual([thinkpad.id])
684
+
685
+ const byCode = await listAssets(WS_FILTER, { limit: 50, q: thinkpad.code })
686
+ expect(ids(byCode)).toEqual([thinkpad.id])
687
+
688
+ const bySerial = await listAssets(WS_FILTER, { limit: 50, q: 'qqq' })
689
+ expect(ids(bySerial)).toEqual([thinkpad.id])
690
+ })
691
+ })
692
+
693
+ /**
694
+ * Tenant isolation through the API, not through the policy.
695
+ *
696
+ * The database is reached as a superuser here, exactly as `core` reaches it in production, so RLS
697
+ * is bypassed and the `workspace_id` predicate in the service is the only thing standing between
698
+ * two customers. That is the predicate this asserts.
699
+ */
700
+ describe('two workspaces', () => {
701
+ it('does not let one read the other’s asset', async () => {
702
+ const mine = await createAsset(WS_A, { name: 'Only ours' })
703
+ expect(
704
+ await refusedWith(() =>
705
+ call(inv.assets.get, { workspaceId: WS_B, assetId: mine.id }, { context: asUser(BOB, WS_B) }),
706
+ ),
707
+ 'a leak and a 403 are both wrong here: from B, the row does not exist',
708
+ ).toBe('NOT_FOUND')
709
+ })
710
+ })
711
+
712
+ /**
713
+ * The history trail — the module's own authoritative record of what happened, written inside the
714
+ * caller's transaction. Core activity is a best-effort mirror of it and may be missing; this may
715
+ * not be.
716
+ */
717
+ describe('the history trail', () => {
718
+ const historyOf = (workspaceId: string, assetId: string) =>
719
+ kernel.database.withWorkspace(workspaceId, (tx) =>
720
+ tx
721
+ .select()
722
+ .from(assetHistory)
723
+ .where(and(eq(assetHistory.workspaceId, workspaceId), eq(assetHistory.assetId, assetId)))
724
+ .orderBy(asc(assetHistory.id)),
725
+ )
726
+
727
+ it('records a creation, then an update carrying only the field that moved', async () => {
728
+ // No description on purpose. With one set, this diff also names `description` on a patch that
729
+ // never mentioned it — see "patching one field" below, which is the same defect seen from the
730
+ // other side.
731
+ const asset = await createAsset(WS_A, { name: 'Timeline' })
732
+ await call(
733
+ inv.assets.update,
734
+ { workspaceId: WS_A, assetId: asset.id, name: 'Timeline mk2' },
735
+ { context: asUser(ALICE) },
736
+ )
737
+
738
+ const rows = await historyOf(WS_A, asset.id)
739
+ expect(rows.map((r) => r.action)).toEqual(['created', 'updated'])
740
+ expect(rows[1]?.changes).toEqual([{ field: 'name', from: 'Timeline', to: 'Timeline mk2' }])
741
+ expect(rows[1]?.actorId).toBe(ALICE)
742
+ })
743
+
744
+ it('writes nothing for an update that changes nothing', async () => {
745
+ // A timeline that logs a no-op is a timeline nobody reads, and "somebody touched this" is not
746
+ // what the page promises.
747
+ const asset = await createAsset(WS_A, { name: 'Unmoved' })
748
+ await call(
749
+ inv.assets.update,
750
+ { workspaceId: WS_A, assetId: asset.id, name: 'Unmoved' },
751
+ { context: asUser(ALICE) },
752
+ )
753
+ const rows = await historyOf(WS_A, asset.id)
754
+ expect(rows.map((r) => r.action)).toEqual(['created'])
755
+ })
756
+
757
+ it('records archiving and restoring under their own actions', async () => {
758
+ const asset = await createAsset(WS_A, { name: 'Round trip' })
759
+ const ctx = { context: asUser(ALICE) }
760
+ await call(inv.assets.archive, { workspaceId: WS_A, assetId: asset.id, archived: true }, ctx)
761
+ const back = await call(
762
+ inv.assets.archive,
763
+ { workspaceId: WS_A, assetId: asset.id, archived: false },
764
+ ctx,
765
+ )
766
+ expect(back.archivedAt).toBeNull()
767
+ const rows = await historyOf(WS_A, asset.id)
768
+ expect(rows.map((r) => r.action)).toEqual(['created', 'retired', 'restored'])
769
+ })
770
+ })
771
+
772
+ /**
773
+ * `undefined` means "not mentioned"; `null` means "clear it".
774
+ *
775
+ * Collapsing the two is how an edit of one field quietly wipes the others, and the wipe is silent —
776
+ * the request succeeds, the screen redraws, and the location the person spent a morning filling in
777
+ * is gone.
778
+ */
779
+ describe('patching one field', () => {
780
+ it('leaves a field the patch never mentioned alone', async () => {
781
+ const asset = await createAsset(WS_A, { name: 'Desk lamp', location: 'Desk 4' })
782
+ const patched = await call(
783
+ inv.assets.update,
784
+ { workspaceId: WS_A, assetId: asset.id, name: 'Desk lamp (tall)' },
785
+ { context: asUser(ALICE) },
786
+ )
787
+ expect(patched.name).toBe('Desk lamp (tall)')
788
+ expect(patched.location, 'a patch that never named location must not clear it').toBe('Desk 4')
789
+ })
790
+
791
+ it('clears a field the patch explicitly passes as null', async () => {
792
+ const asset = await createAsset(WS_A, { name: 'Loose cable', location: 'Drawer' })
793
+ const patched = await call(
794
+ inv.assets.update,
795
+ { workspaceId: WS_A, assetId: asset.id, location: null },
796
+ { context: asUser(ALICE) },
797
+ )
798
+ expect(patched.location).toBeNull()
799
+ })
800
+
801
+ it('leaves a description the patch never mentioned alone', async () => {
802
+ /**
803
+ * The same rule as the two above, and the one field that breaks it.
804
+ *
805
+ * `AssetInput.description` carries `.default('')`, and `.partial()` does not remove a default —
806
+ * it wraps the field in `optional`, and zod still substitutes the default for a missing key. So
807
+ * a PATCH of `{ name }` arrives at the handler as `{ name, description: '' }`, `AssetService`
808
+ * correctly reads that as "clear it", and the description is destroyed by a rename. The
809
+ * `undefined`-versus-`null` care in the service is defeated one layer above it, in the contract.
810
+ */
811
+ const asset = await createAsset(WS_A, { name: 'Server rack', description: 'Second floor, bolted' })
812
+ const patched = await call(
813
+ inv.assets.update,
814
+ { workspaceId: WS_A, assetId: asset.id, name: 'Server rack B' },
815
+ { context: asUser(ALICE) },
816
+ )
817
+ expect(patched.description, 'a rename must not wipe the description').toBe('Second floor, bolted')
818
+ })
819
+ })