package httpapi_test import ( "fmt" "net/http" "strings" "testing" ) // getTeamList 取单位列表并断言 200。 func getTeamList(t *testing.T, env *testEnv, client *http.Client, path string) []map[string]any { t.Helper() status, env2 := env.do(client, http.MethodGet, path, nil, false) if status != http.StatusOK || !env2.OK { t.Fatalf("GET %s 应 200,得到 %d %+v", path, status, env2.Error) } return decodeData[[]map[string]any](t, env2) } // findTeam 在列表里找某个单位,找不到返回 nil。 func findTeam(rows []map[string]any, id int64) map[string]any { for _, row := range rows { if row["id"] == float64(id) { return row } } return nil } // findStandingTeam 在积分榜里找某个单位的行(榜单行的主键字段是 teamId)。 func findStandingTeam(rows []map[string]any, id int64) map[string]any { for _, row := range rows { if row["teamId"] == float64(id) { return row } } return nil } // 彻底删除 = 对界面不可见:默认列表里没有它,但库里那行还在,业务数据一行没动。 func TestPurgeTeamHidesFromList(t *testing.T) { env, ids, client := recorderWithSeed(t) status, env2 := env.do(client, http.MethodDelete, fmt.Sprintf("/api/teams/%d?purge=1", ids.teamB), nil, true) if status != http.StatusOK || !env2.OK { t.Fatalf("彻底删除应 200,得到 %d %+v", status, env2.Error) } if n := countRows(t, env, `SELECT COUNT(*) FROM teams WHERE id = ?`, ids.teamB); n != 1 { t.Fatalf("彻底删除不是物理删除,单位行应还在库里,得到 %d 条", n) } if n := countRows(t, env, `SELECT COUNT(*) FROM teams WHERE id = ? AND deleted_at IS NOT NULL AND active = 0`, ids.teamB); n != 1 { t.Fatalf("彻底删除应置 deleted_at 并把 active 归 0,得到 %d 条", n) } rows := getTeamList(t, env, client, "/api/teams") if row := findTeam(rows, ids.teamB); row != nil { t.Fatalf("默认列表不该有已彻底删除的单位,得到 %+v", row) } if findTeam(rows, ids.teamA) == nil { t.Fatalf("默认列表应保留其它单位,得到 %+v", rows) } } // 不带参数的 DELETE 仍是停用:active = 0,但仍在默认列表里——停用与彻底删除必须能区分开。 func TestSoftDeleteTeamStaysInList(t *testing.T) { env, ids, client := recorderWithSeed(t) publishResults(t, env, client, ids.event, "team-soft", map[string]any{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"}, ) // purge=0 这类明确否定的写法也按停用处理。 for _, path := range []string{ fmt.Sprintf("/api/teams/%d", ids.teamA), fmt.Sprintf("/api/teams/%d?purge=0", ids.teamA), } { if status, env2 := env.do(client, http.MethodDelete, path, nil, true); status != http.StatusOK || !env2.OK { t.Fatalf("DELETE %s 应 200,得到 %d %+v", path, status, env2.Error) } } if n := countRows(t, env, `SELECT COUNT(*) FROM teams WHERE id = ? AND active = 0 AND deleted_at IS NULL`, ids.teamA); n != 1 { t.Fatalf("停用只该置 active = 0,得到 %d 条", n) } rows := getTeamList(t, env, client, "/api/teams") row := findTeam(rows, ids.teamA) if row == nil { t.Fatalf("停用的单位仍应在默认列表里,得到 %+v", rows) } if row["active"] != false || row["deletedAt"] != nil { t.Fatalf("停用的单位应是 active=false、deletedAt=null,得到 %+v", row) } } // 假删除的核心:报名、成绩、积分流水、人工调整、纪录、名下运动员全部原样,榜单一分不变。 func TestPurgeTeamKeepsAllDataAndStandings(t *testing.T) { env, ids, client := recorderWithSeed(t) publishResults(t, env, client, ids.event, "team-hide", 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"}, ) // 人工调整与引用该单位的纪录。 adj := env.insert(t, `INSERT INTO point_adjustments (team_id, delta, category, reason) VALUES (?, 5, 'bonus', 'A 的加分')`, ids.teamA) env.exec(t, `INSERT INTO score_ledger (team_id, event_id, source_type, source_id, points) VALUES (?, ?, 'adjustment', ?, 5)`, ids.teamA, ids.event, adj) recordID := env.insert(t, `INSERT INTO records (event_id, level, holder_name, holder_team_id, is_current, source) VALUES (?, 'meet', '张伟', ?, 1, 'history')`, ids.event, ids.teamA) // 前置:A 单位 10 + 调整 5 = 15,B 单位 8。 if got := teamPoints(t, env, client, ids.teamA); got != 15 { t.Fatalf("前置条件:A 单位总分应是 15,得到 %v", got) } entriesBefore := countRows(t, env, `SELECT COUNT(*) FROM entries WHERE team_id = ?`, ids.teamA) resultsBefore := countRows(t, env, `SELECT COUNT(*) FROM results WHERE entry_id = ?`, ids.entryA) ledgerBefore := countRows(t, env, `SELECT COUNT(*) FROM score_ledger WHERE team_id = ?`, ids.teamA) status, env2 := env.do(client, http.MethodDelete, fmt.Sprintf("/api/teams/%d?purge=1", ids.teamA), nil, true) if status != http.StatusOK || !env2.OK { t.Fatalf("彻底删除应 200,得到 %d %+v", status, env2.Error) } // 界面不可见。 if findTeam(getTeamList(t, env, client, "/api/teams"), ids.teamA) != nil { t.Fatalf("已彻底删除的单位不该出现在默认列表里") } // 业务数据一行不少。 if n := countRows(t, env, `SELECT COUNT(*) FROM entries WHERE team_id = ?`, ids.teamA); n != entriesBefore { t.Fatalf("报名不该被动:之前 %d 条,之后 %d 条", entriesBefore, n) } if n := countRows(t, env, `SELECT COUNT(*) FROM results WHERE entry_id = ?`, ids.entryA); n != resultsBefore { t.Fatalf("成绩不该被动:之前 %d 条,之后 %d 条", resultsBefore, n) } if n := countRows(t, env, `SELECT COUNT(*) FROM score_ledger WHERE team_id = ?`, ids.teamA); n != ledgerBefore { t.Fatalf("积分流水不该被动:之前 %d 条,之后 %d 条", ledgerBefore, n) } if n := countRows(t, env, `SELECT COUNT(*) FROM point_adjustments WHERE team_id = ?`, ids.teamA); n != 1 { t.Fatalf("人工调整不该被动,得到 %d 条", n) } if n := countRows(t, env, `SELECT COUNT(*) FROM records WHERE id = ? AND holder_team_id = ?`, recordID, ids.teamA); n != 1 { t.Fatalf("纪录不该被动,得到 %d 条", n) } if n := countRows(t, env, `SELECT COUNT(*) FROM athletes WHERE team_id = ?`, ids.teamA); n != 1 { t.Fatalf("名下运动员不该被动,得到 %d 条", n) } if n := countRows(t, env, `SELECT COUNT(*) FROM score_ledger WHERE entry_id = ? AND voided = 0`, ids.entryA); n != 1 { t.Fatalf("A 的有效流水应还是 1 条,得到 %d 条", n) } // 榜单不变:A 仍是 15 分、B 仍是 8 分,名次与分项字段都没动, // 且 A 的单位名照旧能补出来(大屏不会出现空白行)。 if got := teamPoints(t, env, client, ids.teamA); got != 15 { t.Fatalf("假删除不该改榜单:A 单位总分应还是 15,得到 %v", got) } if got := teamPoints(t, env, client, ids.teamB); got != 8 { t.Fatalf("假删除不该改榜单:B 单位总分应还是 8,得到 %v", got) } status, env2 = env.do(client, http.MethodGet, "/api/standings", nil, false) if status != http.StatusOK { t.Fatalf("读积分榜应 200,得到 %d", status) } row := findStandingTeam(decodeData[[]map[string]any](t, env2), ids.teamA) if row == nil { t.Fatalf("隐藏的单位若有分数,榜单上仍要有它(否则总分对不上)") } if row["teamCode"] != "A" || row["teamName"] != "第一分公司" { t.Fatalf("隐藏单位的展示字段不该变空,得到 %+v", row) } var rank int if err := env.ev.QueryRow(`SELECT rank FROM results WHERE entry_id = ?`, ids.entryA).Scan(&rank); err != nil { t.Fatalf("读成绩名次: %v", err) } if rank != 1 { t.Fatalf("名次不该被重算,A 应还是 1,得到 %d", rank) } assertNoForeignKeyViolation(t, env) // 审计记的是「隐藏」,能查到。 after := waitAudit(t, env, "team.purge", ids.teamA) if !strings.Contains(after, `"hidden":true`) || !strings.Contains(after, `"deletedAt":"`) { t.Fatalf("审计应写明这是隐藏并记下时间,得到 %s", after) } } // 单查已彻底删除的单位:admin 查得到(便于排查),非 admin 按不存在处理。 func TestPurgedTeamVisibleOnlyToAdmin(t *testing.T) { env, ids, client := recorderWithSeed(t) if status, env2 := env.do(client, http.MethodDelete, fmt.Sprintf("/api/teams/%d?purge=1", ids.teamA), nil, true); status != http.StatusOK { t.Fatalf("彻底删除应 200,得到 %d %+v", status, env2.Error) } path := fmt.Sprintf("/api/teams/%d", ids.teamA) if status, _ := env.do(client, http.MethodGet, path, nil, false); status != http.StatusNotFound { t.Fatalf("recorder 单查已删除的单位应 404,得到 %d", status) } env.createUser("admin", "admin-pass", "admin") adminClient, _ := env.client() env.login(adminClient, "admin", "admin-pass") status, env2 := env.do(adminClient, http.MethodGet, path, nil, false) if status != http.StatusOK || !env2.OK { t.Fatalf("admin 单查已删除的单位应 200,得到 %d %+v", status, env2.Error) } if got := decodeData[map[string]any](t, env2); got["deletedAt"] == nil { t.Fatalf("admin 查到的 DTO 应带 deletedAt,得到 %+v", got) } } // ?includeDeleted=1 只给 admin:admin 能拿到被隐藏的单位,recorder 直接 403。 func TestListTeamsIncludeDeletedRequiresAdmin(t *testing.T) { env, ids, client := recorderWithSeed(t) if status, env2 := env.do(client, http.MethodDelete, fmt.Sprintf("/api/teams/%d?purge=1", ids.teamA), nil, true); status != http.StatusOK { t.Fatalf("彻底删除应 200,得到 %d %+v", status, env2.Error) } status, env2 := env.do(client, http.MethodGet, "/api/teams?includeDeleted=1", nil, false) if status != http.StatusForbidden || env2.Error == nil || env2.Error.Code != "FORBIDDEN" { t.Fatalf("recorder 用 includeDeleted 应 403 FORBIDDEN,得到 %d %+v", status, env2.Error) } env.createUser("admin", "admin-pass", "admin") adminClient, _ := env.client() env.login(adminClient, "admin", "admin-pass") rows := getTeamList(t, env, adminClient, "/api/teams?includeDeleted=1") row := findTeam(rows, ids.teamA) if row == nil { t.Fatalf("admin 带 includeDeleted 应能看到被删的单位,得到 %+v", rows) } if row["deletedAt"] == nil { t.Fatalf("被删单位的 DTO 应带 deletedAt,得到 %+v", row) } // 不带开关时 admin 看到的默认列表同样排除它。 if findTeam(getTeamList(t, env, adminClient, "/api/teams"), ids.teamA) != nil { t.Fatalf("默认列表对 admin 也应排除被删的单位") } } // 导入命中已隐藏的单位:用编码复用同一个单位并复活,不新建第二个,也不报「单位不存在」。 func TestImportRevivesPurgedTeam(t *testing.T) { env, ids, client := recorderWithSeed(t) if status, env2 := env.do(client, http.MethodDelete, fmt.Sprintf("/api/teams/%d?purge=1", ids.teamA), nil, true); status != http.StatusOK { t.Fatalf("彻底删除应 200,得到 %d %+v", status, env2.Error) } // 团体行(athlete_name 为空)只挂单位,正好走 team_code 这条路。 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["skipped"] != float64(0) || result["created"] != float64(1) { t.Fatalf("已隐藏单位的编码应能直接导入,得到 %+v", result) } if n := countRows(t, env, `SELECT COUNT(*) FROM teams WHERE code = 'A'`); n != 1 { t.Fatalf("不该重复建档,得到 %d 个编码为 A 的单位", n) } if n := countRows(t, env, `SELECT COUNT(*) FROM teams WHERE id = ? AND deleted_at IS NULL AND active = 1`, ids.teamA); n != 1 { t.Fatalf("复活应清空 deleted_at 并恢复 active,得到 %d 条", n) } // 重新出现在界面上:默认列表里有它,单查也不再是 404。 if findTeam(getTeamList(t, env, client, "/api/teams"), ids.teamA) == nil { t.Fatalf("复活后应回到默认列表") } if status, _ := env.do(client, http.MethodGet, fmt.Sprintf("/api/teams/%d", ids.teamA), nil, false); status != http.StatusOK { t.Fatalf("复活后单查应 200,得到 %d", status) } } // 单位不存在时(含 purge=1)404 沿用仓库既有语义。 func TestPurgeMissingTeamReturns404(t *testing.T) { env, _, client := recorderWithSeed(t) status, env2 := env.do(client, http.MethodDelete, "/api/teams/999999?purge=1", nil, true) if status != http.StatusNotFound || env2.Error == nil || env2.Error.Message != "单位不存在" { t.Fatalf("不存在的单位应 404 单位不存在,得到 %d %+v", status, env2.Error) } }