routes.test.js 9 KB
/**
 * routes/devloop.js 集成测试
 *
 * 不启动完整 server,只挂 devloop router 到独立 express app + fetch。
 */

process.env.LEMONBOSS_CLAUDE_MODE = 'mock';

const fs = require('fs');
const path = require('path');
const os = require('os');
const express = require('express');

const TEST_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'devloop-rt-'));
process.env.HOME = TEST_HOME;

const pathResolver = require('../../services/devloop/path-resolver');
const devloopRouter = require('../../routes/devloop');
const { ensureTestCompany, removeTestCompany } = require('../helpers/ensure-test-company');

function startApp() {
  const app = express();
  app.use(express.json());
  app.use('/api/devloop', devloopRouter);
  return new Promise((resolve) => {
    const srv = app.listen(0, '127.0.0.1', () => {
      const port = srv.address().port;
      resolve({ srv, base: `http://127.0.0.1:${port}` });
    });
  });
}

describe('routes/devloop', () => {
  let app, base;
  const created = [];

  // 38 号文档 Phase 2:devloop.js 建项目前会校验 company_id 是不是一家真实存在、
  // 当前用户碰得到的公司——这份测试文件里各用例用的 cid 都是硬编码字面量,从没真的建过公司。
  const TEST_CIDS = ['c-route-test', 'c-route-p2', 'c-route-p3', 'c-route-p2u', 'c-p5-routes'];

  beforeAll(async () => {
    pathResolver._resetForTesting();
    for (const cid of TEST_CIDS) ensureTestCompany(cid);
    ({ srv: app, base } = await startApp());
  });

  afterAll(() => {
    app.close();
    for (const c of created) {
      try {
        fs.rmSync(path.join(pathResolver.DATA_DIR, 'companies', c), { recursive: true, force: true });
      } catch { /* ignore */ }
    }
    for (const cid of TEST_CIDS) removeTestCompany(cid);
  });

  it('POST /api/devloop/projects requires company_id', async () => {
    const r = await fetch(`${base}/api/devloop/projects`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ requirement: 'x' }),
    });
    expect(r.status).toBe(400);
    const j = await r.json();
    expect(j.error).toMatch(/company_id/);
  });

  it('POST /api/devloop/projects requires requirement', async () => {
    const r = await fetch(`${base}/api/devloop/projects`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ company_id: 'c-x' }),
    });
    expect(r.status).toBe(400);
    const j = await r.json();
    expect(j.error).toMatch(/requirement/);
  });

  it('POST /api/devloop/projects → creates project and runs PM + Architect', async () => {
    const cid = 'c-route-test';
    created.push(cid);
    const r = await fetch(`${base}/api/devloop/projects`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        company_id: cid,
        name: 'todo-demo',
        requirement: '做一个 TODO 列表:能新增、列表、Vue 前端',
      }),
    });
    expect(r.status).toBe(200);
    const j = await r.json();
    expect(j.ok).toBe(true);
    expect(j.pid).toMatch(/^p-/);
    expect(j.pm.files_written).toBeGreaterThanOrEqual(10);
    expect(j.architect.files_written).toBeGreaterThanOrEqual(10);
    expect(j.pm.commit).toBeTruthy();
    expect(j.architect.commit).toBeTruthy();

    // 验证文件确实落盘
    const root = pathResolver.resolveProjectRoot(j.pid);
    expect(fs.existsSync(path.join(root, 'prd', 'index.html'))).toBe(true);
    expect(fs.existsSync(path.join(root, 'sdd', 'mvcp', 'model.html'))).toBe(true);
    expect(fs.existsSync(path.join(root, 'units.json'))).toBe(true);

    // GET /state
    const sR = await fetch(`${base}/api/devloop/projects/${j.pid}/state`);
    expect(sR.status).toBe(200);
    const s = await sR.json();
    expect(s.pid).toBe(j.pid);
    expect(s.prd_site).toBe(true);
    expect(s.sdd_site).toBe(true);
    expect(s.units).toHaveLength(3);
  });

  it('POST /api/devloop/projects/:pid/run without ?only on unknown pid returns 404 (full-project mode)', async () => {
    const r = await fetch(`${base}/api/devloop/projects/p-anything/run`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({}),
    });
    expect(r.status).toBe(404);
  });

  it('POST /api/devloop/projects/:pid/run?only=todo-be runs L1 loop', async () => {
    const cid = 'c-route-p2';
    created.push(cid);
    // 准备项目
    const r1 = await fetch(`${base}/api/devloop/projects`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ company_id: cid, requirement: '做一个 TODO API' }),
    });
    const j1 = await r1.json();
    const pid = j1.pid;
    // 跑 todo-be
    const r2 = await fetch(`${base}/api/devloop/projects/${pid}/run?only=todo-be`, { method: 'POST' });
    expect(r2.status).toBe(200);
    const j2 = await r2.json();
    expect(j2.ok).toBe(true);
    expect(j2.unit_id).toBe('todo-be');
    expect(j2.attempts).toBe(2);
    expect(j2.final_state).toBe('passed');
    expect(j2.history).toHaveLength(4);
  });

  it('POST /run (full project, no ?only) drives runProject', async () => {
    const cid = 'c-route-p3';
    created.push(cid);
    const r1 = await fetch(`${base}/api/devloop/projects`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ company_id: cid, requirement: 'TODO demo' }),
    });
    const pid = (await r1.json()).pid;
    // ship via full project run (baseline fixtures = happy path)
    const r2 = await fetch(`${base}/api/devloop/projects/${pid}/run`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({}),
    });
    expect(r2.status).toBe(200);
    const j = await r2.json();
    expect(['shipped', 'l2_blocked', 'l2_exhausted']).toContain(j.final_state);
    expect(typeof j.l2_rounds).toBe('number');
  });

  it('GET /api/devloop/projects/:pid/bugs returns 404 on unknown pid', async () => {
    const r = await fetch(`${base}/api/devloop/projects/p-nope/bugs`);
    expect(r.status).toBe(404);
  });

  it('POST /run?only on unknown unit returns 404', async () => {
    const cid = 'c-route-p2u';
    created.push(cid);
    const r1 = await fetch(`${base}/api/devloop/projects`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ company_id: cid, requirement: 'x' }),
    });
    const pid = (await r1.json()).pid;
    const r2 = await fetch(`${base}/api/devloop/projects/${pid}/run?only=no-such-unit`, { method: 'POST' });
    expect(r2.status).toBe(404);
  });

  it('GET /api/devloop/projects/:pid/state on unknown pid returns 404', async () => {
    const r = await fetch(`${base}/api/devloop/projects/p-doesnotexist/state`);
    expect(r.status).toBe(404);
    // 38 号文档 Phase 2:router.param('pid', ...) 现在抢在具体路由前面判"项目存不存在",
    // 消息文案统一成"项目 X 不存在"(跟 company/run 那两处同款措辞),不再是各路由各写一套。
    const j = await r.json();
    expect(j.error).toMatch(/不存在/);
  });

  it('GET /api/devloop/projects lists projects', async () => {
    const r = await fetch(`${base}/api/devloop/projects`);
    expect(r.status).toBe(200);
    const j = await r.json();
    expect(Array.isArray(j.projects)).toBe(true);
  });

  // P5 — UI-supporting endpoints
  describe('P5 UI endpoints', () => {
    let pid;
    beforeAll(async () => {
      const cid = 'c-p5-routes';
      created.push(cid);
      const r = await fetch(`${base}/api/devloop/projects`, {
        method: 'POST', headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ company_id: cid, requirement: 'p5 test' }),
      });
      pid = (await r.json()).pid;
    });

    it('GET /timeline returns events array', async () => {
      const r = await fetch(`${base}/api/devloop/projects/${pid}/timeline`);
      expect(r.status).toBe(200);
      const j = await r.json();
      expect(Array.isArray(j.events)).toBe(true);
      expect(j.events.length).toBeGreaterThan(0);
    });

    it('GET /changelog returns entries array', async () => {
      const r = await fetch(`${base}/api/devloop/projects/${pid}/changelog`);
      expect(r.status).toBe(200);
      const j = await r.json();
      expect(Array.isArray(j.entries)).toBe(true);
    });

    it('GET /units/:unitId returns unit + runs + coder_log_tail', async () => {
      const r = await fetch(`${base}/api/devloop/projects/${pid}/units/todo-be`);
      expect(r.status).toBe(200);
      const j = await r.json();
      expect(j).toHaveProperty('unit');
      expect(j).toHaveProperty('runs');
      expect(Array.isArray(j.runs)).toBe(true);
    });

    it('GET /units/:unitId 404 on unknown unit', async () => {
      const r = await fetch(`${base}/api/devloop/projects/${pid}/units/no-such-unit`);
      expect(r.status).toBe(404);
    });

    it('GET /timeline 404 on unknown pid', async () => {
      const r = await fetch(`${base}/api/devloop/projects/p-nope/timeline`);
      expect(r.status).toBe(404);
    });
  });
});