/** * 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'; 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'; 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'; 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); }); }); });