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() return newTestEnvPrepared(t, nil, customize) } // newTestEnvPrepared 在赛事库迁移之前给测试一次插手的机会(prepare), // 用于构造「旧版赛事包」现场:先把库停在某条迁移之前、塞进老数据,再走 store.MigrateEvent // 的真实升级路径——与 main.go 打开旧包后补跑迁移的顺序完全一致。 func newTestEnvPrepared(t *testing.T, prepare func(ev *sql.DB), 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 prepare != nil { prepare(ev) } 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 }