Files
kevin 9bf397064d 新增文件上传接口与用户头像裁剪上传
- 新增 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 文档
2026-09-21 20:50:19 +08:00

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()
}