import { test } from 'node:test' import assert from 'node:assert/strict' /** * 参赛单位「恢复」的前端契约:方法 / 路径、已删除单位的列表开关,以及失败时服务端那句话的传递。 * * 恢复是「彻底删除」(假删除)的反操作:报名、成绩与积分一行没动,所以服务端只回报 * restored 与恢复后的单位,不重算、不推榜单。这里把契约钉住,免得有人按别的写法改坏。 */ interface Captured { url?: string method?: string headers?: Record } function jsonResponse(body: unknown, status = 200): Response { return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' }, }) } let reply: () => Response = () => jsonResponse({ ok: true, data: null }) let lastRequest: Captured = {} // 必须在导入被测模块之前装好:ApiClient 在构造时就把当时的 fetch 绑进实例, // 之后再替换 globalThis.fetch 对它无效。所以这里装一个固定函数,每个用例只换回复。 globalThis.fetch = (async (input: unknown, init?: RequestInit) => { lastRequest = { url: String(input), method: init?.method ?? 'GET', headers: (init?.headers ?? {}) as Record, } return reply() }) as typeof fetch function respondWith(response: () => Response): void { lastRequest = {} reply = response } const { ApiError, adminApi } = await import('../api/client') test('恢复走 POST /api/teams/{id}/restore 并回报 restored', async () => { respondWith(() => jsonResponse({ ok: true, data: { team: { id: 3, code: 'A', name: '第一分公司', shortName: '一司', logoPath: null, color: null, sortOrder: 0, active: true, deletedAt: null }, restored: true, }, }), ) const result = await adminApi.client.teams.restore(3) assert.equal(lastRequest.url, '/api/teams/3/restore') assert.equal(lastRequest.method, 'POST') assert.equal(result.restored, true) assert.equal(result.team.deletedAt, null) assert.equal(result.team.active, true) }) // 服务端中间件要求已登录的写请求带 X-CSRF-Token,漏掉就是 403,点「恢复」恢复不了还看不出原因。 test('恢复带上 CSRF 双提交 Cookie 的请求头', async () => { respondWith(() => jsonResponse({ ok: true, data: { team: {}, restored: false } })) const globalWithDocument = globalThis as { document?: unknown } globalWithDocument.document = { cookie: 'csrf_token=token-123' } try { await adminApi.client.teams.restore(3) } finally { delete globalWithDocument.document } assert.equal(lastRequest.headers?.['X-CSRF-Token'], 'token-123') }) // 幂等命中:服务端回 restored=false,前端不该把它当成失败。 test('单位本就未删除时 restored=false 也算成功', async () => { respondWith(() => jsonResponse({ ok: true, data: { team: { id: 4, code: 'B', name: '第二分公司', shortName: null, logoPath: null, color: null, sortOrder: 1, active: false, deletedAt: null }, restored: false, }, }), ) const result = await adminApi.client.teams.restore(4) assert.equal(result.restored, false) // 停用的单位不能被「恢复」顺手改成启用。 assert.equal(result.team.active, false) }) // 列已彻底删除的单位要显式带开关,否则服务端不会把隐藏的行放进来。 test('includeDeleted 拼成 ?includeDeleted=1', async () => { respondWith(() => jsonResponse({ ok: true, data: [] })) await adminApi.client.teams.list({ includeDeleted: true }) assert.equal(lastRequest.url, '/api/teams?includeDeleted=1') assert.equal(lastRequest.method, 'GET') }) // 默认列表不能带上这个开关:非 admin 带它会被服务端 403,整个单位列表就空了。 test('默认列表不带 includeDeleted 查询串', async () => { respondWith(() => jsonResponse({ ok: true, data: [] })) await adminApi.client.teams.list() assert.equal(lastRequest.url, '/api/teams') }) // 失败时不吞错误:服务端那句原文(单位不存在 / 编码被占用)必须原样带出。 test('恢复失败时服务端那句原文原样带出', async () => { respondWith(() => jsonResponse({ ok: false, error: { code: 'VALIDATION_FAILED', message: '单位不存在' } }, 404), ) await assert.rejects( () => adminApi.client.teams.restore(999999), (error: unknown) => { assert.ok(error instanceof ApiError) assert.equal(error.status, 404) assert.equal(error.message, '单位不存在') return true }, ) }) test('恢复撞上编码冲突时把 409 的原文带出', async () => { respondWith(() => jsonResponse( { ok: false, error: { code: 'CONFLICT', message: '单位编码 A 已被另一个可见单位占用,请先处理那个单位再恢复' } }, 409, ), ) await assert.rejects( () => adminApi.client.teams.restore(3), (error: unknown) => { assert.ok(error instanceof ApiError) assert.equal(error.status, 409) assert.equal(error.code, 'CONFLICT') assert.match(error.message, /已被另一个可见单位占用/) return true }, ) })