225 lines
9.9 KiB
TypeScript
225 lines
9.9 KiB
TypeScript
// The pages and components, rendered the way the shell renders them, from the loader's data: the platform's
|
|
// refusals are the page's own states, a control is offered to whoever may use it, every table has a
|
|
// caption and scoped headers.
|
|
|
|
import { AppError, buildRegistry, Container, type TransportCaller } from "@conusai-kit/core"
|
|
import { filesModule } from "@conusai-kit/files"
|
|
import { builtInCatalogues, createI18n, uiLabels } from "@conusai-kit/i18n"
|
|
import { tenancyModule } from "@conusai-kit/tenancy"
|
|
import { createDataClient, DataProvider, type PermissionKey, PermissionsProvider } from "@conusai-kit/ui/data"
|
|
import { UiLabelsProvider } from "@conusai-kit/ui/primitives"
|
|
import type { ReactNode } from "react"
|
|
import { renderToStaticMarkup as renderMarkup } from "react-dom/server"
|
|
import { describe, expect, it, vi } from "vitest"
|
|
import { modulePages } from "../../pages.js"
|
|
import { archiveModule } from "../module.js"
|
|
import { archivePage } from "../ui/pages.js"
|
|
|
|
const catalogues = buildRegistry([tenancyModule, filesModule, archiveModule]).catalogues
|
|
const i18n = createI18n("en", {
|
|
en: { archive: catalogues.en?.archive ?? {}, app: { failed: "That did not work: {detail}" } },
|
|
})
|
|
const lithuanian = createI18n("lt", {
|
|
lt: { archive: catalogues.lt?.archive ?? {}, app: { failed: "Nepavyko: {detail}" } },
|
|
})
|
|
const slugs = { org: "acme", workspace: "ws", project: "p" }
|
|
const VIEWER: readonly PermissionKey[] = ["archive.document.read"]
|
|
const EDITOR: readonly PermissionKey[] = [...VIEWER, "archive.document.write"]
|
|
const ADMIN: readonly PermissionKey[] = [...EDITOR, "archive.retention.manage"]
|
|
|
|
const kitStrings = uiLabels(createI18n("en", builtInCatalogues))
|
|
const renderToStaticMarkup = (node: ReactNode) =>
|
|
renderMarkup(<UiLabelsProvider labels={kitStrings}>{node}</UiLabelsProvider>)
|
|
|
|
const inProvider = (node: ReactNode, permissions: readonly PermissionKey[] = ADMIN) =>
|
|
renderToStaticMarkup(
|
|
<DataProvider client={createDataClient({ fetch: async () => new Response(null, { status: 501 }) })}>
|
|
<PermissionsProvider scope={slugs} seed={{ scope: slugs, permissions, revision: "1" }}>
|
|
{node}
|
|
</PermissionsProvider>
|
|
</DataProvider>,
|
|
)
|
|
|
|
const callerOf = (answers: Record<string, unknown>): TransportCaller =>
|
|
({
|
|
call: async (contract: { name: string }) => {
|
|
const answer = answers[contract.name]
|
|
if (answer instanceof Error) throw answer
|
|
return answer
|
|
},
|
|
}) as unknown as TransportCaller
|
|
|
|
/** The page as the shell renders it: the loader's data through JSON, as the browser receives it, then the view. */
|
|
const render = async (segments: string[], answers: Record<string, unknown>, permissions?: readonly PermissionKey[]) => {
|
|
const data = await archivePage.load({
|
|
slugs,
|
|
segments,
|
|
search: {},
|
|
caller: callerOf(answers),
|
|
i18n,
|
|
container: new Container(),
|
|
})
|
|
expect(JSON.parse(JSON.stringify(data))).toEqual(data)
|
|
const { View } = archivePage
|
|
return inProvider(
|
|
<View slugs={slugs} segments={segments} locale="en" data={JSON.parse(JSON.stringify(data))} />,
|
|
permissions,
|
|
)
|
|
}
|
|
|
|
const escaped = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
|
|
|
/** Every label of a form names a control the page holds, and every hint is in its control's description. */
|
|
const expectLabelled = (html: string, labels: number): void => {
|
|
const targets = [...html.matchAll(/<label[^>]* for="([^"]+)"/g)].map((match) => match[1] ?? "")
|
|
expect(targets).toHaveLength(labels)
|
|
for (const id of targets) expect(html).toMatch(new RegExp(`<(?:input|select|button)[^>]* id="${escaped(id)}"`))
|
|
for (const [, id = ""] of html.matchAll(/<p id="([^"]+)" data-slot="field-description"/g))
|
|
expect(html).toMatch(new RegExp(`aria-describedby="(?:[^"]* )?${escaped(id)}[ "]`))
|
|
}
|
|
|
|
const document = {
|
|
documentId: "doc_1",
|
|
title: "Signed contract",
|
|
kind: "380",
|
|
retentionClass: "contracts",
|
|
legalHold: false,
|
|
status: "active",
|
|
disposed: false,
|
|
revision: "2",
|
|
createdAt: "2026-11-02T10:00:00.000Z",
|
|
lastVersionAt: "2026-11-02T11:00:00.000Z",
|
|
disposedAt: null,
|
|
}
|
|
|
|
const version = {
|
|
versionId: "dvr_1",
|
|
sequence: 1,
|
|
sha256: "a".repeat(64),
|
|
mediaType: "application/pdf",
|
|
size: "1234",
|
|
createdAt: "2026-11-02T10:00:00.000Z",
|
|
}
|
|
|
|
const contracts = { code: "contracts", keepFor: "P10Y", from: "created", createdAt: "2026-11-02T10:00:00.000Z" }
|
|
|
|
const listAnswers = {
|
|
"archive.document.list": { rows: [document] },
|
|
"archive.retention.list": { rows: [contracts] },
|
|
}
|
|
const documentAnswers = { "archive.document.get": { document, versions: [version] } }
|
|
const retentionAnswers = {
|
|
"archive.retention.list": { rows: [contracts] },
|
|
"archive.disposal.list": { rows: [document] },
|
|
}
|
|
|
|
describe("the archive page", () => {
|
|
it("lists the documents, with the register form for whoever may add one", async () => {
|
|
const html = await render([], listAnswers)
|
|
expect(html).toMatch(/<h1\b[^>]*>Archive<\/h1>/)
|
|
expect(html).toMatch(
|
|
/<a\b(?=[^>]*href="\/acme\/ws\/p\/archive\/doc_1")(?=[^>]*aria-label="Open Signed contract")[^>]*>Signed contract<\/a>/,
|
|
)
|
|
expect(html).toMatch(/<table\b[^>]*aria-label="Documents"/)
|
|
expect(html).toMatch(/<option\b[^>]*value="contracts"[^>]*>contracts<\/option>/)
|
|
expect(html).toContain('type="file"')
|
|
expectLabelled(html, 5)
|
|
expect(html.match(/data-slot="field-description"/g)).toHaveLength(1)
|
|
expect(await render([], listAnswers, VIEWER)).not.toContain('type="file"')
|
|
})
|
|
|
|
it("says a document cannot be registered before there is a retention class", async () => {
|
|
const html = await render([], { ...listAnswers, "archive.retention.list": { rows: [] } })
|
|
expect(html).toContain("Define a retention class first.")
|
|
expect(html).not.toContain('type="file"')
|
|
})
|
|
|
|
it("shows a document's versions, with the controls each role may use", async () => {
|
|
const html = await render(["doc_1"], documentAnswers)
|
|
expect(html).toContain("<h2>Signed contract</h2>")
|
|
expect(html).toMatch(/<caption\b[^>]*>Versions<\/caption>/)
|
|
expect(html).toMatch(/<th\b[^>]*scope="row"[^>]*>1<\/th><td\b[^>]*>application\/pdf<\/td><td\b[^>]*>1234<\/td>/)
|
|
expect(html).toContain("Add a version")
|
|
expectLabelled(html, 1)
|
|
expect(html).toContain("Place a legal hold")
|
|
expect(html).toContain("Dispose")
|
|
|
|
const editor = await render(["doc_1"], documentAnswers, EDITOR)
|
|
expect(editor).toContain("Add a version")
|
|
expect(editor).not.toContain("Place a legal hold")
|
|
const viewer = await render(["doc_1"], documentAnswers, VIEWER)
|
|
expect(viewer).not.toContain("Add a version")
|
|
})
|
|
|
|
it("offers nothing on a disposed document", async () => {
|
|
const disposed = { ...document, status: "disposed", disposed: true, disposedAt: "2026-12-01T10:00:00.000Z" }
|
|
const html = await render(["doc_1"], { "archive.document.get": { document: disposed, versions: [version] } })
|
|
expect(html).toContain("Disposed")
|
|
expect(html).not.toContain("Add a version")
|
|
expect(html).not.toContain("Place a legal hold")
|
|
})
|
|
|
|
it("lists the retention classes and what is due, with the form for an administrator", async () => {
|
|
const html = await render(["retention"], retentionAnswers)
|
|
expect(html).toMatch(/<h1\b[^>]*>Retention<\/h1>/)
|
|
expect(html).toMatch(
|
|
/<th\b[^>]*scope="row"[^>]*>contracts<\/th><td\b[^>]*>P10Y<\/td><td\b[^>]*>When the document is registered<\/td>/,
|
|
)
|
|
expect(html).toMatch(/<caption\b[^>]*>Disposal due<\/caption>/)
|
|
expect(html).toContain("New retention class")
|
|
expectLabelled(html, 3)
|
|
expect(html.match(/data-slot="field-description"/g)).toHaveLength(1)
|
|
expect(await render(["retention"], retentionAnswers, EDITOR)).not.toContain("New retention class")
|
|
})
|
|
|
|
it.each([
|
|
["service_not_enabled", "This service is not enabled for your organization."],
|
|
["service_permission_missing", "This app is not allowed to use this service. Ask an administrator to grant it."],
|
|
["business_unavailable", "The service did not answer. Try again later."],
|
|
])("shows the typed state %s", async (code, text) => {
|
|
const refused = new AppError({ type: `/problems/${code}`, title: "Refused", status: 503, code, detail: "why" })
|
|
const html = await render([], { ...listAnswers, "archive.document.list": refused })
|
|
expect(html).toContain(`data-state="${code}"`)
|
|
expect(html).toContain('role="status"')
|
|
expect(html).toContain(text)
|
|
expect(html).not.toContain("<table")
|
|
})
|
|
|
|
it("shows the same state in Lithuanian", async () => {
|
|
const code = "service_not_enabled"
|
|
const refused = new AppError({ type: `/problems/${code}`, title: "Refused", status: 404, code })
|
|
const data = await archivePage.load({
|
|
slugs,
|
|
segments: [],
|
|
search: {},
|
|
caller: callerOf({ ...listAnswers, "archive.document.list": refused }),
|
|
i18n: lithuanian,
|
|
container: new Container(),
|
|
})
|
|
expect(data).toMatchObject({
|
|
kind: "state",
|
|
notice: { code, text: "Ši paslauga jūsų organizacijai neįjungta." },
|
|
})
|
|
})
|
|
|
|
it("leaves a document that is not there, and a path it does not have, to the shell", async () => {
|
|
const missing = new AppError({ type: "/problems/not_found", title: "Not found", status: 404, code: "not_found" })
|
|
await expect(render(["doc_2"], { "archive.document.get": missing })).rejects.toMatchObject({
|
|
problem: { code: "not_found" },
|
|
})
|
|
await expect(render(["doc_1", "x"], {})).rejects.toMatchObject({ problem: { status: 404 } })
|
|
await expect(render(["retention", "x"], {})).rejects.toMatchObject({ problem: { status: 404 } })
|
|
await expect(render(["%2Fetc"], {})).rejects.toMatchObject({ problem: { status: 404 } })
|
|
await expect(render(["%E0%A4%A"], {})).rejects.toMatchObject({ problem: { status: 404 } })
|
|
})
|
|
|
|
it("is listed for the shell", async () => {
|
|
await expect(modulePages.archive?.()).resolves.toBe(archivePage)
|
|
})
|
|
})
|
|
|
|
// Explicit synthetic admission: module-local fixtures exercise the selected module.
|
|
vi.mock("../../inclusion.json", () => ({
|
|
default: { schemaVersion: 1, selected: ["archive"], everAdopted: ["archive"] },
|
|
}))
|