Files
MealQuest/internal/recipes/photo_test.go
T
dev_agent f25b28b39b
CI / ci (push) Successful in 2m0s
Feature 10: Recipe grounding and photos (#78)
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-06 07:27:58 -04:00

496 lines
15 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 recipes
import (
"bytes"
"context"
"database/sql"
"errors"
"image"
"image/color"
"image/gif"
"image/jpeg"
"image/png"
"io/fs"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
)
// photoServer serves each path's body with its Content-Type and counts
// requests.
type photoServer struct {
files map[string]photoFile
requests atomic.Int32
}
type photoFile struct {
contentType string
body []byte
}
func (s *photoServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
s.requests.Add(1)
f, ok := s.files[r.URL.Path]
if !ok {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", f.contentType)
w.Write(f.body)
}
// newTestPhotoCache returns a PhotoCache over files, a migrated temp
// database and a temp data directory.
func newTestPhotoCache(t *testing.T, files map[string]photoFile) (*PhotoCache, *store.Store, string, *photoServer) {
t.Helper()
srv := &photoServer{files: files}
env := newTestEnv(t, srv, publicAnswers())
installClock(env.f)
dataDir := t.TempDir()
st, err := store.Open(t.Context(), dataDir)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := st.Close(); err != nil {
t.Error(err)
}
})
if err := store.Migrate(t.Context(), st.Writer, migrations.FS); err != nil {
t.Fatal(err)
}
cache, err := NewPhotoCache(env.f, st, dataDir)
if err != nil {
t.Fatal(err)
}
return cache, st, dataDir, srv
}
// cachePhoto downloads the image at rawURL and records it in its own
// transaction, as a caller of DownloadPhoto and CachePhoto does.
func cachePhoto(t *testing.T, cache *PhotoCache, rawURL string) (db.Photo, error) {
t.Helper()
p, err := cache.DownloadPhoto(t.Context(), rawURL)
if err != nil {
return db.Photo{}, err
}
var photo db.Photo
err = cache.store.WithTx(t.Context(), func(tx *sql.Tx) error {
photo, _, err = cache.CachePhoto(t.Context(), tx, p)
return err
})
return photo, err
}
// gradient is a w×h opaque image.
func gradient(w, h int) *image.RGBA {
img := image.NewRGBA(image.Rect(0, 0, w, h))
for y := range h {
for x := range w {
img.Set(x, y, color.RGBA{uint8(x * 255 / w), uint8(y * 255 / h), 128, 255})
}
}
return img
}
func encodeJPEG(t *testing.T, img image.Image) []byte {
t.Helper()
var b bytes.Buffer
if err := jpeg.Encode(&b, img, nil); err != nil {
t.Fatal(err)
}
return b.Bytes()
}
func encodePNG(t *testing.T, img image.Image) []byte {
t.Helper()
var b bytes.Buffer
if err := png.Encode(&b, img); err != nil {
t.Fatal(err)
}
return b.Bytes()
}
// twoFrameGIF is a w×h animated GIF whose first frame is red and second
// blue.
func twoFrameGIF(t *testing.T, w, h int) []byte {
t.Helper()
frame := func(c color.Color) *image.Paletted {
p := image.NewPaletted(image.Rect(0, 0, w, h), color.Palette{c})
return p
}
var b bytes.Buffer
anim := &gif.GIF{
Image: []*image.Paletted{frame(color.RGBA{255, 0, 0, 255}), frame(color.RGBA{0, 0, 255, 255})},
Delay: []int{10, 10},
}
if err := gif.EncodeAll(&b, anim); err != nil {
t.Fatal(err)
}
return b.Bytes()
}
func readTestdata(t *testing.T, name string) []byte {
t.Helper()
b, err := os.ReadFile(filepath.Join("testdata", name))
if err != nil {
t.Fatal(err)
}
return b
}
// storedSize decodes the stored JPEG of id at size and returns it.
func storedSize(t *testing.T, dataDir, id string, size int) image.Image {
t.Helper()
b, err := os.ReadFile(filepath.Join(dataDir, "media", "photos", photoFileName(id, size)))
if err != nil {
t.Fatalf("read stored photo: %v", err)
}
img, format, err := image.Decode(bytes.NewReader(b))
if err != nil || format != "jpeg" {
t.Fatalf("stored photo is %q, %v; want a JPEG", format, err)
}
return img
}
func TestCachePhotoFormatsAndSizes(t *testing.T) {
t.Parallel()
files := map[string]photoFile{
"/landscape.jpg": {"image/jpeg", encodeJPEG(t, gradient(800, 600))},
"/portrait.png": {"image/png", encodePNG(t, gradient(600, 900))},
"/photo.webp": {"image/webp", readTestdata(t, "photo.webp")},
"/anim.gif": {"image/gif; charset=binary", twoFrameGIF(t, 300, 450)},
"/square.png": {"image/png", encodePNG(t, gradient(1500, 1500))},
}
cache, _, dataDir, _ := newTestPhotoCache(t, files)
tests := []struct {
path string
width, height int64
// sizes maps each stored size to its expected dimensions.
sizes map[int]image.Point
}{
{"/landscape.jpg", 800, 600, map[int]image.Point{1200: {1200, 900}, 400: {400, 300}}},
{"/portrait.png", 600, 900, map[int]image.Point{1200: {800, 1200}, 400: {267, 400}}},
{"/photo.webp", 640, 480, map[int]image.Point{1200: {1200, 900}, 400: {400, 300}}},
{"/anim.gif", 300, 450, map[int]image.Point{1200: {800, 1200}, 400: {267, 400}}},
{"/square.png", 1500, 1500, map[int]image.Point{1200: {1200, 1200}, 400: {400, 400}}},
}
for _, tc := range tests {
photo, err := cachePhoto(t, cache, "https://example.com"+tc.path)
if err != nil {
t.Fatalf("CachePhoto %s: %v", tc.path, err)
}
if photo.Width != tc.width || photo.Height != tc.height {
t.Errorf("%s: row is %dx%d, want %dx%d", tc.path, photo.Width, photo.Height, tc.width, tc.height)
}
if photo.SourceUrl != "https://example.com"+tc.path || len(photo.Sha256) != 64 {
t.Errorf("%s: row = %+v", tc.path, photo)
}
if want := "media/photos/" + photo.ID + "-1200.jpg"; photo.LocalPath != want {
t.Errorf("%s: local_path = %q, want %q", tc.path, photo.LocalPath, want)
}
for size, want := range tc.sizes {
img := storedSize(t, dataDir, photo.ID, size)
if got := img.Bounds().Size(); got != want {
t.Errorf("%s at %d: stored %v, want %v", tc.path, size, got, want)
}
}
}
}
func TestCachePhotoGIFUsesFirstFrame(t *testing.T) {
t.Parallel()
cache, _, dataDir, _ := newTestPhotoCache(t, map[string]photoFile{
"/anim.gif": {"image/gif", twoFrameGIF(t, 400, 400)},
})
photo, err := cachePhoto(t, cache, "https://example.com/anim.gif")
if err != nil {
t.Fatalf("CachePhoto: %v", err)
}
r, g, b, _ := storedSize(t, dataDir, photo.ID, 400).At(200, 200).RGBA()
if r>>8 < 200 || g>>8 > 50 || b>>8 > 50 {
t.Errorf("centre pixel = (%d,%d,%d), want the first frame's red", r>>8, g>>8, b>>8)
}
}
func TestCachePhotoRejectsSmallGIFFirstFrame(t *testing.T) {
t.Parallel()
// A 400x400 screen whose first frame is 1x1: the frame is what would be
// stored, so it is what must measure PhotoMinShortSide.
var b bytes.Buffer
anim := &gif.GIF{
Image: []*image.Paletted{image.NewPaletted(image.Rect(0, 0, 1, 1), color.Palette{color.Black})},
Delay: []int{10},
Config: image.Config{ColorModel: color.Palette{color.Black}, Width: 400, Height: 400},
}
if err := gif.EncodeAll(&b, anim); err != nil {
t.Fatal(err)
}
cache, st, dataDir, _ := newTestPhotoCache(t, map[string]photoFile{
"/speck.gif": {"image/gif", b.Bytes()},
})
if _, err := cachePhoto(t, cache, "https://example.com/speck.gif"); !errors.Is(err, ErrPhotoTooSmall) {
t.Errorf("CachePhoto error = %v, want ErrPhotoTooSmall", err)
}
if n := countPhotos(t, st); n != 0 {
t.Errorf("%d photos recorded for a refused image", n)
}
if names := photoFiles(t, dataDir); len(names) != 0 {
t.Errorf("files stored for a refused image: %v", names)
}
}
func TestCachePhotoFillsTransparencyWhite(t *testing.T) {
t.Parallel()
cache, _, dataDir, _ := newTestPhotoCache(t, map[string]photoFile{
"/clear.png": {"image/png", encodePNG(t, image.NewNRGBA(image.Rect(0, 0, 400, 400)))},
})
photo, err := cachePhoto(t, cache, "https://example.com/clear.png")
if err != nil {
t.Fatalf("CachePhoto: %v", err)
}
r, g, b, _ := storedSize(t, dataDir, photo.ID, 400).At(200, 200).RGBA()
if r>>8 < 250 || g>>8 < 250 || b>>8 < 250 {
t.Errorf("transparent pixel stored as (%d,%d,%d), want white", r>>8, g>>8, b>>8)
}
}
func countPhotos(t *testing.T, st *store.Store) int {
t.Helper()
var n int
if err := st.Reader.QueryRowContext(context.Background(), "SELECT count(*) FROM photos").Scan(&n); err != nil {
t.Fatal(err)
}
return n
}
func photoFiles(t *testing.T, dataDir string) []string {
t.Helper()
entries, err := os.ReadDir(filepath.Join(dataDir, "media", "photos"))
if err != nil {
t.Fatal(err)
}
var names []string
for _, e := range entries {
names = append(names, e.Name())
}
return names
}
func TestCachePhotoRejectsTooSmall(t *testing.T) {
t.Parallel()
cache, st, dataDir, _ := newTestPhotoCache(t, map[string]photoFile{
"/narrow.png": {"image/png", encodePNG(t, gradient(299, 1000))},
"/short.jpg": {"image/jpeg", encodeJPEG(t, gradient(1000, 299))},
"/edge.png": {"image/png", encodePNG(t, gradient(300, 1000))},
})
for _, p := range []string{"/narrow.png", "/short.jpg"} {
if _, err := cachePhoto(t, cache, "https://example.com"+p); !errors.Is(err, ErrPhotoTooSmall) {
t.Errorf("CachePhoto %s error = %v, want ErrPhotoTooSmall", p, err)
}
}
if n := countPhotos(t, st); n != 0 {
t.Errorf("%d photos recorded for refused images", n)
}
if names := photoFiles(t, dataDir); len(names) != 0 {
t.Errorf("files stored for refused images: %v", names)
}
if _, err := cachePhoto(t, cache, "https://example.com/edge.png"); err != nil {
t.Errorf("CachePhoto with a 300 px short side: %v", err)
}
}
func TestCachePhotoRejectsNonImages(t *testing.T) {
t.Parallel()
cache, st, _, _ := newTestPhotoCache(t, map[string]photoFile{
"/page.html": {"text/html", encodePNG(t, gradient(400, 400))},
"/fake.jpg": {"image/jpeg", []byte("<html>not a picture</html>")},
})
if _, err := cachePhoto(t, cache, "https://example.com/page.html"); !errors.Is(err, ErrNotImage) {
t.Errorf("text/html error = %v, want ErrNotImage", err)
}
if _, err := cachePhoto(t, cache, "https://example.com/fake.jpg"); !errors.Is(err, ErrPhotoFormat) {
t.Errorf("undecodable image error = %v, want ErrPhotoFormat", err)
}
if n := countPhotos(t, st); n != 0 {
t.Errorf("%d photos recorded", n)
}
}
func TestCachePhotoDeduplicatesBySHA256(t *testing.T) {
t.Parallel()
body := encodeJPEG(t, gradient(640, 480))
cache, st, dataDir, srv := newTestPhotoCache(t, map[string]photoFile{
"/a.jpg": {"image/jpeg", body},
"/b.jpg": {"image/jpeg", body},
"/other.jpg": {"image/jpeg", encodeJPEG(t, gradient(480, 640))},
})
first, err := cachePhoto(t, cache, "https://example.com/a.jpg")
if err != nil {
t.Fatalf("CachePhoto a: %v", err)
}
again, err := cachePhoto(t, cache, "https://example.com/b.jpg")
if err != nil {
t.Fatalf("CachePhoto b: %v", err)
}
if again != first {
t.Errorf("same bytes at another URL = %+v, want the first row %+v", again, first)
}
if n := srv.requests.Load(); n != 2 {
t.Errorf("%d downloads, want 2: each URL is fetched to hash it", n)
}
other, err := cachePhoto(t, cache, "https://example.com/other.jpg")
if err != nil {
t.Fatalf("CachePhoto other: %v", err)
}
if other.ID == first.ID || other.Sha256 == first.Sha256 {
t.Errorf("different bytes share a photo: %+v and %+v", first, other)
}
if n := countPhotos(t, st); n != 2 {
t.Errorf("%d photos recorded, want 2", n)
}
if names := photoFiles(t, dataDir); len(names) != 4 {
t.Errorf("stored files %v, want two sizes of two photos", names)
}
}
func TestOpenPhoto(t *testing.T) {
t.Parallel()
cache, _, dataDir, _ := newTestPhotoCache(t, map[string]photoFile{
"/a.png": {"image/png", encodePNG(t, gradient(400, 300))},
})
photo, err := cachePhoto(t, cache, "https://example.com/a.png")
if err != nil {
t.Fatalf("CachePhoto: %v", err)
}
for _, size := range PhotoSizes {
f, err := cache.OpenPhoto(t.Context(), photo.ID, size)
if err != nil {
t.Fatalf("OpenPhoto %d: %v", size, err)
}
f.Close()
}
// A file with no photos row is not a stored photo.
orphan := store.NewID()
if err := os.WriteFile(filepath.Join(dataDir, "media", "photos", orphan+"-400.jpg"), []byte("jpeg"), 0o600); err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
id string
size int
}{
{photo.ID, 800},
{store.NewID(), 400},
{orphan, 400},
{strings.ToLower(photo.ID), 400},
{"../" + photo.ID, 400},
{"", 400},
} {
if _, err := cache.OpenPhoto(t.Context(), tc.id, tc.size); !errors.Is(err, fs.ErrNotExist) {
t.Errorf("OpenPhoto(%q, %d) error = %v, want fs.ErrNotExist", tc.id, tc.size, err)
}
}
}
func TestFetchImageSizeCap(t *testing.T) {
t.Parallel()
env := newTestEnv(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
size := MaxImageBytes
if r.URL.Path != "/exact" {
size++
}
w.Header().Set("Content-Type", "image/jpeg")
w.Write(bytes.Repeat([]byte("a"), size))
}), publicAnswers())
installClock(env.f)
page, err := env.f.FetchImage(t.Context(), "https://example.com/exact")
if err != nil {
t.Fatalf("FetchImage at the cap: %v", err)
}
if len(page.Body) != MaxImageBytes {
t.Errorf("body is %d bytes, want %d", len(page.Body), MaxImageBytes)
}
if _, err := env.f.FetchImage(t.Context(), "https://example.com/over"); !errors.Is(err, ErrTooLarge) {
t.Errorf("FetchImage over the cap error = %v, want ErrTooLarge", err)
}
}
func TestFetchImageContentTypes(t *testing.T) {
t.Parallel()
var accept atomic.Value
env := newTestEnv(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
accept.Store(r.Header.Get("Accept"))
w.Header().Set("Content-Type", r.URL.Query().Get("type"))
w.Write([]byte("x"))
}), publicAnswers())
installClock(env.f)
typeURL := func(ct string) string { return "https://example.com/?type=" + url.QueryEscape(ct) }
for _, ct := range []string{"image/jpeg", "image/png", "image/webp", "image/gif", "IMAGE/PNG; q=1"} {
if _, err := env.f.FetchImage(t.Context(), typeURL(ct)); err != nil {
t.Errorf("FetchImage %q: %v", ct, err)
}
}
if got := accept.Load(); got != "image/jpeg,image/png,image/webp,image/gif" {
t.Errorf("Accept = %q", got)
}
for _, ct := range []string{"text/html", "image/svg+xml", "image/avif", "application/octet-stream", ""} {
if _, err := env.f.FetchImage(t.Context(), typeURL(ct)); !errors.Is(err, ErrNotImage) {
t.Errorf("FetchImage %q error = %v, want ErrNotImage", ct, err)
}
}
// Pages still fetch whatever the Content-Type.
if _, err := env.f.Fetch(t.Context(), typeURL("text/plain")); err != nil {
t.Errorf("Fetch: %v", err)
}
}
func TestCachePhotoRollsBackWithTransaction(t *testing.T) {
t.Parallel()
cache, st, dataDir, _ := newTestPhotoCache(t, map[string]photoFile{
"/a.jpg": {"image/jpeg", encodeJPEG(t, gradient(640, 480))},
})
p, err := cache.DownloadPhoto(t.Context(), "https://example.com/a.jpg")
if err != nil {
t.Fatal(err)
}
errAbort := errors.New("abort")
var photo db.Photo
var created bool
err = st.WithTx(t.Context(), func(tx *sql.Tx) error {
photo, created, err = cache.CachePhoto(t.Context(), tx, p)
if err != nil {
return err
}
return errAbort
})
if !errors.Is(err, errAbort) || !created {
t.Fatalf("WithTx error = %v, created = %v; want the abort after a new photo", err, created)
}
if n := countPhotos(t, st); n != 0 {
t.Errorf("%d photos rows after rollback", n)
}
if names := photoFiles(t, dataDir); len(names) != len(PhotoSizes) {
t.Errorf("files before removal = %v, want one per size", names)
}
if err := cache.RemovePhotoFiles(photo.ID); err != nil {
t.Fatal(err)
}
if names := photoFiles(t, dataDir); len(names) != 0 {
t.Errorf("files after RemovePhotoFiles = %v", names)
}
if err := cache.RemovePhotoFiles(photo.ID); err != nil {
t.Errorf("RemovePhotoFiles of absent files: %v", err)
}
}