Files
tanabata/backend/internal/storage/disk_test.go
T
H1K0 384386a34e feat(backend): generate thumbnails/previews via vipsthumbnail
Use vipsthumbnail as the primary still-image path for both thumbnails
and previews (shared serveGenerated), falling back to the pure-Go
imaging pipeline when vips isn't on PATH. vips shrinks on load (e.g.
JPEG DCT scaling), so a 200+ Mpx photo is resized in a fraction of the
memory and CPU of a full in-process decode and no longer exceeds the
decode cap — the source that previously got only a placeholder now gets
a real thumbnail and preview. The output JPEG is written straight to the
cache (atomic temp→rename), fit within the target box and never upscaled.

The in-process pixel cap now guards only the pure-Go fallback.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 01:25:55 +03:00

182 lines
5.2 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 storage
import (
"bytes"
"context"
"image"
"image/color"
"image/png"
"io"
"os"
"path/filepath"
"testing"
"github.com/disintegration/imaging"
"github.com/google/uuid"
)
// writeTestImage writes a w×h PNG filled with a distinct (non-placeholder)
// colour so a generated thumbnail can be told apart from the grey placeholder.
func writeTestImage(t *testing.T, path string, w, h int) {
t.Helper()
img := image.NewNRGBA(image.Rect(0, 0, w, h))
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
img.Set(x, y, color.NRGBA{R: 0xC0, G: 0x10, B: 0x20, A: 0xFF})
}
}
f, err := os.Create(path)
if err != nil {
t.Fatal(err)
}
defer f.Close()
if err := png.Encode(f, img); err != nil {
t.Fatal(err)
}
}
func TestDecodeImageLimited(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "img.png")
writeTestImage(t, p, 100, 80) // 8000 px
if _, err := decodeImageLimited(p, 4000); err == nil {
t.Fatal("expected rejection for an image over the pixel cap")
}
img, err := decodeImageLimited(p, 100000)
if err != nil {
t.Fatalf("expected decode within the cap, got %v", err)
}
if b := img.Bounds(); b.Dx() != 100 || b.Dy() != 80 {
t.Fatalf("unexpected decoded size %v", b.Size())
}
}
// TestThumbnailGeneratesAndCaches exercises the full generation path (semaphore
// acquire → decode → fit → encode → cache) and the cache fast path on re-request.
func TestThumbnailGeneratesAndCaches(t *testing.T) {
files := t.TempDir()
thumbs := t.TempDir()
id := uuid.New()
writeTestImage(t, filepath.Join(files, id.String()), 100, 80)
s, err := NewDiskStorage(files, thumbs, 160, 160, 1920, 1080, 0, 1)
if err != nil {
t.Fatal(err)
}
rc, err := s.Thumbnail(context.Background(), id)
if err != nil {
t.Fatalf("Thumbnail: %v", err)
}
data, _ := io.ReadAll(rc)
rc.Close()
out, err := imaging.Decode(bytes.NewReader(data))
if err != nil {
t.Fatalf("decode thumbnail: %v", err)
}
// The source fits within 160×160, so it is not upscaled.
if b := out.Bounds(); b.Dx() != 100 || b.Dy() != 80 {
t.Fatalf("unexpected thumbnail size %v", b.Size())
}
// Centre pixel should be the source's red, not the grey placeholder.
r, g, b, _ := out.At(50, 40).RGBA()
if !(r>>8 > g>>8+40 && r>>8 > b>>8+40) {
t.Fatalf("thumbnail is not the source image (got r=%d g=%d b=%d) — fell back to placeholder?", r>>8, g>>8, b>>8)
}
// The cache file must now exist, and a second request must serve it.
if _, err := os.Stat(s.thumbCachePath(id)); err != nil {
t.Fatalf("cache file not written: %v", err)
}
rc2, err := s.Thumbnail(context.Background(), id)
if err != nil {
t.Fatalf("Thumbnail (cached): %v", err)
}
rc2.Close()
}
// TestThumbnailFallbackWithoutVips forces the pure-Go pipeline (as if vips were
// not installed) and verifies generation still produces the source image.
func TestThumbnailFallbackWithoutVips(t *testing.T) {
orig := vipsThumbnailPath
vipsThumbnailPath = ""
t.Cleanup(func() { vipsThumbnailPath = orig })
files := t.TempDir()
thumbs := t.TempDir()
id := uuid.New()
writeTestImage(t, filepath.Join(files, id.String()), 100, 80)
s, err := NewDiskStorage(files, thumbs, 160, 160, 1920, 1080, 0, 1)
if err != nil {
t.Fatal(err)
}
rc, err := s.Thumbnail(context.Background(), id)
if err != nil {
t.Fatalf("Thumbnail: %v", err)
}
data, _ := io.ReadAll(rc)
rc.Close()
out, err := imaging.Decode(bytes.NewReader(data))
if err != nil {
t.Fatalf("decode thumbnail: %v", err)
}
if b := out.Bounds(); b.Dx() != 100 || b.Dy() != 80 {
t.Fatalf("unexpected thumbnail size %v", b.Size())
}
r, g, b, _ := out.At(50, 40).RGBA()
if !(r>>8 > g>>8+40 && r>>8 > b>>8+40) {
t.Fatalf("fallback produced a placeholder, not the source (r=%d g=%d b=%d)", r>>8, g>>8, b>>8)
}
}
// TestPreviewGeneratesAndCaches verifies Preview runs through the same pipeline
// with the preview dimensions and its own cache file (not the thumbnail's).
func TestPreviewGeneratesAndCaches(t *testing.T) {
files := t.TempDir()
thumbs := t.TempDir()
id := uuid.New()
// Larger than the thumbnail box but within the preview box, so the preview
// keeps full resolution where a thumbnail would shrink it.
writeTestImage(t, filepath.Join(files, id.String()), 400, 300)
s, err := NewDiskStorage(files, thumbs, 160, 160, 1920, 1080, 0, 1)
if err != nil {
t.Fatal(err)
}
rc, err := s.Preview(context.Background(), id)
if err != nil {
t.Fatalf("Preview: %v", err)
}
data, _ := io.ReadAll(rc)
rc.Close()
out, err := imaging.Decode(bytes.NewReader(data))
if err != nil {
t.Fatalf("decode preview: %v", err)
}
// 400×300 fits within 1920×1080, so the preview is not downscaled.
if b := out.Bounds(); b.Dx() != 400 || b.Dy() != 300 {
t.Fatalf("unexpected preview size %v", b.Size())
}
r, g, b, _ := out.At(200, 150).RGBA()
if !(r>>8 > g>>8+40 && r>>8 > b>>8+40) {
t.Fatalf("preview is not the source image (r=%d g=%d b=%d)", r>>8, g>>8, b>>8)
}
// The preview cache must be written, and the thumbnail cache must not — they
// are separate files served by the same code with different dimensions.
if _, err := os.Stat(s.previewCachePath(id)); err != nil {
t.Fatalf("preview cache not written: %v", err)
}
if _, err := os.Stat(s.thumbCachePath(id)); err == nil {
t.Fatal("thumbnail cache should not exist after a preview-only request")
}
}