- 新增 PUT /api/site/logo(multipart、仅图片,立即生效)与 DELETE /api/site/logo,均仅管理员可用 - Logo 文件引用计数自动管理:上传占用、替换/清空/切换外链释放旧文件;PUT /site 手填地址同样处理,挂载不存在的本地文件返回 400 - 抽出 file.IsImageUpload 供头像与 Logo 共用(按文件头探测图片) - 后台管理 Logo 区保留外链输入,新增上传/清空按钮(无裁剪,校验类型与大小),三语文案补齐 - 补充引用计数、权限与校验测试并重新生成 Swagger 文档
272 lines
11 KiB
Go
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())
|
|
}
|
|
}
|