需求:主人要「CSV 模板的表头能改一下,或者提供两个按钮,一个中文表头一个英文表头」。
- 解析器加**中文别名**(英文仍是规范名:取值、报错、纯英文模板、勾选导出都用它)。
12 列,顺序即模板列序:项目编码 / 组别 / 单位编码 / 姓名 / 证件号 / 性别 / 年龄 /
号码布 / 种子 / 批次 / 状态 / 队员名单;另认一批同义写法(小项编码、参赛单位编码、
运动员姓名、身份证号、道次、批次号、报名状态…)。
Go 别名表 == 前端 COLUMN_ALIASES == 导入规范文档表格,三方逐列逐名一致(脚本核对)。
- **向后兼容**:纯英文表头一字不改照常导入;回归用**字面量**(不复用常量)钉死老 11 列表头
与大写/空格变体,避免有人改常量把回归一起改掉。
- **中英可任意混排**,但**同一列出现两种写法(或写两遍)整份拦下**并说明——两列都可能带数据,
静默取其一等于丢另一列。容错沿用既有先例:BOM(含格子中间的 U+FEFF)、全角空格/全角 ASCII、
首尾空格、大小写。
- 缺列报错**中英并列**(「缺少列 athlete_name(姓名)」),现场拿中文模板的人一眼看懂。
- 前端两个下载按钮:「下载模板(中文表头)」「下载模板(英文表头)」,列序与列数完全一致,
文件名可区分;示例数据模式(mock)同步,避免两条路径产出不同模板。
- 文档 `docs/报名名单导入规范.md` 补两套表头与「两套都能导入」的说明(原内容未删)。
端到端(真服务端 + curl):中文模板填两行 → {"created":2} 且库中值取自中文列;
英文模板同一份数据 → {"updated":2} 不重复建档;缺列与表头重复分别给出上述人话报错。
自检:gofmt / go build / go vet / go test 全绿;vue-tsc + 前端 754 例全过。
2733 lines
115 KiB
Go
2733 lines
115 KiB
Go
package httpapi_test
|
||
|
||
import (
|
||
"bytes"
|
||
"database/sql"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"mime/multipart"
|
||
"net/http"
|
||
"os"
|
||
"path/filepath"
|
||
"reflect"
|
||
"slices"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
|
||
"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', bib = 'A100' WHERE id = ?`, ids.athleteA)
|
||
|
||
body := importHeader + "\nM100,男子,B,张伟,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["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)
|
||
}
|
||
}
|
||
|
||
// 同一份表里同一证件号、同一小项同一个组别出现两次 = 真重复:
|
||
// 两行都解析到同一个人(不新建第二份档案、不报「证件号已被占用」),
|
||
// 但要不要覆盖已落库的那条报名由用户决定,所以次行进冲突面板并带 duplicate 标记;
|
||
// 同时给一条 notices 提示,说明这个证件号在本表里出现了两次。
|
||
func TestImportAcceptsDuplicateIDCardInFile(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["updated"] != float64(0) || result["skipped"] != float64(0) {
|
||
t.Fatalf("首行新建、次行等决策(既不算更新也不算跳过): %+v", result)
|
||
}
|
||
if errs, _ := result["errors"].([]any); len(errs) != 0 {
|
||
t.Fatalf("同证件号重复不应报行级错误,得到 %+v", errs)
|
||
}
|
||
notices, _ := result["notices"].([]any)
|
||
wantNotice := "证件号 110101199001011234 在本表中出现 2 次(同一人报名多个组别)"
|
||
if len(notices) != 1 || notices[0] != wantNotice {
|
||
t.Fatalf("应给一条重复提示 %q,得到 %+v", wantNotice, notices)
|
||
}
|
||
|
||
conflicts, _ := result["conflicts"].([]any)
|
||
if len(conflicts) != 1 {
|
||
t.Fatalf("同一个小项同一个组别的重复行应进冲突面板,得到 %+v", conflicts)
|
||
}
|
||
conflict := conflicts[0].(map[string]any)
|
||
if conflict["duplicate"] != true || conflict["row"] != float64(3) {
|
||
t.Fatalf("重复行应带 duplicate 标记并指向次行: %+v", conflict)
|
||
}
|
||
var athleteID int64
|
||
if err := env.ev.QueryRow(`SELECT id FROM athletes WHERE id_card = ?`, "110101199001011234").Scan(&athleteID); err != nil {
|
||
t.Fatalf("查询人员: %v", err)
|
||
}
|
||
if conflict["athleteId"] != float64(athleteID) {
|
||
t.Fatalf("重复行应指向同一个人 %d: %+v", athleteID, conflict)
|
||
}
|
||
|
||
// 同一个人只应建 1 份档案(次行绝不能再新建或撞 id_card 唯一索引)。
|
||
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)
|
||
}
|
||
}
|
||
|
||
// 证件号优先:表里的证件号在库里有主时,命中那个人走冲突面板,而不是报「证件号已被占用」。
|
||
// 姓名 / 单位 / 号码布都对不上也只当差异交给用户决策——认人是按证件号,不是按号码布或姓名。
|
||
func TestImportIDCardHitGoesToConflictNotTakenError(t *testing.T) {
|
||
env, ids, client := recorderWithSeed(t)
|
||
env.exec(t, `UPDATE athletes SET id_card = '110101199001011234', bib = 'A100' WHERE id = ?`, ids.athleteA)
|
||
|
||
// 另一个人(李四 / 号码布 B200)拿着张伟的证件号来建档:证件号命中张伟 → 冲突,不是行级错误。
|
||
body := importHeader + "\nM100,男子,B,李四,110101199001011234,男,,B200,,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(0) {
|
||
t.Fatalf("证件号命中应进冲突面板,既不算新建也不算跳过: %+v", result)
|
||
}
|
||
if errs, _ := result["errors"].([]any); len(errs) != 0 {
|
||
t.Fatalf("证件号能认到人就不该报「已被占用」,得到 %+v", errs)
|
||
}
|
||
conflicts, _ := result["conflicts"].([]any)
|
||
if len(conflicts) != 1 {
|
||
t.Fatalf("应收集 1 条冲突,得到 %+v", conflicts)
|
||
}
|
||
conflict := conflicts[0].(map[string]any)
|
||
if conflict["athleteId"] != float64(ids.athleteA) || conflict["duplicate"] != false {
|
||
t.Fatalf("冲突应指向证件号命中的人: %+v", conflict)
|
||
}
|
||
changed, _ := conflict["changedFields"].([]any)
|
||
if len(changed) != 3 || changed[0] != "name" || changed[1] != "teamCode" || changed[2] != "bib" {
|
||
t.Fatalf("姓名 / 单位 / 号码布三处差异都该列出,得到 %+v", changed)
|
||
}
|
||
|
||
// 库里的张伟一行没动,也没有多出一个人。
|
||
var (
|
||
name string
|
||
bib string
|
||
count int
|
||
)
|
||
if err := env.ev.QueryRow(`SELECT name, bib FROM athletes WHERE id = ?`, ids.athleteA).Scan(&name, &bib); err != nil {
|
||
t.Fatalf("查询人员: %v", err)
|
||
}
|
||
if name != "张伟" || bib != "A100" {
|
||
t.Fatalf("冲突不应改动库里已有人员,得到 name=%s bib=%s", name, bib)
|
||
}
|
||
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM athletes`).Scan(&count); err != nil {
|
||
t.Fatalf("统计人员: %v", err)
|
||
}
|
||
if count != 2 {
|
||
t.Fatalf("证件号命中不应新建人员,得到 %d 个", count)
|
||
}
|
||
}
|
||
|
||
// 号码布不同(表里证件号也对不上)→ 按「同名的另一个人」新建档案,
|
||
// 并在 sameNameNewPersons 里带上证件号 / 号码布两侧取值单独报告,不进冲突面板。
|
||
func TestImportSameNameDifferentBibCreatesSecondPerson(t *testing.T) {
|
||
env, ids, client := recorderWithSeed(t)
|
||
env.exec(t, `UPDATE athletes SET bib = '1001' WHERE id = ?`, ids.athleteA)
|
||
|
||
body := importHeader + "\nM100,男子,A,张伟,,男,,2001,,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)
|
||
}
|
||
if conflicts, _ := result["conflicts"].([]any); len(conflicts) != 0 {
|
||
t.Fatalf("同名不同号码布不进冲突面板: %+v", conflicts)
|
||
}
|
||
reported, _ := result["sameNameNewPersons"].([]any)
|
||
if len(reported) != 1 {
|
||
t.Fatalf("应报告 1 条同名新建,得到 %+v", reported)
|
||
}
|
||
item := reported[0].(map[string]any)
|
||
if item["name"] != "张伟" || item["teamCode"] != "A" || item["newBib"] != "2001" || item["existingBib"] != "1001" {
|
||
t.Fatalf("同名新建报告字段不对: %+v", item)
|
||
}
|
||
// 证件号两侧都没有值也要如实带上,现场看到的「原 / 新」两栏才完整。
|
||
if item["newIdCard"] != "" || item["existingIdCard"] != "" {
|
||
t.Fatalf("证件号两侧都为空时应是空串: %+v", item)
|
||
}
|
||
|
||
var athletes int
|
||
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM athletes WHERE name = '张伟' AND team_id = ?`, ids.teamA).Scan(&athletes); err != nil {
|
||
t.Fatalf("统计人员: %v", err)
|
||
}
|
||
if athletes != 2 {
|
||
t.Fatalf("应新建第二个人,得到 %d 个", athletes)
|
||
}
|
||
// 原档案的号码布保持不动,新档案拿表里的号码布。
|
||
var oldBib string
|
||
if err := env.ev.QueryRow(`SELECT bib FROM athletes WHERE id = ?`, ids.athleteA).Scan(&oldBib); err != nil {
|
||
t.Fatalf("查询原人员: %v", err)
|
||
}
|
||
if oldBib != "1001" {
|
||
t.Fatalf("原档案号码布不应被改,得到 %s", oldBib)
|
||
}
|
||
}
|
||
|
||
// 姓名 + 单位命中多条候选:不能替用户挑人,进冲突面板等决策。
|
||
func TestImportAmbiguousNameTeamGoesToConflictPanel(t *testing.T) {
|
||
env, ids, client := recorderWithSeed(t)
|
||
// 同单位两个「张伟」,都没有号码布,表里来第三个候选信息 → 歧义。
|
||
env.exec(t, `INSERT INTO athletes (name, gender, team_id, active) VALUES ('张伟', '男', ?, 1)`, ids.teamA)
|
||
|
||
body := importHeader + "\nM100,男子,A,张伟,,男,,9001,,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(0) {
|
||
t.Fatalf("歧义行不落库也不跳过: %+v", result)
|
||
}
|
||
conflicts, _ := result["conflicts"].([]any)
|
||
if len(conflicts) != 1 {
|
||
t.Fatalf("应收集 1 条冲突,得到 %+v", conflicts)
|
||
}
|
||
if conflicts[0].(map[string]any)["athleteId"] != float64(ids.athleteA) {
|
||
t.Fatalf("歧义冲突应以第一条候选为代表: %+v", conflicts[0])
|
||
}
|
||
}
|
||
|
||
// 有 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', bib = 'A100' WHERE id = ?`, ids.athleteA)
|
||
|
||
body := importHeader + "\nM100,男子,B,张伟,110101199001011234,男,,A100,,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 = 'entry.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', bib = 'A100' WHERE id = ?`, ids.athleteA)
|
||
|
||
body := importHeader + "\nM100,男子,B,张伟,110101199001011234,男,,A100,,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)
|
||
}
|
||
}
|
||
|
||
// B01:报名这一族的审计动作必须共用单数前缀 entry.(导入、批次、批量退赛/恢复/移出都算)。
|
||
// 用例真发三个写请求,再走日志页同一条查询(`/api/audit-logs?action=entry.`,子串模糊)
|
||
// 断言三条都在 —— 只看字符串常量的话,改回 entries.import 这类回归照样能绿。
|
||
func TestEntryAuditActionsShareOnePrefix(t *testing.T) {
|
||
env, ids, client := recorderWithSeed(t)
|
||
|
||
// ① 报名表导入(新增一个人 + 一条报名)。
|
||
body := importHeader + "\nM100,男子,A,前缀用例选手,110101199001019999,男,,X900,,registered,\n"
|
||
if status, env2 := env.postImport(t, client, "/api/entries/import", body, ""); status != http.StatusOK || !env2.OK {
|
||
t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error)
|
||
}
|
||
// ② 批量设置批次。
|
||
if status, env2 := env.do(client, http.MethodPut, "/api/entries/heat", map[string]any{
|
||
"entryIds": []int64{ids.entryA}, "heat": 3,
|
||
}, true); status != http.StatusOK || !env2.OK {
|
||
t.Fatalf("设置批次应 200,得到 %d %+v", status, env2.Error)
|
||
}
|
||
// ③ 批量退赛。
|
||
if status, env2 := env.do(client, http.MethodPost, "/api/entries/batch", map[string]any{
|
||
"action": "scratch", "ids": []int64{ids.entryB}, "reason": "前缀用例",
|
||
}, true); status != http.StatusOK || !env2.OK {
|
||
t.Fatalf("批量退赛应 200,得到 %d %+v", status, env2.Error)
|
||
}
|
||
|
||
want := []string{"entry.import", "entry.heat", "entry.scratch"}
|
||
// /api/audit-logs 是 admin-only;审计在响应之后落库,轮询到齐再断言。
|
||
env.createUser("admin", "admin-pass", "admin")
|
||
adminClient, _ := env.client()
|
||
env.login(adminClient, "admin", "admin-pass")
|
||
|
||
found := map[string]bool{}
|
||
for i := 0; i < 100 && len(found) < len(want); i++ {
|
||
_, env2 := env.do(adminClient, http.MethodGet, "/api/audit-logs?action=entry.&size=200", nil, false)
|
||
items, _ := decodeData[map[string]any](t, env2)["items"].([]any)
|
||
for _, raw := range items {
|
||
item, ok := raw.(map[string]any)
|
||
if !ok {
|
||
continue
|
||
}
|
||
action, _ := item["action"].(string)
|
||
if slices.Contains(want, action) {
|
||
found[action] = true
|
||
}
|
||
}
|
||
if len(found) < len(want) {
|
||
time.Sleep(10 * time.Millisecond)
|
||
}
|
||
}
|
||
for _, action := range want {
|
||
if !found[action] {
|
||
t.Fatalf("按 entry. 前缀应能筛到 %s,实际只筛到 %+v", action, found)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
if errs, _ := result["errors"].([]any); len(errs) != 0 {
|
||
t.Fatalf("号码布命中且对方没有证件号,不该报错: %+v", errs)
|
||
}
|
||
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 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)
|
||
}
|
||
}
|
||
|
||
// 证件号优先:库里某人 id_card=X / bib=A100,表里同证件号但号码布写错成 A200 时,
|
||
// 命中同一个人(不新建、不报「证件号已被占用」),号码布差异按既有口径进冲突面板;
|
||
// 决策 keep-new 后号码布按表里的值更新。
|
||
func TestImportIDCardPriorityIgnoresWrongBib(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)
|
||
if result["created"] != float64(0) || result["skipped"] != float64(0) {
|
||
t.Fatalf("冲突行不落库也不跳过: %+v", result)
|
||
}
|
||
if errs, _ := result["errors"].([]any); len(errs) != 0 {
|
||
t.Fatalf("证件号命中同一个人,不该报「已被占用」: %+v", errs)
|
||
}
|
||
conflicts, _ := result["conflicts"].([]any)
|
||
if len(conflicts) != 1 {
|
||
t.Fatalf("号码布差异应进冲突面板,得到 %+v", conflicts)
|
||
}
|
||
conflict := conflicts[0].(map[string]any)
|
||
if conflict["athleteId"] != float64(ids.athleteA) || conflict["duplicate"] != false {
|
||
t.Fatalf("冲突应指向证件号命中的那个人: %+v", conflict)
|
||
}
|
||
changed, _ := conflict["changedFields"].([]any)
|
||
if len(changed) != 1 || changed[0] != "bib" {
|
||
t.Fatalf("应只有号码布差异,得到 %+v", changed)
|
||
}
|
||
if reported, _ := result["sameNameNewPersons"].([]any); len(reported) != 0 {
|
||
t.Fatalf("证件号命中同一个人时不该报告成同名新建: %+v", reported)
|
||
}
|
||
|
||
// 未决策前库里那个人一个字段都不动,也没有多出一个人。
|
||
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)
|
||
}
|
||
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)
|
||
}
|
||
|
||
// 决策键是证件号(证件号优先),keep-new 用表里的号码布覆盖。
|
||
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 conflicts, _ := result["conflicts"].([]any); len(conflicts) != 0 {
|
||
t.Fatalf("已决策的冲突不应再返回: %+v", conflicts)
|
||
}
|
||
if result["updated"] != float64(1) || result["created"] != float64(0) {
|
||
t.Fatalf("keep-new 应更新已有报名: %+v", result)
|
||
}
|
||
if err := env.ev.QueryRow(`SELECT bib FROM athletes WHERE id = ?`, ids.athleteA).Scan(&bib); err != nil {
|
||
t.Fatalf("查询人员: %v", err)
|
||
}
|
||
if bib != "A200" {
|
||
t.Fatalf("keep-new 应用表里的号码布覆盖,得到 %s", bib)
|
||
}
|
||
}
|
||
|
||
// 证件号与号码布都没命中 → 按「姓名 + 单位」命中且对方既没证件号也没号码布:
|
||
// 认定同一个人,把表里的证件号与号码布一并回填,不新建档案。
|
||
func TestImportBackfillsIDCardAndBibBySameNameTeam(t *testing.T) {
|
||
env, ids, client := recorderWithSeed(t)
|
||
|
||
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) || result["skipped"] != float64(0) {
|
||
t.Fatalf("姓名 + 单位命中应更新已有报名: %+v", result)
|
||
}
|
||
if errs, _ := result["errors"].([]any); len(errs) != 0 {
|
||
t.Fatalf("回填不该报行级错误: %+v", errs)
|
||
}
|
||
if conflicts, _ := result["conflicts"].([]any); len(conflicts) != 0 {
|
||
t.Fatalf("两边都没登记身份字段时回填不算冲突: %+v", conflicts)
|
||
}
|
||
|
||
var (
|
||
idCard sql.NullString
|
||
bib sql.NullString
|
||
)
|
||
if err := env.ev.QueryRow(`SELECT id_card, bib FROM athletes WHERE id = ?`, ids.athleteA).Scan(&idCard, &bib); err != nil {
|
||
t.Fatalf("查询人员: %v", err)
|
||
}
|
||
if !idCard.Valid || idCard.String != "110101199001011234" {
|
||
t.Fatalf("应回填证件号,得到 %+v", idCard)
|
||
}
|
||
if !bib.Valid || bib.String != "A100" {
|
||
t.Fatalf("应回填号码布,得到 %+v", bib)
|
||
}
|
||
if reported, _ := result["sameNameNewPersons"].([]any); len(reported) != 0 {
|
||
t.Fatalf("一边没给身份字段只是「未指定」,不该按同名的另一个人新建: %+v", reported)
|
||
}
|
||
|
||
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 TestImportSameNameDifferentBibWithUnmatchedIDCardCreatesSecondPerson(t *testing.T) {
|
||
env, ids, client := recorderWithSeed(t)
|
||
env.exec(t, `UPDATE athletes SET bib = '1001' WHERE id = ?`, ids.athleteA)
|
||
|
||
body := importHeader + "\nM100,男子,A,张伟,110101199001011234,男,,2001,,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)
|
||
}
|
||
if conflicts, _ := result["conflicts"].([]any); len(conflicts) != 0 {
|
||
t.Fatalf("同名的另一个人不进冲突面板: %+v", conflicts)
|
||
}
|
||
|
||
reported, _ := result["sameNameNewPersons"].([]any)
|
||
if len(reported) != 1 {
|
||
t.Fatalf("应报告 1 条同名新建,得到 %+v", reported)
|
||
}
|
||
item := reported[0].(map[string]any)
|
||
want := map[string]any{
|
||
"name": "张伟", "teamCode": "A",
|
||
"newIdCard": "110101199001011234", "existingIdCard": "",
|
||
"newBib": "2001", "existingBib": "1001",
|
||
}
|
||
for key, value := range want {
|
||
if item[key] != value {
|
||
t.Fatalf("同名新建报告 %s 应为 %q,得到 %+v", key, value, item)
|
||
}
|
||
}
|
||
|
||
var athletes int
|
||
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM athletes WHERE name = '张伟' AND team_id = ?`, ids.teamA).Scan(&athletes); err != nil {
|
||
t.Fatalf("统计人员: %v", err)
|
||
}
|
||
if athletes != 2 {
|
||
t.Fatalf("应新建第二个人,得到 %d 个", athletes)
|
||
}
|
||
}
|
||
|
||
// 姓名 + 单位命中、对方有另一个证件号、表里也没给号码布 → 同名的另一个人(与号码布不同同一条路径)。
|
||
func TestImportSameNameDifferentIDCardCreatesSecondPerson(t *testing.T) {
|
||
env, ids, client := recorderWithSeed(t)
|
||
env.exec(t, `UPDATE athletes SET id_card = '110101199001019999', bib = '1001' WHERE id = ?`, ids.athleteA)
|
||
|
||
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("证件号不同应新建报名: %+v", result)
|
||
}
|
||
if conflicts, _ := result["conflicts"].([]any); len(conflicts) != 0 {
|
||
t.Fatalf("证件号不同不是冲突,是同名的另一个人: %+v", conflicts)
|
||
}
|
||
reported, _ := result["sameNameNewPersons"].([]any)
|
||
if len(reported) != 1 {
|
||
t.Fatalf("应报告 1 条同名新建,得到 %+v", reported)
|
||
}
|
||
item := reported[0].(map[string]any)
|
||
if item["newIdCard"] != "110101199001011234" || item["existingIdCard"] != "110101199001019999" {
|
||
t.Fatalf("同名新建报告应带两侧证件号: %+v", item)
|
||
}
|
||
if item["newBib"] != "" || item["existingBib"] != "1001" {
|
||
t.Fatalf("表里没给号码布时应是空串、库里那条如实带上: %+v", item)
|
||
}
|
||
}
|
||
|
||
// 同一份表里同一证件号出现多次(同一个人报多个组别):两行都解析到同一个档案、各自落一条报名,
|
||
// 只给 notices 提示,绝不新建第二个人、也绝不撞 id_card 唯一索引。
|
||
func TestImportDuplicateIDCardAcrossScopesResolvesToOnePerson(t *testing.T) {
|
||
env, ids, client := recorderWithSeed(t)
|
||
// 加一个组别,让同一人的两行落在不同 (小项, 组别) 上——那才是真正的「报多个组别」。
|
||
env.exec(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order) VALUES (?, '女子', 1, 1)`, ids.event)
|
||
|
||
body := importHeader + "\n" +
|
||
"M100,男子,A,张三,110101199001011234,男,,A100,,registered,\n" +
|
||
"M100,女子,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["created"] != float64(2) || result["skipped"] != float64(0) {
|
||
t.Fatalf("同一人的两条报名都应落库: %+v", result)
|
||
}
|
||
if errs, _ := result["errors"].([]any); len(errs) != 0 {
|
||
t.Fatalf("同证件号重复不该报行级错误,得到 %+v", errs)
|
||
}
|
||
if conflicts, _ := result["conflicts"].([]any); len(conflicts) != 0 {
|
||
t.Fatalf("不同组别不是真重复,不该进冲突面板: %+v", conflicts)
|
||
}
|
||
notices, _ := result["notices"].([]any)
|
||
if len(notices) != 1 || notices[0] != "证件号 110101199001011234 在本表中出现 2 次(同一人报名多个组别)" {
|
||
t.Fatalf("应给一条重复提示,得到 %+v", notices)
|
||
}
|
||
|
||
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)
|
||
}
|
||
var entries int
|
||
if err := env.ev.QueryRow(
|
||
`SELECT COUNT(*) FROM entries WHERE athlete_id = (SELECT id FROM athletes WHERE id_card = ?)`,
|
||
"110101199001011234").Scan(&entries); err != nil {
|
||
t.Fatalf("统计报名: %v", err)
|
||
}
|
||
if entries != 2 {
|
||
t.Fatalf("同一个人两个组别应各有一条报名,得到 %d", entries)
|
||
}
|
||
}
|
||
|
||
// 同一份表里同一证件号两行的号码布不同(号码布写错):两行仍是同一个人(冲突指向同一个 athleteId),
|
||
// 号码布差异按既有口径进冲突面板,不新建第二个人、不报「证件号已被占用」。
|
||
func TestImportDuplicateIDCardDifferentBibStaysOnePerson(t *testing.T) {
|
||
env, ids, client := recorderWithSeed(t)
|
||
env.exec(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order) VALUES (?, '女子', 1, 1)`, ids.event)
|
||
|
||
body := importHeader + "\n" +
|
||
"M100,男子,A,张三,110101199001011234,男,,B1,,registered,\n" +
|
||
"M100,女子,A,张三,110101199001011234,男,,B2,,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)
|
||
}
|
||
if errs, _ := result["errors"].([]any); len(errs) != 0 {
|
||
t.Fatalf("不该报「证件号已被占用」这类行级错误,得到 %+v", errs)
|
||
}
|
||
notices, _ := result["notices"].([]any)
|
||
if len(notices) != 1 || notices[0] != "证件号 110101199001011234 在本表中出现 2 次(同一人报名多个组别)" {
|
||
t.Fatalf("应给一条重复提示,得到 %+v", notices)
|
||
}
|
||
|
||
var athleteID int64
|
||
if err := env.ev.QueryRow(`SELECT id FROM athletes WHERE id_card = ?`, "110101199001011234").Scan(&athleteID); err != nil {
|
||
t.Fatalf("查询人员: %v", err)
|
||
}
|
||
conflicts, _ := result["conflicts"].([]any)
|
||
if len(conflicts) != 1 {
|
||
t.Fatalf("号码布写错应进冲突面板,得到 %+v", conflicts)
|
||
}
|
||
conflict := conflicts[0].(map[string]any)
|
||
if conflict["athleteId"] != float64(athleteID) {
|
||
t.Fatalf("次行必须解析到同一个人 %d,得到 %+v", athleteID, conflict)
|
||
}
|
||
changed, _ := conflict["changedFields"].([]any)
|
||
if len(changed) != 1 || changed[0] != "bib" {
|
||
t.Fatalf("应只有号码布差异,得到 %+v", changed)
|
||
}
|
||
|
||
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)
|
||
}
|
||
}
|
||
|
||
// 号码布已经在库里被 A 占用,表里这一行的证件号又对不上 A → 两个人共用一个号码布:
|
||
// 按既有号码布防呆口径报行级错误并跳过,绝不静默改库、也不新建第二个占号的人。
|
||
func TestImportRejectsBibTakenByDifferentIDCard(t *testing.T) {
|
||
env, ids, client := recorderWithSeed(t)
|
||
env.exec(t, `UPDATE athletes SET id_card = '110101199001019999', 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["created"] != float64(0) || result["skipped"] != float64(1) {
|
||
t.Fatalf("两人共用一个号码布应跳过该行: %+v", result)
|
||
}
|
||
errs, _ := result["errors"].([]any)
|
||
want := "第 2 行:号码布 A100 已被其它人员使用"
|
||
if len(errs) != 1 || errs[0] != want {
|
||
t.Fatalf("错误文案不对:\n 得到 %+v\n 期望 %q", errs, want)
|
||
}
|
||
if reported, _ := result["sameNameNewPersons"].([]any); len(reported) != 0 {
|
||
t.Fatalf("被号码布防呆挡下时不该报告成同名新建: %+v", reported)
|
||
}
|
||
|
||
// 库里那条一个字段都没动,也没多出一个人。
|
||
var (
|
||
name string
|
||
bib string
|
||
count int
|
||
)
|
||
if err := env.ev.QueryRow(`SELECT name, bib FROM athletes WHERE id = ?`, ids.athleteA).Scan(&name, &bib); err != nil {
|
||
t.Fatalf("查询人员: %v", err)
|
||
}
|
||
if name != "张伟" || bib != "A100" {
|
||
t.Fatalf("号码布防呆不该改动库里已有人员,得到 name=%s bib=%s", name, bib)
|
||
}
|
||
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM athletes`).Scan(&count); err != nil {
|
||
t.Fatalf("统计人员: %v", err)
|
||
}
|
||
if count != 2 {
|
||
t.Fatalf("不应新建人员,得到 %d 个", count)
|
||
}
|
||
}
|
||
|
||
// 号码布命中但姓名与库中不一致 → 冲突;决策按号码布为键,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)
|
||
}
|
||
}
|
||
|
||
// ---------- 导入改「谁还算数」必须重算 ----------
|
||
|
||
// importZhangWeiIDCard 是种子人员张伟在导入用例里用的证件号(先写库,导入才能按证件号命中他)。
|
||
const importZhangWeiIDCard = "110101199001011234"
|
||
|
||
// importZhangWeiRow 拼一行「M100 / 男子 / A / 张伟」的报名表内容;表头见 importHeader。
|
||
func importZhangWeiRow(status string) string {
|
||
return "M100,男子,A,张伟," + importZhangWeiIDCard + ",男,,,," + status + ",\n"
|
||
}
|
||
|
||
// seedZhangWeiIDCard 给张伟补一个证件号:证件号优先的匹配口径下,导入才认得出是同一个人。
|
||
func seedZhangWeiIDCard(t *testing.T, env *testEnv, ids seedIDs) {
|
||
t.Helper()
|
||
env.exec(t, `UPDATE athletes SET id_card = ? WHERE id = ?`, importZhangWeiIDCard, ids.athleteA)
|
||
}
|
||
|
||
// runImportScenario 用与批量动作同一串操作跑一遍「名单导入改状态」:
|
||
// 8 人报名 → 录完前 4 条并发布 →(withSettle 时)立即结算 → 导入把第 1 名改成 status。
|
||
//
|
||
// 直接复用 entries_batch_test.go 的 runScenario,是为了让两个入口(批量退赛 / 名单导入)
|
||
// 的最终数据能逐字节对照:它们改的是同一件事(谁还算数)。
|
||
func runImportScenario(t *testing.T, live, withSettle bool, status string) batchRun {
|
||
t.Helper()
|
||
return runScenario(t, live, withSettle,
|
||
func(t *testing.T, env *testEnv, client *http.Client, ids seedIDs, entries []int64) {
|
||
t.Helper()
|
||
seedZhangWeiIDCard(t, env, ids)
|
||
body := importHeader + "\n" + importZhangWeiRow(status)
|
||
if st, e := env.postImport(t, client, "/api/entries/import", body, ""); st != http.StatusOK || !e.OK {
|
||
t.Fatalf("导入应 200,得到 %d %+v", st, e.Error)
|
||
}
|
||
})
|
||
}
|
||
|
||
// 同一批导入里多行改状态、且都落在同一组:按 (小项, 组别) 去重,只重算一次、只推一次榜。
|
||
// 导入动辄几百行,逐行推榜会让大屏收一串中间态、也会把现场拖慢。
|
||
func TestImportManyStatusChangesPushOnce(t *testing.T) {
|
||
env, ids, client := recorderWithSeed(t)
|
||
seedZhangWeiIDCard(t, env, ids)
|
||
env.exec(t, `UPDATE athletes SET id_card = ? WHERE id = ?`, "110101199001015678", ids.athB)
|
||
publishResults(t, env, client, ids.event, "import-many",
|
||
map[string]any{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"},
|
||
map[string]any{"entryId": ids.entryB, "resultValue": 11.92, "resultText": "11.92", "unit": "s", "status": "ok"},
|
||
)
|
||
pushesBefore := standingsPushes(t, env)
|
||
|
||
body := importHeader + "\n" + importZhangWeiRow("scratched") +
|
||
"M100,男子,B,李强,110101199001015678,男,,,,scratched,\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(2) || result["skipped"] != float64(0) {
|
||
t.Fatalf("两行都应就地更新,得到 %+v", result)
|
||
}
|
||
// 一个组只推一次榜(两行都改了状态,但 scope 去重成一次重算)。
|
||
if got := standingsPushes(t, env) - pushesBefore; got != 1 {
|
||
t.Fatalf("同一组的多行改动只应推 1 次榜单,得到 %d", got)
|
||
}
|
||
// 两人都退出名次与流水:整组没有可排名成绩,榜单归零。
|
||
if got := ledgerRowsOf(t, env, ids.event, nil); len(got) != 0 {
|
||
t.Fatalf("两人都退赛后不该有有效流水,得到 %v", got)
|
||
}
|
||
if got := ranksOf(t, env, []int64{ids.entryA, ids.entryB}); len(got) != 0 {
|
||
t.Fatalf("两人都退赛后不该占名次,得到 %v", got)
|
||
}
|
||
if got := standingsTotal(t, env, client); got != 0 {
|
||
t.Fatalf("榜单总分应归零,得到 %v", got)
|
||
}
|
||
}
|
||
|
||
// 导入把已发布成绩的人改成退赛:流水作废、名次让出来给别人、榜单被推。
|
||
// 现场「改完名单重新导入一遍」很自然,不重算就会留下「名单说退赛、榜上还挂着 10 分与金牌」。
|
||
func TestImportStatusChangeRecomputes(t *testing.T) {
|
||
env, ids, client := recorderWithSeed(t)
|
||
seedZhangWeiIDCard(t, env, ids)
|
||
publishResults(t, env, client, ids.event, "import-status",
|
||
map[string]any{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"},
|
||
map[string]any{"entryId": ids.entryB, "resultValue": 11.92, "resultText": "11.92", "unit": "s", "status": "ok"},
|
||
)
|
||
// 前置:A 第 1 名 10 分、B 第 2 名 8 分。
|
||
if got := activeLedgerPoints(t, env, ids.entryA); !equalFloats(got, []float64{10}) {
|
||
t.Fatalf("前置条件:A 应是 10 分,得到 %v", got)
|
||
}
|
||
pushesBefore := standingsPushes(t, env)
|
||
|
||
status, env2 := env.postImport(t, client, "/api/entries/import", importHeader+"\n"+importZhangWeiRow("scratched"), "")
|
||
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) || result["skipped"] != float64(0) {
|
||
t.Fatalf("应就地更新 1 条报名(不是新建、不跳过),得到 %+v", result)
|
||
}
|
||
if got := entryStatus(t, env, ids.entryA); got != "scratched" {
|
||
t.Fatalf("A 应已改成退赛,得到 %s", got)
|
||
}
|
||
|
||
// 流水作废:旧流水留着当证据,有效流水一条不剩。
|
||
if got := activeLedgerPoints(t, env, ids.entryA); len(got) != 0 {
|
||
t.Fatalf("退赛后不应再有有效流水,得到 %v", got)
|
||
}
|
||
if n := countRows(t, env, `SELECT COUNT(*) FROM score_ledger WHERE entry_id = ? AND voided = 1`, ids.entryA); n == 0 {
|
||
t.Fatalf("旧流水应被作废留痕,得到 %d 条", n)
|
||
}
|
||
// 名次让出来:退赛的人不再占名次,B 升到第 1 名。
|
||
if got := ranksOf(t, env, []int64{ids.entryA}); len(got) != 0 {
|
||
t.Fatalf("退赛的报名项不该再占名次,得到 %v", got)
|
||
}
|
||
if got := rankOfEntry(t, env, ids.entryB); got != 1 {
|
||
t.Fatalf("B 应升到第 1 名,得到 %d", got)
|
||
}
|
||
// 榜单跟着变:A 归零,B 从第 2 名的 8 分变成第 1 名的 10 分。
|
||
if got := teamPoints(t, env, client, ids.teamA); got != 0 {
|
||
t.Fatalf("A 单位总分应归零,得到 %v", got)
|
||
}
|
||
if got := teamPoints(t, env, client, ids.teamB); got != 10 {
|
||
t.Fatalf("B 单位总分应变成 10,得到 %v", got)
|
||
}
|
||
// 大屏要收到新榜单,否则现场看到的还是「退赛者拿金牌」的旧画面。
|
||
if got := standingsPushes(t, env) - pushesBefore; got != 1 {
|
||
t.Fatalf("导入改状态应推 1 次榜单(standings replace),得到 %d", got)
|
||
}
|
||
}
|
||
|
||
// 名单导入与批量退赛是同一口径:实时 / 延迟两种结算模式下最终数据逐字节一致。
|
||
func TestImportScratchMatchesRealTime(t *testing.T) {
|
||
for _, withSettle := range []bool{true, false} {
|
||
live := runImportScenario(t, true, withSettle, "scratched")
|
||
deferred := runImportScenario(t, false, withSettle, "scratched")
|
||
if withSettle {
|
||
assertSameBefore(t, "名单导入改状态", live, deferred)
|
||
}
|
||
assertSameAfter(t, "名单导入改状态", live, deferred)
|
||
|
||
// 期望值与「批量退赛第 1 名」同一份:退赛者退出名次与流水,剩下 3 人 10/8/6。
|
||
wantLedger := []string{
|
||
entryLedgerRow(live.entries[1], live.teamB, 10),
|
||
entryLedgerRow(live.entries[2], live.teamA, 8),
|
||
entryLedgerRow(live.entries[3], live.teamB, 6),
|
||
}
|
||
if !reflect.DeepEqual(deferred.after.ledger, wantLedger) {
|
||
t.Fatalf("导入退赛后流水应重算成 %v,得到 %v", wantLedger, deferred.after.ledger)
|
||
}
|
||
wantRanks := map[int64]int{live.entries[1]: 1, live.entries[2]: 2, live.entries[3]: 3}
|
||
if !reflect.DeepEqual(deferred.after.ranks, wantRanks) {
|
||
t.Fatalf("导入退赛后名次应为 %v,得到 %v", wantRanks, deferred.after.ranks)
|
||
}
|
||
if got := deferred.pushesAfter - deferred.pushesBefore; got != 1 {
|
||
t.Fatalf("延迟结算下导入改状态也应推 1 次榜单,得到 %d", got)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 反例保护:只改不影响算分的字段(号码布 / 名单 / 批次)不触发重算,也不推榜单。
|
||
// 导入动辄几百行,逐行无条件重算会把现场拖慢,也会让大屏收到一串无意义刷新。
|
||
func TestImportNonScoringChangeDoesNotRecompute(t *testing.T) {
|
||
env, ids, client := recorderWithSeed(t)
|
||
seedZhangWeiIDCard(t, env, ids)
|
||
publishResults(t, env, client, ids.event, "import-non-scoring",
|
||
map[string]any{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"},
|
||
map[string]any{"entryId": ids.entryB, "resultValue": 11.92, "resultText": "11.92", "unit": "s", "status": "ok"},
|
||
)
|
||
|
||
ledgerBefore := ledgerRowsOf(t, env, ids.event, nil)
|
||
ranksBefore := ranksOf(t, env, []int64{ids.entryA, ids.entryB})
|
||
standingsBefore := rawData(t, env, client, "/api/standings")
|
||
pushesBefore := standingsPushes(t, env)
|
||
|
||
// 号码布 / 种子 / 团体名单都是展示字段;status 写 registered,与库中现值相同。
|
||
body := importHeader + "\nM100,男子,A,张伟," + importZhangWeiIDCard + ",男,,A-777,S-3,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["skipped"] != float64(0) {
|
||
t.Fatalf("应就地更新 1 条报名,得到 %+v", result)
|
||
}
|
||
// 先证明这一行确实写了(否则「没重算」可能只是行被跳过了)。
|
||
var bib, seed string
|
||
if err := env.ev.QueryRow(`SELECT COALESCE(bib, ''), COALESCE(seed, '') FROM entries WHERE id = ?`, ids.entryA).
|
||
Scan(&bib, &seed); err != nil {
|
||
t.Fatalf("读报名项: %v", err)
|
||
}
|
||
if bib != "A-777" || seed != "S-3" {
|
||
t.Fatalf("号码布 / 种子应已落库,得到 bib=%q seed=%q", bib, seed)
|
||
}
|
||
|
||
if got := ledgerRowsOf(t, env, ids.event, nil); !reflect.DeepEqual(got, ledgerBefore) {
|
||
t.Fatalf("不影响算分的改动不该动流水:%v → %v", ledgerBefore, got)
|
||
}
|
||
if got := ranksOf(t, env, []int64{ids.entryA, ids.entryB}); !reflect.DeepEqual(got, ranksBefore) {
|
||
t.Fatalf("不影响算分的改动不该动名次:%v → %v", ranksBefore, got)
|
||
}
|
||
if got := rawData(t, env, client, "/api/standings"); got != standingsBefore {
|
||
t.Fatalf("不影响算分的改动不该动榜单:\n之前 %s\n之后 %s", standingsBefore, got)
|
||
}
|
||
if got := standingsPushes(t, env); got != pushesBefore {
|
||
t.Fatalf("不影响算分的改动不该推榜单:%d → %d", pushesBefore, got)
|
||
}
|
||
}
|
||
|
||
// 幂等:同一份名单连导两次,第二次不该再改动流水与榜单(状态已经是 scratched,口径没变)。
|
||
func TestImportStatusChangeIsIdempotent(t *testing.T) {
|
||
env, ids, client := recorderWithSeed(t)
|
||
seedZhangWeiIDCard(t, env, ids)
|
||
publishResults(t, env, client, ids.event, "import-idempotent",
|
||
map[string]any{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"},
|
||
map[string]any{"entryId": ids.entryB, "resultValue": 11.92, "resultText": "11.92", "unit": "s", "status": "ok"},
|
||
)
|
||
body := importHeader + "\n" + importZhangWeiRow("scratched")
|
||
pushesBefore := standingsPushes(t, env)
|
||
|
||
for i := 0; i < 2; i++ {
|
||
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
|
||
if status != http.StatusOK || !env2.OK {
|
||
t.Fatalf("第 %d 次导入应 200,得到 %d %+v", i+1, status, env2.Error)
|
||
}
|
||
result := decodeData[map[string]any](t, env2)
|
||
if result["updated"] != float64(1) || result["skipped"] != float64(0) {
|
||
t.Fatalf("第 %d 次导入应就地更新 1 条,得到 %+v", i+1, result)
|
||
}
|
||
if i == 0 {
|
||
// 第一次确实改了口径:推一次榜。
|
||
if got := standingsPushes(t, env) - pushesBefore; got != 1 {
|
||
t.Fatalf("第一次导入应推 1 次榜单,得到 %d", got)
|
||
}
|
||
continue
|
||
}
|
||
// 第二次:状态没变,不该再重算、也不该再推榜。
|
||
if got := standingsPushes(t, env) - pushesBefore; got != 1 {
|
||
t.Fatalf("重复导入不该再推榜单,得到 %d 次(期望仍是 1)", got)
|
||
}
|
||
if got := activeLedgerPoints(t, env, ids.entryA); len(got) != 0 {
|
||
t.Fatalf("重复导入不该让退赛者重新有流水,得到 %v", got)
|
||
}
|
||
if got := rankOfEntry(t, env, ids.entryB); got != 1 {
|
||
t.Fatalf("重复导入后 B 仍应是第 1 名,得到 %d", got)
|
||
}
|
||
if got := teamPoints(t, env, client, ids.teamB); got != 10 {
|
||
t.Fatalf("重复导入后 B 单位总分应仍是 10,得到 %v", got)
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---------- 图片上传与静态访问 ----------
|
||
|
||
// 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)
|
||
}
|
||
}
|
||
|
||
// 小项的 scored / recordEligible 漏传 = 建表默认值(migrations/event/0004 里都是 1),
|
||
// 显式 false 才是 false;更新时漏传沿用库里的现值,与单位 / 大项的 active 同口径。
|
||
// 与基础数据包导入侧(export_handlers.go 里新建小项默认 true/true)也一致。
|
||
func TestEventBoolDefaultsFollowSchemaDefaults(t *testing.T) {
|
||
env := newTestEnv(t)
|
||
ids := env.seed(t)
|
||
client := recorderClient(t, env)
|
||
|
||
boolField := func(t *testing.T, body envelope, field string) bool {
|
||
t.Helper()
|
||
got, ok := decodeData[map[string]any](t, body)[field].(bool)
|
||
if !ok {
|
||
t.Fatalf("响应里 %s 不是布尔值: %+v", field, body.Data)
|
||
}
|
||
return got
|
||
}
|
||
|
||
// 库里也真的按接口回报的值落着,不只是响应体好看。
|
||
assertStoredFlags := func(t *testing.T, code string, want int) {
|
||
t.Helper()
|
||
var scored, recordEligible int
|
||
if err := env.ev.QueryRow(`SELECT scored, record_eligible FROM events WHERE code = ?`, code).
|
||
Scan(&scored, &recordEligible); err != nil {
|
||
t.Fatalf("查询小项 %s 的开关: %v", code, err)
|
||
}
|
||
if scored != want || recordEligible != want {
|
||
t.Fatalf("小项 %s 的开关应为 %d/%d,得到 %d/%d", code, want, want, scored, recordEligible)
|
||
}
|
||
}
|
||
|
||
base := map[string]any{"sportId": ids.sport, "code": "E-BOOL", "name": "开关缺省"}
|
||
|
||
status, body := env.do(client, http.MethodPost, "/api/events", base, true)
|
||
if status != http.StatusOK || !body.OK {
|
||
t.Fatalf("建小项应 200,得到 %d %+v", status, body.Error)
|
||
}
|
||
if !boolField(t, body, "scored") || !boolField(t, body, "recordEligible") {
|
||
t.Fatalf("漏传开关应取建表默认值 true: %+v", body.Data)
|
||
}
|
||
assertStoredFlags(t, "E-BOOL", 1)
|
||
eventID := int64(decodeData[map[string]any](t, body)["id"].(float64))
|
||
|
||
// 更新漏传沿用现值:库里是 true/true 时,只改名字不能把它们翻成 false。
|
||
rename := map[string]any{"sportId": ids.sport, "code": "E-BOOL", "name": "开关缺省(改名一)"}
|
||
status, body = env.do(client, http.MethodPut, fmt.Sprintf("/api/events/%d", eventID), rename, true)
|
||
if status != http.StatusOK || !body.OK {
|
||
t.Fatalf("改名应 200,得到 %d %+v", status, body.Error)
|
||
}
|
||
if !boolField(t, body, "scored") || !boolField(t, body, "recordEligible") {
|
||
t.Fatalf("更新漏传应沿用库里的 true: %+v", body.Data)
|
||
}
|
||
|
||
// 显式 false 才关得掉:接口不能无视调用方的明确意图。
|
||
turnOff := map[string]any{
|
||
"sportId": ids.sport, "code": "E-BOOL", "name": "开关缺省(改名二)",
|
||
"scored": false, "recordEligible": false,
|
||
}
|
||
status, body = env.do(client, http.MethodPut, fmt.Sprintf("/api/events/%d", eventID), turnOff, true)
|
||
if status != http.StatusOK || !body.OK {
|
||
t.Fatalf("显式关开关应 200,得到 %d %+v", status, body.Error)
|
||
}
|
||
if boolField(t, body, "scored") || boolField(t, body, "recordEligible") {
|
||
t.Fatalf("显式 false 应落库为 false: %+v", body.Data)
|
||
}
|
||
|
||
// 反过来:库里是 false/false 时,更新漏传也不能把它打开。
|
||
rename["name"] = "开关缺省(改名三)"
|
||
status, body = env.do(client, http.MethodPut, fmt.Sprintf("/api/events/%d", eventID), rename, true)
|
||
if status != http.StatusOK || !body.OK {
|
||
t.Fatalf("再改名应 200,得到 %d %+v", status, body.Error)
|
||
}
|
||
if boolField(t, body, "scored") || boolField(t, body, "recordEligible") {
|
||
t.Fatalf("更新漏传应沿用库里的 false: %+v", body.Data)
|
||
}
|
||
|
||
// 新建时显式传 false 同样照办,缺省值不覆盖调用方的明确意图。
|
||
explicitOff := map[string]any{
|
||
"sportId": ids.sport, "code": "E-BOOL-OFF", "name": "开关显式关闭",
|
||
"scored": false, "recordEligible": false,
|
||
}
|
||
status, body = env.do(client, http.MethodPost, "/api/events", explicitOff, true)
|
||
if status != http.StatusOK || !body.OK {
|
||
t.Fatalf("显式传 false 应 200,得到 %d %+v", status, body.Error)
|
||
}
|
||
if boolField(t, body, "scored") || boolField(t, body, "recordEligible") {
|
||
t.Fatalf("显式 false 应落库为 false: %+v", body.Data)
|
||
}
|
||
assertStoredFlags(t, "E-BOOL-OFF", 0)
|
||
assertStoredFlags(t, "E-BOOL", 0)
|
||
}
|
||
|
||
// ---------- 上传图片的删除与孤儿清理 ----------
|
||
|
||
// uploadOrphansBody 对应 GET /api/uploads/orphans 的 data。
|
||
type uploadOrphansBody struct {
|
||
Items []uploadOrphanBody `json:"items"`
|
||
Total int `json:"total"`
|
||
TotalBytes int64 `json:"totalBytes"`
|
||
}
|
||
|
||
type uploadOrphanBody struct {
|
||
Name string `json:"name"`
|
||
URL string `json:"url"`
|
||
Size int64 `json:"size"`
|
||
ModifiedAt string `json:"modifiedAt"`
|
||
}
|
||
|
||
type uploadDeleteFailureBody struct {
|
||
Name string `json:"name"`
|
||
Reason string `json:"reason"`
|
||
}
|
||
|
||
type uploadDeleteBody struct {
|
||
Deleted []string `json:"deleted"`
|
||
Failed []uploadDeleteFailureBody `json:"failed"`
|
||
}
|
||
|
||
// uploadAdminEnv 建好带项目目录的环境并登录 admin:
|
||
// 删文件与孤儿清理是系统级资源管理,只有 admin 能做(与字体删除同一档)。
|
||
func uploadAdminEnv(t *testing.T) (*testEnv, string, *http.Client) {
|
||
t.Helper()
|
||
env, uploadsDir := newUploadEnv(t)
|
||
env.createUser("admin", "admin-pass", "admin")
|
||
client, _ := env.client()
|
||
env.login(client, "admin", "admin-pass")
|
||
return env, uploadsDir, client
|
||
}
|
||
|
||
// uploadOne 上传一张图片,返回 /uploads/<随机名>。
|
||
func uploadOne(t *testing.T, env *testEnv, client *http.Client) string {
|
||
t.Helper()
|
||
status, body := env.postUpload(t, client, "logo.png", pngContent())
|
||
if status != http.StatusOK || !body.OK {
|
||
t.Fatalf("上传图片应 200,得到 %d %+v", status, body.Error)
|
||
}
|
||
return decodeData[uploadResultBody](t, body).Path
|
||
}
|
||
|
||
func uploadBaseName(path string) string {
|
||
return path[strings.LastIndex(path, "/")+1:]
|
||
}
|
||
|
||
type teamCreatedBody struct {
|
||
ID int64 `json:"id"`
|
||
}
|
||
|
||
// createTeamWithLogo 建一个挂 logo 的单位,返回 id;logo 引用点是 teams.logo_path。
|
||
func createTeamWithLogo(t *testing.T, env *testEnv, client *http.Client, code, name, logo string) int64 {
|
||
t.Helper()
|
||
status, body := env.do(client, http.MethodPost, "/api/teams", map[string]any{
|
||
"code": code, "name": name, "logoPath": logo, "active": true,
|
||
}, true)
|
||
if status != http.StatusOK || !body.OK {
|
||
t.Fatalf("建单位应 200,得到 %d %+v", status, body.Error)
|
||
}
|
||
return decodeData[teamCreatedBody](t, body).ID
|
||
}
|
||
|
||
// putImageRef 改一处赛事设置的图片引用(path 传空串表示清除)。
|
||
func putImageRef(t *testing.T, env *testEnv, client *http.Client, key, path string) {
|
||
t.Helper()
|
||
event := map[string]any{}
|
||
display := map[string]any{}
|
||
switch key {
|
||
case "event.logo_path":
|
||
event["logoPath"] = path
|
||
case "display.logo":
|
||
display["logo"] = path
|
||
case "display.background":
|
||
display["background"] = path
|
||
default:
|
||
t.Fatalf("未知的引用点 %s", key)
|
||
}
|
||
status, body := env.do(client, http.MethodPut, "/api/settings",
|
||
// requestId 必须带上目标值:服务端按 body.requestId 做幂等回放,
|
||
// 「先设成 A 再清空」用同一个 id 会让第二次直接回放第一次的响应,清空根本没落库。
|
||
map[string]any{"event": event, "display": display, "requestId": "req-ref-" + key + "-" + path}, true)
|
||
if status != http.StatusOK {
|
||
t.Fatalf("设置 %s 应 200,得到 %d %+v", key, status, body.Error)
|
||
}
|
||
}
|
||
|
||
func listOrphans(t *testing.T, env *testEnv, client *http.Client) uploadOrphansBody {
|
||
t.Helper()
|
||
status, body := env.do(client, http.MethodGet, "/api/uploads/orphans", nil, false)
|
||
if status != http.StatusOK || !body.OK {
|
||
t.Fatalf("列孤儿图片应 200,得到 %d %+v", status, body.Error)
|
||
}
|
||
return decodeData[uploadOrphansBody](t, body)
|
||
}
|
||
|
||
// 孤儿清单只列「没有任何引用点用到」的图片:赛事会标与单位图标都要被认出来。
|
||
func TestListUploadOrphansOnlyUnreferenced(t *testing.T) {
|
||
env, uploadsDir, client := uploadAdminEnv(t)
|
||
|
||
eventLogo := uploadOne(t, env, client)
|
||
teamLogo := uploadOne(t, env, client)
|
||
free := uploadOne(t, env, client)
|
||
if names := uploadsDirEntries(t, uploadsDir); len(names) != 3 {
|
||
t.Fatalf("应先落盘 3 张图片,实际 %v", names)
|
||
}
|
||
|
||
putImageRef(t, env, client, "event.logo_path", eventLogo)
|
||
createTeamWithLogo(t, env, client, "T1", "第一中学", teamLogo)
|
||
|
||
orphans := listOrphans(t, env, client)
|
||
if orphans.Total != 1 || len(orphans.Items) != 1 {
|
||
t.Fatalf("只应剩 1 张孤儿,得到 total=%d items=%+v", orphans.Total, orphans.Items)
|
||
}
|
||
item := orphans.Items[0]
|
||
if item.Name != uploadBaseName(free) {
|
||
t.Fatalf("孤儿应是未被引用的那张,得到 %q,期望 %q", item.Name, uploadBaseName(free))
|
||
}
|
||
if item.URL != free {
|
||
t.Fatalf("孤儿 URL = %q,期望 %q", item.URL, free)
|
||
}
|
||
if item.Size != int64(len(pngContent())) {
|
||
t.Fatalf("孤儿大小 = %d,期望 %d", item.Size, len(pngContent()))
|
||
}
|
||
if _, err := time.Parse(time.RFC3339, item.ModifiedAt); err != nil {
|
||
t.Fatalf("修改时间应是 RFC3339,得到 %q(%v)", item.ModifiedAt, err)
|
||
}
|
||
if orphans.TotalBytes != item.Size {
|
||
t.Fatalf("总占用 = %d,期望 %d", orphans.TotalBytes, item.Size)
|
||
}
|
||
|
||
// 把图配到大屏会标上,它就不再是孤儿。
|
||
putImageRef(t, env, client, "display.logo", free)
|
||
if after := listOrphans(t, env, client); after.Total != 0 || len(after.Items) != 0 {
|
||
t.Fatalf("全部被引用后应没有孤儿,得到 %+v", after)
|
||
}
|
||
}
|
||
|
||
// 没有任何上传时是空清单而不是错误,便于前端直接渲染空态。
|
||
func TestListUploadOrphansEmptyWhenNoUploads(t *testing.T) {
|
||
env, _, client := uploadAdminEnv(t)
|
||
orphans := listOrphans(t, env, client)
|
||
if orphans.Total != 0 || orphans.TotalBytes != 0 || orphans.Items == nil {
|
||
t.Fatalf("空目录应回空数组而不是 null:%+v(items=%#v)", orphans, orphans.Items)
|
||
}
|
||
}
|
||
|
||
// 删除被引用的图片必须 409,并把「被谁引用」写进提示;被引用的文件不能被删掉。
|
||
func TestDeleteUploadRejectsReferenced(t *testing.T) {
|
||
env, uploadsDir, client := uploadAdminEnv(t)
|
||
|
||
eventLogo := uploadOne(t, env, client)
|
||
displayLogo := uploadOne(t, env, client)
|
||
background := uploadOne(t, env, client)
|
||
teamLogo := uploadOne(t, env, client)
|
||
createTeamWithLogo(t, env, client, "T1", "第一中学", teamLogo)
|
||
putImageRef(t, env, client, "event.logo_path", eventLogo)
|
||
putImageRef(t, env, client, "display.logo", displayLogo)
|
||
putImageRef(t, env, client, "display.background", background)
|
||
|
||
cases := []struct {
|
||
path string
|
||
label string
|
||
}{
|
||
{eventLogo, "赛事会标"},
|
||
{displayLogo, "大屏会标"},
|
||
{background, "大屏背景图"},
|
||
{teamLogo, "单位「第一中学」的单位图标"},
|
||
}
|
||
for _, c := range cases {
|
||
status, body := env.do(client, http.MethodDelete, "/api/uploads/"+uploadBaseName(c.path), nil, true)
|
||
if status != http.StatusConflict {
|
||
t.Fatalf("删除被引用的 %s 应 409,得到 %d %+v", c.path, status, body.Error)
|
||
}
|
||
if body.Error == nil || !strings.Contains(body.Error.Message, c.label) {
|
||
t.Fatalf("409 提示应说明引用位置 %q:%+v", c.label, body.Error)
|
||
}
|
||
if _, err := os.Stat(filepath.Join(uploadsDir, uploadBaseName(c.path))); err != nil {
|
||
t.Fatalf("被引用的图片不能被删除 %s: %v", c.path, err)
|
||
}
|
||
}
|
||
|
||
// 清掉引用后同一张图就能删掉,文件确实消失。
|
||
putImageRef(t, env, client, "event.logo_path", "")
|
||
if status, body := env.do(client, http.MethodDelete, "/api/uploads/"+uploadBaseName(eventLogo), nil, true); status != http.StatusOK {
|
||
t.Fatalf("不再被引用的图片应可删除,得到 %d %+v", status, body.Error)
|
||
}
|
||
if _, err := os.Stat(filepath.Join(uploadsDir, uploadBaseName(eventLogo))); !os.IsNotExist(err) {
|
||
t.Fatalf("删除后文件应消失: %v", err)
|
||
}
|
||
|
||
// 名字合法但文件不存在 → 404;非法文件名一律 404,不做任何删除尝试。
|
||
if status, _ := env.do(client, http.MethodDelete, "/api/uploads/0123456789abcdef0123456789abcdef.png", nil, true); status != http.StatusNotFound {
|
||
t.Fatalf("删除不存在的图片应 404,得到 %d", status)
|
||
}
|
||
if status, _ := env.do(client, http.MethodDelete, "/api/uploads/notes.txt", nil, true); status != http.StatusNotFound {
|
||
t.Fatalf("非图片扩展名应 404,得到 %d", status)
|
||
}
|
||
}
|
||
|
||
// 被「彻底删除」(可恢复)的单位仍算引用:恢复后图标必须还在,所以不能当孤儿删掉。
|
||
func TestUploadOrphansCountsHiddenTeam(t *testing.T) {
|
||
env, _, client := uploadAdminEnv(t)
|
||
logo := uploadOne(t, env, client)
|
||
teamID := createTeamWithLogo(t, env, client, "T1", "第一中学", logo)
|
||
|
||
if status, body := env.do(client, http.MethodDelete, fmt.Sprintf("/api/teams/%d?purge=1", teamID), nil, true); status != http.StatusOK {
|
||
t.Fatalf("彻底删除单位应 200,得到 %d %+v", status, body.Error)
|
||
}
|
||
if orphans := listOrphans(t, env, client); orphans.Total != 0 {
|
||
t.Fatalf("隐藏单位的 logo 仍算被引用,不应进孤儿清单:%+v", orphans.Items)
|
||
}
|
||
if status, body := env.do(client, http.MethodDelete, "/api/uploads/"+uploadBaseName(logo), nil, true); status != http.StatusConflict {
|
||
t.Fatalf("隐藏单位的 logo 应拒删,得到 %d %+v", status, body.Error)
|
||
}
|
||
}
|
||
|
||
// 批量删除只动点名的文件:被引用的、不存在的、非法的逐条回报且不动文件。
|
||
func TestDeleteUploadOrphansBatch(t *testing.T) {
|
||
env, uploadsDir, client := uploadAdminEnv(t)
|
||
used := uploadOne(t, env, client)
|
||
orphanA := uploadOne(t, env, client)
|
||
orphanB := uploadOne(t, env, client)
|
||
createTeamWithLogo(t, env, client, "T1", "第一中学", used)
|
||
|
||
names := []string{uploadBaseName(orphanA), uploadBaseName(orphanB), uploadBaseName(used), "0123456789abcdef0123456789abcdef.png", "../config.yaml"}
|
||
status, body := env.do(client, http.MethodPost, "/api/uploads/orphans/delete",
|
||
map[string]any{"names": names, "requestId": "req-orphan-del-1"}, true)
|
||
if status != http.StatusOK || !body.OK {
|
||
t.Fatalf("批量删除应 200,得到 %d %+v", status, body.Error)
|
||
}
|
||
result := decodeData[uploadDeleteBody](t, body)
|
||
if len(result.Deleted) != 2 || result.Deleted[0] != uploadBaseName(orphanA) || result.Deleted[1] != uploadBaseName(orphanB) {
|
||
t.Fatalf("只应删掉两张孤儿,得到 %+v", result.Deleted)
|
||
}
|
||
if len(result.Failed) != 3 {
|
||
t.Fatalf("应有 3 条失败回报,得到 %+v", result.Failed)
|
||
}
|
||
reasonOf := func(name string) string {
|
||
for _, item := range result.Failed {
|
||
if item.Name == name {
|
||
return item.Reason
|
||
}
|
||
}
|
||
t.Fatalf("失败回报里缺少 %s:%+v", name, result.Failed)
|
||
return ""
|
||
}
|
||
if reason := reasonOf(uploadBaseName(used)); !strings.Contains(reason, "仍被引用") || !strings.Contains(reason, "单位「第一中学」") {
|
||
t.Fatalf("被引用条目的原因应点名引用位置,得到 %q", reason)
|
||
}
|
||
if reason := reasonOf("0123456789abcdef0123456789abcdef.png"); !strings.Contains(reason, "文件不存在") {
|
||
t.Fatalf("缺失文件应报不存在,得到 %q", reason)
|
||
}
|
||
if reason := reasonOf("../config.yaml"); !strings.Contains(reason, "文件名非法") {
|
||
t.Fatalf("非法文件名应被拒,得到 %q", reason)
|
||
}
|
||
|
||
for _, name := range []string{uploadBaseName(orphanA), uploadBaseName(orphanB)} {
|
||
if _, err := os.Stat(filepath.Join(uploadsDir, name)); !os.IsNotExist(err) {
|
||
t.Fatalf("孤儿 %s 应已删除: %v", name, err)
|
||
}
|
||
}
|
||
if _, err := os.Stat(filepath.Join(uploadsDir, uploadBaseName(used))); err != nil {
|
||
t.Fatalf("被引用的图片不能被批量删除: %v", err)
|
||
}
|
||
var audits int
|
||
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM audit_logs WHERE action = 'uploads.delete'`).Scan(&audits); err != nil {
|
||
t.Fatalf("查询删除审计: %v", err)
|
||
}
|
||
if audits != 1 {
|
||
t.Fatalf("批量删除应写一条 uploads.delete 审计,得到 %d", audits)
|
||
}
|
||
|
||
// 空清单直接拒绝,不能让「没传 names」变成「什么都没删但显示成功」。
|
||
if status, _ := env.do(client, http.MethodPost, "/api/uploads/orphans/delete",
|
||
map[string]any{"names": []string{}, "requestId": "req-orphan-del-2"}, true); status != http.StatusBadRequest {
|
||
t.Fatalf("空 names 应 400,得到 %d", status)
|
||
}
|
||
}
|
||
|
||
// 删除与清理是系统级资源管理:guest 401、recorder 403。
|
||
func TestUploadDeleteRequiresAdmin(t *testing.T) {
|
||
env, uploadsDir, admin := uploadAdminEnv(t)
|
||
orphan := uploadBaseName(uploadOne(t, env, admin))
|
||
env.createUser("recorder", "recorder-pass", "recorder")
|
||
|
||
guest, _ := env.client()
|
||
// 各自用对应的方法:GET 那条走的是 /api/uploads/{name} 之外的兜底路由,用错方法量到的是 404 而不是权限。
|
||
if status, _ := env.do(guest, http.MethodGet, "/api/uploads/orphans", nil, false); status != http.StatusForbidden {
|
||
t.Fatalf("guest 读孤儿清单应 403,得到 %d", status)
|
||
}
|
||
if status, _ := env.do(guest, http.MethodDelete, "/api/uploads/"+orphan, nil, false); status != http.StatusUnauthorized {
|
||
t.Fatalf("guest 删图片应 401,得到 %d", status)
|
||
}
|
||
if status, _ := env.do(guest, http.MethodPost, "/api/uploads/orphans/delete", map[string]any{"names": []string{orphan}}, false); status != http.StatusUnauthorized {
|
||
t.Fatalf("guest 批量删孤儿应 401,得到 %d", status)
|
||
}
|
||
|
||
recorder, _ := env.client()
|
||
env.login(recorder, "recorder", "recorder-pass")
|
||
if status, _ := env.do(recorder, http.MethodGet, "/api/uploads/orphans", nil, false); status != http.StatusForbidden {
|
||
t.Fatalf("recorder 读孤儿清单应 403,得到 %d", status)
|
||
}
|
||
if status, _ := env.do(recorder, http.MethodDelete, "/api/uploads/"+orphan, nil, true); status != http.StatusForbidden {
|
||
t.Fatalf("recorder 删图片应 403,得到 %d", status)
|
||
}
|
||
if status, _ := env.do(recorder, http.MethodPost, "/api/uploads/orphans/delete",
|
||
map[string]any{"names": []string{orphan}, "requestId": "req-orphan-rec"}, true); status != http.StatusForbidden {
|
||
t.Fatalf("recorder 批量删孤儿应 403,得到 %d", status)
|
||
}
|
||
// 权限不足的请求不能有任何副作用:那张孤儿必须还在。
|
||
if _, err := os.Stat(filepath.Join(uploadsDir, orphan)); err != nil {
|
||
t.Fatalf("权限不足的删除请求不该动文件: %v", err)
|
||
}
|
||
}
|
||
|
||
// 删除接口不能靠路径穿越打到 uploads 之外:uploads 同级的图片扩展名文件必须毫发无损。
|
||
func TestDeleteUploadRejectsTraversal(t *testing.T) {
|
||
env, uploadsDir, client := uploadAdminEnv(t)
|
||
projectDir := filepath.Dir(uploadsDir)
|
||
const secret = "outside-secret"
|
||
if err := os.MkdirAll(projectDir, 0o755); err != nil {
|
||
t.Fatalf("创建项目目录: %v", err)
|
||
}
|
||
outside := filepath.Join(projectDir, "secret.png")
|
||
if err := os.WriteFile(outside, []byte(secret), 0o644); err != nil {
|
||
t.Fatalf("写 uploads 之外的文件: %v", err)
|
||
}
|
||
|
||
paths := []string{
|
||
"/api/uploads/..%2Fsecret.png",
|
||
"/api/uploads/%2e%2e%2Fsecret.png",
|
||
"/api/uploads/a%2Fb.png",
|
||
}
|
||
for _, path := range paths {
|
||
status, _ := env.do(client, http.MethodDelete, path, nil, true)
|
||
if status == http.StatusOK {
|
||
t.Fatalf("%s 不应删成功", path)
|
||
}
|
||
if body, err := os.ReadFile(outside); err != nil || string(body) != secret {
|
||
t.Fatalf("uploads 之外的文件被动了 %s: %v %q", path, err, body)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 建运动员漏传 active 必须默认「启用」(与单位 / 大项 / 小项同口径):
|
||
// Go 的 bool 零值是 false,静默建成「停用」的人在报名选择器里等于不存在——
|
||
// 少写一个字段就变成另一种东西,只能靠 decodeJSONPresence 的「键是否存在」判断。
|
||
func TestAthleteActivePresenceDefaults(t *testing.T) {
|
||
env, ids, client := recorderWithSeed(t)
|
||
|
||
// 漏传 active:建出来是启用的,库里的 active 也真的是 1。
|
||
status, env2 := env.do(client, http.MethodPost, "/api/athletes", map[string]any{
|
||
"name": "无active", "gender": "男", "teamId": ids.teamA,
|
||
}, true)
|
||
if status != http.StatusOK || !env2.OK {
|
||
t.Fatalf("建人员应 200,得到 %d %+v", status, env2.Error)
|
||
}
|
||
created := decodeData[map[string]any](t, env2)
|
||
if created["active"] != true {
|
||
t.Fatalf("漏传 active 应建成启用,得到 %+v", created)
|
||
}
|
||
newID := int64(created["id"].(float64))
|
||
var storedActive int
|
||
if err := env.ev.QueryRow(`SELECT active FROM athletes WHERE id = ?`, newID).Scan(&storedActive); err != nil {
|
||
t.Fatalf("读人员启用状态: %v", err)
|
||
}
|
||
if storedActive != 1 {
|
||
t.Fatalf("漏传 active 应在库里落 1,得到 %d", storedActive)
|
||
}
|
||
|
||
// 显式 false 仍然是「停用」(不能把默认值做成「永远启用」)。
|
||
status, env2 = env.do(client, http.MethodPost, "/api/athletes", map[string]any{
|
||
"name": "显式停用", "gender": "女", "teamId": ids.teamA, "active": false,
|
||
}, true)
|
||
if status != http.StatusOK {
|
||
t.Fatalf("显式 active=false 应 200,得到 %d %+v", status, env2.Error)
|
||
}
|
||
if decodeData[map[string]any](t, env2)["active"] != false {
|
||
t.Fatalf("显式 active=false 应建成停用")
|
||
}
|
||
|
||
// 更新时漏传 active 沿用库里的现值:不能把在用的人静默停用。
|
||
status, env2 = env.do(client, http.MethodPut, fmt.Sprintf("/api/athletes/%d", newID), map[string]any{
|
||
"name": "无active改名", "gender": "男", "teamId": ids.teamA,
|
||
}, true)
|
||
if status != http.StatusOK || !env2.OK {
|
||
t.Fatalf("改名应 200,得到 %d %+v", status, env2.Error)
|
||
}
|
||
if decodeData[map[string]any](t, env2)["active"] != true {
|
||
t.Fatalf("更新漏传 active 应沿用启用状态,得到 %+v", decodeData[map[string]any](t, env2))
|
||
}
|
||
if err := env.ev.QueryRow(`SELECT active FROM athletes WHERE id = ?`, newID).Scan(&storedActive); err != nil {
|
||
t.Fatalf("读人员启用状态: %v", err)
|
||
}
|
||
if storedActive != 1 {
|
||
t.Fatalf("更新漏传 active 不该把人停用,库里得到 %d", storedActive)
|
||
}
|
||
|
||
// 显式 false 的更新仍能把人停用(「漏传 = 沿用」不是「永远不能停用」)。
|
||
if status, env2 = env.do(client, http.MethodPut, fmt.Sprintf("/api/athletes/%d", newID), map[string]any{
|
||
"name": "无active改名", "gender": "男", "teamId": ids.teamA, "active": false,
|
||
}, true); status != http.StatusOK {
|
||
t.Fatalf("显式停用应 200,得到 %d %+v", status, env2.Error)
|
||
}
|
||
if err := env.ev.QueryRow(`SELECT active FROM athletes WHERE id = ?`, newID).Scan(&storedActive); err != nil {
|
||
t.Fatalf("读人员启用状态: %v", err)
|
||
}
|
||
if storedActive != 0 {
|
||
t.Fatalf("显式 active=false 应把人停用,库里得到 %d", storedActive)
|
||
}
|
||
}
|
||
|
||
// ── 表头别名:中文表头也要能导入 ──────────────────────────────────────────────
|
||
//
|
||
// 现场手里同时有两种表:老的纯英文模板、以及新下载的中文模板。英文是规范名
|
||
// (取值、报错、纯英文模板都用它),中文只是别名;别名只加不改,纯英文的老表一字不改照常导入。
|
||
|
||
// importHeaderZH 是中文表头,与 importHeader 逐列对应(同样是 11 列,heat 列同样缺席)。
|
||
const importHeaderZH = "项目编码,组别,单位编码,姓名,证件号,性别,年龄,号码布,种子,状态,队员名单"
|
||
|
||
// importHeaderZHHeat 是带批次列的中文表头,与 importHeaderHeat 逐列对应。
|
||
const importHeaderZHHeat = "项目编码,组别,单位编码,姓名,证件号,性别,年龄,号码布,种子,批次,状态,队员名单"
|
||
|
||
// importHeaderEnglishLegacy 是升级前就在用的纯英文表头,**逐字钉在这里**(故意不复用 importHeader 常量):
|
||
// 别名解析只要碰坏了任何一个英文列名,这条立刻红。
|
||
const importHeaderEnglishLegacy = "event_code,category,team_code,athlete_name,id_card,gender,age,bib,seed,status,member_names"
|
||
|
||
// importEntryFields 读一条报名项的号码布 / 种子 / 批次 / 状态,用来验证值确实取自表里对应的那一列。
|
||
func importEntryFields(t *testing.T, env *testEnv, idCard string, eventID int64) (string, string, sql.NullInt64, string) {
|
||
t.Helper()
|
||
|
||
var (
|
||
bib, seed, status string
|
||
heat sql.NullInt64
|
||
)
|
||
if err := env.ev.QueryRow(
|
||
`SELECT COALESCE(bib, ''), COALESCE(seed, ''), heat, status FROM entries
|
||
WHERE event_id = ? AND athlete_id = (SELECT id FROM athletes WHERE id_card = ?)`,
|
||
eventID, idCard).Scan(&bib, &seed, &heat, &status); err != nil {
|
||
t.Fatalf("读报名项(证件号 %s): %v", idCard, err)
|
||
}
|
||
return bib, seed, heat, status
|
||
}
|
||
|
||
// 纯英文表头是既有现场文件的口径:别名功能上线后必须一字不改地照常导入。
|
||
func TestImportKeepsEnglishHeaderUnchanged(t *testing.T) {
|
||
env, ids, client := recorderWithSeed(t)
|
||
|
||
body := importHeaderEnglishLegacy + "\nM100,男子,A,张三,110101199001011234,男,26,101,种子1,registered,\n"
|
||
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
|
||
assertImportedAsZhangSan(t, env, status, env2)
|
||
|
||
bib, seed, heat, entryStatus := importEntryFields(t, env, "110101199001011234", ids.event)
|
||
if bib != "101" || seed != "种子1" || heat.Valid || entryStatus != "registered" {
|
||
t.Fatalf("英文表头各列应原样落库,得到 bib=%q seed=%q heat=%+v status=%q", bib, seed, heat, entryStatus)
|
||
}
|
||
|
||
// 大小写不敏感、首尾空格容错也是既有行为:表头识别的改动不能把它弄丢。
|
||
t.Run("大写与首尾空格", func(t *testing.T) {
|
||
env, _, client := recorderWithSeed(t)
|
||
|
||
header := " Event_Code ,CATEGORY, team_code ,ATHLETE_NAME,ID_CARD,GENDER,AGE,BIB,SEED,STATUS,MEMBER_NAMES"
|
||
body := header + "\nM100,男子,A,张三,110101199001011234,男,26,101,,registered,\n"
|
||
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
|
||
assertImportedAsZhangSan(t, env, status, env2)
|
||
})
|
||
}
|
||
|
||
// 中文表头:列名认得出还不够,每一列的值都要真的从中文那一列取。
|
||
func TestImportAcceptsChineseHeader(t *testing.T) {
|
||
env, ids, client := recorderWithSeed(t)
|
||
|
||
body := importHeaderZH + "\nM100,男子,A,张三,110101199001011234,男,26,101,种子1,registered,\n"
|
||
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
|
||
assertImportedAsZhangSan(t, env, status, env2)
|
||
|
||
bib, seed, _, entryStatus := importEntryFields(t, env, "110101199001011234", ids.event)
|
||
if bib != "101" || seed != "种子1" || entryStatus != "registered" {
|
||
t.Fatalf("中文列的值应落进对应字段,得到 bib=%q seed=%q status=%q", bib, seed, entryStatus)
|
||
}
|
||
|
||
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 != 26 {
|
||
t.Fatalf("「年龄」列应写进人员档案,得到 %+v", age)
|
||
}
|
||
}
|
||
|
||
// 批次列(heat)的中文写法:别名映射必须与英文列同等对待,值也要落进 entries.heat。
|
||
func TestImportAcceptsChineseHeatColumn(t *testing.T) {
|
||
env, ids, client := recorderWithSeed(t)
|
||
|
||
body := importHeaderZHHeat + "\nM100,男子,A,张三,110101199001011234,男,26,101,,2,registered,\n"
|
||
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
|
||
assertImportedAsZhangSan(t, env, status, env2)
|
||
|
||
_, _, heat, _ := importEntryFields(t, env, "110101199001011234", ids.event)
|
||
if !heat.Valid || heat.Int64 != 2 {
|
||
t.Fatalf("「批次」列应写进 entries.heat=2,得到 %+v", heat)
|
||
}
|
||
}
|
||
|
||
// 中英混用:每一列各自认名字,列与列之间互不影响(现场常拿英文模板补几列中文)。
|
||
func TestImportAcceptsMixedLanguageHeader(t *testing.T) {
|
||
env, _, client := recorderWithSeed(t)
|
||
|
||
header := "event_code,category,team_code,姓名,证件号,性别,年龄,号码布,种子,状态,队员名单"
|
||
body := header + "\nM100,男子,A,张三,110101199001011234,男,,,,registered,\n"
|
||
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
|
||
assertImportedAsZhangSan(t, env, status, env2)
|
||
}
|
||
|
||
// 同一列在一份表里出现两种写法(中英各来一次,或同一格重复两次)一律拦下:
|
||
// 两列都可能有数据,静默取其中一列等于丢掉另一列的内容,报错让人删掉重复的那一列更安全。
|
||
func TestImportRejectsSameColumnTwice(t *testing.T) {
|
||
for _, tc := range []struct {
|
||
name string
|
||
header string
|
||
wantIn []string
|
||
}{
|
||
{"中英各一次", "event_code,项目编码,category,team_code,athlete_name,bib,seed,status,member_names",
|
||
[]string{"表头重复", "event_code", "项目编码"}},
|
||
{"同一写法两次", "event_code,category,team_code,athlete_name,event_code,bib,seed,status,member_names",
|
||
[]string{"表头重复", "event_code"}},
|
||
{"中文别名重复", "项目编码,小项编码,组别,单位编码,姓名,号码布,种子,状态,队员名单",
|
||
[]string{"表头重复", "项目编码", "小项编码"}},
|
||
} {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
env, _, client := recorderWithSeed(t)
|
||
|
||
body := tc.header + "\nM100,男子,A,张三,110101199001011234,男,,,registered,\n"
|
||
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
|
||
if status != http.StatusBadRequest || env2.OK {
|
||
t.Fatalf("同一列写两遍应回 400,得到 %d %+v", status, env2.Error)
|
||
}
|
||
for _, want := range tc.wantIn {
|
||
if !strings.Contains(env2.Error.Message, want) {
|
||
t.Fatalf("message 应包含 %q,得到 %q", want, env2.Error.Message)
|
||
}
|
||
}
|
||
if !strings.Contains(env2.Error.Message, "第 1 行") {
|
||
t.Fatalf("message 应点到表头行,得到 %q", env2.Error.Message)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// 缺列提示中英并列:手拿中文模板的人看到「缺少列 athlete_name(姓名)」才知道自己缺的是哪一格。
|
||
func TestImportMissingColumnMessageIsBilingual(t *testing.T) {
|
||
for _, tc := range []struct {
|
||
name string
|
||
header string
|
||
want string
|
||
}{
|
||
{"中文表头缺姓名", "项目编码,组别,单位编码,证件号,性别,年龄,号码布,种子,状态,队员名单", "缺少列 athlete_name(姓名)"},
|
||
{"中文表头缺队员名单", "项目编码,组别,单位编码,姓名,证件号,性别,年龄,号码布,种子,状态", "缺少列 member_names(队员名单)"},
|
||
{"英文表头缺小项编码", "category,team_code,athlete_name,bib,seed,status,member_names", "缺少列 event_code(项目编码)"},
|
||
} {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
env, _, client := recorderWithSeed(t)
|
||
|
||
body := tc.header + "\nM100,男子,A,张三,110101199001011234,男,,,registered,\n"
|
||
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
|
||
if status != http.StatusBadRequest || env2.OK {
|
||
t.Fatalf("缺列应回 400,得到 %d %+v", status, env2.Error)
|
||
}
|
||
msg := env2.Error.Message
|
||
if !strings.Contains(msg, tc.want) {
|
||
t.Fatalf("message 应中英并列,期望包含 %q,得到 %q", tc.want, msg)
|
||
}
|
||
// 提示还要教人往哪看:别的名字(中文模板上没见过的写法)只是其中一种可能。
|
||
if !strings.Contains(msg, "第 1 行") || !strings.Contains(msg, "表头") {
|
||
t.Fatalf("message 应点到表头行,得到 %q", msg)
|
||
}
|
||
if !strings.Contains(msg, "中文") || !strings.Contains(msg, "英文") {
|
||
t.Fatalf("message 应说明中英表头都认,得到 %q", msg)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// 中文表头 + 各种编码:BOM 粘在中文列名上("\ufeff项目编码")是最容易翻车的组合,
|
||
// 编码处理(decodeTableText)与表头归一(normalizeImportHeader)两层都要兜住。
|
||
func TestImportAcceptsChineseHeaderInEveryEncoding(t *testing.T) {
|
||
plain := importHeaderZH + "\nM100,男子,A,张三,110101199001011234,男,26,101,,registered,\n"
|
||
for _, tc := range []struct {
|
||
name string
|
||
body string
|
||
}{
|
||
{"utf8_bom", "\uFEFF" + plain},
|
||
{"gb18030", encodeGB18030(t, plain)},
|
||
{"utf16le_bom", encodeUTF16LE(t, plain)},
|
||
} {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
env, _, client := recorderWithSeed(t)
|
||
|
||
status, env2 := env.postImport(t, client, "/api/entries/import", tc.body, "")
|
||
assertImportedAsZhangSan(t, env, status, env2)
|
||
})
|
||
}
|
||
}
|
||
|
||
// 表头格自身的容错:中文输入法打出的全角字母、全角空格、格子中间混进的 BOM
|
||
// 都不能退化成「缺少列 xxx」——那是升级前就踩过的坑。
|
||
func TestImportToleratesFullWidthHeaderCells(t *testing.T) {
|
||
env, _, client := recorderWithSeed(t)
|
||
|
||
header := "EVENT_CODE,\u3000category ,team_code,\uFEFF姓名 ,证件号,性别,年龄,号码布,种子,状态,队员名单"
|
||
body := header + "\nM100,男子,A,张三,110101199001011234,男,,,,registered,\n"
|
||
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
|
||
assertImportedAsZhangSan(t, env, status, env2)
|
||
}
|