mirror of
https://github.com/langgenius/dify.git
synced 2026-09-02 22:36:15 +08:00
1390 lines
47 KiB
TypeScript
1390 lines
47 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
|
|
import type { DifyCapabilityV2SanitizedGrant } from "./dify-capability-v2-grant";
|
|
import type { DocumentAssetRepository } from "./document-asset-repository";
|
|
import type {
|
|
DocumentChunkRepository,
|
|
DocumentChunkStateService,
|
|
DocumentRevisionChunk,
|
|
} from "./document-chunk-repository";
|
|
import type {
|
|
DocumentCompilationJob,
|
|
DocumentCompilationJobStateMachine,
|
|
} from "./document-compilation-job";
|
|
import type {
|
|
DocumentProcessingTask,
|
|
DocumentProcessingTaskRepository,
|
|
} from "./document-processing-task-repository";
|
|
import type {
|
|
DocumentSettingsHead,
|
|
DocumentSettingsRepository,
|
|
} from "./document-settings-repository";
|
|
import { createKnowledgeGatewayApp } from "./gateway-app";
|
|
import type {
|
|
KnowledgeSpaceAccessService,
|
|
KnowledgeSpaceApiKeyPermissionBinding,
|
|
} from "./knowledge-space-access-control";
|
|
import {
|
|
KnowledgeSpaceAuthorizationError,
|
|
type KnowledgeSpaceAuthorizationGuard,
|
|
type KnowledgeSpaceCallerKind,
|
|
} from "./knowledge-space-authorization";
|
|
import {
|
|
type KnowledgeSpaceMetadataRepository,
|
|
KnowledgeSpaceMetadataValidationError,
|
|
} from "./knowledge-space-metadata-repository";
|
|
import type { KnowledgeSpaceRepository } from "./knowledge-space-repository";
|
|
import {
|
|
type DocumentSettingsChangeCoordinator,
|
|
type RegisterLogicalDocumentHandlersOptions,
|
|
registerLogicalDocumentHandlers,
|
|
} from "./logical-document-handlers";
|
|
import {
|
|
type DocumentRevision,
|
|
LogicalDocumentConflictError,
|
|
LogicalDocumentNotFoundError,
|
|
type LogicalDocumentRepository,
|
|
LogicalDocumentValidationError,
|
|
type LogicalDocumentWithActiveRevision,
|
|
createInMemoryLogicalDocumentRepository,
|
|
} from "./logical-document-repository";
|
|
|
|
const tenantId = "tenant-a";
|
|
const knowledgeSpaceId = "018f0d60-7a49-7cc2-9c1b-5b36f18f2c42";
|
|
const documentId = "018f0d60-7a49-7cc2-9c1b-5b36f18f2d01";
|
|
const assetId = "018f0d60-7a49-7cc2-9c1b-5b36f18f2d11";
|
|
const chunkId = "018f0d60-7a49-7cc2-9c1b-5b36f18f2d31";
|
|
const taskId = "018f0d60-7a49-7cc2-9c1b-5b36f18f2d41";
|
|
const sourceId = "018f0d60-7a49-7cc2-9c1b-5b36f18f2d51";
|
|
|
|
describe("logical document handlers", () => {
|
|
it("returns readable pending and failed documents even when no active revision exists", async () => {
|
|
const logicalDocuments = createInMemoryLogicalDocumentRepository({
|
|
canReadDocument: ({ candidateGrants }) => candidateGrants.includes("document:read"),
|
|
canReadRevision: ({ candidateGrants }) => candidateGrants.includes("document:read"),
|
|
generateDocumentId: () => documentId,
|
|
maxDocuments: 10,
|
|
maxRevisionsPerDocument: 10,
|
|
});
|
|
const created = await logicalDocuments.createCandidateRevision({
|
|
contentHash: "a".repeat(64),
|
|
documentAssetId: assetId,
|
|
documentAssetVersion: 1,
|
|
knowledgeSpaceId,
|
|
mimeType: "text/plain",
|
|
now: "2026-07-14T12:00:00.000Z",
|
|
sizeBytes: 12,
|
|
systemMetadata: {},
|
|
tenantId,
|
|
title: "Design notes",
|
|
});
|
|
const app = testApp(logicalDocuments);
|
|
|
|
const pendingList = await app.request(
|
|
`/knowledge-spaces/${knowledgeSpaceId}/logical-documents`,
|
|
);
|
|
expect(pendingList.status, await pendingList.clone().text()).toBe(200);
|
|
await expect(pendingList.json()).resolves.toMatchObject({
|
|
items: [{ active: null, id: documentId, status: "pending" }],
|
|
});
|
|
|
|
await logicalDocuments.failCandidate({
|
|
documentId,
|
|
knowledgeSpaceId,
|
|
now: "2026-07-14T12:01:00.000Z",
|
|
revision: created.revision.revision,
|
|
tenantId,
|
|
});
|
|
const failedGet = await app.request(
|
|
`/knowledge-spaces/${knowledgeSpaceId}/logical-documents/${documentId}`,
|
|
);
|
|
expect(failedGet.status, await failedGet.clone().text()).toBe(200);
|
|
await expect(failedGet.json()).resolves.toMatchObject({
|
|
active: null,
|
|
id: documentId,
|
|
status: "failed",
|
|
});
|
|
});
|
|
|
|
it("passes a fresh durable permission reference into the metadata CAS", async () => {
|
|
const logicalDocuments = createInMemoryLogicalDocumentRepository({
|
|
canReadDocument: ({ candidateGrants }) => candidateGrants.includes("document:read"),
|
|
canReadRevision: ({ candidateGrants }) => candidateGrants.includes("document:read"),
|
|
generateDocumentId: () => documentId,
|
|
maxDocuments: 10,
|
|
maxRevisionsPerDocument: 10,
|
|
});
|
|
await logicalDocuments.createCandidateRevision({
|
|
contentHash: "a".repeat(64),
|
|
documentAssetId: assetId,
|
|
documentAssetVersion: 1,
|
|
knowledgeSpaceId,
|
|
mimeType: "text/plain",
|
|
now: "2026-07-14T12:00:00.000Z",
|
|
sizeBytes: 12,
|
|
systemMetadata: {},
|
|
tenantId,
|
|
title: "Design notes",
|
|
});
|
|
const patch = vi.spyOn(logicalDocuments, "patchUserMetadata");
|
|
const createPermissionSnapshot = vi.fn(async () => permissionSnapshot());
|
|
const app = testApp(logicalDocuments, { createPermissionSnapshot });
|
|
|
|
const response = await app.request(
|
|
`/knowledge-spaces/${knowledgeSpaceId}/documents/${documentId}/metadata`,
|
|
{
|
|
body: JSON.stringify({ expectedRowVersion: 0, patch: { category: "camera" } }),
|
|
headers: { "content-type": "application/json" },
|
|
method: "PATCH",
|
|
},
|
|
);
|
|
|
|
expect(response.status, await response.clone().text()).toBe(200);
|
|
expect(createPermissionSnapshot).toHaveBeenCalledTimes(1);
|
|
expect(patch).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
permissionSnapshot: {
|
|
accessChannel: "interactive",
|
|
id: "018f0d60-7a49-7cc2-9c1b-5b36f18f2d21",
|
|
revision: 7,
|
|
},
|
|
requestedBySubjectId: "member-a",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("fails closed for unavailable resources, denied spaces, and malformed cursors", async () => {
|
|
const unavailable = handlerApp({ logicalDocuments: undefined });
|
|
await expectStatus(
|
|
unavailable.request(`/knowledge-spaces/${knowledgeSpaceId}/logical-documents`),
|
|
404,
|
|
);
|
|
await expectStatus(
|
|
unavailable.request(`/knowledge-spaces/${knowledgeSpaceId}/logical-documents/${documentId}`),
|
|
404,
|
|
);
|
|
|
|
const denied = handlerApp({
|
|
spaces: { get: vi.fn(async () => null) } as unknown as KnowledgeSpaceRepository,
|
|
});
|
|
await expectStatus(
|
|
denied.request(`/knowledge-spaces/${knowledgeSpaceId}/logical-documents`),
|
|
403,
|
|
);
|
|
await expectStatus(
|
|
denied.request(`/knowledge-spaces/${knowledgeSpaceId}/logical-documents/${documentId}`),
|
|
403,
|
|
);
|
|
await expectStatus(
|
|
denied.request(`/knowledge-spaces/${knowledgeSpaceId}/documents/${documentId}/revisions`),
|
|
404,
|
|
);
|
|
|
|
const invalid = handlerApp({ tasks: taskRepository() });
|
|
await expectStatus(
|
|
invalid.request(
|
|
`/knowledge-spaces/${knowledgeSpaceId}/logical-documents?cursor=missing-separator`,
|
|
),
|
|
400,
|
|
);
|
|
await expectStatus(
|
|
invalid.request(
|
|
`/knowledge-spaces/${knowledgeSpaceId}/documents/${documentId}/revisions?cursor=0`,
|
|
),
|
|
400,
|
|
);
|
|
await expectStatus(
|
|
invalid.request(
|
|
`/knowledge-spaces/${knowledgeSpaceId}/processing-tasks?cursor=too%7Cmany%7Cparts`,
|
|
),
|
|
400,
|
|
);
|
|
});
|
|
|
|
it("passes decoded cursors and serializes optional document and revision fields", async () => {
|
|
const active = revisionFixture({ activatedAt: "2026-07-14T12:02:00.000Z" });
|
|
const document = documentFixture({
|
|
active,
|
|
activeRevision: 1,
|
|
providerItemId: "provider-item-a",
|
|
sourceId,
|
|
});
|
|
const list = vi.fn(async () => ({
|
|
items: [document],
|
|
nextCursor: { createdAt: document.createdAt, id: document.id },
|
|
}));
|
|
const listRevisions = vi.fn(async () => ({
|
|
items: [active, revisionFixture({ revision: 2, state: "candidate" })],
|
|
nextCursor: { revision: 2 },
|
|
}));
|
|
const logicalDocuments = logicalDocumentRepository({
|
|
get: vi.fn(async () => document),
|
|
list,
|
|
listRevisions,
|
|
});
|
|
const app = handlerApp({ logicalDocuments });
|
|
const pairCursor = encodeURIComponent(`${document.createdAt}|${document.id}`);
|
|
|
|
const listed = await app.request(
|
|
`/knowledge-spaces/${knowledgeSpaceId}/logical-documents?cursor=${pairCursor}&limit=1`,
|
|
);
|
|
expect(listed.status, await listed.clone().text()).toBe(200);
|
|
await expect(listed.json()).resolves.toMatchObject({
|
|
items: [
|
|
{
|
|
active: { activatedAt: active.activatedAt, revision: 1 },
|
|
activeRevision: 1,
|
|
providerItemId: "provider-item-a",
|
|
sourceId,
|
|
},
|
|
],
|
|
nextCursor: expect.any(String),
|
|
});
|
|
expect(list).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
cursor: { createdAt: document.createdAt, id: document.id },
|
|
limit: 1,
|
|
}),
|
|
);
|
|
|
|
const fetched = await app.request(
|
|
`/knowledge-spaces/${knowledgeSpaceId}/logical-documents/${documentId}`,
|
|
);
|
|
expect(fetched.status, await fetched.clone().text()).toBe(200);
|
|
await expect(fetched.json()).resolves.toMatchObject({
|
|
providerItemId: "provider-item-a",
|
|
sourceId,
|
|
});
|
|
|
|
const revisions = await app.request(
|
|
`/knowledge-spaces/${knowledgeSpaceId}/documents/${documentId}/revisions?cursor=1&limit=1`,
|
|
);
|
|
expect(revisions.status, await revisions.clone().text()).toBe(200);
|
|
await expect(revisions.json()).resolves.toMatchObject({
|
|
items: [{ activatedAt: active.activatedAt }, { revision: 2 }],
|
|
nextCursor: "2",
|
|
});
|
|
expect(listRevisions).toHaveBeenCalledWith(
|
|
expect.objectContaining({ cursor: { revision: 1 }, limit: 1 }),
|
|
);
|
|
});
|
|
|
|
it("hides missing documents and revisions whose immutable assets are unreadable", async () => {
|
|
const missing = handlerApp({
|
|
logicalDocuments: logicalDocumentRepository({ get: vi.fn(async () => null) }),
|
|
});
|
|
await expectStatus(
|
|
missing.request(`/knowledge-spaces/${knowledgeSpaceId}/logical-documents/${documentId}`),
|
|
404,
|
|
);
|
|
|
|
const unreadable = handlerApp({
|
|
assets: { get: vi.fn(async () => null) },
|
|
logicalDocuments: logicalDocumentRepository(),
|
|
});
|
|
await expectStatus(
|
|
unreadable.request(`/knowledge-spaces/${knowledgeSpaceId}/logical-documents/${documentId}`),
|
|
404,
|
|
);
|
|
const revisions = await unreadable.request(
|
|
`/knowledge-spaces/${knowledgeSpaceId}/documents/${documentId}/revisions`,
|
|
);
|
|
expect(revisions.status, await revisions.clone().text()).toBe(200);
|
|
await expect(revisions.json()).resolves.toEqual({ items: [] });
|
|
});
|
|
|
|
it("handles rollback availability, authorization, success, and CAS conflicts", async () => {
|
|
const path = `/knowledge-spaces/${knowledgeSpaceId}/documents/${documentId}/revisions/1/rollback`;
|
|
const init = {
|
|
body: JSON.stringify({ expectedActiveRevision: 1, expectedRowVersion: 2 }),
|
|
headers: { "content-type": "application/json" },
|
|
method: "POST",
|
|
};
|
|
await expectStatus(handlerApp().request(path, init), 503);
|
|
|
|
const request = vi.fn(async () => taskFixture());
|
|
const accepted = await handlerApp({ rollbackCoordinator: { request } }).request(path, init);
|
|
expect(accepted.status, await accepted.clone().text()).toBe(202);
|
|
expect(request).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
documentId,
|
|
expectedActiveRevision: 1,
|
|
expectedRowVersion: 2,
|
|
permissionSnapshot: {
|
|
accessChannel: "interactive",
|
|
id: permissionSnapshot().id,
|
|
revision: 7,
|
|
},
|
|
revision: 1,
|
|
}),
|
|
);
|
|
|
|
const conflict = handlerApp({
|
|
rollbackCoordinator: {
|
|
request: vi.fn(async () => {
|
|
throw new LogicalDocumentConflictError(1, 2, 2, 3);
|
|
}),
|
|
},
|
|
});
|
|
await expectStatus(conflict.request(path, init), 409);
|
|
|
|
const forbidden = handlerApp({
|
|
access: denyingAccess(),
|
|
rollbackCoordinator: { request },
|
|
});
|
|
await expectStatus(forbidden.request(path, init), 403);
|
|
|
|
const missingRevision = handlerApp({
|
|
logicalDocuments: logicalDocumentRepository({ getRevision: vi.fn(async () => null) }),
|
|
rollbackCoordinator: { request },
|
|
});
|
|
await expectStatus(missingRevision.request(path, init), 404);
|
|
});
|
|
|
|
it("uses capability provenance and maps every metadata repository error", async () => {
|
|
const createPermissionSnapshot = vi.fn(async () => permissionSnapshot());
|
|
const patchUserMetadata = vi.fn(async () => documentFixture());
|
|
const capabilityApp = handlerApp(
|
|
{
|
|
access: { createPermissionSnapshot },
|
|
assets: { get: vi.fn(async () => assetFixture({ metadata: {} })) },
|
|
logicalDocuments: logicalDocumentRepository({ patchUserMetadata }),
|
|
},
|
|
{ capabilityGrant: capabilityGrant({ contentScopeIds: [" invalid"] }) },
|
|
);
|
|
const capabilityResponse = await patchMetadata(capabilityApp);
|
|
expect(capabilityResponse.status, await capabilityResponse.clone().text()).toBe(200);
|
|
expect(createPermissionSnapshot).not.toHaveBeenCalled();
|
|
expect(patchUserMetadata).toHaveBeenCalledWith(
|
|
expect.objectContaining({ capabilityGrantId: "grant-a" }),
|
|
);
|
|
|
|
const errorCases = [
|
|
[new LogicalDocumentConflictError(1, 2, 3, 4), 409],
|
|
[new LogicalDocumentValidationError("Reserved metadata"), 400],
|
|
[new LogicalDocumentNotFoundError("gone"), 404],
|
|
] as const;
|
|
for (const [error, status] of errorCases) {
|
|
const app = handlerApp({
|
|
logicalDocuments: logicalDocumentRepository({
|
|
patchUserMetadata: vi.fn(async () => {
|
|
throw error;
|
|
}),
|
|
}),
|
|
});
|
|
await expectStatus(patchMetadata(app), status);
|
|
}
|
|
|
|
const get = vi
|
|
.fn<LogicalDocumentRepository["get"]>()
|
|
.mockResolvedValueOnce(documentFixture())
|
|
.mockResolvedValueOnce(null);
|
|
const disappeared = handlerApp({
|
|
logicalDocuments: logicalDocumentRepository({ get }),
|
|
});
|
|
await expectStatus(patchMetadata(disappeared), 404);
|
|
await expectStatus(patchMetadata(handlerApp({ access: denyingAccess() })), 404);
|
|
});
|
|
|
|
it("validates field types and reconciles bindings around a document metadata patch", async () => {
|
|
const updated = documentFixture({
|
|
rowVersion: 3,
|
|
updatedAt: "2026-07-14T12:05:00.000Z",
|
|
userMetadata: { category: "camera" },
|
|
});
|
|
const patchUserMetadata = vi.fn(async () => updated);
|
|
const validatePatch = vi.fn<KnowledgeSpaceMetadataRepository["validatePatch"]>(
|
|
async () => undefined,
|
|
);
|
|
const reconcileDocument = vi.fn<KnowledgeSpaceMetadataRepository["reconcileDocument"]>(
|
|
async () => undefined,
|
|
);
|
|
const app = handlerApp({
|
|
logicalDocuments: logicalDocumentRepository({ patchUserMetadata }),
|
|
metadataFields: metadataRepository({ reconcileDocument, validatePatch }),
|
|
});
|
|
|
|
await expectStatus(patchMetadata(app), 200);
|
|
expect(validatePatch).toHaveBeenCalledWith({
|
|
knowledgeSpaceId,
|
|
patch: { category: "camera" },
|
|
tenantId,
|
|
});
|
|
expect(reconcileDocument).toHaveBeenCalledWith({
|
|
documentId,
|
|
knowledgeSpaceId,
|
|
now: updated.updatedAt,
|
|
subjectId: "member-a",
|
|
tenantId,
|
|
userMetadata: { category: "camera" },
|
|
});
|
|
expect(validatePatch.mock.invocationCallOrder[0]).toBeLessThan(
|
|
patchUserMetadata.mock.invocationCallOrder[0] ?? Number.MAX_SAFE_INTEGER,
|
|
);
|
|
expect(patchUserMetadata.mock.invocationCallOrder[0]).toBeLessThan(
|
|
reconcileDocument.mock.invocationCallOrder[0] ?? Number.MAX_SAFE_INTEGER,
|
|
);
|
|
|
|
const rejectedPatch = vi.fn(async () => updated);
|
|
const invalid = handlerApp({
|
|
logicalDocuments: logicalDocumentRepository({ patchUserMetadata: rejectedPatch }),
|
|
metadataFields: metadataRepository({
|
|
validatePatch: vi.fn(async () => {
|
|
throw new KnowledgeSpaceMetadataValidationError("wrong type");
|
|
}),
|
|
}),
|
|
});
|
|
await expectStatus(patchMetadata(invalid), 400);
|
|
expect(rejectedPatch).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not duplicate metadata lifecycle work managed by the database repository", async () => {
|
|
const patchUserMetadata = vi.fn(async () =>
|
|
documentFixture({ rowVersion: 3, userMetadata: { category: "camera" } }),
|
|
);
|
|
const validatePatch = vi.fn<KnowledgeSpaceMetadataRepository["validatePatch"]>(
|
|
async () => undefined,
|
|
);
|
|
const reconcileDocument = vi.fn<KnowledgeSpaceMetadataRepository["reconcileDocument"]>(
|
|
async () => undefined,
|
|
);
|
|
const app = handlerApp({
|
|
logicalDocuments: logicalDocumentRepository({
|
|
metadataLifecycleManaged: true,
|
|
patchUserMetadata,
|
|
}),
|
|
metadataFields: metadataRepository({ reconcileDocument, validatePatch }),
|
|
});
|
|
|
|
await expectStatus(patchMetadata(app), 200);
|
|
expect(patchUserMetadata).toHaveBeenCalledWith(
|
|
expect.objectContaining({ metadataSubjectId: "member-a" }),
|
|
);
|
|
expect(validatePatch).not.toHaveBeenCalled();
|
|
expect(reconcileDocument).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("lists and fetches visible chunks with optional query and cursor fields", async () => {
|
|
const chunk = chunkFixture();
|
|
const list = vi.fn(async () => ({
|
|
items: [chunk],
|
|
nextCursor: { id: chunk.id },
|
|
}));
|
|
const get = vi
|
|
.fn<DocumentChunkRepository["get"]>()
|
|
.mockResolvedValueOnce(chunk)
|
|
.mockResolvedValueOnce(null);
|
|
const app = handlerApp({ chunks: chunkRepository({ get, list }) });
|
|
const collectionPath = `/knowledge-spaces/${knowledgeSpaceId}/documents/${documentId}/revisions/1/chunks`;
|
|
|
|
const listed = await app.request(`${collectionPath}?cursor=chunk-cursor&query=design&limit=1`);
|
|
expect(listed.status, await listed.clone().text()).toBe(200);
|
|
await expect(listed.json()).resolves.toEqual({
|
|
items: [publicChunkFixture()],
|
|
nextCursor: chunk.id,
|
|
});
|
|
expect(list).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
cursor: { id: "chunk-cursor" },
|
|
query: "design",
|
|
}),
|
|
);
|
|
|
|
const found = await app.request(`${collectionPath}/${chunkId}`);
|
|
expect(found.status, await found.clone().text()).toBe(200);
|
|
await expect(found.json()).resolves.toEqual(publicChunkFixture());
|
|
await expectStatus(app.request(`${collectionPath}/${chunkId}`), 404);
|
|
|
|
const legacyChunk = { ...chunk, systemMetadata: { kind: "invalid", private: true } };
|
|
const legacyApp = handlerApp({
|
|
chunks: chunkRepository({ list: vi.fn(async () => ({ items: [legacyChunk] })) }),
|
|
});
|
|
const legacyResponse = await legacyApp.request(collectionPath);
|
|
expect(legacyResponse.status, await legacyResponse.clone().text()).toBe(200);
|
|
const legacyPayload = await legacyResponse.json();
|
|
expect(legacyPayload).toMatchObject({
|
|
items: [{ kind: "chunk", sectionPath: [] }],
|
|
});
|
|
expect(JSON.stringify(legacyPayload)).not.toContain("private");
|
|
|
|
await expectStatus(handlerApp().request(collectionPath), 404);
|
|
const hidden = handlerApp({
|
|
chunks: chunkRepository(),
|
|
logicalDocuments: logicalDocumentRepository({ getRevision: vi.fn(async () => null) }),
|
|
});
|
|
await expectStatus(hidden.request(collectionPath), 404);
|
|
});
|
|
|
|
it("validates chunk state mutations and strips server-only response fields", async () => {
|
|
const path = `/knowledge-spaces/${knowledgeSpaceId}/documents/${documentId}/revisions/1/chunks/${chunkId}/state`;
|
|
const init = {
|
|
body: JSON.stringify({ enabled: false }),
|
|
headers: { "content-type": "application/json" },
|
|
method: "POST",
|
|
};
|
|
await expectStatus(handlerApp().request(path, init), 503);
|
|
|
|
const hidden = handlerApp({
|
|
chunkState: chunkStateService(),
|
|
logicalDocuments: logicalDocumentRepository({ getRevision: vi.fn(async () => null) }),
|
|
});
|
|
await expectStatus(hidden.request(path, init), 404);
|
|
|
|
const forbidden = handlerApp({
|
|
access: denyingAccess(),
|
|
chunkState: chunkStateService(),
|
|
});
|
|
await expectStatus(forbidden.request(path, init), 403);
|
|
|
|
const invalid = handlerApp({
|
|
chunkState: chunkStateService({
|
|
request: vi.fn(async () => {
|
|
throw new LogicalDocumentValidationError("Invalid chunk state");
|
|
}),
|
|
}),
|
|
});
|
|
await expectStatus(invalid.request(path, init), 400);
|
|
|
|
const request = vi.fn(async () => chunkStateChangeFixture());
|
|
const accepted = await handlerApp({ chunkState: chunkStateService({ request }) }).request(
|
|
path,
|
|
init,
|
|
);
|
|
expect(accepted.status, await accepted.clone().text()).toBe(202);
|
|
const body = await accepted.json();
|
|
expect(body).toMatchObject({
|
|
chunkId,
|
|
state: "candidate",
|
|
statusUrl: `/knowledge-spaces/${knowledgeSpaceId}/documents/${documentId}/processing-tasks/${taskId}`,
|
|
});
|
|
expect(body).not.toHaveProperty("activatedAt");
|
|
expect(body).not.toHaveProperty("tenantId");
|
|
});
|
|
|
|
it("filters task lists by visible revision and round-trips pair cursors", async () => {
|
|
const visible = taskFixture();
|
|
const hidden = taskFixture({
|
|
documentRevision: 2,
|
|
id: "018f0d60-7a49-7cc2-9c1b-5b36f18f2d42",
|
|
});
|
|
const list = vi.fn(async () => ({
|
|
items: [visible, hidden],
|
|
nextCursor: { createdAt: visible.createdAt, id: visible.id },
|
|
}));
|
|
const logicalDocuments = logicalDocumentRepository({
|
|
getRevision: vi.fn(async (input) => (input.revision === 1 ? revisionFixture() : null)),
|
|
});
|
|
const app = handlerApp({ logicalDocuments, tasks: taskRepository({ list }) });
|
|
const cursor = encodeURIComponent(`${visible.createdAt}|${visible.id}`);
|
|
|
|
const spaceTasks = await app.request(
|
|
`/knowledge-spaces/${knowledgeSpaceId}/processing-tasks?cursor=${cursor}&limit=1`,
|
|
);
|
|
expect(spaceTasks.status, await spaceTasks.clone().text()).toBe(200);
|
|
await expect(spaceTasks.json()).resolves.toMatchObject({
|
|
items: [{ id: visible.id }],
|
|
nextCursor: expect.any(String),
|
|
});
|
|
expect(list).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
cursor: { createdAt: visible.createdAt, id: visible.id },
|
|
}),
|
|
);
|
|
|
|
const documentTasks = await app.request(
|
|
`/knowledge-spaces/${knowledgeSpaceId}/documents/${documentId}/processing-tasks`,
|
|
);
|
|
expect(documentTasks.status, await documentTasks.clone().text()).toBe(200);
|
|
expect(list).toHaveBeenLastCalledWith(expect.objectContaining({ documentId }));
|
|
|
|
const noDocuments = handlerApp({
|
|
logicalDocuments: undefined,
|
|
tasks: taskRepository({ list }),
|
|
});
|
|
const empty = await noDocuments.request(
|
|
`/knowledge-spaces/${knowledgeSpaceId}/processing-tasks`,
|
|
);
|
|
expect(empty.status, await empty.clone().text()).toBe(200);
|
|
await expect(empty.json()).resolves.toMatchObject({ items: [] });
|
|
|
|
await expectStatus(
|
|
handlerApp({ tasks: undefined }).request(
|
|
`/knowledge-spaces/${knowledgeSpaceId}/processing-tasks`,
|
|
),
|
|
404,
|
|
);
|
|
const denied = handlerApp({
|
|
spaces: { get: vi.fn(async () => null) } as unknown as KnowledgeSpaceRepository,
|
|
tasks: taskRepository(),
|
|
});
|
|
await expectStatus(
|
|
denied.request(`/knowledge-spaces/${knowledgeSpaceId}/processing-tasks`),
|
|
403,
|
|
);
|
|
});
|
|
|
|
it("polls terminal tasks and honors Last-Event-ID in the SSE snapshot", async () => {
|
|
const task = taskFixture({
|
|
completedAt: "2026-07-14T12:04:00.000Z",
|
|
progressPercent: 100,
|
|
stage: "published",
|
|
state: "succeeded",
|
|
updatedAt: "2026-07-14T12:04:00.000Z",
|
|
});
|
|
const get = vi.fn(async () => task);
|
|
const app = handlerApp({ tasks: taskRepository({ get }) });
|
|
const path = `/knowledge-spaces/${knowledgeSpaceId}/documents/${documentId}/processing-tasks/${task.id}`;
|
|
|
|
const polled = await app.request(path);
|
|
expect(polled.status, await polled.clone().text()).toBe(200);
|
|
await expect(polled.json()).resolves.toMatchObject({ id: task.id, state: "succeeded" });
|
|
|
|
const stream = await app.request(`${path}/events`, {
|
|
headers: { "last-event-id": `${task.id}:${task.updatedAt}` },
|
|
});
|
|
expect(stream.status).toBe(200);
|
|
expect(stream.headers.get("content-type")).toContain("text/event-stream");
|
|
const events = await stream.text();
|
|
expect(events).not.toContain("event: progress");
|
|
expect(events).toContain("event: terminal");
|
|
|
|
const missing = handlerApp({
|
|
tasks: taskRepository({ get: vi.fn(async () => null) }),
|
|
});
|
|
await expectStatus(missing.request(path), 404);
|
|
await expectStatus(missing.request(`${path}/events`), 404);
|
|
});
|
|
|
|
it("maps cancel and retry task preconditions, fallbacks, and coordinator failures", async () => {
|
|
const path = `/knowledge-spaces/${knowledgeSpaceId}/documents/${documentId}/processing-tasks/${taskId}`;
|
|
const task = taskFixture();
|
|
|
|
const withoutJobs = handlerApp({ tasks: taskRepository() });
|
|
await expectStatus(withoutJobs.request(path, { method: "DELETE" }), 409);
|
|
await expectStatus(withoutJobs.request(`${path}/retry`, { method: "POST" }), 409);
|
|
|
|
const forbidden = handlerApp({
|
|
access: denyingAccess(),
|
|
compilationJobs: compilationJobs(),
|
|
tasks: taskRepository(),
|
|
});
|
|
await expectStatus(forbidden.request(path, { method: "DELETE" }), 403);
|
|
await expectStatus(forbidden.request(`${path}/retry`, { method: "POST" }), 403);
|
|
|
|
const cancel = vi.fn(async () => compilationJobFixture());
|
|
const retry = vi.fn(async () => compilationJobFixture());
|
|
const getAfterMutation = vi
|
|
.fn<DocumentProcessingTaskRepository["get"]>()
|
|
.mockResolvedValueOnce(task)
|
|
.mockResolvedValueOnce(null)
|
|
.mockResolvedValueOnce(task)
|
|
.mockResolvedValueOnce(null);
|
|
const accepted = handlerApp({
|
|
compilationJobs: compilationJobs({ cancel, retry }),
|
|
tasks: taskRepository({ get: getAfterMutation }),
|
|
});
|
|
const canceled = await accepted.request(path, { method: "DELETE" });
|
|
expect(canceled.status, await canceled.clone().text()).toBe(200);
|
|
await expect(canceled.json()).resolves.toMatchObject({ id: task.id });
|
|
const retried = await accepted.request(`${path}/retry`, { method: "POST" });
|
|
expect(retried.status, await retried.clone().text()).toBe(200);
|
|
await expect(retried.json()).resolves.toMatchObject({ id: task.id });
|
|
|
|
const failing = handlerApp({
|
|
compilationJobs: compilationJobs({
|
|
cancel: vi.fn(async () => {
|
|
throw new Error("cancel race");
|
|
}),
|
|
retry: vi.fn(async () => {
|
|
throw new Error("retry race");
|
|
}),
|
|
}),
|
|
tasks: taskRepository(),
|
|
});
|
|
await expectStatus(failing.request(path, { method: "DELETE" }), 409);
|
|
await expectStatus(failing.request(`${path}/retry`, { method: "POST" }), 409);
|
|
|
|
const missing = handlerApp({
|
|
compilationJobs: compilationJobs(),
|
|
tasks: taskRepository({ get: vi.fn(async () => null) }),
|
|
});
|
|
await expectStatus(missing.request(path, { method: "DELETE" }), 404);
|
|
await expectStatus(missing.request(`${path}/retry`, { method: "POST" }), 404);
|
|
});
|
|
|
|
it("gets and changes settings across unavailable, hidden, denied, and conflict paths", async () => {
|
|
const path = `/knowledge-spaces/${knowledgeSpaceId}/documents/${documentId}/settings`;
|
|
const init = {
|
|
body: JSON.stringify({
|
|
expectedSettingsHeadRevision: 1,
|
|
settings: {
|
|
chunkOverlap: 32,
|
|
chunkSize: 512,
|
|
enableGraph: true,
|
|
enablePageIndex: false,
|
|
},
|
|
}),
|
|
headers: { "content-type": "application/json" },
|
|
method: "PUT",
|
|
};
|
|
await expectStatus(handlerApp().request(path), 404);
|
|
|
|
const hidden = handlerApp({
|
|
logicalDocuments: logicalDocumentRepository({ get: vi.fn(async () => null) }),
|
|
settings: settingsRepository(),
|
|
});
|
|
await expectStatus(hidden.request(path), 404);
|
|
await expectStatus(hidden.request(path, init), 404);
|
|
|
|
const getHead = vi
|
|
.fn<DocumentSettingsRepository["getHead"]>()
|
|
.mockResolvedValueOnce(settingsHeadFixture({ activatedAt: null }))
|
|
.mockResolvedValueOnce(null);
|
|
const readable = handlerApp({ settings: settingsRepository({ getHead }) });
|
|
const found = await readable.request(path);
|
|
expect(found.status, await found.clone().text()).toBe(200);
|
|
const foundBody = await found.json();
|
|
expect(foundBody).toMatchObject({ activeRevision: 1, profile: { state: "active" } });
|
|
expect(foundBody.profile).not.toHaveProperty("activatedAt");
|
|
await expectStatus(readable.request(path), 404);
|
|
|
|
await expectStatus(handlerApp().request(path, init), 503);
|
|
await expectStatus(
|
|
handlerApp({
|
|
access: denyingAccess(),
|
|
settingsChangeCoordinator: settingsCoordinator(),
|
|
}).request(path, init),
|
|
403,
|
|
);
|
|
const conflict = handlerApp({
|
|
settingsChangeCoordinator: settingsCoordinator({
|
|
request: vi.fn(async () => {
|
|
throw new LogicalDocumentConflictError(1, 2, 2, 3);
|
|
}),
|
|
}),
|
|
});
|
|
await expectStatus(conflict.request(path, init), 409);
|
|
|
|
const request = vi.fn(async () => settingsChangeFixture());
|
|
const accepted = await handlerApp({
|
|
settingsChangeCoordinator: settingsCoordinator({ request }),
|
|
}).request(path, init);
|
|
expect(accepted.status, await accepted.clone().text()).toBe(202);
|
|
expect(request).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
expectedSettingsHeadRevision: 1,
|
|
permissionSnapshot: expect.any(Object),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("authorizes parent-scoped capabilities without invoking the member guard", async () => {
|
|
const authorize = vi.fn(defaultAuthorization().authorize);
|
|
const app = handlerApp(
|
|
{ authorization: { authorize } },
|
|
{
|
|
capabilityGrant: capabilityGrant({
|
|
resource: { id: documentId, parent_id: knowledgeSpaceId, type: "document" },
|
|
}),
|
|
},
|
|
);
|
|
const response = await app.request(
|
|
`/knowledge-spaces/${knowledgeSpaceId}/logical-documents/${documentId}`,
|
|
);
|
|
expect(response.status, await response.clone().text()).toBe(200);
|
|
expect(authorize).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("fails closed at each nested lookup and authorization boundary", async () => {
|
|
const revisionsPath = `/knowledge-spaces/${knowledgeSpaceId}/documents/${documentId}/revisions`;
|
|
await expectStatus(handlerApp({ logicalDocuments: undefined }).request(revisionsPath), 404);
|
|
await expectStatus(
|
|
handlerApp({
|
|
logicalDocuments: logicalDocumentRepository({ get: vi.fn(async () => null) }),
|
|
}).request(revisionsPath),
|
|
404,
|
|
);
|
|
|
|
const chunkPath = `${revisionsPath}/1/chunks/${chunkId}`;
|
|
await expectStatus(handlerApp().request(chunkPath), 404);
|
|
await expectStatus(
|
|
handlerApp({
|
|
chunks: chunkRepository(),
|
|
logicalDocuments: logicalDocumentRepository({ getRevision: vi.fn(async () => null) }),
|
|
}).request(chunkPath),
|
|
404,
|
|
);
|
|
|
|
const taskPath = `/knowledge-spaces/${knowledgeSpaceId}/documents/${documentId}/processing-tasks/${taskId}`;
|
|
await expectStatus(handlerApp({ tasks: undefined }).request(taskPath), 404);
|
|
await expectStatus(
|
|
handlerApp({ logicalDocuments: undefined, tasks: taskRepository() }).request(taskPath),
|
|
404,
|
|
);
|
|
await expectStatus(
|
|
handlerApp({
|
|
logicalDocuments: logicalDocumentRepository({ getRevision: vi.fn(async () => null) }),
|
|
tasks: taskRepository(),
|
|
}).request(taskPath),
|
|
404,
|
|
);
|
|
|
|
const rejected = handlerApp({
|
|
authorization: deniedAuthorization(),
|
|
chunkState: chunkStateService(),
|
|
chunks: chunkRepository(),
|
|
tasks: taskRepository(),
|
|
});
|
|
await expectStatus(rejected.request(taskPath), 404);
|
|
await expectStatus(patchMetadata(rejected), 404);
|
|
await expectStatus(rejected.request(`${revisionsPath}/1/chunks`), 404);
|
|
});
|
|
|
|
it("bounds durable permission expiry for API keys and applies caller-kind fallbacks", async () => {
|
|
const expiresAt = new Date(Date.now() + 30 * 60_000).toISOString();
|
|
const apiKey = { expiresAt, id: "api-key-a", revision: 3 };
|
|
const createPermissionSnapshot = vi.fn(async () => ({
|
|
...permissionSnapshot(),
|
|
accessChannel: "service_api" as const,
|
|
apiKeyExpiresAt: expiresAt,
|
|
apiKeyId: apiKey.id,
|
|
apiKeyRevision: apiKey.revision,
|
|
}));
|
|
const apiKeyApp = handlerApp(
|
|
{ access: { createPermissionSnapshot } },
|
|
{ authenticatedApiKey: apiKey, callerKind: "api_key" },
|
|
);
|
|
await expectStatus(patchMetadata(apiKeyApp), 200);
|
|
expect(createPermissionSnapshot).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
accessChannel: "service_api",
|
|
apiKey,
|
|
expiresAt,
|
|
}),
|
|
);
|
|
|
|
const noExpirySnapshot = vi.fn(async () => permissionSnapshot());
|
|
const noExpiryApp = handlerApp(
|
|
{ access: { createPermissionSnapshot: noExpirySnapshot } },
|
|
{
|
|
authenticatedApiKey: { id: "api-key-b", revision: 4 },
|
|
callerKind: null,
|
|
},
|
|
);
|
|
await expectStatus(patchMetadata(noExpiryApp), 200);
|
|
expect(noExpirySnapshot).toHaveBeenCalledWith(
|
|
expect.objectContaining({ accessChannel: "interactive" }),
|
|
);
|
|
});
|
|
|
|
it.each([0, 1.5, 60_001, Number.MAX_SAFE_INTEGER + 1])(
|
|
"rejects invalid SSE duration %s at registration",
|
|
(duration) => {
|
|
expect(() => handlerApp({ taskSseHeartbeatMs: duration })).toThrow(
|
|
"taskSseHeartbeatMs must be an integer between 1 and 60000",
|
|
);
|
|
},
|
|
);
|
|
});
|
|
|
|
type HandlerOverrides = Partial<Omit<RegisterLogicalDocumentHandlersOptions, "app">>;
|
|
|
|
function handlerApp(
|
|
overrides: HandlerOverrides = {},
|
|
contextOptions: {
|
|
readonly authenticatedApiKey?: KnowledgeSpaceApiKeyPermissionBinding;
|
|
readonly callerKind?: KnowledgeSpaceCallerKind | null;
|
|
readonly capabilityGrant?: DifyCapabilityV2SanitizedGrant;
|
|
} = {},
|
|
) {
|
|
const app = createKnowledgeGatewayApp();
|
|
app.use("*", async (context, next) => {
|
|
if (contextOptions.callerKind !== null) {
|
|
context.set("callerKind", contextOptions.callerKind ?? "interactive");
|
|
}
|
|
context.set("subject", {
|
|
scopes: ["knowledge-spaces:*"],
|
|
subjectId: "member-a",
|
|
tenantId,
|
|
});
|
|
if (contextOptions.authenticatedApiKey) {
|
|
context.set("authenticatedApiKey", contextOptions.authenticatedApiKey);
|
|
}
|
|
if (contextOptions.capabilityGrant) {
|
|
context.set("capabilityV2Grant", contextOptions.capabilityGrant);
|
|
}
|
|
await next();
|
|
});
|
|
const defaults: Omit<RegisterLogicalDocumentHandlersOptions, "app"> = {
|
|
access: defaultAccess(),
|
|
assets: { get: vi.fn(async () => assetFixture()) },
|
|
authorization: defaultAuthorization(),
|
|
logicalDocuments: logicalDocumentRepository(),
|
|
now: () => "2026-07-14T12:05:00.000Z",
|
|
spaces: {
|
|
get: vi.fn(async () => ({ id: knowledgeSpaceId, tenantId })),
|
|
} as unknown as KnowledgeSpaceRepository,
|
|
};
|
|
registerLogicalDocumentHandlers({ ...defaults, ...overrides, app });
|
|
return app;
|
|
}
|
|
|
|
async function expectStatus(response: Response | Promise<Response>, status: number): Promise<void> {
|
|
const resolved = await response;
|
|
expect(resolved.status, await resolved.clone().text()).toBe(status);
|
|
}
|
|
|
|
function patchMetadata(app: ReturnType<typeof handlerApp>): Response | Promise<Response> {
|
|
return app.request(`/knowledge-spaces/${knowledgeSpaceId}/documents/${documentId}/metadata`, {
|
|
body: JSON.stringify({ expectedRowVersion: 2, patch: { category: "camera" } }),
|
|
headers: { "content-type": "application/json" },
|
|
method: "PATCH",
|
|
});
|
|
}
|
|
|
|
function defaultAuthorization(): KnowledgeSpaceAuthorizationGuard {
|
|
return {
|
|
authorize: vi.fn(async () => ({
|
|
accessContext: {} as never,
|
|
permissionSnapshot: {
|
|
apiAccessRevision: 1,
|
|
callerKind: "interactive" as const,
|
|
candidateGrants: ["document:read"],
|
|
issuedAt: "2026-07-14T12:00:00.000Z",
|
|
knowledgeSpaceId,
|
|
memberRevision: 1,
|
|
memberRole: "editor" as const,
|
|
policyRevision: 1,
|
|
subjectId: "member-a",
|
|
tenantId,
|
|
},
|
|
})),
|
|
};
|
|
}
|
|
|
|
function deniedAuthorization(): KnowledgeSpaceAuthorizationGuard {
|
|
return {
|
|
authorize: vi.fn(async () => {
|
|
throw new KnowledgeSpaceAuthorizationError(
|
|
"KNOWLEDGE_SPACE_ACCESS_DENIED",
|
|
"Knowledge space access denied",
|
|
);
|
|
}),
|
|
};
|
|
}
|
|
|
|
function defaultAccess(): Pick<KnowledgeSpaceAccessService, "createPermissionSnapshot"> {
|
|
return { createPermissionSnapshot: vi.fn(async () => permissionSnapshot()) };
|
|
}
|
|
|
|
function denyingAccess(): Pick<KnowledgeSpaceAccessService, "createPermissionSnapshot"> {
|
|
return {
|
|
createPermissionSnapshot: vi.fn(async () => {
|
|
throw new KnowledgeSpaceAuthorizationError(
|
|
"KNOWLEDGE_SPACE_ACCESS_DENIED",
|
|
"Knowledge space access denied",
|
|
);
|
|
}),
|
|
};
|
|
}
|
|
|
|
function logicalDocumentRepository(
|
|
overrides: Partial<LogicalDocumentRepository> = {},
|
|
): LogicalDocumentRepository {
|
|
const revision = revisionFixture();
|
|
const document = documentFixture();
|
|
return {
|
|
activateRevision: vi.fn(async () => document),
|
|
bindCompilationAttempt: vi.fn(async () => revision),
|
|
createCandidateRevision: vi.fn(async () => ({ document, revision })),
|
|
discardUnboundCandidate: vi.fn(async () => true),
|
|
failCandidate: vi.fn(async () => revision),
|
|
get: vi.fn(async () => document),
|
|
getRevision: vi.fn(async () => revision),
|
|
isAssetReferenced: vi.fn(async () => false),
|
|
isFailedSourceRevisionCleanupEligible: vi.fn(async () => false),
|
|
list: vi.fn(async () => ({ items: [document] })),
|
|
listActiveBySource: vi.fn(async () => ({ items: [] })),
|
|
listRevisions: vi.fn(async () => ({ items: [revision] })),
|
|
patchUserMetadata: vi.fn(async () => document),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function metadataRepository(
|
|
overrides: Partial<KnowledgeSpaceMetadataRepository> = {},
|
|
): KnowledgeSpaceMetadataRepository {
|
|
return {
|
|
create: vi.fn(async () => {
|
|
throw new Error("not used");
|
|
}),
|
|
delete: vi.fn(async () => undefined),
|
|
list: vi.fn(async () => ({ items: [] })),
|
|
reconcileDocument: vi.fn(async () => undefined),
|
|
updateName: vi.fn(async () => {
|
|
throw new Error("not used");
|
|
}),
|
|
validatePatch: vi.fn(async () => undefined),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function revisionFixture(overrides: Partial<DocumentRevision> = {}): DocumentRevision {
|
|
return {
|
|
contentHash: "a".repeat(64),
|
|
createdAt: "2026-07-14T12:00:00.000Z",
|
|
documentAssetId: assetId,
|
|
documentAssetVersion: 1,
|
|
documentId,
|
|
expectedActiveRevision: null,
|
|
expectedDocumentRowVersion: 0,
|
|
knowledgeSpaceId,
|
|
mimeType: "text/plain",
|
|
revision: 1,
|
|
sizeBytes: 12,
|
|
state: "active",
|
|
systemMetadata: {},
|
|
tenantId,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function documentFixture(
|
|
overrides: Partial<LogicalDocumentWithActiveRevision> = {},
|
|
): LogicalDocumentWithActiveRevision {
|
|
return {
|
|
active: revisionFixture(),
|
|
activeRevision: 1,
|
|
createdAt: "2026-07-14T12:00:00.000Z",
|
|
id: documentId,
|
|
knowledgeSpaceId,
|
|
rowVersion: 2,
|
|
status: "ready",
|
|
systemMetadata: {},
|
|
tenantId,
|
|
title: "Design notes",
|
|
updatedAt: "2026-07-14T12:02:00.000Z",
|
|
userMetadata: {},
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function assetFixture(
|
|
overrides: Readonly<Record<string, unknown>> = {},
|
|
): NonNullable<Awaited<ReturnType<DocumentAssetRepository["get"]>>> {
|
|
return {
|
|
createdAt: "2026-07-14T12:00:00.000Z",
|
|
filename: "design-notes.txt",
|
|
id: assetId,
|
|
knowledgeSpaceId,
|
|
metadata: { permissionScope: ["document:read"] },
|
|
mimeType: "text/plain",
|
|
objectKey: "tenant-a/design-notes.txt",
|
|
parserStatus: "parsed",
|
|
sha256: "a".repeat(64),
|
|
sizeBytes: 12,
|
|
version: 1,
|
|
...overrides,
|
|
} as NonNullable<Awaited<ReturnType<DocumentAssetRepository["get"]>>>;
|
|
}
|
|
|
|
function chunkFixture(): DocumentRevisionChunk {
|
|
return {
|
|
createdAt: "2026-07-14T12:00:00.000Z",
|
|
documentId,
|
|
documentRevision: 1,
|
|
enabled: true,
|
|
id: chunkId,
|
|
knowledgeSpaceId,
|
|
ordinal: 0,
|
|
parentChunkId: assetId,
|
|
systemMetadata: {
|
|
kind: "chunk",
|
|
private: true,
|
|
sourceLocation: {
|
|
endOffset: 12,
|
|
pageNumber: 2,
|
|
sectionPath: ["Setup", "Requirements"],
|
|
startOffset: 0,
|
|
},
|
|
},
|
|
tenantId,
|
|
text: "Design chunk",
|
|
tokenCount: 2,
|
|
userMetadata: { category: "camera" },
|
|
};
|
|
}
|
|
|
|
function publicChunkFixture() {
|
|
const { systemMetadata: _systemMetadata, tenantId: _tenantId, ...chunk } = chunkFixture();
|
|
return {
|
|
...chunk,
|
|
endOffset: 12,
|
|
kind: "chunk",
|
|
sectionPath: ["Setup", "Requirements"],
|
|
startOffset: 0,
|
|
};
|
|
}
|
|
|
|
function chunkRepository(
|
|
overrides: Partial<DocumentChunkRepository> = {},
|
|
): DocumentChunkRepository {
|
|
const chunk = chunkFixture();
|
|
return {
|
|
activateStateChange: vi.fn(async () => chunkStateChangeFixture()),
|
|
createMany: vi.fn(async () => [chunk]),
|
|
failStateChange: vi.fn(async () => chunkStateChangeFixture({ state: "failed" })),
|
|
get: vi.fn(async () => chunk),
|
|
list: vi.fn(async () => ({ items: [chunk] })),
|
|
stageStateChange: vi.fn(async () => chunkStateChangeFixture()),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function chunkStateChangeFixture(
|
|
overrides: Readonly<Record<string, unknown>> = {},
|
|
): Awaited<ReturnType<DocumentChunkStateService["request"]>> {
|
|
return {
|
|
activatedAt: "2026-07-14T12:06:00.000Z",
|
|
candidateFingerprint: "fingerprint-a",
|
|
candidatePublicationId: sourceId,
|
|
chunkId,
|
|
compilationAttemptId: taskId,
|
|
createdAt: "2026-07-14T12:05:00.000Z",
|
|
documentId,
|
|
documentRevision: 1,
|
|
enabled: false,
|
|
id: "018f0d60-7a49-7cc2-9c1b-5b36f18f2d61",
|
|
knowledgeSpaceId,
|
|
state: "candidate",
|
|
tenantId,
|
|
...overrides,
|
|
} as Awaited<ReturnType<DocumentChunkStateService["request"]>>;
|
|
}
|
|
|
|
function chunkStateService(
|
|
overrides: Partial<DocumentChunkStateService> = {},
|
|
): DocumentChunkStateService {
|
|
return { request: vi.fn(async () => chunkStateChangeFixture()), ...overrides };
|
|
}
|
|
|
|
function taskFixture(overrides: Partial<DocumentProcessingTask> = {}): DocumentProcessingTask {
|
|
return {
|
|
createdAt: "2026-07-14T12:00:00.000Z",
|
|
documentId,
|
|
documentRevision: 1,
|
|
id: taskId,
|
|
knowledgeSpaceId,
|
|
progressPercent: 20,
|
|
stage: "parsed",
|
|
state: "running",
|
|
updatedAt: "2026-07-14T12:01:00.000Z",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function taskRepository(
|
|
overrides: Partial<DocumentProcessingTaskRepository> = {},
|
|
): DocumentProcessingTaskRepository {
|
|
const task = taskFixture();
|
|
return {
|
|
get: vi.fn(async () => task),
|
|
list: vi.fn(async () => ({ items: [task] })),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function compilationJobs(
|
|
overrides: Partial<DocumentCompilationJobStateMachine> = {},
|
|
): DocumentCompilationJobStateMachine {
|
|
return {
|
|
cancel: vi.fn(async () => compilationJobFixture()),
|
|
retry: vi.fn(async () => compilationJobFixture()),
|
|
...overrides,
|
|
} as unknown as DocumentCompilationJobStateMachine;
|
|
}
|
|
|
|
function compilationJobFixture(): DocumentCompilationJob {
|
|
return {
|
|
createdAt: 1_752_494_400_000,
|
|
documentAssetId: assetId,
|
|
id: taskId,
|
|
knowledgeSpaceId,
|
|
runState: "running",
|
|
stage: "parsed",
|
|
tenantId,
|
|
updatedAt: 1_752_494_460_000,
|
|
version: 1,
|
|
};
|
|
}
|
|
|
|
function settingsHeadFixture({
|
|
activatedAt = "2026-07-14T12:02:00.000Z",
|
|
}: { readonly activatedAt?: string | null | undefined } = {}): DocumentSettingsHead {
|
|
return {
|
|
activeRevision: 1,
|
|
documentId,
|
|
knowledgeSpaceId,
|
|
profile: {
|
|
...(activatedAt ? { activatedAt } : {}),
|
|
createdAt: "2026-07-14T12:00:00.000Z",
|
|
createdBySubjectId: "member-a",
|
|
documentId,
|
|
knowledgeSpaceId,
|
|
revision: 1,
|
|
settings: {
|
|
chunkOverlap: 32,
|
|
chunkSize: 512,
|
|
enableGraph: true,
|
|
enablePageIndex: false,
|
|
},
|
|
state: "active",
|
|
tenantId,
|
|
},
|
|
rowVersion: 2,
|
|
tenantId,
|
|
updatedAt: "2026-07-14T12:02:00.000Z",
|
|
};
|
|
}
|
|
|
|
function settingsRepository(
|
|
overrides: Partial<DocumentSettingsRepository> = {},
|
|
): DocumentSettingsRepository {
|
|
return {
|
|
getHead: vi.fn(async () => settingsHeadFixture()),
|
|
...overrides,
|
|
} as unknown as DocumentSettingsRepository;
|
|
}
|
|
|
|
function settingsChangeFixture() {
|
|
return {
|
|
attemptId: "018f0d60-7a49-7cc2-9c1b-5b36f18f2d71",
|
|
compilationAttemptId: taskId,
|
|
settingsRevision: 2,
|
|
state: "running" as const,
|
|
statusUrl: `/knowledge-spaces/${knowledgeSpaceId}/documents/${documentId}/processing-tasks/${taskId}`,
|
|
};
|
|
}
|
|
|
|
function settingsCoordinator(
|
|
overrides: Partial<DocumentSettingsChangeCoordinator> = {},
|
|
): DocumentSettingsChangeCoordinator {
|
|
return { request: vi.fn(async () => settingsChangeFixture()), ...overrides };
|
|
}
|
|
|
|
function capabilityGrant(
|
|
overrides: Partial<DifyCapabilityV2SanitizedGrant> = {},
|
|
): DifyCapabilityV2SanitizedGrant {
|
|
return {
|
|
action: "documents.update",
|
|
actor: "dify-account:member-a",
|
|
authzRevision: {
|
|
credential_revision: null,
|
|
external_access_epoch: 1,
|
|
membership_epoch: 1,
|
|
space_acl_epoch: 1,
|
|
},
|
|
azp: "dify-console",
|
|
callerKind: "interactive",
|
|
capVersion: 2,
|
|
contentPolicyRevision: 1,
|
|
contentScopeIds: ["document:read"],
|
|
controlSpaceId: "control-space-a",
|
|
expiresAt: 1_785_152_860,
|
|
grantId: "grant-a",
|
|
issuedAt: 1_785_152_800,
|
|
jtiHash: `sha256:${"a".repeat(64)}`,
|
|
namespaceId: tenantId,
|
|
notBefore: 1_785_152_800,
|
|
resource: { id: knowledgeSpaceId, parent_id: null, type: "knowledge_space" },
|
|
subject: "member-a",
|
|
traceId: "trace-a",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function testApp(
|
|
logicalDocuments: ReturnType<typeof createInMemoryLogicalDocumentRepository>,
|
|
access: Pick<KnowledgeSpaceAccessService, "createPermissionSnapshot"> = {
|
|
createPermissionSnapshot: vi.fn(),
|
|
} as unknown as Pick<KnowledgeSpaceAccessService, "createPermissionSnapshot">,
|
|
) {
|
|
const app = createKnowledgeGatewayApp();
|
|
app.use("*", async (context, next) => {
|
|
context.set("callerKind", "interactive");
|
|
context.set("subject", {
|
|
scopes: ["knowledge-spaces:read"],
|
|
subjectId: "member-a",
|
|
tenantId,
|
|
});
|
|
await next();
|
|
});
|
|
const authorization: KnowledgeSpaceAuthorizationGuard = {
|
|
authorize: vi.fn(async () => ({
|
|
accessContext: {} as never,
|
|
permissionSnapshot: {
|
|
apiAccessRevision: 1,
|
|
callerKind: "interactive" as const,
|
|
candidateGrants: ["document:read"],
|
|
issuedAt: "2026-07-14T12:00:00.000Z",
|
|
knowledgeSpaceId,
|
|
memberRevision: 1,
|
|
memberRole: "viewer" as const,
|
|
policyRevision: 1,
|
|
subjectId: "member-a",
|
|
tenantId,
|
|
},
|
|
})),
|
|
};
|
|
const assets: Pick<DocumentAssetRepository, "get"> = {
|
|
get: vi.fn(
|
|
async () =>
|
|
({
|
|
createdAt: "2026-07-14T12:00:00.000Z",
|
|
filename: "design-notes.txt",
|
|
id: assetId,
|
|
knowledgeSpaceId,
|
|
metadata: { permissionScope: ["document:read"] },
|
|
mimeType: "text/plain",
|
|
objectKey: "tenant-a/design-notes.txt",
|
|
parserStatus: "parsed",
|
|
sha256: "a".repeat(64),
|
|
sizeBytes: 12,
|
|
version: 1,
|
|
}) as unknown as Awaited<ReturnType<DocumentAssetRepository["get"]>>,
|
|
),
|
|
};
|
|
registerLogicalDocumentHandlers({
|
|
access,
|
|
app,
|
|
assets,
|
|
authorization,
|
|
logicalDocuments,
|
|
spaces: {
|
|
get: vi.fn(async () => ({ id: knowledgeSpaceId, tenantId })),
|
|
} as unknown as KnowledgeSpaceRepository,
|
|
});
|
|
return app;
|
|
}
|
|
|
|
function permissionSnapshot() {
|
|
return {
|
|
accessChannel: "interactive" as const,
|
|
accessPolicyRevision: 3,
|
|
apiAccessRevision: 2,
|
|
createdAt: "2026-07-14T12:00:00.000Z",
|
|
expiresAt: "2026-07-14T13:00:00.000Z",
|
|
id: "018f0d60-7a49-7cc2-9c1b-5b36f18f2d21",
|
|
knowledgeSpaceId,
|
|
memberRevision: 4,
|
|
permissionScopes: ["document:read"],
|
|
revision: 7,
|
|
role: "editor" as const,
|
|
status: "active" as const,
|
|
subjectId: "member-a",
|
|
tenantId,
|
|
updatedAt: "2026-07-14T12:00:00.000Z",
|
|
visibility: "partial_members" as const,
|
|
};
|
|
}
|