p4-l3-feedback.test.js 9.4 KB
/**
 * P4 · L3 Human Feedback tests
 *
 * 覆盖:
 *   - project.status FSM: draft → running → shipped → shipped-final
 *   - submitFeedback bumps prd_rev + shipped → running
 *   - signOff requires status=shipped
 *   - abandon any active state → abandoned (emits close)
 *   - InvalidProjectStatusTransition error mapping
 *   - PM incremental fixture variant runs on 2nd call
 */

process.env.LEMONBOSS_CLAUDE_MODE = 'mock';

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

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

const pathResolver = require('../../services/devloop/path-resolver');
const sseBroker = require('../../services/devloop/sse-broker');
const lc = require('../../src/core/devloop/loop-controller');

const HAPPY_DIR = path.resolve(__dirname, '../../../fixtures/devloop-mock-scenarios/happy_path');

// 公司 id 用序号而不是随机后缀:同一次跑里照样唯一,跨次跑复用同一个 id,
// 清理漏一次也不会在 data/companies/ 下越堆越多。
let p4Seq = 0;

async function fresh(idea = 'p4 test') {
  pathResolver._resetForTesting();
  sseBroker._resetForTesting();
  const cid = 'c-p4-' + (++p4Seq);
  const proj = await lc.createProject({ company_id: cid, mode: 'sandbox', idea });
  return { pid: proj.pid, cid };
}

function cleanup(cid) {
  try { fs.rmSync(path.join(pathResolver.DATA_DIR, 'companies', cid), { recursive: true, force: true }); }
  catch { /* ignore */ }
  require('../helpers/ensure-test-company').removeTestCompany(cid);
}

describe('P4 · L3 status FSM', () => {
  describe('happy-path: draft → running → shipped → shipped-final', () => {
    let pid, cid;
    beforeAll(async () => { ({ pid, cid } = await fresh()); });
    afterAll(() => cleanup(cid));

    it('starts as draft', () => {
      expect(lc.getProjectState(pid).status).toBe('draft');
    });

    it('runDocsPhase → running', async () => {
      await lc.runDocsPhase(pid, 'TODO', { mockFixtureDir: HAPPY_DIR });
      expect(lc.getProjectState(pid).status).toBe('running');
    });

    it('runProject → shipped', async () => {
      const r = await lc.runProject(pid, { mockFixtureDir: HAPPY_DIR });
      expect(r.final_state).toBe('shipped');
      expect(lc.getProjectState(pid).status).toBe('shipped');
    });

    it('feedback bumps prd_rev 1 → 2 and flips status shipped → running', async () => {
      const r = await lc.submitFeedback(pid, 'add toggle done feature', { mockFixtureDir: HAPPY_DIR });
      expect(r.prd_rev_after).toBe(2);
      expect(r.prd_rev_before).toBe(1);
      expect(r.status).toBe('running');
    });

    it('changelog has prd_rev=2 entry', () => {
      const cl = pathResolver.resolveChangelog(pid);
      expect(fs.existsSync(cl)).toBe(true);
      const lines = fs.readFileSync(cl, 'utf8').trim().split('\n').filter(Boolean).map(JSON.parse);
      expect(lines.some(l => l.kind === 'prd_rev' && l.rev === 2)).toBe(true);
    });

    it('runProject again → shipped', async () => {
      await lc.runProject(pid, { mockFixtureDir: HAPPY_DIR });
      expect(lc.getProjectState(pid).status).toBe('shipped');
    });

    it('signOff → shipped-final', async () => {
      const meta = await lc.signOff(pid, { by: 'yibo' });
      expect(meta.status).toBe('shipped-final');
    });

    it('feedback after sign-off is rejected', async () => {
      await expect(lc.submitFeedback(pid, 'late change', { mockFixtureDir: HAPPY_DIR }))
        .rejects.toMatchObject({ name: 'InvalidProjectStatusTransition' });
    });

    it('signOff after sign-off is rejected', async () => {
      await expect(lc.signOff(pid)).rejects.toMatchObject({ name: 'InvalidProjectStatusTransition' });
    });
  });

  describe('abandon path', () => {
    let pid, cid;
    beforeAll(async () => { ({ pid, cid } = await fresh('abandon test')); });
    afterAll(() => cleanup(cid));

    it('abandon from draft state succeeds', async () => {
      const m = await lc.abandon(pid, { by: 'yibo', reason: 'no longer needed' });
      expect(m.status).toBe('abandoned');
    });

    it('abandon after abandon is rejected', async () => {
      await expect(lc.abandon(pid)).rejects.toMatchObject({ name: 'InvalidProjectStatusTransition' });
    });

    it('feedback on abandoned project is rejected', async () => {
      await expect(lc.submitFeedback(pid, 'x', { mockFixtureDir: HAPPY_DIR }))
        .rejects.toMatchObject({ name: 'InvalidProjectStatusTransition' });
    });
  });

  describe('signOff requires shipped (not running)', () => {
    let pid, cid;
    beforeAll(async () => {
      ({ pid, cid } = await fresh('not yet shipped'));
      await lc.runDocsPhase(pid, 'TODO', { mockFixtureDir: HAPPY_DIR });
      // 不跑 runProject,status 还是 running
    });
    afterAll(() => cleanup(cid));

    it('signOff from running fails', async () => {
      await expect(lc.signOff(pid)).rejects.toMatchObject({ name: 'InvalidProjectStatusTransition', from: 'running', to: 'shipped-final' });
    });
  });

  describe('feedback empty text rejected', () => {
    let pid, cid;
    beforeAll(async () => { ({ pid, cid } = await fresh('empty feedback')); });
    afterAll(() => cleanup(cid));

    it('throws on empty/missing text', async () => {
      await expect(lc.submitFeedback(pid, '')).rejects.toThrow(/text/);
      await expect(lc.submitFeedback(pid, null)).rejects.toThrow(/text/);
    });
  });
});

describe('P4 · routes', () => {
  let app, base;
  const cids = [];

  beforeAll(async () => {
    const express = require('express');
    const devloopRouter = require('../../routes/devloop');
    pathResolver._resetForTesting();
    sseBroker._resetForTesting();
    const e = express();
    e.use(express.json());
    e.use('/api/devloop', devloopRouter);
    await new Promise(r => {
      app = e.listen(0, '127.0.0.1', () => {
        base = `http://127.0.0.1:${app.address().port}`;
        r();
      });
    });
  });

  afterAll(() => {
    app.close();
    for (const c of cids) cleanup(c);
  });

  it('POST /sign-off requires shipped status (400)', async () => {
    const cid = 'c-route-signoff';
    cids.push(cid);
    require('../helpers/ensure-test-company').ensureTestCompany(cid);
    const r = await fetch(`${base}/api/devloop/projects`, {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ company_id: cid, requirement: 'x', mockFixtureDir: HAPPY_DIR }),
    });
    const j = await r.json();
    // immediately after creation/run (full flow), check signoff
    const r2 = await fetch(`${base}/api/devloop/projects/${j.pid}/sign-off`, {
      method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}',
    });
    // After POST /projects, status is 'running' (not yet shipped via runProject)
    // → signOff should 400
    expect(r2.status).toBe(400);
  });

  it('POST /feedback bumps PRD rev', async () => {
    const cid = 'c-route-feedback';
    cids.push(cid);
    require('../helpers/ensure-test-company').ensureTestCompany(cid);
    const r = await fetch(`${base}/api/devloop/projects`, {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ company_id: cid, requirement: 'x', mockFixtureDir: HAPPY_DIR }),
    });
    const pid = (await r.json()).pid;
    const fb = await fetch(`${base}/api/devloop/projects/${pid}/feedback`, {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ text: 'add toggle', mockFixtureDir: HAPPY_DIR }),
    });
    expect(fb.status).toBe(200);
    const j = await fb.json();
    expect(j.prd_rev_after).toBeGreaterThan(j.prd_rev_before);
  });

  it('POST /feedback missing text → 400', async () => {
    const cid = 'c-route-fb-empty';
    cids.push(cid);
    require('../helpers/ensure-test-company').ensureTestCompany(cid);
    const r = await fetch(`${base}/api/devloop/projects`, {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ company_id: cid, requirement: 'x', mockFixtureDir: HAPPY_DIR }),
    });
    const pid = (await r.json()).pid;
    const fb = await fetch(`${base}/api/devloop/projects/${pid}/feedback`, {
      method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}',
    });
    expect(fb.status).toBe(400);
  });

  it('POST /abandon → status=abandoned', async () => {
    const cid = 'c-route-abandon';
    cids.push(cid);
    require('../helpers/ensure-test-company').ensureTestCompany(cid);
    const r = await fetch(`${base}/api/devloop/projects`, {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ company_id: cid, requirement: 'x', mockFixtureDir: HAPPY_DIR }),
    });
    const pid = (await r.json()).pid;
    const ab = await fetch(`${base}/api/devloop/projects/${pid}/abandon`, {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ by: 'yibo', reason: 'test' }),
    });
    expect(ab.status).toBe(200);
    const j = await ab.json();
    expect(j.status).toBe('abandoned');
  });

  it('POST /sign-off / abandon / feedback on unknown pid → 404', async () => {
    for (const ep of ['sign-off', 'abandon', 'feedback']) {
      const r = await fetch(`${base}/api/devloop/projects/p-nope/${ep}`, {
        method: 'POST', headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(ep === 'feedback' ? { text: 'x' } : {}),
      });
      expect(r.status).toBe(404);
    }
  });
});