package httpapi_test import ( "fmt" "net/http" "reflect" "testing" ) // resultIDOf 取某条报名项当前成绩行的 id。 func resultIDOf(t *testing.T, env *testEnv, entryID int64) int64 { t.Helper() var id int64 if err := env.ev.QueryRow(`SELECT id FROM results WHERE entry_id = ?`, entryID).Scan(&id); err != nil { t.Fatalf("读成绩 id: %v", err) } return id } // 作废 → 重新录入 → 改 → 重新发布:名次、流水与榜单必须与「一开始就填对」逐字节一致(A16)。 // // 这是主人「手滑填错」场景的验收:填错不是删数据,改完重新发布后,榜上不该留下任何 // 「曾经填错」的痕迹(旧流水要作废、名次要让位、别人的名次要跟着回退)。 func TestReopenResultMatchesCorrectionFromScratch(t *testing.T) { // ① 现场路径:先按填错的值发布 → 作废这一条 → 重新录入 → 改成正确值 → 重新发布本组。 env, ids, client := recorderWithSeed(t) publishResults(t, env, client, ids.event, "typo", map[string]any{"entryId": ids.entryA, "resultValue": 11.92, "resultText": "11.92", "unit": "s", "status": "ok"}, map[string]any{"entryId": ids.entryB, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"}, ) // 填错的是 A 的成绩(11.50 填成了 11.92),改对后 A 该是第一。 // 前置:填错时 B 是第一,A 只有第 2 名。 if got := ranksOf(t, env, []int64{ids.entryA, ids.entryB}); got[ids.entryB] != 1 || got[ids.entryA] != 2 { t.Fatalf("前置名次不对: %v", got) } resultA := resultIDOf(t, env, ids.entryA) if status, env2 := env.do(client, http.MethodPost, fmt.Sprintf("/api/results/%d/void", resultA), map[string]any{"reason": "成绩填错", "requestId": "typo-void"}, true); status != http.StatusOK { t.Fatalf("作废应 200,得到 %d %+v", status, env2.Error) } // 重新录入要推一条 result 变更(大屏据此重拉成绩),change_log.action 只有 upsert/void/replace。 upsertsBefore := changeLogCount(t, env, "result", "upsert") standingsBefore := changeLogCount(t, env, "standings", "replace") status, env2 := env.do(client, http.MethodPost, fmt.Sprintf("/api/results/%d/reopen", resultA), map[string]any{"reason": "成绩填错,重新录入", "requestId": "typo-reopen"}, true) if status != http.StatusOK || !env2.OK { t.Fatalf("重新录入应 200,得到 %d %+v", status, env2.Error) } reopened := decodeData[map[string]any](t, env2) if reopened["state"] != "draft" || reopened["status"] != "ok" { t.Fatalf("重新录入后应是草稿且状态复位为 ok: %+v", reopened) } if reopened["rank"] != nil { t.Fatalf("重新录入后名次应被清空: %+v", reopened) } if reopened["note"] != "成绩填错,重新录入" { t.Fatalf("重新录入理由应写进 note: %+v", reopened) } if n := changeLogCount(t, env, "result", "upsert") - upsertsBefore; n != 1 { t.Fatalf("重新录入应写 1 条 result 变更,得到 %d", n) } if n := countRows(t, env, `SELECT COUNT(*) FROM audit_logs WHERE action = 'result.reopen'`); n != 1 { t.Fatalf("重新录入应写 1 条审计,得到 %d", n) } // 退回草稿后这一条立刻不再计分(回滚类动作无条件重算并推榜,与作废同口径): // 少了这一步,榜上就还挂着一条「已经退回草稿」的成绩。 if n := countRows(t, env, `SELECT COUNT(*) FROM score_ledger WHERE entry_id = ? AND voided = 0`, ids.entryA); n != 0 { t.Fatalf("退回草稿后该条不该再有有效流水,得到 %d 条", n) } if got := teamPoints(t, env, client, ids.teamA); got != 0 { t.Fatalf("退回草稿后单位总分应清零,得到 %v", got) } if n := changeLogCount(t, env, "standings", "replace") - standingsBefore; n < 1 { t.Fatalf("重新录入必须推榜(standings 变更),得到 %d 条", n) } // 退回草稿的意义就在这里:这一条现在真的能改(已发布行会被 saveDraft 拒绝)。 publishResults(t, env, client, ids.event, "fixed", map[string]any{"entryId": ids.entryA, "resultValue": 11.50, "resultText": "11.50", "unit": "s", "status": "ok"}, ) // ② 对照路径:同样两条成绩,一开始就填对(全新环境,报名项 / 单位 id 与上面逐一对应)。 fresh, freshIDs, freshClient := recorderWithSeed(t) publishResults(t, fresh, freshClient, freshIDs.event, "correct", map[string]any{"entryId": freshIDs.entryA, "resultValue": 11.50, "resultText": "11.50", "unit": "s", "status": "ok"}, map[string]any{"entryId": freshIDs.entryB, "resultValue": 11.92, "resultText": "11.92", "unit": "s", "status": "ok"}, ) entries := []int64{ids.entryA, ids.entryB} if got, want := ranksOf(t, env, entries), ranksOf(t, fresh, entries); !reflect.DeepEqual(got, want) { t.Fatalf("改错后重新发布的名次应与一开始就填对一致:%v vs %v", got, want) } if got, want := ledgerRowsOf(t, env, ids.event, nil), ledgerRowsOf(t, fresh, freshIDs.event, nil); !reflect.DeepEqual(got, want) { t.Fatalf("改错后重新发布的流水应与一开始就填对一致:\n%v\n%v", got, want) } if got, want := rawData(t, env, client, "/api/standings"), rawData(t, fresh, freshClient, "/api/standings"); got != want { t.Fatalf("改错后重新发布的榜单应与一开始就填对一致:\n%s\n%s", got, want) } } // 直接对一条「已发布」的成绩重新录入(不经作废):它必须立刻从榜上消失。 // // 现场更常见的路径是先作废再重新录入,但接口本身允许直接重录(只有草稿才拒绝), // 少了「无条件重算 + 推榜」,这一条就会一边是草稿、一边还在榜上计分。 func TestReopenPublishedResultDropsItFromBoard(t *testing.T) { env, ids, client := recorderWithSeed(t) publishResults(t, env, client, ids.event, "published-then-reopen", 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"}, ) if got := teamPoints(t, env, client, ids.teamA); got == 0 { t.Fatalf("前置:A 队应有名次分,得到 %v", got) } resultA := resultIDOf(t, env, ids.entryA) status, env2 := env.do(client, http.MethodPost, fmt.Sprintf("/api/results/%d/reopen", resultA), map[string]any{"reason": "要改成绩", "requestId": "reopen-published"}, true) if status != http.StatusOK || !env2.OK { t.Fatalf("重新录入应 200,得到 %d %+v", status, env2.Error) } if n := countRows(t, env, `SELECT COUNT(*) FROM score_ledger WHERE entry_id = ? AND voided = 0`, ids.entryA); n != 0 { t.Fatalf("退回草稿后不该还挂着旧流水,得到 %d 条", n) } if got := teamPoints(t, env, client, ids.teamA); got != 0 { t.Fatalf("退回草稿后 A 队总分应清零,得到 %v", got) } if got := ranksOf(t, env, []int64{ids.entryA}); len(got) != 0 { t.Fatalf("退回草稿后名次应让出来,得到 %v", got) } // 让出来的名次立刻归 B。 if got := ranksOf(t, env, []int64{ids.entryB}); got[ids.entryB] != 1 { t.Fatalf("A 让出的第 1 名应归 B,得到 %v", got) } } // 重新录入的校验:理由必填、非草稿才能退回、登记不存在 404;已是草稿时不许再退(否则会清掉录入员正在改的内容)。 func TestReopenResultValidation(t *testing.T) { env, ids, client := recorderWithSeed(t) publishResults(t, env, client, ids.event, "reopen-check", map[string]any{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"}, ) resultA := resultIDOf(t, env, ids.entryA) // 理由必填,措辞与作废一致。 status, env2 := env.do(client, http.MethodPost, fmt.Sprintf("/api/results/%d/reopen", resultA), map[string]any{}, true) if status != http.StatusBadRequest || env2.Error == nil { t.Fatalf("空理由应 400,得到 %d %+v", status, env2.Error) } // 登记不存在。 if status, env2 = env.do(client, http.MethodPost, "/api/results/999999/reopen", map[string]any{"reason": "不存在"}, true); status != http.StatusNotFound { t.Fatalf("成绩不存在应 404,得到 %d %+v", status, env2.Error) } // 已是草稿:再退一次只会把录入员正在填的内容清空,必须拒绝。 if status, env2 = env.do(client, http.MethodPost, fmt.Sprintf("/api/results/%d/reopen", resultA), map[string]any{"reason": "第一次", "requestId": "reopen-1"}, true); status != http.StatusOK { t.Fatalf("第一次重新录入应 200,得到 %d %+v", status, env2.Error) } status, env2 = env.do(client, http.MethodPost, fmt.Sprintf("/api/results/%d/reopen", resultA), map[string]any{"reason": "第二次", "requestId": "reopen-2"}, true) if status != http.StatusBadRequest || env2.Error == nil || env2.Error.Message != "该成绩已是草稿,可直接修改" { t.Fatalf("草稿再退回应 400「该成绩已是草稿,可直接修改」,得到 %d %+v", status, env2.Error) } // guest(未登录)不能重新录入。 guest, _ := env.client() if status, _ := env.do(guest, http.MethodPost, fmt.Sprintf("/api/results/%d/reopen", resultA), map[string]any{"reason": "guest"}, false); status != http.StatusUnauthorized { t.Fatalf("未登录重新录入应 401,得到 %d", status) } }