import { test } from 'node:test' import assert from 'node:assert/strict' import { ApiError, createApiClient } from '@/shared/api' import { waitForServerOnline } from '../components/system/systemApi' /** * 赛事项目切换是本路新增能力:导入赛事包后不切换,服务端就一直看着原来那个项目, * 页面上永远是空数据。这里钉住前端这一侧的契约(路径 / 字段名 / 降级口径)。 */ 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 captureClient(reply: (captured: Captured) => Response): { client: ReturnType captured: Captured } { const captured: Captured = {} const client = createApiClient({ fetchImpl: async (input, init) => { captured.url = String(input) captured.method = init?.method captured.body = init?.body return reply(captured) }, }) return { client, captured } } test('projects.list 读 GET /api/projects 并归一化每条项目', async () => { const { client, captured } = captureClient(() => jsonResponse({ ok: true, data: { projects: [ { name: 'default', active: true, hasEvent: true, sizeBytes: 2048, updatedAt: '2026-09-10 08:00:00', }, { name: '市运会2026', active: false, hasEvent: true, sizeBytes: 0, updatedAt: null }, ], }, }), ) const rows = await client.projects.list() assert.equal(captured.url, '/api/projects') assert.equal(captured.method, 'GET') assert.deepEqual(rows, [ { name: 'default', active: true, hasEvent: true, sizeBytes: 2048, updatedAt: '2026-09-10 08:00:00', }, { name: '市运会2026', active: false, hasEvent: true, sizeBytes: 0, updatedAt: null }, ]) }) // 缺字段时按「有库、未激活」呈现:真正缺库激活时服务端会给出中文 400, // 不该在列表里被静默禁掉,那样用户只会看到一排点不动的按钮。 test('projects.list 补全缺失字段,并跳过没有名字的条目', async () => { const { client } = captureClient(() => jsonResponse({ ok: true, data: [{ name: '甲' }, { active: true }, 'x'] }), ) const rows = await client.projects.list() assert.deepEqual(rows, [{ name: '甲', active: false, hasEvent: true, sizeBytes: 0, updatedAt: null }]) }) test('projects.list 响应格式不符时报 PARSE_FAILED,不伪装成空列表', async () => { const { client } = captureClient(() => jsonResponse({ ok: true, data: { nope: 1 } })) await assert.rejects( () => client.projects.list(), (error: unknown) => error instanceof ApiError && error.code === 'PARSE_FAILED', ) }) test('projects.activate 以 POST /api/projects/activate 提交项目名', async () => { const { client, captured } = captureClient(() => jsonResponse({ ok: true, data: { ok: true, name: '市运会2026', restarting: true } }), ) const result = await client.projects.activate('市运会2026') assert.equal(captured.url, '/api/projects/activate') assert.equal(captured.method, 'POST') const body = JSON.parse(String(captured.body)) as Record assert.equal(body.name, '市运会2026') assert.equal(result.restarting, true) assert.equal(result.name, '市运会2026') }) // restarting 缺失按 false 处理:这是「本来就是当前赛事」的回答, // 误判成 true 会让页面干等一个永远不会发生的重启,60 秒后误报失败。 test('projects.activate 缺 restarting 时按「不重启」处理,失败文案原样抛出', async () => { const ok = captureClient(() => jsonResponse({ ok: true, data: { name: 'default' } })) assert.deepEqual(await ok.client.projects.activate('default'), { name: 'default', restarting: false }) const failed = captureClient(() => jsonResponse( { ok: false, error: { code: 'VALIDATION_FAILED', message: '赛事项目不存在:丙', detail: '丙' } }, 400, ), ) await assert.rejects( () => failed.client.projects.activate('丙'), (error: unknown) => error instanceof ApiError && error.status === 400 && error.message === '赛事项目不存在:丙', ) }) // 可控时钟:sleep 只推进 now,不真的等待,超时分支才能瞬间跑完。 function fakeClock(): ServerRestartClock { let time = 0 return { now: () => time, sleep: async (ms: number) => { time += ms }, } } interface ServerRestartClock { now: () => number sleep: (ms: number) => Promise } test('waitForServerOnline 在服务端重新可达时返回 true', async () => { const clock = fakeClock() const replies = [false, false, true] let probes = 0 const online = await waitForServerOnline({ ...clock, intervalMs: 1500, timeoutMs: 60_000, probe: async () => { const up = replies[probes] ?? true probes += 1 if (!up) throw new ApiError('NETWORK', '无法连接服务端', 0) }, }) assert.equal(online, true) assert.equal(probes, 3) }) // 优雅退出需要时间:旧进程在退出前仍能满足健康检查, // 不等首次失败就下结论会在旧进程上误判「已经回来了」。 test('waitForServerOnline 要求先探到一次失败,再等恢复', async () => { const clock = fakeClock() let probes = 0 const online = await waitForServerOnline({ ...clock, intervalMs: 1500, timeoutMs: 60_000, waitForDown: true, probe: async () => { probes += 1 // 前两次仍是旧进程(健康检查照常通过),第三、四次进入重启窗口,第五次新进程已起来。 if (probes === 3 || probes === 4) throw new ApiError('NETWORK', '无法连接服务端', 0) }, }) assert.equal(online, true) assert.equal(probes, 5) }) test('waitForServerOnline 一直连不上时超时返回 false', async () => { const clock = fakeClock() let probes = 0 const online = await waitForServerOnline({ ...clock, intervalMs: 1500, timeoutMs: 6000, probe: async () => { probes += 1 throw new ApiError('NETWORK', '无法连接服务端', 0) }, }) assert.equal(online, false) assert.equal(probes, 5) })