@@ -0,0 +1,135 @@
|
||||
import { simulatedBusinessAdmission } from "../../../test/helpers/business-admission.js"
|
||||
// Simulated adapter dispatch; these fixtures do not prove gateway or partition isolation.
|
||||
// The module on its own, wired the way apps/web wires a request -- tenancy on the in-memory data
|
||||
// source, the archive module, and the Archive port bound to the real generated client over the
|
||||
// business-services fake of @conusai-kit/testing -- so its procedures are tested whether or not this
|
||||
// app turned the module on.
|
||||
|
||||
import { businessClient, type Client } from "@conusai-kit/business"
|
||||
import { type Actor, buildRegistry, Container, createCaller, type TransportCaller } from "@conusai-kit/core"
|
||||
import { createMemoryDataSource, DATA_SOURCE, entitiesOf, OBJECT_STORE, SYSTEM_DATA } from "@conusai-kit/data"
|
||||
import { files, filesModule } from "@conusai-kit/files"
|
||||
import { createTenancyHooks, TENANCY, tenancyModule, users } from "@conusai-kit/tenancy"
|
||||
import { type FakeBusinessServices, fakeBusinessServices, fakeObjectStore } from "@conusai-kit/testing"
|
||||
import { createClient } from "@conusai-sdk/archive/client"
|
||||
import { sdkArchiveService } from "../adapters/service.js"
|
||||
import { ARCHIVE_SERVICE } from "../core/ports.js"
|
||||
import { archiveModule } from "../module.js"
|
||||
|
||||
export interface Tenant {
|
||||
readonly owner: Actor
|
||||
readonly call: TransportCaller
|
||||
readonly orgSlug: string
|
||||
readonly orgId: string
|
||||
readonly workspaceSlug: string
|
||||
readonly workspaceId: string
|
||||
readonly projectSlug: string
|
||||
readonly projectId: string
|
||||
readonly slugs: { org: string; workspace: string; project: string }
|
||||
}
|
||||
|
||||
export function archiveApp(options: Parameters<typeof fakeBusinessServices>[0] = {}) {
|
||||
const fake: FakeBusinessServices = fakeBusinessServices(options)
|
||||
const client: Client = businessClient({ apiUrl: fake.apiUrl, token: fake.token, orgSlug: fake.orgSlug }, createClient)
|
||||
client.setConfig({ fetch: fake.fetch as typeof fetch })
|
||||
// The files module for its table alone: its procedures have their fixtures in test/isolation.test.ts.
|
||||
const registry = buildRegistry([tenancyModule, { ...filesModule, procedures: [] }, archiveModule])
|
||||
const entities = entitiesOf(registry, "postgres")
|
||||
const data = createMemoryDataSource({ entities })
|
||||
const root = new Container()
|
||||
root.value(DATA_SOURCE, data)
|
||||
root.value(SYSTEM_DATA, data)
|
||||
root.value(OBJECT_STORE, fakeObjectStore())
|
||||
root.value(ARCHIVE_SERVICE, sdkArchiveService({ client, orgSlug: fake.orgSlug }))
|
||||
|
||||
const caller = (actor: Actor): TransportCaller => {
|
||||
const tenancy = createTenancyHooks({ data, registry })
|
||||
const container = root.child()
|
||||
container.value(TENANCY, tenancy)
|
||||
return createCaller({ registry, container, tenancy, serviceReadiness: simulatedBusinessAdmission }, { actor })
|
||||
}
|
||||
|
||||
const signUp = async (email: string): Promise<Actor> => {
|
||||
const user = await data.global(users).insert({
|
||||
issuer: "https://id.example.test",
|
||||
subject: email,
|
||||
email,
|
||||
displayName: email.split("@")[0] ?? email,
|
||||
locale: "en",
|
||||
})
|
||||
return { userId: user.id }
|
||||
}
|
||||
|
||||
/** An organization with one workspace and one project, owned by a new user. */
|
||||
const tenant = async (slug: string): Promise<Tenant> => {
|
||||
const owner = await signUp(`owner@${slug}.test`)
|
||||
const call = caller(owner)
|
||||
const org = (await call.callByName("tenancy.org.create", { slug, name: slug })) as { orgId: string; slug: string }
|
||||
const workspace = (await call.callByName("tenancy.workspace.create", {
|
||||
org: slug,
|
||||
slug: `ws-${slug}`,
|
||||
name: `Workspace ${slug}`,
|
||||
visibility: "org",
|
||||
})) as { workspaceId: string; slug: string }
|
||||
const project = (await call.callByName("tenancy.project.create", {
|
||||
org: slug,
|
||||
workspace: workspace.slug,
|
||||
slug: `p-${slug}`,
|
||||
name: `Project ${slug}`,
|
||||
moduleKey: "archive",
|
||||
})) as { projectId: string; slug: string }
|
||||
return {
|
||||
owner,
|
||||
call,
|
||||
orgSlug: org.slug,
|
||||
orgId: org.orgId,
|
||||
workspaceSlug: workspace.slug,
|
||||
workspaceId: workspace.workspaceId,
|
||||
projectSlug: project.slug,
|
||||
projectId: project.projectId,
|
||||
slugs: { org: org.slug, workspace: workspace.slug, project: project.slug },
|
||||
}
|
||||
}
|
||||
|
||||
/** A second user who joins `t`'s organization with the org role `role`. */
|
||||
const member = async (t: Tenant, email: string, role: "viewer" | "member"): Promise<TransportCaller> => {
|
||||
const invite = (await t.call.callByName("tenancy.member.invite", { org: t.orgSlug, email, role })) as {
|
||||
token: string
|
||||
}
|
||||
const actor = await signUp(email)
|
||||
const call = caller(actor)
|
||||
await call.callByName("tenancy.invitation.accept", { org: t.orgSlug, token: invite.token })
|
||||
return call
|
||||
}
|
||||
|
||||
/** A file stored in `t`'s project, as the files module leaves an upload it has finished. */
|
||||
const storedFile = async (t: Tenant, sha256 = "a".repeat(64), size = 1234): Promise<string> => {
|
||||
const scope = {
|
||||
level: "project" as const,
|
||||
actor: t.owner,
|
||||
orgId: t.orgId,
|
||||
workspaceId: t.workspaceId,
|
||||
projectId: t.projectId,
|
||||
}
|
||||
const row = await data
|
||||
.forScope(scope)
|
||||
.of(files)
|
||||
.insert({
|
||||
status: "stored",
|
||||
storageKey: `files/${t.workspaceId}/${t.projectId}/${crypto.randomUUID()}`,
|
||||
filename: "record.pdf",
|
||||
contentType: "application/pdf",
|
||||
size,
|
||||
sha256,
|
||||
uploadedBy: t.owner.userId,
|
||||
storedAt: "2026-11-02T10:00:00.000Z",
|
||||
uploadId: null,
|
||||
declaredSize: null,
|
||||
partSize: null,
|
||||
deletingAt: null,
|
||||
})
|
||||
return row.id
|
||||
}
|
||||
|
||||
return { fake, client, registry, data, caller, signUp, tenant, member, storedFile }
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
// The archive use cases over a recording port: what each asks the service, with no client and no network.
|
||||
|
||||
import type { ProjectScope } from "@conusai-kit/core"
|
||||
import { createMemoryDataSource } from "@conusai-kit/data"
|
||||
import { DEFAULT_FILES_POLICY, files } from "@conusai-kit/files"
|
||||
import { fakeObjectStore } from "@conusai-kit/testing"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { addVersion, listDocuments, registerDocument, requestKey, versionFacts } from "../core/archive.js"
|
||||
import { type ArchiveService, archiveReadsOf } from "../core/ports.js"
|
||||
|
||||
const scope: ProjectScope = {
|
||||
level: "project",
|
||||
actor: { userId: "01900000-0000-7000-0000-000000000009" },
|
||||
orgId: "01900000-0000-7000-0000-000000000001",
|
||||
workspaceId: "01900000-0000-7000-0000-000000000002",
|
||||
projectId: "01900000-0000-7000-0000-000000000003",
|
||||
}
|
||||
|
||||
const receipt = { documentId: "doc_1", revision: "1" }
|
||||
|
||||
function recording() {
|
||||
const asked: Array<[string, unknown]> = []
|
||||
const service: ArchiveService = {
|
||||
listDocuments: async (query) => {
|
||||
asked.push(["listDocuments", query])
|
||||
return { rows: [] }
|
||||
},
|
||||
getDocument: async () => {
|
||||
throw new Error("not asked")
|
||||
},
|
||||
listVersions: async () => ({ rows: [] }),
|
||||
listRetentionClasses: async () => ({ rows: [] }),
|
||||
listDisposalDue: async () => ({ rows: [] }),
|
||||
registerDocument: async (input) => {
|
||||
asked.push(["registerDocument", input])
|
||||
return receipt
|
||||
},
|
||||
addVersion: async (input) => {
|
||||
asked.push(["addVersion", input])
|
||||
return receipt
|
||||
},
|
||||
setLegalHold: async () => receipt,
|
||||
disposeDocument: async () => receipt,
|
||||
createRetentionClass: async () => undefined,
|
||||
}
|
||||
return { service, asked }
|
||||
}
|
||||
|
||||
async function storedFile() {
|
||||
const data = createMemoryDataSource({ entities: [files] })
|
||||
const deps = { data, store: fakeObjectStore(), policy: DEFAULT_FILES_POLICY }
|
||||
const row = await data
|
||||
.forScope(scope)
|
||||
.of(files)
|
||||
.insert({
|
||||
status: "stored",
|
||||
storageKey: "files/w/p/f",
|
||||
filename: "record.pdf",
|
||||
contentType: "application/pdf",
|
||||
size: 1234,
|
||||
sha256: "a".repeat(64),
|
||||
uploadedBy: scope.actor.userId,
|
||||
storedAt: "2026-11-02T10:00:00.000Z",
|
||||
uploadId: null,
|
||||
declaredSize: null,
|
||||
partSize: null,
|
||||
deletingAt: null,
|
||||
})
|
||||
return { deps, fileId: row.id }
|
||||
}
|
||||
|
||||
describe("the archive use cases", () => {
|
||||
it("list documents with a trimmed search and the module's page size", async () => {
|
||||
const { service, asked } = recording()
|
||||
await listDocuments(service, { q: " invoice " })
|
||||
await listDocuments(service, { q: " " })
|
||||
expect(asked).toEqual([
|
||||
["listDocuments", { q: "invoice", limit: 50 }],
|
||||
["listDocuments", { limit: 50 }],
|
||||
])
|
||||
})
|
||||
|
||||
it("take a version's facts from the stored file, with an opaque Blob reference", async () => {
|
||||
const { deps, fileId } = await storedFile()
|
||||
expect(await versionFacts(deps, scope, fileId)).toEqual({
|
||||
sha256: "a".repeat(64),
|
||||
mediaType: "application/pdf",
|
||||
size: 1234,
|
||||
blobRef: `conusai://blob/files/${fileId}`,
|
||||
})
|
||||
})
|
||||
|
||||
it("register a document with the project's request key and a trimmed title, and no kind unless asked", async () => {
|
||||
const { deps, fileId } = await storedFile()
|
||||
const { service, asked } = recording()
|
||||
await registerDocument(service, deps, scope, {
|
||||
title: " Signed contract ",
|
||||
kind: " ",
|
||||
retentionClass: "contracts",
|
||||
fileId,
|
||||
requestId: "r1",
|
||||
})
|
||||
expect(asked).toEqual([
|
||||
[
|
||||
"registerDocument",
|
||||
{
|
||||
title: "Signed contract",
|
||||
retentionClass: "contracts",
|
||||
version: await versionFacts(deps, scope, fileId),
|
||||
key: requestKey(scope, "r1"),
|
||||
},
|
||||
],
|
||||
])
|
||||
})
|
||||
|
||||
it("add a version over the revision the page read", async () => {
|
||||
const { deps, fileId } = await storedFile()
|
||||
const { service, asked } = recording()
|
||||
await addVersion(service, deps, scope, { documentId: "doc_1", revision: "4", fileId, requestId: "r2" })
|
||||
expect(asked[0]?.[1]).toMatchObject({ documentId: "doc_1", revision: "4", key: requestKey(scope, "r2") })
|
||||
})
|
||||
|
||||
it("refuse a file this project does not hold before the service is asked", async () => {
|
||||
const { deps } = await storedFile()
|
||||
const { service, asked } = recording()
|
||||
await expect(
|
||||
registerDocument(service, deps, scope, {
|
||||
title: "Someone else's",
|
||||
retentionClass: "contracts",
|
||||
fileId: "01900000-0000-7000-0000-0000000000ff",
|
||||
requestId: "r3",
|
||||
}),
|
||||
).rejects.toMatchObject({ problem: { status: 404 } })
|
||||
expect(asked).toEqual([])
|
||||
})
|
||||
|
||||
it("hand a read handler a view without a single write", () => {
|
||||
expect(Object.keys(archiveReadsOf(recording().service)).sort()).toEqual([
|
||||
"getDocument",
|
||||
"listDisposalDue",
|
||||
"listDocuments",
|
||||
"listRetentionClasses",
|
||||
"listVersions",
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,109 @@
|
||||
// Every string a page of the module shows is a message in English and in Lithuanian, and no component of
|
||||
// the module carries a string of its own.
|
||||
|
||||
import { readdirSync, readFileSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import { buildRegistry } from "@conusai-kit/core"
|
||||
import { filesModule } from "@conusai-kit/files"
|
||||
import { createI18n, lintUiStrings } from "@conusai-kit/i18n"
|
||||
import { tenancyModule } from "@conusai-kit/tenancy"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { archiveModule } from "../module.js"
|
||||
import {
|
||||
documentActionsLabels,
|
||||
documentListLabels,
|
||||
registerFormLabels,
|
||||
retentionFormLabels,
|
||||
retentionLabels,
|
||||
versionFormLabels,
|
||||
versionsLabels,
|
||||
} from "../ui/labels.js"
|
||||
|
||||
// The messages as an app that turned the module on has them: from the manifest, flattened by the kit.
|
||||
const catalogues = buildRegistry([tenancyModule, filesModule, archiveModule]).catalogues
|
||||
const en = catalogues.en?.archive ?? {}
|
||||
const lt = catalogues.lt?.archive ?? {}
|
||||
|
||||
function keysUsed(): string[] {
|
||||
const asked: string[] = []
|
||||
const t = (key: string) => {
|
||||
asked.push(key)
|
||||
return key
|
||||
}
|
||||
documentListLabels(t, "failed", {})
|
||||
registerFormLabels(t, "failed", {})
|
||||
versionFormLabels(t, "failed", {})
|
||||
versionsLabels(t, 2)
|
||||
documentActionsLabels(t, "failed", {})
|
||||
retentionLabels(t)
|
||||
retentionFormLabels(t, "failed", {})
|
||||
return asked
|
||||
}
|
||||
|
||||
describe("the archive catalogues", () => {
|
||||
it("hold the same keys in English and in Lithuanian, none empty", () => {
|
||||
expect(Object.keys(lt).sort()).toEqual(Object.keys(en).sort())
|
||||
for (const [key, text] of [...Object.entries(en), ...Object.entries(lt)]) expect(text.trim(), key).not.toBe("")
|
||||
})
|
||||
|
||||
it("hold every message the labels ask for, and none that nothing asks for", () => {
|
||||
const asked = keysUsed()
|
||||
for (const key of asked) {
|
||||
expect(en[key as keyof typeof en], `en is missing archive.${key}`).toBeTruthy()
|
||||
expect(lt[key as keyof typeof lt], `lt is missing archive.${key}`).toBeTruthy()
|
||||
}
|
||||
// The pages and the navigation ask for these directly.
|
||||
const direct = new Set([
|
||||
"title",
|
||||
"intro",
|
||||
"nav.documents",
|
||||
"nav.retention",
|
||||
"document.back",
|
||||
"document.retentionClass",
|
||||
"document.status",
|
||||
"document.revision",
|
||||
"document.created",
|
||||
"document.hold",
|
||||
"document.disposedAt",
|
||||
"document.yes",
|
||||
"document.no",
|
||||
"statuses.active",
|
||||
"statuses.disposed",
|
||||
"retention.title",
|
||||
"retention.intro",
|
||||
"retention.bases.created",
|
||||
"retention.bases.last_version",
|
||||
"retention.bases.event",
|
||||
])
|
||||
expect(Object.keys(en).filter((key) => !asked.includes(key) && !direct.has(key))).toEqual([])
|
||||
})
|
||||
|
||||
it("answer the navigation label the manifest names", () => {
|
||||
expect(archiveModule.ui?.nav?.map((item) => item.label)).toEqual(["archive.title"])
|
||||
})
|
||||
|
||||
it("count versions with the Lithuanian one, few and other forms", () => {
|
||||
const count = (locale: "en" | "lt", n: number) =>
|
||||
createI18n(locale, { en: { archive: en }, lt: { archive: lt } }).message("archive.versions.count", { count: n })
|
||||
expect(count("lt", 1)).toBe("1 versija")
|
||||
expect(count("lt", 2)).toBe("2 versijos")
|
||||
expect(count("lt", 10)).toBe("10 versijų")
|
||||
expect(count("lt", 21)).toBe("21 versija")
|
||||
expect(count("en", 2)).toBe("2 versions")
|
||||
})
|
||||
})
|
||||
|
||||
describe("the components", () => {
|
||||
const dir = new URL("../ui/", import.meta.url).pathname
|
||||
const sources = readdirSync(dir)
|
||||
.filter((name) => name.endsWith(".tsx"))
|
||||
.map((name) => ({ name, text: readFileSync(join(dir, name), "utf8") }))
|
||||
|
||||
it("carry no hard-coded user-facing string and no colour literal", () => {
|
||||
expect(sources.length).toBeGreaterThanOrEqual(5)
|
||||
for (const { name, text } of sources) {
|
||||
expect(lintUiStrings(text, name), name).toEqual([])
|
||||
expect(text, name).not.toMatch(/#[0-9a-fA-F]{3,8}\b|\brgb\(|\bhsl\(|\boklch\(/)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,62 @@
|
||||
// What the generated tenant-isolation suite (test/isolation.test.ts) needs of this module, from this
|
||||
// folder: one fixture per tenant-scoped procedure, each called as a member of one organization with
|
||||
// another's slugs, which the resolver must refuse before the Archive service is reached.
|
||||
//
|
||||
// The module keeps no row of its own, so it seeds nothing: what a fixture names is the other tenant's
|
||||
// project, and an id is any well-formed one (the service is never asked). The suite reads this file by
|
||||
// its place whether or not the module is on, and checks only the procedures the app registers, so
|
||||
// while the module is off (the template's default) it brings no fixture; `procedures.test.ts` runs the
|
||||
// same fixtures against the module on its own, on or off.
|
||||
|
||||
import type { TransportCaller } from "@conusai-kit/core"
|
||||
import type { IsolationFixture, IsolationTenant } from "@conusai-kit/testing"
|
||||
import { businessModulesOn } from "../../index.js"
|
||||
|
||||
const SOMEWHERE = "00000000-0000-4000-8000-000000000001"
|
||||
const inProjectOf = (t: IsolationTenant) => ({ org: t.orgSlug, workspace: t.workspaceSlug, project: t.projectSlug })
|
||||
|
||||
export const archiveIsolationFixtures: Record<string, IsolationFixture> = {
|
||||
"archive.document.list": { input: (t) => inProjectOf(t) },
|
||||
"archive.document.get": { input: (t) => ({ ...inProjectOf(t), documentId: SOMEWHERE }) },
|
||||
"archive.document.register": {
|
||||
input: (t) => ({
|
||||
...inProjectOf(t),
|
||||
title: "Intruder",
|
||||
retentionClass: "anything",
|
||||
fileId: SOMEWHERE,
|
||||
requestId: SOMEWHERE,
|
||||
}),
|
||||
},
|
||||
"archive.version.add": {
|
||||
input: (t) => ({
|
||||
...inProjectOf(t),
|
||||
documentId: SOMEWHERE,
|
||||
revision: "1",
|
||||
fileId: SOMEWHERE,
|
||||
requestId: SOMEWHERE,
|
||||
}),
|
||||
},
|
||||
"archive.document.hold": {
|
||||
input: (t) => ({ ...inProjectOf(t), documentId: SOMEWHERE, revision: "1", legalHold: true, requestId: SOMEWHERE }),
|
||||
},
|
||||
"archive.document.dispose": {
|
||||
input: (t) => ({ ...inProjectOf(t), documentId: SOMEWHERE, revision: "1", requestId: SOMEWHERE }),
|
||||
},
|
||||
"archive.retention.list": { input: (t) => inProjectOf(t) },
|
||||
"archive.retention.create": {
|
||||
input: (t) => ({ ...inProjectOf(t), code: "intruder", keepFor: "P1Y", from: "created", requestId: SOMEWHERE }),
|
||||
},
|
||||
"archive.disposal.list": { input: (t) => inProjectOf(t) },
|
||||
}
|
||||
|
||||
/** The shape the suite reads (`modules/notes/__tests__/isolation.ts` names it), so no other module is imported. */
|
||||
export const isolation: {
|
||||
seed(
|
||||
call: TransportCaller,
|
||||
inProject: { org: string; workspace: string; project: string },
|
||||
): Promise<Record<string, string>>
|
||||
fixtures: Record<string, IsolationFixture>
|
||||
} = {
|
||||
seed: async () => ({}),
|
||||
fixtures: businessModulesOn.includes("archive") ? archiveIsolationFixtures : {},
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
// The archive procedures end to end: the caller, the tenancy resolver, the module and the real generated
|
||||
// client against the business-services fake -- what a member sees, what the service gets, and each
|
||||
// refusal as the typed state a page shows.
|
||||
|
||||
import { describeBusinessRefusal } from "@conusai-kit/business/refusals"
|
||||
import type { AppError } from "@conusai-kit/core"
|
||||
import { entitiesOf } from "@conusai-kit/data"
|
||||
import { TENANCY_ALSO_CHECK, tenancyIsolationExempt, tenancyIsolationFixtures } from "@conusai-kit/tenancy/testing"
|
||||
import { type IsolationTenant, runIsolation, snapshotOf } from "@conusai-kit/testing"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { sdkArchiveService } from "../adapters/service.js"
|
||||
import { requestKey } from "../core/archive.js"
|
||||
import { archiveApp, type Tenant } from "./app.js"
|
||||
import { archiveIsolationFixtures } from "./isolation.js"
|
||||
|
||||
const refusal = async (promise: Promise<unknown>): Promise<AppError> => {
|
||||
try {
|
||||
await promise
|
||||
} catch (error) {
|
||||
return error as AppError
|
||||
}
|
||||
throw new Error("the call was not refused")
|
||||
}
|
||||
|
||||
type App = ReturnType<typeof archiveApp>
|
||||
|
||||
const retention = async (t: Tenant, code = "contracts", keepFor = "P10Y") =>
|
||||
t.call.callByName("archive.retention.create", {
|
||||
...t.slugs,
|
||||
code,
|
||||
keepFor,
|
||||
from: "created",
|
||||
requestId: crypto.randomUUID(),
|
||||
})
|
||||
|
||||
const register = async (app: App, t: Tenant, title = "Signed contract", retentionClass = "contracts") =>
|
||||
(await t.call.callByName("archive.document.register", {
|
||||
...t.slugs,
|
||||
title,
|
||||
retentionClass,
|
||||
fileId: await app.storedFile(t),
|
||||
requestId: crypto.randomUUID(),
|
||||
})) as { documentId: string; revision: string }
|
||||
|
||||
describe("the archive procedures", () => {
|
||||
it("accepts incidental date-like digits in either UUID component without merging project keys", async () => {
|
||||
const app = archiveApp()
|
||||
const service = sdkArchiveService({ client: app.client, orgSlug: app.fake.orgSlug })
|
||||
const incidental = "0195a3c4-e12a-7b3f-8a1b-39001010000f"
|
||||
const ordinary = "0195a3c4-e12a-7b3f-8a1b-aaaaaaaaaaaa"
|
||||
const first = { code: "first", keepFor: "P10Y", from: "created" as const }
|
||||
const key = requestKey({ projectId: incidental }, ordinary)
|
||||
expect(key).toBe(`app-project:${incidental}:${ordinary}`)
|
||||
await service.createRetentionClass({ ...first, key })
|
||||
await service.createRetentionClass({ ...first, key })
|
||||
await service.createRetentionClass({
|
||||
code: "second",
|
||||
keepFor: "P10Y",
|
||||
from: "created",
|
||||
key: requestKey({ projectId: ordinary }, incidental),
|
||||
})
|
||||
await service.createRetentionClass({
|
||||
code: "third",
|
||||
keepFor: "P10Y",
|
||||
from: "created",
|
||||
key: requestKey({ projectId: ordinary }, ordinary),
|
||||
})
|
||||
expect(app.fake.archive.retentionClasses.size).toBe(3)
|
||||
})
|
||||
|
||||
it("accepts a request UUID with incidental date-like digits and replays its receipt", async () => {
|
||||
const app = archiveApp()
|
||||
const t = await app.tenant("alpha")
|
||||
const request = {
|
||||
...t.slugs,
|
||||
code: "contracts",
|
||||
keepFor: "P10Y",
|
||||
from: "created",
|
||||
requestId: "0195a3c4-e12a-7b3f-8a1b-39001010000f",
|
||||
}
|
||||
expect(await t.call.callByName("archive.retention.create", request)).toEqual({ code: "contracts" })
|
||||
expect(await t.call.callByName("archive.retention.create", request)).toEqual({ code: "contracts" })
|
||||
expect(app.fake.archive.retentionClasses.size).toBe(1)
|
||||
})
|
||||
|
||||
it("register a document once per request, list it, and read it with its versions", async () => {
|
||||
const app = archiveApp()
|
||||
const t = await app.tenant("alpha")
|
||||
await retention(t)
|
||||
const fileId = await app.storedFile(t)
|
||||
const request = {
|
||||
...t.slugs,
|
||||
title: "Signed contract",
|
||||
kind: "380",
|
||||
retentionClass: "contracts",
|
||||
fileId,
|
||||
requestId: crypto.randomUUID(),
|
||||
}
|
||||
const receipt = (await t.call.callByName("archive.document.register", request)) as {
|
||||
documentId: string
|
||||
revision: string
|
||||
}
|
||||
expect(receipt.revision).toBe("1")
|
||||
expect(await t.call.callByName("archive.document.register", request)).toEqual(receipt)
|
||||
expect(app.fake.archive.documents.size).toBe(1)
|
||||
const listed = await t.call.callByName("archive.document.list", t.slugs)
|
||||
expect(listed).toMatchObject({ rows: [{ documentId: receipt.documentId, title: "Signed contract", kind: "380" }] })
|
||||
|
||||
const read = await t.call.callByName("archive.document.get", { ...t.slugs, documentId: receipt.documentId })
|
||||
expect(read).toMatchObject({
|
||||
document: { status: "active", retentionClass: "contracts", legalHold: false, revision: "1", disposed: false },
|
||||
versions: [{ sequence: 1, sha256: "a".repeat(64), mediaType: "application/pdf", size: "1234" }],
|
||||
})
|
||||
expect(JSON.parse(JSON.stringify(read))).toEqual(read)
|
||||
const [version] = [...app.fake.archive.versions.values()]
|
||||
expect(version?.blob_ref).toBe(`conusai://blob/files/${fileId}`)
|
||||
})
|
||||
|
||||
it("append a version over the revision it read, and refuse a stale one", async () => {
|
||||
const app = archiveApp()
|
||||
const t = await app.tenant("alpha")
|
||||
await retention(t)
|
||||
const { documentId } = await register(app, t)
|
||||
const added = await t.call.callByName("archive.version.add", {
|
||||
...t.slugs,
|
||||
documentId,
|
||||
revision: "1",
|
||||
fileId: await app.storedFile(t, "b".repeat(64), 99),
|
||||
requestId: crypto.randomUUID(),
|
||||
})
|
||||
expect(added).toEqual({ documentId, revision: "2" })
|
||||
const stale = await refusal(
|
||||
t.call.callByName("archive.version.add", {
|
||||
...t.slugs,
|
||||
documentId,
|
||||
revision: "1",
|
||||
fileId: await app.storedFile(t, "c".repeat(64), 7),
|
||||
requestId: crypto.randomUUID(),
|
||||
}),
|
||||
)
|
||||
expect(stale.problem).toMatchObject({ status: 409, code: "business_conflict" })
|
||||
const read = (await t.call.callByName("archive.document.get", { ...t.slugs, documentId })) as {
|
||||
versions: Array<{ sequence: number; size: string }>
|
||||
}
|
||||
expect(read.versions.map((version) => [version.sequence, version.size])).toEqual([
|
||||
[1, "1234"],
|
||||
[2, "99"],
|
||||
])
|
||||
})
|
||||
|
||||
it("keep retention: a class, a hold that blocks disposal, the due list, and disposal when it has ended", async () => {
|
||||
let now = new Date("2026-11-02T10:00:00Z").getTime()
|
||||
const app = archiveApp({ clock: () => new Date(now) })
|
||||
const t = await app.tenant("alpha")
|
||||
await retention(t, "short", "P1D")
|
||||
expect(await t.call.callByName("archive.retention.list", t.slugs)).toMatchObject({
|
||||
rows: [{ code: "short", keepFor: "P1D", from: "created" }],
|
||||
})
|
||||
const { documentId } = await register(app, t, "Delivery note", "short")
|
||||
const request = () => ({ ...t.slugs, documentId, requestId: crypto.randomUUID() })
|
||||
|
||||
const early = await refusal(t.call.callByName("archive.document.dispose", { ...request(), revision: "1" }))
|
||||
expect(early.problem).toMatchObject({ status: 409, code: "business_retention_active" })
|
||||
|
||||
expect(await t.call.callByName("archive.document.hold", { ...request(), revision: "1", legalHold: true })).toEqual({
|
||||
documentId,
|
||||
revision: "2",
|
||||
})
|
||||
now += 2 * 24 * 3600 * 1000
|
||||
expect(await t.call.callByName("archive.disposal.list", t.slugs)).toEqual({ rows: [] })
|
||||
const held = await refusal(t.call.callByName("archive.document.dispose", { ...request(), revision: "2" }))
|
||||
expect(held.problem).toMatchObject({ status: 409, code: "business_legal_hold" })
|
||||
// The person reads the kit's sentence for the refusal, in their language.
|
||||
expect(describeBusinessRefusal(held, "lt")).toBe(
|
||||
"Šiam įrašui taikomas teisinis sulaikymas, todėl jo keisti ar ištrinti negalima.",
|
||||
)
|
||||
|
||||
await t.call.callByName("archive.document.hold", { ...request(), revision: "2", legalHold: false })
|
||||
expect(await t.call.callByName("archive.disposal.list", t.slugs)).toMatchObject({ rows: [{ documentId }] })
|
||||
await t.call.callByName("archive.document.dispose", { ...request(), revision: "3" })
|
||||
const read = await t.call.callByName("archive.document.get", { ...t.slugs, documentId })
|
||||
expect(read).toMatchObject({ document: { status: "disposed", disposed: true } })
|
||||
})
|
||||
|
||||
it("show the service's refusals as the kit's codes", async () => {
|
||||
const app = archiveApp()
|
||||
const t = await app.tenant("alpha")
|
||||
const missing = await refusal(t.call.callByName("archive.document.get", { ...t.slugs, documentId: "nobody" }))
|
||||
expect(missing.problem).toMatchObject({ status: 404, code: "business_not_found" })
|
||||
const noClass = await refusal(register(app, t, "Signed contract", "nothing"))
|
||||
expect(noClass.problem).toMatchObject({ status: 404, code: "business_not_found" })
|
||||
const bad = await refusal(
|
||||
t.call.callByName("archive.retention.create", {
|
||||
...t.slugs,
|
||||
code: "bad",
|
||||
keepFor: "ten years",
|
||||
from: "created",
|
||||
requestId: crypto.randomUUID(),
|
||||
}),
|
||||
)
|
||||
expect(bad.problem).toMatchObject({ status: 400, code: "business_invalid" })
|
||||
})
|
||||
|
||||
it("answer a disabled service, a missing scope and a silent service with the platform's own codes", async () => {
|
||||
const app = archiveApp({ archive: false })
|
||||
const t = await app.tenant("alpha")
|
||||
const disabled = await refusal(t.call.callByName("archive.document.list", t.slugs))
|
||||
expect(disabled.problem).toMatchObject({ status: 404, code: "service_not_enabled" })
|
||||
app.fake.enable("archive")
|
||||
app.fake.revoke("archive.retention:write")
|
||||
const scoped = await refusal(retention(t))
|
||||
expect(scoped.problem).toMatchObject({ status: 403, code: "service_permission_missing" })
|
||||
expect(scoped.problem.detail).not.toContain("archive.retention:write")
|
||||
app.fake.refuseNext(503)
|
||||
const silent = await refusal(t.call.callByName("archive.document.list", t.slugs))
|
||||
expect(silent.problem).toMatchObject({ status: 503, code: "business_unavailable" })
|
||||
})
|
||||
|
||||
it("let a viewer read and refuse their writes before the service is called", async () => {
|
||||
const app = archiveApp()
|
||||
const t = await app.tenant("alpha")
|
||||
await retention(t)
|
||||
const { documentId } = await register(app, t)
|
||||
const viewer = await app.member(t, "viewer@alpha.test", "viewer")
|
||||
expect(await viewer.callByName("archive.document.list", t.slugs)).toMatchObject({ rows: [{ documentId }] })
|
||||
const before = app.fake.calls.length
|
||||
// A member without the permission is answered as a non-member is (kit-core's caller, spec §3.3).
|
||||
for (const [name, input] of [
|
||||
[
|
||||
"archive.document.register",
|
||||
{ title: "No", retentionClass: "contracts", fileId: await app.storedFile(t), requestId: crypto.randomUUID() },
|
||||
],
|
||||
["archive.document.hold", { documentId, revision: "1", legalHold: true, requestId: crypto.randomUUID() }],
|
||||
["archive.document.dispose", { documentId, revision: "1", requestId: crypto.randomUUID() }],
|
||||
["archive.retention.create", { code: "no", keepFor: "P1Y", from: "created", requestId: crypto.randomUUID() }],
|
||||
] as const) {
|
||||
const refused = await refusal(viewer.callByName(name, { ...t.slugs, ...input }))
|
||||
expect(refused.problem.status, name).toBe(404)
|
||||
}
|
||||
expect(app.fake.calls.length).toBe(before)
|
||||
})
|
||||
|
||||
it("let an editor register and keep retention to an administrator", async () => {
|
||||
const app = archiveApp()
|
||||
const t = await app.tenant("alpha")
|
||||
await retention(t)
|
||||
const { documentId } = await register(app, t)
|
||||
const editor = await app.member(t, "editor@alpha.test", "member")
|
||||
const registered = await editor.callByName("archive.document.register", {
|
||||
...t.slugs,
|
||||
title: "Editor's document",
|
||||
retentionClass: "contracts",
|
||||
fileId: await app.storedFile(t, "d".repeat(64)),
|
||||
requestId: crypto.randomUUID(),
|
||||
})
|
||||
expect(registered).toMatchObject({ revision: "1" })
|
||||
const refused = await refusal(
|
||||
editor.callByName("archive.document.hold", {
|
||||
...t.slugs,
|
||||
documentId,
|
||||
revision: "1",
|
||||
legalHold: true,
|
||||
requestId: crypto.randomUUID(),
|
||||
}),
|
||||
)
|
||||
expect(refused.problem.status).toBe(404)
|
||||
})
|
||||
|
||||
it("need no scope beyond the ones `kit module:on archive` asks for", async () => {
|
||||
let now = new Date("2026-11-02T10:00:00Z").getTime()
|
||||
const app = archiveApp({
|
||||
clock: () => new Date(now),
|
||||
scopes: [
|
||||
"archive.documents:read",
|
||||
"archive.documents:write",
|
||||
"archive.holds:write",
|
||||
"archive.retention:read",
|
||||
"archive.retention:write",
|
||||
"archive.disposal:read",
|
||||
"archive.disposal:write",
|
||||
],
|
||||
})
|
||||
const t = await app.tenant("alpha")
|
||||
await retention(t, "short", "P1D")
|
||||
const { documentId } = await register(app, t, "Delivery note", "short")
|
||||
await t.call.callByName("archive.version.add", {
|
||||
...t.slugs,
|
||||
documentId,
|
||||
revision: "1",
|
||||
fileId: await app.storedFile(t, "b".repeat(64)),
|
||||
requestId: crypto.randomUUID(),
|
||||
})
|
||||
await t.call.callByName("archive.document.list", t.slugs)
|
||||
await t.call.callByName("archive.document.get", { ...t.slugs, documentId })
|
||||
await t.call.callByName("archive.retention.list", t.slugs)
|
||||
const request = () => ({ ...t.slugs, documentId, requestId: crypto.randomUUID() })
|
||||
await t.call.callByName("archive.document.hold", { ...request(), revision: "2", legalHold: true })
|
||||
await t.call.callByName("archive.document.hold", { ...request(), revision: "3", legalHold: false })
|
||||
now += 2 * 24 * 3600 * 1000
|
||||
await t.call.callByName("archive.disposal.list", t.slugs)
|
||||
await t.call.callByName("archive.document.dispose", { ...request(), revision: "4" })
|
||||
})
|
||||
|
||||
it("refuse another organization's file: a document cannot be made of a record its project does not hold", async () => {
|
||||
const app = archiveApp()
|
||||
const alpha = await app.tenant("alpha")
|
||||
const bravo = await app.tenant("bravo")
|
||||
await retention(bravo)
|
||||
const theirs = await app.storedFile(alpha)
|
||||
const before = app.fake.calls.length
|
||||
const refused = await refusal(
|
||||
bravo.call.callByName("archive.document.register", {
|
||||
...bravo.slugs,
|
||||
title: "Not mine",
|
||||
retentionClass: "contracts",
|
||||
fileId: theirs,
|
||||
requestId: crypto.randomUUID(),
|
||||
}),
|
||||
)
|
||||
expect(refused.problem.status).toBe(404)
|
||||
expect(app.fake.calls.length).toBe(before)
|
||||
expect(app.fake.archive.documents.size).toBe(0)
|
||||
})
|
||||
|
||||
it("refuse a record of another platform organization: the token reaches only its own", async () => {
|
||||
const app = archiveApp()
|
||||
const t = await app.tenant("alpha")
|
||||
await retention(t)
|
||||
const { documentId } = await register(app, t)
|
||||
const elsewhere = sdkArchiveService({ client: app.client, orgSlug: "another-organization" })
|
||||
await expect(elsewhere.getDocument(documentId)).rejects.toMatchObject({ problem: { status: 404 } })
|
||||
})
|
||||
})
|
||||
|
||||
describe("the archive module's tenant isolation", () => {
|
||||
it("refuses every procedure called with another organization's slugs, and never reaches the service", async () => {
|
||||
const app = archiveApp()
|
||||
const tenant = async (slug: string): Promise<IsolationTenant> => {
|
||||
const t = await app.tenant(slug)
|
||||
return {
|
||||
orgSlug: t.orgSlug,
|
||||
workspaceSlug: t.workspaceSlug,
|
||||
projectSlug: t.projectSlug,
|
||||
orgId: t.orgId,
|
||||
workspaceId: t.workspaceId,
|
||||
projectId: t.projectId,
|
||||
userId: t.owner.userId,
|
||||
email: `owner@${slug}.test`,
|
||||
extra: {},
|
||||
}
|
||||
}
|
||||
const a = await tenant("alpha")
|
||||
const b = await tenant("bravo")
|
||||
const result = await runIsolation({
|
||||
registry: app.registry,
|
||||
seed: async () => ({ a, b }),
|
||||
caller: (actor) => app.caller(actor),
|
||||
snapshot: () => snapshotOf(app.data, entitiesOf(app.registry, "postgres"), { outbox: app.data.outbox }),
|
||||
fixtures: { ...tenancyIsolationFixtures(), ...archiveIsolationFixtures },
|
||||
alsoCheck: [...TENANCY_ALSO_CHECK],
|
||||
exempt: tenancyIsolationExempt,
|
||||
})
|
||||
expect(result.failures).toEqual([])
|
||||
for (const name of Object.keys(archiveIsolationFixtures)) expect(result.passed, name).toContain(name)
|
||||
expect(app.fake.calls).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,224 @@
|
||||
// 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"] },
|
||||
}))
|
||||
Reference in New Issue
Block a user