/** * Integration Test — company-service.hire() P1 引用模式 * * 覆盖 docs/todo/system-design/27-p1-virtual-reference.md §6 验收标准 1/3/5: * 1. system_agents 里有 source_kind='template' 行的角色(qa-judge) → 引用模式: * 私有目录没有 agent.md,有 company-context.md(含公司 ID),config.json 有 * systemAgentRef,org_graph_nodes 新增一行。 * 2. 不在 system_agents 里的角色 → 完全 legacy 行为(含 agent.md 被拷贝)。 * 3. 幂等性:同一 (companyId, role) 调用 hire() 两次,第二次 already_exists, * org_graph_nodes 不重复。 * * 用 GRAPH_DB_PATH 覆盖到临时 SQLite 文件(同 graph-service.test.js / graph-db.test.js * 的模式),永不触碰真实 data/graph.sqlite;用临时公司 id,测试完清理 data/companies/ * 下对应目录,不污染真实数据。 */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import fs from 'fs'; import path from 'path'; describe('company-service.hire() — P1 virtual reference mode', () => { const TEST_DB_PATH = '/tmp/test-graph-agent-p1-hire.sqlite'; const REPO_ROOT = path.resolve(__dirname, '../../..'); const DATA_DIR = path.join(REPO_ROOT, 'data/companies'); const TEMPLATES_DIR = path.join(REPO_ROOT, 'system/templates'); const TEST_COMPANY_ID = `tmp-p1-hire-test-co-${Date.now()}`; const LEGACY_TEMPLATE_ROLE = `tmp-p1-legacy-role-${Date.now()}`; const legacyTemplateDir = path.join(TEMPLATES_DIR, LEGACY_TEMPLATE_ROLE); let graphDb; let CompanyService; let companyService; 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/company-service')]; graphDb = require('../../services/graph-db'); ({ CompanyService } = require('../../services/company-service')); companyService = new CompanyService(); // Seed a 'template' row for qa-judge pointing at the real template dir (already exists // in the repo with a real agent.md) — this is exactly what npm run graph:seed produced // in commit 8421f55, just re-created here against the isolated test DB. 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 (?, ?, 'public', NULL, 'template', ?, 'active', NULL, ?, ?) `).run('qa-judge', 'qa-judge', 'system/templates/qa-judge', now, now); // Fixture template dir for the "role not in system_agents" legacy-fallback case. // Deliberately NOT inserted into system_agents, so lookupSharedAgentMd() returns null // and hire() must fall back to the full legacy copy (including agent.md). fs.mkdirSync(legacyTemplateDir, { recursive: true }); fs.writeFileSync(path.join(legacyTemplateDir, 'agent.md'), '# legacy fixture agent\n', 'utf-8'); fs.writeFileSync(path.join(legacyTemplateDir, 'config.json'), JSON.stringify({ type: 'skill', name: LEGACY_TEMPLATE_ROLE }), 'utf-8'); // Company fixture (CompanyService.hire() requires an existing company). companyService.create({ id: TEST_COMPANY_ID, name: 'P1 Hire Test Co', description: 'test fixture' }); }); afterAll(() => { try { fs.rmSync(path.join(DATA_DIR, TEST_COMPANY_ID), { recursive: true, force: true }); } catch {} try { fs.rmSync(legacyTemplateDir, { recursive: true, force: true }); } catch {} 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('reference mode: hiring a system_agents template role does not copy agent.md, writes company-context.md, sets systemAgentRef, records org_graph_nodes', () => { const result = companyService.hire(TEST_COMPANY_ID, 'qa-judge', {}); expect(result.status).toBe('hired_from_template'); const agentDir = result.agentDir; expect(fs.existsSync(path.join(agentDir, 'agent.md'))).toBe(false); const contextPath = path.join(agentDir, 'company-context.md'); expect(fs.existsSync(contextPath)).toBe(true); const contextContent = fs.readFileSync(contextPath, 'utf-8'); expect(contextContent).toContain(TEST_COMPANY_ID); expect(contextContent).toContain('## 所属公司'); const config = JSON.parse(fs.readFileSync(path.join(agentDir, 'config.json'), 'utf-8')); expect(config.systemAgentRef).toBe('qa-judge'); // Non-shared files must still be provisioned privately (workDir constraint, §3.3 of the design doc). expect(fs.existsSync(path.join(agentDir, 'soul.md'))).toBe(true); expect(fs.existsSync(path.join(agentDir, 'identity.md'))).toBe(true); expect(fs.existsSync(path.join(agentDir, 'memory/memory.md'))).toBe(true); const db = graphDb.getDb(); const node = db.prepare('SELECT * FROM org_graph_nodes WHERE company_id = ? AND role_name = ?').get(TEST_COMPANY_ID, 'qa-judge'); expect(node).toBeTruthy(); expect(node.system_agent_id).toBe('qa-judge'); }); it('legacy fallback: hiring a role absent from system_agents copies agent.md and writes no company-context.md / systemAgentRef', () => { const result = companyService.hire(TEST_COMPANY_ID, LEGACY_TEMPLATE_ROLE, {}); expect(result.status).toBe('hired_from_template'); const agentDir = result.agentDir; expect(fs.existsSync(path.join(agentDir, 'agent.md'))).toBe(true); const agentMdContent = fs.readFileSync(path.join(agentDir, 'agent.md'), 'utf-8'); expect(agentMdContent).toContain('legacy fixture agent'); expect(agentMdContent).toContain('## 所属公司'); // still appended in-place, legacy behavior expect(fs.existsSync(path.join(agentDir, 'company-context.md'))).toBe(false); const config = JSON.parse(fs.readFileSync(path.join(agentDir, 'config.json'), 'utf-8')); expect(config.systemAgentRef).toBeUndefined(); // Still gets an org_graph_nodes audit row even though it went through the legacy path. const db = graphDb.getDb(); const node = db.prepare('SELECT * FROM org_graph_nodes WHERE company_id = ? AND role_name = ?').get(TEST_COMPANY_ID, LEGACY_TEMPLATE_ROLE); expect(node).toBeTruthy(); }); it('idempotency: hiring the same (companyId, role) twice returns already_exists the second time and does not duplicate org_graph_nodes', () => { const role = 'qa-judge'; // already hired by the first test above const second = companyService.hire(TEST_COMPANY_ID, role, {}); expect(second.status).toBe('already_exists'); const db = graphDb.getDb(); const count = db.prepare('SELECT COUNT(*) AS c FROM org_graph_nodes WHERE company_id = ? AND role_name = ?').get(TEST_COMPANY_ID, role).c; expect(count).toBe(1); }); });