|
|
@@ -5,17 +5,53 @@ import type { AddressInfo } from 'node:net'
|
|
|
import path from 'node:path'
|
|
|
import pino from 'pino'
|
|
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
|
|
-import type { ProfileInput } from '@agent-server/shared'
|
|
|
+import type { KnowledgeBrowseDto, KnowledgeLocalCheckDto, ProfileInput } from '@agent-server/shared'
|
|
|
import { createApp, type App } from '../src/app'
|
|
|
-import { knowledgeFilesDir } from '../src/config'
|
|
|
+import { knowledgeFilesDir, knowledgeMountDir } from '../src/config'
|
|
|
import { describeKnowledge, linkOrigins } from '../src/knowledge/describe'
|
|
|
+import { MounterRejected, MounterUnavailable, type MounterApi, type MountSpec, type MountStatusRow } from '../src/knowledge/mounter'
|
|
|
+
|
|
|
+/** 模拟挂载服务:/home/u 下在系统盘,/media/u/Data 下在外接盘(UUID aaaa) */
|
|
|
+class FakeMounter implements MounterApi {
|
|
|
+ up = true
|
|
|
+ synced: MountSpec[][] = []
|
|
|
+ checks: string[] = []
|
|
|
+ states = new Map<string, Omit<MountStatusRow, 'id'>>()
|
|
|
+ private guard() {
|
|
|
+ if (!this.up) throw new MounterUnavailable('挂载服务没有运行')
|
|
|
+ }
|
|
|
+ async list(p: string): Promise<KnowledgeBrowseDto> {
|
|
|
+ this.guard()
|
|
|
+ return { path: p, parent: '', entries: [{ name: 'docs', path: `${p}/docs`, selectable: true }] }
|
|
|
+ }
|
|
|
+ async check(p: string): Promise<KnowledgeLocalCheckDto> {
|
|
|
+ this.guard()
|
|
|
+ this.checks.push(p)
|
|
|
+ if (p.startsWith('/media/u/Data/')) return { path: p, uuid: 'aaaa', relPath: p.slice('/media/u/Data'.length), systemDisk: false, mountPoint: '/media/u/Data', fsType: 'ext4', device: '/dev/sda1' }
|
|
|
+ if (p.startsWith('/home/u/')) return { path: p, uuid: null, relPath: null, systemDisk: true, mountPoint: '/', fsType: 'ext4', device: '/dev/dm-0' }
|
|
|
+ throw new MounterRejected('只能选择 /home、/mnt、/media 下的目录', 'not_allowed')
|
|
|
+ }
|
|
|
+ private rows(): MountStatusRow[] {
|
|
|
+ return (this.synced.at(-1) ?? []).map(m => ({ id: m.id, ...(this.states.get(m.id) ?? { state: 'ok', message: null, since: '2026-11-06T00:00:00Z' }) }))
|
|
|
+ }
|
|
|
+ async sync(mounts: MountSpec[]) {
|
|
|
+ this.guard()
|
|
|
+ this.synced.push(mounts)
|
|
|
+ return this.rows()
|
|
|
+ }
|
|
|
+ async status() {
|
|
|
+ this.guard()
|
|
|
+ return { version: 'test', mounts: this.rows() }
|
|
|
+ }
|
|
|
+}
|
|
|
+const mounter = new FakeMounter()
|
|
|
|
|
|
let app: App
|
|
|
let site: http.Server
|
|
|
let siteOrigin: string
|
|
|
|
|
|
beforeAll(async () => {
|
|
|
- app = createApp(pino({ level: 'silent' }), { providers: {} })
|
|
|
+ app = createApp(pino({ level: 'silent' }), { providers: {}, mounter })
|
|
|
site = http.createServer((req, res) => {
|
|
|
if (req.url === '/missing') return res.writeHead(404).end()
|
|
|
res.setHeader('content-type', 'text/html; charset=utf-8')
|
|
|
@@ -35,30 +71,30 @@ const profile = (over: Partial<ProfileInput> = {}): ProfileInput => ({
|
|
|
})
|
|
|
|
|
|
describe('资料库类型', () => {
|
|
|
- it('托管目录:平台在 files/<id> 下创建目录,可以设为 Agent 可写;删除时连同文件一起删除', () => {
|
|
|
- const k = app.knowledge.create({ kind: 'managed', name: '产品资料', description: '产品手册', access: 'write' })
|
|
|
+ it('托管目录:平台在 files/<id> 下创建目录,可以设为 Agent 可写;删除时连同文件一起删除', async () => {
|
|
|
+ const k = await app.knowledge.create({ kind: 'managed', name: '产品资料', description: '产品手册', access: 'write' })
|
|
|
expect(k.path).toBe(path.join(knowledgeFilesDir(), k.id))
|
|
|
expect(fs.statSync(k.path).isDirectory()).toBe(true)
|
|
|
expect(k).toMatchObject({ kind: 'managed', access: 'write', url: null, status: { state: 'ok' } })
|
|
|
fs.writeFileSync(path.join(k.path, 'a.md'), 'x')
|
|
|
- expect(app.knowledge.update(k.id, { kind: 'managed', name: '产品资料', access: 'read' }).access).toBe('read')
|
|
|
+ expect((await app.knowledge.update(k.id, { kind: 'managed', name: '产品资料', access: 'read' })).access).toBe('read')
|
|
|
app.knowledge.delete(k.id)
|
|
|
expect(fs.existsSync(k.path)).toBe(false)
|
|
|
})
|
|
|
|
|
|
- it('网络链接:只接受 http/https,不能带用户名密码;没有目录、只读', () => {
|
|
|
- const k = app.knowledge.create({ kind: 'link', name: '内部文档', url: 'http://wiki.local/docs/', access: 'write' })
|
|
|
+ it('网络链接:只接受 http/https,不能带用户名密码;没有目录、只读', async () => {
|
|
|
+ const k = await app.knowledge.create({ kind: 'link', name: '内部文档', url: 'http://wiki.local/docs/', access: 'write' })
|
|
|
expect(k).toMatchObject({ path: '', url: 'http://wiki.local/docs/', access: 'read', status: { state: 'ok' } })
|
|
|
- expect(() => app.knowledge.create({ kind: 'link', name: 'ftp', url: 'ftp://a.com/x' })).toThrow(/http/)
|
|
|
- expect(() => app.knowledge.create({ kind: 'link', name: '坏链接', url: 'not a url' })).toThrow(/格式不正确/)
|
|
|
- expect(() => app.knowledge.create({ kind: 'link', name: '带密码', url: 'http://u:p@a.com/' })).toThrow(/用户名或密码/)
|
|
|
+ await expect(app.knowledge.create({ kind: 'link', name: 'ftp', url: 'ftp://a.com/x' })).rejects.toThrow(/http/)
|
|
|
+ await expect(app.knowledge.create({ kind: 'link', name: '坏链接', url: 'not a url' })).rejects.toThrow(/格式不正确/)
|
|
|
+ await expect(app.knowledge.create({ kind: 'link', name: '带密码', url: 'http://u:p@a.com/' })).rejects.toThrow(/用户名或密码/)
|
|
|
})
|
|
|
|
|
|
- it('名称不能重复;类型不能修改;本机目录与远程存储需要挂载服务', () => {
|
|
|
+ it('名称不能重复;类型不能修改;远程存储尚未提供', async () => {
|
|
|
const k = app.knowledge.list().find(x => x.name === '内部文档')!
|
|
|
- expect(() => app.knowledge.create({ kind: 'managed', name: '内部文档' })).toThrow(/已被使用/)
|
|
|
- expect(() => app.knowledge.update(k.id, { kind: 'managed', name: '内部文档' })).toThrow(/类型不能修改/)
|
|
|
- expect(() => app.knowledge.create({ kind: 'local', name: '本机', localPath: '/home/x/docs' })).toThrow(/挂载服务/)
|
|
|
+ await expect(app.knowledge.create({ kind: 'managed', name: '内部文档' })).rejects.toThrow(/已被使用/)
|
|
|
+ await expect(app.knowledge.update(k.id, { kind: 'managed', name: '内部文档' })).rejects.toThrow(/类型不能修改/)
|
|
|
+ await expect(app.knowledge.create({ kind: 'remote', name: '远程' })).rejects.toThrow(/尚未提供/)
|
|
|
})
|
|
|
|
|
|
it('测试访问:返回状态码、标题;失败时给出原因', async () => {
|
|
|
@@ -81,10 +117,10 @@ describe('资料库类型', () => {
|
|
|
})
|
|
|
|
|
|
describe('资料集合', () => {
|
|
|
- it('Profile 生效的资料库 = 单独勾选的 ∪ 集合成员,去掉停用的;集合成员变化立即生效', () => {
|
|
|
- const a = app.knowledge.create({ kind: 'managed', name: '合同' })
|
|
|
- const b = app.knowledge.create({ kind: 'managed', name: '发票' })
|
|
|
- const c = app.knowledge.create({ kind: 'link', name: '报价单', url: 'https://erp.example.com/quotes' })
|
|
|
+ it('Profile 生效的资料库 = 单独勾选的 ∪ 集合成员,去掉停用的;集合成员变化立即生效', async () => {
|
|
|
+ const a = await app.knowledge.create({ kind: 'managed', name: '合同' })
|
|
|
+ const b = await app.knowledge.create({ kind: 'managed', name: '发票' })
|
|
|
+ const c = await app.knowledge.create({ kind: 'link', name: '报价单', url: 'https://erp.example.com/quotes' })
|
|
|
const col = app.knowledge.createCollection({ name: '财务', description: '财务相关', rootIds: [a.id, b.id, a.id] })
|
|
|
expect(col.rootIds).toEqual([a.id, b.id])
|
|
|
expect(app.knowledge.get(a.id).collectionIds).toEqual([col.id])
|
|
|
@@ -94,10 +130,10 @@ describe('资料集合', () => {
|
|
|
const names = () => app.knowledge.effective(app.profiles.get(p.id)).map(k => k.name).sort()
|
|
|
expect(names()).toEqual(['发票', '合同', '报价单'])
|
|
|
|
|
|
- app.knowledge.update(b.id, { kind: 'managed', name: '发票', enabled: false })
|
|
|
+ await app.knowledge.update(b.id, { kind: 'managed', name: '发票', enabled: false })
|
|
|
expect(names()).toEqual(['合同', '报价单'])
|
|
|
app.knowledge.updateCollection(col.id, { name: '财务', rootIds: [a.id] })
|
|
|
- app.knowledge.update(b.id, { kind: 'managed', name: '发票', enabled: true })
|
|
|
+ await app.knowledge.update(b.id, { kind: 'managed', name: '发票', enabled: true })
|
|
|
expect(names()).toEqual(['合同', '报价单'])
|
|
|
|
|
|
// 被 Profile 使用的集合不能删除;删除资料库时从集合中移除
|
|
|
@@ -123,3 +159,68 @@ describe('资料集合', () => {
|
|
|
expect(v2.version).toBe(2)
|
|
|
})
|
|
|
})
|
|
|
+
|
|
|
+describe('本机目录(挂载服务)', () => {
|
|
|
+ const settle = () => new Promise(r => setTimeout(r, 20))
|
|
|
+
|
|
|
+ it('新建时由挂载服务检查并识别设备;登记列表同步给挂载服务,状态来自挂载服务', async () => {
|
|
|
+ const home = await app.knowledge.create({ kind: 'local', name: '项目文档', localPath: '/home/u/docs' })
|
|
|
+ expect(home).toMatchObject({ kind: 'local', access: 'read', path: path.join(knowledgeMountDir(), home.id), local: { path: '/home/u/docs', uuid: null, relPath: null } })
|
|
|
+ const disk = await app.knowledge.create({ kind: 'local', name: '移动硬盘资料', localPath: '/media/u/Data/资料', access: 'write' })
|
|
|
+ expect(disk).toMatchObject({ access: 'read', local: { path: '/media/u/Data/资料', uuid: 'aaaa', relPath: '/资料' } })
|
|
|
+ await settle()
|
|
|
+ expect(mounter.synced.at(-1)).toEqual(expect.arrayContaining([
|
|
|
+ { id: home.id, kind: 'local', local: { path: '/home/u/docs', uuid: null, relPath: null } },
|
|
|
+ { id: disk.id, kind: 'local', local: { path: '/media/u/Data/资料', uuid: 'aaaa', relPath: '/资料' } },
|
|
|
+ ]))
|
|
|
+ expect(app.knowledge.get(home.id).status.state).toBe('ok')
|
|
|
+
|
|
|
+ // 设备拔出:状态与原因来自挂载服务,提示词中标注暂不可用
|
|
|
+ mounter.states.set(disk.id, { state: 'device_missing', message: '设备未连接(文件系统 UUID aaaa)', since: '2026-11-06T01:00:00Z' })
|
|
|
+ await app.knowledge.refreshMounts()
|
|
|
+ expect(app.knowledge.get(disk.id).status).toEqual({ state: 'device_missing', message: '设备未连接(文件系统 UUID aaaa)', since: '2026-11-06T01:00:00Z' })
|
|
|
+ expect(describeKnowledge([app.knowledge.get(disk.id)])).toContain('(本机目录,只读,暂不可用:设备未连接(文件系统 UUID aaaa))')
|
|
|
+ mounter.states.delete(disk.id)
|
|
|
+
|
|
|
+ // 停用后不再挂载;删除后从登记列表中移除
|
|
|
+ await app.knowledge.update(home.id, { kind: 'local', name: '项目文档', localPath: '/home/u/docs', enabled: false })
|
|
|
+ await settle()
|
|
|
+ expect(mounter.synced.at(-1)!.map(m => m.id)).not.toContain(home.id)
|
|
|
+ app.knowledge.delete(home.id)
|
|
|
+ await settle()
|
|
|
+ expect(app.knowledge.list().some(k => k.id === home.id)).toBe(false)
|
|
|
+ })
|
|
|
+
|
|
|
+ it('挂载服务拒绝的目录不能登记;路径不变时修改名称不重新检查', async () => {
|
|
|
+ await expect(app.knowledge.create({ kind: 'local', name: '系统目录', localPath: '/etc' })).rejects.toThrow(/只能选择/)
|
|
|
+ await expect(app.knowledge.create({ kind: 'local', name: '空路径' })).rejects.toThrow(/请选择本机目录/)
|
|
|
+ const k = app.knowledge.list().find(x => x.name === '移动硬盘资料')!
|
|
|
+ mounter.checks = []
|
|
|
+ await app.knowledge.update(k.id, { kind: 'local', name: '移动硬盘', localPath: '/media/u/Data/资料' })
|
|
|
+ expect(mounter.checks).toEqual([])
|
|
|
+ expect(app.knowledge.get(k.id).local).toEqual({ path: '/media/u/Data/资料', uuid: 'aaaa', relPath: '/资料' })
|
|
|
+ })
|
|
|
+
|
|
|
+ it('挂载服务不可用:本机目录状态为 mounter_down,不能新建;恢复后重新同步', async () => {
|
|
|
+ mounter.up = false
|
|
|
+ await app.knowledge.refreshMounts()
|
|
|
+ const k = app.knowledge.list().find(x => x.name === '移动硬盘')!
|
|
|
+ expect(k.status).toMatchObject({ state: 'mounter_down', message: '挂载服务没有运行' })
|
|
|
+ expect(app.knowledge.mounterInfo()).toEqual({ available: false, version: null, error: '挂载服务没有运行' })
|
|
|
+ await expect(app.knowledge.create({ kind: 'local', name: '新目录', localPath: '/home/u/new' })).rejects.toMatchObject({ status: 503 })
|
|
|
+ await expect(app.knowledge.browse('/home')).rejects.toThrow(/agent-mounter/)
|
|
|
+ // 挂载服务不可用时仍可修改名称、说明,也可以停用
|
|
|
+ await app.knowledge.update(k.id, { kind: 'local', name: '移动硬盘', description: '合同扫描件', localPath: '/media/u/Data/资料', enabled: false })
|
|
|
+
|
|
|
+ // 恢复后:挂载服务的登记与平台不一致,重新同步
|
|
|
+ mounter.up = true
|
|
|
+ await app.knowledge.refreshMounts()
|
|
|
+ expect(app.knowledge.mounterInfo()).toEqual({ available: true, version: 'test', error: null })
|
|
|
+ await settle()
|
|
|
+ expect(mounter.synced.at(-1)!.map(m => m.id)).not.toContain(k.id)
|
|
|
+ await app.knowledge.update(k.id, { kind: 'local', name: '移动硬盘', localPath: '/media/u/Data/资料', enabled: true })
|
|
|
+ await settle()
|
|
|
+ expect(mounter.synced.at(-1)!.map(m => m.id)).toContain(k.id)
|
|
|
+ expect(app.knowledge.get(k.id).status.state).toBe('ok')
|
|
|
+ })
|
|
|
+})
|