import { test } from 'node:test'; import assert from 'node:assert/strict'; import { mkdtempSync, mkdirSync, writeFileSync, existsSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { Store } from '../src/store/index.js'; import { buildServer } from '../src/api/server.js'; import type { Attachment } from '../src/model/types.js'; /** 临时数据目录(充当 MAESTRO_DATA_DIR)。 */ function tmpData(): string { return mkdtempSync(join(tmpdir(), 'maestro-attach-')); } /** 在 store 里建项目 + 任务,返回 {store, taskId}。 */ function freshTask(): { store: Store; taskId: string } { const store = new Store(':memory:'); const p = store.createProject({ name: 'a', repoPath: '/tmp/a-' + Math.random() }); const t = store.createTask({ projectId: p.id, title: 'demo', complexity: 'easy' }); return { store, taskId: t.id }; } /** 把一份附件文件落盘到 /tasks//attachments/,并返回元数据。 */ function placeFile(dir: string, taskId: string, name: string, mime: string, body: Buffer | string): Attachment { const attDir = join(dir, 'tasks', taskId, 'attachments'); mkdirSync(attDir, { recursive: true }); writeFileSync(join(attDir, name), body); return { name, type: mime, path: `tasks/${taskId}/attachments/${name}` }; } // ───────────────────────── Store 层 ───────────────────────── test('addAttachments:按 path 去重(later-wins)', () => { const { store, taskId } = freshTask(); store.addAttachments(taskId, [{ name: 'a.png', type: 'image/png', path: `tasks/${taskId}/attachments/a.png` }]); // 重传同一 path(safeName 相同)→ 元数据只保留一条,且取最新 name/type const next = store.addAttachments(taskId, [ { name: 'a-renamed.png', type: 'image/jpeg', path: `tasks/${taskId}/attachments/a.png` }, { name: 'b.pdf', type: 'application/pdf', path: `tasks/${taskId}/attachments/b.pdf` }, ]); assert.equal(next.length, 2); const a = next.find((x) => x.path.endsWith('/a.png')); assert.equal(a?.name, 'a-renamed.png'); assert.equal(a?.type, 'image/jpeg'); // 插入顺序保持:a 在前、b 在后 assert.deepEqual(next.map((x) => x.path.split('/').pop()), ['a.png', 'b.pdf']); store.close(); }); test('removeAttachment:按磁盘文件名移除,返回 removed;不存在则 removed=null', () => { const { store, taskId } = freshTask(); store.addAttachments(taskId, [ { name: 'keep.png', type: 'image/png', path: `tasks/${taskId}/attachments/keep.png` }, { name: 'gone.pdf', type: 'application/pdf', path: `tasks/${taskId}/attachments/gone.pdf` }, ]); const r1 = store.removeAttachment(taskId, 'gone.pdf'); assert.equal(r1.removed?.path, `tasks/${taskId}/attachments/gone.pdf`); assert.deepEqual(r1.attachments.map((x) => x.path.split('/').pop()), ['keep.png']); // 再删不存在的名字 → removed=null,列表不变 const r2 = store.removeAttachment(taskId, 'nope.png'); assert.equal(r2.removed, null); assert.equal(r2.attachments.length, 1); store.close(); }); test('removeAttachment / addAttachments:任务不存在抛错', () => { const store = new Store(':memory:'); assert.throws(() => store.removeAttachment('tsk_nope', 'x')); assert.throws(() => store.addAttachments('tsk_nope', [])); store.close(); }); // ───────────────────────── API 层(Fastify inject)───────────────────────── test('GET 附件:默认 inline,content-type/length 正确', async () => { const dir = tmpData(); process.env.MAESTRO_DATA_DIR = dir; const { store, taskId } = freshTask(); const png = Buffer.from('\x89PNG\r\n\x1a\n-fake-image-bytes', 'binary'); store.addAttachments(taskId, [placeFile(dir, taskId, 'pic.png', 'image/png', png)]); const app = buildServer({ store }); const res = await app.inject({ method: 'GET', url: `/api/tasks/${taskId}/attachments/pic.png` }); assert.equal(res.statusCode, 200); assert.match(res.headers['content-type'] as string, /image\/png/); assert.equal(res.headers['content-length'], String(png.length)); assert.match(res.headers['content-disposition'] as string, /^inline; filename\*=UTF-8''pic\.png/); await app.close(); store.close(); }); test('GET 附件:?download=1 → attachment + RFC5987 文件名(中文不乱码)', async () => { const dir = tmpData(); process.env.MAESTRO_DATA_DIR = dir; const { store, taskId } = freshTask(); // 磁盘安全名为 ascii,但原始 name 含中文 store.addAttachments(taskId, [{ name: '设计稿.png', type: 'image/png', path: `tasks/${taskId}/attachments/paste-1.png`, }]); placeFile(dir, taskId, 'paste-1.png', 'image/png', 'data'); const app = buildServer({ store }); const res = await app.inject({ method: 'GET', url: `/api/tasks/${taskId}/attachments/paste-1.png?download=1` }); assert.equal(res.statusCode, 200); const cd = res.headers['content-disposition'] as string; assert.match(cd, /^attachment; filename\*=UTF-8''/); assert.match(cd, new RegExp(encodeURIComponent('设计稿.png'))); await app.close(); store.close(); }); test('DELETE 附件:剔除元数据 + 删磁盘文件;之后 GET 404', async () => { const dir = tmpData(); process.env.MAESTRO_DATA_DIR = dir; const { store, taskId } = freshTask(); store.addAttachments(taskId, [placeFile(dir, taskId, 'doc.pdf', 'application/pdf', 'pdfdata')]); const absFile = join(dir, 'tasks', taskId, 'attachments', 'doc.pdf'); assert.ok(existsSync(absFile)); const app = buildServer({ store }); const del = await app.inject({ method: 'DELETE', url: `/api/tasks/${taskId}/attachments/doc.pdf` }); assert.equal(del.statusCode, 200); assert.deepEqual(del.json().attachments, []); assert.ok(!existsSync(absFile), '磁盘文件应被删除'); const get = await app.inject({ method: 'GET', url: `/api/tasks/${taskId}/attachments/doc.pdf` }); assert.equal(get.statusCode, 404); await app.close(); store.close(); }); test('GET/DELETE:任务不存在 → 404', async () => { const store = new Store(':memory:'); const app = buildServer({ store }); const g = await app.inject({ method: 'GET', url: `/api/tasks/tsk_nope/attachments/x.png` }); assert.equal(g.statusCode, 404); const d = await app.inject({ method: 'DELETE', url: `/api/tasks/tsk_nope/attachments/x.png` }); assert.equal(d.statusCode, 404); await app.close(); store.close(); }); test('GET:未登记的附件名 → 404(不返回目录外/任意文件)', async () => { const dir = tmpData(); process.env.MAESTRO_DATA_DIR = dir; const { store, taskId } = freshTask(); placeFile(dir, taskId, 'secret.png', 'image/png', 'x'); // 落盘但不登记元数据 const app = buildServer({ store }); const res = await app.inject({ method: 'GET', url: `/api/tasks/${taskId}/attachments/secret.png` }); assert.equal(res.statusCode, 404); await app.close(); store.close(); }); test('GET:元数据在但磁盘文件丢失 → 404「附件文件已丢失」', async () => { const dir = tmpData(); process.env.MAESTRO_DATA_DIR = dir; const { store, taskId } = freshTask(); // 只写元数据,不落盘 store.addAttachments(taskId, [{ name: 'ghost.png', type: 'image/png', path: `tasks/${taskId}/attachments/ghost.png` }]); const app = buildServer({ store }); const res = await app.inject({ method: 'GET', url: `/api/tasks/${taskId}/attachments/ghost.png` }); assert.equal(res.statusCode, 404); assert.match(res.json().error, /丢失/); await app.close(); store.close(); }); test('DELETE:元数据在但磁盘文件已被外部删除 → 仍成功清元数据', async () => { const dir = tmpData(); process.env.MAESTRO_DATA_DIR = dir; const { store, taskId } = freshTask(); store.addAttachments(taskId, [{ name: 'ghost.png', type: 'image/png', path: `tasks/${taskId}/attachments/ghost.png` }]); const app = buildServer({ store }); const res = await app.inject({ method: 'DELETE', url: `/api/tasks/${taskId}/attachments/ghost.png` }); assert.equal(res.statusCode, 200); assert.deepEqual(res.json().attachments, []); await app.close(); store.close(); }); test('上传→去重端到端:同名文件连传两次,元数据只一条', async () => { const dir = tmpData(); process.env.MAESTRO_DATA_DIR = dir; const { store, taskId } = freshTask(); const app = buildServer({ store }); function multipart(filename: string, content: string): { payload: Buffer; headers: Record } { const boundary = '----maestrotest'; const body = `--${boundary}\r\n` + `Content-Disposition: form-data; name="files"; filename="${filename}"\r\n` + `Content-Type: image/png\r\n\r\n` + content + `\r\n` + `--${boundary}--\r\n`; return { payload: Buffer.from(body), headers: { 'content-type': `multipart/form-data; boundary=${boundary}` } }; } const m1 = multipart('shot.png', 'aaa'); const up1 = await app.inject({ method: 'POST', url: `/api/tasks/${taskId}/attachments`, payload: m1.payload, headers: m1.headers }); assert.equal(up1.statusCode, 200); assert.equal(up1.json().attachments.length, 1); const m2 = multipart('shot.png', 'bbb'); const up2 = await app.inject({ method: 'POST', url: `/api/tasks/${taskId}/attachments`, payload: m2.payload, headers: m2.headers }); assert.equal(up2.statusCode, 200); // 同名 safeName → path 相同 → 去重为一条 const atts = up2.json().attachments; assert.equal(atts.length, 1, '同名重传应去重为一条'); // 通过 GET /api/tasks/:id 复核 const t = await app.inject({ method: 'GET', url: `/api/tasks/${taskId}` }); assert.equal(t.json().attachments.length, 1); await app.close(); store.close(); });