package httpapi_test import ( "fmt" "net/http" "testing" ) // seedBatch 在 seed 的基础上补齐到 30 条报名项(第 1、2 条来自 seed), // 供「现场分批比赛」场景使用:跑道只有 8 条,一次只跑完一批。 func seedBatch(t *testing.T, env *testEnv, ids seedIDs, total int) []int64 { t.Helper() entries := []int64{ids.entryA, ids.entryB} for i := len(entries) + 1; i <= total; i++ { team := ids.teamA if i%2 == 0 { team = ids.teamB } athlete := env.insert(t, `INSERT INTO athletes (name, gender, team_id, active) VALUES (?, '男', ?, 1)`, fmt.Sprintf("运动员%02d", i), team) entries = append(entries, env.insert(t, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status) VALUES (?, ?, ?, ?, 'registered')`, ids.event, ids.category, team, athlete)) } return entries } // 批次草稿:前 filled 条填成绩,其余留空,全量发给服务端(与录入页 buildDraftPayload 一致)。 func batchDraft(entries []int64, filled, from int, times []float64) []map[string]any { rows := make([]map[string]any, 0, len(entries)-from) for i := from; i < len(entries); i++ { row := map[string]any{"entryId": entries[i], "status": "ok"} if i < filled { value := times[i] row["resultValue"] = value row["resultText"] = fmt.Sprintf("%.2f", value) row["unit"] = "s" } rows = append(rows, row) } return rows } func assertCount(t *testing.T, env *testEnv, query string, want int, args ...any) { t.Helper() var got int if err := env.ev.QueryRow(query, args...).Scan(&got); err != nil { t.Fatalf("统计失败: %v", err) } if got != want { t.Fatalf("期望 %d,得到 %d(%s)", want, got, query) } } func rankOfEntry(t *testing.T, env *testEnv, entryID int64) int { t.Helper() var rank int if err := env.ev.QueryRow(`SELECT rank FROM results WHERE entry_id = ?`, entryID).Scan(&rank); err != nil { t.Fatalf("读取名次: %v", err) } return rank } // 现场分批比赛:30 人报名,先跑完 8 人发布,再跑完 8 人发布。 // 关键要求:没跑/没录的行不阻塞发布、不被发布;已发布的先算名次积分,补发后重算要纳入先前批次。 func TestPartialPublishKeepsUnfinishedRowsDraft(t *testing.T) { env := newTestEnv(t) ids := env.seed(t) entries := seedBatch(t, env, ids, 30) env.createUser("recorder", "recorder-pass", "recorder") client, _ := env.client() env.login(client, "recorder", "recorder-pass") // 第一批:第 1–8 人跑完,成绩由慢到快;其余 22 人留空。 first := make([]float64, 30) for i := range first { first[i] = 20.0 + float64(i)*0.1 } if status, e := env.do(client, http.MethodPut, "/api/results/draft", map[string]any{ "eventId": ids.event, "categoryId": ids.category, "results": batchDraft(entries, 8, 0, first), }, true); status != http.StatusOK { t.Fatalf("保存第一批草稿应 200,得到 %d %+v", status, e.Error) } if status, e := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{ "eventId": ids.event, "categoryId": ids.category, }, true); status != http.StatusOK { t.Fatalf("缺 22 条成绩时发布应成功(不报错),得到 %d %+v", status, e.Error) } // 只有 8 条被发布,其余 22 条仍是草稿。 assertCount(t, env, `SELECT COUNT(*) FROM results WHERE event_id = ? AND state = 'published'`, 8, ids.event) assertCount(t, env, `SELECT COUNT(*) FROM results WHERE event_id = ? AND state = 'draft'`, 22, ids.event) // 未录行必须保持空值:发布不能给它们塞任何成绩。 assertCount(t, env, `SELECT COUNT(*) FROM results WHERE event_id = ? AND state = 'draft' AND result_value IS NULL AND rank IS NULL`, 22, ids.event) for i := 0; i < 8; i++ { if got := rankOfEntry(t, env, entries[i]); got != i+1 { t.Fatalf("第一批第 %d 人应第 %d 名,得到 %d", i+1, i+1, got) } } // 积分只按已发布的 8 人算:前六名 10+8+6+3+2+1=30 分。 assertCount(t, env, `SELECT CAST(COALESCE(SUM(points), 0) AS INTEGER) FROM score_ledger WHERE event_id = ? AND voided = 0 AND source_type = 'result'`, 30, ids.event) // 第二批:第 9–16 人跑完且明显更快。已发布的 1–8 不能再存草稿,只补未发布的行。 second := make([]float64, 30) for i := range second { second[i] = 10.0 + float64(i)*0.1 } if status, e := env.do(client, http.MethodPut, "/api/results/draft", map[string]any{ "eventId": ids.event, "categoryId": ids.category, "results": batchDraft(entries, 16, 8, second), }, true); status != http.StatusOK { t.Fatalf("补录第二批草稿应 200,得到 %d %+v", status, e.Error) } if status, e := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{ "eventId": ids.event, "categoryId": ids.category, }, true); status != http.StatusOK { t.Fatalf("第二批发布应 200,得到 %d %+v", status, e.Error) } assertCount(t, env, `SELECT COUNT(*) FROM results WHERE event_id = ? AND state = 'published'`, 16, ids.event) assertCount(t, env, `SELECT COUNT(*) FROM results WHERE event_id = ? AND state = 'draft'`, 14, ids.event) // 先前的 8 人必须被重算纳入:第二批更快,第一批整体退到第 9–16 名。 if got := rankOfEntry(t, env, entries[0]); got != 9 { t.Fatalf("补发后第一批第一名应退到第 9 名(证明重算纳入了先前批次),得到 %d", got) } if got := rankOfEntry(t, env, entries[8]); got != 1 { t.Fatalf("第二批最快者应第 1 名,得到 %d", got) } if got := rankOfEntry(t, env, entries[15]); got != 8 { t.Fatalf("第二批最慢者应第 8 名,得到 %d", got) } // 16 人里仍只有前六名得分:30 分;流水不能因为重算而翻倍。 assertCount(t, env, `SELECT CAST(COALESCE(SUM(points), 0) AS INTEGER) FROM score_ledger WHERE event_id = ? AND voided = 0 AND source_type = 'result'`, 30, ids.event) assertCount(t, env, `SELECT COUNT(*) FROM score_ledger WHERE event_id = ? AND voided = 0`, 6, ids.event) } // 一条有效成绩 + 29 条空成绩:发布必须成功,且只有那一条上屏。 // 这也是「服务端是否会因缺成绩报错」的回归断言。 func TestPublishWithSingleFilledRow(t *testing.T) { env := newTestEnv(t) ids := env.seed(t) entries := seedBatch(t, env, ids, 30) env.createUser("recorder", "recorder-pass", "recorder") client, _ := env.client() env.login(client, "recorder", "recorder-pass") times := make([]float64, 30) for i := range times { times[i] = 12.0 + float64(i)*0.1 } if status, e := env.do(client, http.MethodPut, "/api/results/draft", map[string]any{ "eventId": ids.event, "categoryId": ids.category, "results": batchDraft(entries, 1, 0, times), }, true); status != http.StatusOK { t.Fatalf("保存草稿应 200,得到 %d %+v", status, e.Error) } status, e := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{ "eventId": ids.event, "categoryId": ids.category, }, true) if status != http.StatusOK || !e.OK { t.Fatalf("缺 29 条成绩不是错误,发布应 200,得到 %d %+v", status, e.Error) } assertCount(t, env, `SELECT COUNT(*) FROM results WHERE event_id = ? AND state = 'published'`, 1, ids.event) assertCount(t, env, `SELECT COUNT(*) FROM results WHERE event_id = ? AND state = 'draft'`, 29, ids.event) if got := rankOfEntry(t, env, entries[0]); got != 1 { t.Fatalf("唯一有效成绩应第 1 名,得到 %d", got) } } // DNS / DQ 这类「没有成绩但确实是现场结果」的行必须能发布(不能被当成空行留在草稿)。 func TestPublishKeepsNonOKStatusWithoutValue(t *testing.T) { env := newTestEnv(t) ids := env.seed(t) env.createUser("recorder", "recorder-pass", "recorder") client, _ := env.client() env.login(client, "recorder", "recorder-pass") if status, e := env.do(client, http.MethodPut, "/api/results/draft", map[string]any{ "eventId": ids.event, "categoryId": ids.category, "results": []map[string]any{ {"entryId": ids.entryA, "resultValue": 11.5, "resultText": "11.50", "unit": "s", "status": "ok"}, {"entryId": ids.entryB, "status": "dns"}, }, }, true); status != http.StatusOK { t.Fatalf("保存草稿应 200,得到 %d %+v", status, e.Error) } if status, e := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{ "eventId": ids.event, "categoryId": ids.category, }, true); status != http.StatusOK { t.Fatalf("含 DNS 的组应能发布,得到 %d %+v", status, e.Error) } assertCount(t, env, `SELECT COUNT(*) FROM results WHERE event_id = ? AND state = 'published'`, 2, ids.event) assertCount(t, env, `SELECT COUNT(*) FROM results WHERE event_id = ? AND state = 'draft'`, 0, ids.event) }