@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user