test(knowledge): 增加文档相关命令的独立读回验证

- 在 knowledge doc delete 命令中添加异步删除的轮询验证,确保文档从服务器彻底移除
- 为 knowledge doc tag 添加标签设置后,独立调用 file get 验证标签正确应用
- 在知识库更新操作后,通过 info 命令独立验证更新是否成功保存
- 在文件删除和类别删除后,通过独立列表命令验证资源确实被清除
- 对知识块更新及排除标记修改,添加通过列表接口的内容验证步骤
- 对知识服务代理删除操作后,增加独立查询接口确保代理已彻底删除
- 补充 doc delete 备注,明确 doc_id 与 fileId 的区别及异步删除机制说明
- 增加 e2e 路由映射中缺失的 knowledge info 和 knowledge file get 命令支持
This commit is contained in:
zeyu.fz
2026-08-10 17:37:21 +08:00
parent 219d8be80a
commit 12e7a22195
7 changed files with 231 additions and 4 deletions
@@ -42,6 +42,8 @@ export default defineCommand({
flags: DOC_DELETE_FLAGS,
notes: [
"Removes documents from the knowledge base index only; the source files remain in the data center.",
"Use the doc_id from `knowledge doc list --quiet`, not the fileId from `knowledge doc upload`. For documents created via `knowledge create --doc-id`, the doc_id equals the fileId; for documents imported via `knowledge doc upload --index-id`, the doc_id may include a workspace suffix.",
"Deletion is asynchronous: the server returns Success immediately, but the document may still appear in `knowledge doc list` for up to ~30s until the change propagates.",
"The output lists the ids actually deleted as reported by the server.",
],
exampleArgs: [
@@ -1072,6 +1072,20 @@ describe.skipIf(!isKbAdminE2EReady())(
workspaceId,
]);
expect(deleteRun.exitCode, deleteRun.stderr).toBe(0);
// Verify the file is actually gone from the data center via read-back
const postFileListRun = await runCommandE2e(KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, [
"knowledge",
"file",
"list",
"--category-id",
categoryId,
"--workspace-id",
workspaceId,
"--quiet",
]);
expect(postFileListRun.exitCode, postFileListRun.stderr).toBe(0);
expect(postFileListRun.stdout.trim()).not.toContain(fileId);
} finally {
// Clean up the self-created category (doubles as live coverage of category delete)
const categoryDeleteRun = await runCommandE2e(KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, [
@@ -1085,6 +1099,20 @@ describe.skipIf(!isKbAdminE2EReady())(
workspaceId,
]);
expect(categoryDeleteRun.exitCode, categoryDeleteRun.stderr).toBe(0);
// Verify the category is actually gone from the server via read-back
const postCategoryListRun = await runCommandE2e(KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, [
"knowledge",
"category",
"list",
"--name",
categoryName,
"--workspace-id",
workspaceId,
"--quiet",
]);
expect(postCategoryListRun.exitCode, postCategoryListRun.stderr).toBe(0);
expect(postCategoryListRun.stdout.trim()).not.toContain(categoryId);
}
// kb create --wait adds a full import phase — generous timeout
}, 600_000);
@@ -1176,6 +1204,31 @@ describe.skipIf(!isKbAdminE2EReady())(
]);
expect(updateRun.exitCode, updateRun.stderr).toBe(0);
// Verify the content update landed on the server via independent read-back
const updateVerifyRun = await runCommandE2e(KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, [
"knowledge",
"chunk",
"list",
"--index-id",
indexId,
"--workspace-id",
workspaceId,
"--output",
"json",
]);
expect(updateVerifyRun.exitCode, updateVerifyRun.stderr).toBe(0);
const updateVerifyData = parseStdoutJson<{
data?: {
nodes?: Array<{ metadata?: { _id?: string; content?: string }; text?: string }>;
};
}>(updateVerifyRun.stdout);
const updatedChunk = updateVerifyData.data?.nodes?.find(
(node) => node.metadata?._id === targetChunkId,
);
expect(updatedChunk?.metadata?.content ?? updatedChunk?.text).toBe(
"e2e updated chunk content",
);
// update toggling --exclude only (no content — exercises the fetchChunkContent read-back path live)
const excludeRun = await runCommandE2e(KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, [
"knowledge",
@@ -1193,6 +1246,29 @@ describe.skipIf(!isKbAdminE2EReady())(
]);
expect(excludeRun.exitCode, excludeRun.stderr).toBe(0);
// Verify the exclude flag landed on the server via independent read-back
const excludeVerifyRun = await runCommandE2e(KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, [
"knowledge",
"chunk",
"list",
"--index-id",
indexId,
"--workspace-id",
workspaceId,
"--output",
"json",
]);
expect(excludeVerifyRun.exitCode, excludeVerifyRun.stderr).toBe(0);
const excludeVerifyData = parseStdoutJson<{
data?: {
nodes?: Array<{ metadata?: { _id?: string; is_displayed_chunk_content?: boolean } }>;
};
}>(excludeVerifyRun.stdout);
const excludedChunk = excludeVerifyData.data?.nodes?.find(
(node) => node.metadata?._id === targetChunkId,
);
expect(excludedChunk?.metadata?.is_displayed_chunk_content).toBe(false);
// kb stats on this base doubles as live coverage of the monitor endpoint
// (the only indices endpoint not touched by the chain)
const statsRun = await runCommandE2e(KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, [
@@ -1298,8 +1374,47 @@ describe.skipIf(!isKbAdminE2EReady())(
),
);
// Verify the chunks are actually gone from the server via independent read-back
const postDeleteListRun = await runCommandE2e(KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, [
"knowledge",
"chunk",
"list",
"--index-id",
indexId,
"--workspace-id",
workspaceId,
"--quiet",
]);
expect(postDeleteListRun.exitCode, postDeleteListRun.stderr).toBe(0);
expect(postDeleteListRun.stdout.trim()).toBe("");
// doc delete verifies document-level delete semantics (only unlinks from this
// base, unlike file delete)
// base, unlike file delete). The --doc-id must be the full doc_id from doc
// list (e.g. "file_xxx_<workspaceId>"), NOT the bare fileId ("file_xxx") —
// passing the bare fileId returns Success but does not actually remove the
// document.
const preDocListRun = await runCommandE2e(KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, [
"knowledge",
"doc",
"list",
"--index-id",
indexId,
"--workspace-id",
workspaceId,
"--output",
"json",
]);
expect(preDocListRun.exitCode, preDocListRun.stderr).toBe(0);
const preDocListData = parseStdoutJson<{
data?: { rows?: Array<{ doc_id?: string; doc_name?: string }> };
}>(preDocListRun.stdout);
const docRow = preDocListData.data?.rows?.find((row) => row.doc_id?.startsWith(fileId));
expect(
docRow,
`expected doc list to contain a doc_id starting with "${fileId}"`,
).toBeTruthy();
const fullDocId = docRow!.doc_id!;
const docDeleteRun = await runCommandE2e(KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, [
"knowledge",
"doc",
@@ -1307,13 +1422,41 @@ describe.skipIf(!isKbAdminE2EReady())(
"--index-id",
indexId,
"--doc-id",
fileId,
fullDocId,
"--yes",
"--workspace-id",
workspaceId,
]);
expect(docDeleteRun.exitCode, docDeleteRun.stderr).toBe(0);
expect(docDeleteRun.stdout).toMatch(new RegExp(fileId));
expect(docDeleteRun.stdout).toMatch(new RegExp(fullDocId));
// Verify the document is actually gone from the server via independent read-back.
// doc delete is asynchronous — the server returns Success immediately but the
// document disappears from doc list after a propagation delay (same pattern as
// IndexStatusError on kb delete). Poll until the doc_id is gone or timeout.
let docGone = false;
for (let retry = 0; retry < 6; retry++) {
await new Promise((resolve) => setTimeout(resolve, 10_000));
const postDocListRun = await runCommandE2e(KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, [
"knowledge",
"doc",
"list",
"--index-id",
indexId,
"--workspace-id",
workspaceId,
"--quiet",
]);
expect(postDocListRun.exitCode, postDocListRun.stderr).toBe(0);
if (!postDocListRun.stdout.trim().includes(fullDocId)) {
docGone = true;
break;
}
}
expect(
docGone,
`doc delete returned Success but the document is still in doc list after 60s`,
).toBe(true);
} finally {
// Clean up the throwaway base + data-center file (same IndexStatusError retry as the kb delete chain)
await runCommandE2e(KNOWLEDGE_CHUNK_CATEGORY_FILE_ROUTES, [
@@ -218,6 +218,22 @@ describe.skipIf(!isKbAdminE2EReady())("e2e: knowledge doc tag (live)", () => {
const data = parseStdoutJson<{ code: string }>(tagRun.stdout);
expect(data.code).toBe("Success");
// Verify the tag actually landed on the server via an independent read-back
const tagGetRun = await runCommandE2e(KNOWLEDGE_DOC_TAG_ROUTES, [
"knowledge",
"file",
"get",
"--file-id",
fileId,
"--workspace-id",
workspaceId,
"--output",
"json",
]);
expect(tagGetRun.exitCode, tagGetRun.stderr).toBe(0);
const tagGetDetail = parseStdoutJson<{ data: { tags?: string[] } }>(tagGetRun.stdout);
expect(tagGetDetail.data?.tags ?? []).toContain("e2e-tag");
// overwrite mode replaces the tag set live (append above covered the default)
const overwriteRun = await runCommandE2e(KNOWLEDGE_DOC_TAG_ROUTES, [
"knowledge",
@@ -238,6 +254,26 @@ describe.skipIf(!isKbAdminE2EReady())("e2e: knowledge doc tag (live)", () => {
const overwriteData = parseStdoutJson<{ code: string }>(overwriteRun.stdout);
expect(overwriteData.code).toBe("Success");
// Verify overwrite replaced the tag set — old tag gone, new tag present
const overwriteGetRun = await runCommandE2e(KNOWLEDGE_DOC_TAG_ROUTES, [
"knowledge",
"file",
"get",
"--file-id",
fileId,
"--workspace-id",
workspaceId,
"--output",
"json",
]);
expect(overwriteGetRun.exitCode, overwriteGetRun.stderr).toBe(0);
const overwriteGetDetail = parseStdoutJson<{ data: { tags?: string[] } }>(
overwriteGetRun.stdout,
);
const overwriteTags = overwriteGetDetail.data?.tags ?? [];
expect(overwriteTags).toContain("e2e-tag-final");
expect(overwriteTags).not.toContain("e2e-tag");
// Clean up the uploaded data-center file
const fileDeleteRun = await runCommandE2e(KNOWLEDGE_DOC_TAG_ROUTES, [
"knowledge",
@@ -113,13 +113,14 @@ describe.skipIf(!isKbAdminE2EReady())("e2e: knowledge kb 写链路 (live, 自清
expect(indexId).toBeTruthy();
// 2.5) Live update coverage: name / description / rerank threshold in one call
const updatedName = `e2e-upd-${Date.now() % 100000000}`;
const updateRun = await runCommandE2e(KNOWLEDGE_KB_DELETE_ROUTES, [
"knowledge",
"update",
"--index-id",
indexId,
"--name",
`e2e-upd-${Date.now() % 100000000}`,
updatedName,
"--description",
"e2e chain updated description",
"--rerank-min-score",
@@ -130,6 +131,28 @@ describe.skipIf(!isKbAdminE2EReady())("e2e: knowledge kb 写链路 (live, 自清
expect(updateRun.exitCode, updateRun.stderr).toBe(0);
expect(updateRun.stdout).toMatch(/updated/);
// 2.6) Verify the update actually landed on the server — not just that the
// command returned Success, but that a fresh read-back shows the new values
const infoRun = await runCommandE2e(KNOWLEDGE_KB_DELETE_ROUTES, [
"knowledge",
"info",
"--index-id",
indexId,
"--workspace-id",
workspaceId,
"--output",
"json",
]);
expect(infoRun.exitCode, infoRun.stderr).toBe(0);
const infoData = parseStdoutJson<{
name?: string;
description?: string;
rerankMinScore?: number;
}>(infoRun.stdout);
expect(infoData.name).toBe(updatedName);
expect(infoData.description).toBe("e2e chain updated description");
expect(infoData.rerankMinScore).toBe(0.3);
// 2.7) Live coverage of the upload → import orchestration (doc upload --index-id --wait):
// the journeys always upload bare files and import via kb create, so this is the
// only place the createImportJob step runs live. Using --output json (not --quiet)
@@ -647,5 +647,24 @@ describe.skipIf(!isKbAdminE2EReady())("e2e: knowledge service 生命周期 (live
]);
expect(deleteRun.exitCode, deleteRun.stderr).toBe(0);
}
// 6.5) Verify both agents are gone from the server — not just that delete
// returned Success, but that a fresh list filtered by agent_id finds nothing
for (const idToVerify of [copiedAgentId, agentId]) {
const postDeleteListRun = await runCommandE2e(KNOWLEDGE_SERVICE_ROUTES, [
"knowledge",
"service",
"list",
"--scene",
"chat",
"--agent-id",
idToVerify,
"--workspace-id",
workspaceId,
"--quiet",
]);
expect(postDeleteListRun.exitCode, postDeleteListRun.stderr).toBe(0);
expect(postDeleteListRun.stdout.trim()).toBe("");
}
}, 300_000);
});
@@ -220,6 +220,7 @@ export const KNOWLEDGE_KB_DELETE_ROUTES: E2eRouteExports = {
"knowledge delete": "knowledgeKbDelete",
"knowledge create": "knowledgeKbCreate", // live self-cleaning chain
"knowledge update": "knowledgeKbUpdate", // live update step in the chain
"knowledge info": "knowledgeKbInfo", // live: verify update landed on the server
"knowledge list": "knowledgeKbList",
"knowledge doc upload": "knowledgeDocUpload",
"knowledge doc list": "knowledgeDocList", // live: verify imported file is visible in the KB
@@ -233,6 +234,7 @@ export const KNOWLEDGE_DOC_DELETE_ROUTES: E2eRouteExports = {
export const KNOWLEDGE_DOC_TAG_ROUTES: E2eRouteExports = {
"knowledge doc tag": "knowledgeDocTag",
"knowledge doc upload": "knowledgeDocUpload", // live uploads first to grab a real fileId
"knowledge file get": "knowledgeFileGet", // live: verify tags landed on the server
"knowledge file delete": "knowledgeFileDelete", // live cleanup of data-center files
};
@@ -502,6 +502,8 @@ bl knowledge delete --index-id idx-xxx --yes
#### Notes
- Removes documents from the knowledge base index only; the source files remain in the data center.
- Use the doc_id from `knowledge doc list --quiet`, not the fileId from `knowledge doc upload`. For documents created via `knowledge create --doc-id`, the doc_id equals the fileId; for documents imported via `knowledge doc upload --index-id`, the doc_id may include a workspace suffix.
- Deletion is asynchronous: the server returns Success immediately, but the document may still appear in `knowledge doc list` for up to ~30s until the change propagates.
- The output lists the ids actually deleted as reported by the server.
#### Examples