routes-crud.test.js 6.59 KB
/**
 * routes/devloop.js · 项目 CRUD 路由测试
 *
 * 覆盖 (新增的) PATCH / DELETE (soft + hard) / restore + listProjects 过滤。
 */

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-crud-'));
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', () => {
      resolve({ srv, base: `http://127.0.0.1:${srv.address().port}` });
    });
  });
}

async function createProject(base, cid, requirement = 'crud test') {
  const r = await fetch(`${base}/api/devloop/projects`, {
    method: 'POST', headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ company_id: cid, requirement }),
  });
  const j = await r.json();
  return j.pid;
}

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

  beforeAll(async () => {
    pathResolver._resetForTesting();
    ({ srv: app, base } = await startApp());
  });

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

  describe('PATCH /projects/:pid', () => {
    let pid, cid;
    beforeAll(async () => {
      cid = 'c-patch-' + Math.random().toString(36).slice(2, 7);
      cids.push(cid);
      pid = await createProject(base, cid);
    });

    it('updates name + idea_summary', async () => {
      const r = await fetch(`${base}/api/devloop/projects/${pid}`, {
        method: 'PATCH', headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ name: 'renamed project', idea_summary: 'new summary' }),
      });
      expect(r.status).toBe(200);
      const j = await r.json();
      expect(j.project.name).toBe('renamed project');
      expect(j.project.idea_summary).toBe('new summary');
    });

    it('rejects empty body (no allowed fields)', async () => {
      const r = await fetch(`${base}/api/devloop/projects/${pid}`, {
        method: 'PATCH', headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({}),
      });
      expect(r.status).toBe(400);
    });

    it('rejects non-string fields', async () => {
      const r = await fetch(`${base}/api/devloop/projects/${pid}`, {
        method: 'PATCH', headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ name: 123 }),
      });
      expect(r.status).toBe(400);
    });

    it('ignores fields not in allowlist (status / archived not changeable here)', async () => {
      const r = await fetch(`${base}/api/devloop/projects/${pid}`, {
        method: 'PATCH', headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ status: 'shipped-final', archived: true }),
      });
      expect(r.status).toBe(400); // 没有任何 allowed 字段 → 400
    });

    it('404 on unknown pid', async () => {
      const r = await fetch(`${base}/api/devloop/projects/p-nope`, {
        method: 'PATCH', headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ name: 'x' }),
      });
      expect(r.status).toBe(404);
    });
  });

  describe('DELETE soft (archive) + GET list', () => {
    let pidA, pidB, cid;
    beforeAll(async () => {
      cid = 'c-del-' + Math.random().toString(36).slice(2, 7);
      cids.push(cid);
      pidA = await createProject(base, cid, 'kept');
      pidB = await createProject(base, cid, 'will be archived');
    });

    it('soft delete sets archived=true (default no hard query)', async () => {
      const r = await fetch(`${base}/api/devloop/projects/${pidB}`, { method: 'DELETE' });
      expect(r.status).toBe(200);
      const j = await r.json();
      expect(j.hard).toBe(false);
      expect(j.archived).toBe(true);
      expect(j.project.archived).toBe(true);
    });

    it('default listProjects excludes archived', async () => {
      const r = await fetch(`${base}/api/devloop/projects`);
      const j = await r.json();
      const ids = j.projects.map(p => p.pid);
      expect(ids).toContain(pidA);
      expect(ids).not.toContain(pidB);
    });

    it('?include_archived=true returns archived too', async () => {
      const r = await fetch(`${base}/api/devloop/projects?include_archived=true`);
      const j = await r.json();
      const ids = j.projects.map(p => p.pid);
      expect(ids).toContain(pidA);
      expect(ids).toContain(pidB);
      const arch = j.projects.find(p => p.pid === pidB);
      expect(arch.archived).toBe(true);
    });

    it('POST /restore un-archives', async () => {
      const r = await fetch(`${base}/api/devloop/projects/${pidB}/restore`, { method: 'POST' });
      expect(r.status).toBe(200);
      const j = await r.json();
      expect(j.archived).toBe(false);
      // 现在默认 list 应能见到
      const list = await (await fetch(`${base}/api/devloop/projects`)).json();
      expect(list.projects.map(p => p.pid)).toContain(pidB);
    });
  });

  describe('DELETE hard', () => {
    it('?hard=true rm -rf 项目目录 (sandbox)', async () => {
      const cid = 'c-hard-' + Math.random().toString(36).slice(2, 7);
      cids.push(cid);
      const pid = await createProject(base, cid, 'hard delete me');
      const rootBefore = pathResolver.resolveProjectRoot(pid);
      expect(fs.existsSync(rootBefore)).toBe(true);

      const r = await fetch(`${base}/api/devloop/projects/${pid}?hard=true`, { method: 'DELETE' });
      expect(r.status).toBe(200);
      const j = await r.json();
      expect(j.hard).toBe(true);
      expect(j.removed_disk).toBe(true);
      expect(fs.existsSync(rootBefore)).toBe(false);

      // 后续读 state 应 404 (pid 已从 cache 移除)
      const s = await fetch(`${base}/api/devloop/projects/${pid}/state`);
      expect(s.status).toBe(404);
    });

    it('hard delete unknown pid → 404', async () => {
      const r = await fetch(`${base}/api/devloop/projects/p-nope?hard=true`, { method: 'DELETE' });
      expect(r.status).toBe(404);
    });
  });

  describe('GET /projects?include_archived=false (default)', () => {
    it('returns projects array (could be empty)', 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);
    });
  });
});