Files
RenewCMS/test/image_variants_per_image_http_test.go

782 lines
29 KiB
Go

package test
import (
"RenewCMS/api/pkg/apperr"
domain "RenewCMS/internal/domain/image"
"RenewCMS/internal/infrastructure/imaging"
"RenewCMS/internal/infrastructure/useCases"
"bytes"
"errors"
"net/http"
"net/http/httptest"
"runtime"
"strconv"
"strings"
"sync"
"testing"
"time"
)
const (
listedStoredName = "a1b2.jpg"
svgStoredName = "e5f6.svg"
imageNotFoundBody = "Image not found"
internalErrorBody = "Internal server error"
unreadableBodyBytes = "not an image at all"
)
func (e *variantEnv) seedPerImage(id uint32, filename string, storedName string, contentType string, width int, height int, content []byte) domain.Image {
img := domain.Image{
ID: id,
Path: domain.PublicPath(storedName),
Filename: filename,
Size: int64(len(content)),
ContentType: contentType,
Width: width,
Height: height,
SHA256: "hash-" + strconv.Itoa(int(id)),
CreatedAt: mockImageEpoch.Add(time.Duration(id) * time.Minute),
}
e.images.images[id] = img
if id > e.images.nextID {
e.images.nextID = id
}
if content != nil {
e.storage.seed(storedName, content)
}
return img
}
func (e *variantEnv) seedCurrentFile(variantName string, storedName string, size int64) domain.VariantFile {
cached := "cached-" + variantName + "-" + storedName
e.storage.seed(cached, []byte("generated"))
saved, _ := e.files.Save(domain.VariantFile{
VariantName: variantName,
SourceName: storedName,
CachedName: cached,
ContentType: "image/webp",
Size: size,
Fingerprint: e.variants.stored(variantName).Settings().Fingerprint(),
})
return saved
}
func (e *variantEnv) disable(name string) {
variant := e.variants.stored(name)
variant.Enabled = false
e.variants.variants[name] = variant
}
func (e *variantEnv) seedListingVariants() {
seedVariant(e.variants, "card", 800, 0, domain.FitInside, domain.FormatWebP, 80)
seedVariant(e.variants, "hero", 1200, 630, domain.FitCover, domain.FormatWebP, 82)
seedVariant(e.variants, "og", 1200, 630, domain.FitCover, domain.FormatJPEG, 80)
seedVariant(e.variants, "print", 0, 2400, domain.FitInside, domain.FormatPNG, 0)
seedVariant(e.variants, "square", 600, 600, domain.FitCover, domain.FormatJPEG, 85)
seedVariant(e.variants, "thumb", 400, 300, domain.FitCover, domain.FormatWebP, 80)
e.disable("print")
}
func (e *variantEnv) seedListingBackground() {
e.seedListingVariants()
e.seedPerImage(1, "north-wall.jpg", listedStoredName, "image/jpeg", 4032, 3024, nil)
e.images.images[1] = withSize(e.images.images[1], 4089446)
e.seedPerImage(2, "logo.svg", svgStoredName, "image/svg+xml", 0, 0, nil)
e.seedCurrentFile("thumb", listedStoredName, 18432)
e.seedCurrentFile("card", listedStoredName, 65536)
e.seedCurrentFile("hero", listedStoredName, 98304)
e.seedCurrentFile("square", listedStoredName, 83968)
e.seedCurrentFile("print", listedStoredName, 3250586)
}
func withSize(img domain.Image, size int64) domain.Image {
img.Size = size
return img
}
func (e *variantEnv) seedGenerateBackground() {
e.seedVariantsForGeneration()
e.seedPerImage(1, "north-wall.jpg", "a1b2.png", "image/png", 1000, 800, encodePNG(e.t, variantSolidImage(1000, 800, variantRed)))
}
func (e *variantEnv) seedVariantsForGeneration() {
seedVariant(e.variants, "thumb", 400, 300, domain.FitCover, domain.FormatJPEG, 80)
seedVariant(e.variants, "hero", 1200, 630, domain.FitCover, domain.FormatJPEG, 82)
seedVariant(e.variants, "off", 300, 0, domain.FitInside, domain.FormatPNG, 0)
e.disable("off")
}
func itemsOf(t *testing.T, recorder *httptest.ResponseRecorder) []map[string]any {
t.Helper()
raw, ok := decodeBody(t, recorder)["items"].([]any)
if !ok {
t.Fatalf("items missing in %q", recorder.Body.String())
}
items := make([]map[string]any, 0, len(raw))
for _, entry := range raw {
items = append(items, entry.(map[string]any))
}
return items
}
func itemNames(items []map[string]any) []string {
names := make([]string, 0, len(items))
for _, item := range items {
names = append(names, item["name"].(string))
}
return names
}
func itemNamed(t *testing.T, items []map[string]any, name string) map[string]any {
t.Helper()
for _, item := range items {
if item["name"] == name {
return item
}
}
t.Fatalf("no item named %q in %v", name, itemNames(items))
return nil
}
func TestPerImageVariantRoutesAreGuarded(t *testing.T) {
rows := []struct {
method string
path string
credentials string
status int
body string
}{
{http.MethodGet, "/v1/media/1/variants", "none", 401, apperr.UnauthorizedErrorMessage},
{http.MethodGet, "/v1/media/1/variants", "stale", 401, apperr.UnauthorizedErrorMessage},
{http.MethodGet, "/v1/media/1/variants", "unverified", 403, apperr.VerificationRequiredErrorMessage},
{http.MethodGet, "/v1/media/1/variants", "plain", 403, apperr.OwnerOrAdminRequiredErrorMessage},
{http.MethodPost, "/v1/media/1/variants", "plain", 403, apperr.OwnerOrAdminRequiredErrorMessage},
{http.MethodPost, "/v1/media/1/variants/hero", "plain", 403, apperr.OwnerOrAdminRequiredErrorMessage},
{http.MethodDelete, "/v1/media/1/variants/hero", "plain", 403, apperr.OwnerOrAdminRequiredErrorMessage},
{http.MethodGet, "/v1/media/1/variants", "apikey", 401, apperr.UnauthorizedErrorMessage},
{http.MethodPost, "/v1/media/1/variants", "apikey", 401, apperr.UnauthorizedErrorMessage},
}
for _, row := range rows {
t.Run("Every per-image variant route is guarded: "+row.method+" "+row.path+" "+row.credentials, func(t *testing.T) {
env := newVariantEnv(t)
env.seedGenerateBackground()
variantCalls, fileCalls, opens := env.variants.calls, env.files.calls, env.storage.opens
request := httptest.NewRequest(row.method, row.path, nil)
switch row.credentials {
case "stale":
request.AddCookie(env.cookie(variantStaleId, 0))
case "unverified":
request.AddCookie(env.cookie(variantUnverifiedId, 0))
case "plain":
request.AddCookie(env.cookie(variantPlainUserId, 0))
case "apikey":
request.Header.Set("Authorization", "Bearer "+env.issueAPIKey())
}
recorder := httptest.NewRecorder()
env.admin.ServeHTTP(recorder, request)
assertStatusAndBody(t, recorder, row.status, row.body)
if env.variants.calls != variantCalls || env.files.calls != fileCalls || env.storage.opens != opens || env.storage.putCount() != 0 || env.renderer.renderCount() != 0 || env.events() != 0 {
t.Error("a variant, index row, blob or activity event was read or changed")
}
})
}
for name, userId := range map[string]uint32{"the owner": variantOwnerId, "an admin": variantAdminId} {
t.Run("Owners and admins are both allowed: "+name, func(t *testing.T) {
env := newVariantEnv(t)
env.seedGenerateBackground()
recorder := env.send(http.MethodGet, "/v1/media/1/variants", "", userId)
if recorder.Code != http.StatusOK {
t.Errorf("status = %d", recorder.Code)
}
})
}
}
func TestPerImageVariantMalformedAndUnknown(t *testing.T) {
rows := []struct {
method string
id string
suffix string
status int
body string
}{
{http.MethodGet, "abc", "", 400, apperr.IdUint32ErrorMessage},
{http.MethodPost, "abc", "", 400, apperr.IdUint32ErrorMessage},
{http.MethodGet, "9999", "", 404, imageNotFoundBody},
{http.MethodPost, "9999", "", 404, imageNotFoundBody},
{http.MethodPost, "9999", "/hero", 404, imageNotFoundBody},
{http.MethodDelete, "9999", "/hero", 404, imageNotFoundBody},
}
for _, row := range rows {
t.Run("A malformed id or an unknown image: "+row.method+" "+row.id+row.suffix, func(t *testing.T) {
env := newVariantEnv(t)
env.seedGenerateBackground()
recorder := env.sendAsAdmin(row.method, "/v1/media/"+row.id+"/variants"+row.suffix, "")
assertStatusAndBody(t, recorder, row.status, row.body)
if env.events() != 0 {
t.Error("an activity event was recorded")
}
})
}
}
func TestPerImageVariantListing(t *testing.T) {
t.Run("The enabled variants, in name order, with their output and what is cached", func(t *testing.T) {
env := newVariantEnv(t)
env.seedListingBackground()
recorder := env.sendAsAdmin(http.MethodGet, "/v1/media/1/variants", "")
if recorder.Code != 200 || decodeBody(t, recorder)["unsupported"] != nil {
t.Fatalf("response = %d %q", recorder.Code, recorder.Body.String())
}
items := itemsOf(t, recorder)
if got := strings.Join(itemNames(items), ","); got != "card,hero,og,square,thumb" {
t.Fatalf("names = %s", got)
}
expected := []struct {
name, format string
width, height any
outWidth, outHeight float64
generated bool
size any
}{
{"card", "webp", 800.0, nil, 800, 600, true, 65536.0},
{"hero", "webp", 1200.0, 630.0, 1200, 630, true, 98304.0},
{"og", "jpeg", 1200.0, 630.0, 1200, 630, false, nil},
{"square", "jpeg", 600.0, 600.0, 600, 600, true, 83968.0},
{"thumb", "webp", 400.0, 300.0, 400, 300, true, 18432.0},
}
for _, want := range expected {
item := itemNamed(t, items, want.name)
if item["width"] != want.width || item["height"] != want.height || item["output_width"] != want.outWidth || item["output_height"] != want.outHeight || item["output_format"] != want.format || item["generated"] != want.generated || item["size"] != want.size {
t.Errorf("%s = %v", want.name, item)
}
}
if itemNamed(t, items, "thumb")["url"] != "/static/uploadedImages/a1b2.jpg?v=thumb" || itemNamed(t, items, "card")["fit"] != "inside" || itemNamed(t, items, "hero")["format"] != "webp" {
t.Errorf("thumb = %v", itemNamed(t, items, "thumb"))
}
if !strings.Contains(recorder.Body.String(), `"height":null`) || !strings.Contains(recorder.Body.String(), `"size":null`) {
t.Errorf("raw body = %q", recorder.Body.String())
}
})
t.Run("A row whose fingerprint is stale is not generated", func(t *testing.T) {
env := newVariantEnv(t)
env.seedListingBackground()
row, _, _ := env.files.Find("hero", listedStoredName)
row.Fingerprint = "older-quality"
_, _ = env.files.Save(row)
recorder := env.sendAsAdmin(http.MethodGet, "/v1/media/1/variants", "")
hero := itemNamed(t, itemsOf(t, recorder), "hero")
if hero["generated"] != false || hero["size"] != nil {
t.Errorf("hero = %v", hero)
}
})
formats := []struct {
contentType string
webp bool
want string
}{
{"image/jpeg", true, "jpeg"},
{"image/png", true, "png"},
{"image/webp", true, "webp"},
{"image/webp", false, "png"},
}
for _, row := range formats {
t.Run("A variant in the original format takes the format of the source: "+row.contentType+" webp "+strconv.FormatBool(row.webp), func(t *testing.T) {
renderer := newCountingRenderer()
if !row.webp {
renderer = newCountingRendererWithoutWebP()
}
env := newVariantEnvWith(t, renderer, variantDefaultCap)
seedVariant(env.variants, "keep", 100, 0, domain.FitInside, domain.FormatOriginal, 80)
env.seedPerImage(1, "photo", "src.bin", row.contentType, 400, 300, nil)
recorder := env.sendAsAdmin(http.MethodGet, "/v1/media/1/variants", "")
if got := itemNamed(t, itemsOf(t, recorder), "keep")["output_format"]; got != row.want {
t.Errorf("output_format = %v, want %s", got, row.want)
}
})
}
geometry := []struct {
sw, sh, w, h int
fit domain.VariantFit
ow, oh float64
}{
{4032, 3024, 800, 0, domain.FitCover, 800, 600},
{4032, 3024, 0, 2400, domain.FitInside, 3200, 2400},
{4032, 3024, 8000, 8000, domain.FitCover, 3024, 3024},
{300, 200, 800, 0, domain.FitCover, 300, 200},
{300, 200, 400, 300, domain.FitCover, 267, 200},
{300, 200, 400, 300, domain.FitContain, 400, 300},
{300, 200, 1200, 1200, domain.FitInside, 300, 200},
}
for _, row := range geometry {
name := strconv.Itoa(row.sw) + "x" + strconv.Itoa(row.sh) + " into " + strconv.Itoa(row.w) + "x" + strconv.Itoa(row.h) + " " + string(row.fit)
t.Run("The output size never exceeds the source: "+name, func(t *testing.T) {
env := newVariantEnv(t)
variant := seedVariant(env.variants, "probe", row.w, row.h, row.fit, domain.FormatPNG, 0)
env.seedPerImage(1, "photo", "src.png", "image/png", row.sw, row.sh, nil)
recorder := env.sendAsAdmin(http.MethodGet, "/v1/media/1/variants", "")
item := itemNamed(t, itemsOf(t, recorder), "probe")
if item["output_width"] != row.ow || item["output_height"] != row.oh {
t.Fatalf("output = %v x %v", item["output_width"], item["output_height"])
}
rendered, err := imaging.NewVariantRenderer().Render(bytes.NewReader(encodePNG(t, variantSolidImage(row.sw, row.sh, variantRed))), variant.Settings())
if err != nil {
t.Fatal(err)
}
width, height := decodedSize(t, rendered.Content)
if float64(width) != row.ow || float64(height) != row.oh {
t.Errorf("renderer produced %d x %d", width, height)
}
})
}
t.Run("A source that no variant can be generated for answers an empty list", func(t *testing.T) {
env := newVariantEnv(t)
env.seedListingBackground()
recorder := env.sendAsAdmin(http.MethodGet, "/v1/media/2/variants", "")
assertStatusAndBody(t, recorder, 200, `{"unsupported":"svg","items":[]}`)
})
t.Run("No enabled variant answers an empty array, not null", func(t *testing.T) {
env := newVariantEnv(t)
env.seedListingBackground()
for _, name := range []string{"card", "hero", "og", "square", "thumb"} {
env.disable(name)
}
recorder := env.sendAsAdmin(http.MethodGet, "/v1/media/1/variants", "")
if recorder.Code != 200 || !strings.Contains(recorder.Body.String(), `"items":[]`) || !strings.Contains(recorder.Body.String(), `"unsupported":null`) {
t.Errorf("response = %d %q", recorder.Code, recorder.Body.String())
}
})
t.Run("Listing reads nothing it should not change", func(t *testing.T) {
env := newVariantEnv(t)
env.seedListingBackground()
rows := env.files.rowCount()
env.sendAsAdmin(http.MethodGet, "/v1/media/1/variants", "")
if env.storage.opens != 0 || env.storage.putCount() != 0 || env.renderer.renderCount() != 0 || env.files.saves != 5 || env.files.rowCount() != rows || env.events() != 0 {
t.Error("the listing opened a blob, rendered, wrote a row or recorded an event")
}
})
}
func TestPerImageVariantGenerateOne(t *testing.T) {
t.Run("The first generation renders, stores and indexes the file", func(t *testing.T) {
env := newVariantEnv(t)
env.seedGenerateBackground()
recorder := env.sendAsAdmin(http.MethodPost, "/v1/media/1/variants/thumb", "")
body := decodeBody(t, recorder)
row, found, _ := env.files.Find("thumb", "a1b2.png")
if recorder.Code != 200 || body["name"] != "thumb" || body["generated"] != true || body["output_width"] != 400.0 || body["output_height"] != 300.0 || body["output_format"] != "jpeg" || !found || body["size"] != float64(row.Size) {
t.Fatalf("response = %d %q", recorder.Code, recorder.Body.String())
}
stored, _ := env.storage.Open(row.CachedName)
if stored.Size != row.Size || env.storage.putCount() != 1 || env.files.rowCount() != 1 || len(env.storage.deletedNames()) != 0 || !env.storage.has("a1b2.png") || env.events() != 0 {
t.Error("the generation changed more than the one blob and row")
}
})
t.Run("The public url then serves that file without rendering", func(t *testing.T) {
env := newVariantEnv(t)
env.seedGenerateBackground()
first := env.sendAsAdmin(http.MethodPost, "/v1/media/1/variants/thumb", "")
row, _, _ := env.files.Find("thumb", "a1b2.png")
public := env.get("/static/uploadedImages/a1b2.png?v=thumb", nil)
if first.Code != 200 || public.Code != 200 || !bytes.Equal(public.Body.Bytes(), env.storage.blobs[row.CachedName]) || env.renderer.renderCount() != 1 {
t.Errorf("public = %d, renders = %d", public.Code, env.renderer.renderCount())
}
})
t.Run("A file generated by the public url is a hit for the admin route", func(t *testing.T) {
env := newVariantEnv(t)
env.seedGenerateBackground()
env.get("/static/uploadedImages/a1b2.png?v=thumb", nil)
recorder := env.sendAsAdmin(http.MethodPost, "/v1/media/1/variants/thumb", "")
if recorder.Code != 200 || env.renderer.renderCount() != 1 || env.storage.putCount() != 1 {
t.Errorf("status = %d, renders = %d", recorder.Code, env.renderer.renderCount())
}
})
t.Run("Generating a variant that is already generated renders nothing", func(t *testing.T) {
env := newVariantEnv(t)
env.seedGenerateBackground()
first := decodeBody(t, env.sendAsAdmin(http.MethodPost, "/v1/media/1/variants/thumb", ""))
again := env.sendAsAdmin(http.MethodPost, "/v1/media/1/variants/thumb", "")
if again.Code != 200 || decodeBody(t, again)["size"] != first["size"] || env.renderer.renderCount() != 1 {
t.Errorf("status = %d, renders = %d", again.Code, env.renderer.renderCount())
}
})
t.Run("A public request and an admin request at the same time render once", func(t *testing.T) {
env := newVariantEnv(t)
env.seedGenerateBackground()
env.renderer.onRender = func() {
for env.storage.openCountOf("a1b2.png") < 3 {
runtime.Gosched()
}
}
codes := make([]int, 2)
start := make(chan struct{})
var finished sync.WaitGroup
finished.Add(2)
go func() {
defer finished.Done()
<-start
codes[0] = env.sendAsAdmin(http.MethodPost, "/v1/media/1/variants/thumb", "").Code
}()
go func() {
defer finished.Done()
<-start
codes[1] = env.get("/static/uploadedImages/a1b2.png?v=thumb", nil).Code
}()
close(start)
finished.Wait()
if codes[0] != 200 || codes[1] != 200 || env.renderer.renderCount() != 1 || env.storage.putCount() != 1 {
t.Errorf("codes = %v, renders = %d, puts = %d", codes, env.renderer.renderCount(), env.storage.putCount())
}
})
refused := []struct{ name, variant string }{{"unknown", "missing"}, {"disabled", "off"}, {"wrongly cased", "Thumb"}}
for _, row := range refused {
t.Run("A request that cannot be served: "+row.name, func(t *testing.T) {
env := newVariantEnv(t)
env.seedGenerateBackground()
recorder := env.sendAsAdmin(http.MethodPost, "/v1/media/1/variants/"+row.variant, "")
assertStatusAndBody(t, recorder, 404, variantNotFound)
if env.renderer.renderCount() != 0 || env.storage.putCount() != 0 {
t.Error("something was rendered or stored")
}
})
}
t.Run("An SVG answers 422", func(t *testing.T) {
env := newVariantEnv(t)
env.seedGenerateBackground()
env.seedPerImage(2, "logo.svg", svgStoredName, "image/svg+xml", 0, 0, []byte("<svg/>"))
recorder := env.sendAsAdmin(http.MethodPost, "/v1/media/2/variants/thumb", "")
assertStatusAndBody(t, recorder, 422, apperr.VariantSourceUnsupportedErrorMessage)
if env.renderer.renderCount() != 0 || env.storage.putCount() != 0 {
t.Error("something was rendered or stored")
}
})
t.Run("A source beyond the byte cap answers 413", func(t *testing.T) {
env := newVariantEnvWith(t, newCountingRenderer(), 10)
env.seedGenerateBackground()
recorder := env.sendAsAdmin(http.MethodPost, "/v1/media/1/variants/thumb", "")
assertStatusAndBody(t, recorder, 413, apperr.VariantSourceTooLargeErrorMessage)
if env.storage.putCount() != 0 {
t.Error("a blob was stored")
}
})
t.Run("A source beyond the pixel cap answers 413", func(t *testing.T) {
env := newVariantEnvLimits(t, newCountingRenderer(), useCases.ServeVariantLimits{MaxSourceBytes: variantDefaultCap, MaxSourcePixels: 100, MaxConcurrentRenders: variantDefaultRenders})
env.seedGenerateBackground()
recorder := env.sendAsAdmin(http.MethodPost, "/v1/media/1/variants/thumb", "")
assertStatusAndBody(t, recorder, 413, apperr.VariantSourceTooLargeErrorMessage)
if env.storage.putCount() != 0 {
t.Error("a blob was stored")
}
})
t.Run("A source that cannot be decoded answers 415", func(t *testing.T) {
env := newVariantEnv(t)
env.seedGenerateBackground()
env.storage.seed("a1b2.png", []byte(unreadableBodyBytes))
recorder := env.sendAsAdmin(http.MethodPost, "/v1/media/1/variants/thumb", "")
assertStatusAndBody(t, recorder, 415, apperr.VariantSourceUnreadableErrorMessage)
if env.storage.putCount() != 0 {
t.Error("a blob was stored")
}
})
t.Run("Any other render failure is a generic 500 and is not cached", func(t *testing.T) {
env := newVariantEnv(t)
env.seedGenerateBackground()
env.renderer.failFirst = true
first := env.sendAsAdmin(http.MethodPost, "/v1/media/1/variants/thumb", "")
puts, rows := env.storage.putCount(), env.files.rowCount()
second := env.sendAsAdmin(http.MethodPost, "/v1/media/1/variants/thumb", "")
assertStatusAndBody(t, first, 500, internalErrorBody)
if puts != 0 || rows != 0 || second.Code != 200 {
t.Errorf("puts = %d, rows = %d, second = %d", puts, rows, second.Code)
}
})
t.Run("A generation does not change the original, the other variants or other images", func(t *testing.T) {
env := newVariantEnv(t)
env.seedGenerateBackground()
env.seedPerImage(3, "other.png", "c3d4.png", "image/png", 10, 10, solidPNG(t, 10, 10))
other := env.seedCurrentFile("hero", "c3d4.png", 5)
before := env.images.images[1]
env.sendAsAdmin(http.MethodPost, "/v1/media/1/variants/thumb", "")
kept, _, _ := env.files.Find("hero", "c3d4.png")
if env.images.images[1] != before || kept != other || env.files.rowCount() != 2 {
t.Error("the original, another variant or another image changed")
}
})
}
func TestPerImageVariantGenerateAll(t *testing.T) {
seedFive := func(env *variantEnv) {
seedVariant(env.variants, "card", 800, 0, domain.FitInside, domain.FormatJPEG, 80)
seedVariant(env.variants, "hero", 1200, 630, domain.FitCover, domain.FormatJPEG, 82)
seedVariant(env.variants, "og", 1200, 630, domain.FitCover, domain.FormatJPEG, 80)
seedVariant(env.variants, "print", 0, 2400, domain.FitInside, domain.FormatPNG, 0)
seedVariant(env.variants, "square", 600, 600, domain.FitCover, domain.FormatJPEG, 85)
seedVariant(env.variants, "thumb", 400, 300, domain.FitCover, domain.FormatJPEG, 80)
env.disable("print")
env.seedPerImage(1, "north-wall.jpg", "a1b2.png", "image/png", 1000, 800, encodePNG(env.t, variantSolidImage(1000, 800, variantRed)))
}
t.Run("Generate all renders every enabled variant and answers the refreshed list", func(t *testing.T) {
env := newVariantEnv(t)
seedFive(env)
recorder := env.sendAsAdmin(http.MethodPost, "/v1/media/1/variants", "")
body := decodeBody(t, recorder)
items := itemsOf(t, recorder)
if recorder.Code != 200 || body["generated"] != 5.0 || body["failed"] != 0.0 || !strings.Contains(recorder.Body.String(), `"failures":[]`) || len(items) != 5 {
t.Fatalf("response = %d %q", recorder.Code, recorder.Body.String())
}
for _, item := range items {
if item["generated"] != true || item["size"] == nil {
t.Errorf("item = %v", item)
}
}
if env.storage.putCount() != 5 || env.files.rowCount() != 5 || len(env.files.rowsOf("print")) != 0 {
t.Error("expected exactly five blobs and rows, none for the disabled variant")
}
})
t.Run("Variants already generated are skipped and not counted", func(t *testing.T) {
env := newVariantEnv(t)
seedFive(env)
env.sendAsAdmin(http.MethodPost, "/v1/media/1/variants/card", "")
env.sendAsAdmin(http.MethodPost, "/v1/media/1/variants/thumb", "")
recorder := env.sendAsAdmin(http.MethodPost, "/v1/media/1/variants", "")
generated := decodeBody(t, recorder)["generated"]
if generated != 3.0 || env.renderer.renderCount() != 5 {
t.Errorf("generated = %v, renders = %d", generated, env.renderer.renderCount())
}
for _, item := range itemsOf(t, recorder) {
if item["generated"] != true {
t.Errorf("item = %v", item)
}
}
})
failures := []struct {
name string
fail error
cause string
}{
{"ErrSourceTooLarge", domain.ErrSourceTooLarge, "too_large"},
{"ErrVariantSourceUnreadable", domain.ErrVariantSourceUnreadable, "unsupported_format"},
{"a renderer error that wraps no sentinel", errors.New("boom"), "failed"},
}
for _, row := range failures {
t.Run("A failing variant is reported and the others are still generated: "+row.name, func(t *testing.T) {
env := newVariantEnv(t)
seedFive(env)
heroFingerprint := env.variants.stored("hero").Settings().Fingerprint()
env.renderer.failFor = func(settings domain.VariantSettings) error {
if settings.Fingerprint() == heroFingerprint {
return row.fail
}
return nil
}
recorder := env.sendAsAdmin(http.MethodPost, "/v1/media/1/variants", "")
body := decodeBody(t, recorder)
failed, _ := body["failures"].([]any)
if recorder.Code != 200 || body["generated"] != 4.0 || body["failed"] != 1.0 || len(failed) != 1 || failed[0].(map[string]any)["name"] != "hero" || failed[0].(map[string]any)["cause"] != row.cause {
t.Fatalf("response = %d %q", recorder.Code, recorder.Body.String())
}
for _, item := range itemsOf(t, recorder) {
if (item["name"] == "hero") == (item["generated"] == true) {
t.Errorf("item = %v", item)
}
}
})
}
t.Run("Nothing to generate answers zero", func(t *testing.T) {
env := newVariantEnv(t)
seedFive(env)
env.sendAsAdmin(http.MethodPost, "/v1/media/1/variants", "")
rendered := env.renderer.renderCount()
recorder := env.sendAsAdmin(http.MethodPost, "/v1/media/1/variants", "")
body := decodeBody(t, recorder)
if recorder.Code != 200 || body["generated"] != 0.0 || body["failed"] != 0.0 || env.renderer.renderCount() != rendered {
t.Errorf("response = %d %q", recorder.Code, recorder.Body.String())
}
})
t.Run("No enabled variant answers zero", func(t *testing.T) {
env := newVariantEnv(t)
seedFive(env)
for _, name := range []string{"card", "hero", "og", "square", "thumb"} {
env.disable(name)
}
recorder := env.sendAsAdmin(http.MethodPost, "/v1/media/1/variants", "")
body := decodeBody(t, recorder)
if recorder.Code != 200 || body["generated"] != 0.0 || body["failed"] != 0.0 || !strings.Contains(recorder.Body.String(), `"items":[]`) {
t.Errorf("response = %d %q", recorder.Code, recorder.Body.String())
}
})
t.Run("An SVG answers 422", func(t *testing.T) {
env := newVariantEnv(t)
seedFive(env)
env.seedPerImage(2, "logo.svg", svgStoredName, "image/svg+xml", 0, 0, []byte("<svg/>"))
recorder := env.sendAsAdmin(http.MethodPost, "/v1/media/2/variants", "")
assertStatusAndBody(t, recorder, 422, apperr.VariantSourceUnsupportedErrorMessage)
})
}
func TestPerImageVariantPurge(t *testing.T) {
seedBoth := func(env *variantEnv) {
seedVariant(env.variants, "thumb", 400, 300, domain.FitCover, domain.FormatJPEG, 80)
seedVariant(env.variants, "hero", 1200, 630, domain.FitCover, domain.FormatJPEG, 82)
env.seedPerImage(1, "north-wall.jpg", "a1b2.jpg", "image/jpeg", 100, 100, nil)
env.seedPerImage(3, "other.jpg", "c3d4.jpg", "image/jpeg", 100, 100, nil)
for _, source := range []string{"a1b2.jpg", "c3d4.jpg"} {
env.seedCurrentFile("thumb", source, 18432)
env.seedCurrentFile("hero", source, 98304)
}
}
t.Run("Purging a generated variant removes its file and its row", func(t *testing.T) {
env := newVariantEnv(t)
seedBoth(env)
recorder := env.sendAsAdmin(http.MethodDelete, "/v1/media/1/variants/thumb", "")
assertStatusAndBody(t, recorder, 200, `{"purged_files":1,"purged_bytes":18432}`)
if got := strings.Join(env.storage.deletedNames(), ","); got != "cached-thumb-a1b2.jpg" {
t.Errorf("deleted = %s", got)
}
if _, found, _ := env.files.Find("thumb", "a1b2.jpg"); found || env.files.rowCount() != 3 {
t.Error("the row is still there, or another row went with it")
}
})
t.Run("The next public request generates the file again", func(t *testing.T) {
env := newVariantEnv(t)
env.seedGenerateBackground()
env.sendAsAdmin(http.MethodPost, "/v1/media/1/variants/thumb", "")
env.sendAsAdmin(http.MethodDelete, "/v1/media/1/variants/thumb", "")
puts := env.storage.putCount()
public := env.get("/static/uploadedImages/a1b2.png?v=thumb", nil)
if public.Code != 200 || env.storage.putCount() != puts+1 {
t.Errorf("status = %d, puts = %d", public.Code, env.storage.putCount())
}
})
t.Run("Purging a variant that is not generated answers zero", func(t *testing.T) {
env := newVariantEnv(t)
seedBoth(env)
row, _, _ := env.files.Find("thumb", "a1b2.jpg")
_ = env.files.Delete(row.ID)
recorder := env.sendAsAdmin(http.MethodDelete, "/v1/media/1/variants/thumb", "")
assertStatusAndBody(t, recorder, 200, `{"purged_files":0,"purged_bytes":0}`)
})
t.Run("A disabled variant keeps its files and they can be purged", func(t *testing.T) {
env := newVariantEnv(t)
seedBoth(env)
env.disable("thumb")
recorder := env.sendAsAdmin(http.MethodDelete, "/v1/media/1/variants/thumb", "")
if decodeBody(t, recorder)["purged_files"] != 1.0 {
t.Errorf("body = %q", recorder.Body.String())
}
})
t.Run("An unknown variant answers 404", func(t *testing.T) {
env := newVariantEnv(t)
seedBoth(env)
recorder := env.sendAsAdmin(http.MethodDelete, "/v1/media/1/variants/missing", "")
assertStatusAndBody(t, recorder, 404, variantNotFound)
})
t.Run("A blob that cannot be deleted keeps its row and the call fails", func(t *testing.T) {
env := newVariantEnv(t)
seedBoth(env)
env.storage.failDelete["cached-thumb-a1b2.jpg"] = errVariantStorageFailure
recorder := env.sendAsAdmin(http.MethodDelete, "/v1/media/1/variants/thumb", "")
assertStatusAndBody(t, recorder, 500, internalErrorBody)
if _, found, _ := env.files.Find("thumb", "a1b2.jpg"); !found {
t.Error("the index row was removed")
}
})
}