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