import { test } from 'node:test' import assert from 'node:assert/strict' import { ApiError } from '@/shared' import { ENTRY_BATCH_PATH, submitEntryBatch } from '../pages/entryBatch' /** * 报名项批量操作的请求契约:方法 / 路径 / 请求体字段必须与服务端 * internal/httpapi/entries_batch.go 的 entryBatchPayload 一致; * 写请求漏掉 X-CSRF-Token 会被中间件 403,现场只会看到「删不掉」。 */ interface Captured { url?: string method?: string headers?: Record body?: unknown credentials?: string } let lastRequest: Captured = {} let reply: () => Response = () => jsonResponse({ ok: true, data: { items: [], summary: emptySummary() } }) function jsonResponse(body: unknown, status = 200): Response { return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' }, }) } function emptySummary() { return { ok: 0, failed: 0, skipped: 0, changed: 0, removedEntries: 0, removedResults: 0, removedLedger: 0, removedBreaks: 0, recomputedEvents: [], } } globalThis.fetch = (async (input: unknown, init?: RequestInit) => { lastRequest = { url: String(input), method: init?.method ?? 'GET', headers: (init?.headers ?? {}) as Record, body: init?.body === undefined ? undefined : JSON.parse(String(init.body)), credentials: init?.credentials, } return reply() }) as typeof fetch function respondWith(response: () => Response): void { lastRequest = {} reply = response } test('批量操作走 POST /api/entries/batch,body 带 action + ids', async () => { respondWith(() => jsonResponse({ ok: true, data: { items: [{ id: 1, ok: true }], summary: { ...emptySummary(), ok: 1, changed: 1, recomputedEvents: [3] }, }, }), ) const result = await submitEntryBatch({ action: 'scratch', ids: [1, 1, 2] }) assert.equal(lastRequest.url, ENTRY_BATCH_PATH) assert.equal(lastRequest.method, 'POST') assert.equal(lastRequest.credentials, 'include') assert.equal((lastRequest.body as { action: string }).action, 'scratch') assert.deepEqual((lastRequest.body as { ids: number[] }).ids, [1, 1, 2]) // 写请求必须有 requestId(幂等)与 JSON 内容类型。 assert.equal(typeof (lastRequest.body as { requestId?: unknown }).requestId, 'string') assert.equal(lastRequest.headers?.['Content-Type'], 'application/json; charset=utf-8') assert.deepEqual(result.summary.recomputedEvents, [3]) }) // 已登录写请求必须带 CSRF:漏掉就是 403,退赛 / 移出都不会生效。 test('带 csrf_token Cookie 时补上 X-CSRF-Token', async () => { const original = (globalThis as { document?: unknown }).document ;(globalThis as { document?: unknown }).document = { cookie: 'a=1; csrf_token=tok-123; b=2' } try { respondWith(() => jsonResponse({ ok: true, data: { items: [], summary: emptySummary() } })) await submitEntryBatch({ action: 'restore', ids: [2] }) assert.equal(lastRequest.headers?.['X-CSRF-Token'], 'tok-123') } finally { if (original === undefined) delete (globalThis as { document?: unknown }).document else (globalThis as { document?: unknown }).document = original } }) // 服务端是两层幂等:外层 cached 认 X-Request-Id,内层 idempotent 认 body.requestId。 // 两处各生成一个 id,服务端永远拼不出同一个 key —— 超时重试「移出报名」会真的删第二遍。 test('X-Request-Id 与 body.requestId 是同一次调用的同一个值', async () => { respondWith(() => jsonResponse({ ok: true, data: { items: [], summary: emptySummary() } })) await submitEntryBatch({ action: 'remove', ids: [7] }) const headerId = lastRequest.headers?.['X-Request-Id'] const bodyId = (lastRequest.body as { requestId?: unknown }).requestId assert.equal(typeof headerId, 'string') assert.equal(bodyId, headerId, 'header 与 body 必须是同一个幂等键,否则服务端两层缓存都命中不了') }) test('调用方显式给的 requestId 会同时用于 header 与 body', async () => { respondWith(() => jsonResponse({ ok: true, data: { items: [], summary: emptySummary() } })) await submitEntryBatch({ action: 'scratch', ids: [8], requestId: 'retry-1' }) assert.equal(lastRequest.headers?.['X-Request-Id'], 'retry-1') assert.equal((lastRequest.body as { requestId?: unknown }).requestId, 'retry-1') }) test('没有 document(非浏览器环境)时不带 CSRF,也不报错', async () => { respondWith(() => jsonResponse({ ok: true, data: { items: [], summary: emptySummary() } })) await submitEntryBatch({ action: 'remove', ids: [3] }) assert.equal(lastRequest.headers?.['X-CSRF-Token'], undefined) }) // 服务端的中文原文要原样传给页面;批量里的逐条失败也在 data 里,不能被当成请求失败。 test('服务端报错时原样抛出 code 与中文 message', async () => { respondWith(() => jsonResponse({ ok: false, error: { code: 'VALIDATION_FAILED', message: '一次最多处理 500 条报名项,请分批提交' } }, 400)) await assert.rejects( () => submitEntryBatch({ action: 'remove', ids: [1] }), (error: unknown) => { assert.ok(error instanceof ApiError) assert.equal(error.code, 'VALIDATION_FAILED') assert.match(error.message, /分批提交/) return true }, ) }) test('逐条失败(ok:false)仍算成功响应,由页面逐条提示', async () => { respondWith(() => jsonResponse({ ok: true, data: { items: [ { id: 1, ok: true }, { id: 999999, ok: false, error: '报名项不存在' }, ], summary: { ...emptySummary(), ok: 1, failed: 1 }, }, }), ) const result = await submitEntryBatch({ action: 'remove', ids: [1, 999999] }) assert.equal(result.summary.failed, 1) assert.equal(result.items[1]?.error, '报名项不存在') }) test('网络不可达时报「没有生效」,不能让操作员以为已经删掉了', async () => { respondWith(() => { throw new TypeError('fetch failed') }) await assert.rejects( () => submitEntryBatch({ action: 'remove', ids: [1] }), (error: unknown) => { assert.ok(error instanceof ApiError) assert.equal(error.code, 'NETWORK') assert.match(error.message, /没有生效/) return true }, ) })