## 运动员「彻底删除」= 真删除
`DELETE /api/athletes/{id}?purge=1`(不带参数仍是「停用」软删除)。在一个事务里:
删积分流水(含已作废历史流水)→ 删破纪录登记 → 删成绩 → 删报名 → 删人员 →
对受影响的 (小项, 组别) 调 recomputeLedgerTx + applyComputedRanks 重算流水与名次 →
bumpEvent/bumpStandings 推大屏 → 审计 athlete.purge(含删除规模)。
响应带 {deletedEntries, deletedResults, deletedLedger, deletedBreaks, recomputedEvents}。
必须先删 results 再删 entries(results.entry_id 无 ON DELETE);流水按 athlete_id / entry_id /
result_id 三种引用一把清,漏一种就留悬挂。
管理端「运动员」行加「彻底删除」按钮 + 危险确认框(如实写明报名与成绩一并删除、
相关小项排名与积分会重算),成功 toast 报出删除规模。
## 报名表 status 列容错
新增 entry_status.go 的 normalizeImportStatus:大小写不敏感(DNS/Registered 都认),
认「正常/退赛/弃赛/请假/取消资格/犯规/未出场/未到/缺席」等中文别名,留空即 registered,
认不出则给中文行级错误并跳过该行。此前原样入库,撞 entries.status 的 CHECK 约束后
抛原始 SQLite 错误,填表人看不懂。
## 修 isUniqueViolation 把 CHECK 失败误报成「已存在」
SQLite 的 CHECK / NOT NULL 失败也带 "constraint failed",旧匹配把「小项 status 取值非法」
报成 409「小项编码已存在」,现场会去找一个根本不存在的重名编码。收紧为只认
"unique constraint failed"。实测:重复编码仍 409;非法 status 不再误报。
## 安装包平铺《报名名单导入规范》
deploy/prepare-extra.mjs 改清单式,部署包根目录除 使用说明.txt 外新增
报名名单导入规范.txt。
测试:服务端 12 包全绿(新增 athlete_purge_test.go、entry_status_test.go);
前端 vue-tsc 通过、166 例全过;Console 语法检查 17 文件。
另在真二进制 + 真 SQLite 上跑通破坏性路径:删人后本人痕迹全为 0、他人不受影响、
榜单按新排序重算、foreign_key_check 无悬挂。docs/ 与 devdocs/ 同步更新。
1269 lines
48 KiB
Go
1269 lines
48 KiB
Go
package httpapi_test
|
||
|
||
import (
|
||
"bytes"
|
||
"database/sql"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"mime/multipart"
|
||
"net/http"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"testing"
|
||
|
||
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
|
||
"git.yeij.top/AskaEth/Scoreboard/server/internal/dto"
|
||
"git.yeij.top/AskaEth/Scoreboard/server/internal/httpapi"
|
||
)
|
||
|
||
// importHeader 与 ImportSection 模板 / 服务端 importColumns 保持一致。
|
||
const importHeader = "event_code,category,team_code,athlete_name,id_card,gender,age,bib,seed,status,member_names"
|
||
|
||
// importHeaderNoAge 是升级前的 10 列表头,用来验证缺 age 列不报错也不误报冲突。
|
||
const importHeaderNoAge = "event_code,category,team_code,athlete_name,id_card,gender,bib,seed,status,member_names"
|
||
|
||
// postImport 以 multipart 上传报名表;decisions 非空时作为 resolve 接口的 decisions 字段。
|
||
func (e *testEnv) postImport(t *testing.T, client *http.Client, path, content, decisions string) (int, envelope) {
|
||
t.Helper()
|
||
|
||
var buf bytes.Buffer
|
||
mw := multipart.NewWriter(&buf)
|
||
fw, err := mw.CreateFormFile("file", "entries.csv")
|
||
if err != nil {
|
||
t.Fatalf("构造 multipart: %v", err)
|
||
}
|
||
if _, err := fw.Write([]byte(content)); err != nil {
|
||
t.Fatalf("写入报名表: %v", err)
|
||
}
|
||
if decisions != "" {
|
||
if err := mw.WriteField("decisions", decisions); err != nil {
|
||
t.Fatalf("写入 decisions: %v", err)
|
||
}
|
||
}
|
||
if err := mw.Close(); err != nil {
|
||
t.Fatalf("关闭 multipart: %v", err)
|
||
}
|
||
|
||
req, err := http.NewRequest(http.MethodPost, e.server.URL+path, &buf)
|
||
if err != nil {
|
||
t.Fatalf("构造请求: %v", err)
|
||
}
|
||
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||
req.Header.Set("X-CSRF-Token", e.csrfToken(client))
|
||
|
||
resp, err := client.Do(req)
|
||
if err != nil {
|
||
t.Fatalf("上传报名表: %v", err)
|
||
}
|
||
defer func() { _ = resp.Body.Close() }()
|
||
|
||
var env envelope
|
||
raw, _ := io.ReadAll(resp.Body)
|
||
if len(bytes.TrimSpace(raw)) > 0 {
|
||
if err := json.Unmarshal(raw, &env); err != nil {
|
||
t.Fatalf("解析响应: %v (%s)", err, raw)
|
||
}
|
||
}
|
||
return resp.StatusCode, env
|
||
}
|
||
|
||
// recorderWithSeed 建好种子数据并返回已登录的 recorder 客户端。
|
||
func recorderWithSeed(t *testing.T) (*testEnv, seedIDs, *http.Client) {
|
||
t.Helper()
|
||
env := newTestEnv(t)
|
||
ids := env.seed(t)
|
||
env.createUser("recorder", "recorder-pass", "recorder")
|
||
client, _ := env.client()
|
||
env.login(client, "recorder", "recorder-pass")
|
||
return env, ids, client
|
||
}
|
||
|
||
// 登录审计必须落在真实用户上:请求 ctx 里仍是 guest,靠 AuditEntry.ActorID 补(P1-13)。
|
||
func TestLoginAuditRecordsUserID(t *testing.T) {
|
||
env := newTestEnv(t)
|
||
userID := env.createUser("recorder", "recorder-pass", "recorder")
|
||
client, _ := env.client()
|
||
env.login(client, "recorder", "recorder-pass")
|
||
|
||
var auditUser sql.NullInt64
|
||
if err := env.ev.QueryRow(
|
||
`SELECT user_id FROM audit_logs WHERE action = 'auth.login' ORDER BY id DESC LIMIT 1`).Scan(&auditUser); err != nil {
|
||
t.Fatalf("查询登录审计: %v", err)
|
||
}
|
||
if !auditUser.Valid || auditUser.Int64 != userID {
|
||
t.Fatalf("登录审计 user_id 应为 %d,得到 %+v", userID, auditUser)
|
||
}
|
||
|
||
// 按 userId 过滤必须能查到这条登录记录。
|
||
env.createUser("admin", "admin-pass", "admin")
|
||
adminClient, _ := env.client()
|
||
env.login(adminClient, "admin", "admin-pass")
|
||
_, env2 := env.do(adminClient, http.MethodGet, fmt.Sprintf("/api/audit-logs?userId=%d", userID), nil, false)
|
||
page := decodeData[map[string]any](t, env2)
|
||
items, _ := page["items"].([]any)
|
||
found := false
|
||
for _, raw := range items {
|
||
item, ok := raw.(map[string]any)
|
||
if !ok {
|
||
continue
|
||
}
|
||
if item["action"] == "auth.login" && item["userId"] == float64(userID) {
|
||
found = true
|
||
}
|
||
}
|
||
if !found {
|
||
t.Fatalf("按 userId 过滤应能查到登录审计,得到 %+v", items)
|
||
}
|
||
}
|
||
|
||
// 跨站登录(login CSRF)必须被同源校验挡住(P2-6)。
|
||
func TestLoginRejectsCrossOrigin(t *testing.T) {
|
||
env := newTestEnv(t)
|
||
env.createUser("recorder", "recorder-pass", "recorder")
|
||
client, _ := env.client()
|
||
|
||
body := map[string]any{"username": "recorder", "password": "recorder-pass"}
|
||
status, env2 := env.doH(client, http.MethodPost, "/api/auth/login", body, false,
|
||
map[string]string{"Origin": "http://evil.example"})
|
||
if status != http.StatusForbidden || env2.Error == nil || env2.Error.Code != "FORBIDDEN" {
|
||
t.Fatalf("跨站登录应 403 FORBIDDEN,得到 %d %+v", status, env2.Error)
|
||
}
|
||
|
||
// 同源(Origin 与 Host 一致)仍可登录。
|
||
status, env2 = env.doH(client, http.MethodPost, "/api/auth/login", body, false,
|
||
map[string]string{"Origin": env.server.URL})
|
||
if status != http.StatusOK || !env2.OK {
|
||
t.Fatalf("同源登录应 200,得到 %d %+v", status, env2.Error)
|
||
}
|
||
}
|
||
|
||
// 超过 bcrypt 72 字节上限的新密码必须 400,而不是被静默截断(P2-6)。
|
||
func TestChangePasswordRejectsTooLong(t *testing.T) {
|
||
env := newTestEnv(t)
|
||
env.createUser("recorder", "recorder-pass", "recorder")
|
||
client, _ := env.client()
|
||
env.login(client, "recorder", "recorder-pass")
|
||
|
||
tooLong := strings.Repeat("a", auth.MaxPasswordBytes+1)
|
||
status, env2 := env.do(client, http.MethodPost, "/api/auth/password", map[string]any{
|
||
"currentPassword": "recorder-pass",
|
||
"newPassword": tooLong,
|
||
}, true)
|
||
if status != http.StatusBadRequest {
|
||
t.Fatalf("超长新密码应 400,得到 %d %+v", status, env2.Error)
|
||
}
|
||
}
|
||
|
||
// 导入遇到新证件号 → 新建人员(name/gender/id_card/team)并建报名。
|
||
func TestImportCreatesAthleteWithIDCard(t *testing.T) {
|
||
env, ids, client := recorderWithSeed(t)
|
||
|
||
body := importHeader + "\nM100,男子,A,张三,110101199001011234,男,,,,registered,\n"
|
||
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
|
||
if status != http.StatusOK || !env2.OK {
|
||
t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error)
|
||
}
|
||
result := decodeData[map[string]any](t, env2)
|
||
if result["created"] != float64(1) || result["skipped"] != float64(0) {
|
||
t.Fatalf("应新建 1 条报名且无跳过: %+v", result)
|
||
}
|
||
if conflicts, _ := result["conflicts"].([]any); len(conflicts) != 0 {
|
||
t.Fatalf("不应有冲突: %+v", conflicts)
|
||
}
|
||
|
||
var (
|
||
name, gender string
|
||
teamID int64
|
||
)
|
||
if err := env.ev.QueryRow(
|
||
`SELECT name, gender, team_id FROM athletes WHERE id_card = ?`, "110101199001011234").
|
||
Scan(&name, &gender, &teamID); err != nil {
|
||
t.Fatalf("查询新建人员: %v", err)
|
||
}
|
||
if name != "张三" || gender != "男" || teamID != ids.teamA {
|
||
t.Fatalf("新建人员字段不对: name=%s gender=%s team=%d", name, gender, teamID)
|
||
}
|
||
|
||
var entries int
|
||
if err := env.ev.QueryRow(
|
||
`SELECT COUNT(*) FROM entries WHERE event_id = ? AND athlete_id = (SELECT id FROM athletes WHERE id_card = ?)`,
|
||
ids.event, "110101199001011234").Scan(&entries); err != nil {
|
||
t.Fatalf("统计报名: %v", err)
|
||
}
|
||
if entries != 1 {
|
||
t.Fatalf("应建 1 条报名,得到 %d", entries)
|
||
}
|
||
}
|
||
|
||
// 姓名 + 单位命中且库中证件号为空 → 回填证件号,不新建人员。
|
||
func TestImportBackfillsIDCardForExistingAthlete(t *testing.T) {
|
||
env, ids, client := recorderWithSeed(t)
|
||
|
||
body := importHeader + "\nM100,男子,A,张伟,110101199001011234,男,,,,registered,\n"
|
||
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
|
||
if status != http.StatusOK || !env2.OK {
|
||
t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error)
|
||
}
|
||
result := decodeData[map[string]any](t, env2)
|
||
if result["updated"] != float64(1) || result["created"] != float64(0) {
|
||
t.Fatalf("回填应更新已有报名: %+v", result)
|
||
}
|
||
if conflicts, _ := result["conflicts"].([]any); len(conflicts) != 0 {
|
||
t.Fatalf("回填不算冲突: %+v", conflicts)
|
||
}
|
||
|
||
var idCard sql.NullString
|
||
if err := env.ev.QueryRow(`SELECT id_card FROM athletes WHERE id = ?`, ids.athleteA).Scan(&idCard); err != nil {
|
||
t.Fatalf("查询人员证件号: %v", err)
|
||
}
|
||
if !idCard.Valid || idCard.String != "110101199001011234" {
|
||
t.Fatalf("应回填证件号,得到 %+v", idCard)
|
||
}
|
||
|
||
var athletes int
|
||
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM athletes`).Scan(&athletes); err != nil {
|
||
t.Fatalf("统计人员: %v", err)
|
||
}
|
||
if athletes != 2 {
|
||
t.Fatalf("回填不应新建人员,得到 %d 个", athletes)
|
||
}
|
||
}
|
||
|
||
// 证件号命中但单位不一致 → 记为冲突,本轮不落库(人员与报名都不动)。
|
||
func TestImportCollectsConflictWithoutWriting(t *testing.T) {
|
||
env, ids, client := recorderWithSeed(t)
|
||
env.exec(t, `UPDATE athletes SET id_card = '110101199001011234' WHERE id = ?`, ids.athleteA)
|
||
|
||
body := importHeader + "\nM100,男子,B,张伟,110101199001011234,男,,,,registered,\n"
|
||
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
|
||
if status != http.StatusOK || !env2.OK {
|
||
t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error)
|
||
}
|
||
result := decodeData[map[string]any](t, env2)
|
||
if result["created"] != float64(0) || result["updated"] != float64(0) || result["skipped"] != float64(0) {
|
||
t.Fatalf("冲突行不应计入新增/更新/跳过: %+v", result)
|
||
}
|
||
|
||
conflicts, _ := result["conflicts"].([]any)
|
||
if len(conflicts) != 1 {
|
||
t.Fatalf("应收集 1 条冲突,得到 %+v", conflicts)
|
||
}
|
||
conflict := conflicts[0].(map[string]any)
|
||
if conflict["row"] != float64(2) || conflict["idCard"] != "110101199001011234" ||
|
||
conflict["athleteId"] != float64(ids.athleteA) {
|
||
t.Fatalf("冲突定位信息不对: %+v", conflict)
|
||
}
|
||
changed, _ := conflict["changedFields"].([]any)
|
||
if len(changed) != 1 || changed[0] != "teamCode" {
|
||
t.Fatalf("应只有 teamCode 变化,得到 %+v", changed)
|
||
}
|
||
existing := conflict["existing"].(map[string]any)
|
||
if existing["name"] != "张伟" || existing["teamCode"] != "A" || existing["gender"] != "男" {
|
||
t.Fatalf("existing 快照不对: %+v", existing)
|
||
}
|
||
incoming := conflict["incoming"].(map[string]any)
|
||
if incoming["name"] != "张伟" || incoming["teamCode"] != "B" {
|
||
t.Fatalf("incoming 快照不对: %+v", incoming)
|
||
}
|
||
|
||
var teamID int64
|
||
if err := env.ev.QueryRow(`SELECT team_id FROM athletes WHERE id = ?`, ids.athleteA).Scan(&teamID); err != nil {
|
||
t.Fatalf("查询人员单位: %v", err)
|
||
}
|
||
if teamID != ids.teamA {
|
||
t.Fatalf("冲突不应改人员单位,得到 %d", teamID)
|
||
}
|
||
var entries int
|
||
if err := env.ev.QueryRow(
|
||
`SELECT COUNT(*) FROM entries WHERE athlete_id = ? AND team_id = ?`, ids.athleteA, ids.teamB).Scan(&entries); err != nil {
|
||
t.Fatalf("统计报名: %v", err)
|
||
}
|
||
if entries != 0 {
|
||
t.Fatalf("冲突行的报名不应落库,得到 %d 条", entries)
|
||
}
|
||
}
|
||
|
||
// 同一文件里证件号重复出现 → 该行报错并跳过。
|
||
func TestImportRejectsDuplicateIDCardInFile(t *testing.T) {
|
||
env, _, client := recorderWithSeed(t)
|
||
|
||
body := importHeader + "\n" +
|
||
"M100,男子,A,张三,110101199001011234,男,,,,registered,\n" +
|
||
"M100,男子,A,张三,110101199001011234,男,,,,registered,\n"
|
||
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
|
||
if status != http.StatusOK || !env2.OK {
|
||
t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error)
|
||
}
|
||
result := decodeData[map[string]any](t, env2)
|
||
if result["created"] != float64(1) || result["skipped"] != float64(1) {
|
||
t.Fatalf("首行应新建、次行应跳过: %+v", result)
|
||
}
|
||
errs, _ := result["errors"].([]any)
|
||
if len(errs) != 1 || !strings.Contains(errs[0].(string), "重复") {
|
||
t.Fatalf("应报证件号重复,得到 %+v", errs)
|
||
}
|
||
|
||
var athletes int
|
||
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM athletes WHERE id_card = ?`, "110101199001011234").Scan(&athletes); err != nil {
|
||
t.Fatalf("统计人员: %v", err)
|
||
}
|
||
if athletes != 1 {
|
||
t.Fatalf("重复证件号只应建 1 个人员,得到 %d", athletes)
|
||
}
|
||
}
|
||
|
||
// 有 athlete_name 的行必须给 id_card 或 bib,两个都缺才报错。
|
||
func TestImportRequiresIDCardOrBibWhenNameGiven(t *testing.T) {
|
||
env, _, client := recorderWithSeed(t)
|
||
|
||
body := importHeader + "\nM100,男子,A,张三,,男,,,,registered,\n"
|
||
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
|
||
if status != http.StatusOK || !env2.OK {
|
||
t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error)
|
||
}
|
||
result := decodeData[map[string]any](t, env2)
|
||
if result["created"] != float64(0) || result["skipped"] != float64(1) {
|
||
t.Fatalf("缺证件号与号码布应跳过: %+v", result)
|
||
}
|
||
errs, _ := result["errors"].([]any)
|
||
if len(errs) != 1 || !strings.Contains(errs[0].(string), "id_card") {
|
||
t.Fatalf("应报缺少 id_card 或 bib,得到 %+v", errs)
|
||
}
|
||
|
||
var athletes int
|
||
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM athletes`).Scan(&athletes); err != nil {
|
||
t.Fatalf("统计人员: %v", err)
|
||
}
|
||
if athletes != 2 {
|
||
t.Fatalf("该行不应建人员,得到 %d 个", athletes)
|
||
}
|
||
}
|
||
|
||
// id_card / gender / age 是可选列:表头缺失不报错(团体行不涉及人员)。
|
||
func TestImportToleratesMissingOptionalColumns(t *testing.T) {
|
||
env, _, client := recorderWithSeed(t)
|
||
|
||
body := "event_code,category,team_code,athlete_name,bib,seed,status,member_names\n" +
|
||
"M100,男子,A,,,,,registered,张三/李四\n"
|
||
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
|
||
if status != http.StatusOK || !env2.OK {
|
||
t.Fatalf("缺可选列应 200,得到 %d %+v", status, env2.Error)
|
||
}
|
||
result := decodeData[map[string]any](t, env2)
|
||
if result["created"] != float64(1) || result["skipped"] != float64(0) {
|
||
t.Fatalf("团体行应正常导入: %+v", result)
|
||
}
|
||
}
|
||
|
||
// keep-existing:保留库中人员字段,报名跟随库里已有的单位。
|
||
func TestImportResolveKeepExisting(t *testing.T) {
|
||
env, ids, client := recorderWithSeed(t)
|
||
env.exec(t, `UPDATE athletes SET id_card = '110101199001011234' WHERE id = ?`, ids.athleteA)
|
||
|
||
body := importHeader + "\nM100,男子,B,张伟,110101199001011234,男,,,,registered,\n"
|
||
|
||
// 未给决策时,冲突原样返回。
|
||
status, env2 := env.postImport(t, client, "/api/entries/import/resolve", body, "[]")
|
||
if status != http.StatusOK || !env2.OK {
|
||
t.Fatalf("决策接口应 200,得到 %d %+v", status, env2.Error)
|
||
}
|
||
if conflicts, _ := decodeData[map[string]any](t, env2)["conflicts"].([]any); len(conflicts) != 1 {
|
||
t.Fatalf("未决策的冲突应继续返回: %+v", conflicts)
|
||
}
|
||
|
||
decisions := `[{"idCard":"110101199001011234","action":"keep-existing"}]`
|
||
status, env2 = env.postImport(t, client, "/api/entries/import/resolve", body, decisions)
|
||
if status != http.StatusOK || !env2.OK {
|
||
t.Fatalf("决策应 200,得到 %d %+v", status, env2.Error)
|
||
}
|
||
result := decodeData[map[string]any](t, env2)
|
||
if result["updated"] != float64(1) || result["created"] != float64(0) {
|
||
t.Fatalf("keep-existing 应更新库里已有报名: %+v", result)
|
||
}
|
||
if conflicts, _ := result["conflicts"].([]any); len(conflicts) != 0 {
|
||
t.Fatalf("已决策的冲突不应再返回: %+v", conflicts)
|
||
}
|
||
|
||
var teamID int64
|
||
if err := env.ev.QueryRow(`SELECT team_id FROM athletes WHERE id = ?`, ids.athleteA).Scan(&teamID); err != nil {
|
||
t.Fatalf("查询人员单位: %v", err)
|
||
}
|
||
if teamID != ids.teamA {
|
||
t.Fatalf("keep-existing 应保留库中单位,得到 %d", teamID)
|
||
}
|
||
var entries int
|
||
if err := env.ev.QueryRow(
|
||
`SELECT COUNT(*) FROM entries WHERE athlete_id = ? AND team_id = ?`, ids.athleteA, ids.teamB).Scan(&entries); err != nil {
|
||
t.Fatalf("统计报名: %v", err)
|
||
}
|
||
if entries != 0 {
|
||
t.Fatalf("keep-existing 不应在表里的单位下建报名,得到 %d 条", entries)
|
||
}
|
||
|
||
var audit int
|
||
if err := env.ev.QueryRow(
|
||
`SELECT COUNT(*) FROM audit_logs WHERE action = 'entries.import.resolve'`).Scan(&audit); err != nil {
|
||
t.Fatalf("统计审计: %v", err)
|
||
}
|
||
if audit == 0 {
|
||
t.Fatalf("决策应写审计")
|
||
}
|
||
}
|
||
|
||
// keep-new:用表里的字段更新人员,再按表里的单位建报名。
|
||
func TestImportResolveKeepNew(t *testing.T) {
|
||
env, ids, client := recorderWithSeed(t)
|
||
env.exec(t, `UPDATE athletes SET id_card = '110101199001011234' WHERE id = ?`, ids.athleteA)
|
||
|
||
body := importHeader + "\nM100,男子,B,张伟,110101199001011234,男,,,,registered,\n"
|
||
decisions := `[{"idCard":"110101199001011234","action":"keep-new"}]`
|
||
status, env2 := env.postImport(t, client, "/api/entries/import/resolve", body, decisions)
|
||
if status != http.StatusOK || !env2.OK {
|
||
t.Fatalf("决策应 200,得到 %d %+v", status, env2.Error)
|
||
}
|
||
result := decodeData[map[string]any](t, env2)
|
||
if result["created"] != float64(1) || result["updated"] != float64(0) {
|
||
t.Fatalf("keep-new 应在表里的单位下新建报名: %+v", result)
|
||
}
|
||
if conflicts, _ := result["conflicts"].([]any); len(conflicts) != 0 {
|
||
t.Fatalf("已决策的冲突不应再返回: %+v", conflicts)
|
||
}
|
||
|
||
var teamID int64
|
||
if err := env.ev.QueryRow(`SELECT team_id FROM athletes WHERE id = ?`, ids.athleteA).Scan(&teamID); err != nil {
|
||
t.Fatalf("查询人员单位: %v", err)
|
||
}
|
||
if teamID != ids.teamB {
|
||
t.Fatalf("keep-new 应用表里的单位更新人员,得到 %d", teamID)
|
||
}
|
||
var entries int
|
||
if err := env.ev.QueryRow(
|
||
`SELECT COUNT(*) FROM entries WHERE athlete_id = ? AND team_id = ?`, ids.athleteA, ids.teamB).Scan(&entries); err != nil {
|
||
t.Fatalf("统计报名: %v", err)
|
||
}
|
||
if entries != 1 {
|
||
t.Fatalf("keep-new 应在表里的单位下建 1 条报名,得到 %d 条", entries)
|
||
}
|
||
}
|
||
|
||
// decisions 非法时返回 400。
|
||
func TestImportResolveRejectsBadDecisions(t *testing.T) {
|
||
env, _, client := recorderWithSeed(t)
|
||
body := importHeader + "\nM100,男子,A,张三,110101199001011234,男,,,,registered,\n"
|
||
|
||
for _, decisions := range []string{"不是 JSON", `[{"idCard":"1","action":"keep-both"}]`} {
|
||
status, env2 := env.postImport(t, client, "/api/entries/import/resolve", body, decisions)
|
||
if status != http.StatusBadRequest || env2.Error == nil {
|
||
t.Fatalf("非法 decisions 应 400,得到 %d %+v", status, env2.Error)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 只给号码布(没有证件号)也能新建人员,人员与报名都写入号码布。
|
||
func TestImportCreatesAthleteWithBibOnly(t *testing.T) {
|
||
env, _, client := recorderWithSeed(t)
|
||
|
||
body := importHeader + "\nM100,男子,A,赵六,,男,,B1001,,registered,\n"
|
||
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
|
||
if status != http.StatusOK || !env2.OK {
|
||
t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error)
|
||
}
|
||
result := decodeData[map[string]any](t, env2)
|
||
if result["created"] != float64(1) || result["skipped"] != float64(0) {
|
||
t.Fatalf("仅给号码布应新建 1 条报名: %+v", result)
|
||
}
|
||
|
||
var (
|
||
idCard sql.NullString
|
||
bib string
|
||
)
|
||
if err := env.ev.QueryRow(`SELECT id_card, bib FROM athletes WHERE name = '赵六'`).Scan(&idCard, &bib); err != nil {
|
||
t.Fatalf("查询新建人员: %v", err)
|
||
}
|
||
if idCard.Valid || bib != "B1001" {
|
||
t.Fatalf("人员应只有号码布: id_card=%+v bib=%s", idCard, bib)
|
||
}
|
||
|
||
var entryBib sql.NullString
|
||
if err := env.ev.QueryRow(
|
||
`SELECT bib FROM entries WHERE athlete_id = (SELECT id FROM athletes WHERE bib = 'B1001')`).Scan(&entryBib); err != nil {
|
||
t.Fatalf("查询报名号码布: %v", err)
|
||
}
|
||
if !entryBib.Valid || entryBib.String != "B1001" {
|
||
t.Fatalf("报名也应写号码布: %+v", entryBib)
|
||
}
|
||
}
|
||
|
||
// 号码布精确命中已有人员 → 视为同一人,只更新报名。
|
||
func TestImportMatchesAthleteByBib(t *testing.T) {
|
||
env, ids, client := recorderWithSeed(t)
|
||
env.exec(t, `UPDATE athletes SET bib = 'A100' WHERE id = ?`, ids.athleteA)
|
||
|
||
body := importHeader + "\nM100,男子,A,张伟,,男,,A100,,registered,\n"
|
||
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
|
||
if status != http.StatusOK || !env2.OK {
|
||
t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error)
|
||
}
|
||
result := decodeData[map[string]any](t, env2)
|
||
if result["updated"] != float64(1) || result["created"] != float64(0) {
|
||
t.Fatalf("号码布命中应更新已有报名: %+v", result)
|
||
}
|
||
if conflicts, _ := result["conflicts"].([]any); len(conflicts) != 0 {
|
||
t.Fatalf("号码布命中不算冲突: %+v", conflicts)
|
||
}
|
||
|
||
var athletes int
|
||
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM athletes`).Scan(&athletes); err != nil {
|
||
t.Fatalf("统计人员: %v", err)
|
||
}
|
||
if athletes != 2 {
|
||
t.Fatalf("号码布命中不应新建人员,得到 %d 个", athletes)
|
||
}
|
||
}
|
||
|
||
// 姓名 + 单位命中且库中证件号为空 → 回填号码布,不新建人员。
|
||
func TestImportBackfillsBibForExistingAthlete(t *testing.T) {
|
||
env, ids, client := recorderWithSeed(t)
|
||
|
||
body := importHeader + "\nM100,男子,A,张伟,,男,,A100,,registered,\n"
|
||
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
|
||
if status != http.StatusOK || !env2.OK {
|
||
t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error)
|
||
}
|
||
result := decodeData[map[string]any](t, env2)
|
||
if result["updated"] != float64(1) || result["created"] != float64(0) {
|
||
t.Fatalf("回填应更新已有报名: %+v", result)
|
||
}
|
||
if conflicts, _ := result["conflicts"].([]any); len(conflicts) != 0 {
|
||
t.Fatalf("回填不算冲突: %+v", conflicts)
|
||
}
|
||
|
||
var bib sql.NullString
|
||
if err := env.ev.QueryRow(`SELECT bib FROM athletes WHERE id = ?`, ids.athleteA).Scan(&bib); err != nil {
|
||
t.Fatalf("查询人员号码布: %v", err)
|
||
}
|
||
if !bib.Valid || bib.String != "A100" {
|
||
t.Fatalf("应回填号码布,得到 %+v", bib)
|
||
}
|
||
|
||
var athletes int
|
||
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM athletes`).Scan(&athletes); err != nil {
|
||
t.Fatalf("统计人员: %v", err)
|
||
}
|
||
if athletes != 2 {
|
||
t.Fatalf("回填不应新建人员,得到 %d 个", athletes)
|
||
}
|
||
}
|
||
|
||
// 号码布命中时,表里多给的证件号也回填进库中为空的人员。
|
||
func TestImportBackfillsIDCardWhenMatchedByBib(t *testing.T) {
|
||
env, ids, client := recorderWithSeed(t)
|
||
env.exec(t, `UPDATE athletes SET bib = 'A100' WHERE id = ?`, ids.athleteA)
|
||
|
||
body := importHeader + "\nM100,男子,A,张伟,110101199001011234,男,,A100,,registered,\n"
|
||
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
|
||
if status != http.StatusOK || !env2.OK {
|
||
t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error)
|
||
}
|
||
result := decodeData[map[string]any](t, env2)
|
||
if result["updated"] != float64(1) || result["created"] != float64(0) {
|
||
t.Fatalf("号码布命中应更新已有报名: %+v", result)
|
||
}
|
||
|
||
var idCard sql.NullString
|
||
if err := env.ev.QueryRow(`SELECT id_card FROM athletes WHERE id = ?`, ids.athleteA).Scan(&idCard); err != nil {
|
||
t.Fatalf("查询人员证件号: %v", err)
|
||
}
|
||
if !idCard.Valid || idCard.String != "110101199001011234" {
|
||
t.Fatalf("应回填证件号,得到 %+v", idCard)
|
||
}
|
||
|
||
var athletes int
|
||
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM athletes`).Scan(&athletes); err != nil {
|
||
t.Fatalf("统计人员: %v", err)
|
||
}
|
||
if athletes != 2 {
|
||
t.Fatalf("回填不应新建人员,得到 %d 个", athletes)
|
||
}
|
||
}
|
||
|
||
// 同一文件里号码布重复出现 → 该行报错并跳过。
|
||
func TestImportRejectsDuplicateBibInFile(t *testing.T) {
|
||
env, _, client := recorderWithSeed(t)
|
||
|
||
body := importHeader + "\n" +
|
||
"M100,男子,A,张三,,男,,B2001,,registered,\n" +
|
||
"M100,男子,A,李四,,男,,B2001,,registered,\n"
|
||
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
|
||
if status != http.StatusOK || !env2.OK {
|
||
t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error)
|
||
}
|
||
result := decodeData[map[string]any](t, env2)
|
||
if result["created"] != float64(1) || result["skipped"] != float64(1) {
|
||
t.Fatalf("首行应新建、次行应跳过: %+v", result)
|
||
}
|
||
errs, _ := result["errors"].([]any)
|
||
if len(errs) != 1 || !strings.Contains(errs[0].(string), "号码布") {
|
||
t.Fatalf("应报号码布重复,得到 %+v", errs)
|
||
}
|
||
|
||
var athletes int
|
||
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM athletes WHERE bib = 'B2001'`).Scan(&athletes); err != nil {
|
||
t.Fatalf("统计人员: %v", err)
|
||
}
|
||
if athletes != 1 {
|
||
t.Fatalf("重复号码布只应建 1 个人员,得到 %d", athletes)
|
||
}
|
||
}
|
||
|
||
// 证件号命中但号码布与库中不同 → 冲突,快照与差异字段都带 bib,本轮不落库。
|
||
func TestImportConflictIncludesBibDiff(t *testing.T) {
|
||
env, ids, client := recorderWithSeed(t)
|
||
env.exec(t, `UPDATE athletes SET id_card = '110101199001011234', bib = 'A100' WHERE id = ?`, ids.athleteA)
|
||
|
||
body := importHeader + "\nM100,男子,A,张伟,110101199001011234,男,,A200,,registered,\n"
|
||
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
|
||
if status != http.StatusOK || !env2.OK {
|
||
t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error)
|
||
}
|
||
result := decodeData[map[string]any](t, env2)
|
||
conflicts, _ := result["conflicts"].([]any)
|
||
if len(conflicts) != 1 {
|
||
t.Fatalf("应收集 1 条冲突,得到 %+v", conflicts)
|
||
}
|
||
conflict := conflicts[0].(map[string]any)
|
||
if conflict["bib"] != "A200" {
|
||
t.Fatalf("冲突定位应带表里的号码布: %+v", conflict)
|
||
}
|
||
changed, _ := conflict["changedFields"].([]any)
|
||
if len(changed) != 1 || changed[0] != "bib" {
|
||
t.Fatalf("应只有 bib 变化,得到 %+v", changed)
|
||
}
|
||
existing := conflict["existing"].(map[string]any)
|
||
if existing["bib"] != "A100" {
|
||
t.Fatalf("existing 快照应带库中号码布: %+v", existing)
|
||
}
|
||
incoming := conflict["incoming"].(map[string]any)
|
||
if incoming["bib"] != "A200" {
|
||
t.Fatalf("incoming 快照应带表里号码布: %+v", incoming)
|
||
}
|
||
|
||
var bib string
|
||
if err := env.ev.QueryRow(`SELECT bib FROM athletes WHERE id = ?`, ids.athleteA).Scan(&bib); err != nil {
|
||
t.Fatalf("查询人员号码布: %v", err)
|
||
}
|
||
if bib != "A100" {
|
||
t.Fatalf("冲突不应改库中号码布,得到 %s", bib)
|
||
}
|
||
}
|
||
|
||
// 号码布命中但姓名与库中不一致 → 冲突;决策按号码布为键,keep-new 用表里字段覆盖。
|
||
func TestImportResolveConflictByBib(t *testing.T) {
|
||
env, ids, client := recorderWithSeed(t)
|
||
env.exec(t, `UPDATE athletes SET bib = 'A100' WHERE id = ?`, ids.athleteA)
|
||
|
||
body := importHeader + "\nM100,男子,A,张伟伟,,男,,A100,,registered,\n"
|
||
decisions := `[{"bib":"A100","action":"keep-new"}]`
|
||
status, env2 := env.postImport(t, client, "/api/entries/import/resolve", body, decisions)
|
||
if status != http.StatusOK || !env2.OK {
|
||
t.Fatalf("决策应 200,得到 %d %+v", status, env2.Error)
|
||
}
|
||
result := decodeData[map[string]any](t, env2)
|
||
if result["updated"] != float64(1) || result["created"] != float64(0) {
|
||
t.Fatalf("keep-new 应更新已有报名: %+v", result)
|
||
}
|
||
if conflicts, _ := result["conflicts"].([]any); len(conflicts) != 0 {
|
||
t.Fatalf("已决策的冲突不应再返回: %+v", conflicts)
|
||
}
|
||
|
||
var name string
|
||
if err := env.ev.QueryRow(`SELECT name FROM athletes WHERE id = ?`, ids.athleteA).Scan(&name); err != nil {
|
||
t.Fatalf("查询人员姓名: %v", err)
|
||
}
|
||
if name != "张伟伟" {
|
||
t.Fatalf("keep-new 应用表里的姓名覆盖人员,得到 %s", name)
|
||
}
|
||
}
|
||
|
||
// 表里的 age 列写入新建人员。
|
||
func TestImportCreatesAthleteWithAge(t *testing.T) {
|
||
env, _, client := recorderWithSeed(t)
|
||
|
||
body := importHeader + "\nM100,男子,A,张三,110101199001011234,男,23,,,registered,\n"
|
||
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
|
||
if status != http.StatusOK || !env2.OK {
|
||
t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error)
|
||
}
|
||
result := decodeData[map[string]any](t, env2)
|
||
if result["created"] != float64(1) || result["skipped"] != float64(0) {
|
||
t.Fatalf("应新建 1 条报名且无跳过: %+v", result)
|
||
}
|
||
|
||
var age sql.NullInt64
|
||
if err := env.ev.QueryRow(`SELECT age FROM athletes WHERE id_card = ?`, "110101199001011234").Scan(&age); err != nil {
|
||
t.Fatalf("查询新建人员: %v", err)
|
||
}
|
||
if !age.Valid || age.Int64 != 23 {
|
||
t.Fatalf("应写入年龄 23,得到 %+v", age)
|
||
}
|
||
}
|
||
|
||
// age 非整数或超出 0-120 → 该行报错并跳过,人员与报名都不落库。
|
||
func TestImportRejectsInvalidAge(t *testing.T) {
|
||
env, _, client := recorderWithSeed(t)
|
||
|
||
for _, age := range []string{"abc", "-1", "121", "30.5"} {
|
||
body := importHeader + "\nM100,男子,A,张三,110101199001011234,男," + age + ",,,registered,\n"
|
||
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
|
||
if status != http.StatusOK || !env2.OK {
|
||
t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error)
|
||
}
|
||
result := decodeData[map[string]any](t, env2)
|
||
if result["created"] != float64(0) || result["skipped"] != float64(1) {
|
||
t.Fatalf("age=%s 应跳过该行: %+v", age, result)
|
||
}
|
||
errs, _ := result["errors"].([]any)
|
||
if len(errs) != 1 || !strings.Contains(errs[0].(string), "age") {
|
||
t.Fatalf("age=%s 应报年龄非法,得到 %+v", age, errs)
|
||
}
|
||
}
|
||
|
||
var athletes int
|
||
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM athletes`).Scan(&athletes); err != nil {
|
||
t.Fatalf("统计人员: %v", err)
|
||
}
|
||
if athletes != 2 {
|
||
t.Fatalf("非法行不应建人员,得到 %d 个", athletes)
|
||
}
|
||
}
|
||
|
||
// 姓名 + 单位命中且库中证件号为空 → 一并回填年龄,不新建人员。
|
||
func TestImportBackfillsAgeForExistingAthlete(t *testing.T) {
|
||
env, ids, client := recorderWithSeed(t)
|
||
|
||
body := importHeader + "\nM100,男子,A,张伟,110101199001011234,男,35,,,registered,\n"
|
||
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
|
||
if status != http.StatusOK || !env2.OK {
|
||
t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error)
|
||
}
|
||
result := decodeData[map[string]any](t, env2)
|
||
if result["updated"] != float64(1) || result["created"] != float64(0) {
|
||
t.Fatalf("回填应更新已有报名: %+v", result)
|
||
}
|
||
if conflicts, _ := result["conflicts"].([]any); len(conflicts) != 0 {
|
||
t.Fatalf("回填不算冲突: %+v", conflicts)
|
||
}
|
||
|
||
var age sql.NullInt64
|
||
if err := env.ev.QueryRow(`SELECT age FROM athletes WHERE id = ?`, ids.athleteA).Scan(&age); err != nil {
|
||
t.Fatalf("查询人员年龄: %v", err)
|
||
}
|
||
if !age.Valid || age.Int64 != 35 {
|
||
t.Fatalf("应回填年龄 35,得到 %+v", age)
|
||
}
|
||
|
||
// 回填后年龄两边相同,再导入一次不算冲突。
|
||
status, env2 = env.postImport(t, client, "/api/entries/import", body, "")
|
||
if status != http.StatusOK || !env2.OK {
|
||
t.Fatalf("二次导入应 200,得到 %d %+v", status, env2.Error)
|
||
}
|
||
result = decodeData[map[string]any](t, env2)
|
||
if conflicts, _ := result["conflicts"].([]any); len(conflicts) != 0 {
|
||
t.Fatalf("年龄相同不算冲突: %+v", conflicts)
|
||
}
|
||
if result["updated"] != float64(1) {
|
||
t.Fatalf("二次导入应更新已有报名: %+v", result)
|
||
}
|
||
}
|
||
|
||
// 年龄两边都有且不同 → 冲突,快照与差异字段都带 age,本轮不落库。
|
||
func TestImportConflictIncludesAgeDiff(t *testing.T) {
|
||
env, ids, client := recorderWithSeed(t)
|
||
env.exec(t, `UPDATE athletes SET id_card = '110101199001011234', age = 30 WHERE id = ?`, ids.athleteA)
|
||
|
||
body := importHeader + "\nM100,男子,A,张伟,110101199001011234,男,26,,,registered,\n"
|
||
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
|
||
if status != http.StatusOK || !env2.OK {
|
||
t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error)
|
||
}
|
||
result := decodeData[map[string]any](t, env2)
|
||
conflicts, _ := result["conflicts"].([]any)
|
||
if len(conflicts) != 1 {
|
||
t.Fatalf("年龄不同应收集 1 条冲突,得到 %+v", conflicts)
|
||
}
|
||
conflict := conflicts[0].(map[string]any)
|
||
changed, _ := conflict["changedFields"].([]any)
|
||
if len(changed) != 1 || changed[0] != "age" {
|
||
t.Fatalf("应只有 age 变化,得到 %+v", changed)
|
||
}
|
||
if existing := conflict["existing"].(map[string]any); existing["age"] != float64(30) {
|
||
t.Fatalf("existing 快照应带库中年龄: %+v", existing)
|
||
}
|
||
if incoming := conflict["incoming"].(map[string]any); incoming["age"] != float64(26) {
|
||
t.Fatalf("incoming 快照应带表里年龄: %+v", incoming)
|
||
}
|
||
|
||
var age sql.NullInt64
|
||
if err := env.ev.QueryRow(`SELECT age FROM athletes WHERE id = ?`, ids.athleteA).Scan(&age); err != nil {
|
||
t.Fatalf("查询人员年龄: %v", err)
|
||
}
|
||
if !age.Valid || age.Int64 != 30 {
|
||
t.Fatalf("冲突不应改库中年龄,得到 %+v", age)
|
||
}
|
||
}
|
||
|
||
// 升级前的 10 列表头没有 age 列:不报错,也不能把已有年龄的人误报成冲突。
|
||
func TestImportWithoutAgeColumnKeepsExistingAge(t *testing.T) {
|
||
env, ids, client := recorderWithSeed(t)
|
||
env.exec(t, `UPDATE athletes SET id_card = '110101199001011234', age = 30 WHERE id = ?`, ids.athleteA)
|
||
|
||
body := importHeaderNoAge + "\nM100,男子,A,张伟,110101199001011234,男,,,registered,\n"
|
||
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
|
||
if status != http.StatusOK || !env2.OK {
|
||
t.Fatalf("缺 age 列应 200,得到 %d %+v", status, env2.Error)
|
||
}
|
||
result := decodeData[map[string]any](t, env2)
|
||
if conflicts, _ := result["conflicts"].([]any); len(conflicts) != 0 {
|
||
t.Fatalf("缺 age 列不该误报冲突: %+v", conflicts)
|
||
}
|
||
if result["updated"] != float64(1) {
|
||
t.Fatalf("缺 age 列应正常更新: %+v", result)
|
||
}
|
||
|
||
var age sql.NullInt64
|
||
if err := env.ev.QueryRow(`SELECT age FROM athletes WHERE id = ?`, ids.athleteA).Scan(&age); err != nil {
|
||
t.Fatalf("查询人员年龄: %v", err)
|
||
}
|
||
if !age.Valid || age.Int64 != 30 {
|
||
t.Fatalf("缺 age 列不该清掉库中年龄,得到 %+v", age)
|
||
}
|
||
}
|
||
|
||
// keep-new 只在表里给了年龄时才覆盖,缺列/空值不能清掉库中已有的年龄。
|
||
func TestImportResolveKeepNewAgeOnlyOverwritesWhenGiven(t *testing.T) {
|
||
env, ids, client := recorderWithSeed(t)
|
||
env.exec(t, `UPDATE athletes SET id_card = '110101199001011234', age = 30 WHERE id = ?`, ids.athleteA)
|
||
|
||
// 表里 age 为空:keep-new 覆盖单位与姓名,年龄保持库里的值。
|
||
body := importHeader + "\nM100,男子,B,张伟,110101199001011234,男,,,,registered,\n"
|
||
decisions := `[{"idCard":"110101199001011234","action":"keep-new"}]`
|
||
status, env2 := env.postImport(t, client, "/api/entries/import/resolve", body, decisions)
|
||
if status != http.StatusOK || !env2.OK {
|
||
t.Fatalf("决策应 200,得到 %d %+v", status, env2.Error)
|
||
}
|
||
if conflicts, _ := decodeData[map[string]any](t, env2)["conflicts"].([]any); len(conflicts) != 0 {
|
||
t.Fatalf("已决策的冲突不应再返回: %+v", conflicts)
|
||
}
|
||
|
||
var age sql.NullInt64
|
||
if err := env.ev.QueryRow(`SELECT age FROM athletes WHERE id = ?`, ids.athleteA).Scan(&age); err != nil {
|
||
t.Fatalf("查询人员年龄: %v", err)
|
||
}
|
||
if !age.Valid || age.Int64 != 30 {
|
||
t.Fatalf("表里没给年龄时不该清掉库里年龄,得到 %+v", age)
|
||
}
|
||
|
||
// 表里给了 age:keep-new 覆盖成表里的值。
|
||
body = importHeader + "\nM100,男子,A,张伟,110101199001011234,男,26,,,registered,\n"
|
||
if status, env2 = env.postImport(t, client, "/api/entries/import/resolve", body, decisions); status != http.StatusOK || !env2.OK {
|
||
t.Fatalf("决策应 200,得到 %d %+v", status, env2.Error)
|
||
}
|
||
if err := env.ev.QueryRow(`SELECT age FROM athletes WHERE id = ?`, ids.athleteA).Scan(&age); err != nil {
|
||
t.Fatalf("查询人员年龄: %v", err)
|
||
}
|
||
if !age.Valid || age.Int64 != 26 {
|
||
t.Fatalf("keep-new 应用表里的年龄覆盖,得到 %+v", age)
|
||
}
|
||
}
|
||
|
||
// status 列归一:DNS / 退赛 / 空串 / Registered 都能正常入库,库里存的是四个合法值之一。
|
||
func TestImportNormalizesStatusColumn(t *testing.T) {
|
||
env, _, client := recorderWithSeed(t)
|
||
|
||
cases := []struct{ cell, want string }{
|
||
{"DNS", "dns"},
|
||
{"退赛", "scratched"},
|
||
{"", "registered"},
|
||
{"Registered", "registered"},
|
||
}
|
||
body := importHeader + "\n"
|
||
for i, c := range cases {
|
||
body += fmt.Sprintf("M100,男子,A,选手%d,11010119900101%04d,男,,,,%s,\n", i+1, i+1, c.cell)
|
||
}
|
||
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
|
||
if status != http.StatusOK || !env2.OK {
|
||
t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error)
|
||
}
|
||
result := decodeData[map[string]any](t, env2)
|
||
if result["created"] != float64(len(cases)) || result["skipped"] != float64(0) {
|
||
t.Fatalf("四种写法都应导入成功: %+v", result)
|
||
}
|
||
|
||
for i, c := range cases {
|
||
idCard := fmt.Sprintf("11010119900101%04d", i+1)
|
||
var got string
|
||
if err := env.ev.QueryRow(`SELECT e.status FROM entries e JOIN athletes a ON a.id = e.athlete_id
|
||
WHERE a.id_card = ?`, idCard).Scan(&got); err != nil {
|
||
t.Fatalf("查询 status(%s): %v", c.cell, err)
|
||
}
|
||
if got != c.want {
|
||
t.Errorf("status=%q 应存成 %q,得到 %q", c.cell, c.want, got)
|
||
}
|
||
}
|
||
}
|
||
|
||
// status 填了认不出的值:该行跳过,错误消息要是中文可读的行级提示,不能是原始 SQLite 报错。
|
||
func TestImportRejectsUnknownStatus(t *testing.T) {
|
||
env, _, client := recorderWithSeed(t)
|
||
|
||
body := importHeader + "\nM100,男子,A,张三,110101199001011234,男,,,,已报到,\n"
|
||
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
|
||
if status != http.StatusOK || !env2.OK {
|
||
t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error)
|
||
}
|
||
result := decodeData[map[string]any](t, env2)
|
||
if result["created"] != float64(0) || result["skipped"] != float64(1) {
|
||
t.Fatalf("乱填 status 的行应跳过: %+v", result)
|
||
}
|
||
errs, _ := result["errors"].([]any)
|
||
if len(errs) != 1 {
|
||
t.Fatalf("应有 1 条行级错误,得到 %+v", errs)
|
||
}
|
||
msg, _ := errs[0].(string)
|
||
for _, want := range []string{"第 2 行", "已报到", "registered / scratched / dq / dns"} {
|
||
if !strings.Contains(msg, want) {
|
||
t.Fatalf("错误消息应含 %q,得到 %q", want, msg)
|
||
}
|
||
}
|
||
|
||
// 该行一个字段都不该落库:既不建人员,也不建报名。
|
||
var entries int
|
||
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM entries e JOIN athletes a ON a.id = e.athlete_id
|
||
WHERE a.id_card = ?`, "110101199001011234").Scan(&entries); err != nil {
|
||
t.Fatalf("统计报名: %v", err)
|
||
}
|
||
if entries != 0 {
|
||
t.Fatalf("认不出 status 的行不应落库,得到 %d 条报名", entries)
|
||
}
|
||
}
|
||
|
||
// ---------- 图片上传与静态访问 ----------
|
||
|
||
// newUploadEnv 构造带赛事项目目录的测试环境,返回 uploads 目录的真实路径。
|
||
func newUploadEnv(t *testing.T) (*testEnv, string) {
|
||
t.Helper()
|
||
projectDir := filepath.Join(t.TempDir(), "event_projs", "test")
|
||
env := newTestEnvWith(t, func(o *httpapi.Options) { o.ProjectDir = projectDir })
|
||
return env, filepath.Join(projectDir, "uploads")
|
||
}
|
||
|
||
// pngContent 是占位 PNG:服务端只按扩展名白名单放行,不解码图片内容。
|
||
func pngContent() []byte {
|
||
return append([]byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}, []byte("fake-png-body")...)
|
||
}
|
||
|
||
// postUpload 以 multipart 字段 file 上传到 /api/uploads。
|
||
func (e *testEnv) postUpload(t *testing.T, client *http.Client, filename string, content []byte) (int, envelope) {
|
||
t.Helper()
|
||
|
||
var buf bytes.Buffer
|
||
mw := multipart.NewWriter(&buf)
|
||
fw, err := mw.CreateFormFile("file", filename)
|
||
if err != nil {
|
||
t.Fatalf("构造 multipart: %v", err)
|
||
}
|
||
if _, err := fw.Write(content); err != nil {
|
||
t.Fatalf("写入上传内容: %v", err)
|
||
}
|
||
if err := mw.Close(); err != nil {
|
||
t.Fatalf("关闭 multipart: %v", err)
|
||
}
|
||
|
||
req, err := http.NewRequest(http.MethodPost, e.server.URL+"/api/uploads", &buf)
|
||
if err != nil {
|
||
t.Fatalf("构造请求: %v", err)
|
||
}
|
||
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||
if token := e.csrfToken(client); token != "" {
|
||
req.Header.Set("X-CSRF-Token", token)
|
||
}
|
||
resp, err := client.Do(req)
|
||
if err != nil {
|
||
t.Fatalf("上传图片: %v", err)
|
||
}
|
||
defer func() { _ = resp.Body.Close() }()
|
||
|
||
var env envelope
|
||
raw, _ := io.ReadAll(resp.Body)
|
||
if len(bytes.TrimSpace(raw)) > 0 {
|
||
if err := json.Unmarshal(raw, &env); err != nil {
|
||
t.Fatalf("解析上传响应: %v (%s)", err, raw)
|
||
}
|
||
}
|
||
return resp.StatusCode, env
|
||
}
|
||
|
||
type uploadResultBody struct {
|
||
Path string `json:"path"`
|
||
URL string `json:"url"`
|
||
}
|
||
|
||
func uploadsDirEntries(t *testing.T, dir string) []string {
|
||
t.Helper()
|
||
entries, err := os.ReadDir(dir)
|
||
if os.IsNotExist(err) {
|
||
return nil
|
||
}
|
||
if err != nil {
|
||
t.Fatalf("读取 uploads 目录: %v", err)
|
||
}
|
||
names := make([]string, 0, len(entries))
|
||
for _, e := range entries {
|
||
names = append(names, e.Name())
|
||
}
|
||
return names
|
||
}
|
||
|
||
// 上传的图片要真落盘到 <ProjectDir>/uploads,且匿名 <img> 能取到同样的内容(赛事包导出靠这个目录)。
|
||
func TestUploadImageThenServeAnonymously(t *testing.T) {
|
||
env, uploadsDir := newUploadEnv(t)
|
||
env.createUser("recorder", "recorder-pass", "recorder")
|
||
client, _ := env.client()
|
||
env.login(client, "recorder", "recorder-pass")
|
||
|
||
content := pngContent()
|
||
status, body := env.postUpload(t, client, "赛事会标.png", content)
|
||
if status != http.StatusOK || !body.OK {
|
||
t.Fatalf("上传 png 应 200,得到 %d %+v", status, body.Error)
|
||
}
|
||
result := decodeData[uploadResultBody](t, body)
|
||
if result.Path != result.URL {
|
||
t.Fatalf("path 与 url 应一致,得到 %q / %q", result.Path, result.URL)
|
||
}
|
||
if !strings.HasPrefix(result.Path, "/uploads/") || !strings.HasSuffix(result.Path, ".png") {
|
||
t.Fatalf("返回路径应形如 /uploads/xxx.png,得到 %q", result.Path)
|
||
}
|
||
// 落盘名不能用客户端文件名,否则重名会互相覆盖、也会带出路径注入。
|
||
if strings.Contains(result.Path, "赛事会标") {
|
||
t.Fatalf("落盘名应为随机名,得到 %q", result.Path)
|
||
}
|
||
|
||
stored := filepath.Join(uploadsDir, filepath.Base(result.Path))
|
||
got, err := os.ReadFile(stored)
|
||
if err != nil {
|
||
t.Fatalf("上传文件未落在 projectDir/uploads: %v", err)
|
||
}
|
||
if !bytes.Equal(got, content) {
|
||
t.Fatalf("落盘内容与上传内容不一致: %q", got)
|
||
}
|
||
|
||
var audits int
|
||
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM audit_logs WHERE action = 'uploads.create'`).Scan(&audits); err != nil {
|
||
t.Fatalf("查询上传审计: %v", err)
|
||
}
|
||
if audits != 1 {
|
||
t.Fatalf("上传应写一条 uploads.create 审计,得到 %d", audits)
|
||
}
|
||
|
||
// 大屏与 <img> 不带会话:匿名 GET 必须 200。
|
||
resp, err := http.Get(env.server.URL + result.Path)
|
||
if err != nil {
|
||
t.Fatalf("匿名取图: %v", err)
|
||
}
|
||
defer func() { _ = resp.Body.Close() }()
|
||
if resp.StatusCode != http.StatusOK {
|
||
t.Fatalf("匿名取图应 200,得到 %d", resp.StatusCode)
|
||
}
|
||
if ct := resp.Header.Get("Content-Type"); ct != "image/png" {
|
||
t.Fatalf("Content-Type 应为 image/png,得到 %q", ct)
|
||
}
|
||
if cc := resp.Header.Get("Cache-Control"); !strings.Contains(cc, "max-age") {
|
||
t.Fatalf("随机文件名应可长缓存,得到 Cache-Control %q", cc)
|
||
}
|
||
served, err := io.ReadAll(resp.Body)
|
||
if err != nil {
|
||
t.Fatalf("读取图片响应: %v", err)
|
||
}
|
||
if !bytes.Equal(served, content) {
|
||
t.Fatalf("静态响应内容与上传内容不一致: %q", served)
|
||
}
|
||
}
|
||
|
||
// 只收位图:SVG 可内嵌脚本,匿名同源访问等于存储型 XSS 入口。
|
||
func TestUploadRejectsUnsupportedImageType(t *testing.T) {
|
||
env, uploadsDir := newUploadEnv(t)
|
||
env.createUser("recorder", "recorder-pass", "recorder")
|
||
client, _ := env.client()
|
||
env.login(client, "recorder", "recorder-pass")
|
||
|
||
cases := []struct {
|
||
name string
|
||
content []byte
|
||
}{
|
||
{"logo.svg", []byte(`<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>`)},
|
||
{"logo.txt", []byte("not an image")},
|
||
{"logo.png.exe", []byte("not an image")},
|
||
}
|
||
for _, c := range cases {
|
||
status, body := env.postUpload(t, client, c.name, c.content)
|
||
if status != http.StatusBadRequest || body.OK {
|
||
t.Fatalf("上传 %s 应 400,得到 %d %+v", c.name, status, body.Error)
|
||
}
|
||
}
|
||
if names := uploadsDirEntries(t, uploadsDir); len(names) != 0 {
|
||
t.Fatalf("被拒的上传不该落盘,实际有 %v", names)
|
||
}
|
||
}
|
||
|
||
// 单文件上限 5 MiB:略超与远超都要 400,且不落盘。
|
||
func TestUploadRejectsOversizeImage(t *testing.T) {
|
||
env, uploadsDir := newUploadEnv(t)
|
||
env.createUser("recorder", "recorder-pass", "recorder")
|
||
client, _ := env.client()
|
||
env.login(client, "recorder", "recorder-pass")
|
||
|
||
for _, size := range []int{5<<20 + 1, 8 << 20} {
|
||
status, body := env.postUpload(t, client, "big.png", bytes.Repeat([]byte{'a'}, size))
|
||
if status != http.StatusBadRequest || body.OK {
|
||
t.Fatalf("%d 字节应 400,得到 %d %+v", size, status, body.Error)
|
||
}
|
||
if !strings.Contains(body.Error.Message, "5 MiB") {
|
||
t.Fatalf("超限提示应说明上限,得到 %q", body.Error.Message)
|
||
}
|
||
}
|
||
if names := uploadsDirEntries(t, uploadsDir); len(names) != 0 {
|
||
t.Fatalf("超限上传不该落盘,实际有 %v", names)
|
||
}
|
||
}
|
||
|
||
// 上传是写操作:未登录(guest)一律 401 AUTH_REQUIRED,与其它写接口口径一致。
|
||
func TestUploadRequiresRecorder(t *testing.T) {
|
||
env, uploadsDir := newUploadEnv(t)
|
||
client, _ := env.client()
|
||
|
||
status, body := env.postUpload(t, client, "logo.png", pngContent())
|
||
if status != http.StatusUnauthorized {
|
||
t.Fatalf("未登录上传应 401,得到 %d %+v", status, body.Error)
|
||
}
|
||
if body.Error == nil || body.Error.Code != "AUTH_REQUIRED" {
|
||
t.Fatalf("错误码应为 AUTH_REQUIRED,得到 %+v", body.Error)
|
||
}
|
||
if names := uploadsDirEntries(t, uploadsDir); len(names) != 0 {
|
||
t.Fatalf("未登录上传不该落盘,实际有 %v", names)
|
||
}
|
||
}
|
||
|
||
// 没配置赛事项目目录时没有落盘位置,明确报 503 而不是 panic。
|
||
func TestUploadWithoutProjectDir(t *testing.T) {
|
||
env := newTestEnv(t)
|
||
env.createUser("recorder", "recorder-pass", "recorder")
|
||
client, _ := env.client()
|
||
env.login(client, "recorder", "recorder-pass")
|
||
|
||
status, body := env.postUpload(t, client, "logo.png", pngContent())
|
||
if status != http.StatusServiceUnavailable {
|
||
t.Fatalf("未配置 ProjectDir 应 503,得到 %d %+v", status, body.Error)
|
||
}
|
||
}
|
||
|
||
// 静态访问只认「单层文件名 + 白名单扩展名」,任何穿越写法都不能读到 uploads 之外的文件。
|
||
func TestUploadStaticRejectsTraversal(t *testing.T) {
|
||
env, uploadsDir := newUploadEnv(t)
|
||
root := filepath.Dir(filepath.Dir(uploadsDir)) // projectDir 的上一级(event_projs)
|
||
const secret = "top-secret-content"
|
||
if err := os.MkdirAll(root, 0o755); err != nil {
|
||
t.Fatalf("创建 uploads 之外的目录: %v", err)
|
||
}
|
||
if err := os.WriteFile(filepath.Join(root, "config.yaml"), []byte(secret), 0o644); err != nil {
|
||
t.Fatalf("写 uploads 之外的文件: %v", err)
|
||
}
|
||
if err := os.WriteFile(filepath.Join(root, "..", "secret.txt"), []byte(secret), 0o644); err != nil {
|
||
t.Fatalf("写 uploads 之外的文件: %v", err)
|
||
}
|
||
|
||
// 不跟随重定向:要看到服务端真正给出的状态码。
|
||
client := &http.Client{
|
||
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
|
||
}
|
||
paths := []string{
|
||
"/uploads/..%2F..%2Fconfig.yaml",
|
||
"/uploads/..%2F..%2F..%2Fsecret.txt",
|
||
"/uploads/%2e%2e%2Fconfig.yaml",
|
||
"/uploads/a/b.png",
|
||
"/uploads/..%2Fconfig.yaml",
|
||
}
|
||
for _, path := range paths {
|
||
resp, err := client.Get(env.server.URL + path)
|
||
if err != nil {
|
||
t.Fatalf("请求 %s: %v", path, err)
|
||
}
|
||
raw, _ := io.ReadAll(resp.Body)
|
||
_ = resp.Body.Close()
|
||
if resp.StatusCode != http.StatusNotFound && resp.StatusCode != http.StatusBadRequest {
|
||
t.Fatalf("%s 应 404/400,得到 %d", path, resp.StatusCode)
|
||
}
|
||
if strings.Contains(string(raw), secret) {
|
||
t.Fatalf("%s 读到了 uploads 之外的文件: %s", path, raw)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 名字合法但文件不存在时返回 404,不能把目录列表或空图当成功。
|
||
func TestUploadStaticMissingFile(t *testing.T) {
|
||
env, _ := newUploadEnv(t)
|
||
resp, err := http.Get(env.server.URL + "/uploads/0123456789abcdef0123456789abcdef.png")
|
||
if err != nil {
|
||
t.Fatalf("请求不存在的图片: %v", err)
|
||
}
|
||
defer func() { _ = resp.Body.Close() }()
|
||
if resp.StatusCode != http.StatusNotFound {
|
||
t.Fatalf("不存在的图片应 404,得到 %d", resp.StatusCode)
|
||
}
|
||
}
|
||
|
||
// 上传返回的路径要能直接写进赛事配置,且大屏匿名取图链路通(会标/背景的实际用法)。
|
||
func TestUploadedImagePathRoundTripsThroughSettings(t *testing.T) {
|
||
env, _ := newUploadEnv(t)
|
||
env.createUser("admin", "admin-pass", "admin")
|
||
client, _ := env.client()
|
||
env.login(client, "admin", "admin-pass")
|
||
|
||
status, body := env.postUpload(t, client, "logo.png", pngContent())
|
||
if status != http.StatusOK || !body.OK {
|
||
t.Fatalf("上传应 200,得到 %d %+v", status, body.Error)
|
||
}
|
||
path := decodeData[uploadResultBody](t, body).Path
|
||
|
||
status, body = env.do(client, http.MethodPut, "/api/settings", map[string]any{
|
||
"event": map[string]any{"logoPath": path},
|
||
"display": map[string]any{"logo": path, "background": path},
|
||
}, true)
|
||
if status != http.StatusOK || !body.OK {
|
||
t.Fatalf("把上传路径写进配置应 200,得到 %d %+v", status, body.Error)
|
||
}
|
||
|
||
status, body = env.do(client, http.MethodGet, "/api/settings", nil, false)
|
||
if status != http.StatusOK || !body.OK {
|
||
t.Fatalf("读取配置应 200,得到 %d %+v", status, body.Error)
|
||
}
|
||
settings := decodeData[dto.EventSettings](t, body)
|
||
if settings.Event.LogoPath == nil || *settings.Event.LogoPath != path {
|
||
t.Fatalf("event.logoPath 应保存上传路径 %q,得到 %+v", path, settings.Event.LogoPath)
|
||
}
|
||
if settings.Display.Logo == nil || *settings.Display.Logo != path {
|
||
t.Fatalf("display.logo 应保存上传路径 %q,得到 %+v", path, settings.Display.Logo)
|
||
}
|
||
|
||
// 大屏用的就是这个路径,匿名 GET 必须直接取到图。
|
||
resp, err := http.Get(env.server.URL + path)
|
||
if err != nil {
|
||
t.Fatalf("匿名取图: %v", err)
|
||
}
|
||
defer func() { _ = resp.Body.Close() }()
|
||
if resp.StatusCode != http.StatusOK {
|
||
t.Fatalf("配置里的图片路径应可匿名访问,得到 %d", resp.StatusCode)
|
||
}
|
||
}
|