@@ -0,0 +1,62 @@
|
||||
import { Container, type TenantScope } from "@conusai-kit/core"
|
||||
import { createMemoryDataSource, DATA_SOURCE, SYSTEM_DATA } from "@conusai-kit/data"
|
||||
import { accountData, projects } from "@conusai-kit/tenancy"
|
||||
import { expect, it } from "vitest"
|
||||
import { custodyModule } from "../../custody.js"
|
||||
import { knowledgeAccountData } from "../adapters/account.js"
|
||||
import { autoIndex, KNOWLEDGE_ENTITIES } from "../contract/index.js"
|
||||
import { knowledgeModule } from "../module.js"
|
||||
|
||||
const subject = "01900000-0000-7000-0000-000000000009"
|
||||
async function fixture() {
|
||||
const data = createMemoryDataSource({ entities: [projects, ...KNOWLEDGE_ENTITIES] })
|
||||
const container = new Container()
|
||||
container.value(DATA_SOURCE, data)
|
||||
container.value(SYSTEM_DATA, data)
|
||||
const scopes: TenantScope[] = []
|
||||
for (const n of [1, 2, 3]) {
|
||||
const orgId = `01900000-0000-7000-0000-00000000000${n}`
|
||||
const workspaceId = `01900000-0000-7000-0000-00000000001${n}`
|
||||
const actor = { userId: subject }
|
||||
const project = await data
|
||||
.forScope({ level: "workspace", orgId, workspaceId, actor })
|
||||
.of(projects)
|
||||
.insert({ slug: "site", name: "Site", moduleKey: "notes", archivedAt: null })
|
||||
const scope: TenantScope = { level: "project", orgId, workspaceId, projectId: project.id, actor }
|
||||
scopes.push(scope)
|
||||
await data
|
||||
.forScope(scope)
|
||||
.of(autoIndex)
|
||||
.insert({
|
||||
collectionName: "contracts",
|
||||
enabledBy: n === 3 ? "another-person" : subject,
|
||||
enabledAt: "2026-11-11T00:00:00.000Z",
|
||||
})
|
||||
}
|
||||
return { data, container, scopes }
|
||||
}
|
||||
|
||||
it("exports and erases only that administrator’s opt-ins across projects, idempotently", async () => {
|
||||
const { data, container, scopes } = await fixture()
|
||||
const exported = await knowledgeAccountData.export?.({ subject, container })
|
||||
expect(exported?.automaticIndexing).toHaveLength(2)
|
||||
await knowledgeAccountData.erase?.({ subject, container })
|
||||
expect(await knowledgeAccountData.export?.({ subject, container })).toEqual({ automaticIndexing: [] })
|
||||
await knowledgeAccountData.erase?.({ subject, container })
|
||||
const otherScope = scopes[2]
|
||||
if (!otherScope) throw new Error("Missing unrelated project fixture")
|
||||
expect((await data.forScope(otherScope).of(autoIndex).list()).items).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("retains the account contribution when processing is deselected", () => {
|
||||
const retained = custodyModule(knowledgeModule)
|
||||
expect(retained.extends?.find((entry) => entry.point.id === accountData.id)?.values).toEqual([knowledgeAccountData])
|
||||
})
|
||||
|
||||
it("an aborted account operation changes no opt-in", async () => {
|
||||
const { data, container, scopes } = await fixture()
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
await expect(knowledgeAccountData.erase?.({ subject, container, signal: controller.signal })).rejects.toThrow()
|
||||
for (const scope of scopes) expect((await data.forScope(scope).of(autoIndex).list()).items).toHaveLength(1)
|
||||
})
|
||||
@@ -0,0 +1,309 @@
|
||||
// The automatic PDF intake (template lesson core-11): an uploaded PDF of an opted-in project is read by
|
||||
// the platform's document reader and indexed passage by passage into the opted-in collection, for the
|
||||
// administrator who turned it on, whose permission is checked again before each step that leaves the
|
||||
// process. A project that is not opted in, or has knowledge off, reads nothing; removing the document
|
||||
// tombstones every passage it indexed, even once the file itself is gone.
|
||||
import type { TenantScope } from "@conusai-kit/core"
|
||||
import { createMemoryDataSource } from "@conusai-kit/data"
|
||||
import { DEFAULT_FILES_POLICY, files, filesProcessors, sha256Hex } from "@conusai-kit/files"
|
||||
import { fakeObjectStore } from "@conusai-kit/testing"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import {
|
||||
AUTO_INDEX_JOB,
|
||||
AUTO_INDEX_PROCESSOR_KEY,
|
||||
autoIndex,
|
||||
collections,
|
||||
documents,
|
||||
KNOWLEDGE_ENTITIES,
|
||||
passageSets,
|
||||
} from "../contract/index.js"
|
||||
import { autoIndexUpload } from "../core/auto-index-job.js"
|
||||
import { removeDocument } from "../core/index-job.js"
|
||||
import type {
|
||||
DocumentAiPlatform,
|
||||
KnowledgeDeps,
|
||||
KnowledgePassage,
|
||||
KnowledgePassageIndex,
|
||||
KnowledgeReader,
|
||||
} from "../core/ports.js"
|
||||
import { knowledgeModule } from "../module.js"
|
||||
|
||||
const ADMIN = "01900000-0000-7000-0000-000000000009"
|
||||
const UPLOADER = "01900000-0000-7000-0000-000000000010"
|
||||
const scope: TenantScope = {
|
||||
level: "project",
|
||||
actor: { userId: "system:knowledge.autoindex", system: true },
|
||||
orgId: "01900000-0000-7000-0000-000000000001",
|
||||
workspaceId: "01900000-0000-7000-0000-000000000002",
|
||||
projectId: "01900000-0000-7000-0000-000000000003",
|
||||
}
|
||||
const pdf = new TextEncoder().encode("%PDF-1.7 synthetic")
|
||||
const read: readonly KnowledgePassage[] = [
|
||||
{ page: 1, index: 0, blockIds: ["b1", "b2"], text: "First page, first passage" },
|
||||
{ page: 1, index: 1, blockIds: ["b3"], text: "First page, second passage" },
|
||||
{ page: 3, index: 0, blockIds: [], text: "Third page" },
|
||||
]
|
||||
|
||||
function makeDeps(
|
||||
options: { enabled?: boolean; reader?: KnowledgeReader; indexError?: Error; enablement?: DocumentAiPlatform } = {},
|
||||
) {
|
||||
const data = createMemoryDataSource({ entities: [...KNOWLEDGE_ENTITIES, files] })
|
||||
const store = fakeObjectStore()
|
||||
const calls = {
|
||||
reads: [] as Array<{ filename: string; idempotencyKey: string }>,
|
||||
indexed: [] as Array<{ collectionName: string; fileId: string; passages: readonly KnowledgePassage[] }>,
|
||||
removed: [] as Array<{ documentRef: string; passages: ReadonlyArray<{ page: number; index: number }> }>,
|
||||
admitted: [] as string[],
|
||||
}
|
||||
const reader: KnowledgeReader = options.reader ?? {
|
||||
async read(bytes, input) {
|
||||
expect(bytes).toEqual(pdf)
|
||||
calls.reads.push({ filename: input.filename, idempotencyKey: input.idempotencyKey })
|
||||
return read
|
||||
},
|
||||
}
|
||||
const passages: KnowledgePassageIndex = {
|
||||
async reference(_scope, fileId) {
|
||||
return `org/${scope.orgId}/${fileRow.storageKey}?${fileId}`
|
||||
},
|
||||
async index(_scope, input) {
|
||||
expect(input.documentRef).toBe(`org/${scope.orgId}/${fileRow.storageKey}?${input.fileId}`)
|
||||
calls.indexed.push({ collectionName: input.collectionName, fileId: input.fileId, passages: input.passages })
|
||||
if (options.indexError) throw options.indexError
|
||||
},
|
||||
async remove(_scope, input) {
|
||||
calls.removed.push({ documentRef: input.documentRef, passages: input.passages })
|
||||
},
|
||||
}
|
||||
const deps: KnowledgeDeps = {
|
||||
data,
|
||||
files: { data, store, policy: DEFAULT_FILES_POLICY },
|
||||
flags: { enabled: async () => options.enabled ?? true },
|
||||
reader,
|
||||
passages,
|
||||
enablement: options.enablement ?? {
|
||||
checkEligibility: async () => ({ ok: true }),
|
||||
indexingQuota: async () => 1000,
|
||||
},
|
||||
beforeIndex: async (subject) => {
|
||||
calls.admitted.push(subject)
|
||||
},
|
||||
}
|
||||
return { data, store, deps, calls }
|
||||
}
|
||||
|
||||
const fileRow = {
|
||||
status: "stored" as const,
|
||||
storageKey: `files/${scope.workspaceId}/${scope.projectId}/01900000-0000-7000-0000-000000000100`,
|
||||
filename: "contract.pdf",
|
||||
contentType: "application/pdf",
|
||||
size: pdf.length,
|
||||
sha256: "",
|
||||
uploadedBy: UPLOADER,
|
||||
storedAt: "2026-11-09T19:00:00.000Z",
|
||||
uploadId: null,
|
||||
declaredSize: null,
|
||||
partSize: null,
|
||||
deletingAt: null,
|
||||
}
|
||||
|
||||
async function seed(fixture: ReturnType<typeof makeDeps>, options: { optIn?: boolean } = {}) {
|
||||
const sha256 = await sha256Hex(pdf)
|
||||
await fixture.data.forScope(scope).of(collections).insert({ name: "contracts", createdBy: ADMIN })
|
||||
if (options.optIn ?? true)
|
||||
await fixture.data
|
||||
.forScope(scope)
|
||||
.of(autoIndex)
|
||||
.insert({ collectionName: "contracts", enabledBy: ADMIN, enabledAt: "2026-11-09T18:00:00.000Z" })
|
||||
const file = await fixture.data
|
||||
.forScope(scope)
|
||||
.of(files)
|
||||
.insert({ ...fileRow, sha256 })
|
||||
await fixture.store.put(scope, { key: fileRow.storageKey, body: pdf, contentType: "application/pdf" })
|
||||
return { fileId: file.id, storageKey: fileRow.storageKey, contentType: "application/pdf", sha256 }
|
||||
}
|
||||
|
||||
/** The run's document, which these cases expect there to be. */
|
||||
async function indexedView<T>(view: T | null): Promise<T> {
|
||||
if (view === null) throw new Error("the upload made no document")
|
||||
return view
|
||||
}
|
||||
|
||||
describe("the automatic PDF intake", () => {
|
||||
it("reads an opted-in project's PDF and indexes its passages into the collection, for the administrator who opted in", async () => {
|
||||
const fixture = makeDeps()
|
||||
const upload = await seed(fixture)
|
||||
|
||||
const done = await autoIndexUpload(fixture.deps, scope, upload)
|
||||
|
||||
expect(done).toMatchObject({ collectionName: "contracts", fileId: upload.fileId, status: "indexed" })
|
||||
expect(fixture.calls.reads).toEqual([
|
||||
{ filename: "contract.pdf", idempotencyKey: `knowledge.autoindex.${upload.fileId}.${upload.sha256}` },
|
||||
])
|
||||
expect(fixture.calls.indexed).toEqual([{ collectionName: "contracts", fileId: upload.fileId, passages: read }])
|
||||
// The job runs for nobody else: the administrator's permission is what each step leaving the process checks.
|
||||
expect(fixture.calls.admitted.length).toBeGreaterThan(0)
|
||||
expect(new Set(fixture.calls.admitted)).toEqual(new Set([ADMIN]))
|
||||
const [row] = (await fixture.data.forScope(scope).of(documents).list({ limit: 10 })).items
|
||||
expect(row).toMatchObject({ filename: "contract.pdf", requestedBy: ADMIN, status: "indexed", error: null })
|
||||
// What removal needs, kept beside the document: the file's reference and the passages per page.
|
||||
const [set] = (await fixture.data.forScope(scope).of(passageSets).list({ limit: 10 })).items
|
||||
expect(set).toMatchObject({
|
||||
collectionName: "contracts",
|
||||
fileId: upload.fileId,
|
||||
documentRef: `org/${scope.orgId}/${fileRow.storageKey}?${upload.fileId}`,
|
||||
pages: "2,0,1",
|
||||
})
|
||||
})
|
||||
|
||||
it("is the same work once: a redelivered job reads and indexes nothing again", async () => {
|
||||
const fixture = makeDeps()
|
||||
const upload = await seed(fixture)
|
||||
await autoIndexUpload(fixture.deps, scope, upload)
|
||||
expect(await autoIndexUpload(fixture.deps, scope, upload)).toMatchObject({ status: "indexed" })
|
||||
expect(fixture.calls.reads).toHaveLength(1)
|
||||
expect(fixture.calls.indexed).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("reads nothing for a project that is not opted in, has knowledge off, or a file that is not a PDF", async () => {
|
||||
const notOptedIn = makeDeps()
|
||||
expect(await autoIndexUpload(notOptedIn.deps, scope, await seed(notOptedIn, { optIn: false }))).toBeNull()
|
||||
|
||||
const off = makeDeps({ enabled: false })
|
||||
expect(await autoIndexUpload(off.deps, scope, await seed(off))).toBeNull()
|
||||
|
||||
const text = makeDeps()
|
||||
const upload = await seed(text)
|
||||
expect(await autoIndexUpload(text.deps, scope, { ...upload, contentType: "text/plain" })).toBeNull()
|
||||
|
||||
for (const fixture of [notOptedIn, off, text]) {
|
||||
expect(fixture.calls.reads).toEqual([])
|
||||
expect((await fixture.data.forScope(scope).of(documents).list({ limit: 10 })).items).toEqual([])
|
||||
}
|
||||
})
|
||||
|
||||
it("reads nothing once the administrator's permission is gone: the refusal is the job's", async () => {
|
||||
const fixture = makeDeps()
|
||||
const upload = await seed(fixture)
|
||||
const refused = Object.assign(new Error("permission_missing"), { status: 403 })
|
||||
const deps: KnowledgeDeps = {
|
||||
...fixture.deps,
|
||||
beforeIndex: async () => {
|
||||
throw refused
|
||||
},
|
||||
}
|
||||
await expect(autoIndexUpload(deps, scope, upload)).rejects.toBe(refused)
|
||||
expect(fixture.calls.reads).toEqual([])
|
||||
expect(fixture.calls.indexed).toEqual([])
|
||||
})
|
||||
|
||||
it("records a PDF the reader cannot make passages of as failed, with a stable code", async () => {
|
||||
const fixture = makeDeps({
|
||||
reader: {
|
||||
read: async () => {
|
||||
throw Object.assign(new Error("block id over 256 characters"), { permanent: "not_indexable" })
|
||||
},
|
||||
},
|
||||
})
|
||||
const upload = await seed(fixture)
|
||||
expect(await autoIndexUpload(fixture.deps, scope, upload)).toMatchObject({
|
||||
status: "failed",
|
||||
error: "not_indexable",
|
||||
})
|
||||
expect(fixture.calls.indexed).toEqual([])
|
||||
})
|
||||
|
||||
it("keeps the passage layout of an index the service refused partway, so removal still tombstones it", async () => {
|
||||
const fixture = makeDeps({ indexError: Object.assign(new Error("quota"), { permanent: "quota_exhausted" }) })
|
||||
const upload = await seed(fixture)
|
||||
const failed = await autoIndexUpload(fixture.deps, scope, upload)
|
||||
expect(failed).toMatchObject({ status: "failed", error: "quota_exhausted" })
|
||||
await removeDocument(fixture.deps, scope, (await indexedView(failed)).documentId)
|
||||
expect(fixture.calls.removed.map((call) => call.passages.length)).toEqual([3])
|
||||
expect((await fixture.data.forScope(scope).of(passageSets).list({ limit: 10 })).items).toEqual([])
|
||||
})
|
||||
|
||||
it("reads nothing once the organization is no longer eligible or its indexing quota is spent", async () => {
|
||||
const ineligible = makeDeps({
|
||||
enablement: {
|
||||
checkEligibility: async () => ({
|
||||
ok: false,
|
||||
reason: "feature_disabled",
|
||||
detail: "the agent_document_tools flag is off",
|
||||
}),
|
||||
indexingQuota: async () => 1000,
|
||||
},
|
||||
})
|
||||
expect(await autoIndexUpload(ineligible.deps, scope, await seed(ineligible))).toMatchObject({
|
||||
status: "failed",
|
||||
error: "feature_disabled",
|
||||
})
|
||||
|
||||
const spent = makeDeps({
|
||||
enablement: { checkEligibility: async () => ({ ok: true }), indexingQuota: async () => 0 },
|
||||
})
|
||||
expect(await autoIndexUpload(spent.deps, scope, await seed(spent))).toMatchObject({
|
||||
status: "failed",
|
||||
error: "quota_exhausted",
|
||||
})
|
||||
|
||||
for (const fixture of [ineligible, spent]) {
|
||||
expect(fixture.calls.reads).toEqual([])
|
||||
expect(fixture.calls.indexed).toEqual([])
|
||||
}
|
||||
})
|
||||
|
||||
it("refuses to run where the enablement platform is not bound, rather than skip its checks", async () => {
|
||||
const fixture = makeDeps()
|
||||
const upload = await seed(fixture)
|
||||
const { enablement: _unbound, ...deps } = fixture.deps
|
||||
await expect(autoIndexUpload(deps, scope, upload)).rejects.toThrow("enablement")
|
||||
expect(fixture.calls.reads).toEqual([])
|
||||
})
|
||||
|
||||
it("does not read a file whose bytes changed since the upload", async () => {
|
||||
const fixture = makeDeps()
|
||||
const upload = await seed(fixture)
|
||||
expect(await autoIndexUpload(fixture.deps, scope, { ...upload, sha256: "0".repeat(64) })).toBeNull()
|
||||
expect(fixture.calls.reads).toEqual([])
|
||||
})
|
||||
|
||||
it("tombstones every passage it indexed when the document is removed, even after the file is gone", async () => {
|
||||
const fixture = makeDeps()
|
||||
const upload = await seed(fixture)
|
||||
const done = await autoIndexUpload(fixture.deps, scope, upload)
|
||||
await fixture.data.forScope(scope).of(files).delete(upload.fileId)
|
||||
|
||||
await removeDocument(fixture.deps, scope, (await indexedView(done)).documentId)
|
||||
|
||||
expect(fixture.calls.removed).toEqual([
|
||||
{
|
||||
documentRef: `org/${scope.orgId}/${fileRow.storageKey}?${upload.fileId}`,
|
||||
passages: [
|
||||
{ page: 1, index: 0 },
|
||||
{ page: 1, index: 1 },
|
||||
{ page: 3, index: 0 },
|
||||
],
|
||||
},
|
||||
])
|
||||
expect((await fixture.data.forScope(scope).of(documents).list({ limit: 10 })).items).toEqual([])
|
||||
expect((await fixture.data.forScope(scope).of(passageSets).list({ limit: 10 })).items).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("the intake's place in the manifest", () => {
|
||||
it("contributes a files processor for PDFs whose job the module runs", () => {
|
||||
const contributions = (knowledgeModule.extends ?? []).filter((entry) => entry.point.id === filesProcessors.id)
|
||||
expect(contributions.flatMap((entry) => entry.values)).toEqual([
|
||||
{ key: AUTO_INDEX_PROCESSOR_KEY, contentTypes: ["application/pdf"], job: AUTO_INDEX_JOB },
|
||||
])
|
||||
expect(Object.keys(knowledgeModule.jobs ?? {})).toContain(AUTO_INDEX_JOB)
|
||||
})
|
||||
|
||||
it("declares the platform operations the job uses: the knowledge service's and the document reader's", () => {
|
||||
const operations = (service: string) =>
|
||||
knowledgeModule.serviceRequirements?.find((requirement) => requirement.service === service)?.operations
|
||||
expect(operations("knowledge")?.[AUTO_INDEX_JOB]).toEqual(["knowledge.collections:use"])
|
||||
expect(operations("ocr")?.[AUTO_INDEX_JOB]).toEqual(["ocr:use"])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,73 @@
|
||||
// A project's automatic PDF indexing (template lesson core-11): an administrator turns it on into an
|
||||
// existing collection, and is recorded as the one whose permission each indexing job re-checks; off
|
||||
// is no row. Another project's collection is not found, and a project with knowledge off refuses.
|
||||
import type { TenantScope } 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 { autoIndex, collections, KNOWLEDGE_ENTITIES } from "../contract/index.js"
|
||||
import { getAutoIndex, setAutoIndex } from "../core/auto-index.js"
|
||||
import type { KnowledgeDeps } from "../core/ports.js"
|
||||
|
||||
const ADMIN = "01900000-0000-7000-0000-000000000009"
|
||||
const scope: TenantScope = {
|
||||
level: "project",
|
||||
actor: { userId: ADMIN },
|
||||
orgId: "01900000-0000-7000-0000-000000000001",
|
||||
workspaceId: "01900000-0000-7000-0000-000000000002",
|
||||
projectId: "01900000-0000-7000-0000-000000000003",
|
||||
}
|
||||
const otherProject: TenantScope = { ...scope, projectId: "01900000-0000-7000-0000-000000000004" }
|
||||
|
||||
function makeDeps(enabled = true) {
|
||||
const data = createMemoryDataSource({ entities: [...KNOWLEDGE_ENTITIES, files] })
|
||||
const deps: KnowledgeDeps & { now: () => number } = {
|
||||
data,
|
||||
files: { data, store: fakeObjectStore(), policy: DEFAULT_FILES_POLICY },
|
||||
flags: { enabled: async () => enabled },
|
||||
now: () => Date.parse("2026-11-09T19:00:00.000Z"),
|
||||
}
|
||||
return { data, deps }
|
||||
}
|
||||
|
||||
describe("automatic PDF indexing of a project", () => {
|
||||
it("is off until an administrator names a collection, and records who turned it on", async () => {
|
||||
const { data, deps } = makeDeps()
|
||||
await data.forScope(scope).of(collections).insert({ name: "contracts", createdBy: ADMIN })
|
||||
expect(await getAutoIndex(deps, scope)).toEqual({ collectionName: null })
|
||||
|
||||
expect(await setAutoIndex(deps, scope, "contracts")).toEqual({ collectionName: "contracts" })
|
||||
expect(await getAutoIndex(deps, scope)).toEqual({ collectionName: "contracts" })
|
||||
const [row] = (await data.forScope(scope).of(autoIndex).list({ limit: 10 })).items
|
||||
expect(row).toMatchObject({ collectionName: "contracts", enabledBy: ADMIN, enabledAt: "2026-11-09T19:00:00.000Z" })
|
||||
// Another project of the same organization is not opted in.
|
||||
expect(await getAutoIndex(deps, otherProject)).toEqual({ collectionName: null })
|
||||
})
|
||||
|
||||
it("keeps one row per project: naming another collection moves it, and off removes it", async () => {
|
||||
const { data, deps } = makeDeps()
|
||||
for (const name of ["contracts", "invoices"])
|
||||
await data.forScope(scope).of(collections).insert({ name, createdBy: ADMIN })
|
||||
await setAutoIndex(deps, scope, "contracts")
|
||||
await setAutoIndex(deps, scope, "invoices")
|
||||
expect(
|
||||
(await data.forScope(scope).of(autoIndex).list({ limit: 10 })).items.map((row) => row.collectionName),
|
||||
).toEqual(["invoices"])
|
||||
expect(await setAutoIndex(deps, scope, null)).toEqual({ collectionName: null })
|
||||
expect((await data.forScope(scope).of(autoIndex).list({ limit: 10 })).items).toEqual([])
|
||||
// Off when already off writes nothing and answers the same.
|
||||
expect(await setAutoIndex(deps, scope, null)).toEqual({ collectionName: null })
|
||||
})
|
||||
|
||||
it("answers another project's collection as not found, and refuses while knowledge is off", async () => {
|
||||
const { data, deps } = makeDeps()
|
||||
await data.forScope(otherProject).of(collections).insert({ name: "theirs", createdBy: ADMIN })
|
||||
await expect(setAutoIndex(deps, scope, "theirs")).rejects.toMatchObject({ status: 404 })
|
||||
|
||||
const off = makeDeps(false)
|
||||
await off.data.forScope(scope).of(collections).insert({ name: "contracts", createdBy: ADMIN })
|
||||
await expect(setAutoIndex(off.deps, scope, "contracts")).rejects.toMatchObject({ status: 403 })
|
||||
expect((await off.data.forScope(scope).of(autoIndex).list({ limit: 10 })).items).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,134 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
import {
|
||||
buildRegistry,
|
||||
Container,
|
||||
contribute, // kit:guides
|
||||
type ModuleManifest,
|
||||
type TenantScope,
|
||||
} from "@conusai-kit/core"
|
||||
import { createMemoryDataSource, DATA_SOURCE, entitiesOf, JOB_QUEUE, OBJECT_STORE } from "@conusai-kit/data"
|
||||
import * as guidesKit from "@conusai-kit/guides" // kit:guides
|
||||
import { projects, workspaces } from "@conusai-kit/tenancy"
|
||||
import { fakeObjectStore } from "@conusai-kit/testing"
|
||||
import { expect, it } from "vitest"
|
||||
import { custodyModule } from "../../custody.js"
|
||||
import { OPTIONAL_MODULE_IDS } from "../../inclusion.js"
|
||||
import { appModules } from "../../index.js"
|
||||
import { documents, KNOWLEDGE_ENTITIES, KNOWLEDGE_SCOPE, KNOWLEDGE_SEARCH, knowledgeModule } from "../index.js"
|
||||
|
||||
const actor = { userId: "01900000-0000-7000-0000-000000000009" }
|
||||
const orgId = "01900000-0000-7000-0000-000000000001"
|
||||
/**
|
||||
* A module as the app composes it without the opt-in guides module: its contributions to the guides extension
|
||||
* points go with it, whichever module made them (an app's own module may bring code guides too).
|
||||
*/
|
||||
function withoutGuideContributions(entry: ModuleManifest): ModuleManifest {
|
||||
return entry.extends
|
||||
? { ...entry, extends: entry.extends.filter((item) => !item.point.id.startsWith("guides.")) }
|
||||
: entry
|
||||
}
|
||||
|
||||
// kit:guides:begin
|
||||
it("drops guides contributions from every module, not only notes", () => {
|
||||
// An app's own module with a code guide: knowledge stands in for it.
|
||||
const guided: ModuleManifest = {
|
||||
...knowledgeModule,
|
||||
extends: [...(knowledgeModule.extends ?? []), contribute(guidesKit.guidesCode, [])],
|
||||
}
|
||||
expect(withoutGuideContributions(guided).extends?.filter((item) => item.point.id.startsWith("guides."))).toEqual([])
|
||||
})
|
||||
// kit:guides:end
|
||||
|
||||
it("off retains the original Knowledge rows/schema and executes only source cleanup", async () => {
|
||||
const base = appModules.filter(
|
||||
(entry) => !OPTIONAL_MODULE_IDS.some((id) => id === entry.id) && entry.id !== "assistant",
|
||||
)
|
||||
const independent = base.map(withoutGuideContributions)
|
||||
const active = buildRegistry([...independent, knowledgeModule])
|
||||
const retained = buildRegistry([...independent, custodyModule(knowledgeModule)])
|
||||
expect(retained.migrations.postgres).toEqual(active.migrations.postgres)
|
||||
expect(entitiesOf(retained, "postgres").map((entity) => entity.table)).toEqual(
|
||||
entitiesOf(active, "postgres").map((entity) => entity.table),
|
||||
)
|
||||
expect([...retained.procedures.keys()].filter((key) => key.startsWith("knowledge."))).toEqual([])
|
||||
expect(Object.keys(retained.jobs).filter((key) => key.startsWith("knowledge."))).toEqual(["knowledge.remove"])
|
||||
// The project the deleted file was in, as the organization has it: a delivery acts only in a project that exists.
|
||||
const data = createMemoryDataSource({ entities: [workspaces, projects, ...KNOWLEDGE_ENTITIES] })
|
||||
const workspace = await data
|
||||
.forScope({ level: "org", actor, orgId })
|
||||
.of(workspaces)
|
||||
.insert({ slug: "main", name: "Main", isDefault: true, visibility: "org" })
|
||||
const project = await data
|
||||
.forScope({ level: "workspace", actor, orgId, workspaceId: workspace.id })
|
||||
.of(projects)
|
||||
.insert({ slug: "site", name: "Site", moduleKey: "notes", archivedAt: null })
|
||||
const scope: TenantScope = { level: "project", actor, orgId, workspaceId: workspace.id, projectId: project.id }
|
||||
const row = await data.forScope(scope).of(documents).insert({
|
||||
collectionName: "synthetic",
|
||||
fileId: "01900000-0000-7000-0000-000000000004",
|
||||
filename: "synthetic.txt",
|
||||
status: "indexed",
|
||||
error: null,
|
||||
indexedAt: "2026-11-03T00:00:00Z",
|
||||
requestedBy: "synthetic",
|
||||
})
|
||||
const module = custodyModule(knowledgeModule)
|
||||
expect(await data.forScope(scope).of(documents).get(row.id)).toEqual(row)
|
||||
const container = new Container()
|
||||
container.value(DATA_SOURCE, data)
|
||||
container.value(OBJECT_STORE, fakeObjectStore())
|
||||
container.value(KNOWLEDGE_SCOPE, scope)
|
||||
const enqueued: string[] = []
|
||||
container.value(JOB_QUEUE, {
|
||||
enqueue: async (_scope, job) => {
|
||||
enqueued.push(job)
|
||||
return "synthetic-job"
|
||||
},
|
||||
})
|
||||
const deletion = module.events?.subscribes?.find((entry) => entry.event.name === "files.FileDeleted")
|
||||
if (!deletion) throw new Error("source cleanup subscription missing")
|
||||
await deletion.handler(
|
||||
{
|
||||
name: "files.FileDeleted",
|
||||
// As kit-files publishes it: the deleted file's storage key comes with the record.
|
||||
payload: {
|
||||
orgId: scope.orgId,
|
||||
workspaceId: scope.workspaceId,
|
||||
projectId: scope.projectId,
|
||||
fileId: row.fileId,
|
||||
storageKey: "synthetic/key",
|
||||
},
|
||||
},
|
||||
{ container } as never,
|
||||
)
|
||||
expect((await data.forScope(scope).of(documents).get(row.id))?.status).toBe("cancelled")
|
||||
const event = (await data.outbox.pending(10))[0]
|
||||
if (!event) throw new Error("cleanup event missing")
|
||||
const cleanup = module.events?.subscribes?.find((entry) => entry.event.name === "knowledge.documentRemoveRequested")
|
||||
if (!cleanup) throw new Error("cleanup subscription missing")
|
||||
await cleanup.handler({ name: event.name, payload: event.payload }, { container } as never)
|
||||
expect(enqueued).toEqual(["knowledge.remove"])
|
||||
expect(module.events?.subscribes?.some((entry) => entry.event.name === "knowledge.documentIndexRequested")).toBe(
|
||||
false,
|
||||
)
|
||||
const removed: string[] = []
|
||||
container.value(KNOWLEDGE_SEARCH, {
|
||||
remove: async (_scope: unknown, input: { fileId: string }) => {
|
||||
removed.push(input.fileId)
|
||||
},
|
||||
index: async () => {
|
||||
throw new Error("processing must be fenced")
|
||||
},
|
||||
search: async () => {
|
||||
throw new Error("interactive search must be fenced")
|
||||
},
|
||||
} as never)
|
||||
// The removal job resolves the original repositories and ports; no alternate store is created.
|
||||
const remove = module.jobs?.["knowledge.remove"]
|
||||
if (!remove) throw new Error("cleanup job missing")
|
||||
await remove.handler({ documentId: row.id, collectionName: row.collectionName, fileId: row.fileId }, {
|
||||
container,
|
||||
} as never)
|
||||
expect(removed).toEqual([row.fileId])
|
||||
expect(await data.forScope(scope).of(documents).get(row.id)).toBeNull()
|
||||
})
|
||||
@@ -0,0 +1,78 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// A delivery's project comes from the record, inside the delivery's organization (adapters/jobs.ts). The record's
|
||||
// workspace and project must exist in that organization before knowledge acts on them; one that names another
|
||||
// workspace's project, or one the organization does not have, is refused by name and nothing is enqueued.
|
||||
|
||||
import { Container, type EventContext, newId, type OrgScope } from "@conusai-kit/core"
|
||||
import { createMemoryDataSource, DATA_SOURCE, JOB_QUEUE } from "@conusai-kit/data"
|
||||
import { projects, workspaces } from "@conusai-kit/tenancy"
|
||||
import { fakeJobQueue } from "@conusai-kit/testing"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { KNOWLEDGE_SUBSCRIPTIONS } from "../adapters/jobs.js"
|
||||
import { KNOWLEDGE_ENTITIES, KNOWLEDGE_SCOPE } from "../index.js"
|
||||
|
||||
async function delivery(archivedAt: string | null = null) {
|
||||
const data = createMemoryDataSource({ entities: [workspaces, projects, ...KNOWLEDGE_ENTITIES] })
|
||||
const orgId = newId()
|
||||
const org: OrgScope = { level: "org", actor: { userId: newId() }, orgId }
|
||||
const workspace = await data
|
||||
.forScope(org)
|
||||
.of(workspaces)
|
||||
.insert({ slug: "main", name: "Main", isDefault: true, visibility: "org" })
|
||||
const other = await data
|
||||
.forScope(org)
|
||||
.of(workspaces)
|
||||
.insert({ slug: "other", name: "Other", isDefault: false, visibility: "org" })
|
||||
const project = await data
|
||||
.forScope({ level: "workspace", actor: org.actor, orgId, workspaceId: workspace.id })
|
||||
.of(projects)
|
||||
.insert({ slug: "site", name: "Site", moduleKey: "notes", archivedAt })
|
||||
const queue = fakeJobQueue()
|
||||
const container = new Container()
|
||||
container.value(DATA_SOURCE, data)
|
||||
container.value(JOB_QUEUE, queue)
|
||||
// The relay's delivery: the organization's scope, with a system actor.
|
||||
container.value(KNOWLEDGE_SCOPE, org)
|
||||
const ctx = { container } as unknown as EventContext
|
||||
return { ctx, queue, orgId, workspaceId: workspace.id, otherWorkspaceId: other.id, projectId: project.id }
|
||||
}
|
||||
|
||||
const indexRequested = KNOWLEDGE_SUBSCRIPTIONS[1]
|
||||
const request = (ids: { orgId: string; workspaceId: string; projectId: string }) => ({
|
||||
name: "knowledge.DocumentIndexRequested",
|
||||
payload: { ...ids, documentId: newId(), collectionName: "contracts", fileId: newId() },
|
||||
})
|
||||
|
||||
describe("a knowledge delivery acts only in a project of its organization", () => {
|
||||
it("enqueues the job in the record's project when it exists there", async () => {
|
||||
const { ctx, queue, orgId, workspaceId, projectId } = await delivery()
|
||||
await indexRequested?.handler(request({ orgId, workspaceId, projectId }) as never, ctx)
|
||||
expect(queue.of("knowledge.index")).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("refuses, by name, a workspace the organization does not have", async () => {
|
||||
const { ctx, queue, orgId, projectId } = await delivery()
|
||||
const workspaceId = newId()
|
||||
await expect(indexRequested?.handler(request({ orgId, workspaceId, projectId }) as never, ctx)).rejects.toThrow(
|
||||
new RegExp(`workspace ${workspaceId}`),
|
||||
)
|
||||
expect(queue.of("knowledge.index")).toEqual([])
|
||||
})
|
||||
|
||||
it("refuses, by name, a project that is not in the record's workspace", async () => {
|
||||
const { ctx, queue, orgId, otherWorkspaceId, projectId } = await delivery()
|
||||
await expect(
|
||||
indexRequested?.handler(request({ orgId, workspaceId: otherWorkspaceId, projectId }) as never, ctx),
|
||||
).rejects.toThrow(new RegExp(`project ${projectId}`))
|
||||
expect(queue.of("knowledge.index")).toEqual([])
|
||||
})
|
||||
|
||||
it("acts on a record of an archived project: an archive hides a project and does not end its work", async () => {
|
||||
// The kit's one rule (kit-tenancy suspension.ts): an archived project refuses a person's write, not work with
|
||||
// no person behind it. A deleted file's text must still leave the index, and an index request a member made
|
||||
// before the archive still completes.
|
||||
const { ctx, queue, orgId, workspaceId, projectId } = await delivery(new Date("2026-11-09T00:00:00Z").toISOString())
|
||||
await indexRequested?.handler(request({ orgId, workspaceId, projectId }) as never, ctx)
|
||||
expect(queue.of("knowledge.index")).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,349 @@
|
||||
// The document requests and the index job's orchestration: idempotent (collection, file) pairs,
|
||||
// the flag gate, the content-type and size admission, the row's progress states, and which
|
||||
// failures are permanent.
|
||||
|
||||
import type { TenantScope } from "@conusai-kit/core"
|
||||
import { createMemoryDataSource } from "@conusai-kit/data"
|
||||
import { DEFAULT_FILES_POLICY, files, sha256Hex } from "@conusai-kit/files"
|
||||
import { fakeObjectStore } from "@conusai-kit/testing"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { collections, documents, KNOWLEDGE_ENTITIES } from "../contract/index.js"
|
||||
import { listDocuments, requestIndex, requestRemove, requestSourceRemoval } from "../core/documents.js"
|
||||
import { indexDocument, removeDocument } from "../core/index-job.js"
|
||||
import type { KnowledgeDeps } from "../core/ports.js"
|
||||
import { searchKnowledge } from "../core/search.js"
|
||||
|
||||
const USER = "01900000-0000-7000-0000-000000000009"
|
||||
const scope: TenantScope = {
|
||||
level: "project",
|
||||
actor: { userId: USER },
|
||||
orgId: "01900000-0000-7000-0000-000000000001",
|
||||
workspaceId: "01900000-0000-7000-0000-000000000002",
|
||||
projectId: "01900000-0000-7000-0000-000000000003",
|
||||
}
|
||||
|
||||
const fileRow = {
|
||||
status: "stored" as const,
|
||||
storageKey: "k/seed",
|
||||
filename: "a.txt",
|
||||
contentType: "text/plain",
|
||||
size: 12,
|
||||
sha256: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
uploadedBy: USER,
|
||||
storedAt: "2026-10-23T00:00:00.000Z",
|
||||
uploadId: null,
|
||||
declaredSize: null,
|
||||
partSize: null,
|
||||
deletingAt: null,
|
||||
}
|
||||
|
||||
const makeDeps = (options: { flag?: boolean; search?: KnowledgeDeps["search"] } = {}) => {
|
||||
const data = createMemoryDataSource({ entities: [...KNOWLEDGE_ENTITIES, files] })
|
||||
const store = fakeObjectStore()
|
||||
const search = options.search ?? {
|
||||
readiness: async () => ({ state: "ready" as const, reasons: [] }),
|
||||
index: async () => undefined,
|
||||
search: async () => [],
|
||||
remove: async () => undefined,
|
||||
}
|
||||
const deps: KnowledgeDeps = {
|
||||
data,
|
||||
files: { data, store, policy: DEFAULT_FILES_POLICY },
|
||||
search,
|
||||
flags: { enabled: async () => options.flag ?? true },
|
||||
}
|
||||
void deps
|
||||
return { data, deps, store }
|
||||
}
|
||||
|
||||
const seed = async (data: ReturnType<typeof createMemoryDataSource>, contentType = "text/plain") => {
|
||||
await data.forScope(scope).of(collections).insert({ name: "contracts", createdBy: USER })
|
||||
const row = await data
|
||||
.forScope(scope)
|
||||
.of(files)
|
||||
.insert({ ...fileRow, ...(contentType === "text/plain" ? {} : { contentType }) })
|
||||
return row.id
|
||||
}
|
||||
|
||||
/** Seeds the file row AND the object the row names, the way a completed upload leaves both. */
|
||||
const seedWithBytes = async (
|
||||
data: ReturnType<typeof createMemoryDataSource>,
|
||||
store: ReturnType<typeof fakeObjectStore>,
|
||||
bytes: Uint8Array,
|
||||
) => {
|
||||
const fileId = await seed(data)
|
||||
await data
|
||||
.forScope(scope)
|
||||
.of(files)
|
||||
.update(fileId, { size: bytes.length, sha256: await sha256Hex(bytes) }, 1)
|
||||
await store.put(scope, { key: fileRow.storageKey, body: bytes, contentType: "text/plain" })
|
||||
return fileId
|
||||
}
|
||||
|
||||
describe("requesting a document's indexing", () => {
|
||||
it("creates the row, publishes the event, and a repeat request changes nothing", async () => {
|
||||
const { data, deps } = makeDeps()
|
||||
const fileId = await seed(data)
|
||||
const first = await requestIndex(deps, scope, { collectionName: "contracts", fileId })
|
||||
expect(first.status).toBe("queued")
|
||||
const second = await requestIndex(deps, scope, { collectionName: "contracts", fileId: first.fileId })
|
||||
expect(second.documentId).toBe(first.documentId)
|
||||
expect(await listDocuments(deps, scope, "contracts")).toEqual({
|
||||
documents: [expect.objectContaining({ documentId: first.documentId, status: "queued" })],
|
||||
})
|
||||
// One outbox event: the subscriber's enqueue is the exactly-once path.
|
||||
const outbox = data.outbox.pending(10)
|
||||
expect((await outbox).filter((record) => record.name === "knowledge.documentIndexRequested")).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("re-queues an indexed pair instead of duplicating it", async () => {
|
||||
const { data, deps } = makeDeps()
|
||||
const fileId = await seed(data)
|
||||
const first = await requestIndex(deps, scope, { collectionName: "contracts", fileId })
|
||||
await data.forScope(scope).of(documents).update(first.documentId, { status: "indexed" }, 1)
|
||||
const again = await requestIndex(deps, scope, { collectionName: "contracts", fileId })
|
||||
expect(again.documentId).toBe(first.documentId)
|
||||
expect(again.status).toBe("queued")
|
||||
})
|
||||
|
||||
it("refuses when the flag is off, and admits text files only, within the size cap", async () => {
|
||||
const { data, deps } = makeDeps({ flag: false })
|
||||
const fileId = await seed(data)
|
||||
const off = await requestIndex(deps, scope, { collectionName: "contracts", fileId }).catch((e: unknown) => e)
|
||||
expect((off as { status?: number }).status).toBe(403)
|
||||
|
||||
const refusing = makeDeps()
|
||||
const pdfFile = await seed(refusing.data, "application/pdf")
|
||||
const pdf = await requestIndex(refusing.deps, scope, { collectionName: "contracts", fileId: pdfFile }).catch(
|
||||
(e: unknown) => e,
|
||||
)
|
||||
expect((pdf as { status?: number }).status).toBe(422)
|
||||
|
||||
const big = makeDeps()
|
||||
const bigFile = await seed(big.data)
|
||||
await big.data.forScope(scope).of(files).update(bigFile, { size: 2_000_000 }, 1)
|
||||
const tooBig = await requestIndex(big.deps, scope, { collectionName: "contracts", fileId: bigFile }).catch(
|
||||
(e: unknown) => e,
|
||||
)
|
||||
expect((tooBig as { status?: number }).status).toBe(422)
|
||||
})
|
||||
})
|
||||
|
||||
describe("current retrieval authority", () => {
|
||||
it("withholds cancelled and unindexed documents even while remote deletion retries", async () => {
|
||||
const { data, deps } = makeDeps()
|
||||
const fileId = await seed(data)
|
||||
const row = await requestIndex(deps, scope, { collectionName: "contracts", fileId })
|
||||
const searching = {
|
||||
...deps,
|
||||
search: {
|
||||
...deps.search,
|
||||
readiness: async () => ({ state: "ready" as const, reasons: [] }),
|
||||
index: async () => undefined,
|
||||
remove: async () => undefined,
|
||||
search: async () => [
|
||||
{ fileId, filename: "a.txt", collectionName: "contracts", snippet: "private source", score: 1, chunk: 1 },
|
||||
],
|
||||
},
|
||||
}
|
||||
expect((await searchKnowledge(searching, scope, { collectionName: "contracts", query: "source" })).results).toEqual(
|
||||
[],
|
||||
)
|
||||
await data.forScope(scope).of(documents).update(row.documentId, { status: "indexed" }, 1)
|
||||
expect(
|
||||
(await searchKnowledge(searching, scope, { collectionName: "contracts", query: "source" })).results,
|
||||
).toHaveLength(1)
|
||||
await requestRemove(deps, scope, { collectionName: "contracts", fileId })
|
||||
expect((await searchKnowledge(searching, scope, { collectionName: "contracts", query: "source" })).results).toEqual(
|
||||
[],
|
||||
)
|
||||
// A removal a person asks for names the stored file's object, read when it is asked for: if the file is deleted
|
||||
// before the job runs, the job still removes the right text (F407, review R1).
|
||||
const asked = (await data.outbox.pending(100)).filter((event) => event.name === "knowledge.documentRemoveRequested")
|
||||
expect(asked).toHaveLength(1)
|
||||
expect(asked[0]?.payload).toMatchObject({ storageKey: "k/seed" })
|
||||
})
|
||||
|
||||
it("refuses a person's removal of a file being deleted, which leaves the document to the deletion's own removal", async () => {
|
||||
const { data, deps } = makeDeps()
|
||||
const fileId = await seed(data)
|
||||
const row = await requestIndex(deps, scope, { collectionName: "contracts", fileId })
|
||||
await data.forScope(scope).of(documents).update(row.documentId, { status: "indexed" }, 1)
|
||||
const file = await data.forScope(scope).of(files).require(fileId)
|
||||
await data
|
||||
.forScope(scope)
|
||||
.of(files)
|
||||
.update(fileId, { status: "deleting", deletingAt: new Date().toISOString() }, file.version)
|
||||
await expect(requestRemove(deps, scope, { collectionName: "contracts", fileId })).rejects.toMatchObject({
|
||||
status: 409,
|
||||
})
|
||||
// Still active, so `FileDeleted` (which carries the key) cancels it and asks for the removal.
|
||||
expect((await data.forScope(scope).of(documents).require(row.documentId)).status).toBe("indexed")
|
||||
expect(
|
||||
(await data.outbox.pending(100)).filter((event) => event.name === "knowledge.documentRemoveRequested"),
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("retained remote citations after source deletion", () => {
|
||||
it("withholds a deleting or missing file even when its index row still says indexed", async () => {
|
||||
const { data, deps } = makeDeps()
|
||||
const fileId = await seed(data)
|
||||
const row = await requestIndex(deps, scope, { collectionName: "contracts", fileId })
|
||||
await data.forScope(scope).of(documents).update(row.documentId, { status: "indexed" }, 1)
|
||||
if (!deps.search) throw new Error("the retained citation fixture must configure search")
|
||||
const searching = {
|
||||
...deps,
|
||||
search: {
|
||||
...deps.search,
|
||||
search: async () => [
|
||||
{ fileId, filename: "a.txt", collectionName: "contracts", snippet: "retained source", score: 1, chunk: 1 },
|
||||
],
|
||||
},
|
||||
}
|
||||
expect(
|
||||
(await searchKnowledge(searching, scope, { collectionName: "contracts", query: "source" })).results,
|
||||
).toHaveLength(1)
|
||||
await data.forScope(scope).of(files).update(fileId, { status: "deleting" }, 1)
|
||||
expect((await searchKnowledge(searching, scope, { collectionName: "contracts", query: "source" })).results).toEqual(
|
||||
[],
|
||||
)
|
||||
const { storageKey } = (await data.forScope(scope).of(files).get(fileId)) ?? { storageKey: "" }
|
||||
await data.forScope(scope).of(files).delete(fileId)
|
||||
await requestSourceRemoval({ ...deps, flags: { enabled: () => false } }, scope, fileId, storageKey)
|
||||
await requestSourceRemoval(deps, scope, fileId, storageKey)
|
||||
expect((await listDocuments(deps, scope, "contracts")).documents[0]?.status).toBe("cancelled")
|
||||
const cleanup = (await data.outbox.pending(100)).filter(
|
||||
(event) => event.name === "knowledge.documentRemoveRequested",
|
||||
)
|
||||
expect(cleanup).toHaveLength(1)
|
||||
// The deleted file's key travels with the request: its row is gone, so the removal cannot look it up (F407).
|
||||
expect(cleanup[0]?.payload).toMatchObject({ storageKey })
|
||||
expect((await searchKnowledge(searching, scope, { collectionName: "contracts", query: "source" })).results).toEqual(
|
||||
[],
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("the index job's orchestration", () => {
|
||||
it("moves the row queued -> indexing -> indexed and calls the search service once", async () => {
|
||||
const indexed: string[] = []
|
||||
const { data, deps, store } = makeDeps({
|
||||
search: {
|
||||
readiness: async () => ({ state: "ready", reasons: [] }),
|
||||
index: async (_scope, input) => {
|
||||
indexed.push(input.fileId)
|
||||
},
|
||||
search: async () => [],
|
||||
remove: async () => undefined,
|
||||
},
|
||||
})
|
||||
const fileId = await seedWithBytes(data, store, new TextEncoder().encode("hello knowledge"))
|
||||
const requested = await requestIndex(deps, scope, { collectionName: "contracts", fileId })
|
||||
const done = await indexDocument(deps, scope, requested.documentId)
|
||||
expect(done.status).toBe("indexed")
|
||||
expect(indexed).toEqual([fileId])
|
||||
// A redelivered job finds the row indexed and does the external work again never.
|
||||
const again = await indexDocument(deps, scope, requested.documentId)
|
||||
expect(again.status).toBe("indexed")
|
||||
expect(indexed).toEqual([fileId])
|
||||
})
|
||||
|
||||
it("records a permanent failure on the row; a transient one throws for the queue to retry", async () => {
|
||||
const permanent = makeDeps({
|
||||
search: {
|
||||
readiness: async () => ({ state: "ready", reasons: [] }),
|
||||
index: async () => {
|
||||
throw Object.assign(new Error("not text"), { permanent: "not_text" })
|
||||
},
|
||||
search: async () => [],
|
||||
remove: async () => undefined,
|
||||
},
|
||||
})
|
||||
const permanentFile = await seedWithBytes(
|
||||
permanent.data,
|
||||
permanent.store,
|
||||
new TextEncoder().encode("not text at all"),
|
||||
)
|
||||
const requested = await requestIndex(permanent.deps, scope, { collectionName: "contracts", fileId: permanentFile })
|
||||
const failed = await indexDocument(permanent.deps, scope, requested.documentId)
|
||||
expect(failed.status).toBe("failed")
|
||||
expect(failed.error).toBe("not_text")
|
||||
|
||||
const transient = makeDeps({
|
||||
search: {
|
||||
readiness: async () => ({ state: "ready", reasons: [] }),
|
||||
index: async () => {
|
||||
throw new Error("the store hiccuped")
|
||||
},
|
||||
search: async () => [],
|
||||
remove: async () => undefined,
|
||||
},
|
||||
})
|
||||
const transientFile = await seedWithBytes(transient.data, transient.store, new TextEncoder().encode("hello"))
|
||||
const requested2 = await requestIndex(transient.deps, scope, { collectionName: "contracts", fileId: transientFile })
|
||||
await expect(indexDocument(transient.deps, scope, requested2.documentId)).rejects.toThrow("hiccuped")
|
||||
let resumed = 0
|
||||
const retry = {
|
||||
...transient.deps,
|
||||
search: {
|
||||
...transient.deps.search,
|
||||
readiness: async () => ({ state: "ready" as const, reasons: [] }),
|
||||
search: async () => [],
|
||||
remove: async () => undefined,
|
||||
index: async () => {
|
||||
resumed++
|
||||
},
|
||||
},
|
||||
}
|
||||
expect((await indexDocument(retry, scope, requested2.documentId)).status).toBe("indexed")
|
||||
expect(resumed).toBe(1)
|
||||
})
|
||||
|
||||
it("removal deletes the row and asks the service to drop the vectors", async () => {
|
||||
const removed: string[] = []
|
||||
const { data, deps } = makeDeps({
|
||||
search: {
|
||||
readiness: async () => ({ state: "ready", reasons: [] }),
|
||||
index: async () => undefined,
|
||||
search: async () => [],
|
||||
remove: async (_scope, input) => {
|
||||
removed.push(input.fileId)
|
||||
},
|
||||
},
|
||||
})
|
||||
const fileId = await seed(data)
|
||||
const requested = await requestIndex(deps, scope, { collectionName: "contracts", fileId })
|
||||
const cancelled = await requestRemove(deps, scope, { collectionName: "contracts", fileId })
|
||||
expect(cancelled.status).toBe("cancelled")
|
||||
expect((await indexDocument(deps, scope, requested.documentId)).status).toBe("cancelled")
|
||||
// The remove job's body: delete the vectors, then the row.
|
||||
await removeDocument(deps, scope, requested.documentId)
|
||||
expect(removed).toEqual([fileId])
|
||||
expect((await listDocuments(deps, scope, "contracts")).documents).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
it("persists a final transient indexing failure but never overwrites cancellation on a lost claim", async () => {
|
||||
const transient = makeDeps({
|
||||
search: {
|
||||
readiness: async () => ({ state: "ready", reasons: [] }),
|
||||
index: async () => {
|
||||
throw new Error("temporary provider outage")
|
||||
},
|
||||
search: async () => [],
|
||||
remove: async () => {},
|
||||
},
|
||||
})
|
||||
const fileId = await seedWithBytes(transient.data, transient.store, new TextEncoder().encode("synthetic source"))
|
||||
const requested = await requestIndex(transient.deps, scope, { collectionName: "contracts", fileId })
|
||||
const outcome = await indexDocument(transient.deps, scope, requested.documentId, { finalAttempt: true })
|
||||
expect(outcome).toMatchObject({ status: "failed", error: "processing_exhausted" })
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
const next = await requestIndex(transient.deps, scope, { collectionName: "contracts", fileId })
|
||||
await expect(
|
||||
indexDocument(transient.deps, scope, next.documentId, { finalAttempt: true, signal: controller.signal }),
|
||||
).rejects.toBeDefined()
|
||||
expect((await transient.data.forScope(scope).of(documents).get(next.documentId))?.status).not.toBe("failed")
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
// The module's messages: en and lt declare the same keys, or a page in one language breaks.
|
||||
|
||||
import { buildRegistry } from "@conusai-kit/core"
|
||||
import { filesModule } from "@conusai-kit/files"
|
||||
import { createI18n } from "@conusai-kit/i18n"
|
||||
import { tenancyModule } from "@conusai-kit/tenancy"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { knowledgeModule } from "../index.js"
|
||||
|
||||
describe("the knowledge module's messages", () => {
|
||||
const catalogues = buildRegistry([tenancyModule, filesModule, knowledgeModule]).catalogues
|
||||
|
||||
it("declares en and lt, and every en key resolves in lt", () => {
|
||||
const en = catalogues.en?.knowledge ?? {}
|
||||
const lt = catalogues.lt?.knowledge ?? {}
|
||||
const flatten = (tree: Record<string, unknown>, prefix = ""): string[] =>
|
||||
Object.entries(tree).flatMap(([key, value]) =>
|
||||
typeof value === "string" ? [`${prefix}${key}`] : flatten(value as Record<string, unknown>, `${prefix}${key}.`),
|
||||
)
|
||||
const enKeys = flatten(en)
|
||||
const ltKeys = new Set(flatten(lt))
|
||||
expect(enKeys.length).toBeGreaterThan(0)
|
||||
for (const key of enKeys) expect(ltKeys.has(key), `knowledge.${key} has no Lithuanian message`).toBe(true)
|
||||
})
|
||||
|
||||
it("formats a message with values in both locales", () => {
|
||||
const i18n = createI18n("lt", { lt: { knowledge: catalogues.lt?.knowledge ?? {} } })
|
||||
expect(i18n.message("knowledge.status.documents", { queued: 1, indexing: 2, indexed: 3, failed: 0 })).toContain("1")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
// What the generated tenant-isolation suite (test/isolation.test.ts) needs of this module: rows it
|
||||
// seeds in every tenant, and one fixture per tenant-scoped procedure. A fixture is built from the
|
||||
// OTHER tenant's ids: the call is made as a member of organization A with organization B's slugs
|
||||
// and ids, and has to be refused or answered empty.
|
||||
|
||||
import type { TransportCaller } from "@conusai-kit/core"
|
||||
import { type IsolationFixture, type IsolationTenant, uploadViaProcedures } from "@conusai-kit/testing"
|
||||
|
||||
/** What a module's isolation file exports: the shape the suite reads. */
|
||||
export interface ModuleIsolation {
|
||||
seed(
|
||||
call: TransportCaller,
|
||||
inProject: { org: string; workspace: string; project: string },
|
||||
): Promise<Record<string, string>>
|
||||
fixtures: Record<string, IsolationFixture>
|
||||
}
|
||||
|
||||
const NOWHERE = "00000000-0000-0000-0000-000000000000"
|
||||
const inProjectOf = (t: IsolationTenant) => ({ org: t.orgSlug, workspace: t.workspaceSlug, project: t.projectSlug })
|
||||
const collectionOf = (t: IsolationTenant) => t.extra.knowledgeCollection ?? "contracts"
|
||||
const fileOf = (t: IsolationTenant) => t.extra.knowledgeFileId ?? NOWHERE
|
||||
/** A knowledge_document row the suite seeds in each tenant (test/isolation.test.ts): indexing needs the module on. */
|
||||
const documentOf = (t: IsolationTenant) => t.extra.knowledgeDocumentId ?? NOWHERE
|
||||
|
||||
export const isolation: ModuleIsolation = {
|
||||
async seed(call, inProject) {
|
||||
const file = await uploadViaProcedures(call, inProject, {
|
||||
filename: "knowledge-seed.txt",
|
||||
contentType: "text/plain",
|
||||
bytes: new TextEncoder().encode("seed knowledge"),
|
||||
})
|
||||
// The seed needs a collection row to exist; collection.create needs the admin role, which the
|
||||
// isolation harness does not grant the seeding caller, so the fixture names a collection and
|
||||
// the write fixtures create it through the manage permission where a denial is the assertion.
|
||||
return { knowledgeCollection: "contracts", knowledgeFileId: file.fileId }
|
||||
},
|
||||
fixtures: {
|
||||
"knowledge.collection.list": { input: (t) => inProjectOf(t) },
|
||||
"knowledge.collection.create": { input: (t) => ({ ...inProjectOf(t), name: "intruders" }) },
|
||||
"knowledge.document.list": {
|
||||
input: (t) => ({ ...inProjectOf(t), collectionName: collectionOf(t) }),
|
||||
},
|
||||
// The live collection's read-back: another tenant's document id answers nothing.
|
||||
"knowledge.document.getMany": { input: (t) => ({ ...inProjectOf(t), ids: [documentOf(t)] }) },
|
||||
"knowledge.document.index": {
|
||||
input: (t) => ({ ...inProjectOf(t), collectionName: collectionOf(t), fileId: fileOf(t) }),
|
||||
},
|
||||
"knowledge.document.remove": {
|
||||
input: (t) => ({ ...inProjectOf(t), collectionName: collectionOf(t), fileId: fileOf(t) }),
|
||||
},
|
||||
"knowledge.search": { input: (t) => ({ ...inProjectOf(t), collectionName: collectionOf(t), query: "seed" }) },
|
||||
"knowledge.status": { input: (t) => inProjectOf(t) },
|
||||
"knowledge.settings.get": { input: (t) => ({ org: t.orgSlug }) },
|
||||
"knowledge.settings.enableDocumentAi": { input: (t) => ({ org: t.orgSlug }) },
|
||||
"knowledge.settings.disableDocumentAi": { input: (t) => ({ org: t.orgSlug }) },
|
||||
"knowledge.autoindex.get": { input: (t) => inProjectOf(t) },
|
||||
"knowledge.autoindex.set": { input: (t) => ({ ...inProjectOf(t), collectionName: collectionOf(t) }) },
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// The module's migrations are its own files, each exactly `schemaStatements()` over the entities it adds
|
||||
// (decision AT-D20: the entity definitions are the one source of a column). The module ships the
|
||||
// Postgres profile; the Convex binding is provided for the integration owner and has no SQL.
|
||||
|
||||
import { readFileSync } from "node:fs"
|
||||
import { schemaStatements } from "@conusai-kit/data-kysely"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { autoIndex, KNOWLEDGE_ENTITIES, KNOWLEDGE_ENTITIES_V1, knowledgeModule, passageSets } from "../index.js"
|
||||
|
||||
/** The statements for these tables alone: `schemaStatements` leads with the kernel's own two. */
|
||||
function tablesOf(entities: Parameters<typeof schemaStatements>[0]): string {
|
||||
const kernel = schemaStatements([]).length
|
||||
return `${schemaStatements(entities)
|
||||
.slice(kernel)
|
||||
.map((statement) => `${statement};`)
|
||||
.join("\n\n")}\n`
|
||||
}
|
||||
|
||||
describe("the migration of the knowledge module", () => {
|
||||
it("is its own file, exactly its three tables as first shipped", async () => {
|
||||
await expect(
|
||||
`-- SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0\n${tablesOf(KNOWLEDGE_ENTITIES_V1)}`,
|
||||
).toMatchFileSnapshot("../migrations/0001_knowledge.sql")
|
||||
})
|
||||
|
||||
it("adds the auto-index opt-in in the second file, exactly its table", async () => {
|
||||
await expect(
|
||||
`-- SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0\n${tablesOf([autoIndex])}`,
|
||||
).toMatchFileSnapshot("../migrations/0002_knowledge_auto_index.sql")
|
||||
})
|
||||
|
||||
it("adds the PDF intake's passage record in the third file, exactly its table", async () => {
|
||||
await expect(
|
||||
`-- SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0\n${tablesOf([passageSets])}`,
|
||||
).toMatchFileSnapshot("../migrations/0003_knowledge_passage_set.sql")
|
||||
expect(KNOWLEDGE_ENTITIES).toEqual([...KNOWLEDGE_ENTITIES_V1, autoIndex, passageSets])
|
||||
})
|
||||
|
||||
it("is declared by the manifest, so the migrate job finds it, and every table is row-level secured", () => {
|
||||
expect(knowledgeModule.migrations).toEqual({ postgres: "migrations" })
|
||||
expect(knowledgeModule.profiles).toEqual(["postgres"])
|
||||
const sql = ["0001_knowledge.sql", "0002_knowledge_auto_index.sql", "0003_knowledge_passage_set.sql"]
|
||||
.map((file) => readFileSync(new URL(`../migrations/${file}`, import.meta.url), "utf8"))
|
||||
.join("\n")
|
||||
for (const table of [
|
||||
"knowledge_collection",
|
||||
"knowledge_document",
|
||||
"knowledge_config",
|
||||
"knowledge_auto_index",
|
||||
"knowledge_passage_set",
|
||||
]) {
|
||||
expect(sql).toContain(`create table "${table}"`)
|
||||
expect(sql).toContain(`alter table "${table}" force row level security`)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,208 @@
|
||||
// The knowledge page on F10: its loader reads through the caller and returns plain data with every label
|
||||
// translated, and its view renders what reached the browser. The service's health, the indexing indicator
|
||||
// and the search stay three separate things: "indexing" is not "down".
|
||||
|
||||
import { AppError, buildRegistry, Container, notFound, type TransportCaller } from "@conusai-kit/core"
|
||||
import { builtInCatalogues, createI18n, uiLabels } from "@conusai-kit/i18n"
|
||||
import { createDataClient, DataProvider } from "@conusai-kit/ui/data"
|
||||
import { UiLabelsProvider } from "@conusai-kit/ui/primitives"
|
||||
import { renderToStaticMarkup as renderMarkup } from "react-dom/server"
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import { appModules } from "../../index.js"
|
||||
import { modulePages } from "../../pages.js"
|
||||
import { knowledgePage } from "../ui/pages.js"
|
||||
|
||||
const kitStrings = uiLabels(createI18n("en", builtInCatalogues))
|
||||
const renderToStaticMarkup = (node: React.ReactNode) =>
|
||||
renderMarkup(<UiLabelsProvider labels={kitStrings}>{node}</UiLabelsProvider>)
|
||||
|
||||
const catalogues = buildRegistry([...appModules]).catalogues
|
||||
const i18n = createI18n("en", {
|
||||
en: { knowledge: catalogues.en?.knowledge ?? {}, app: { failed: "That did not work: {detail}" } },
|
||||
})
|
||||
const slugs = { org: "acme", workspace: "ws", project: "p" }
|
||||
|
||||
const callerOf = (answers: Record<string, unknown>, asked: unknown[] = []): TransportCaller =>
|
||||
({
|
||||
call: async (contract: { name: string }, input: unknown) => {
|
||||
asked.push([contract.name, input])
|
||||
const answer = answers[contract.name]
|
||||
if (answer instanceof Error) throw answer
|
||||
return answer
|
||||
},
|
||||
}) as unknown as TransportCaller
|
||||
|
||||
const load = (segments: string[], answers: Record<string, unknown>, asked?: unknown[]) =>
|
||||
knowledgePage.load({
|
||||
slugs,
|
||||
segments,
|
||||
search: {},
|
||||
caller: callerOf(answers, asked),
|
||||
i18n,
|
||||
container: new Container(),
|
||||
})
|
||||
|
||||
/** The page as the shell renders it: the loader's data through JSON, as the browser receives it, then the view. */
|
||||
const render = async (answers: Record<string, unknown>) => {
|
||||
const data = await load([], answers)
|
||||
expect(JSON.parse(JSON.stringify(data))).toStrictEqual(data)
|
||||
const { View } = knowledgePage
|
||||
return renderToStaticMarkup(
|
||||
<DataProvider client={createDataClient({ fetch: async () => new Response(null, { status: 501 }) })}>
|
||||
<View slugs={slugs} segments={[]} locale="en" data={JSON.parse(JSON.stringify(data))} />
|
||||
</DataProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
const document = (documentId: string, status: string, error: string | null) => ({
|
||||
documentId,
|
||||
collectionName: "contracts",
|
||||
fileId: `file-${documentId}`,
|
||||
filename: `${documentId}.pdf`,
|
||||
status,
|
||||
error,
|
||||
indexedAt: null,
|
||||
requestedBy: "u1",
|
||||
})
|
||||
|
||||
const status = (enabled: boolean, state: string, reasons: readonly string[] = [], refusal?: string) => ({
|
||||
enabled,
|
||||
service: { state, reasons, ...(refusal === undefined ? {} : { refusal }) },
|
||||
documents: { queued: 1, indexing: 0, indexed: 2, failed: 0, cancelled: 0 },
|
||||
})
|
||||
|
||||
const answers = {
|
||||
"knowledge.collection.list": {
|
||||
collections: [{ name: "contracts", documentCount: 2, indexedCount: 1, createdBy: "u1" }],
|
||||
},
|
||||
"knowledge.status": status(true, "degraded", ["the search index is behind"]),
|
||||
"knowledge.document.list": {
|
||||
documents: [document("d1", "indexed", null), document("d2", "failed", "the file could not be read")],
|
||||
},
|
||||
"knowledge.autoindex.get": { collectionName: null },
|
||||
}
|
||||
|
||||
describe("the knowledge page", () => {
|
||||
it("is listed for the shell", async () => {
|
||||
await expect(modulePages.knowledge?.()).resolves.toBe(knowledgePage)
|
||||
})
|
||||
|
||||
it("shows the service's health, the collection and its documents in words", async () => {
|
||||
const html = await render(answers)
|
||||
expect(html).toContain("<h1 ")
|
||||
expect(html).toContain("<li>the search index is behind</li>")
|
||||
expect(html).toContain(">contracts</h2>")
|
||||
expect(html).toContain("d1.pdf")
|
||||
expect(html).toContain("the file could not be read")
|
||||
expect(html).toContain('role="grid"')
|
||||
expect(html.match(/role="columnheader"/g)).toHaveLength(3)
|
||||
})
|
||||
|
||||
// core-11: the project's automatic PDF indexing, off or naming its collection, with the project's slugs.
|
||||
it("offers automatic PDF indexing into one of the collections, showing the current choice", async () => {
|
||||
const asked: unknown[] = []
|
||||
const off = await load([], answers, asked)
|
||||
expect(asked).toContainEqual(["knowledge.autoindex.get", slugs])
|
||||
expect(off.autoIndex.current).toBeNull()
|
||||
const html = await render(answers)
|
||||
expect(html).toContain("Index uploaded PDFs automatically")
|
||||
const on = await load([], { ...answers, "knowledge.autoindex.get": { collectionName: "contracts" } })
|
||||
expect(on.autoIndex.current).toBe("contracts")
|
||||
})
|
||||
|
||||
it("does not offer automatic indexing while the module is off", async () => {
|
||||
const html = await render({ ...answers, "knowledge.status": status(false, "disabled") })
|
||||
expect(html).not.toContain("Index uploaded PDFs automatically")
|
||||
})
|
||||
|
||||
it("reads each collection's documents with the project's slugs", async () => {
|
||||
const asked: unknown[] = []
|
||||
await load([], answers, asked)
|
||||
expect(asked).toContainEqual(["knowledge.document.list", { ...slugs, collectionName: "contracts" }])
|
||||
})
|
||||
|
||||
it("says the module is off in place of its collections", async () => {
|
||||
const html = await render({ ...answers, "knowledge.status": status(false, "disabled") })
|
||||
expect(html).toContain('role="status"')
|
||||
expect(html).not.toContain('role="grid"')
|
||||
expect(html).not.toContain("<dl>")
|
||||
})
|
||||
|
||||
it.each([
|
||||
["plan_required", "The organization's plan does not include document search."],
|
||||
["feature_disabled", "Document search is switched off for this organization by platform policy."],
|
||||
["quota_exhausted", "Today's indexing allowance is spent. Try again tomorrow."],
|
||||
["budget_exhausted", "The organization's AI budget is spent. An administrator can raise it on the platform."],
|
||||
[
|
||||
"scope_missing",
|
||||
"This deployment's token does not hold knowledge.collections:use. Deploy the app again, then have an owner or admin approve it.",
|
||||
],
|
||||
["indexing", "The service is building its index. Try again in a few minutes."],
|
||||
["approval_required", "An administrator must approve this on the platform first."],
|
||||
["runtime_unavailable", "The knowledge service did not answer. Try again later."],
|
||||
])("shows the refusal %s as the page's own state", async (refusal, text) => {
|
||||
const html = await render({
|
||||
...answers,
|
||||
"knowledge.status": status(true, "unavailable", [], refusal),
|
||||
})
|
||||
expect(html).toContain(`data-state="${refusal}"`)
|
||||
expect(html).toContain('role="status"')
|
||||
expect(html).toContain(text.replaceAll("'", "'"))
|
||||
})
|
||||
|
||||
it("shows a refusal in Lithuanian to a Lithuanian reader", async () => {
|
||||
const lt = createI18n("lt", {
|
||||
lt: { knowledge: catalogues.lt?.knowledge ?? {}, app: { failed: "Nepavyko: {detail}" } },
|
||||
})
|
||||
const data = await knowledgePage.load({
|
||||
slugs,
|
||||
segments: [],
|
||||
search: {},
|
||||
caller: callerOf({ ...answers, "knowledge.status": status(true, "disabled", [], "plan_required") }),
|
||||
i18n: lt,
|
||||
container: new Container(),
|
||||
})
|
||||
expect(data.status.refusal).toEqual({
|
||||
code: "plan_required",
|
||||
text: "Organizacijos planas dokumentų paieškos neapima.",
|
||||
})
|
||||
})
|
||||
|
||||
it("says a document's failure in words, and keeps a code it does not know", async () => {
|
||||
const html = await render({
|
||||
...answers,
|
||||
"knowledge.document.list": {
|
||||
documents: [document("d1", "failed", "quota_exhausted"), document("d2", "failed", "something_new")],
|
||||
},
|
||||
})
|
||||
// Worded in the browser from the failure labels the loader handed over: the list is live.
|
||||
expect(html).toContain(">Today's indexing allowance is spent. Try again tomorrow.<")
|
||||
expect(html).toContain(">something_new<")
|
||||
})
|
||||
|
||||
it("answers a path it does not know as not found, without reading", async () => {
|
||||
const call = vi.fn()
|
||||
await expect(
|
||||
knowledgePage.load({
|
||||
slugs,
|
||||
segments: ["extra"],
|
||||
search: {},
|
||||
caller: { call } as unknown as TransportCaller,
|
||||
i18n,
|
||||
container: new Container(),
|
||||
}),
|
||||
).rejects.toMatchObject({ status: 404 })
|
||||
expect(call).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("hands a procedure's refusal on, as the AppError the shell answers", async () => {
|
||||
const refused = load([], { ...answers, "knowledge.collection.list": notFound("no such project") })
|
||||
await expect(refused).rejects.toBeInstanceOf(AppError)
|
||||
await expect(refused).rejects.toMatchObject({ status: 404 })
|
||||
})
|
||||
})
|
||||
|
||||
// Explicit synthetic admission: module-local fixtures exercise the selected module.
|
||||
vi.mock("../../inclusion.json", () => ({
|
||||
default: { schemaVersion: 1, selected: ["knowledge"], everAdopted: ["knowledge"] },
|
||||
}))
|
||||
@@ -0,0 +1,304 @@
|
||||
// The PDF intake's two adapters (template lesson core-11). The reader hands a PDF to the platform's
|
||||
// document reader and answers its passages (`@conusai-sdk/ocr/passages`); the passage index sends one
|
||||
// inline item per passage, under the file's Blob reference plus `#p{page}.{index}` and with its page,
|
||||
// blocks and file in metadata, and tombstones exactly those references. A search hit on a passage
|
||||
// cites back into the file it came from.
|
||||
import { createMemoryDataSource } from "@conusai-kit/data"
|
||||
import { DEFAULT_FILES_POLICY, files } from "@conusai-kit/files"
|
||||
import {
|
||||
KNOWLEDGE_LIMITS,
|
||||
type Knowledge,
|
||||
type KnowledgeClient,
|
||||
KnowledgeError,
|
||||
type KnowledgeInlineItem,
|
||||
} from "@conusai-kit/platform"
|
||||
import { fakeObjectStore } from "@conusai-kit/testing"
|
||||
import { OcrError } from "@conusai-sdk/ocr"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { platformKnowledgeSearch, platformPassageIndex } from "../adapters/platform-knowledge.js"
|
||||
import { sdkKnowledgeReader } from "../adapters/reader.js"
|
||||
import type { KnowledgePassage, KnowledgeScope } from "../core/ports.js"
|
||||
|
||||
const scope: KnowledgeScope = {
|
||||
level: "project",
|
||||
actor: { userId: "system:knowledge.autoindex", system: true },
|
||||
orgId: "01900000-0000-7000-0000-000000000001",
|
||||
workspaceId: "01900000-0000-7000-0000-000000000002",
|
||||
projectId: "01900000-0000-7000-0000-000000000003",
|
||||
}
|
||||
const storageKey = `files/${scope.workspaceId}/${scope.projectId}/01900000-0000-7000-0000-000000000100`
|
||||
const pathname = `org/${scope.orgId}/${storageKey}`
|
||||
|
||||
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,
|
||||
filename: "contract.pdf",
|
||||
contentType: "application/pdf",
|
||||
size: 10,
|
||||
sha256: "0".repeat(64),
|
||||
uploadedBy: "01900000-0000-7000-0000-000000000010",
|
||||
storedAt: "2026-11-09T19:00:00.000Z",
|
||||
uploadId: null,
|
||||
declaredSize: null,
|
||||
partSize: null,
|
||||
deletingAt: null,
|
||||
})
|
||||
return { deps, fileId: row.id }
|
||||
}
|
||||
|
||||
type PassageClient = Pick<KnowledgeClient, "createCollection" | "indexInline" | "job" | "remove">
|
||||
|
||||
function passageClient(jobError: string | null = null) {
|
||||
const calls = {
|
||||
collections: [] as string[],
|
||||
batches: [] as KnowledgeInlineItem[][],
|
||||
polled: [] as string[],
|
||||
removed: [] as string[],
|
||||
}
|
||||
const client: PassageClient = {
|
||||
createCollection: async (input) => {
|
||||
calls.collections.push(`${input.collection}@${input.environment}`)
|
||||
return input
|
||||
},
|
||||
indexInline: async (input) => {
|
||||
calls.batches.push([...input.items])
|
||||
return input.items.map((item, at) => ({
|
||||
documentRef: item.documentRef,
|
||||
jobId: `job-${calls.batches.length}-${at}`,
|
||||
}))
|
||||
},
|
||||
job: async (jobId) => {
|
||||
calls.polled.push(jobId)
|
||||
return jobError === null
|
||||
? { jobId, status: "succeeded", progress: 1, generation: 1, error: null }
|
||||
: { jobId, status: "failed", progress: null, generation: null, error: jobError }
|
||||
},
|
||||
remove: async (documentRef) => {
|
||||
calls.removed.push(documentRef)
|
||||
},
|
||||
}
|
||||
return { client, calls }
|
||||
}
|
||||
|
||||
const passage = (page: number, index: number, blockIds: string[] = []): KnowledgePassage => ({
|
||||
page,
|
||||
index,
|
||||
blockIds,
|
||||
text: `page ${page}, passage ${index}`,
|
||||
})
|
||||
|
||||
describe("the passage index", () => {
|
||||
it("indexes one tenant item per passage under the file's reference, with its page, blocks and file in metadata", async () => {
|
||||
const { deps, fileId } = await storedFile()
|
||||
const { client, calls } = passageClient()
|
||||
const index = platformPassageIndex(client, deps, { environment: "production", intervalMs: 1 })
|
||||
|
||||
const documentRef = await index.reference(scope, fileId)
|
||||
expect(documentRef).toBe(pathname)
|
||||
await index.index(scope, {
|
||||
collectionName: "contracts",
|
||||
documentRef,
|
||||
fileId,
|
||||
passages: [passage(1, 0, ["b1", "b2"]), passage(2, 0)],
|
||||
})
|
||||
|
||||
expect(calls.collections).toEqual(["contracts@production"])
|
||||
const [batch] = calls.batches
|
||||
expect(batch?.map((item) => item.documentRef)).toEqual([`${pathname}#p1.0`, `${pathname}#p2.0`])
|
||||
expect(batch?.[0]).toMatchObject({
|
||||
text: "page 1, passage 0",
|
||||
visibility: "tenant",
|
||||
metadata: { page: "1", block_ids: "b1,b2", file_id: fileId },
|
||||
})
|
||||
// A passage on a page with no blocks carries no block ids rather than an empty one.
|
||||
expect(batch?.[1]?.metadata).toEqual({ page: "2", file_id: fileId })
|
||||
// The tenant partition is the project's, the same one a search of the project asks for.
|
||||
expect(new Set(batch?.map((item) => item.tenant))).toEqual(new Set([batch?.[0]?.tenant]))
|
||||
expect(batch?.[0]?.tenant).toMatch(/^[0-9a-f]{64}$/)
|
||||
// Every item's durable job is waited for.
|
||||
expect([...calls.polled].sort()).toEqual(["job-1-0", "job-1-1"])
|
||||
})
|
||||
|
||||
it("sends at most the service's item count per call", async () => {
|
||||
const { deps, fileId } = await storedFile()
|
||||
const { client, calls } = passageClient()
|
||||
const many = Array.from({ length: KNOWLEDGE_LIMITS.inlineItems + 1 }, (_, at) => passage(1, at))
|
||||
await platformPassageIndex(client, deps, { environment: "production", intervalMs: 1 }).index(scope, {
|
||||
collectionName: "contracts",
|
||||
documentRef: pathname,
|
||||
fileId,
|
||||
passages: many,
|
||||
})
|
||||
expect(calls.batches.map((batch) => batch.length)).toEqual([KNOWLEDGE_LIMITS.inlineItems, 1])
|
||||
})
|
||||
|
||||
it("records a quota refusal as the document's, not as something to retry", async () => {
|
||||
const { deps, fileId } = await storedFile()
|
||||
const { client } = passageClient("quota-exhausted")
|
||||
await expect(
|
||||
platformPassageIndex(client, deps, { environment: "production", intervalMs: 1 }).index(scope, {
|
||||
collectionName: "contracts",
|
||||
documentRef: pathname,
|
||||
fileId,
|
||||
passages: [passage(1, 0)],
|
||||
}),
|
||||
).rejects.toMatchObject({ reason: "quota_exhausted", permanent: "quota_exhausted" })
|
||||
})
|
||||
|
||||
it("tombstones exactly the references it indexed, without the file", async () => {
|
||||
const { deps } = await storedFile()
|
||||
const { client, calls } = passageClient()
|
||||
await platformPassageIndex(client, deps, { environment: "production" }).remove(scope, {
|
||||
documentRef: pathname,
|
||||
passages: [
|
||||
{ page: 1, index: 0 },
|
||||
{ page: 1, index: 1 },
|
||||
{ page: 3, index: 0 },
|
||||
],
|
||||
})
|
||||
expect(calls.removed).toEqual([`${pathname}#p1.0`, `${pathname}#p1.1`, `${pathname}#p3.0`])
|
||||
})
|
||||
|
||||
it("takes a passage the service never indexed as already removed", async () => {
|
||||
const { deps } = await storedFile()
|
||||
const { client, calls } = passageClient()
|
||||
const index = platformPassageIndex(
|
||||
{
|
||||
...client,
|
||||
remove: async (documentRef) => {
|
||||
calls.removed.push(documentRef)
|
||||
if (documentRef.endsWith("#p1.1")) throw new KnowledgeError("unavailable_resource", "no such document")
|
||||
},
|
||||
},
|
||||
deps,
|
||||
{ environment: "production" },
|
||||
)
|
||||
await index.remove(scope, {
|
||||
documentRef: pathname,
|
||||
passages: [
|
||||
{ page: 1, index: 0 },
|
||||
{ page: 1, index: 1 },
|
||||
{ page: 2, index: 0 },
|
||||
],
|
||||
})
|
||||
expect(calls.removed).toEqual([`${pathname}#p1.0`, `${pathname}#p1.1`, `${pathname}#p2.0`])
|
||||
})
|
||||
})
|
||||
|
||||
describe("a search hit on a passage", () => {
|
||||
it("cites the file it came from, with the passage's page and first block", async () => {
|
||||
const { deps, fileId } = await storedFile()
|
||||
const client = {
|
||||
search: async () => [
|
||||
{
|
||||
documentRef: `${pathname}#p2.0`,
|
||||
snippet: "page 2",
|
||||
score: 0.9,
|
||||
citations: [],
|
||||
chunkId: "chunk-1",
|
||||
metadata: { page: "2", block_ids: "b3,b4", file_id: fileId },
|
||||
blockIds: [],
|
||||
},
|
||||
],
|
||||
} as unknown as Knowledge
|
||||
const hits = await platformKnowledgeSearch(client, deps, { environment: "production" }).search(scope, {
|
||||
collectionName: "contracts",
|
||||
query: "page",
|
||||
limit: 5,
|
||||
actorId: "01900000-0000-7000-0000-000000000010",
|
||||
})
|
||||
expect(hits).toEqual([
|
||||
{
|
||||
fileId,
|
||||
filename: "contract.pdf",
|
||||
collectionName: "contracts",
|
||||
score: 0.9,
|
||||
chunk: 1,
|
||||
snippet: "page 2",
|
||||
page: 2,
|
||||
blockId: "b3",
|
||||
chunkId: "chunk-1",
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe("the reader", () => {
|
||||
const document = {
|
||||
schema_version: "conusai.ocr.document/v1",
|
||||
pages: [
|
||||
{
|
||||
index: 0,
|
||||
lines: [
|
||||
{ text: "Agreement", order: 0, repeated: false },
|
||||
{ text: "Signed", order: 1, repeated: false },
|
||||
],
|
||||
blocks: [{ id: "p1-b1", order: 0, lines: [0, 1] }],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
it("submits the PDF once under its key and answers the read document's passages", async () => {
|
||||
const submitted: Array<{ filename?: string; idempotencyKey?: string }> = []
|
||||
const reader = sdkKnowledgeReader(
|
||||
{
|
||||
submit: (async (_bytes: unknown, options: { filename?: string; idempotencyKey?: string }) => {
|
||||
submitted.push(options)
|
||||
return { id: 7 }
|
||||
}) as never,
|
||||
wait: (async (job: unknown) => {
|
||||
expect(job).toBe(7)
|
||||
return document
|
||||
}) as never,
|
||||
},
|
||||
{ intervalMs: 1 },
|
||||
)
|
||||
const read = await reader.read(new Uint8Array([1]), { filename: "contract.pdf", idempotencyKey: "key-1" })
|
||||
expect(submitted).toEqual([{ filename: "contract.pdf", idempotencyKey: "key-1" }])
|
||||
expect(read).toEqual([{ page: 1, index: 0, blockIds: ["p1-b1"], text: "Agreement\nSigned" }])
|
||||
})
|
||||
|
||||
it("answers a job the platform failed, or a document it cannot cite, as not indexable", async () => {
|
||||
const failing = sdkKnowledgeReader({
|
||||
submit: (async () => ({ id: 1 })) as never,
|
||||
wait: (async () => {
|
||||
throw new OcrError("failed", { type: "job-failed", status: 200 })
|
||||
}) as never,
|
||||
})
|
||||
await expect(failing.read(new Uint8Array([1]), { filename: "a.pdf", idempotencyKey: "k" })).rejects.toMatchObject({
|
||||
permanent: "not_indexable",
|
||||
})
|
||||
|
||||
const longId = {
|
||||
...document,
|
||||
pages: [{ ...document.pages[0], blocks: [{ id: "x".repeat(257), order: 0, lines: [0, 1] }] }],
|
||||
}
|
||||
const uncitable = sdkKnowledgeReader({
|
||||
submit: (async () => ({ id: 1 })) as never,
|
||||
wait: (async () => longId) as never,
|
||||
})
|
||||
await expect(uncitable.read(new Uint8Array([1]), { filename: "a.pdf", idempotencyKey: "k" })).rejects.toMatchObject(
|
||||
{
|
||||
permanent: "not_indexable",
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
it("leaves a transient failure to the queue", async () => {
|
||||
const transient = sdkKnowledgeReader({
|
||||
submit: (async () => {
|
||||
throw new OcrError("unavailable", { status: 503 })
|
||||
}) as never,
|
||||
wait: (async () => document) as never,
|
||||
})
|
||||
const error = await transient.read(new Uint8Array([1]), { filename: "a.pdf", idempotencyKey: "k" }).catch((e) => e)
|
||||
expect(error).toBeInstanceOf(OcrError)
|
||||
expect((error as { permanent?: unknown }).permanent).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,325 @@
|
||||
import { createMemoryDataSource } from "@conusai-kit/data"
|
||||
import { DEFAULT_FILES_POLICY, type FilesDeps, files } from "@conusai-kit/files"
|
||||
import { type Knowledge, KnowledgeError, type KnowledgeSearchHit } from "@conusai-kit/platform"
|
||||
import { fakeObjectStore } from "@conusai-kit/testing"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { platformKnowledgeSearch } from "../adapters/platform-knowledge.js"
|
||||
import type { KnowledgeScope } from "../core/ports.js"
|
||||
|
||||
const scope: KnowledgeScope = {
|
||||
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 otherScope: KnowledgeScope = {
|
||||
...scope,
|
||||
projectId: "01900000-0000-7000-0000-000000000004",
|
||||
}
|
||||
const storageKey = `files/${scope.workspaceId}/${scope.projectId}/01900000-0000-7000-0000-000000000100`
|
||||
const otherStorageKey = `files/${otherScope.workspaceId}/${otherScope.projectId}/01900000-0000-7000-0000-000000000101`
|
||||
const pathname = `org/${scope.orgId}/${storageKey}`
|
||||
|
||||
interface ClientStub {
|
||||
indexed: string[]
|
||||
removed: string[]
|
||||
searches: Array<Parameters<Knowledge["search"]>[0]>
|
||||
results: readonly KnowledgeSearchHit[]
|
||||
}
|
||||
|
||||
function knowledgeClient(stub: ClientStub): Knowledge {
|
||||
return {
|
||||
readiness: async () => ({ state: "ready", reasons: [], checkedAt: new Date().toISOString() }),
|
||||
createCollection: async (input) => input,
|
||||
search: async (input) => {
|
||||
stub.searches.push(input)
|
||||
return stub.results
|
||||
},
|
||||
index: async (input) => {
|
||||
stub.indexed.push(input.documentRef)
|
||||
return { jobId: "job-1" }
|
||||
},
|
||||
job: async (jobId) => ({ jobId, status: "succeeded", progress: 1, generation: 1, error: null }),
|
||||
cancel: async () => undefined,
|
||||
remove: async (documentRef) => {
|
||||
stub.removed.push(documentRef)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function stub(results: readonly KnowledgeSearchHit[] = []): ClientStub {
|
||||
return { indexed: [], removed: [], searches: [], results }
|
||||
}
|
||||
|
||||
function makeFiles() {
|
||||
const data = createMemoryDataSource({ entities: [files] })
|
||||
const deps: FilesDeps = { data, store: fakeObjectStore(), policy: DEFAULT_FILES_POLICY }
|
||||
return { data, deps }
|
||||
}
|
||||
|
||||
async function seedFile(
|
||||
data: ReturnType<typeof createMemoryDataSource>,
|
||||
atScope: KnowledgeScope,
|
||||
key: string,
|
||||
filename = "contract.txt",
|
||||
): Promise<string> {
|
||||
const row = await data.forScope(atScope).of(files).insert({
|
||||
status: "stored",
|
||||
storageKey: key,
|
||||
filename,
|
||||
contentType: "text/plain",
|
||||
size: 17,
|
||||
sha256: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
uploadedBy: atScope.actor.userId,
|
||||
storedAt: "2026-10-24T00:00:00.000Z",
|
||||
uploadId: null,
|
||||
declaredSize: null,
|
||||
partSize: null,
|
||||
deletingAt: null,
|
||||
})
|
||||
return row.id
|
||||
}
|
||||
|
||||
describe("the managed knowledge Blob reference", () => {
|
||||
it("indexes and removes by Blob pathname rather than opaque file id", async () => {
|
||||
const { data, deps } = makeFiles()
|
||||
const fileId = await seedFile(data, scope, storageKey)
|
||||
const client = stub()
|
||||
const service = platformKnowledgeSearch(knowledgeClient(client), deps, { environment: "preview" })
|
||||
|
||||
await service.index(scope, {
|
||||
collectionName: "contracts",
|
||||
fileId,
|
||||
filename: "contract.txt",
|
||||
bytes: new TextEncoder().encode("managed knowledge"),
|
||||
})
|
||||
await service.remove(scope, { collectionName: "contracts", fileId })
|
||||
|
||||
expect(client.indexed).toEqual([pathname])
|
||||
expect(client.removed).toEqual([pathname])
|
||||
})
|
||||
|
||||
it("removes a deleted source by the key it had, whose files row is gone (F407)", async () => {
|
||||
const { data, deps } = makeFiles()
|
||||
const fileId = await seedFile(data, scope, storageKey)
|
||||
await data.forScope(scope).of(files).delete(fileId)
|
||||
const client = stub()
|
||||
const service = platformKnowledgeSearch(knowledgeClient(client), deps, { environment: "preview" })
|
||||
|
||||
// Without the key the file can no longer be found, so the removal refuses rather than guessing.
|
||||
await expect(service.remove(scope, { collectionName: "contracts", fileId })).rejects.toThrow()
|
||||
await service.remove(scope, { collectionName: "contracts", fileId, storageKey })
|
||||
|
||||
expect(client.removed).toEqual([pathname])
|
||||
})
|
||||
|
||||
it("maps citations through scoped file rows and drops foreign or stale references", async () => {
|
||||
const { data, deps } = makeFiles()
|
||||
const fileId = await seedFile(data, scope, storageKey)
|
||||
await seedFile(data, otherScope, otherStorageKey, "other-project.txt")
|
||||
const client = stub([
|
||||
{ documentRef: pathname, snippet: "own snippet", score: 0.9, citations: [{ page: 1 }] },
|
||||
{
|
||||
documentRef: `org/${scope.orgId}/${otherStorageKey}`,
|
||||
snippet: "other project snippet",
|
||||
score: 0.8,
|
||||
citations: [],
|
||||
},
|
||||
{
|
||||
documentRef: `org/01900000-0000-7000-0000-000000000777/${storageKey}`,
|
||||
snippet: "other organization snippet",
|
||||
score: 0.7,
|
||||
citations: [],
|
||||
},
|
||||
{
|
||||
documentRef: `org/${scope.orgId}/files/${scope.workspaceId}/${scope.projectId}/01900000-0000-7000-0000-000000000200`,
|
||||
snippet: "stale snippet",
|
||||
score: 0.6,
|
||||
citations: [],
|
||||
},
|
||||
])
|
||||
const service = platformKnowledgeSearch(knowledgeClient(client), deps, { environment: "preview" })
|
||||
|
||||
const hits = await service.search(scope, {
|
||||
collectionName: "contracts",
|
||||
query: "contract",
|
||||
limit: 10,
|
||||
actorId: scope.actor.userId,
|
||||
})
|
||||
|
||||
expect(hits).toEqual([
|
||||
{
|
||||
fileId,
|
||||
filename: "contract.txt",
|
||||
collectionName: "contracts",
|
||||
score: 0.9,
|
||||
chunk: 1,
|
||||
snippet: "own snippet",
|
||||
page: 1,
|
||||
},
|
||||
])
|
||||
expect(client.searches).toHaveLength(1)
|
||||
expect(client.searches[0]).toMatchObject({ collection: "contracts", query: "contract", limit: 10 })
|
||||
expect(client.searches[0]?.tenant).toMatch(/^[0-9a-f]{64}$/)
|
||||
expect(client.searches[0]).not.toHaveProperty("ownerRef")
|
||||
})
|
||||
|
||||
it("refuses a foreign file id before any managed index call", async () => {
|
||||
const { data, deps } = makeFiles()
|
||||
const foreignFileId = await seedFile(data, otherScope, otherStorageKey)
|
||||
const client = stub()
|
||||
const service = platformKnowledgeSearch(knowledgeClient(client), deps, { environment: "preview" })
|
||||
|
||||
await expect(
|
||||
service.index(scope, {
|
||||
collectionName: "contracts",
|
||||
fileId: foreignFileId,
|
||||
filename: "other-project.txt",
|
||||
bytes: new TextEncoder().encode("foreign content"),
|
||||
}),
|
||||
).rejects.toThrow()
|
||||
expect(client.indexed).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("the managed service's collections", () => {
|
||||
it("creates the collection in the deployment's environment before it indexes: the service answers 404 otherwise", async () => {
|
||||
const { data, deps } = makeFiles()
|
||||
const fileId = await seedFile(data, scope, storageKey)
|
||||
const order: string[] = []
|
||||
const client: Knowledge = {
|
||||
...knowledgeClient(stub()),
|
||||
createCollection: async (input) => {
|
||||
order.push(`collection ${input.collection} in ${input.environment}`)
|
||||
return input
|
||||
},
|
||||
index: async () => {
|
||||
order.push("index")
|
||||
return { jobId: "job-1" }
|
||||
},
|
||||
}
|
||||
const input = { collectionName: "contracts", fileId, filename: "a.txt", bytes: new TextEncoder().encode("text") }
|
||||
|
||||
await platformKnowledgeSearch(client, deps, { environment: "preview" }).index(scope, input)
|
||||
expect(order).toEqual(["collection contracts in preview", "index"])
|
||||
|
||||
order.length = 0
|
||||
const missing = platformKnowledgeSearch(client, deps, { environment: undefined })
|
||||
expect(await missing.readiness(scope)).toMatchObject({ state: "unavailable", refusal: "configuration_missing" })
|
||||
await expect(missing.index(scope, input)).rejects.toMatchObject({
|
||||
reason: "configuration_missing",
|
||||
permanent: "configuration_missing",
|
||||
})
|
||||
expect(order).toEqual([])
|
||||
})
|
||||
|
||||
it("answers no results for a collection nothing was indexed into yet", async () => {
|
||||
const client: Knowledge = {
|
||||
...knowledgeClient(stub()),
|
||||
search: async () => {
|
||||
throw new KnowledgeError("unavailable_resource", "no such collection")
|
||||
},
|
||||
}
|
||||
const hits = await platformKnowledgeSearch(client, makeFiles().deps, { environment: "preview" }).search(scope, {
|
||||
collectionName: "empty",
|
||||
query: "anything",
|
||||
limit: 5,
|
||||
actorId: scope.actor.userId,
|
||||
})
|
||||
expect(hits).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("the managed service's refusals", () => {
|
||||
const answering = (over: Partial<Knowledge>) => ({ ...knowledgeClient(stub()), ...over })
|
||||
|
||||
it.each([
|
||||
["plan_required", "disabled", "plan_required"],
|
||||
["feature_disabled", "disabled", "feature_disabled"],
|
||||
["forbidden", "unavailable", "scope_missing"],
|
||||
["unauthenticated", "unavailable", "runtime_unavailable"],
|
||||
["runtime_unavailable", "unavailable", "runtime_unavailable"],
|
||||
["indexing", "indexing", "indexing"],
|
||||
] as const)("readiness refused with %s is state %s and the typed refusal %s", async (reason, state, refusal) => {
|
||||
const client = answering({
|
||||
readiness: async () => {
|
||||
throw new KnowledgeError(reason, "the service's own words")
|
||||
},
|
||||
})
|
||||
const answer = await platformKnowledgeSearch(client, makeFiles().deps, { environment: "preview" }).readiness(scope)
|
||||
expect(answer).toEqual({ state, reasons: [], refusal })
|
||||
})
|
||||
|
||||
it("records a refusal waiting cannot fix on the document, and leaves a transient failure to the queue", async () => {
|
||||
const { data, deps } = makeFiles()
|
||||
const fileId = await seedFile(data, scope, storageKey)
|
||||
const bytes = new TextEncoder().encode("managed knowledge")
|
||||
const input = { collectionName: "contracts", fileId, filename: "contract.txt", bytes }
|
||||
|
||||
const quota = platformKnowledgeSearch(
|
||||
answering({
|
||||
job: async (jobId) => ({ jobId, status: "failed", progress: null, generation: null, error: "quota-exhausted" }),
|
||||
}),
|
||||
deps,
|
||||
{ environment: "preview" },
|
||||
)
|
||||
await expect(quota.index(scope, input)).rejects.toMatchObject({ permanent: "quota_exhausted" })
|
||||
|
||||
const scope_missing = platformKnowledgeSearch(
|
||||
answering({
|
||||
index: async () => {
|
||||
throw new KnowledgeError("forbidden", "the token lacks knowledge.collections:use")
|
||||
},
|
||||
}),
|
||||
deps,
|
||||
{ environment: "preview" },
|
||||
)
|
||||
await expect(scope_missing.index(scope, input)).rejects.toMatchObject({ permanent: "scope_missing" })
|
||||
|
||||
const down = platformKnowledgeSearch(
|
||||
answering({
|
||||
index: async () => {
|
||||
throw new KnowledgeError("runtime_unavailable", "try later", true)
|
||||
},
|
||||
}),
|
||||
deps,
|
||||
{ environment: "preview" },
|
||||
)
|
||||
const transient = await down.index(scope, input).catch((error: unknown) => error)
|
||||
expect(transient).toBeInstanceOf(KnowledgeError)
|
||||
expect(transient).not.toHaveProperty("permanent")
|
||||
})
|
||||
})
|
||||
|
||||
describe("bounded durable index polling", () => {
|
||||
it("stops a queued wait on cancellation without cancelling the durable platform job", async () => {
|
||||
const { data, deps } = makeFiles()
|
||||
const fileId = await seedFile(data, scope, storageKey)
|
||||
const controller = new AbortController()
|
||||
let polls = 0
|
||||
let cancellations = 0
|
||||
const client: Knowledge = {
|
||||
...knowledgeClient(stub()),
|
||||
job: async (jobId) => {
|
||||
polls++
|
||||
controller.abort()
|
||||
return { jobId, status: "queued", progress: null, generation: null, error: null }
|
||||
},
|
||||
cancel: async () => {
|
||||
cancellations++
|
||||
},
|
||||
}
|
||||
await expect(
|
||||
platformKnowledgeSearch(client, deps, { environment: "preview", intervalMs: 1 }).index(scope, {
|
||||
collectionName: "contracts",
|
||||
fileId,
|
||||
filename: "a.txt",
|
||||
bytes: new Uint8Array(),
|
||||
signal: controller.signal,
|
||||
}),
|
||||
).rejects.toBeDefined()
|
||||
expect(polls).toBe(1)
|
||||
expect(cancellations).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,144 @@
|
||||
// Every knowledge procedure a page calls, run as the kernel runs it: `createCaller` hands a handler the
|
||||
// container of its kind, and that container refuses a port above the kind. `knowledge.status` backs the
|
||||
// knowledge page and `knowledge.settings.get` the organization's settings page, so a handler that reaches
|
||||
// for an effect port from a read is a 500 `kind_violation` on those pages on every real stack. The ports
|
||||
// stay effects: what changes is what each handler asks for.
|
||||
|
||||
import { Container, type ProcedureKind, type TenantScope } from "@conusai-kit/core"
|
||||
import { createMemoryDataSource, DATA_SOURCE, OBJECT_STORE } from "@conusai-kit/data"
|
||||
import { files } from "@conusai-kit/files"
|
||||
import { fakeObjectStore } from "@conusai-kit/testing"
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import {
|
||||
knowledgeStatusImpl,
|
||||
settingsDisableDocumentAiImpl,
|
||||
settingsEnableDocumentAiImpl,
|
||||
settingsGetImpl,
|
||||
} from "../adapters/procedures.js"
|
||||
import { platformKnowledgeReadiness } from "../adapters/readiness.js"
|
||||
import { KNOWLEDGE_ENTITIES } from "../contract/index.js"
|
||||
import {
|
||||
type DocumentAiPlatform,
|
||||
KNOWLEDGE_ENABLEMENT,
|
||||
KNOWLEDGE_READINESS,
|
||||
KNOWLEDGE_SEARCH,
|
||||
type KnowledgeScope,
|
||||
} from "../core/ports.js"
|
||||
|
||||
const scope: TenantScope & KnowledgeScope = {
|
||||
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 projectInput = { org: "acme", workspace: "general", project: "demo" }
|
||||
const orgInput = { org: "acme" }
|
||||
|
||||
const processContainer = () => {
|
||||
const container = new Container()
|
||||
container.value(DATA_SOURCE, createMemoryDataSource({ entities: [...KNOWLEDGE_ENTITIES, files] }))
|
||||
container.value(OBJECT_STORE, fakeObjectStore())
|
||||
return container
|
||||
}
|
||||
|
||||
/** A handler as the kernel calls it (`createCaller`): with the container of its procedure's kind. */
|
||||
const run = (
|
||||
impl: { readonly kind: ProcedureKind; readonly handler: (call: never) => unknown },
|
||||
input: unknown,
|
||||
container: Container,
|
||||
) => impl.handler({ input, ctx: { container: container.restrict(impl.kind), scope } } as never)
|
||||
|
||||
const neverBuilt = () =>
|
||||
vi.fn((): never => {
|
||||
throw new Error("a port above this procedure's kind was built")
|
||||
})
|
||||
|
||||
const platform: DocumentAiPlatform = {
|
||||
checkEligibility: async () => ({ ok: true }),
|
||||
indexingQuota: async () => 1000,
|
||||
}
|
||||
|
||||
describe("the ports that leave the process", () => {
|
||||
it("stay refused to a read and a write", () => {
|
||||
const container = processContainer()
|
||||
container.value(KNOWLEDGE_ENABLEMENT, platform)
|
||||
container.register(KNOWLEDGE_SEARCH, neverBuilt())
|
||||
for (const kind of ["read", "write"] as const) {
|
||||
expect(() => container.restrict(kind).resolve(KNOWLEDGE_SEARCH)).toThrow(/cannot resolve knowledge.search/)
|
||||
expect(() => container.restrict(kind).resolve(KNOWLEDGE_ENABLEMENT)).toThrow(
|
||||
/cannot resolve knowledge.enablement/,
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("the knowledge page's knowledge.status", () => {
|
||||
it("answers through a read handler's container, and never builds the search service", async () => {
|
||||
const container = processContainer()
|
||||
const search = neverBuilt()
|
||||
container.register(KNOWLEDGE_SEARCH, search)
|
||||
await expect(run(knowledgeStatusImpl, projectInput, container)).resolves.toMatchObject({
|
||||
documents: { queued: 0, indexing: 0, indexed: 0, failed: 0, cancelled: 0 },
|
||||
})
|
||||
expect(search).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("the settings page's knowledge.settings", () => {
|
||||
it("get answers through a read handler's container, without the enablement platform", async () => {
|
||||
const container = processContainer()
|
||||
const enablement = neverBuilt()
|
||||
container.register(KNOWLEDGE_ENABLEMENT, enablement)
|
||||
await expect(run(settingsGetImpl, orgInput, container)).resolves.toEqual({
|
||||
enabled: false,
|
||||
backend: null,
|
||||
})
|
||||
expect(enablement).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("enable reaches the platform and stores the configuration", async () => {
|
||||
const container = processContainer()
|
||||
container.value(KNOWLEDGE_ENABLEMENT, platform)
|
||||
await expect(run(settingsEnableDocumentAiImpl, orgInput, container)).resolves.toEqual({
|
||||
enabled: true,
|
||||
backend: "platform",
|
||||
})
|
||||
await expect(run(settingsGetImpl, orgInput, container)).resolves.toEqual({ enabled: true, backend: "platform" })
|
||||
})
|
||||
|
||||
it("disable only flips the stored flag, without the enablement platform", async () => {
|
||||
const container = processContainer()
|
||||
const enablement = neverBuilt()
|
||||
container.register(KNOWLEDGE_ENABLEMENT, enablement)
|
||||
await expect(run(settingsDisableDocumentAiImpl, orgInput, container)).resolves.toEqual({ enabled: false })
|
||||
expect(enablement).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("what knowledge.status says of the backend", () => {
|
||||
it("says what the bound readiness says, and asks it with the request's scope", async () => {
|
||||
const container = processContainer()
|
||||
const readiness = vi.fn(async () => ({ state: "degraded" as const, reasons: ["the index is behind"] }))
|
||||
container.value(KNOWLEDGE_READINESS, { readiness })
|
||||
await expect(run(knowledgeStatusImpl, projectInput, container)).resolves.toMatchObject({
|
||||
service: { state: "degraded", reasons: ["the index is behind"] },
|
||||
})
|
||||
expect(readiness).toHaveBeenCalledWith(scope)
|
||||
})
|
||||
|
||||
it("says the backend is not bound in a process that has none", async () => {
|
||||
await expect(run(knowledgeStatusImpl, projectInput, processContainer())).resolves.toMatchObject({
|
||||
service: { state: "unavailable", reasons: ["the knowledge search service is not bound in this process"] },
|
||||
})
|
||||
})
|
||||
|
||||
it("reports the managed service failing to answer as unavailable, not as an error page", async () => {
|
||||
const container = processContainer()
|
||||
const client = { readiness: vi.fn(async () => Promise.reject(new Error("no answer\nstack"))) }
|
||||
container.value(KNOWLEDGE_READINESS, platformKnowledgeReadiness(client as never))
|
||||
await expect(run(knowledgeStatusImpl, projectInput, container)).resolves.toMatchObject({
|
||||
service: { state: "unavailable", reasons: [], refusal: "runtime_unavailable" },
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,157 @@
|
||||
import { syntheticNativeReadiness } from "../../../test/support/native-readiness.js"
|
||||
// A refusal of the platform's knowledge service reaches a person as one typed code: the procedures end to
|
||||
// end, on the real module, with the service's client refusing underneath; and each code a form looks up.
|
||||
|
||||
import { AppError, buildRegistry, Container, createCaller, type TransportCaller } from "@conusai-kit/core"
|
||||
import { createMemoryDataSource, DATA_SOURCE, entitiesOf, OBJECT_STORE, SYSTEM_DATA } from "@conusai-kit/data"
|
||||
import { FILES_REGISTRY, filesModule } from "@conusai-kit/files"
|
||||
import { createI18n } from "@conusai-kit/i18n"
|
||||
import { KnowledgeError, type KnowledgeReasonCode } from "@conusai-kit/platform"
|
||||
import { createTenancyHooks, TENANCY, tenancyModule, users } from "@conusai-kit/tenancy"
|
||||
import { fakeObjectStore } from "@conusai-kit/testing"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { knowledgeRefusal } from "../adapters/refusals.js"
|
||||
import { KNOWLEDGE_REFUSALS, type KnowledgeRefusal } from "../contract/index.js"
|
||||
import {
|
||||
KNOWLEDGE_ENABLEMENT,
|
||||
KNOWLEDGE_FLAGS,
|
||||
KNOWLEDGE_READINESS,
|
||||
KNOWLEDGE_SEARCH,
|
||||
type KnowledgeSearchService,
|
||||
} from "../core/ports.js"
|
||||
import { knowledgeModule } from "../index.js"
|
||||
import { refusalLabels, refusalOf } from "../ui/labels.js"
|
||||
|
||||
async function app(
|
||||
service: KnowledgeSearchService,
|
||||
options: { flags?: boolean } = {},
|
||||
): Promise<{ call: TransportCaller; slugs: Record<string, string> }> {
|
||||
const registry = buildRegistry([tenancyModule, filesModule, knowledgeModule])
|
||||
const data = createMemoryDataSource({ entities: entitiesOf(registry, "postgres") })
|
||||
const root = new Container()
|
||||
root.value(DATA_SOURCE, data)
|
||||
root.value(SYSTEM_DATA, data)
|
||||
root.value(OBJECT_STORE, fakeObjectStore())
|
||||
root.value(FILES_REGISTRY, registry)
|
||||
root.value(KNOWLEDGE_SEARCH, service)
|
||||
root.value(KNOWLEDGE_READINESS, service)
|
||||
root.value(KNOWLEDGE_ENABLEMENT, { checkEligibility: async () => ({ ok: true }), indexingQuota: async () => 1000 })
|
||||
if (options.flags !== false) root.value(KNOWLEDGE_FLAGS, { enabled: () => true })
|
||||
const user = await data.global(users).insert({
|
||||
issuer: "https://id.example.test",
|
||||
subject: "owner",
|
||||
email: "owner@example.test",
|
||||
displayName: "Owner",
|
||||
locale: "en",
|
||||
})
|
||||
const container = root.child()
|
||||
const tenancy = createTenancyHooks({ data, registry })
|
||||
container.value(TENANCY, tenancy)
|
||||
const call = createCaller(
|
||||
{ registry, container, tenancy, serviceReadiness: syntheticNativeReadiness },
|
||||
{ actor: { userId: user.id } },
|
||||
)
|
||||
await call.callByName("tenancy.org.create", { slug: "acme", name: "Acme" })
|
||||
await call.callByName("tenancy.workspace.create", { org: "acme", slug: "ws", name: "WS", visibility: "org" })
|
||||
await call.callByName("tenancy.project.create", {
|
||||
org: "acme",
|
||||
workspace: "ws",
|
||||
slug: "p",
|
||||
name: "P",
|
||||
moduleKey: "knowledge",
|
||||
})
|
||||
return { call, slugs: { org: "acme", workspace: "ws", project: "p" } }
|
||||
}
|
||||
|
||||
const refusing = (reason: KnowledgeReasonCode): KnowledgeSearchService => ({
|
||||
readiness: async () => ({ state: "disabled", reasons: [], refusal: "plan_required" }),
|
||||
index: async () => undefined,
|
||||
search: async () => {
|
||||
throw new KnowledgeError(reason, "the service's own words")
|
||||
},
|
||||
remove: async () => undefined,
|
||||
})
|
||||
|
||||
describe("a refused search", () => {
|
||||
it.each([
|
||||
["plan_required", "plan_required", 403],
|
||||
["feature_disabled", "feature_disabled", 403],
|
||||
["quota_exhausted", "quota_exhausted", 429],
|
||||
["budget_exhausted", "budget_exhausted", 429],
|
||||
["forbidden", "scope_missing", 503],
|
||||
["indexing", "indexing", 503],
|
||||
["approval_required", "approval_required", 403],
|
||||
["runtime_unavailable", "runtime_unavailable", 503],
|
||||
["unauthenticated", "runtime_unavailable", 503],
|
||||
] as const)("by %s answers %s", async (reason, code, status) => {
|
||||
const { call, slugs } = await app(refusing(reason))
|
||||
await call.callByName("knowledge.collection.create", { ...slugs, name: "contracts" })
|
||||
const refused = await call
|
||||
.callByName("knowledge.search", { ...slugs, collectionName: "contracts", query: "anything" })
|
||||
.catch((error: unknown) => error)
|
||||
expect(refused).toBeInstanceOf(AppError)
|
||||
expect((refused as AppError).problem).toMatchObject({ code, status })
|
||||
})
|
||||
|
||||
it("leaves a resource the service does not hold as not found, and a programming error as it is", () => {
|
||||
const missing = knowledgeRefusal(new KnowledgeError("unavailable_resource", "no such collection"))
|
||||
expect((missing as AppError).problem.status).toBe(404)
|
||||
const bug = new TypeError("not a refusal")
|
||||
expect(knowledgeRefusal(bug)).toBe(bug)
|
||||
})
|
||||
})
|
||||
|
||||
describe("the status a page reads", () => {
|
||||
it("carries the service's refusal through the read procedure", async () => {
|
||||
const { call, slugs } = await app(refusing("plan_required"))
|
||||
expect(await call.callByName("knowledge.status", slugs)).toMatchObject({
|
||||
enabled: true,
|
||||
service: { state: "disabled", reasons: [], refusal: "plan_required" },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("enabling document AI", () => {
|
||||
const ready: KnowledgeSearchService = {
|
||||
readiness: async () => ({ state: "ready", reasons: [] }),
|
||||
index: async () => undefined,
|
||||
search: async () => [],
|
||||
remove: async () => undefined,
|
||||
}
|
||||
|
||||
it("turns knowledge on for the organization's projects, and disabling turns it off again", async () => {
|
||||
const { call, slugs } = await app(ready, { flags: false })
|
||||
const enabled = async () => ((await call.callByName("knowledge.status", slugs)) as { enabled: boolean }).enabled
|
||||
expect(await enabled()).toBe(false)
|
||||
await call.callByName("knowledge.settings.enableDocumentAi", { org: slugs.org })
|
||||
expect(await enabled()).toBe(true)
|
||||
await call.callByName("knowledge.settings.disableDocumentAi", { org: slugs.org })
|
||||
expect(await enabled()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("a form's refusal", () => {
|
||||
const t = (key: string) => key
|
||||
it("names every typed refusal, and leaves any other error to the form's own text", () => {
|
||||
const labels = refusalLabels(t)
|
||||
expect(Object.keys(labels).sort()).toEqual([...KNOWLEDGE_REFUSALS].sort())
|
||||
const refused = (code: KnowledgeRefusal) => ({ problem: { code } })
|
||||
expect(refusalOf(labels, refused("scope_missing"))).toEqual({
|
||||
code: "scope_missing",
|
||||
text: "refusals.scope_missing",
|
||||
})
|
||||
expect(refusalOf(labels, { problem: { code: "not_found" } })).toBeUndefined()
|
||||
expect(refusalOf(labels, new Error("boom"))).toBeUndefined()
|
||||
})
|
||||
|
||||
it("has a message in both languages, the Lithuanian one its own", () => {
|
||||
const catalogues = buildRegistry([tenancyModule, filesModule, knowledgeModule]).catalogues
|
||||
const en = createI18n("en", { en: { knowledge: catalogues.en?.knowledge ?? {} } })
|
||||
const lt = createI18n("lt", { lt: { knowledge: catalogues.lt?.knowledge ?? {} } })
|
||||
for (const code of KNOWLEDGE_REFUSALS) {
|
||||
const english = en.message(`knowledge.refusals.${code}`)
|
||||
expect(english, code).not.toContain("refusals.")
|
||||
expect(lt.message(`knowledge.refusals.${code}`), code).not.toBe(english)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,121 @@
|
||||
// The enablement flow's semantics: idempotent enable, typed denials from the platform checks, nothing
|
||||
// provisioned on the app's side, and disable that preserves every row and every indexed document.
|
||||
|
||||
import type { TenantScope } from "@conusai-kit/core"
|
||||
import { createMemoryDataSource } from "@conusai-kit/data"
|
||||
import { DEFAULT_FILES_POLICY } from "@conusai-kit/files"
|
||||
import { fakeObjectStore } from "@conusai-kit/testing"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { collections, config, documents, KNOWLEDGE_ENTITIES } from "../contract/index.js"
|
||||
import { createCollection } from "../core/collections.js"
|
||||
import type { DocumentAiPlatform } from "../core/ports.js"
|
||||
import { disableDocumentAi, enableDocumentAi, getSettings } from "../core/settings.js"
|
||||
|
||||
const ORG = "01900000-0000-7000-0000-000000000001"
|
||||
const scope: TenantScope = { level: "org", actor: { userId: "01900000-0000-7000-0000-000000000009" }, orgId: ORG }
|
||||
|
||||
const platform = (over: Partial<DocumentAiPlatform> = {}): DocumentAiPlatform => ({
|
||||
checkEligibility: async () => ({ ok: true }),
|
||||
indexingQuota: async () => 1000,
|
||||
...over,
|
||||
})
|
||||
|
||||
const deps = (data: ReturnType<typeof createMemoryDataSource>, p: DocumentAiPlatform) => ({
|
||||
data,
|
||||
platform: p,
|
||||
now: () => Date.UTC(2026, 9, 23),
|
||||
})
|
||||
|
||||
describe("the enablement flow", () => {
|
||||
it("enables idempotently: the second call writes nothing and answers the same configuration", async () => {
|
||||
const data = createMemoryDataSource({ entities: [...KNOWLEDGE_ENTITIES] })
|
||||
const first = await enableDocumentAi(deps(data, platform()), scope)
|
||||
expect(first).toEqual({ enabled: true, backend: "platform" })
|
||||
const second = await enableDocumentAi(deps(data, platform()), scope)
|
||||
expect(second).toEqual(first)
|
||||
expect((await getSettings(deps(data, platform()), scope)).enabled).toBe(true)
|
||||
})
|
||||
|
||||
it("surfaces the platform's denials as typed reason codes, retirable", async () => {
|
||||
const data = createMemoryDataSource({ entities: [...KNOWLEDGE_ENTITIES] })
|
||||
const denied = await enableDocumentAi(
|
||||
deps(data, platform({ checkEligibility: async () => ({ ok: false, reason: "plan_required" }) })),
|
||||
scope,
|
||||
).catch((error: unknown) => error)
|
||||
expect((denied as { code?: string }).code).toBe("plan_required")
|
||||
// Nothing was persisted by the denied attempt; the retry path runs the same checks again.
|
||||
expect((await getSettings(deps(data, platform()), scope)).enabled).toBe(false)
|
||||
const quota = await enableDocumentAi(deps(data, platform({ indexingQuota: async () => 0 })), scope).catch(
|
||||
(error: unknown) => error,
|
||||
)
|
||||
expect((quota as { code?: string }).code).toBe("quota_exhausted")
|
||||
})
|
||||
|
||||
it("provisions nothing of the app's own: enabling records that the platform serves this organization", async () => {
|
||||
const data = createMemoryDataSource({ entities: [...KNOWLEDGE_ENTITIES] })
|
||||
const asked: string[] = []
|
||||
const watched: DocumentAiPlatform = new Proxy(platform(), {
|
||||
get: (target, name, receiver) => {
|
||||
asked.push(String(name))
|
||||
return Reflect.get(target, name, receiver)
|
||||
},
|
||||
})
|
||||
await enableDocumentAi(deps(data, watched), scope)
|
||||
expect(asked).toEqual(["checkEligibility", "indexingQuota"])
|
||||
const rows = await data.forScope(scope).of(config).list({ limit: 1 })
|
||||
expect(rows.items[0]).toMatchObject({ enabled: true, backend: "platform", serviceId: null, url: null })
|
||||
expect(await getSettings(deps(data, platform()), scope)).toEqual({ enabled: true, backend: "platform" })
|
||||
})
|
||||
|
||||
it("disable preserves collections, documents and the recorded configuration; re-enable resumes without duplication", async () => {
|
||||
const data = createMemoryDataSource({ entities: [...KNOWLEDGE_ENTITIES] })
|
||||
const projectScope: TenantScope = {
|
||||
level: "project",
|
||||
actor: { userId: "01900000-0000-7000-0000-000000000009" },
|
||||
orgId: ORG,
|
||||
workspaceId: "01900000-0000-7000-0000-000000000002",
|
||||
projectId: "01900000-0000-7000-0000-000000000003",
|
||||
}
|
||||
const knowledgeDepsForProject = {
|
||||
data,
|
||||
files: { data, store: fakeObjectStore(), policy: DEFAULT_FILES_POLICY },
|
||||
flags: { enabled: async () => true },
|
||||
}
|
||||
await enableDocumentAi(deps(data, platform()), scope)
|
||||
const collection = await createCollection(knowledgeDepsForProject, projectScope as never, "contracts")
|
||||
expect(collection.name).toBe("contracts")
|
||||
// Seed a document row directly (the upload half is the files module's own suite).
|
||||
await data.forScope(projectScope).of(documents).insert({
|
||||
collectionName: "contracts",
|
||||
fileId: "01900000-0000-7000-0000-000000000099",
|
||||
filename: "a.txt",
|
||||
status: "indexed",
|
||||
error: null,
|
||||
indexedAt: "2026-10-23T00:00:00.000Z",
|
||||
requestedBy: "01900000-0000-7000-0000-000000000009",
|
||||
})
|
||||
|
||||
const disabled = await disableDocumentAi(deps(data, platform()), scope)
|
||||
expect(disabled.enabled).toBe(false)
|
||||
// Everything is still there.
|
||||
expect((await getSettings(deps(data, platform()), scope)).backend).toBe("platform")
|
||||
const collectionsAfter = await data
|
||||
.forScope(projectScope)
|
||||
.of(collections)
|
||||
.list({ where: [{ field: "name", op: "eq", value: "contracts" }], limit: 1 })
|
||||
expect(collectionsAfter.items).toHaveLength(1)
|
||||
const documentsAfter = await data
|
||||
.forScope(projectScope)
|
||||
.of(documents)
|
||||
.list({ where: [{ field: "collectionName", op: "eq", value: "contracts" }], limit: 1 })
|
||||
expect(documentsAfter.items[0]?.status).toBe("indexed")
|
||||
|
||||
// Re-enable: idempotent, same row, no second collection.
|
||||
await enableDocumentAi(deps(data, platform()), scope)
|
||||
const again = await data
|
||||
.forScope(projectScope)
|
||||
.of(collections)
|
||||
.list({ where: [{ field: "name", op: "eq", value: "contracts" }], limit: 1 })
|
||||
expect(again.items).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,86 @@
|
||||
// The components' markup, rendered on the server the way a page renders it: what a screen reader
|
||||
// and a keyboard user get is in the HTML — a name for every control, a status that says what a
|
||||
// colour would, and the AI disclosure where AI output appears.
|
||||
|
||||
import { buildRegistry } from "@conusai-kit/core"
|
||||
import { builtInCatalogues, createI18n, uiLabels } from "@conusai-kit/i18n"
|
||||
import { createDataClient, DataProvider } from "@conusai-kit/ui/data"
|
||||
import { UiLabelsProvider } from "@conusai-kit/ui/primitives"
|
||||
import { renderToStaticMarkup } from "react-dom/server"
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import { appModules } from "../../index.js"
|
||||
import { searchFormLabels } from "../ui/labels.js"
|
||||
import { SearchForm } from "../ui/search-form.js"
|
||||
import { SettingsSection } from "../ui/settings-section.js"
|
||||
|
||||
const catalogues = buildRegistry([...appModules]).catalogues
|
||||
const i18n = createI18n("en", { en: { knowledge: catalogues.en?.knowledge ?? {} } })
|
||||
const t = (key: string, values?: Record<string, string | number>) => i18n.message(`knowledge.${key}`, values)
|
||||
const failed = "That did not work: {detail}"
|
||||
const slugs = { org: "acme", workspace: "ws", project: "p" }
|
||||
|
||||
const kitStrings = uiLabels(createI18n("en", builtInCatalogues))
|
||||
|
||||
const inProvider = (node: React.ReactNode) =>
|
||||
renderToStaticMarkup(
|
||||
<UiLabelsProvider labels={kitStrings}>
|
||||
<DataProvider client={createDataClient({ fetch: async () => new Response(null, { status: 501 }) })}>
|
||||
{node}
|
||||
</DataProvider>
|
||||
</UiLabelsProvider>,
|
||||
)
|
||||
|
||||
describe("the search form", () => {
|
||||
const html = inProvider(
|
||||
<SearchForm slugs={slugs} collectionName="contracts" labels={searchFormLabels(t)} locale="en" />,
|
||||
)
|
||||
|
||||
it("names its controls and carries the AI disclosure", () => {
|
||||
expect(html).toMatch(/<label[^>]*for=/)
|
||||
expect(html).toContain('type="search"')
|
||||
expect(html).toContain("kit-ai-disclosure")
|
||||
expect(html).toContain(t("search.disclosure"))
|
||||
})
|
||||
})
|
||||
|
||||
describe("the settings section", () => {
|
||||
const labels = {
|
||||
heading: t("settings.heading"),
|
||||
state: t("settings.state"),
|
||||
stateOn: t("settings.stateOn"),
|
||||
stateOff: t("settings.stateOff"),
|
||||
backend: t("settings.backend"),
|
||||
backendPlatform: t("settings.backendPlatform"),
|
||||
enable: t("settings.enable"),
|
||||
enabling: t("settings.enabling"),
|
||||
disable: t("settings.disable"),
|
||||
disabling: t("settings.disabling"),
|
||||
enabledDone: t("settings.enabledDone"),
|
||||
disabledDone: t("settings.disabledDone"),
|
||||
planRequired: t("settings.planRequired"),
|
||||
featureDisabled: t("settings.featureDisabled"),
|
||||
quotaExhausted: t("settings.quotaExhausted"),
|
||||
upgrade: t("settings.upgrade"),
|
||||
hint: t("settings.hint"),
|
||||
failed,
|
||||
}
|
||||
|
||||
it("renders the state and the enable action when disabled", () => {
|
||||
const html = inProvider(<SettingsSection org="acme" initial={{ enabled: false, backend: null }} labels={labels} />)
|
||||
expect(html).toContain(t("settings.stateOff"))
|
||||
expect(html).toContain(`>${t("settings.enable")}<`)
|
||||
})
|
||||
|
||||
it("says the platform serves the organization, with the disable action, when enabled", () => {
|
||||
const html = inProvider(
|
||||
<SettingsSection org="acme" initial={{ enabled: true, backend: "platform" }} labels={labels} />,
|
||||
)
|
||||
expect(html).toContain(t("settings.backendPlatform").replaceAll("'", "'"))
|
||||
expect(html).toContain(`>${t("settings.disable")}<`)
|
||||
})
|
||||
})
|
||||
|
||||
// Explicit synthetic admission: module-local fixtures exercise the selected module.
|
||||
vi.mock("../../inclusion.json", () => ({
|
||||
default: { schemaVersion: 1, selected: ["knowledge"], everAdopted: ["knowledge"] },
|
||||
}))
|
||||
Reference in New Issue
Block a user