/** * 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'); }); });