Files
Scoreboard/web/src/admin/tests/projects.test.ts
T
AskaEth 2d207fd654 feat: 赛事项目切换 + 记住上次打开的赛事(修「导入赛事包后没数据」)
根因:服务端启动只认 -project(缺省 default),Console 从不传;导入只是把包解到
event_projs/<名字>,没有任何切换入口 → 导入的赛事永远打不开,页面看到的是空的 default。

- 服务端 -project 缺省改为空串;解析顺序:显式 flag(并写回记忆)> <dataDir>/active_project.txt
  > default;backup/passwd 口径一致;启动日志打印生效项目与来源
- GET /api/projects(admin)列出各赛事(是否当前/是否有 event.db/大小/更新时间)
- POST /api/projects/activate(admin):校验后原子写记忆文件 → 审计 → 返回
  {ok,name,restarting:true} → 触发优雅关闭;Console 对非用户发起的退出会自动拉起,
  重启后即打开新赛事(无需改 Console)。本来就是当前赛事则 restarting:false 不重启
- 管理端「导出」页新增赛事项目列表与「切换为当前赛事」;导入成功后提示「已导入为 X,
  但还不是当前赛事」并给「立即切换并重启服务端」;切换后轮询 /api/health 等它回来再刷新
- 文档:使用手册(导入≠切换、生效四步)、常见问题(导入后看不到数据)

自检:服务端 12 包全绿;前端 vue-tsc + build、node --test 66/66;console 14 文件通过。
2026-09-10 09:47:06 +08:00

205 lines
6.3 KiB
TypeScript

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<typeof createApiClient>
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<string, unknown>
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<void>
}
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)
})