// Package testutil 提供 HTTP 接口测试的公共环境:临时数据库、路由与凭证。 package testutil import ( "bytes" "context" "encoding/json" "image" "image/color" "image/png" "mime/multipart" "net/http" "net/http/httptest" "path/filepath" "testing" "github.com/gin-gonic/gin" "gorm.io/gorm" "rill/internal/api" "rill/internal/auth" "rill/internal/config" "rill/internal/database" "rill/internal/model" ) // Env 测试环境:临时数据库、配置、认证器与初始管理员。 type Env struct { T *testing.T DB *gorm.DB Cfg *config.Config Authn *auth.Authenticator Admin model.User } // Setup 创建临时 SQLite 数据库并执行全部迁移。 func Setup(t *testing.T) *Env { t.Helper() gin.SetMode(gin.TestMode) cfg := &config.Config{ Database: config.DatabaseConfig{ Driver: "sqlite3", ConnectTimeout: "5s", SQLite: config.SQLiteConfig{Path: filepath.Join(t.TempDir(), "test.db")}, }, Auth: config.AuthConfig{ Secret: "test-secret", TokenTTL: "1h", }, Storage: config.StorageConfig{ Dir: filepath.Join(t.TempDir(), "uploads"), MaxSizeMB: 1, }, API: config.APIConfig{Prefix: "/api"}, } db, err := database.Open(cfg) if err != nil { t.Fatalf("打开测试数据库失败: %v", err) } t.Cleanup(func() { if err := database.Close(db); err != nil { t.Errorf("关闭测试数据库失败: %v", err) } }) if err := database.Migrate(context.Background(), db); err != nil { t.Fatalf("执行测试迁移失败: %v", err) } env := &Env{T: t, DB: db, Cfg: cfg, Authn: auth.NewAuthenticator(cfg)} if err := db.Where("username = ?", "admin").First(&env.Admin).Error; err != nil { t.Fatalf("查询初始管理员失败: %v", err) } return env } // Router 返回注入了 Authorization 头的路由;token 为空表示匿名访问。 func (e *Env) Router(token string) *gin.Engine { e.T.Helper() r := gin.New() if token != "" { r.Use(func(c *gin.Context) { c.Request.Header.Set("Authorization", "Bearer "+token) c.Next() }) } api.RegisterRoutes(r.Group("/api"), e.DB, e.Cfg) return r } // Sign 使用测试密钥为指定用户签发凭证。 func (e *Env) Sign(userID uint) string { e.T.Helper() token, _, err := e.Authn.Sign(userID) if err != nil { e.T.Fatalf("签发测试凭证失败: %v", err) } return token } // AdminRouter 返回以初始管理员身份访问的路由。 func (e *Env) AdminRouter() *gin.Engine { e.T.Helper() return e.Router(e.Sign(e.Admin.ID)) } // Call 发送 JSON 请求并返回响应记录器。 func Call(t *testing.T, r http.Handler, method, path string, body any) *httptest.ResponseRecorder { t.Helper() var payload []byte if body != nil { var err error payload, err = json.Marshal(body) if err != nil { t.Fatalf("序列化请求体失败: %v", err) } } req := httptest.NewRequest(method, path, bytes.NewReader(payload)) if body != nil { req.Header.Set("Content-Type", "application/json") } w := httptest.NewRecorder() r.ServeHTTP(w, req) return w } // CallMultipart 发送单文件 multipart/form-data 请求并返回响应记录器。 func CallMultipart(t *testing.T, r http.Handler, method, path, field, filename string, content []byte) *httptest.ResponseRecorder { t.Helper() var body bytes.Buffer writer := multipart.NewWriter(&body) part, err := writer.CreateFormFile(field, filename) if err != nil { t.Fatalf("创建 multipart 字段失败: %v", err) } if _, err := part.Write(content); err != nil { t.Fatalf("写入 multipart 内容失败: %v", err) } if err := writer.Close(); err != nil { t.Fatalf("关闭 multipart 失败: %v", err) } req := httptest.NewRequest(method, path, &body) req.Header.Set("Content-Type", writer.FormDataContentType()) w := httptest.NewRecorder() r.ServeHTTP(w, req) return w } // DecodeUser 解析用户响应。 func DecodeUser(t *testing.T, w *httptest.ResponseRecorder) model.User { t.Helper() var user model.User if err := json.Unmarshal(w.Body.Bytes(), &user); err != nil { t.Fatalf("解析用户响应失败: %v, body=%s", err, w.Body.String()) } return user } // PNG 生成指定尺寸的测试 PNG 图片内容。 func PNG(t *testing.T, width, height int) []byte { t.Helper() img := image.NewRGBA(image.Rect(0, 0, width, height)) for y := 0; y < height; y++ { for x := 0; x < width; x++ { img.Set(x, y, color.RGBA{R: uint8(x * 7), G: uint8(y * 11), B: 128, A: 255}) } } var buf bytes.Buffer if err := png.Encode(&buf, img); err != nil { t.Fatalf("生成测试图片失败: %v", err) } return buf.Bytes() }