import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'; import crypto from 'node:crypto'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; let userDataDir = ''; vi.mock('@electron/utils/paths', () => ({ getDataDir: () => userDataDir, })); describe('knowledge routes', () => { beforeEach(async () => { vi.resetModules(); userDataDir = await mkdtemp(join(tmpdir(), 'zhinian-knowledge-test-')); }); afterEach(async () => { await rm(userDataDir, { recursive: true, force: true }); }); it('copies supported text files into the app knowledge directory', async () => { const sourceDir = await mkdtemp(join(tmpdir(), 'zhinian-knowledge-source-')); const sourcePath = join(sourceDir, 'faq.md'); await writeFile(sourcePath, '# FAQ\n\nhello', 'utf8'); const { importKnowledgeFiles } = await import('@electron/api/routes/knowledge'); const result = await importKnowledgeFiles({ workspaceId: 'workspace/test', filePaths: [sourcePath], }); expect(result.rejected).toEqual([]); expect(result.documents).toHaveLength(1); expect(result.documents[0].name).toBe('faq.md'); expect(result.documents[0].mimeType).toBe('text/markdown'); expect(result.documents[0].storedPath).not.toBe(sourcePath); await expect(stat(result.documents[0].storedPath)).resolves.toBeTruthy(); await expect(readFile(result.documents[0].storedPath, 'utf8')).resolves.toContain('hello'); const registryPath = join(userDataDir, 'yinian', 'knowledge', 'workspace_test', 'registry.json'); await expect(readFile(registryPath, 'utf8')).resolves.toContain('faq.md'); await rm(sourceDir, { recursive: true, force: true }); }); it('builds chat context from the local backup after the source file is deleted', async () => { const sourceDir = await mkdtemp(join(tmpdir(), 'zhinian-knowledge-source-')); const sourcePath = join(sourceDir, 'policy.md'); await writeFile(sourcePath, '# Policy\n\nRefund within 7 days.', 'utf8'); const { importKnowledgeFiles, buildKnowledgeContext } = await import('@electron/api/routes/knowledge'); const imported = await importKnowledgeFiles({ workspaceId: 'workspace-context', filePaths: [sourcePath], }); await rm(sourceDir, { recursive: true, force: true }); const context = await buildKnowledgeContext({ workspaceId: 'workspace-context', documentIds: [imported.documents[0].id], }); expect(context.missing).toEqual([]); expect(context.documents[0].name).toBe('policy.md'); expect(context.context).toContain('[知识库上下文]'); expect(context.context).toContain('Refund within 7 days.'); }); it('builds chat context from extracted docx text', async () => { const sourceDir = await mkdtemp(join(tmpdir(), 'zhinian-knowledge-source-')); const sourcePath = join(sourceDir, 'handbook.docx'); await writeMinimalDocx(sourcePath, 'Use the local backup for answers.'); const { importKnowledgeFiles, buildKnowledgeContext } = await import('@electron/api/routes/knowledge'); const imported = await importKnowledgeFiles({ workspaceId: 'workspace-docx-context', filePaths: [sourcePath], }); const context = await buildKnowledgeContext({ workspaceId: 'workspace-docx-context', documentIds: [imported.documents[0].id], }); expect(context.context).toContain('handbook.docx'); expect(context.context).toContain('Use the local backup for answers.'); await rm(sourceDir, { recursive: true, force: true }); }); it('rejects unsupported binary-looking files', async () => { const sourceDir = await mkdtemp(join(tmpdir(), 'zhinian-knowledge-source-')); const sourcePath = join(sourceDir, 'image.png'); await writeFile(sourcePath, 'not really an image', 'utf8'); const { importKnowledgeFiles } = await import('@electron/api/routes/knowledge'); const result = await importKnowledgeFiles({ workspaceId: 'workspace-test', filePaths: [sourcePath], }); expect(result.documents).toEqual([]); expect(result.rejected).toEqual([{ filePath: sourcePath, reason: '仅支持文本类知识文件' }]); await rm(sourceDir, { recursive: true, force: true }); }); it('rejects missing files without changing the registry', async () => { const missingPath = join(tmpdir(), `missing-${crypto.randomUUID()}.md`); const { importKnowledgeFiles } = await import('@electron/api/routes/knowledge'); const result = await importKnowledgeFiles({ workspaceId: 'workspace-missing', filePaths: [missingPath], }); expect(result.documents).toEqual([]); expect(result.rejected).toEqual([{ filePath: missingPath, reason: '文件不存在或不可读取' }]); const registryPath = join(userDataDir, 'yinian', 'knowledge', 'workspace-missing', 'registry.json'); await expect(readFile(registryPath, 'utf8')).rejects.toThrow(); }); it('keeps newer imports at the top of the local registry', async () => { const sourceDir = await mkdtemp(join(tmpdir(), 'zhinian-knowledge-source-')); const oldPath = join(sourceDir, 'old.md'); const newPath = join(sourceDir, 'new.md'); await writeFile(oldPath, 'old knowledge', 'utf8'); await writeFile(newPath, 'new knowledge', 'utf8'); const { importKnowledgeFiles } = await import('@electron/api/routes/knowledge'); await importKnowledgeFiles({ workspaceId: 'workspace-order', filePaths: [oldPath] }); await importKnowledgeFiles({ workspaceId: 'workspace-order', filePaths: [newPath] }); const registryPath = join(userDataDir, 'yinian', 'knowledge', 'workspace-order', 'registry.json'); const registry = JSON.parse(await readFile(registryPath, 'utf8')) as Array<{ name: string }>; expect(registry.map((doc) => doc.name)).toEqual(['new.md', 'old.md']); await rm(sourceDir, { recursive: true, force: true }); }); it('copies docx files and extracts searchable text', async () => { const sourceDir = await mkdtemp(join(tmpdir(), 'zhinian-knowledge-source-')); const sourcePath = join(sourceDir, 'handbook.docx'); await writeMinimalDocx(sourcePath, 'Welcome to Zhinian Handbook'); const { importKnowledgeFiles } = await import('@electron/api/routes/knowledge'); const result = await importKnowledgeFiles({ workspaceId: 'workspace-docx', filePaths: [sourcePath], }); expect(result.rejected).toEqual([]); expect(result.documents).toHaveLength(1); expect(result.documents[0].name).toBe('handbook.docx'); expect(result.documents[0].mimeType).toBe('application/vnd.openxmlformats-officedocument.wordprocessingml.document'); expect(result.documents[0].storedPath).not.toBe(sourcePath); expect(result.documents[0].textPath).toBeTruthy(); await expect(readFile(result.documents[0].textPath!, 'utf8')).resolves.toContain('Welcome to Zhinian Handbook'); await rm(sourceDir, { recursive: true, force: true }); }); it('rejects invalid docx files before adding them to the registry', async () => { const sourceDir = await mkdtemp(join(tmpdir(), 'zhinian-knowledge-source-')); const sourcePath = join(sourceDir, 'broken.docx'); await writeFile(sourcePath, 'not a zip document', 'utf8'); const { importKnowledgeFiles } = await import('@electron/api/routes/knowledge'); const result = await importKnowledgeFiles({ workspaceId: 'workspace-docx-broken', filePaths: [sourcePath], }); expect(result.documents).toEqual([]); expect(result.rejected).toEqual([{ filePath: sourcePath, reason: 'Word 文档解析失败,请确认文件为 .docx 格式' }]); const registryPath = join(userDataDir, 'yinian', 'knowledge', 'workspace-docx-broken', 'registry.json'); await expect(readFile(registryPath, 'utf8')).rejects.toThrow(); await rm(sourceDir, { recursive: true, force: true }); }); }); async function writeMinimalDocx(filePath: string, text: string) { const JSZip = (await import('jszip')).default; const zip = new JSZip(); zip.file('[Content_Types].xml', ` `); zip.folder('_rels')?.file('.rels', ` `); zip.folder('word')?.file('document.xml', ` ${escapeXml(text)} `); const buffer = await zip.generateAsync({ type: 'nodebuffer' }); await writeFile(filePath, buffer); } function escapeXml(value: string): string { return value .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); }