package httpapi_test import ( "fmt" "net/http" "strings" "testing" ) // 本文件钉住「团体报名的队员身份」(devdocs/20)从报到榜的完整链路: // - 一条团体报名可以关联多个运动员(队员),0 个 = 只报单位(与升级前一致); // - 队员各拿**一份完整的**个人分、各一枚个人奖牌(不是按份额拆,别与联队抄混); // - **单位维度一份都不多**:单位总分与单位奖牌仍只算这条报名一次, // 由通道开关 count_to_team / count_to_team_medals 控制(双打现场是都不计入)。 // seedTeamEntryAthleteChannel 造一个「双打」小项:团体项目 + 一个按成绩排名的**个人**通道 // (现场的双打就是给团体报名配个人通道,分才上得了个人积分榜)。 // 两个单位开关由调用方给:现场是「都不计入」(0/0),默认是都计入(1/1)。 func seedTeamEntryAthleteChannel(t *testing.T, env *testEnv, sportID int64, countToTeam, countToTeamMedals int) (eventID, categoryID int64) { t.Helper() eventID = env.insert(t, `INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type, compare, scored, multiplier, status) VALUES (?, 'DBL', '羽毛球双打', 'team', 'auto', 'count', 'desc', 1, 1, 'ongoing')`, sportID) categoryID = env.insert(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order) VALUES (?, '男子', 1, 0)`, eventID) env.exec(t, `INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order, count_to_team, count_to_team_medals) VALUES (?, 'athlete', 'result', ?, 0, ?, ?)`, eventID, profileIDByCode(t, env, "individual_top6"), countToTeam, countToTeamMedals) return eventID, categoryID } // doublesImportRow 拼一行团体报名(不填姓名 = 团体行,队员名单写两个人名)。 func doublesImportRow(eventCode, category, teamCode string) string { return strings.Join([]string{eventCode, category, teamCode, "", "", "", "", "", "", "registered", "张伟/王五"}, ",") } // putEntryAthletes 调 PUT /api/entries/{id}/athletes 整组替换队员。 func putEntryAthletes(t *testing.T, env *testEnv, client *http.Client, entryID int64, athleteIDs ...int64) (int, envelope) { t.Helper() items := make([]map[string]any, 0, len(athleteIDs)) for _, id := range athleteIDs { items = append(items, map[string]any{"athleteId": id}) } return env.do(client, http.MethodPut, fmt.Sprintf("/api/entries/%d/athletes", entryID), map[string]any{"athletes": items}, true) } // entryAthleteRows 把某小项的队员关联表导成稳定文本。 func entryAthleteRows(t *testing.T, env *testEnv, eventID int64) string { t.Helper() return tableDump(t, env, fmt.Sprintf( `SELECT ea.entry_id, ea.athlete_id, a.name, ea.sort_order FROM entry_athletes ea JOIN athletes a ON a.id = ea.athlete_id JOIN entries e ON e.id = ea.entry_id WHERE e.event_id = %d ORDER BY ea.entry_id, ea.sort_order, ea.athlete_id`, eventID)) } // athleteBoard 是 GET /api/standings/athletes 的原始读数。 func athleteBoard(t *testing.T, env *testEnv, client *http.Client) []map[string]any { t.Helper() status, resp := env.do(client, http.MethodGet, "/api/standings/athletes", nil, false) if status != http.StatusOK || !resp.OK { t.Fatalf("读个人积分榜应 200,得到 %d %+v", status, resp.Error) } return decodeData[[]map[string]any](t, resp) } // athleteRow 取个人榜上某个运动员那一行(不在榜上直接失败)。 func athleteRow(t *testing.T, env *testEnv, client *http.Client, athleteID int64) map[string]any { t.Helper() for _, row := range athleteBoard(t, env, client) { if row["athleteId"] == float64(athleteID) { return row } } t.Fatalf("个人积分榜上没有运动员 %d:%+v", athleteID, athleteBoard(t, env, client)) return nil } // assertAthlete 断言个人榜上某人的总分与金银铜。 func assertAthlete(t *testing.T, env *testEnv, client *http.Client, athleteID int64, points float64, gold, silver, bronze int) { t.Helper() row := athleteRow(t, env, client, athleteID) if got := row["totalPoints"].(float64); got != points { t.Errorf("运动员 %d 个人总分 = %v,期望 %v(整行:%+v)", athleteID, got, points, row) } for name, want := range map[string]int{"gold": gold, "silver": silver, "bronze": bronze} { if got := row[name].(float64); got != float64(want) { t.Errorf("运动员 %d 的 %s = %v,期望 %d(整行:%+v)", athleteID, name, got, want, row) } } } // 双打:一条团体报名 + 两个队员 → 每人各拿一份完整的个人分、各一枚个人奖牌; // 单位维度仍然只算这条报名一次(10 分、1 枚金牌,不是 30 分、3 枚)。 func TestDoublesEntryMembersGetOwnIndividualScore(t *testing.T) { env, ids, client := recorderWithSeed(t) eventID, _ := seedTeamEntryAthleteChannel(t, env, ids.sport, 1, 1) // 双打是同一个单位的两个人:种子里另一个运动员在 B 单位,另外补一个 A 单位的人。 partner := env.insert(t, `INSERT INTO athletes (name, gender, team_id, bib, active) VALUES ('王五', '男', ?, 'A002', 1)`, ids.teamA) if status, resp := importCSV(t, env, client, doublesImportRow("DBL", "男子", "A")); status != http.StatusOK { t.Fatalf("导入双打报名应 200,得到 %d %+v", status, resp.Error) } entryID := firstEntryID(t, env, eventID) // 队员关联:这就是本单新增的写入路径(界面勾了谁,就把它留在报名上)。 if status, resp := putEntryAthletes(t, env, client, entryID, ids.athleteA, partner); status != http.StatusOK || !resp.OK { t.Fatalf("设置队员应 200,得到 %d %+v", status, resp.Error) } want := fmt.Sprintf("entry_id=%d athlete_id=%d name=张伟 sort_order=0 \nentry_id=%d athlete_id=%d name=王五 sort_order=1 \n", entryID, ids.athleteA, entryID, partner) if got := entryAthleteRows(t, env, eventID); got != want { t.Fatalf("队员关联落库不对:\n得到 %s期望 %s", got, want) } publishResults(t, env, client, eventID, "doubles-members", map[string]any{"entryId": entryID, "resultValue": 90, "resultText": "90", "unit": "次", "status": "ok"}) // 个人榜:两个队员各一份满分(第 1 名 10 分)+ 各一枚金牌。 assertAthlete(t, env, client, ids.athleteA, 10, 1, 0, 0) assertAthlete(t, env, client, partner, 10, 1, 0, 0) // 单位维度:仍然只算这条报名一次 —— 既不是两份 20 分,也不是三枚金牌。 assertStanding(t, env, client, ids.teamA, 10, 1, 0, 0) } // 现场的双打口径:通道两个单位开关都关掉 → 单位 0 分 0 牌,但队员的个人分与个人奖牌照发。 func TestDoublesEntryMembersWithTeamFlagsOff(t *testing.T) { env, ids, client := recorderWithSeed(t) eventID, _ := seedTeamEntryAthleteChannel(t, env, ids.sport, 0, 0) partner := env.insert(t, `INSERT INTO athletes (name, gender, team_id, bib, active) VALUES ('王五', '男', ?, 'A002', 1)`, ids.teamA) if status, resp := importCSV(t, env, client, doublesImportRow("DBL", "男子", "A")); status != http.StatusOK { t.Fatalf("导入双打报名应 200,得到 %d %+v", status, resp.Error) } entryID := firstEntryID(t, env, eventID) if status, resp := putEntryAthletes(t, env, client, entryID, ids.athleteA, partner); status != http.StatusOK { t.Fatalf("设置队员应 200,得到 %d %+v", status, resp.Error) } publishResults(t, env, client, eventID, "doubles-flags-off", map[string]any{"entryId": entryID, "resultValue": 90, "resultText": "90", "unit": "次", "status": "ok"}) // 单位总分与单位奖牌都不受队员关联影响(双打现场就是「都不计入」)。 assertStanding(t, env, client, ids.teamA, 0, 0, 0, 0) // 个人分与个人奖牌照发:一人一份满分、一人一枚金牌。 assertAthlete(t, env, client, ids.athleteA, 10, 1, 0, 0) assertAthlete(t, env, client, partner, 10, 1, 0, 0) } // 报名读数要带上队员(界面据此显示「队里有谁」,并能改)。 func TestEntryReadsIncludeAthletes(t *testing.T) { env, ids, client := recorderWithSeed(t) eventID, _ := seedTeamEntryAthleteChannel(t, env, ids.sport, 0, 0) partner := env.insert(t, `INSERT INTO athletes (name, gender, team_id, bib, active) VALUES ('王五', '男', ?, 'A002', 1)`, ids.teamA) env.exec(t, `UPDATE athletes SET bib = 'A001' WHERE id = ?`, ids.athleteA) if status, resp := importCSV(t, env, client, doublesImportRow("DBL", "男子", "A")); status != http.StatusOK { t.Fatalf("导入双打报名应 200,得到 %d %+v", status, resp.Error) } entryID := firstEntryID(t, env, eventID) if status, resp := putEntryAthletes(t, env, client, entryID, ids.athleteA, partner); status != http.StatusOK { t.Fatalf("设置队员应 200,得到 %d %+v", status, resp.Error) } status, resp := env.do(client, http.MethodGet, fmt.Sprintf("/api/events/%d/entries", eventID), nil, false) if status != http.StatusOK || !resp.OK { t.Fatalf("读成绩录入接口应 200,得到 %d %+v", status, resp.Error) } board := decodeData[entryBoard](t, resp) if len(board.Entries) != 1 { t.Fatalf("应有一条报名,得到 %+v", board.Entries) } athletes, _ := board.Entries[0]["athletes"].([]any) if len(athletes) != 2 { t.Fatalf("报名读数应带两个队员,得到 %+v", board.Entries[0]["athletes"]) } first, _ := athletes[0].(map[string]any) if first["athleteId"] != float64(ids.athleteA) || first["name"] != "张伟" || first["bib"] != "A001" { t.Fatalf("队员读数应带 athleteId / 姓名 / 号码布,得到 %+v", first) } } // 队员改动要能拦住会算错的写法,并且一律不落库。 func TestUpdateEntryAthletesRejectsBadPayloads(t *testing.T) { env, ids, client := recorderWithSeed(t) eventID, _ := seedTeamEntryAthleteChannel(t, env, ids.sport, 0, 0) if status, resp := importCSV(t, env, client, doublesImportRow("DBL", "男子", "A")); status != http.StatusOK { t.Fatalf("导入双打报名应 200,得到 %d %+v", status, resp.Error) } entryID := firstEntryID(t, env, eventID) if status, resp := putEntryAthletes(t, env, client, entryID, ids.athleteA); status != http.StatusOK { t.Fatalf("先设一个队员应 200,得到 %d %+v", status, resp.Error) } baseline := entryAthleteRows(t, env, eventID) // 个人报名(带运动员)不该有队员:它的身份就是那一个运动员。 personalEntryID := ids.entryA cases := []struct { name string path string body map[string]any want string }{ {"运动员不存在", fmt.Sprintf("/api/entries/%d/athletes", entryID), map[string]any{"athletes": []map[string]any{{"athleteId": 999999}}}, "不存在"}, {"缺 athleteId", fmt.Sprintf("/api/entries/%d/athletes", entryID), map[string]any{"athletes": []map[string]any{{"name": "张三"}}}, "athleteId"}, {"同一个人写两次", fmt.Sprintf("/api/entries/%d/athletes", entryID), map[string]any{"athletes": []map[string]any{{"athleteId": ids.athleteA}, {"athleteId": ids.athleteA}}}, "两次"}, {"个人报名不能挂队员", fmt.Sprintf("/api/entries/%d/athletes", personalEntryID), map[string]any{"athletes": []map[string]any{{"athleteId": ids.athleteA}}}, "个人报名"}, {"报名不存在", "/api/entries/999999/athletes", map[string]any{"athletes": []map[string]any{{"athleteId": ids.athleteA}}}, "不存在"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { status, resp := env.do(client, http.MethodPut, tc.path, tc.body, true) wantStatus := http.StatusBadRequest if tc.name == "报名不存在" { wantStatus = http.StatusNotFound } if status != wantStatus { t.Fatalf("应 %d,得到 %d %+v", wantStatus, status, resp) } if resp.Error == nil || !strings.Contains(resp.Error.Message, tc.want) { t.Fatalf("报错应含 %q,得到 %+v", tc.want, resp.Error) } if got := entryAthleteRows(t, env, eventID); got != baseline { t.Fatalf("被拒的请求改动了队员表:\n之前 %s之后 %s", baseline, got) } }) } // 空数组是合法值:清空队员 = 只报单位(与升级前一致)。 if status, resp := env.do(client, http.MethodPut, fmt.Sprintf("/api/entries/%d/athletes", entryID), map[string]any{"athletes": []map[string]any{}}, true); status != http.StatusOK || !resp.OK { t.Fatalf("清空队员应 200,得到 %d %+v", status, resp.Error) } if got := entryAthleteRows(t, env, eventID); got != "" { t.Fatalf("清空后队员表应为空,得到 %s", got) } }