graph-seed-service.test.js 13.8 KB
/**
 * Unit Tests — graph-seed-service.js
 *
 * 两块:
 *
 * A. **hermetic 夹具根**(P3B W1 起):`GRAPH_SEED_ROOT` 指向 /tmp 下现造的
 *    system/agents + system/templates + data/companies,配一个一次性 GRAPH_DB_PATH。
 *    行数因此是**写死的精确值**而不是「跟着仓库现状走」——原来的写法直接对着生产
 *    `data/graph.sqlite` 与真实 `data/companies/` 断言,任何人手建一家公司都会让它变红。
 *    悬空行回收 / 模板接管 / 防误伤三条规则都在这里锁。
 *
 * B. **真实仓库根回归**(保留原有两条用例,断言未放松):seed 一遍真实
 *    `system/` + `data/companies/` 到一次性 DB,verifyCounts() 必须 ok,
 *    且往 DB 里塞一行 fs 不背书的数据时必须被抓出来。
 */
import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll } from 'vitest';
import fs from 'fs';
import path from 'path';
import os from 'os';

/** 换掉 GRAPH_SEED_ROOT / GRAPH_DB_PATH 之后必须重新 require,两个模块都在顶层读了常量 */
function reloadServices() {
  delete require.cache[require.resolve('../../services/graph-db')];
  delete require.cache[require.resolve('../../services/graph-seed-service')];
  return {
    graphDb: require('../../services/graph-db'),
    seedService: require('../../services/graph-seed-service'),
  };
}

function write(file, content) {
  fs.mkdirSync(path.dirname(file), { recursive: true });
  fs.writeFileSync(file, content);
}

describe('graph-seed-service (hermetic fixture root)', () => {
  let root;
  let dbPath;
  let seedService;
  let graphDb;

  /** 夹具:1 个 global agent、3 个模板目录(其中 alpha 与 global agent 同名)、1 家活着的公司 */
  function buildFixture() {
    root = fs.mkdtempSync(path.join(os.tmpdir(), 'graph-seed-fixture-'));
    dbPath = path.join(root, 'graph.sqlite');

    write(path.join(root, 'system/agents/alpha/agent.md'), '# alpha global');
    write(path.join(root, 'system/agents/alpha/config.json'), JSON.stringify({ name: 'Alpha Global' }));
    fs.mkdirSync(path.join(root, 'system/agents/no-agent-md'), { recursive: true }); // 无 agent.md → 不算 global

    write(path.join(root, 'system/templates/_registry.json'), JSON.stringify([{ id: 'tmpl-a', benchmarkScore: 7.5 }]));
    write(path.join(root, 'system/templates/tmpl-a/agent.md'), '# tmpl-a');
    write(path.join(root, 'system/templates/tmpl-a/config.json'), JSON.stringify({ name: 'Template A' }));
    write(path.join(root, 'system/templates/tmpl-b/agent.md'), '# tmpl-b'); // 无 config.json
    write(path.join(root, 'system/templates/alpha/agent.md'), '# alpha template'); // 与 global agent 撞 id

    write(
      path.join(root, 'data/companies/live-co/company.json'),
      JSON.stringify({ id: 'live-co', agents: ['tmpl-a', 'private-role'] })
    );
    write(path.join(root, 'data/companies/live-co/agents/private-role/agent.md'), '# private');

    process.env.GRAPH_SEED_ROOT = root;
    process.env.GRAPH_DB_PATH = dbPath;
    ({ graphDb, seedService } = reloadServices());
  }

  function rows() {
    return graphDb.getDb().prepare('SELECT id, source_kind, scope, owner_company_id, legacy_path FROM system_agents ORDER BY id').all();
  }
  function rowOf(id) {
    return rows().find(r => r.id === id) || null;
  }
  function orgNodes() {
    return graphDb.getDb().prepare('SELECT company_id, system_agent_id, role_name FROM org_graph_nodes ORDER BY company_id, role_name').all();
  }

  /** 直接造一行 company_hired(模拟历史遗留 / hire() 写下的行) */
  function insertHired(id, ownerCompanyId, legacyPath) {
    const now = new Date().toISOString();
    graphDb.getDb().prepare(`
      INSERT INTO system_agents (id, name, scope, owner_company_id, source_kind, legacy_path, status, benchmark_score, created_at, updated_at)
      VALUES (?, ?, 'company', ?, 'company_hired', ?, 'active', NULL, ?, ?)
    `).run(id, id, ownerCompanyId, legacyPath, now, now);
  }

  beforeEach(() => buildFixture());

  afterEach(() => {
    delete process.env.GRAPH_SEED_ROOT;
    delete process.env.GRAPH_DB_PATH;
    try { fs.rmSync(root, { recursive: true, force: true }); } catch {}
  });

  it('seeds exact row counts from the fixture and passes verifyCounts', () => {
    seedService.seedGlobalAgents();
    seedService.seedTemplates();
    seedService.seedCompanyAgents();

    expect(rows().map(r => [r.id, r.source_kind])).toEqual([
      ['alpha', 'global_agent'],       // system/agents/alpha 有 agent.md
      ['private-role', 'company_hired'], // live-co 独有角色
      ['tmpl-a', 'template'],
      ['tmpl-b', 'template'],
    ]);
    // system/templates/alpha 与 global agent 撞 id:仍然被跳过,绝不被模板覆盖
    expect(rowOf('alpha').legacy_path).toBe('system/agents/alpha');

    expect(orgNodes()).toEqual([
      { company_id: 'live-co', system_agent_id: 'private-role', role_name: 'private-role' },
      { company_id: 'live-co', system_agent_id: 'tmpl-a', role_name: 'tmpl-a' },
    ]);

    const v = seedService.verifyCounts();
    expect(v.mismatches).toEqual([]);
    expect(v.ok).toBe(true);
    expect(v.expected).toMatchObject({ global_agent: 1, template: 2, company_hired: 1 });
  });

  it('picks up a template directory added after the first seed (W1: 新模板目录自动进表)', () => {
    seedService.runStartupSeed();
    expect(rowOf('tmpl-new')).toBeNull();

    write(path.join(root, 'system/templates/tmpl-new/agent.md'), '# brand new');
    write(path.join(root, 'system/templates/tmpl-new/config.json'), JSON.stringify({ name: 'Brand New' }));

    seedService.runStartupSeed();

    expect(rowOf('tmpl-new')).toEqual({
      id: 'tmpl-new',
      source_kind: 'template',
      scope: 'public',
      owner_company_id: null,
      legacy_path: 'system/templates/tmpl-new',
    });
  });

  it('reclaims a dangling company_hired row that squats on a template id', () => {
    seedService.seedGlobalAgents();
    // 历史遗留:公司 ghost-co 已被删,行还占着 tmpl-b 这个模板 id
    insertHired('tmpl-b', 'ghost-co', 'data/companies/ghost-co/agents/tmpl-b');
    expect(rowOf('tmpl-b').source_kind).toBe('company_hired');

    const summary = seedService.runStartupSeed();

    // reconcile 先删掉它(没有任何外键引着),seedTemplates 再按目录重新种回来
    expect(summary.reconciled.agentsRemoved).toContain('tmpl-b');
    expect(rowOf('tmpl-b')).toEqual({
      id: 'tmpl-b',
      source_kind: 'template',
      scope: 'public',
      owner_company_id: null,
      legacy_path: 'system/templates/tmpl-b',
    });
  });

  it('takes over a squatting row in-place when a foreign key still points at it', () => {
    seedService.seedGlobalAgents();
    insertHired('tmpl-b', 'ghost-co', 'data/companies/ghost-co/agents/tmpl-b');
    // 还活着的公司 live-co 有一个 org 节点引着这行 → reconcile 删不掉(外键)
    graphDb.getDb().prepare(`
      INSERT INTO org_graph_nodes (company_id, system_agent_id, role_name, created_at)
      VALUES ('live-co', 'tmpl-b', 'tmpl-b', ?)
    `).run(new Date().toISOString());

    const summary = seedService.runStartupSeed();

    expect(summary.reconciled.agentsRemoved).not.toContain('tmpl-b');
    expect(summary.reconciled.retained.map(r => r.id)).toContain('tmpl-b');
    // 删不掉就地转正:同一行 id 保留(外键继续有效),source_kind / scope / legacy_path 全部改写
    expect(summary.templates.reclaimed).toContain('tmpl-b');
    expect(rowOf('tmpl-b')).toEqual({
      id: 'tmpl-b',
      source_kind: 'template',
      scope: 'public',
      owner_company_id: null,
      legacy_path: 'system/templates/tmpl-b',
    });
    // 引着它的节点必须还在(转正而不是删了重建的全部意义所在)
    expect(orgNodes().some(n => n.system_agent_id === 'tmpl-b')).toBe(true);
  });

  it('never reclaims rows a live company still backs (防误伤)', () => {
    seedService.seedGlobalAgents();
    seedService.seedTemplates();
    seedService.seedCompanyAgents();

    // ① 目录真实存在的公司私有 agent
    expect(rowOf('private-role').source_kind).toBe('company_hired');
    // ② 目录没了、但 live-co 的 company.json 仍在 agents[] 里点名它
    insertHired('tmpl-a-clone', 'live-co', 'data/companies/live-co/agents/tmpl-a-clone');
    write(
      path.join(root, 'data/companies/live-co/company.json'),
      JSON.stringify({ id: 'live-co', agents: ['tmpl-a', 'private-role', 'tmpl-a-clone'] })
    );

    const r = seedService.reconcileDanglingRows();

    expect(r.agentsRemoved).toEqual([]);
    expect(rowOf('private-role')).not.toBeNull();
    expect(rowOf('tmpl-a-clone')).not.toBeNull();
    expect(rowOf('tmpl-a-clone').source_kind).toBe('company_hired');
  });

  it('never reclaims a global_agent row even when a same-named template dir exists', () => {
    const summary = seedService.runStartupSeed();

    expect(summary.templates.skipped).toEqual([{ id: 'alpha', existingSourceKind: 'global_agent' }]);
    expect(summary.templates.reclaimed).toEqual([]);
    expect(rowOf('alpha')).toMatchObject({ source_kind: 'global_agent', legacy_path: 'system/agents/alpha' });
  });

  it('drops org nodes of deleted companies but keeps live ones, and is idempotent', () => {
    seedService.seedGlobalAgents();
    seedService.seedTemplates();
    seedService.seedCompanyAgents();

    insertHired('ghost-role', 'ghost-co', 'data/companies/ghost-co/agents/ghost-role');
    const now = new Date().toISOString();
    graphDb.getDb().prepare(`
      INSERT INTO org_graph_nodes (company_id, system_agent_id, role_name, created_at)
      VALUES ('ghost-co', 'ghost-role', 'ghost-role', ?)
    `).run(now);

    const first = seedService.reconcileDanglingRows();
    expect(first.ghostCompanyIds).toEqual(['ghost-co']);
    expect(first.orgNodesRemoved).toBe(1);
    expect(first.agentsRemoved).toEqual(['ghost-role']);
    expect(first.retained).toEqual([]);

    // live-co 的两个节点一个没少
    expect(orgNodes()).toEqual([
      { company_id: 'live-co', system_agent_id: 'private-role', role_name: 'private-role' },
      { company_id: 'live-co', system_agent_id: 'tmpl-a', role_name: 'tmpl-a' },
    ]);

    const second = seedService.reconcileDanglingRows();
    expect(second).toMatchObject({ orgNodesRemoved: 0, orgEdgesRemoved: 0, ghostCompanyIds: [], agentsRemoved: [], retained: [] });
  });

  it('skips org-node reconcile entirely when data/companies is absent (安全阀)', () => {
    seedService.seedGlobalAgents();
    seedService.seedTemplates();
    seedService.seedCompanyAgents();
    const before = orgNodes();

    fs.rmSync(path.join(root, 'data/companies'), { recursive: true, force: true });

    const r = seedService.reconcileDanglingRows();
    expect(r.ghostCompanyIds).toEqual([]);
    expect(r.orgNodesRemoved).toBe(0);
    expect(orgNodes()).toEqual(before);
  });

  it('runStartupSeed leaves company-derived rows alone (it must not run seedCompanyAgents)', () => {
    seedService.runStartupSeed();

    // company.json 里的 private-role 没有被启动流程带进表,org 图也一行没写——
    // 那是 hire()/fire() 与 scripts/graph-seed.js 的地盘
    expect(rowOf('private-role')).toBeNull();
    expect(orgNodes()).toEqual([]);
  });

  it('runStartupSeedSafely swallows failures and returns null', () => {
    process.env.GRAPH_DB_PATH = path.join(root, 'no-such-dir/nested/graph.sqlite');
    const { seedService: broken } = reloadServices();
    expect(broken.runStartupSeedSafely()).toBeNull();
  });
});

describe('graph-seed-service verifyCounts (real repo root)', () => {
  let seedService;
  let graphDb;
  const TEST_DB_PATH = '/tmp/test-graph-seed-service.sqlite';

  beforeAll(() => {
    if (fs.existsSync(TEST_DB_PATH)) fs.unlinkSync(TEST_DB_PATH);
    delete process.env.GRAPH_SEED_ROOT; // 真实仓库根
    process.env.GRAPH_DB_PATH = TEST_DB_PATH;
    ({ graphDb, seedService } = reloadServices());
  });

  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;
  });

  it('reports ok:true against a freshly-seeded DB (no drift)', () => {
    seedService.seedGlobalAgents();
    seedService.seedTemplates();
    seedService.seedCompanyAgents();

    const v = seedService.verifyCounts();
    expect(v.mismatches).toEqual([]);
    expect(v.ok).toBe(true);
  });

  it('every system/templates/* dir that is not a global_agent id lands as source_kind=template', () => {
    const db = graphDb.getDb();
    const globalIds = new Set(
      db.prepare(`SELECT id FROM system_agents WHERE source_kind = 'global_agent'`).all().map(r => r.id)
    );
    const templateDirs = fs
      .readdirSync(path.resolve(__dirname, '../../../system/templates'), { withFileTypes: true })
      .filter(d => d.isDirectory())
      .map(d => d.name)
      .filter(id => !globalIds.has(id));

    const seededTemplates = new Set(
      db.prepare(`SELECT id FROM system_agents WHERE source_kind = 'template'`).all().map(r => r.id)
    );
    expect(templateDirs.filter(id => !seededTemplates.has(id))).toEqual([]);
  });

  it('catches a mismatch when the DB has a row the filesystem does not back', () => {
    const db = graphDb.getDb();
    const now = new Date().toISOString();
    db.prepare(`
      INSERT INTO system_agents (id, name, scope, owner_company_id, source_kind, legacy_path, status, benchmark_score, created_at, updated_at)
      VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
    `).run('bogus-agent-no-fs-backing', 'Bogus', 'public', null, 'global_agent', 'system/agents/bogus-agent-no-fs-backing', 'active', null, now, now);

    const v = seedService.verifyCounts();
    expect(v.ok).toBe(false);
    expect(v.mismatches.some(m => m.check.includes("source_kind='global_agent'"))).toBe(true);

    db.prepare('DELETE FROM system_agents WHERE id = ?').run('bogus-agent-no-fs-backing');
  });
});