dream-routes.test.js 4.11 KB
/**
 * dream 的 REST 入口(29-DREAM,见 docs/todo/system-design/29-dream-design.md)。
 *
 * 路由本身没有业务逻辑 —— 全部委托给已经测过的 `dream-service.js`。
 * 这份只验两件路由层独有的东西:
 * ① 六个端点确实接到了对应的 service 函数上(不是「实现在、零调用方」);
 * ② `POST /sweep` 的并发锁 —— 上一轮没跑完时第二次触发要拒(409),
 *   跑完之后 `GET /sweep/status` 要能看到结果。这段状态机全部写在 route 里,
 *   `dream-service.test.js` 覆盖不到。
 *
 * `dream-service` 的六个被测函数在这里整体替身(monkeypatch 掉导出对象上的属性,
 * 用完在 afterAll 里还原)—— 不走真文件系统,也不会误扫真仓库里的 76 个本体。
 */
import { describe, it, expect, beforeAll, afterAll } from 'vitest';

const express = require('express');
const dream = require('../../services/dream-service');
const dreamRouter = require('../../routes/dream');

const AGENT = 'zz-route-agent';
const orig = {};
function patch(name, fn) { orig[name] = dream[name]; dream[name] = fn; }
function restore(name) { dream[name] = orig[name]; }

let server;
let base;
let resolveSweep;

beforeAll(() => {
  patch('listDreamableAgents', () => [AGENT]);
  patch('readState', () => ({ lastRunAt: '2026-08-20T00:00:00.000Z', lastAccepted: 2, lastRejected: 1 }));
  patch('companiesOf', () => ['zz-route-co']);
  patch('definitionWritability', () => [{ file: 'agent.md', allowed: true, reason: '' }]);
  patch('auditDistilled', () => [{ title: 'X', sources: [{ source: 'zz-route-co:memory.md#1', text: '原文' }] }]);
  patch('dreamOnce', async (agentId, opts) => ({ agentId, skipped: null, dryRun: !!(opts && opts.dryRun), accepted: 1 }));
  patch('dreamSweep', () => new Promise((resolve) => { resolveSweep = resolve; }));

  const app = express();
  app.use(express.json());
  app.use('/api/dream', dreamRouter);
  server = app.listen(0);
  base = `http://127.0.0.1:${server.address().port}/api/dream`;
});

afterAll(() => {
  ['listDreamableAgents', 'readState', 'companiesOf', 'definitionWritability', 'auditDistilled', 'dreamOnce', 'dreamSweep']
    .forEach(restore);
  server.close();
});

describe('dream REST 入口', () => {
  it('GET /agents 用 listDreamableAgents + readState + companiesOf 拼总览', async () => {
    const res = await fetch(`${base}/agents`);
    expect(res.status).toBe(200);
    const body = await res.json();
    expect(body.agents).toEqual([
      { agentId: AGENT, companies: 1, lastRunAt: '2026-08-20T00:00:00.000Z', lastAccepted: 2, lastRejected: 1 },
    ]);
  });

  it('GET /agents/:id 把状态、定义文件可写性、经验审计一起交出去', async () => {
    const res = await fetch(`${base}/agents/${AGENT}`);
    const body = await res.json();
    expect(body.agentId).toBe(AGENT);
    expect(body.companies).toEqual(['zz-route-co']);
    expect(body.definitions[0].file).toBe('agent.md');
    expect(body.experience[0].sources[0].text).toBe('原文');
  });

  it('POST /agents/:id/run 把 body.dryRun 原样传给 dreamOnce', async () => {
    const res = await fetch(`${base}/agents/${AGENT}/run`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ dryRun: true }),
    });
    const body = await res.json();
    expect(body.dryRun).toBe(true);
  });

  it('sweep 并发锁:跑着的时候第二次触发要 409,跑完 status 要能看到结果', async () => {
    const start = await fetch(`${base}/sweep`, { method: 'POST' });
    expect(start.status).toBe(202);

    const mid = await fetch(`${base}/sweep/status`);
    expect((await mid.json()).sweeping).toBe(true);

    const blocked = await fetch(`${base}/sweep`, { method: 'POST' });
    expect(blocked.status).toBe(409);

    resolveSweep([{ agentId: AGENT, accepted: 3 }]);
    await new Promise((r) => setTimeout(r, 20)); // 让 .then() 回调跑完

    const done = await fetch(`${base}/sweep/status`);
    const doneBody = await done.json();
    expect(doneBody.sweeping).toBe(false);
    expect(doneBody.results).toEqual([{ agentId: AGENT, accepted: 3 }]);
  });
});