feat: 书库导入导出/图片变体/书籍搜索/小组件运行时等
新增: - 书库导入导出(library_import/library_export)及测试 - 图片变体生成(image_variants)与响应式图片(responsiveImage) - 书籍搜索(bookSearch)+ BookSearch/LibrarySearchGrid 组件 - 小组件运行时(widgetRuntime)与静态检查(widgetLint) - 上传缓存中间件(upload_cache)与字体 CSS 提取脚本 其它: - 后端 handlers/services 全量调整 - 前端页面、组件、库函数与配置更新
This commit is contained in:
282
backend/service/image_variants_test.go
Normal file
282
backend/service/image_variants_test.go
Normal file
@@ -0,0 +1,282 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user