@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
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { t } from '../i18n.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The two versions of a long-text field, behind a disclosure.
|
|
6
|
+
*
|
|
7
|
+
* A description is up to eight thousand characters, and the timeline rendered a change to one as
|
|
8
|
+
* "Description changed from … to …" with both versions inline: sixteen kilobytes of somebody's
|
|
9
|
+
* prose in one row of a 440px panel, with every entry below it pushed off the screen. The line
|
|
10
|
+
* above this now says only *that* it changed, and the text lives here.
|
|
11
|
+
*
|
|
12
|
+
* **A component rather than a bare `<details>` in the timeline, for one reason:** `{#if open}`.
|
|
13
|
+
* `<details>` keeps its content in the document whether or not it is open, so fifty entries would
|
|
14
|
+
* still be four hundred kilobytes of text nodes the browser has to build and lay out. Binding
|
|
15
|
+
* `open` and gating on it means the prose exists only while somebody is reading it, and the
|
|
16
|
+
* disclosure is still a real `<details>` — keyboard-reachable, announced as a disclosure, and
|
|
17
|
+
* open by default in a printed page. Nothing is hidden: it is one keystroke away and shown whole,
|
|
18
|
+
* because truncating what somebody deliberately opened would be the same defect one layer down.
|
|
19
|
+
*
|
|
20
|
+
* `before` is null when there was nothing there — a description filled in for the first time has
|
|
21
|
+
* no previous version, and an empty "Before" block would invent one.
|
|
22
|
+
*/
|
|
23
|
+
interface Props {
|
|
24
|
+
before: string | null
|
|
25
|
+
after: string
|
|
26
|
+
}
|
|
27
|
+
const { before, after }: Props = $props()
|
|
28
|
+
|
|
29
|
+
let open = $state(false)
|
|
30
|
+
</script>
|
|
31
|
+
|
|
32
|
+
<details class="disclosure" bind:open>
|
|
33
|
+
<summary>{t('history_show_text')}</summary>
|
|
34
|
+
{#if open}
|
|
35
|
+
<div class="body">
|
|
36
|
+
{#if before !== null}
|
|
37
|
+
<p class="label">{t('history_text_before')}</p>
|
|
38
|
+
<!-- `pre-wrap`, so the paragraphs somebody typed are the paragraphs they read back. -->
|
|
39
|
+
<blockquote class="text was">{before}</blockquote>
|
|
40
|
+
{/if}
|
|
41
|
+
<p class="label">{t('history_text_after')}</p>
|
|
42
|
+
<blockquote class="text">{after}</blockquote>
|
|
43
|
+
</div>
|
|
44
|
+
{/if}
|
|
45
|
+
</details>
|
|
46
|
+
|
|
47
|
+
<style>
|
|
48
|
+
.disclosure {
|
|
49
|
+
margin-top: 3px;
|
|
50
|
+
font-size: 12px;
|
|
51
|
+
}
|
|
52
|
+
summary {
|
|
53
|
+
/* `list-style` on the summary is what removes the native marker in Firefox; the ::-webkit rule
|
|
54
|
+
below is what removes it in Safari and Chrome. Neither alone does both. */
|
|
55
|
+
list-style: none;
|
|
56
|
+
display: inline-flex;
|
|
57
|
+
align-items: center;
|
|
58
|
+
gap: 4px;
|
|
59
|
+
width: fit-content;
|
|
60
|
+
cursor: pointer;
|
|
61
|
+
color: var(--kern-ink-600);
|
|
62
|
+
/* WCAG 2.5.8 wants 24px and a line of 12px text is about 16. The padding grows the hit area
|
|
63
|
+
and the equal negative margin gives the space back, so nothing moves. */
|
|
64
|
+
padding-block: 4px;
|
|
65
|
+
margin-block: -4px;
|
|
66
|
+
border-radius: var(--kern-r-sm);
|
|
67
|
+
}
|
|
68
|
+
summary::-webkit-details-marker {
|
|
69
|
+
display: none;
|
|
70
|
+
}
|
|
71
|
+
summary:hover {
|
|
72
|
+
color: var(--kern-ink-900);
|
|
73
|
+
}
|
|
74
|
+
/* The disclosure triangle, drawn rather than borrowed, so it turns with the reading direction:
|
|
75
|
+
`▸` in an Arabic panel points the wrong way, and a rotated glyph does not. */
|
|
76
|
+
summary::before {
|
|
77
|
+
content: '';
|
|
78
|
+
width: 0;
|
|
79
|
+
height: 0;
|
|
80
|
+
border-block: 3.5px solid transparent;
|
|
81
|
+
border-inline-start: 5px solid currentColor;
|
|
82
|
+
transition: transform 120ms ease;
|
|
83
|
+
}
|
|
84
|
+
.disclosure[open] summary::before {
|
|
85
|
+
/* Logical would be ideal and there is no logical rotation; the sign is flipped in RTL below. */
|
|
86
|
+
transform: rotate(90deg);
|
|
87
|
+
}
|
|
88
|
+
:global([dir='rtl']) .disclosure[open] summary::before {
|
|
89
|
+
transform: rotate(-90deg);
|
|
90
|
+
}
|
|
91
|
+
.body {
|
|
92
|
+
display: flex;
|
|
93
|
+
flex-direction: column;
|
|
94
|
+
gap: 2px;
|
|
95
|
+
margin-top: 6px;
|
|
96
|
+
}
|
|
97
|
+
.label {
|
|
98
|
+
margin: 4px 0 0;
|
|
99
|
+
font-size: 11px;
|
|
100
|
+
font-weight: 600;
|
|
101
|
+
letter-spacing: 0.03em;
|
|
102
|
+
text-transform: uppercase;
|
|
103
|
+
/* Muted with a colour, never opacity: a faded label is unreadable whatever its token says. */
|
|
104
|
+
color: var(--kern-ink-500);
|
|
105
|
+
}
|
|
106
|
+
.text {
|
|
107
|
+
margin: 0;
|
|
108
|
+
padding-inline-start: 8px;
|
|
109
|
+
border-inline-start: 2px solid var(--kern-border-hairline);
|
|
110
|
+
font-size: 12.5px;
|
|
111
|
+
line-height: 1.55;
|
|
112
|
+
color: var(--kern-ink-700);
|
|
113
|
+
white-space: pre-wrap;
|
|
114
|
+
overflow-wrap: anywhere;
|
|
115
|
+
/* A description somebody typed decides its own direction: `plaintext` takes it from the
|
|
116
|
+
text's own first strong character, so an English paragraph inside a Persian panel reads
|
|
117
|
+
left to right and keeps its own punctuation. `isolate` would not — on a block it leaves
|
|
118
|
+
the paragraph direction inherited. */
|
|
119
|
+
unicode-bidi: plaintext;
|
|
120
|
+
}
|
|
121
|
+
.was {
|
|
122
|
+
color: var(--kern-ink-500);
|
|
123
|
+
}
|
|
124
|
+
</style>
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The slice of core's API this module reaches for, named by shape rather than imported.
|
|
3
|
+
*
|
|
4
|
+
* A module talks to another module through `kernel.call()` on the server; on the client the shell
|
|
5
|
+
* hands over its own configured core client, and typing the seam structurally keeps the dependency
|
|
6
|
+
* pointing one way — inventory does not import core's router type, and core does not know inventory
|
|
7
|
+
* exists.
|
|
8
|
+
*
|
|
9
|
+
* Keep it to what is actually called. A wide type here is a promise about core's surface that this
|
|
10
|
+
* module has no standing to make: `Member` in core carries a role, role ids, group ids, a status
|
|
11
|
+
* and an invitation trail, and naming any of that here would make this module's screens break when
|
|
12
|
+
* a field it never reads changes shape. Four procedures are all of it, and each is gated in
|
|
13
|
+
* `core/src/modules/core/router.ts` — the two settings calls on `core.modules.manage`, the member
|
|
14
|
+
* list on `core.members.view`, the download URL on the file's own workspace membership.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A workspace member, as thinly as a picker and an avatar need one.
|
|
19
|
+
*
|
|
20
|
+
* `name` is nullable in core — somebody invited and not yet signed in has an email and nothing
|
|
21
|
+
* else — so every screen here falls back to the email rather than rendering an empty label.
|
|
22
|
+
*/
|
|
23
|
+
export interface CoreMember {
|
|
24
|
+
userId: string
|
|
25
|
+
user: {
|
|
26
|
+
id: string
|
|
27
|
+
name: string | null
|
|
28
|
+
email: string
|
|
29
|
+
avatarUrl?: string | null
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface CoreApi {
|
|
34
|
+
workspaces: {
|
|
35
|
+
members: {
|
|
36
|
+
/** Paged in core; this module asks for one large page and keeps the people, not the envelope. */
|
|
37
|
+
list(input: { workspaceId: string; limit?: number }): Promise<{ items: CoreMember[] }>
|
|
38
|
+
}
|
|
39
|
+
modules: {
|
|
40
|
+
list(input: { workspaceId: string }): Promise<
|
|
41
|
+
Array<{
|
|
42
|
+
manifest: { id: string }
|
|
43
|
+
state: {
|
|
44
|
+
enabled: boolean
|
|
45
|
+
settings?: Record<string, unknown>
|
|
46
|
+
}
|
|
47
|
+
}>
|
|
48
|
+
>
|
|
49
|
+
updateSettings(input: {
|
|
50
|
+
workspaceId: string
|
|
51
|
+
moduleId: string
|
|
52
|
+
settings: Record<string, unknown>
|
|
53
|
+
}): Promise<unknown>
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Reading a file back.
|
|
58
|
+
*
|
|
59
|
+
* This module records that an asset has a file and never touches a byte — the bytes are core's,
|
|
60
|
+
* the URL is core's to sign, and it is signed for the person asking rather than for this module.
|
|
61
|
+
* `thumbnail: true` is what the asset photo asks for; a full-size photo in a 440px panel is a
|
|
62
|
+
* megabyte nobody looks at.
|
|
63
|
+
*/
|
|
64
|
+
files: {
|
|
65
|
+
downloadUrl(input: {
|
|
66
|
+
id: string
|
|
67
|
+
disposition?: 'inline' | 'attachment'
|
|
68
|
+
thumbnail?: boolean
|
|
69
|
+
}): Promise<{ url: string }>
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { custodyActions } from './custody.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* What the panel is allowed to offer.
|
|
6
|
+
*
|
|
7
|
+
* A decision rather than rendering, and wrong in a way nothing else would catch: a button that
|
|
8
|
+
* always produces a conflict is a door that will not open. What a stored uuid reads as is the
|
|
9
|
+
* neighbouring question and lives in `members.test.ts`, beside the file that answers it.
|
|
10
|
+
*/
|
|
11
|
+
describe('custodyActions', () => {
|
|
12
|
+
it('offers handing over when nobody has it, and handing on or back when somebody does', () => {
|
|
13
|
+
expect(custodyActions({ held: false, archived: false, may: true })).toEqual(['assign'])
|
|
14
|
+
expect(custodyActions({ held: true, archived: false, may: true })).toEqual(['transfer', 'return'])
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('offers nothing without the permission — hidden, not disabled', () => {
|
|
18
|
+
expect(custodyActions({ held: false, archived: false, may: false })).toEqual([])
|
|
19
|
+
expect(custodyActions({ held: true, archived: false, may: false })).toEqual([])
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('offers nothing on an archived item, which the server refuses anyway', () => {
|
|
23
|
+
expect(custodyActions({ held: false, archived: true, may: true })).toEqual([])
|
|
24
|
+
expect(custodyActions({ held: true, archived: true, may: true })).toEqual([])
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('never offers assigning something that is already held', () => {
|
|
28
|
+
// The one combination that would 409 every single time it was pressed.
|
|
29
|
+
expect(custodyActions({ held: true, archived: false, may: true })).not.toContain('assign')
|
|
30
|
+
})
|
|
31
|
+
})
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which handover an asset can actually take right now.
|
|
3
|
+
*
|
|
4
|
+
* Three verbs and not one, because the server has three procedures and refuses the wrong one with
|
|
5
|
+
* its own sentence: assigning something somebody already holds is a mistake worth naming, and
|
|
6
|
+
* handing on something nobody holds is a different mistake. The interface's job is not to offer
|
|
7
|
+
* either — a button that always produces a conflict is a door that will not open.
|
|
8
|
+
*
|
|
9
|
+
* A `.ts` file rather than a `const` inside the panel for the reason `timeline.ts` and `price.ts`
|
|
10
|
+
* are: this is a fact with a right answer, and a `.svelte` file cannot be unit-tested.
|
|
11
|
+
*/
|
|
12
|
+
export type CustodyAction = 'assign' | 'transfer' | 'return'
|
|
13
|
+
|
|
14
|
+
export interface CustodyState {
|
|
15
|
+
/** Somebody is holding it — `assets.custodian_user_id` is set. */
|
|
16
|
+
held: boolean
|
|
17
|
+
/** The workspace has taken it out of the register; the server refuses every handover. */
|
|
18
|
+
archived: boolean
|
|
19
|
+
/** This person holds `inventory.custody.manage`. */
|
|
20
|
+
may: boolean
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* What to offer, in the order the buttons appear.
|
|
25
|
+
*
|
|
26
|
+
* Empty for somebody without the permission — hide what a person may never do — and empty for an
|
|
27
|
+
* archived item, where the panel says *why* instead. An archived item deliberately offers nothing
|
|
28
|
+
* rather than a disabled row of buttons: the reason is one sentence, and one sentence beats three
|
|
29
|
+
* controls that cannot be pressed.
|
|
30
|
+
*/
|
|
31
|
+
export function custodyActions(state: CustodyState): CustodyAction[] {
|
|
32
|
+
if (!state.may || state.archived) return []
|
|
33
|
+
return state.held ? ['transfer', 'return'] : ['assign']
|
|
34
|
+
}
|
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
import { readdirSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { dirname, join } from 'node:path'
|
|
3
|
+
import { fileURLToPath } from 'node:url'
|
|
4
|
+
import { describe, expect, it } from 'vitest'
|
|
5
|
+
import { MAX_LIVE_CATEGORIES } from '../contract/models.js'
|
|
6
|
+
import {
|
|
7
|
+
codeOf,
|
|
8
|
+
errorLine,
|
|
9
|
+
errorMessage,
|
|
10
|
+
isInputValidation,
|
|
11
|
+
messageOf,
|
|
12
|
+
reasonKeys,
|
|
13
|
+
reasonOf,
|
|
14
|
+
} from './errors.js'
|
|
15
|
+
import { en } from './messages.js'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Every actionable failure a person can hit, in a language they chose.
|
|
19
|
+
*
|
|
20
|
+
* Eight call sites read `toast.error(error.message || t('common.error'))`, and `error.message` is a
|
|
21
|
+
* sentence the *server* wrote in English. So a Persian reader losing a race on a handover was shown
|
|
22
|
+
* "Somebody changed who is holding this a moment before you did. Reload to see where it is now." —
|
|
23
|
+
* the single most useful sentence this module has, in the wrong language, at the exact moment they
|
|
24
|
+
* needed to read it.
|
|
25
|
+
*
|
|
26
|
+
* Three things are checked here, and the third is the one that lasts:
|
|
27
|
+
*
|
|
28
|
+
* 1. the reason token is found wherever a transport puts it, and the code when there is none;
|
|
29
|
+
* 2. every key this file can name exists in the catalogue, so none renders as its own key;
|
|
30
|
+
* 3. **every reason the server actually throws has a sentence here** — read out of
|
|
31
|
+
* `src/server`, so a refusal added next month fails in this file rather than reaching
|
|
32
|
+
* somebody's screen in English.
|
|
33
|
+
*/
|
|
34
|
+
const conflict = (reason: string, message = 'English prose the server wrote.') => ({
|
|
35
|
+
code: 'CONFLICT',
|
|
36
|
+
message,
|
|
37
|
+
data: { reason },
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
describe('reading what the server sent', () => {
|
|
41
|
+
it('finds the reason where `kernErrorToORPC` puts it', () => {
|
|
42
|
+
// The reason is folded into `data` rather than sent beside it, because `data` is what oRPC
|
|
43
|
+
// already carries to the client.
|
|
44
|
+
expect(reasonOf(conflict('inventory.custody.conflict'))).toBe('inventory.custody.conflict')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('finds it on the error itself too, which is where the in-memory mock carries it', () => {
|
|
48
|
+
expect(reasonOf({ code: 'CONFLICT', reason: 'inventory.custody.not_held' })).toBe(
|
|
49
|
+
'inventory.custody.not_held',
|
|
50
|
+
)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('has no reason for a plain failure, rather than inventing one', () => {
|
|
54
|
+
expect(reasonOf(new Error('network'))).toBe(null)
|
|
55
|
+
expect(reasonOf(null)).toBe(null)
|
|
56
|
+
expect(reasonOf({ data: { reason: 42 } })).toBe(null)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('reads the code, and keeps the server sentence for the fallback line', () => {
|
|
60
|
+
expect(codeOf({ code: 'NOT_FOUND' })).toBe('NOT_FOUND')
|
|
61
|
+
expect(codeOf(new Error('boom'))).toBe(null)
|
|
62
|
+
expect(messageOf(new Error(' boom '))).toBe('boom')
|
|
63
|
+
expect(messageOf({ message: ' ' })).toBe(null)
|
|
64
|
+
})
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
describe('which sentence a refusal earns', () => {
|
|
68
|
+
it('translates the conflict on a handover, which is the message that mattered most', () => {
|
|
69
|
+
expect(errorLine(conflict('inventory.custody.conflict'))).toEqual({
|
|
70
|
+
key: 'error_custody_conflict',
|
|
71
|
+
detail: null,
|
|
72
|
+
})
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('drops the server prose once it has named the failure itself', () => {
|
|
76
|
+
// Keeping it would print an English sentence under the Persian one, undoing the translation.
|
|
77
|
+
expect(errorLine(conflict('inventory.category.name_taken')).detail).toBe(null)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* A **disabled capability answers 404, not 403** — the module contract says so, and the shell has
|
|
82
|
+
* already hidden the navigation for it. So a workspace that switches `repairs` off mid-session
|
|
83
|
+
* lands here, and "it is no longer there, reload" is the right sentence for it.
|
|
84
|
+
*/
|
|
85
|
+
it('falls back to the class of failure when there is no reason', () => {
|
|
86
|
+
expect(errorLine({ code: 'NOT_FOUND', message: 'Asset not found' })).toEqual({
|
|
87
|
+
key: 'error_not_found',
|
|
88
|
+
detail: null,
|
|
89
|
+
})
|
|
90
|
+
expect(errorLine({ code: 'MODULE_DISABLED', message: 'Module inventory is disabled' }).key).toBe(
|
|
91
|
+
'error_module_disabled',
|
|
92
|
+
)
|
|
93
|
+
expect(errorLine({ code: 'FORBIDDEN' }).key).toBe('error_forbidden')
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('prefers the reason over the code, because it is the more specific fact', () => {
|
|
97
|
+
expect(errorLine(conflict('inventory.repair.already_open')).key).toBe('error_repair_already_open')
|
|
98
|
+
expect(errorLine({ code: 'CONFLICT' }).key).toBe('error_conflict')
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* A reason a newer server invented, an error from core, a socket that died. The generic sentence
|
|
103
|
+
* says what happened and the server's words are kept as the only actionable clue left — as
|
|
104
|
+
* *detail*, under a sentence that is at least in the reader's language.
|
|
105
|
+
*/
|
|
106
|
+
it('keeps the server words only for a failure it does not recognise', () => {
|
|
107
|
+
const unknown = { code: 'CONFLICT', message: 'Some newer refusal.', data: { reason: 'inventory.x.y' } }
|
|
108
|
+
// A reason nothing here maps falls through to the code, which is still translated.
|
|
109
|
+
expect(errorLine(unknown).key).toBe('error_conflict')
|
|
110
|
+
expect(errorLine(new Error('Failed to fetch'))).toEqual({
|
|
111
|
+
key: 'error_unknown',
|
|
112
|
+
detail: 'Failed to fetch',
|
|
113
|
+
})
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('says something rather than nothing for a failure with no words at all', () => {
|
|
117
|
+
expect(errorLine({})).toEqual({ key: 'error_unknown', detail: null })
|
|
118
|
+
expect(errorMessage({}, (key) => key)).toBe('error_unknown')
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The four refusals that were told to go and check a form where nothing was wrong.
|
|
123
|
+
*
|
|
124
|
+
* `KernError.badRequest` and oRPC's own schema failure both come out as `BAD_REQUEST`, and the
|
|
125
|
+
* client mapped every one of them to "Something in the form was not accepted. Check what you
|
|
126
|
+
* entered." The server was actually saying "That person is not a member of this workspace", "That
|
|
127
|
+
* file has not finished uploading yet", "That file is not one this workspace can attach" and
|
|
128
|
+
* "That page marker is not one this list issued" — none of which is a field somebody typed
|
|
129
|
+
* wrong. A confident wrong instruction is worse than a vague right one: nobody finds the mistake,
|
|
130
|
+
* because there isn't one.
|
|
131
|
+
*
|
|
132
|
+
* `data.issues` is the only structural difference between the two, and oRPC always sets it —
|
|
133
|
+
* `validateInput` throws `ORPCError('BAD_REQUEST', { data: { issues } })`.
|
|
134
|
+
*/
|
|
135
|
+
describe('a refusal is not a form error', () => {
|
|
136
|
+
const refusal = (message: string) => ({ code: 'BAD_REQUEST', message })
|
|
137
|
+
|
|
138
|
+
it('tells a validation failure from a service refusing', () => {
|
|
139
|
+
expect(isInputValidation({ code: 'BAD_REQUEST', data: { issues: [{ message: 'too long' }] } })).toBe(
|
|
140
|
+
true,
|
|
141
|
+
)
|
|
142
|
+
expect(isInputValidation(refusal('That file has not finished uploading yet.'))).toBe(false)
|
|
143
|
+
expect(isInputValidation({ data: { issues: 'nope' } })).toBe(false)
|
|
144
|
+
expect(isInputValidation(null)).toBe(false)
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('keeps "check what you entered" for the failure that really is about the form', () => {
|
|
148
|
+
expect(
|
|
149
|
+
errorLine({
|
|
150
|
+
code: 'BAD_REQUEST',
|
|
151
|
+
message: 'Input validation failed',
|
|
152
|
+
data: { issues: [{ message: 'Too long' }] },
|
|
153
|
+
}),
|
|
154
|
+
).toEqual({ key: 'error_bad_request', detail: null })
|
|
155
|
+
// `VALIDATION` is the code making that claim itself, so it needs no issues to be believed.
|
|
156
|
+
expect(errorLine({ code: 'VALIDATION' }).key).toBe('error_bad_request')
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('does not send anybody to the form for any of the four the server actually refuses', () => {
|
|
160
|
+
for (const message of [
|
|
161
|
+
'That person is not a member of this workspace, so nothing can be handed to them.',
|
|
162
|
+
'That file has not finished uploading yet.',
|
|
163
|
+
'That file is not one this workspace can attach.',
|
|
164
|
+
'That page marker is not one this list issued',
|
|
165
|
+
]) {
|
|
166
|
+
expect(errorLine(refusal(message))).toEqual({ key: 'error_refused', detail: message })
|
|
167
|
+
}
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Until those four carry a stable `reason`, the server's own sentence is the only thing that
|
|
172
|
+
* says *which* refusal it was — so it is kept, as detail under a translated line. Dropping it
|
|
173
|
+
* would leave "That was not accepted." and nothing else.
|
|
174
|
+
*/
|
|
175
|
+
it('keeps the server’s explanation, because nothing else identifies the refusal', () => {
|
|
176
|
+
expect(errorMessage(refusal('That file has not finished uploading yet.'), (key) => `<${key}>`)).toBe(
|
|
177
|
+
'<error_refused> — That file has not finished uploading yet.',
|
|
178
|
+
)
|
|
179
|
+
// And says something rather than nothing when there are no words at all.
|
|
180
|
+
expect(errorLine({ code: 'BAD_REQUEST' })).toEqual({ key: 'error_refused', detail: null })
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* The moment the server does carry one, that reason wins and the sentence is fully translated.
|
|
185
|
+
* Nothing is required of this file then except an entry in `REASON_KEYS` — this checks the
|
|
186
|
+
* precedence is already the right way round.
|
|
187
|
+
*/
|
|
188
|
+
it('prefers a reason over the refusal fallback, once the server carries one', () => {
|
|
189
|
+
expect(
|
|
190
|
+
errorLine({
|
|
191
|
+
code: 'BAD_REQUEST',
|
|
192
|
+
message: 'This item is archived. Restore it before handing it over.',
|
|
193
|
+
data: { reason: 'inventory.custody.archived' },
|
|
194
|
+
}),
|
|
195
|
+
).toEqual({ key: 'error_custody_archived', detail: null })
|
|
196
|
+
})
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
it('joins the sentence and the detail into one line for the toast', () => {
|
|
200
|
+
expect(errorMessage(new Error('Failed to fetch'), (key) => `<${key}>`)).toBe(
|
|
201
|
+
'<error_unknown> — Failed to fetch',
|
|
202
|
+
)
|
|
203
|
+
expect(errorMessage(conflict('inventory.custody.not_held'), (key) => `<${key}>`)).toBe(
|
|
204
|
+
'<error_custody_not_held>',
|
|
205
|
+
)
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* The one refusal whose sentence has a number in it.
|
|
210
|
+
*
|
|
211
|
+
* A limit that will not say what it is cannot be planned around, and `KernError.conflict` carries a
|
|
212
|
+
* reason and no data — so the figure comes from the contract, which is the same constant the server
|
|
213
|
+
* enforces. Passed through `t`, which puts it through `Intl.NumberFormat`, so the reader sees it in
|
|
214
|
+
* their own digits rather than in ASCII.
|
|
215
|
+
*/
|
|
216
|
+
it('hands the category limit its number, from the constant the server enforces', () => {
|
|
217
|
+
const refusal = conflict('inventory.category.limit_reached')
|
|
218
|
+
expect(errorLine(refusal)).toEqual({
|
|
219
|
+
key: 'error_category_limit_reached',
|
|
220
|
+
detail: null,
|
|
221
|
+
values: { max: MAX_LIVE_CATEGORIES },
|
|
222
|
+
})
|
|
223
|
+
expect(
|
|
224
|
+
errorMessage(refusal, (key, values) => `${key}:${JSON.stringify(values ?? null)}`),
|
|
225
|
+
'and the screen is handed the values along with the key',
|
|
226
|
+
).toBe(`error_category_limit_reached:{"max":${MAX_LIVE_CATEGORIES}}`)
|
|
227
|
+
expect(en['inventory.error_category_limit_reached'], 'which the sentence really asks for').toContain(
|
|
228
|
+
'{max}',
|
|
229
|
+
)
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
it('leaves values off every key that has no placeholder', () => {
|
|
233
|
+
expect(errorLine(conflict('inventory.category.order_stale')).values).toBeUndefined()
|
|
234
|
+
expect(errorLine({ code: 'NOT_FOUND' }).values).toBeUndefined()
|
|
235
|
+
})
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
// ------------------------------------------------------- the two lists that have to stay in step
|
|
239
|
+
|
|
240
|
+
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
241
|
+
const SERVER = join(HERE, '..', 'server')
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Every reason literal the server passes to a `KernError`, dug out of the source.
|
|
245
|
+
*
|
|
246
|
+
* Deliberately not a grep for `'inventory.…'`: permission keys, capability ids and notification
|
|
247
|
+
* types all look exactly like a reason token, and matching them would demand a sentence for things
|
|
248
|
+
* that are not refusals. This reads the argument position instead — the second of `conflict` and
|
|
249
|
+
* `notFound`, the fourth of the constructor — which is the only place a reason can be.
|
|
250
|
+
*/
|
|
251
|
+
function serverReasons(): string[] {
|
|
252
|
+
const found = new Set<string>()
|
|
253
|
+
const files = readdirSync(SERVER, { recursive: true, encoding: 'utf8' }).filter(
|
|
254
|
+
(file) => file.endsWith('.ts') && !file.endsWith('.test.ts'),
|
|
255
|
+
)
|
|
256
|
+
for (const file of files) {
|
|
257
|
+
const source = readFileSync(join(SERVER, file), 'utf8')
|
|
258
|
+
for (const [call, position] of [
|
|
259
|
+
['KernError.conflict(', 1],
|
|
260
|
+
['KernError.notFound(', 1],
|
|
261
|
+
['new KernError(', 3],
|
|
262
|
+
] as const) {
|
|
263
|
+
let at = source.indexOf(call)
|
|
264
|
+
while (at !== -1) {
|
|
265
|
+
const args = argumentsOf(source, at + call.length - 1)
|
|
266
|
+
const literal = args?.[position]?.trim().match(/^'([^']+)'$/)?.[1]
|
|
267
|
+
if (literal?.startsWith('inventory.')) found.add(literal)
|
|
268
|
+
at = source.indexOf(call, at + 1)
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return [...found].sort()
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* The arguments of a call, split on the commas that are actually arguments.
|
|
277
|
+
*
|
|
278
|
+
* **String-aware, and that is not fussiness.** The first version split on every top-level comma and
|
|
279
|
+
* missed `inventory.custody.not_held` — because its message is "Nobody is holding this item, so
|
|
280
|
+
* there is nothing to hand on. Assign it instead." and the comma inside the sentence made the
|
|
281
|
+
* reason the *third* argument. A scanner that silently finds fewer things than there are is worse
|
|
282
|
+
* than no scanner: it passes.
|
|
283
|
+
*/
|
|
284
|
+
function argumentsOf(source: string, open: number): string[] | null {
|
|
285
|
+
const parts: string[] = []
|
|
286
|
+
let depth = 0
|
|
287
|
+
let quote: string | null = null
|
|
288
|
+
let start = open + 1
|
|
289
|
+
for (let i = open; i < source.length; i++) {
|
|
290
|
+
const ch = source[i]
|
|
291
|
+
if (quote) {
|
|
292
|
+
if (ch === '\\') i++
|
|
293
|
+
else if (ch === quote) quote = null
|
|
294
|
+
continue
|
|
295
|
+
}
|
|
296
|
+
if (ch === "'" || ch === '"' || ch === '`') quote = ch
|
|
297
|
+
else if (ch === '(' || ch === '{' || ch === '[') depth++
|
|
298
|
+
else if (ch === ')' || ch === '}' || ch === ']') {
|
|
299
|
+
depth--
|
|
300
|
+
if (depth === 0) {
|
|
301
|
+
parts.push(source.slice(start, i))
|
|
302
|
+
return parts
|
|
303
|
+
}
|
|
304
|
+
} else if (ch === ',' && depth === 1) {
|
|
305
|
+
parts.push(source.slice(start, i))
|
|
306
|
+
start = i + 1
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return null
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Every sentence the server passes to `KernError.badRequest`, dug out of the source.
|
|
314
|
+
*
|
|
315
|
+
* The same trick `serverReasons` uses and for the same reason: the list grows. It was four when
|
|
316
|
+
* this was written — a non-member, an unfinished upload, a file from another workspace, a page
|
|
317
|
+
* marker this list never issued — and by the time it was finished it was six. None of them is a
|
|
318
|
+
* form-validation failure, and every one of them used to arrive as "check what you entered".
|
|
319
|
+
*/
|
|
320
|
+
function serverBadRequests(): string[] {
|
|
321
|
+
const found = new Set<string>()
|
|
322
|
+
const call = 'KernError.badRequest('
|
|
323
|
+
const files = readdirSync(SERVER, { recursive: true, encoding: 'utf8' }).filter(
|
|
324
|
+
(file) => file.endsWith('.ts') && !file.endsWith('.test.ts'),
|
|
325
|
+
)
|
|
326
|
+
for (const file of files) {
|
|
327
|
+
const source = readFileSync(join(SERVER, file), 'utf8')
|
|
328
|
+
let at = source.indexOf(call)
|
|
329
|
+
while (at !== -1) {
|
|
330
|
+
const literal = argumentsOf(source, at + call.length - 1)?.[0]
|
|
331
|
+
?.trim()
|
|
332
|
+
.match(/^'((?:[^'\\]|\\.)*)'$/)?.[1]
|
|
333
|
+
if (literal) found.add(literal)
|
|
334
|
+
at = source.indexOf(call, at + 1)
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
return [...found].sort()
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
describe('the catalogue keeps up with the server', () => {
|
|
341
|
+
/**
|
|
342
|
+
* Not one of the module's own `BAD_REQUEST` refusals is about a field somebody typed, so not one
|
|
343
|
+
* of them may land on `error_bad_request`. This reads them out of `src/server` rather than
|
|
344
|
+
* listing them, so a refusal added next month is covered the day it is written — and if one ever
|
|
345
|
+
* *is* a form error, it fails here and asks for a `reason` instead of a reworded sentence.
|
|
346
|
+
*/
|
|
347
|
+
it('sends no refusal the server actually throws back to the form', () => {
|
|
348
|
+
const refusals = serverBadRequests()
|
|
349
|
+
expect(refusals.length).toBeGreaterThan(3)
|
|
350
|
+
for (const message of refusals)
|
|
351
|
+
expect({ message, line: errorLine({ code: 'BAD_REQUEST', message }) }).toEqual({
|
|
352
|
+
message,
|
|
353
|
+
line: { key: 'error_refused', detail: message },
|
|
354
|
+
})
|
|
355
|
+
})
|
|
356
|
+
|
|
357
|
+
it('has a translated sentence for every reason the server throws', () => {
|
|
358
|
+
// If this fails, a refusal was added to `src/server` without a sentence — and it will reach a
|
|
359
|
+
// Persian, Arabic, German or Turkish reader as English prose. Add the reason to `REASON_KEYS`
|
|
360
|
+
// and the sentence to all five bundles.
|
|
361
|
+
const missing = serverReasons().filter((reason) => !reasonKeys().includes(reason))
|
|
362
|
+
expect(missing).toEqual([])
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
it('is reading the server rather than passing on an empty sweep', () => {
|
|
366
|
+
// An empty result would make the check above pass for ever. The services throw eleven of these.
|
|
367
|
+
expect(serverReasons().length).toBeGreaterThan(8)
|
|
368
|
+
})
|
|
369
|
+
|
|
370
|
+
it('names no reason the server does not throw', () => {
|
|
371
|
+
// The other direction: a sentence for a refusal that cannot happen is dead weight nobody will
|
|
372
|
+
// ever notice has gone wrong.
|
|
373
|
+
const server = serverReasons()
|
|
374
|
+
expect(reasonKeys().filter((key) => !server.includes(key))).toEqual([])
|
|
375
|
+
})
|
|
376
|
+
|
|
377
|
+
it('names a key the catalogue actually has, for every reason and every code', () => {
|
|
378
|
+
const keys = new Set<string>()
|
|
379
|
+
for (const reason of reasonKeys()) keys.add(errorLine(conflict(reason)).key)
|
|
380
|
+
for (const code of [
|
|
381
|
+
'NOT_FOUND',
|
|
382
|
+
'FORBIDDEN',
|
|
383
|
+
'MODULE_DISABLED',
|
|
384
|
+
'CONFLICT',
|
|
385
|
+
'BAD_REQUEST',
|
|
386
|
+
'VALIDATION',
|
|
387
|
+
'UNAUTHORIZED',
|
|
388
|
+
'RATE_LIMITED',
|
|
389
|
+
'UNAVAILABLE',
|
|
390
|
+
'INTERNAL',
|
|
391
|
+
])
|
|
392
|
+
keys.add(errorLine({ code }).key)
|
|
393
|
+
for (const key of keys) expect({ key, has: `inventory.${key}` in en }).toEqual({ key, has: true })
|
|
394
|
+
})
|
|
395
|
+
})
|