/** * Unit Tests — PUT /api/company/:companyId 的 target_dir 配置入口(42 号文档 Phase 3) * * 39 号文档 P5 落地的是"配了 company.json.target_dir 就生效"这一半,"人怎么把这两个字段 * 填进去"这一半此前只能手工编辑 company.json。这条测试钉住新入口的校验分支——校验复用 * path-resolver.validateLinkedTarget(P5 已经抽出来的共享函数),跟 graph-engine.js 运行时 * 用的是同一份判据,不重新写一遍。 * * 走真入口:把 router 挂到真 express 上 listen(0) 再 fetch,不 mock company-service。 */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import fs from 'fs'; import os from 'os'; import path from 'path'; import express from 'express'; const COMPANIES_DIR = path.resolve(__dirname, '../../../data/companies'); describe('PUT /api/company/:companyId — target_dir 配置(42 号文档 Phase 3)', () => { let server; let base; const createdCompanies = []; const createdDirs = []; beforeAll(async () => { const app = express(); app.use(express.json()); app.use('/api/company', require('../../routes/company')); await new Promise((resolve) => { server = app.listen(0, resolve); }); base = `http://127.0.0.1:${server.address().port}/api/company`; }); afterAll(() => { if (server) server.close(); for (const cid of createdCompanies) { fs.rmSync(path.join(COMPANIES_DIR, cid), { recursive: true, force: true }); } for (const d of createdDirs) { try { fs.rmSync(d, { recursive: true, force: true }); } catch { /* ignore */ } } }); function makeCompany(companyId) { const dir = path.join(COMPANIES_DIR, companyId); fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(path.join(dir, 'company.json'), JSON.stringify({ id: companyId, name: companyId })); createdCompanies.push(companyId); return dir; } async function put(companyId, body) { const r = await fetch(`${base}/${companyId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); return [r.status, await r.json()]; } it('linked-existing:合法的已存在空目录,写入成功', async () => { const cid = 'target-dir-cfg-existing'; makeCompany(cid); const target = fs.mkdtempSync(path.join(os.tmpdir(), 'target-dir-cfg-existing-')); createdDirs.push(target); const [status, body] = await put(cid, { target_dir: target, target_mode: 'linked-existing' }); expect(status).toBe(200); expect(body.company.target_dir).toBe(path.resolve(target)); expect(body.company.target_mode).toBe('linked-existing'); // 真的落盘了,不是只回了个内存对象 const onDisk = JSON.parse(fs.readFileSync(path.join(COMPANIES_DIR, cid, 'company.json'), 'utf8')); expect(onDisk.target_dir).toBe(path.resolve(target)); }); it('linked-new:目录不存在时自动创建,写入成功', async () => { const cid = 'target-dir-cfg-new'; makeCompany(cid); const parent = fs.mkdtempSync(path.join(os.tmpdir(), 'target-dir-cfg-new-parent-')); createdDirs.push(parent); const target = path.join(parent, 'brand-new-project'); expect(fs.existsSync(target)).toBe(false); const [status, body] = await put(cid, { target_dir: target, target_mode: 'linked-new' }); expect(status).toBe(200); expect(body.company.target_mode).toBe('linked-new'); expect(fs.existsSync(target)).toBe(true); // validateLinkedTarget 的既有副作用:自动建目录 }); it('不传 target_mode 时默认按 linked-existing 处理(与 graph-engine.js 的默认口径一致)', async () => { const cid = 'target-dir-cfg-default-mode'; makeCompany(cid); const target = fs.mkdtempSync(path.join(os.tmpdir(), 'target-dir-cfg-default-')); createdDirs.push(target); const [status, body] = await put(cid, { target_dir: target }); expect(status).toBe(200); expect(body.company.target_mode).toBe('linked-existing'); }); it('敏感路径命中黑名单 → 400,不写入', async () => { const cid = 'target-dir-cfg-sensitive'; makeCompany(cid); const sensitive = path.join(os.homedir(), '.ssh', 'target-dir-cfg-should-not-write'); const [status, body] = await put(cid, { target_dir: sensitive, target_mode: 'linked-existing' }); expect(status).toBe(400); expect(body.error).toContain('sensitive_path'); const onDisk = JSON.parse(fs.readFileSync(path.join(COMPANIES_DIR, cid, 'company.json'), 'utf8')); expect(onDisk.target_dir).toBeUndefined(); // 校验失败,company.json 没有被改动 }); it('linked-new 但目录已存在且非空 → 400,不写入', async () => { const cid = 'target-dir-cfg-new-nonempty'; makeCompany(cid); const target = fs.mkdtempSync(path.join(os.tmpdir(), 'target-dir-cfg-nonempty-')); createdDirs.push(target); fs.writeFileSync(path.join(target, 'already-here.txt'), 'x'); const [status, body] = await put(cid, { target_dir: target, target_mode: 'linked-new' }); expect(status).toBe(400); expect(body.error).toContain('not empty'); }); it('linked-existing 但目录不存在 → 400,不写入', async () => { const cid = 'target-dir-cfg-existing-missing'; makeCompany(cid); const parent = fs.mkdtempSync(path.join(os.tmpdir(), 'target-dir-cfg-missing-parent-')); createdDirs.push(parent); const target = path.join(parent, 'does-not-exist'); const [status, body] = await put(cid, { target_dir: target, target_mode: 'linked-existing' }); expect(status).toBe(400); expect(body.error).toContain('does not exist'); }); it('target_dir 传空字符串清掉已有配置', async () => { const cid = 'target-dir-cfg-clear'; makeCompany(cid); const target = fs.mkdtempSync(path.join(os.tmpdir(), 'target-dir-cfg-clear-')); createdDirs.push(target); const [s1, b1] = await put(cid, { target_dir: target, target_mode: 'linked-existing' }); expect(s1).toBe(200); expect(b1.company.target_dir).toBeTruthy(); const [s2, b2] = await put(cid, { target_dir: '' }); expect(s2).toBe(200); expect(b2.company.target_dir).toBeNull(); expect(b2.company.target_mode).toBeNull(); }); it('公司不存在 → 404', async () => { const [status] = await put('target-dir-cfg-nope', { target_dir: '/tmp/whatever' }); expect(status).toBe(404); }); });