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("")) 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("")) 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") } }) }