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