Files
jiang13-bbs/backend/service/image_variants_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

283 lines
7.4 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"
"image"
"image/color"
"image/gif"
"image/jpeg"
"image/png"
"io"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/freefire/jiang13-bbs/config"
webpenc "github.com/gen2brain/webp"
xwebp "golang.org/x/image/webp"
)
// solidImg 生成渐变纯色测试图
func solidImg(w, h int) *image.RGBA {
src := image.NewRGBA(image.Rect(0, 0, w, h))
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
src.Set(x, y, color.RGBA{uint8(x % 256), uint8(y % 256), 128, 255})
}
}
return src
}
// writeVariantSource 把按编码器生成的文件写到 uploads 目录
func writeVariantSource(t *testing.T, dir, url string, encode func(io.Writer) error) {
t.Helper()
abs := filepath.Join(dir, filepath.FromSlash(strings.TrimPrefix(url, "/uploads/")))
if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
f, err := os.Create(abs)
if err != nil {
t.Fatalf("create: %v", err)
}
defer f.Close()
if err := encode(f); err != nil {
t.Fatalf("encode: %v", err)
}
}
func encodeJPEG(img image.Image) func(io.Writer) error {
return func(w io.Writer) error {
return jpeg.Encode(w, img, &jpeg.Options{Quality: 85})
}
}
func encodePNG(img image.Image) func(io.Writer) error {
return func(w io.Writer) error {
return png.Encode(w, img)
}
}
func encodeWebP(img image.Image) func(io.Writer) error {
return func(w io.Writer) error {
return webpenc.Encode(w, img, webpenc.Options{Quality: 85})
}
}
func encodeGIF(img image.Image) func(io.Writer) error {
return func(w io.Writer) error {
return gif.Encode(w, img, nil)
}
}
// decodeVariant 解码返回的 WebP 变体
func decodeVariant(t *testing.T, data []byte) image.Image {
t.Helper()
img, err := xwebp.Decode(bytes.NewReader(data))
if err != nil {
t.Fatalf("decode variant webp: %v", err)
}
return img
}
func TestIsVariantWidth(t *testing.T) {
if !IsVariantWidth(48) || !IsVariantWidth(1920) {
t.Fatal("whitelist endpoints should be valid")
}
if IsVariantWidth(100) || IsVariantWidth(0) || IsVariantWidth(-48) {
t.Fatal("non-whitelist width should be invalid")
}
}
func TestVariantRejectsInvalidInput(t *testing.T) {
s, _ := newUploadTestService(t)
cases := []struct {
name string
url string
w int
}{
{"非法尺寸", "/uploads/images/a.webp", 100},
{"路径穿越", "/uploads/images/../../etc/passwd", 96},
{"非白名单目录", "/uploads/secret/a.webp", 96},
{"非图片扩展名", "/uploads/images/a.txt", 96},
{"外部地址", "https://evil.com/a.webp", 96},
{"空地址", "", 96},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if _, _, err := s.Variant(tc.url, tc.w); err == nil {
t.Fatal("want error, got nil")
}
})
}
}
func TestVariantMissingFile(t *testing.T) {
s, _ := newUploadTestService(t)
if _, _, err := s.Variant("/uploads/images/missing.webp", 96); err == nil {
t.Fatal("missing source should error")
}
}
func TestVariantPassthroughSmallImage(t *testing.T) {
s, dir := newUploadTestService(t)
url := "/uploads/images/small.webp"
writeVariantSource(t, dir, url, encodeWebP(solidImg(48, 48)))
data, m, err := s.Variant(url, 96)
if err != nil {
t.Fatalf("variant: %v", err)
}
if m != "image/webp" {
t.Fatalf("want webp mime, got %s", m)
}
src, _ := os.ReadFile(filepath.Join(dir, filepath.FromSlash("images/small.webp")))
if !bytes.Equal(data, src) {
t.Fatal("small image should be returned byte-for-byte")
}
}
func TestVariantResizesByWidthLandscape(t *testing.T) {
s, dir := newUploadTestService(t)
url := "/uploads/images/wide.jpg"
writeVariantSource(t, dir, url, encodeJPEG(solidImg(1000, 500)))
data, m, err := s.Variant(url, 384)
if err != nil {
t.Fatalf("variant: %v", err)
}
if m != "image/webp" {
t.Fatalf("want webp, got %s", m)
}
b := decodeVariant(t, data).Bounds()
if b.Dx() != 384 || b.Dy() != 192 {
t.Fatalf("want 384x192, got %dx%d", b.Dx(), b.Dy())
}
}
func TestVariantResizesByWidthPortrait(t *testing.T) {
s, dir := newUploadTestService(t)
url := "/uploads/images/tall.png"
writeVariantSource(t, dir, url, encodePNG(solidImg(500, 1000)))
data, _, err := s.Variant(url, 256)
if err != nil {
t.Fatalf("variant: %v", err)
}
b := decodeVariant(t, data).Bounds()
if b.Dx() != 256 || b.Dy() != 512 {
t.Fatalf("want 256x512, got %dx%d", b.Dx(), b.Dy())
}
}
func TestVariantPassthroughGIFSVGICO(t *testing.T) {
s, dir := newUploadTestService(t)
cases := []struct {
name string
url string
mime string
body []byte
}{
{"gif", "/uploads/images/a.gif", "image/gif", nil},
{"svg", "/uploads/images/a.svg", "image/svg+xml", []byte("<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>")},
{"ico", "/uploads/images/a.ico", "image/x-icon", []byte("\x00\x00\x01\x00")},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if tc.body != nil {
writeVariantSource(t, dir, tc.url, func(w io.Writer) error {
_, err := w.Write(tc.body)
return err
})
} else {
writeVariantSource(t, dir, tc.url, encodeGIF(solidImg(16, 16)))
}
data, m, err := s.Variant(tc.url, 96)
if err != nil {
t.Fatalf("variant: %v", err)
}
if m != tc.mime {
t.Fatalf("want %s, got %s", tc.mime, m)
}
src, _ := os.ReadFile(filepath.Join(dir, filepath.FromSlash(strings.TrimPrefix(tc.url, "/uploads/"))))
if !bytes.Equal(data, src) {
t.Fatalf("%s should pass through unchanged", tc.name)
}
})
}
}
func TestVariantCachedAndReused(t *testing.T) {
s, dir := newUploadTestService(t)
url := "/uploads/images/cache.jpg"
writeVariantSource(t, dir, url, encodeJPEG(solidImg(1000, 500)))
first, m, err := s.Variant(url, 384)
if err != nil {
t.Fatalf("first variant: %v", err)
}
if m != "image/webp" {
t.Fatalf("want webp, got %s", m)
}
cache := s.variantCacheFile(url, 384)
if info, err := os.Stat(cache); err != nil || info.IsDir() {
t.Fatalf("cache file should exist: %v", err)
}
second, _, err := s.Variant(url, 384)
if err != nil {
t.Fatalf("second variant: %v", err)
}
if !bytes.Equal(first, second) {
t.Fatal("cached variant should be byte-identical")
}
}
func TestVariantCacheFreshness(t *testing.T) {
_, dir := newUploadTestService(t)
cache := filepath.Join(dir, ".thumbs", "v", "x.webp")
if err := os.MkdirAll(filepath.Dir(cache), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(cache, []byte("data"), 0o644); err != nil {
t.Fatal(err)
}
// 远程对象:modTime 零值,存在即新鲜
if !variantCacheFresh(cache, time.Time{}) {
t.Fatal("remote cache should be fresh when file exists")
}
// 本地源:mtime 新于缓存 → 不新鲜
future := time.Now().Add(1 * time.Hour)
if variantCacheFresh(cache, future) {
t.Fatal("cache should be stale vs newer source mtime")
}
}
func TestVariantRemoteRejectsBadID(t *testing.T) {
s, _ := newUploadTestService(t)
s.ops = NewOperations(s.db, &config.Config{})
if _, _, err := s.Variant("/api/media/bad/id", 96); err == nil {
t.Fatal("id with slash should be rejected")
}
}
func TestVariantRemoteUnavailable(t *testing.T) {
s, _ := newUploadTestService(t)
s.ops = NewOperations(s.db, &config.Config{})
// 表不存在/对象不存在:统一对外“暂不可用”,不泄漏细节
if _, _, err := s.Variant("/api/media/deadbeef", 96); err == nil {
t.Fatal("missing remote object should error")
}
}
func TestVariantRemoteWithoutOps(t *testing.T) {
s, _ := newUploadTestService(t)
if _, _, err := s.Variant("/api/media/abc", 96); err == nil {
t.Fatal("remote url without ops wired should error")
}
}