org-graph-service.test.js 15.2 KB
/**
 * 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);
    });
  });
});