CI / ci (push) Successful in 2m0s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
496 lines
15 KiB
Go
496 lines
15 KiB
Go
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)
|
||
}
|
||
}
|