Files
kevin 067ade546f 后台管理支持上传与清空网站 Logo
- 新增 PUT /api/site/logo(multipart、仅图片,立即生效)与 DELETE /api/site/logo,均仅管理员可用
- Logo 文件引用计数自动管理:上传占用、替换/清空/切换外链释放旧文件;PUT /site 手填地址同样处理,挂载不存在的本地文件返回 400
- 抽出 file.IsImageUpload 供头像与 Logo 共用(按文件头探测图片)
- 后台管理 Logo 区保留外链输入,新增上传/清空按钮(无裁剪,校验类型与大小),三语文案补齐
- 补充引用计数、权限与校验测试并重新生成 Swagger 文档
2026-09-21 21:10:23 +08:00

272 lines
11 KiB
Go

package site_test
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"testing"
"rill/internal/model"
"rill/internal/testutil"
)
func decodeSetting(t *testing.T, body []byte) model.SiteSetting {
t.Helper()
var setting model.SiteSetting
if err := json.Unmarshal(body, &setting); err != nil {
t.Fatalf("解析站点设置响应失败: %v, body=%s", err, body)
}
return setting
}
func registerUser(t *testing.T, env *testutil.Env) model.User {
t.Helper()
w := testutil.Call(t, env.Router(""), http.MethodPost, "/api/auth/register", map[string]string{
"username": "siteuser", "email": "siteuser@example.com", "password": "secret123",
})
if w.Code != http.StatusCreated {
t.Fatalf("注册普通用户失败: %d, body=%s", w.Code, w.Body.String())
}
return testutil.DecodeUser(t, w)
}
func TestSiteSettings(t *testing.T) {
env := testutil.Setup(t)
public := env.Router("")
w := testutil.Call(t, public, http.MethodGet, "/api/site", nil)
if w.Code != http.StatusOK {
t.Fatalf("读取站点信息状态码 = %d, 期望 %d, body=%s", w.Code, http.StatusOK, w.Body.String())
}
seeded := decodeSetting(t, w.Body.Bytes())
if seeded.SiteName != "Rill" || seeded.Logo != "" || seeded.Footer != "" {
t.Fatalf("默认站点信息异常: %+v", seeded)
}
if w := testutil.Call(t, public, http.MethodPut, "/api/site", map[string]any{"site_name": "Hacked"}); w.Code != http.StatusUnauthorized {
t.Errorf("匿名更新状态码 = %d, 期望 %d", w.Code, http.StatusUnauthorized)
}
registered := registerUser(t, env)
normal := env.Router(env.Sign(registered.ID))
if w := testutil.Call(t, normal, http.MethodPut, "/api/site", map[string]any{"site_name": "Hacked"}); w.Code != http.StatusForbidden {
t.Errorf("普通用户更新状态码 = %d, 期望 %d, body=%s", w.Code, http.StatusForbidden, w.Body.String())
}
admin := env.AdminRouter()
w = testutil.Call(t, admin, http.MethodPut, "/api/site", map[string]any{
"site_name": "Rill 流媒体",
"logo": "https://example.com/logo.png",
"footer": "Copyright © 2026 Rill",
})
if w.Code != http.StatusOK {
t.Fatalf("管理员更新状态码 = %d, 期望 %d, body=%s", w.Code, http.StatusOK, w.Body.String())
}
updated := decodeSetting(t, w.Body.Bytes())
if updated.SiteName != "Rill 流媒体" || updated.Logo != "https://example.com/logo.png" || updated.Footer != "Copyright © 2026 Rill" {
t.Fatalf("更新结果异常: %+v", updated)
}
w = testutil.Call(t, public, http.MethodGet, "/api/site", nil)
if w.Code != http.StatusOK {
t.Fatalf("更新后读取状态码 = %d, 期望 %d", w.Code, http.StatusOK)
}
if got := decodeSetting(t, w.Body.Bytes()); got.SiteName != updated.SiteName || got.Logo != updated.Logo || got.Footer != updated.Footer {
t.Errorf("更新后读取结果异常: %+v", got)
}
var stored model.SiteSetting
if err := env.DB.First(&stored, model.SiteSettingID).Error; err != nil {
t.Fatalf("查询数据库失败: %v", err)
}
if stored.SiteName != updated.SiteName || stored.Logo != updated.Logo || stored.Footer != updated.Footer {
t.Errorf("数据库未更新: %+v", stored)
}
}
func TestSiteSettingsValidation(t *testing.T) {
env := testutil.Setup(t)
admin := env.AdminRouter()
cases := []struct {
name string
body map[string]any
}{
{"缺少站名", map[string]any{"logo": "https://example.com/logo.png"}},
{"站名为空白", map[string]any{"site_name": " "}},
{"站名超长", map[string]any{"site_name": strings.Repeat("站", 101)}},
{"Logo 超长", map[string]any{"site_name": "Rill", "logo": strings.Repeat("a", 501)}},
{"Footer 超长", map[string]any{"site_name": "Rill", "footer": strings.Repeat("a", 1001)}},
}
for _, tc := range cases {
w := testutil.Call(t, admin, http.MethodPut, "/api/site", tc.body)
if w.Code != http.StatusBadRequest {
t.Errorf("%s 状态码 = %d, 期望 %d, body=%s", tc.name, w.Code, http.StatusBadRequest, w.Body.String())
}
}
w := testutil.Call(t, admin, http.MethodPut, "/api/site", map[string]any{"site_name": "Rill", "logo": "", "footer": ""})
if w.Code != http.StatusOK {
t.Fatalf("清空可选字段状态码 = %d, 期望 %d, body=%s", w.Code, http.StatusOK, w.Body.String())
}
cleared := decodeSetting(t, w.Body.Bytes())
if cleared.Logo != "" || cleared.Footer != "" {
t.Errorf("清空失败: %+v", cleared)
}
}
func logoFileID(t *testing.T, logo string) uint {
t.Helper()
const prefix = "/api/files/"
if !strings.HasPrefix(logo, prefix) {
t.Fatalf("Logo 地址格式异常: %q", logo)
}
id, err := strconv.ParseUint(strings.TrimPrefix(logo, prefix), 10, 64)
if err != nil || id == 0 {
t.Fatalf("Logo 文件 ID 解析失败: %q", logo)
}
return uint(id)
}
func refCount(t *testing.T, env *testutil.Env, id uint) int64 {
t.Helper()
var record model.File
if err := env.DB.First(&record, id).Error; err != nil {
t.Fatalf("查询文件 %d 失败: %v", id, err)
}
return record.RefCount
}
func TestSiteLogoUpload(t *testing.T) {
env := testutil.Setup(t)
admin := env.AdminRouter()
public := env.Router("")
// 权限:匿名 401、普通用户 403。
if w := testutil.CallMultipart(t, public, http.MethodPut, "/api/site/logo", "file", "logo.png", testutil.PNG(t, 8, 8)); w.Code != http.StatusUnauthorized {
t.Errorf("匿名上传 Logo 状态码 = %d, 期望 %d", w.Code, http.StatusUnauthorized)
}
registered := registerUser(t, env)
normal := env.Router(env.Sign(registered.ID))
if w := testutil.CallMultipart(t, normal, http.MethodPut, "/api/site/logo", "file", "logo.png", testutil.PNG(t, 8, 8)); w.Code != http.StatusForbidden {
t.Errorf("普通用户上传 Logo 状态码 = %d, 期望 %d", w.Code, http.StatusForbidden)
}
if w := testutil.CallMultipart(t, admin, http.MethodPut, "/api/site/logo", "file", "logo.txt", []byte("not an image")); w.Code != http.StatusBadRequest {
t.Errorf("非图片上传状态码 = %d, 期望 %d, body=%s", w.Code, http.StatusBadRequest, w.Body.String())
}
// 首次上传立即生效并占用一次引用。
w := testutil.CallMultipart(t, admin, http.MethodPut, "/api/site/logo", "file", "logo.png", testutil.PNG(t, 16, 16))
if w.Code != http.StatusOK {
t.Fatalf("上传 Logo 状态码 = %d, 期望 %d, body=%s", w.Code, http.StatusOK, w.Body.String())
}
first := decodeSetting(t, w.Body.Bytes())
firstID := logoFileID(t, first.Logo)
if got := refCount(t, env, firstID); got != 1 {
t.Fatalf("首次上传引用计数 = %d, 期望 1", got)
}
if w := testutil.Call(t, public, http.MethodGet, first.Logo, nil); w.Code != http.StatusOK {
t.Errorf("公开访问 Logo 状态码 = %d, 期望 %d", w.Code, http.StatusOK)
}
if w := testutil.Call(t, public, http.MethodGet, "/api/site", nil); w.Code != http.StatusOK {
t.Errorf("读取站点信息状态码 = %d, 期望 %d", w.Code, http.StatusOK)
} else if got := decodeSetting(t, w.Body.Bytes()); got.Logo != first.Logo {
t.Errorf("站点信息 Logo = %q, 期望 %q", got.Logo, first.Logo)
}
// 相同图片秒传:引用计数不变。
if w := testutil.CallMultipart(t, admin, http.MethodPut, "/api/site/logo", "file", "same.png", testutil.PNG(t, 16, 16)); w.Code != http.StatusOK {
t.Fatalf("重复上传状态码 = %d, body=%s", w.Code, w.Body.String())
}
if got := refCount(t, env, firstID); got != 1 {
t.Errorf("重复上传后引用计数 = %d, 期望 1", got)
}
// 换图:旧文件释放引用,新文件占用一次。
w = testutil.CallMultipart(t, admin, http.MethodPut, "/api/site/logo", "file", "new.png", testutil.PNG(t, 24, 24))
if w.Code != http.StatusOK {
t.Fatalf("更换 Logo 状态码 = %d, body=%s", w.Code, w.Body.String())
}
second := decodeSetting(t, w.Body.Bytes())
secondID := logoFileID(t, second.Logo)
if secondID == firstID {
t.Fatal("更换 Logo 应生成新文件")
}
if got := refCount(t, env, firstID); got != 0 {
t.Errorf("旧 Logo 引用计数 = %d, 期望 0", got)
}
if got := refCount(t, env, secondID); got != 1 {
t.Errorf("新 Logo 引用计数 = %d, 期望 1", got)
}
// 清空 Logo:释放引用并置空。
w = testutil.Call(t, admin, http.MethodDelete, "/api/site/logo", nil)
if w.Code != http.StatusOK {
t.Fatalf("清空 Logo 状态码 = %d, body=%s", w.Code, w.Body.String())
}
if cleared := decodeSetting(t, w.Body.Bytes()); cleared.Logo != "" {
t.Errorf("清空后 Logo = %q, 期望空", cleared.Logo)
}
if got := refCount(t, env, secondID); got != 0 {
t.Errorf("清空后引用计数 = %d, 期望 0", got)
}
if w := testutil.Call(t, admin, http.MethodDelete, "/api/site/logo", nil); w.Code != http.StatusOK {
t.Errorf("重复清空状态码 = %d, 期望 %d", w.Code, http.StatusOK)
}
}
func TestSiteLogoUpdateReference(t *testing.T) {
env := testutil.Setup(t)
admin := env.AdminRouter()
// 上传一个未被引用的文件,再通过 PUT /site 挂为 Logo 应占用引用。
w := testutil.CallMultipart(t, admin, http.MethodPost, "/api/files", "file", "logo.png", testutil.PNG(t, 10, 10))
if w.Code != http.StatusCreated {
t.Fatalf("上传文件状态码 = %d, body=%s", w.Code, w.Body.String())
}
var record model.File
if err := json.Unmarshal(w.Body.Bytes(), &record); err != nil {
t.Fatalf("解析文件响应失败: %v", err)
}
logoURL := "/api/files/" + strconv.FormatUint(uint64(record.ID), 10)
if w := testutil.Call(t, admin, http.MethodPut, "/api/site", map[string]any{"site_name": "Rill", "logo": logoURL}); w.Code != http.StatusOK {
t.Fatalf("挂载本地 Logo 状态码 = %d, body=%s", w.Code, w.Body.String())
}
if got := refCount(t, env, record.ID); got != 1 {
t.Errorf("挂载后引用计数 = %d, 期望 1", got)
}
// 同值再次保存不重复计数。
if w := testutil.Call(t, admin, http.MethodPut, "/api/site", map[string]any{"site_name": "Rill", "logo": logoURL}); w.Code != http.StatusOK {
t.Fatalf("重复保存状态码 = %d, body=%s", w.Code, w.Body.String())
}
if got := refCount(t, env, record.ID); got != 1 {
t.Errorf("重复保存后引用计数 = %d, 期望 1", got)
}
// 换成外链:释放本地文件引用。
if w := testutil.Call(t, admin, http.MethodPut, "/api/site", map[string]any{"site_name": "Rill", "logo": "https://example.com/logo.png"}); w.Code != http.StatusOK {
t.Fatalf("切换外链状态码 = %d, body=%s", w.Code, w.Body.String())
}
if got := refCount(t, env, record.ID); got != 0 {
t.Errorf("切换外链后引用计数 = %d, 期望 0", got)
}
// 不存在的本地文件应拒绝。
if w := testutil.Call(t, admin, http.MethodPut, "/api/site", map[string]any{"site_name": "Rill", "logo": "/api/files/9999"}); w.Code != http.StatusBadRequest {
t.Errorf("无效本地 Logo 状态码 = %d, 期望 %d, body=%s", w.Code, http.StatusBadRequest, w.Body.String())
}
}
func TestSiteLogoSizeLimit(t *testing.T) {
env := testutil.Setup(t)
tooLarge := make([]byte, env.Cfg.MaxUploadBytes()+1)
copy(tooLarge, testutil.PNG(t, 2, 2))
if w := testutil.CallMultipart(t, env.AdminRouter(), http.MethodPut, "/api/site/logo", "file", "big.png", tooLarge); w.Code != http.StatusRequestEntityTooLarge {
t.Errorf("超限 Logo 状态码 = %d, 期望 %d, body=%s", w.Code, http.StatusRequestEntityTooLarge, w.Body.String())
}
}