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

135 lines
6.0 KiB
TypeScript

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