Files
Scoreboard/server/internal/httpapi/testenv_test.go
T
AskaEth 7f9701c465 fix: 审查修复波次(P0x4 + P1x30+)
- domain/rules: 计分与排名解耦、破纪录按组别、tieBreak 生效、调整分不丢、移除 aggregate 假能力
- realtime: 新建 internal/dto,快照三项契约对齐,/ws 只认会话身份
- httpapi: 流水幂等、standings 整表载荷、广播接线、强制改密、XFF 防伪造、records 写入、审计、赛事包内容、发布前备份、result.points、实例列表
- web: FormData 上传、YAML 解析、快照归一化、大屏解析层、bootstrap 降级、访客判定、主题切换
- console: 优雅关闭握手、中文化、免验证改管理员账号、选屏与日志轮转修复
- docs: 用户文档三份 + README
- 主代理: 破纪录加分接线(levels 未加载导致恒 0)
2026-09-09 21:06:40 +08:00

205 lines
5.4 KiB
Go

package httpapi_test
import (
"bytes"
"database/sql"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"testing"
"golang.org/x/crypto/bcrypt"
_ "modernc.org/sqlite"
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
"git.yeij.top/AskaEth/Scoreboard/server/internal/httpapi"
"git.yeij.top/AskaEth/Scoreboard/server/internal/store"
)
// testEnv 是集成测试的完整环境:临时系统库 + 临时赛事库 + httptest 服务。
type testEnv struct {
t *testing.T
server *httptest.Server
sys *sql.DB
ev *sql.DB
auth *auth.Service
}
func newTestEnv(t *testing.T) *testEnv {
t.Helper()
return newTestEnvWith(t, nil)
}
// newTestEnvWith 在默认环境基础上允许测试注入 Options(例如实时层)。
func newTestEnvWith(t *testing.T, customize func(*httpapi.Options)) *testEnv {
t.Helper()
dir := t.TempDir()
st, err := store.Open(filepath.Join(dir, "data"))
if err != nil {
t.Fatalf("打开系统库: %v", err)
}
t.Cleanup(func() { _ = st.Close() })
if err := st.Migrate(); err != nil {
t.Fatalf("迁移系统库: %v", err)
}
evPath := filepath.Join(dir, "data", "event_projs", "test", "event.db")
if err := os.MkdirAll(filepath.Dir(evPath), 0o755); err != nil {
t.Fatalf("创建赛事目录: %v", err)
}
ev, err := sql.Open("sqlite", "file:"+evPath+"?_pragma=foreign_keys(1)&_pragma=busy_timeout(5000)")
if err != nil {
t.Fatalf("打开赛事库: %v", err)
}
ev.SetMaxOpenConns(1)
t.Cleanup(func() { _ = ev.Close() })
if err := store.MigrateEvent(ev); err != nil {
t.Fatalf("迁移赛事库: %v", err)
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
svc := auth.New(st.DB(), auth.Options{Logger: logger, BcryptCost: bcrypt.MinCost})
opts := httpapi.Options{
Logger: logger,
Version: "test",
System: st.DB(),
Event: ev,
Auth: svc,
DataDir: filepath.Join(dir, "data"),
}
if customize != nil {
customize(&opts)
}
srv := httptest.NewServer(httpapi.New(opts))
t.Cleanup(srv.Close)
return &testEnv{t: t, server: srv, sys: st.DB(), ev: ev, auth: svc}
}
func (e *testEnv) createUser(username, password, role string) int64 {
e.t.Helper()
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.MinCost)
if err != nil {
e.t.Fatalf("生成密码哈希: %v", err)
}
res, err := e.sys.Exec(
`INSERT INTO users (username, password_hash, display_name, role, active, must_change_password)
VALUES (?, ?, ?, ?, 1, 0)`, username, string(hash), username, role)
if err != nil {
e.t.Fatalf("创建用户 %s: %v", username, err)
}
id, _ := res.LastInsertId()
return id
}
func (e *testEnv) client() (*http.Client, *cookiejar.Jar) {
e.t.Helper()
jar, err := cookiejar.New(nil)
if err != nil {
e.t.Fatalf("创建 cookie jar: %v", err)
}
return &http.Client{Jar: jar}, jar
}
type envelope struct {
OK bool `json:"ok"`
Data json.RawMessage `json:"data"`
Error *apiErrorBody `json:"error"`
}
type apiErrorBody struct {
Code string `json:"code"`
Message string `json:"message"`
Detail json.RawMessage `json:"detail"`
}
// do 发起请求;csrf=true 时带上与 csrf_token Cookie 一致的请求头。
func (e *testEnv) do(client *http.Client, method, path string, body any, csrf bool) (int, envelope) {
return e.doH(client, method, path, body, csrf, nil)
}
// doH 在 do 的基础上附加自定义请求头(例如 X-Request-Id)。
func (e *testEnv) doH(client *http.Client, method, path string, body any, csrf bool, headers map[string]string) (int, envelope) {
e.t.Helper()
var reader io.Reader
if body != nil {
raw, err := json.Marshal(body)
if err != nil {
e.t.Fatalf("序列化请求体: %v", err)
}
reader = bytes.NewReader(raw)
}
req, err := http.NewRequest(method, e.server.URL+path, reader)
if err != nil {
e.t.Fatalf("构造请求: %v", err)
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if csrf {
token := e.csrfToken(client)
if token == "" {
e.t.Fatalf("请求 %s %s 需要 CSRF,但 Cookie 里没有 csrf_token", method, path)
}
req.Header.Set("X-CSRF-Token", token)
}
for k, v := range headers {
req.Header.Set(k, v)
}
resp, err := client.Do(req)
if err != nil {
e.t.Fatalf("请求 %s %s: %v", method, path, 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 {
e.t.Fatalf("解析响应 %s %s: %v (%s)", method, path, err, raw)
}
}
return resp.StatusCode, env
}
func (e *testEnv) csrfToken(client *http.Client) string {
u, err := url.Parse(e.server.URL)
if err != nil {
e.t.Fatalf("解析测试服务地址: %v", err)
}
for _, c := range client.Jar.Cookies(u) {
if c.Name == auth.CookieCSRF {
return c.Value
}
}
return ""
}
func (e *testEnv) login(client *http.Client, username, password string) envelope {
e.t.Helper()
status, env := e.do(client, http.MethodPost, "/api/auth/login",
map[string]any{"username": username, "password": password}, false)
if status != http.StatusOK || !env.OK {
e.t.Fatalf("登录失败 status=%d env=%+v", status, env.Error)
}
return env
}
func decodeData[T any](t *testing.T, env envelope) T {
t.Helper()
var out T
if err := json.Unmarshal(env.Data, &out); err != nil {
t.Fatalf("解析 data: %v (%s)", err, env.Data)
}
return out
}