Files
template-app/modules/knowledge/__tests__/platform-knowledge.test.ts
T
2026-11-14 09:56:45 +00:00

326 lines
12 KiB
TypeScript

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)
})
})