feat: 参赛单位「彻底删除」= 假删除(界面隐藏,数据与榜单全保留)
与运动员的真物理删除是**两套相反口径**,有意如此(两个按钮同名,代码与
文档里都做了显著区分):
- 运动员 `?purge=1`:真删人员+报名+成绩+流水并重算积分(上一版 `9d4ee09`)
- 参赛单位 `?purge=1`:只置 `teams.deleted_at` + `active = 0`,让单位从列表里
消失;报名、成绩、积分流水、人工调整、纪录、名下运动员全部原样,
**不触发重算、积分榜不变**(单位下挂着几十号人,真删代价太大)
实现:
- 迁移 0018 `ALTER TABLE teams ADD COLUMN deleted_at TEXT`
- `listTeams` 默认排除已隐藏单位;`?includeDeleted=1` 仅 admin;
`GET /api/teams/{id}` 对已隐藏单位:admin 200 / 非 admin 404
- 审计 `team.purge`(detail 标明是隐藏);成功走 `writeVoid`(无统计对象)
- 报名导入按 `team_code` 命中已隐藏单位时复活(清 deleted_at、置 active=1)
- 故意不过滤的地方都加了注释:榜单补单位名、写路径与审计、导入按 code 查、
赛事包导出、大屏快照、按 team_id JOIN 取历史单位名——过滤会让历史成绩的
单位名变空白或让包的引用悬挂
管理端「参赛单位」行加「彻底删除」按钮 + 危险确认框(如实写明"从列表移除、
数据不受影响、积分榜不会变化"),失败原文展示、404 不降级。
顺带修一处界面文案与行为不符:单位页原写「停用后新报名不再可选该单位」,
但报名选择器其实仍会列出已停用单位供手动勾选(只有「全选」跳过它们),
已按真实行为改写(两处)。
测试:服务端 12 包全绿(新增 team_purge_test.go 与 0018 迁移用例);
前端 172 例全过(新增 team-purge-api.test.ts 6 例)。docs/ 与 devdocs/ 同步。
This commit is contained in:
1 parent
9d4ee0946c
commit
5ae49b001c
18 files changed
+767
-13
No files matched your search
@@ -0,0 +1,132 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
/**
|
||||
* 参赛单位「彻底删除」的前端契约:方法 / 路径 + `?purge=1`,以及失败时服务端那句话的传递。
|
||||
*
|
||||
* 口径与运动员不同:这是假删除(单位从列表里消失,报名、成绩、积分流水一行未动、积分榜不变),
|
||||
* 所以响应是空响应、没有统计对象——这里一并钉住,免得有人照运动员那套抄。
|
||||
*/
|
||||
|
||||
interface Captured {
|
||||
url?: string
|
||||
method?: string
|
||||
headers?: Record<string, string>
|
||||
}
|
||||
|
||||
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<string, string>,
|
||||
}
|
||||
return reply()
|
||||
}) as typeof fetch
|
||||
|
||||
function respondWith(response: () => Response): void {
|
||||
lastRequest = {}
|
||||
reply = response
|
||||
}
|
||||
|
||||
const { ApiError, adminApi } = await import('../api/client')
|
||||
const { baseDataApi, baseDataFallback } = await import('../components/baseData/baseDataApi')
|
||||
|
||||
test('彻底删除走 DELETE /api/teams/{id}?purge=1', async () => {
|
||||
respondWith(() => jsonResponse({ ok: true, data: null }))
|
||||
|
||||
await adminApi.purgeTeam(3)
|
||||
|
||||
assert.equal(lastRequest.url, '/api/teams/3?purge=1')
|
||||
assert.equal(lastRequest.method, 'DELETE')
|
||||
})
|
||||
|
||||
// 服务端中间件要求已登录的写请求带 X-CSRF-Token,漏掉就是 403,删不掉还看不出原因。
|
||||
test('彻底删除带上 CSRF 双提交 Cookie 的请求头', async () => {
|
||||
respondWith(() => jsonResponse({ ok: true, data: null }))
|
||||
const globalWithDocument = globalThis as { document?: unknown }
|
||||
globalWithDocument.document = { cookie: 'csrf_token=token-123' }
|
||||
try {
|
||||
await adminApi.purgeTeam(3)
|
||||
} finally {
|
||||
delete globalWithDocument.document
|
||||
}
|
||||
|
||||
assert.equal(lastRequest.headers?.['X-CSRF-Token'], 'token-123')
|
||||
})
|
||||
|
||||
test('失败时不吞错误:服务端那句原文原样带出', async () => {
|
||||
respondWith(() =>
|
||||
jsonResponse({ ok: false, error: { code: 'INTERNAL', message: '服务端异常' } }, 500),
|
||||
)
|
||||
|
||||
await assert.rejects(
|
||||
() => adminApi.purgeTeam(3),
|
||||
(error: unknown) => {
|
||||
assert.ok(error instanceof ApiError)
|
||||
assert.equal(error.status, 500)
|
||||
assert.equal(error.code, 'INTERNAL')
|
||||
assert.equal(error.message, '服务端异常')
|
||||
return true
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
// 404 是「单位已经被别人删掉」这种真实回答,不是「接口未实现」:
|
||||
// 一旦降级到示例数据,页面会把「根本没删掉」提示成「已彻底删除」。
|
||||
test('purge 收到 404 时不降级,错误原样抛给页面', async () => {
|
||||
baseDataFallback.value = false
|
||||
respondWith(() =>
|
||||
jsonResponse({ ok: false, error: { code: 'VALIDATION_FAILED', message: '参赛单位不存在' } }, 404),
|
||||
)
|
||||
|
||||
await assert.rejects(
|
||||
() => baseDataApi.teams.purge(3),
|
||||
(error: unknown) => {
|
||||
assert.ok(error instanceof ApiError)
|
||||
assert.equal(error.status, 404)
|
||||
assert.equal(error.message, '参赛单位不存在')
|
||||
return true
|
||||
},
|
||||
)
|
||||
assert.equal(baseDataFallback.value, false, '不应切到示例数据')
|
||||
})
|
||||
|
||||
// 对照组:同组其它接口的 404 仍按「接口未实现」降级到示例数据,只有 purge 不降级。
|
||||
// (baseDataApi.teams 没暴露软删除 remove——页面上的「停用」走 update,所以拿 list 作对照。)
|
||||
test('teams 其它接口的 404 仍降级到示例数据(既有行为未受影响)', async () => {
|
||||
baseDataFallback.value = false
|
||||
respondWith(() =>
|
||||
jsonResponse({ ok: false, error: { code: 'VALIDATION_FAILED', message: '接口不存在' } }, 404),
|
||||
)
|
||||
|
||||
const teams = await baseDataApi.teams.list()
|
||||
|
||||
assert.ok(teams.length > 0, '应拿到示例数据的单位')
|
||||
assert.equal(baseDataFallback.value, true)
|
||||
})
|
||||
|
||||
// 后端整体连不上时仍降级到示例数据;假删除没有统计对象,示例数据也不能凭空造一个出来。
|
||||
// 用 id=2 避开上一条已经删掉的 1(示例数据是模块级单例)。
|
||||
test('后端不可用降级示例数据时,假删除照常生效且不返回统计', async () => {
|
||||
baseDataFallback.value = false
|
||||
respondWith(() => {
|
||||
throw new TypeError('fetch failed')
|
||||
})
|
||||
|
||||
const result = await baseDataApi.teams.purge(2)
|
||||
|
||||
assert.equal(result, undefined)
|
||||
assert.equal(baseDataFallback.value, true)
|
||||
})
|
||||
Reference in new issue
Block a user