routes.test.js 5.5 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');

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 = [];

  beforeAll(async () => {
    pathResolver._resetForTesting();
    ({ 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 */ }
    }
  });

  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-' + Math.random().toString(36).slice(2, 7);
    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 requires ?only (501 without)', async () => {
    const r = await fetch(`${base}/api/devloop/projects/p-anything/run`, { method: 'POST' });
    expect(r.status).toBe(501);
  });

  it('POST /api/devloop/projects/:pid/run?only=todo-be runs L1 loop', async () => {
    const cid = 'c-route-p2-' + Math.random().toString(36).slice(2, 7);
    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?only on unknown unit returns 404', async () => {
    const cid = 'c-route-p2u-' + Math.random().toString(36).slice(2, 7);
    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);
    const j = await r.json();
    expect(j.error).toMatch(/unknown/);
  });

  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);
  });
});