import { test } from 'node:test' import assert from 'node:assert/strict' import { ApiError } from '@/shared' import { downloadBaseDataXLSX, downloadEntriesXLSX, entriesExportPath, importBaseDataXLSX, } from '@/shared/basedata' /** * 报名名单导出与基础数据包导入的前端契约:路径、筛选参数、dryRun 标志、解析出的差异。 * * 路径或参数写错时现场拿到的是 404 或「筛了却导出全量」;dryRun 标志丢了则会 * 让「预览差异」直接写库。这里用 stub fetch 钉住真正发出去的请求。 */ interface Captured { url?: string method?: string body?: unknown headers?: Record } function jsonResponse(body: unknown, status = 200): Response { return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' }, }) } function xlsxResponse(): Response { return new Response(new Blob([new Uint8Array([0x50, 0x4b, 0x03, 0x04])]), { status: 200, headers: { 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }, }) } let reply: () => Response = () => xlsxResponse() let lastRequest: Captured = {} globalThis.fetch = (async (input: unknown, init?: RequestInit) => { lastRequest = { url: String(input), method: init?.method ?? 'GET', body: init?.body, headers: (init?.headers ?? {}) as Record, } return reply() }) as typeof fetch function respondWith(next: () => Response): void { lastRequest = {} reply = next } function formValue(body: unknown, key: string): FormDataEntryValue | null { assert.ok(body instanceof FormData, '上传必须是 multipart FormData') return body.get(key) } test('报名名单导出路径按筛选条件拼 query,留空的维度不出现', () => { assert.equal(entriesExportPath(), '/export/entries.xlsx') assert.equal(entriesExportPath({ eventId: 3 }), '/export/entries.xlsx?eventId=3') assert.equal( entriesExportPath({ eventId: 3, categoryId: 9, teamId: 1, heat: 2 }), '/export/entries.xlsx?eventId=3&categoryId=9&teamId=1&heat=2', ) }) test('导出报名名单走 GET /api/export/entries.xlsx,文件名是「报名名单.xlsx」', async () => { respondWith(() => xlsxResponse()) const result = await downloadEntriesXLSX({ eventId: 3, heat: 2 }) assert.equal(lastRequest.url, '/api/export/entries.xlsx?eventId=3&heat=2') assert.equal(lastRequest.method, 'GET') assert.equal(result.filename, '报名名单.xlsx') assert.equal(result.blob.size, 4) }) test('导出基础数据包走 GET /api/export/basedata.xlsx,文件名是「基础数据包.xlsx」', async () => { respondWith(() => xlsxResponse()) const result = await downloadBaseDataXLSX() assert.equal(lastRequest.url, '/api/export/basedata.xlsx') assert.equal(result.filename, '基础数据包.xlsx') }) test('预览差异带 dryRun=1,结果里的三类计数与逐行差异都解析出来', async () => { respondWith(() => jsonResponse({ ok: true, data: { dryRun: true, added: 1, updated: 2, unchanged: 5, errorCount: 1, errors: ['单位 第 3 行:单位编码必填'], changes: [ { sheet: '单位', line: 2, action: 'updated', key: 'T1', fields: [{ field: '单位名称', from: '第一分公司', to: '第一分公司(改)' }], }, ], }, })) const file = new File(['x'], 'basedata.xlsx') const result = await importBaseDataXLSX(file, { dryRun: true }) assert.equal(lastRequest.url, '/api/export/basedata/import') assert.equal(lastRequest.method, 'POST') assert.equal(formValue(lastRequest.body, 'dryRun'), '1') assert.equal(formValue(lastRequest.body, 'file'), file) assert.equal(result.dryRun, true) assert.equal(result.added, 1) assert.equal(result.updated, 2) assert.equal(result.unchanged, 5) assert.equal(result.errorCount, 1) assert.deepEqual(result.errors, ['单位 第 3 行:单位编码必填']) assert.equal(result.changes.length, 1) assert.equal(result.changes[0].sheet, '单位') assert.equal(result.changes[0].line, 2) assert.equal(result.changes[0].fields[0].field, '单位名称') assert.equal(result.changes[0].fields[0].from, '第一分公司') assert.equal(result.changes[0].fields[0].to, '第一分公司(改)') }) test('确认写入不带 dryRun,避免预览之后又走一次只读分支', async () => { respondWith(() => jsonResponse({ ok: true, data: { dryRun: false, added: 3, updated: 0, unchanged: 0, errorCount: 0, errors: [], changes: [] }, })) const result = await importBaseDataXLSX(new File(['x'], 'basedata.xlsx'), { dryRun: false }) assert.equal(formValue(lastRequest.body, 'dryRun'), null) assert.equal(result.dryRun, false) assert.equal(result.added, 3) }) // 基础数据包导入是唯一一条不带 JSON 体的写请求(multipart),但服务端对这条路由 // 挂了基于 X-Request-Id 的幂等缓存:不带这个 header,cached 层直接放行, // 现场超时重发就会把整包数据(单位 / 大项 / 小项 / 组别)再写一遍。 test('导入基础数据包带 X-Request-Id(服务端按它做幂等与审计)', async () => { respondWith(() => jsonResponse({ ok: true, data: { dryRun: false, added: 0, updated: 0, unchanged: 0, errorCount: 0, errors: [], changes: [] }, })) await importBaseDataXLSX(new File(['x'], 'basedata.xlsx')) assert.equal(typeof lastRequest.headers?.['X-Request-Id'], 'string') assert.notEqual(lastRequest.headers?.['X-Request-Id'], '') }) test('两次导入各自生成一个幂等键(不会复用上一次的)', async () => { respondWith(() => jsonResponse({ ok: true, data: { dryRun: true, added: 0, updated: 0, unchanged: 0, errorCount: 0, errors: [], changes: [] }, })) const file = new File(['x'], 'basedata.xlsx') await importBaseDataXLSX(file, { dryRun: true }) const first = lastRequest.headers?.['X-Request-Id'] await importBaseDataXLSX(file, { dryRun: true }) const second = lastRequest.headers?.['X-Request-Id'] assert.equal(typeof first, 'string') assert.equal(typeof second, 'string') assert.notEqual(first, second) }) test('服务端 400 的行级错误原样抛出,页面能拿到那句中文', async () => { respondWith(() => jsonResponse({ ok: false, error: { code: 'VALIDATION_FAILED', message: '导出链接参数不对' }, }, 400)) await assert.rejects( () => downloadEntriesXLSX({ heat: 0 }), (error: unknown) => { assert.ok(error instanceof ApiError) assert.equal(error.status, 400) assert.equal(error.code, 'VALIDATION_FAILED') assert.equal(error.message, '导出链接参数不对') return true }, ) }) test('导入 403 / 500 原样抛出,不降级成示例数据', async () => { respondWith(() => jsonResponse({ ok: false, error: { code: 'FORBIDDEN', message: '只有管理员可以导入基础数据' }, }, 403)) await assert.rejects( () => importBaseDataXLSX(new File(['x'], 'basedata.xlsx')), (error: unknown) => { assert.ok(error instanceof ApiError) assert.equal(error.code, 'FORBIDDEN') assert.equal(error.message, '只有管理员可以导入基础数据') return true }, ) }) test('响应不是 {ok,data} 结构时报解析错误,而不是假装导入成功', async () => { respondWith(() => jsonResponse({ hello: 'world' })) await assert.rejects( () => importBaseDataXLSX(new File(['x'], 'basedata.xlsx')), (error: unknown) => { assert.ok(error instanceof ApiError) assert.equal(error.code, 'INTERNAL') return true }, ) }) test('连不上服务端时报 NETWORK,页面据此区分「后端未就绪」', async () => { respondWith(() => { throw new TypeError('fetch failed') }) await assert.rejects( () => downloadEntriesXLSX(), (error: unknown) => { assert.ok(error instanceof ApiError) assert.equal(error.code, 'NETWORK') return true }, ) })