Files
Scoreboard/server/internal/httpapi/testenv_test.go
T
AskaEth bc8b1ee6c0 feat: M3-M7 全模块落地 + 集成接线
- M3 认证/权限/审计/限流 + 全部 REST 接口
- M4 管理面板(MIUIx + 成绩录入/积分榜下钻/大屏控制)
- M5a 大屏页面(六页 + 明细覆盖层 + 缩放适配)
- M6 WebSocket 实时同步中心
- M7 xlsx 导出 + 赛事包 zip + 备份
- 规则包 YAML 导入导出(差异预览)
- 字体 vendor 子集化(4 套 x2 字重)
- 集成:realtime/export/rules 接入 main+httpapi;backup 子命令
- 修复:中间件包装 ResponseWriter 丢失 Hijacker,导致 WS 握手 501
2026-09-09 19:02:24 +08:00

196 lines
5.1 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()
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})
handler := httpapi.New(httpapi.Options{
Logger: logger,
Version: "test",
System: st.DB(),
Event: ev,
Auth: svc,
DataDir: filepath.Join(dir, "data"),
})
srv := httptest.NewServer(handler)
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
}