/** * Unit Tests — org-graph-service.js (P2 / T9) * * 覆盖 27-p2-org-graph.md §3.3 的四项校验(各至少一条负例)+ §4.3 的读写语义: * writeGraph 覆盖式写入、removeNode 级联失效边、patchGraph 破坏合法性时整体回滚。 * * 与 graph-db.test.js / graph-service.test.js 同一套隔离手法:先设 GRAPH_DB_PATH * 再 require,真实的 data/graph.sqlite 全程不被碰到。 */ import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; import fs from 'fs'; describe('org-graph-service', () => { let graphDb; let svc; const TEST_DB_PATH = '/tmp/test-org-graph-service.sqlite'; const CO = 'test-co'; beforeAll(() => { if (fs.existsSync(TEST_DB_PATH)) fs.unlinkSync(TEST_DB_PATH); process.env.GRAPH_DB_PATH = TEST_DB_PATH; delete require.cache[require.resolve('../../services/graph-db')]; delete require.cache[require.resolve('../../services/graph-service')]; delete require.cache[require.resolve('../../services/org-graph-service')]; graphDb = require('../../services/graph-db'); svc = require('../../services/org-graph-service'); }); afterAll(() => { try { fs.unlinkSync(TEST_DB_PATH); } catch {} try { fs.unlinkSync(`${TEST_DB_PATH}-shm`); } catch {} try { fs.unlinkSync(`${TEST_DB_PATH}-wal`); } catch {} delete process.env.GRAPH_DB_PATH; }); beforeEach(() => { // 外键开着,删除顺序必须 边 → 节点 → agent graphDb.getDb().exec( 'DELETE FROM org_graph_edges; DELETE FROM org_graph_nodes; DELETE FROM system_agents;' ); }); /** 断言 errors 里确实报出了某条规则;失败时把全部 rule 打出来便于定位 */ const rules = (verdict) => verdict.errors.map((e) => e.rule); // 合法样板图:planner → coder → validator,外加 validator --fail--> coder 的回退边 const DEV_NODES = ['code-planner', 'code-coder', 'code-validator']; const DEV_EDGES = [ { from: 'code-planner', to: 'code-coder', condition: 'success' }, { from: 'code-coder', to: 'code-validator', condition: 'success' }, { from: 'code-validator', to: 'code-coder', condition: 'fail', edgeKind: 'backward' }, ]; // ────────────────────────────────────────────────────────────── // §3.3 四项校验 // ────────────────────────────────────────────────────────────── describe('validateGraph', () => { it('合法图通过校验(planner → coder → validator + backward 回退边)', () => { const v = svc.validateGraph(DEV_NODES, DEV_EDGES); expect(v.errors).toEqual([]); expect(v.ok).toBe(true); }); it('负例 acyclic:forward 边成环的图被拒', () => { const v = svc.validateGraph(['a', 'b', 'c'], [ { from: 'a', to: 'b', condition: 'success' }, { from: 'b', to: 'c', condition: 'success' }, { from: 'c', to: 'a', condition: 'success' }, // 没标 backward,构成 forward 环 ]); expect(v.ok).toBe(false); expect(rules(v)).toContain('acyclic'); }); it('负例 single-entry:两个入度为 0 的节点被拒', () => { const v = svc.validateGraph(['entry-a', 'entry-b', 'sink'], [ { from: 'entry-a', to: 'sink', condition: 'success' }, { from: 'entry-b', to: 'sink', condition: 'success' }, ]); expect(v.ok).toBe(false); expect(rules(v)).toContain('single-entry'); // 报错信息要点出是哪两个入口,供规划 agent 自行修正 const err = v.errors.find((e) => e.rule === 'single-entry'); expect(err.detail).toContain('entry-a'); expect(err.detail).toContain('entry-b'); }); it('负例 reachable:孤立节点被拒', () => { const v = svc.validateGraph(['code-planner', 'code-coder', 'code-orphan'], [ { from: 'code-planner', to: 'code-coder', condition: 'success' }, ]); expect(v.ok).toBe(false); expect(rules(v)).toContain('reachable'); expect(v.errors.find((e) => e.rule === 'reachable').detail).toContain('code-orphan'); }); it('负例 backward-labeled:回退边没标 edgeKind=backward 被拒', () => { const v = svc.validateGraph(DEV_NODES, [ { from: 'code-planner', to: 'code-coder', condition: 'success' }, { from: 'code-coder', to: 'code-validator', condition: 'success' }, // 与 DEV_EDGES 的唯一差别:漏标 backward { from: 'code-validator', to: 'code-coder', condition: 'fail' }, ]); expect(v.ok).toBe(false); expect(rules(v)).toContain('backward-labeled'); // 环上每条边都被独立复核,所以漏标的那条一定在报错里(同环的另一条边也会被点名, // 因为两条都标 forward 时无从判断哪条才是回退边——这是规则 4 的预期行为) const details = v.errors.filter((e) => e.rule === 'backward-labeled').map((e) => e.detail).join('\n'); expect(details).toContain('code-validator -> code-coder'); }); it('负例 backward-labeled(反向):不构成回路的边被误标 backward 也被拒', () => { const v = svc.validateGraph(DEV_NODES, [ { from: 'code-planner', to: 'code-coder', condition: 'success' }, { from: 'code-coder', to: 'code-validator', condition: 'success', edgeKind: 'backward' }, ]); expect(v.ok).toBe(false); expect(rules(v)).toContain('backward-labeled'); }); it('负例 reference:边引用了图中不存在的节点', () => { const v = svc.validateGraph(['a'], [{ from: 'a', to: 'ghost', condition: 'success' }]); expect(v.ok).toBe(false); expect(rules(v)).toContain('reference'); }); it('空图合法(patchGraph 要能把一张图清空)', () => { expect(svc.validateGraph([], []).ok).toBe(true); }); }); // ────────────────────────────────────────────────────────────── // writeGraph // ────────────────────────────────────────────────────────────── describe('writeGraph / getGraph', () => { it('合法图能写入,getGraph 按 id 读回节点与边', () => { const res = svc.writeGraph(CO, { nodes: DEV_NODES, edges: DEV_EDGES }); expect(res).toMatchObject({ ok: true, nodeCount: 3, edgeCount: 3 }); const g = svc.getGraph(CO); expect(g.nodes.map((n) => n.roleName)).toEqual(DEV_NODES); expect(g.nodes.every((n) => n.status === 'active')).toBe(true); const byName = new Map(g.nodes.map((n) => [n.roleName, n.id])); expect(g.edges).toEqual([ { from: byName.get('code-planner'), to: byName.get('code-coder'), condition: 'success', edgeKind: 'forward' }, { from: byName.get('code-coder'), to: byName.get('code-validator'), condition: 'success', edgeKind: 'forward' }, { from: byName.get('code-validator'), to: byName.get('code-coder'), condition: 'fail', edgeKind: 'backward' }, ]); }); it('非法图一行都不落库,并返回具体违反了哪条规则', () => { const res = svc.writeGraph(CO, { nodes: ['a', 'b', 'orphan'], edges: [{ from: 'a', to: 'b', condition: 'success' }], }); expect(res.ok).toBe(false); expect(res.errors.map((e) => e.rule)).toContain('reachable'); expect(svc.getGraph(CO)).toEqual({ nodes: [], edges: [] }); }); it('重复写同一张图是幂等的(节点/边不翻倍)', () => { svc.writeGraph(CO, { nodes: DEV_NODES, edges: DEV_EDGES }); svc.writeGraph(CO, { nodes: DEV_NODES, edges: DEV_EDGES }); const g = svc.getGraph(CO); expect(g.nodes).toHaveLength(3); expect(g.edges).toHaveLength(3); }); it('写第二张图会把旧图整体置 removed,getGraph 只返回新图', () => { svc.writeGraph(CO, { nodes: DEV_NODES, edges: DEV_EDGES }); const oldIds = svc.getGraph(CO).nodes.map((n) => n.id); const res = svc.writeGraph(CO, { nodes: ['content-planner', 'content-writer'], edges: [{ from: 'content-planner', to: 'content-writer', condition: 'success' }], }); expect(res.ok).toBe(true); const g = svc.getGraph(CO); expect(g.nodes.map((n) => n.roleName)).toEqual(['content-planner', 'content-writer']); expect(g.edges).toHaveLength(1); // 旧节点还在库里,只是不再 active(软删除保审计轨迹) const db = graphDb.getDb(); for (const id of oldIds) { expect(db.prepare('SELECT status FROM org_graph_nodes WHERE id = ?').get(id).status).toBe('removed'); } expect( db.prepare(`SELECT COUNT(*) c FROM org_graph_edges WHERE company_id = ? AND status = 'active'`).get(CO).c ).toBe(1); }); it('不同公司的图互不影响', () => { svc.writeGraph(CO, { nodes: DEV_NODES, edges: DEV_EDGES }); svc.writeGraph('other-co', { nodes: ['ops-runner'], edges: [] }); expect(svc.getGraph(CO).nodes).toHaveLength(3); expect(svc.getGraph('other-co').nodes.map((n) => n.roleName)).toEqual(['ops-runner']); }); it('没有图的公司返回空图而不是抛错', () => { expect(svc.getGraph('never-created')).toEqual({ nodes: [], edges: [] }); }); }); // ────────────────────────────────────────────────────────────── // removeNode(fire 用,级联失效进出边) // ────────────────────────────────────────────────────────────── describe('removeNode', () => { // planner → coder → validator → shipper,外加 validator --fail--> coder const FOUR_NODES = ['code-planner', 'code-coder', 'code-validator', 'code-shipper']; const FOUR_EDGES = [ { from: 'code-planner', to: 'code-coder', condition: 'success' }, { from: 'code-coder', to: 'code-validator', condition: 'success' }, { from: 'code-validator', to: 'code-shipper', condition: 'success' }, { from: 'code-validator', to: 'code-coder', condition: 'fail', edgeKind: 'backward' }, ]; it('删中间节点后,以它为 from 和为 to 的边都不再出现在 getGraph', () => { expect(svc.writeGraph(CO, { nodes: FOUR_NODES, edges: FOUR_EDGES }).ok).toBe(true); const res = svc.removeNode(CO, 'code-coder'); expect(res.ok).toBe(true); expect(res.removedEdges).toBe(3); // planner→coder, coder→validator, validator→coder const g = svc.getGraph(CO); expect(g.nodes.map((n) => n.roleName)).toEqual(['code-planner', 'code-validator', 'code-shipper']); const nameById = new Map(g.nodes.map((n) => [n.id, n.roleName])); // 幸存的只有 validator → shipper,且没有任何一端指向被删的节点 expect(g.edges.map((e) => `${nameById.get(e.from)}->${nameById.get(e.to)}`)).toEqual([ 'code-validator->code-shipper', ]); expect(g.edges.some((e) => !nameById.has(e.from) || !nameById.has(e.to))).toBe(false); }); it('删完的图即使不再合法也照删(fire 是既成事实)', () => { svc.writeGraph(CO, { nodes: FOUR_NODES, edges: FOUR_EDGES }); expect(svc.removeNode(CO, 'code-coder').ok).toBe(true); // 现在是三入口的非法图,但仍然被接受 expect(svc.getGraph(CO).nodes).toHaveLength(3); }); it('删不存在的节点返回 ok:false 而不是抛错', () => { svc.writeGraph(CO, { nodes: DEV_NODES, edges: DEV_EDGES }); const res = svc.removeNode(CO, 'code-ghost'); expect(res.ok).toBe(false); expect(res.errors[0].rule).toBe('reference'); expect(svc.getGraph(CO).nodes).toHaveLength(3); }); it('被删的角色可以重新写回来(部分唯一索引只约束 active 行)', () => { svc.writeGraph(CO, { nodes: DEV_NODES, edges: DEV_EDGES }); svc.removeNode(CO, 'code-coder'); expect(svc.writeGraph(CO, { nodes: DEV_NODES, edges: DEV_EDGES }).ok).toBe(true); expect(svc.getGraph(CO).nodes.map((n) => n.roleName)).toEqual(DEV_NODES); }); }); // ────────────────────────────────────────────────────────────── // patchGraph // ────────────────────────────────────────────────────────────── describe('patchGraph', () => { const snapshot = (companyId) => JSON.stringify(svc.getGraph(companyId)); beforeEach(() => { expect(svc.writeGraph(CO, { nodes: DEV_NODES, edges: DEV_EDGES }).ok).toBe(true); }); it('合法的增量修改能落库(追加一个末端节点 + 一条边)', () => { const res = svc.patchGraph(CO, { addNodes: [{ roleName: 'code-shipper' }], addEdges: [{ from: 'code-validator', to: 'code-shipper', condition: 'success' }], }); expect(res.ok).toBe(true); const g = svc.getGraph(CO); expect(g.nodes.map((n) => n.roleName)).toContain('code-shipper'); expect(g.edges).toHaveLength(4); }); it('破坏合法性的 patch 整体回滚:加一个孤立节点后图还是修改前的样子', () => { const before = snapshot(CO); const res = svc.patchGraph(CO, { addNodes: [{ roleName: 'code-orphan' }] }); expect(res.ok).toBe(false); expect(res.errors.map((e) => e.rule)).toEqual( expect.arrayContaining(['single-entry', 'reachable']) ); expect(snapshot(CO)).toBe(before); }); it('删掉入口节点导致多入口时同样回滚', () => { const before = snapshot(CO); const res = svc.patchGraph(CO, { removeNodes: ['code-coder'] }); expect(res.ok).toBe(false); expect(res.errors.map((e) => e.rule)).toContain('single-entry'); expect(snapshot(CO)).toBe(before); }); it('加一条把回退边标成 forward 的边会回滚', () => { const before = snapshot(CO); const res = svc.patchGraph(CO, { addEdges: [{ from: 'code-validator', to: 'code-planner', condition: 'fail' }], }); expect(res.ok).toBe(false); expect(res.errors.map((e) => e.rule)).toContain('backward-labeled'); expect(snapshot(CO)).toBe(before); }); it('边引用不存在的 active 节点时回滚并报 reference', () => { const before = snapshot(CO); const res = svc.patchGraph(CO, { addEdges: [{ from: 'code-validator', to: 'code-ghost', condition: 'success' }], }); expect(res.ok).toBe(false); expect(res.errors.map((e) => e.rule)).toContain('reference'); expect(snapshot(CO)).toBe(before); }); it('删边后图仍合法则删边生效', () => { const res = svc.patchGraph(CO, { removeEdges: [{ from: 'code-validator', to: 'code-coder' }], }); expect(res.ok).toBe(true); expect(svc.getGraph(CO).edges).toHaveLength(2); }); }); });