- 新增 storage 配置(存储目录、单文件大小上限),ConfigVersion 3→4 自动补全 - internal/file:上传(sha256 秒传去重)、删除(上传者或管理员,引用中 409)、公开查看;本地存储 + 操作日志 + 引用计数,仅安全类型内联防存储型 XSS - internal/avatar:PUT/DELETE /api/me/avatar,自动管理头像文件引用与旧头像解绑 - 前端引入 vue-advanced-cropper,个人中心支持上传/更换/删除头像,裁剪输出 512×512 JPEG;http 请求支持 FormData - 导出 auth.CurrentUser、新增 model.User.IsAdmin 与 testutil 多部件上传辅助,补充接口测试并重新生成 Swagger 文档
179 lines
4.5 KiB
Go
179 lines
4.5 KiB
Go
// 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()
|
|
}
|