Files
jiang13-bbs/backend/service/media_library_test.go
freefire ff2ab286fb feat: 书库导入导出/图片变体/书籍搜索/小组件运行时等
新增:
- 书库导入导出(library_import/library_export)及测试
- 图片变体生成(image_variants)与响应式图片(responsiveImage)
- 书籍搜索(bookSearch)+ BookSearch/LibrarySearchGrid 组件
- 小组件运行时(widgetRuntime)与静态检查(widgetLint)
- 上传缓存中间件(upload_cache)与字体 CSS 提取脚本

其它:
- 后端 handlers/services 全量调整
- 前端页面、组件、库函数与配置更新
2026-10-01 03:06:05 +08:00

369 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"bytes"
"fmt"
"image"
"image/color"
"image/png"
"os"
"path/filepath"
"strings"
"testing"
"time"
xwebp "golang.org/x/image/webp"
"github.com/freefire/jiang13-bbs/model"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
func newMediaLibraryService(t *testing.T) (*UploadService, string) {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(&model.Attachment{}, &model.User{}, &model.Post{}, &model.Comment{}); err != nil {
t.Fatalf("migrate: %v", err)
}
dir := t.TempDir()
s := NewUploadService(db, dir)
if err := s.EnsureDir(); err != nil {
t.Fatalf("ensure dir: %v", err)
}
return s, dir
}
func writeMediaFile(t *testing.T, dir, rel string, data []byte) {
t.Helper()
full := filepath.Join(dir, filepath.FromSlash(rel))
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(full, data, 0o644); err != nil {
t.Fatalf("write %s: %v", rel, err)
}
}
func tinyPNG() []byte {
src := image.NewRGBA(image.Rect(0, 0, 12, 8))
src.Set(0, 0, color.RGBA{1, 2, 3, 255})
var buf bytes.Buffer
_ = png.Encode(&buf, src)
return buf.Bytes()
}
func TestAdminMediaLibraryWebpPreferredWithOriginalURL(t *testing.T) {
s, dir := newMediaLibraryService(t)
writeMediaFile(t, dir, "images/pair.jpg", []byte("fake-jpeg-bytes"))
writeMediaFile(t, dir, "images/pair.webp", []byte("fake-webp-bytes"))
writeMediaFile(t, dir, "images/solo.png", tinyPNG())
writeMediaFile(t, dir, "images/trash.txt", []byte("not an image"))
writeMediaFile(t, dir, "images/partial.jpg.partial", []byte("temp"))
writeMediaFile(t, dir, "ads/qr.gif", []byte("GIF89a-fake"))
items, counts, err := s.AdminMediaLibrary()
if err != nil {
t.Fatalf("AdminMediaLibrary: %v", err)
}
byURL := map[string]MediaLibraryItem{}
for _, it := range items {
byURL[it.URL] = it
}
// 同名原图 + WebP 并存:只出 WebP 条目,原图挂 original_url
pair, ok := byURL["/uploads/images/pair.webp"]
if !ok {
t.Fatalf("pair.webp missing; items=%v", items)
}
if pair.OriginalURL != "/uploads/images/pair.jpg" {
t.Fatalf("want original pair.jpg, got %q", pair.OriginalURL)
}
if _, exists := byURL["/uploads/images/pair.jpg"]; exists {
t.Fatal("pair.jpg should be hidden behind webp")
}
// 无配对的单文件:保留原格式并解析出宽高
solo, ok := byURL["/uploads/images/solo.png"]
if !ok {
t.Fatal("solo.png missing")
}
if solo.MIME != "image/png" || solo.Width != 12 || solo.Height != 8 {
t.Fatalf("solo.png meta wrong: %+v", solo)
}
// 非图片与 .partial 被过滤
if _, exists := byURL["/uploads/images/trash.txt"]; exists {
t.Fatal("trash.txt should be excluded")
}
if _, exists := byURL["/uploads/images/partial.jpg.partial"]; exists {
t.Fatal(".partial should be excluded")
}
// 广告素材目录计入 ads 分类
if qr, ok := byURL["/uploads/ads/qr.gif"]; !ok || qr.Category != "ads" {
t.Fatalf("ads/qr.gif missing or wrong category: %+v", qr)
}
if counts["image"] != 2 || counts["ads"] != 1 {
t.Fatalf("counts wrong: %v", counts)
}
}
func TestAdminMediaLibraryMergesAttachmentMetaAndRemoteObjects(t *testing.T) {
s, dir := newMediaLibraryService(t)
// 附件记录引用的磁盘文件必须真实存在,否则该记录会被跳过(见 SkipsLocalMissingFiles)
writeMediaFile(t, dir, "images/abc.webp", []byte("fake-webp-bytes"))
created := time.Date(2026, 9, 1, 10, 0, 0, 0, time.UTC)
if err := s.db.Create(&model.User{Username: "alice", Nickname: "爱丽丝"}).Error; err != nil {
t.Fatalf("create user: %v", err)
}
var alice model.User
if err := s.db.Where("username = ?", "alice").First(&alice).Error; err != nil {
t.Fatalf("load user: %v", err)
}
if err := s.db.Create(&model.Attachment{
UserID: alice.ID, Kind: model.AttachmentKindImage,
URL: "/uploads/images/abc.webp", MIME: "image/webp",
Size: 4567, Width: 320, Height: 240, CreatedAt: created,
}).Error; err != nil {
t.Fatalf("create attachment: %v", err)
}
if err := s.db.Create(&model.Attachment{
UserID: alice.ID, Kind: model.AttachmentKindAvatar,
URL: "/api/media/remote01", MIME: "image/webp",
Size: 999, Width: 128, Height: 128, CreatedAt: created,
}).Error; err != nil {
t.Fatalf("create remote attachment: %v", err)
}
items, counts, err := s.AdminMediaLibrary()
if err != nil {
t.Fatalf("AdminMediaLibrary: %v", err)
}
byURL := map[string]MediaLibraryItem{}
for _, it := range items {
byURL[it.URL] = it
}
// 磁盘文件与附件记录合并:元数据以记录为准(上传者/尺寸/创建时间)
disk, ok := byURL["/uploads/images/abc.webp"]
if !ok {
t.Fatalf("attachment-backed local file missing: %+v", items)
}
if disk.Uploader != "爱丽丝" || disk.Size != 4567 || disk.Width != 320 || disk.Height != 240 {
t.Fatalf("meta not merged: %+v", disk)
}
// 远程存储对象:不在磁盘上也必须出现在媒体库
remote, ok := byURL["/api/media/remote01"]
if !ok {
t.Fatal("remote object missing")
}
if remote.Category != "avatar" || remote.Uploader != "爱丽丝" {
t.Fatalf("remote meta wrong: %+v", remote)
}
if counts["avatar"] != 1 {
t.Fatalf("avatar count wrong: %v", counts)
}
}
func TestAdminMediaLibrarySkipsLocalMissingFiles(t *testing.T) {
s, _ := newMediaLibraryService(t)
if err := s.db.Create(&model.Attachment{
UserID: 1, Kind: model.AttachmentKindImage,
URL: "/uploads/images/ghost.webp", MIME: "image/webp", Size: 1,
}).Error; err != nil {
t.Fatalf("create attachment: %v", err)
}
items, _, err := s.AdminMediaLibrary()
if err != nil {
t.Fatalf("AdminMediaLibrary: %v", err)
}
for _, it := range items {
if it.URL == "/uploads/images/ghost.webp" {
t.Fatal("local-missing file should not be listed")
}
}
}
func TestAdminMediaLibraryAttachSources(t *testing.T) {
s, dir := newMediaLibraryService(t)
writeMediaFile(t, dir, "avatars/av1.webp", []byte("fake-webp"))
writeMediaFile(t, dir, "images/p1.webp", []byte("fake-webp"))
writeMediaFile(t, dir, "images/c1.jpg", []byte("fake-jpeg"))
// 头像被 bob 使用
if err := s.db.Create(&model.User{
Username: "bob", Nickname: "阿Bob", Avatar: "/uploads/avatars/av1.webp",
}).Error; err != nil {
t.Fatalf("create user: %v", err)
}
// 帖子正文引用 p1
post := model.Post{UserID: 1, Title: "图片来源测试", Content: "看图 ![a](/uploads/images/p1.webp)"}
if err := s.db.Create(&post).Error; err != nil {
t.Fatalf("create post: %v", err)
}
// 评论引用 c1
if err := s.db.Create(&model.Comment{
PostID: post.ID, UserID: 1, Content: "评论里也有 /uploads/images/c1.jpg",
}).Error; err != nil {
t.Fatalf("create comment: %v", err)
}
items, _, err := s.AdminMediaLibrary()
if err != nil {
t.Fatalf("AdminMediaLibrary: %v", err)
}
byURL := map[string]MediaLibraryItem{}
for _, it := range items {
byURL[it.URL] = it
}
av, ok := byURL["/uploads/avatars/av1.webp"]
if !ok {
t.Fatal("avatar item missing")
}
if av.SourceURL != "/u/1" || av.SourceLabel != "@阿Bob 的头像" {
t.Fatalf("avatar source wrong: %+v", av)
}
pimg, ok := byURL["/uploads/images/p1.webp"]
if !ok {
t.Fatal("post image missing")
}
if pimg.SourceURL != fmt.Sprintf("/post/%d", post.ID) ||
!strings.Contains(pimg.SourceLabel, "图片来源测试") {
t.Fatalf("post source wrong: %+v", pimg)
}
cimg, ok := byURL["/uploads/images/c1.jpg"]
if !ok {
t.Fatal("comment image missing")
}
if cimg.SourceURL != fmt.Sprintf("/post/%d#comment-1", post.ID) {
t.Fatalf("comment source url wrong: %+v", cimg)
}
if !strings.Contains(cimg.SourceLabel, "下的评论") {
t.Fatalf("comment source label wrong: %+v", cimg)
}
}
func TestAdminMediaLibraryBookCategory(t *testing.T) {
s, dir := newMediaLibraryService(t)
// 书籍封面(本地磁盘)与正文插图(远程对象),另有一张普通帖子插图
writeMediaFile(t, dir, "images/cover.webp", []byte("fake-webp-bytes"))
if err := s.db.Create(&model.User{Username: "alice", Nickname: "爱丽丝"}).Error; err != nil {
t.Fatalf("create user: %v", err)
}
var alice model.User
if err := s.db.Where("username = ?", "alice").First(&alice).Error; err != nil {
t.Fatalf("load user: %v", err)
}
created := time.Date(2026, 9, 1, 10, 0, 0, 0, time.UTC)
mkAtt := func(source, url string) {
if err := s.db.Create(&model.Attachment{
UserID: alice.ID, Kind: model.AttachmentKindImage, Source: source,
URL: url, MIME: "image/webp", Size: 100, Width: 10, Height: 10,
CreatedAt: created,
}).Error; err != nil {
t.Fatalf("create attachment %s: %v", source, err)
}
}
mkAtt(model.AttachmentSourceLibraryCover, "/uploads/images/cover.webp")
mkAtt(model.AttachmentSourceLibraryContent, "/api/media/book-content")
mkAtt(model.AttachmentSourcePost, "/api/media/post-image")
items, counts, err := s.AdminMediaLibrary()
if err != nil {
t.Fatalf("AdminMediaLibrary: %v", err)
}
byURL := map[string]MediaLibraryItem{}
for _, it := range items {
byURL[it.URL] = it
}
// 封面(磁盘)与正文插图(远程)都归“书籍”
for _, u := range []string{"/uploads/images/cover.webp", "/api/media/book-content"} {
it, ok := byURL[u]
if !ok {
t.Fatalf("%s missing", u)
}
if it.Category != "book" || it.CategoryName != "书籍" {
t.Fatalf("%s should be book category, got %q/%q", u, it.Category, it.CategoryName)
}
}
// 普通帖子插图仍归 image
if post := byURL["/api/media/post-image"]; post.Category != "image" {
t.Fatalf("post image should stay image category, got %q", post.Category)
}
if counts["book"] != 2 || counts["image"] != 1 {
t.Fatalf("counts wrong: %v", counts)
}
}
func TestMediaThumb(t *testing.T) {
s, dir := newMediaLibraryService(t)
// 960×640 PNG → 缩到 480×320 的 WebP
big := image.NewRGBA(image.Rect(0, 0, 960, 640))
var bigBuf bytes.Buffer
if err := png.Encode(&bigBuf, big); err != nil {
t.Fatalf("encode png: %v", err)
}
writeMediaFile(t, dir, "images/big.png", bigBuf.Bytes())
// 小图(≤480)直接回原图
writeMediaFile(t, dir, "images/small.png", tinyPNG())
bigThumb, err := s.MediaThumb("/uploads/images/big.png")
if err != nil {
t.Fatalf("MediaThumb big: %v", err)
}
img, err := xwebp.Decode(bytes.NewReader(bigThumb))
if err != nil {
t.Fatalf("thumb not webp: %v", err)
}
if img.Bounds().Dx() != 480 || img.Bounds().Dy() != 320 {
t.Fatalf("thumb size wrong: %v", img.Bounds())
}
small, err := s.MediaThumb("/uploads/images/small.png")
if err != nil {
t.Fatalf("MediaThumb small: %v", err)
}
if !bytes.Equal(small, tinyPNG()) {
t.Fatal("small image should be returned as-is")
}
// 缓存命中:第二次调用结果一致
again, err := s.MediaThumb("/uploads/images/big.png")
if err != nil || !bytes.Equal(again, bigThumb) {
t.Fatalf("cache miss/mismatch: %v", err)
}
// 非法与越界路径
if _, err := s.MediaThumb("/api/media/xxx"); err == nil {
t.Fatal("remote url should be rejected")
}
if _, err := s.MediaThumb("/uploads/../secrets/x.png"); err == nil {
t.Fatal("traversal should be rejected")
}
if _, err := s.MediaThumb("/uploads/other/x.png"); err == nil {
t.Fatal("unknown category should be rejected")
}
}