graph-run-route-io.test.js 6.18 KB
/**
 * Unit Tests — GET /api/graph-runs/:id 的 R-19 输入输出三字段(27-P4 §4.6)
 *
 * 控制台的「输入输出」tab 读三个字段:`edges[].contracts` / `companyRoot` / `taskFiles`。
 * 缺任何一个它整块降级 —— 契约声明和产物索引都在库里,界面上却看不见,
 * 就是这一路踩了十几次的「实现在、零调用方」换个方向再来一次(这次是前端在、后端没喂)。
 *
 * 走真入口:把 router 挂到真 express 上 listen(0) 再 fetch(与 devloop/routes.test.js 同法),
 * 不导出内部函数、不打桩 res.json。
 *
 * 顺带钉住两条形状约定:
 *   - `contracts` 只在非空时出现(与 failReason 一致,存量边不该凭空多字段)
 *   - `companyRoot` 是绝对路径,且与 recordFile 存的 rel_path 拼起来就是真实文件
 *     —— 「拼得回去」才是这个字段唯一的用处,光断言它非空等于没测
 */
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import fs from 'fs';
import os from 'os';
import path from 'path';
import express from 'express';

const TEST_DB_PATH = '/tmp/test-graph-run-route-io.sqlite';
const DB_FILES = [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`];
const MODULES = [
  '../../services/graph-db',
  '../../services/graph-service',
  '../../services/org-graph-service',
  '../../services/work-graph-db',
  '../../services/company-paths',
  '../../services/graph-run-sse',
  '../../services/graph-engine',
  '../../routes/graph-run',
];

describe('GET /api/graph-runs/:id — R-19 输入输出三字段', () => {
  let wdb;
  let srv;
  let base;
  let runsDir;
  let runId;
  let companyId = 'co-route-io';
  const taskId = 'T-route-io';

  beforeAll(async () => {
    for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch {} }
    process.env.GRAPH_DB_PATH = TEST_DB_PATH;
    runsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'route-io-runs-'));
    process.env.GRAPH_RUNS_DIR = runsDir;
    for (const m of MODULES) delete require.cache[require.resolve(m)];
    wdb = require('../../services/work-graph-db');

    const db = wdb.getDb();
    const ts = new Date().toISOString();
    const ins = db.prepare(`
      INSERT OR IGNORE INTO system_agents
        (id, name, scope, owner_company_id, legacy_path, status, created_at, updated_at)
      VALUES (?, ?, 'public', NULL, ?, 'active', ?, ?)
    `);
    for (const id of ['brd-planning', 'prd-planning']) ins.run(id, id, `system/agents/${id}`, ts, ts);

    const g = wdb.createWorkGraph({
      companyId,
      taskId,
      nodes: [
        { nodeName: 'p-brd', systemAgentId: 'brd-planning' },
        { nodeName: 'p-prd', systemAgentId: 'prd-planning' },
        // 第三个节点只为了造一条**没有任何声明**的边:全是有声明的边时,
        // 「无声明的边上不出现 contracts 字段」那条断言在这份 fixture 上永远自洽
        { nodeName: 'p-tail', systemAgentId: 'prd-planning' },
      ],
      edges: [
        { from: 'p-brd', to: 'p-prd', condition: 'success', produces: ['brd'] },
        { from: 'p-prd', to: 'p-tail', condition: 'success' },
      ],
    });
    expect(g.ok).toBe(true);

    const run = wdb.createRun({ graphKind: 'work', graphId: g.id, companyId, taskId });
    runId = run.id;

    // 真的把文件写到公司根下再登记 —— 「拼得回去」这条断言要求文件真实存在
    const { companyRootDir, taskDirRel } = require('../../services/company-paths');
    const relPath = path.join(taskDirRel(taskId), 'brd.md');
    const abs = path.join(companyRootDir(companyId), relPath);
    fs.mkdirSync(path.dirname(abs), { recursive: true });
    fs.writeFileSync(abs, '# brd\n');
    wdb.recordFile({ companyId, taskId, relPath, kind: 'brd' });

    const app = express();
    app.use(express.json());
    app.use('/api/graph-runs', require('../../routes/graph-run'));
    await new Promise((resolve) => {
      srv = app.listen(0, '127.0.0.1', () => {
        base = `http://127.0.0.1:${srv.address().port}`;
        resolve();
      });
    });
  });

  afterAll(async () => {
    if (srv) await new Promise((r) => srv.close(r));
    for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch {} }
    delete process.env.GRAPH_DB_PATH;
    try { fs.rmSync(runsDir, { recursive: true, force: true }); } catch {}
    delete process.env.GRAPH_RUNS_DIR;
    for (const m of MODULES) delete require.cache[require.resolve(m)];
  });

  it('三个字段都在,且 companyRoot + rel_path 拼回真实文件', async () => {
    const r = await fetch(`${base}/api/graph-runs/${runId}`);
    expect(r.status).toBe(200);
    const d = await r.json();

    // ① 边上的契约(哪一层的边查哪一层的契约;work run 查 work 层)
    const declared = d.edges.filter((e) => e.contracts);
    expect(declared).toHaveLength(1);
    expect(declared[0].contracts.map((c) => c.kind)).toEqual(['brd']);
    expect(declared[0].contracts[0].role).toBe('produces');

    // ② 产物索引:本 task 已登记的实例
    expect(d.taskFiles.map((f) => f.kind)).toEqual(['brd']);

    // ③ companyRoot 的唯一用处是让前端把相对路径拼成可点的绝对路径
    expect(path.isAbsolute(d.companyRoot)).toBe(true);
    const abs = path.join(d.companyRoot, d.taskFiles[0].rel_path);
    expect(fs.existsSync(abs)).toBe(true);
    expect(fs.readFileSync(abs, 'utf8')).toContain('# brd');
  });

  it('无声明的边上不出现 contracts 字段(与 failReason 同一个约定)', async () => {
    const r = await fetch(`${base}/api/graph-runs/${runId}`);
    const d = await r.json();

    // 三个节点两条边:一条有声明、一条没有。没有那条必须整个字段不出现,
    // 而不是回一个空数组(存量边绝大多数都是这个形态,恒空字段会让既有消费方凭空多东西)。
    expect(d.edges).toHaveLength(2);
    expect(d.edges.filter((e) => 'contracts' in e)).toHaveLength(1);
    for (const e of d.edges) {
      if ('contracts' in e) expect(e.contracts.length).toBeGreaterThan(0);
    }
  });

  it('run 不存在仍是 404(新字段不该把错误路径也一起改掉)', async () => {
    const r = await fetch(`${base}/api/graph-runs/run_nope`);
    expect(r.status).toBe(404);
  });
});