import { test } from 'node:test' import assert from 'node:assert/strict' import { imageAssetsApi } from '../components/system/systemApi' /** * 图片删除 / 孤儿清理接口的前端契约:路径、方法、请求体与响应形状。 * * 这一组端点的红线是「只删点名的文件」,路径或字段名写错时现场看到的是 * 「点了删除但列表没变」这类静默故障,所以这里用 stub fetch 钉住真正发出去的请求。 */ interface Captured { url?: string method?: string body?: unknown } function jsonResponse(body: unknown, status = 200): Response { return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' }, }) } function stubFetch(reply: (captured: Captured) => Response): Captured { const captured: Captured = {} globalThis.fetch = (async (input: unknown, init?: RequestInit) => { captured.url = String(input) captured.method = init?.method ?? 'GET' captured.body = init?.body return reply(captured) }) as typeof fetch ;(globalThis as { window?: unknown }).window = { setTimeout: (fn: () => void, ms?: number) => setTimeout(fn, ms), clearTimeout: (id: number) => clearTimeout(id), } return captured } const ORPHAN = { name: '3f6a1c.png', url: '/uploads/3f6a1c.png', size: 2048, modifiedAt: '2026-09-11T03:04:05Z', } test('imageAssetsApi.listOrphans 读 GET /api/uploads/orphans 并带出总数与占用', async () => { const captured = stubFetch(() => jsonResponse({ ok: true, data: { items: [ORPHAN], total: 1, totalBytes: 2048 } }), ) const list = await imageAssetsApi.listOrphans() assert.equal(captured.url, '/api/uploads/orphans') assert.equal(captured.method, 'GET') assert.deepEqual(list, { items: [ORPHAN], total: 1, totalBytes: 2048 }) }) // 形状不对时必须报解析失败,而不是把 undefined 当成「没有孤儿」骗管理员说目录干净。 test('imageAssetsApi.listOrphans 对非法响应报解析失败', async () => { stubFetch(() => jsonResponse({ ok: true, data: [ORPHAN] })) await assert.rejects(() => imageAssetsApi.listOrphans(), (error: Error) => error.message.includes('孤儿图片')) }) test('imageAssetsApi.remove 以 DELETE 删单张并把文件名转义进路径', async () => { const captured = stubFetch(() => jsonResponse({ ok: true, data: null })) await imageAssetsApi.remove('3f6a 1c.png') assert.equal(captured.url, `/api/uploads/${encodeURIComponent('3f6a 1c.png')}`) assert.equal(captured.method, 'DELETE') }) // 被引用的图片服务端回 409:原因必须原样透出,否则管理员不知道该去哪改配置。 test('imageAssetsApi.remove 原样抛出「正在被使用」的 409 文案', async () => { stubFetch(() => jsonResponse( { ok: false, error: { code: 'CONFLICT', message: '图片 3f6a1c.png 正在被使用(赛事信息·大屏背景图),请先在对应位置改掉或清除后再删除', }, }, 409, ), ) await assert.rejects( () => imageAssetsApi.remove('3f6a1c.png'), (error: Error) => error.message.includes('正在被使用'), ) }) test('imageAssetsApi.removeOrphans POST 明确列出的文件名并解析逐条回报', async () => { const captured = stubFetch(() => jsonResponse({ ok: true, data: { deleted: ['3f6a1c.png'], failed: [{ name: 'aabb.png', reason: '仍被引用(单位「第一中学」的单位图标),已跳过' }] }, }), ) const result = await imageAssetsApi.removeOrphans(['3f6a1c.png', 'aabb.png']) assert.equal(captured.url, '/api/uploads/orphans/delete') assert.equal(captured.method, 'POST') // 只断言 names:send() 会按幂等约定往请求体里补一个 requestId,整包深比较会被它带偏。 assert.deepEqual((JSON.parse(String(captured.body)) as { names: string[] }).names, [ '3f6a1c.png', 'aabb.png', ]) assert.deepEqual(result.deleted, ['3f6a1c.png']) assert.equal(result.failed.length, 1) assert.match(result.failed[0]?.reason ?? '', /仍被引用/) })