package test import ( "RenewCMS/api/pkg/apperr" domain "RenewCMS/internal/domain/image" "net/http" "net/http/httptest" "strings" "testing" ) const ( nameSentence = "Use lowercase letters, numbers and dashes, 2 to 32 characters." dimensionsSentence = "Width and height must be between 1 and 8000, and one of them is required." fitSentence = "Fit must be cover, contain or inside." formatSentence = "Format must be original, jpeg, png or webp." formatNoWebP = "Format must be original, jpeg or png." qualitySentence = "Quality must be between 1 and 100." thumbTakenSentence = "A variant named “thumb” already exists. Choose another name." variantNotFound = "Variant not found" ) func assertStatusAndBody(t *testing.T, recorder *httptest.ResponseRecorder, status int, body string) { t.Helper() if recorder.Code != status || strings.TrimSpace(recorder.Body.String()) != body { t.Errorf("response = %d %q, want %d %q", recorder.Code, recorder.Body.String(), status, body) } } func TestImageVariantsGuards(t *testing.T) { rows := []struct { name string method string path string credentials string status int body string }{ {"no cookie", http.MethodGet, "/v1/image-variants", "none", 401, apperr.UnauthorizedErrorMessage}, {"stale cookie", http.MethodGet, "/v1/image-variants", "stale", 401, apperr.UnauthorizedErrorMessage}, {"unverified user", http.MethodGet, "/v1/image-variants", "unverified", 403, apperr.VerificationRequiredErrorMessage}, {"plain user list", http.MethodGet, "/v1/image-variants", "plain", 403, apperr.OwnerOrAdminRequiredErrorMessage}, {"plain user create", http.MethodPost, "/v1/image-variants", "plain", 403, apperr.OwnerOrAdminRequiredErrorMessage}, {"plain user put", http.MethodPut, "/v1/image-variants/hero", "plain", 403, apperr.OwnerOrAdminRequiredErrorMessage}, {"plain user patch", http.MethodPatch, "/v1/image-variants/hero", "plain", 403, apperr.OwnerOrAdminRequiredErrorMessage}, {"plain user delete", http.MethodDelete, "/v1/image-variants/hero", "plain", 403, apperr.OwnerOrAdminRequiredErrorMessage}, {"plain user purge", http.MethodPost, "/v1/image-variants/hero/purge", "plain", 403, apperr.OwnerOrAdminRequiredErrorMessage}, {"api key", http.MethodGet, "/v1/image-variants", "apikey", 401, apperr.UnauthorizedErrorMessage}, } for _, row := range rows { t.Run("Every variant route is guarded: "+row.name, func(t *testing.T) { env := newVariantEnv(t) seedVariant(env.variants, "hero", 100, 0, domain.FitCover, domain.FormatJPEG, 80) seedVariantFiles(env.files, env.storage, "hero", "x", 2, 10) callsBefore := env.variants.calls request := httptest.NewRequest(row.method, row.path, strings.NewReader(`{"width": 100}`)) 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 = bearerKeyHeader() } recorder := httptest.NewRecorder() env.admin.ServeHTTP(recorder, request) assertStatusAndBody(t, recorder, row.status, row.body) if env.variants.calls != callsBefore || env.files.rowCount() != 2 || len(env.storage.deletedNames()) != 0 || env.events() != 0 { t.Error("a variant, generated file or activity event was touched") } }) } 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) recorder := env.send(http.MethodGet, "/v1/image-variants", "", userId) if recorder.Code != http.StatusOK { t.Errorf("status = %d", recorder.Code) } }) } } func TestImageVariantsListing(t *testing.T) { t.Run("An empty list is an empty array", func(t *testing.T) { env := newVariantEnv(t) recorder := env.sendAsAdmin(http.MethodGet, "/v1/image-variants", "") if recorder.Code != 200 || strings.TrimSpace(recorder.Body.String()) != `{"items":[]}` { t.Errorf("response = %d %q", recorder.Code, recorder.Body.String()) } }) t.Run("The list carries the generated count and cache size per variant", func(t *testing.T) { env := newVariantEnv(t) seedVariant(env.variants, "hero", 1200, 630, domain.FitCover, domain.FormatWebP, 82) card := seedVariant(env.variants, "card", 800, 0, domain.FitInside, domain.FormatJPEG, 80) card.Enabled = false env.variants.variants["card"] = card for index, size := range []int64{1000, 2000, 3000} { env.files.Save(domain.VariantFile{VariantName: "hero", SourceName: string(rune('a' + index)), CachedName: "g", Size: size}) } recorder := env.sendAsAdmin(http.MethodGet, "/v1/image-variants", "") items := decodeBody(t, recorder)["items"].([]any) first, second := items[0].(map[string]any), items[1].(map[string]any) if first["name"] != "card" || second["name"] != "hero" { t.Fatalf("order = %v, %v", first["name"], second["name"]) } if first["width"] != 800.0 || first["height"] != nil || first["fit"] != "inside" || first["format"] != "jpeg" || first["quality"] != 80.0 || first["enabled"] != false || first["generated_files"] != 0.0 || first["cache_size"] != 0.0 { t.Errorf("card = %v", first) } if second["width"] != 1200.0 || second["height"] != 630.0 || second["fit"] != "cover" || second["format"] != "webp" || second["quality"] != 82.0 || second["enabled"] != true || second["generated_files"] != 3.0 || second["cache_size"] != 6000.0 { t.Errorf("hero = %v", second) } if !strings.Contains(recorder.Body.String(), `"height":null`) { t.Errorf("raw body = %q", recorder.Body.String()) } }) t.Run("A png variant lists a null quality", func(t *testing.T) { env := newVariantEnv(t) seedVariant(env.variants, "logo", 100, 0, domain.FitCover, domain.FormatPNG, 0) recorder := env.sendAsAdmin(http.MethodGet, "/v1/image-variants", "") if !strings.Contains(recorder.Body.String(), `"quality":null`) { t.Errorf("raw body = %q", recorder.Body.String()) } }) } func TestImageVariantsCreate(t *testing.T) { t.Run("Creating a variant with every field", func(t *testing.T) { env := newVariantEnv(t) recorder := env.sendAsAdmin(http.MethodPost, "/v1/image-variants", `{"name": "hero", "width": 1200, "height": 630, "fit": "cover", "format": "webp", "quality": 82, "generate_on_upload": true, "enabled": true}`) body := decodeBody(t, recorder) if recorder.Code != 201 || body["name"] != "hero" || body["width"] != 1200.0 || body["height"] != 630.0 || body["format"] != "webp" || body["generate_on_upload"] != true || body["generated_files"] != 0.0 || body["cache_size"] != 0.0 || env.variants.count() != 1 { t.Errorf("response = %d %v", recorder.Code, body) } }) t.Run("Omitted optional fields take their defaults", func(t *testing.T) { env := newVariantEnv(t) recorder := env.sendAsAdmin(http.MethodPost, "/v1/image-variants", `{"name": "thumb", "width": 400}`) body := decodeBody(t, recorder) if recorder.Code != 201 || body["width"] != 400.0 || body["height"] != nil || body["fit"] != "cover" || body["format"] != "original" || body["quality"] != 82.0 || body["generate_on_upload"] != false || body["enabled"] != true { t.Errorf("response = %d %v", recorder.Code, body) } }) t.Run("A quality sent with png is ignored and returned as null", func(t *testing.T) { env := newVariantEnv(t) recorder := env.sendAsAdmin(http.MethodPost, "/v1/image-variants", `{"name": "logo", "width": 100, "format": "png", "quality": 30}`) if body := decodeBody(t, recorder); recorder.Code != 201 || body["quality"] != nil || env.variants.stored("logo").Quality != nil { t.Errorf("response = %d %v", recorder.Code, body) } }) for _, body := range []string{ `{"name": "ab", "width": 1}`, `{"name": "a-b-c", "height": 8000}`, `{"name": "` + strings.Repeat("a", 31) + `-", "width": 8000, "height": 8000}`, `{"name": "q1", "width": 10, "quality": 1}`, `{"name": "q100", "width": 10, "quality": 100}`, } { t.Run("A valid boundary value is accepted: "+body, func(t *testing.T) { env := newVariantEnv(t) recorder := env.sendAsAdmin(http.MethodPost, "/v1/image-variants", body) if recorder.Code != 201 { t.Errorf("response = %d %q", recorder.Code, recorder.Body.String()) } }) } invalid := []struct{ body, message string }{ {`{"name": "a", "width": 10}`, nameSentence}, {`{"name": "` + strings.Repeat("a", 33) + `", "width": 10}`, nameSentence}, {`{"name": "Hero", "width": 10}`, nameSentence}, {`{"name": "he ro", "width": 10}`, nameSentence}, {`{"name": "hero"}`, dimensionsSentence}, {`{"name": "hero", "width": 0}`, dimensionsSentence}, {`{"name": "hero", "width": 8001}`, dimensionsSentence}, {`{"name": "hero", "height": -1}`, dimensionsSentence}, {`{"name": "hero", "width": 10, "fit": "stretch"}`, fitSentence}, {`{"name": "hero", "width": 10, "format": "avif"}`, formatSentence}, {`{"name": "hero", "width": 10, "quality": 0}`, qualitySentence}, {`{"name": "hero", "width": 10, "quality": 101}`, qualitySentence}, } for _, row := range invalid { t.Run("An invalid field answers 422 with the field's sentence and stores nothing: "+row.body, func(t *testing.T) { env := newVariantEnv(t) recorder := env.sendAsAdmin(http.MethodPost, "/v1/image-variants", row.body) assertStatusAndBody(t, recorder, 422, row.message) if env.variants.count() != 0 || env.events() != 0 { t.Error("something was stored or recorded") } }) } for _, body := range []string{"not json", `{"name": "hero", "width": "wide"}`, ""} { t.Run("A malformed request answers 400: "+body, func(t *testing.T) { env := newVariantEnv(t) recorder := env.sendAsAdmin(http.MethodPost, "/v1/image-variants", body) assertStatusAndBody(t, recorder, 400, apperr.BodyErrorMessage) }) } t.Run("A duplicate name answers 409 and keeps the existing variant", func(t *testing.T) { env := newVariantEnv(t) before := seedVariant(env.variants, "thumb", 400, 300, domain.FitCover, domain.FormatJPEG, 80) recorder := env.sendAsAdmin(http.MethodPost, "/v1/image-variants", `{"name": "thumb", "width": 800, "format": "png"}`) assertStatusAndBody(t, recorder, 409, thumbTakenSentence) after := env.variants.stored("thumb") if *after.Width != 400 || after.Format != domain.FormatJPEG || !after.CreatedAt.Equal(before.CreatedAt) { t.Errorf("stored = %+v", after) } }) } func TestImageVariantsWebPAvailability(t *testing.T) { t.Run("When no encoder is viable, webp is refused", func(t *testing.T) { env := newVariantEnvWith(t, newCountingRendererWithoutWebP(), variantDefaultCap) recorder := env.sendAsAdmin(http.MethodPost, "/v1/image-variants", `{"name": "wp", "width": 10, "format": "webp"}`) assertStatusAndBody(t, recorder, 422, formatNoWebP) if env.variants.count() != 0 || env.events() != 0 { t.Error("something was stored or recorded") } }) t.Run("Without an encoder an unknown format uses the shorter sentence", func(t *testing.T) { env := newVariantEnvWith(t, newCountingRendererWithoutWebP(), variantDefaultCap) recorder := env.sendAsAdmin(http.MethodPost, "/v1/image-variants", `{"name": "wp", "width": 10, "format": "avif"}`) assertStatusAndBody(t, recorder, 422, formatNoWebP) }) t.Run("Without an encoder original, jpeg and png still work", func(t *testing.T) { env := newVariantEnvWith(t, newCountingRendererWithoutWebP(), variantDefaultCap) for _, format := range []string{"original", "jpeg", "png"} { recorder := env.sendAsAdmin(http.MethodPost, "/v1/image-variants", `{"name": "v-`+format+`", "width": 10, "format": "`+format+`"}`) if recorder.Code != 201 { t.Errorf("%s: status = %d", format, recorder.Code) } } }) t.Run("Without an encoder an update to webp is refused", func(t *testing.T) { env := newVariantEnvWith(t, newCountingRendererWithoutWebP(), variantDefaultCap) seedVariant(env.variants, "hero", 100, 0, domain.FitCover, domain.FormatJPEG, 80) recorder := env.sendAsAdmin(http.MethodPut, "/v1/image-variants/hero", `{"width": 100, "format": "webp"}`) assertStatusAndBody(t, recorder, 422, formatNoWebP) }) t.Run("When an encoder exists, webp is offered", func(t *testing.T) { env := newVariantEnv(t) recorder := env.sendAsAdmin(http.MethodPost, "/v1/image-variants", `{"name": "wp", "width": 10, "format": "webp"}`) if recorder.Code != 201 { t.Errorf("status = %d", recorder.Code) } }) } type updateFixture struct { env *variantEnv } func newUpdateFixture(t *testing.T) updateFixture { t.Helper() env := newVariantEnv(t) seedVariant(env.variants, "hero", 1200, 630, domain.FitCover, domain.FormatWebP, 82) seedVariant(env.variants, "thumb", 400, 0, domain.FitCover, domain.FormatJPEG, 80) seedVariantFiles(env.files, env.storage, "hero", "src", 244, 344000) seedVariantFiles(env.files, env.storage, "thumb", "src", 3, 10) return updateFixture{env} } const storedHeroBody = `"width": 1200, "height": 630, "fit": "cover", "format": "webp", "quality": 82, "generate_on_upload": false, "enabled": true` func TestImageVariantsUpdate(t *testing.T) { t.Run("Changing quality updates the variant and purges its generated files", func(t *testing.T) { fixture := newUpdateFixture(t) env := fixture.env recorder := env.sendAsAdmin(http.MethodPut, "/v1/image-variants/hero", `{"width": 1200, "height": 630, "fit": "cover", "format": "webp", "quality": 70, "generate_on_upload": false, "enabled": true}`) body := decodeBody(t, recorder) if recorder.Code != 200 || body["quality"] != 70.0 || body["generated_files"] != 0.0 || body["cache_size"] != 0.0 || body["purged_files"] != 244.0 { t.Errorf("response = %d %v", recorder.Code, body) } if len(env.storage.deletedNames()) != 244 || len(env.files.rowsOf("hero")) != 0 || len(env.files.rowsOf("thumb")) != 3 { t.Errorf("deleted = %d, hero rows = %d, thumb rows = %d", len(env.storage.deletedNames()), len(env.files.rowsOf("hero")), len(env.files.rowsOf("thumb"))) } if env.variants.stored("hero").UpdatedAt.Equal(env.variants.stored("hero").CreatedAt) { t.Error("updated_at did not move") } }) settings := []struct{ setting, body string }{ {"width", `{"width": 1000, "height": 630, "fit": "cover", "format": "webp", "quality": 82}`}, {"height", `{"width": 1200, "height": null, "fit": "cover", "format": "webp", "quality": 82}`}, {"fit", `{"width": 1200, "height": 630, "fit": "inside", "format": "webp", "quality": 82}`}, {"format", `{"width": 1200, "height": 630, "fit": "cover", "format": "jpeg", "quality": 82}`}, {"quality", `{"width": 1200, "height": 630, "fit": "cover", "format": "webp", "quality": 60}`}, } for _, row := range settings { t.Run("A change to a rendering setting purges: "+row.setting, func(t *testing.T) { env := newUpdateFixture(t).env recorder := env.sendAsAdmin(http.MethodPut, "/v1/image-variants/hero", row.body) if body := decodeBody(t, recorder); recorder.Code != 200 || body["purged_files"] != 244.0 { t.Errorf("response = %d %v", recorder.Code, body) } }) } switches := []struct{ setting, body string }{ {"generate_on_upload", `{"width": 1200, "height": 630, "fit": "cover", "format": "webp", "quality": 82, "generate_on_upload": true, "enabled": true}`}, {"enabled", `{"width": 1200, "height": 630, "fit": "cover", "format": "webp", "quality": 82, "generate_on_upload": false, "enabled": false}`}, } for _, row := range switches { t.Run("A change to a switch does not purge: "+row.setting, func(t *testing.T) { env := newUpdateFixture(t).env recorder := env.sendAsAdmin(http.MethodPut, "/v1/image-variants/hero", row.body) body := decodeBody(t, recorder) if recorder.Code != 200 || body["purged_files"] != 0.0 || body["generated_files"] != 244.0 || len(env.storage.deletedNames()) != 0 { t.Errorf("response = %d %v", recorder.Code, body) } }) } t.Run("A PUT identical to the stored variant changes nothing", func(t *testing.T) { env := newUpdateFixture(t).env before := env.variants.stored("hero") recorder := env.sendAsAdmin(http.MethodPut, "/v1/image-variants/hero", `{`+storedHeroBody+`}`) body := decodeBody(t, recorder) if recorder.Code != 200 || body["purged_files"] != 0.0 || !env.variants.stored("hero").UpdatedAt.Equal(before.UpdatedAt) || env.events() != 0 { t.Errorf("response = %d %v", recorder.Code, body) } }) t.Run("Switching the format to png drops the quality and purges", func(t *testing.T) { env := newUpdateFixture(t).env recorder := env.sendAsAdmin(http.MethodPut, "/v1/image-variants/hero", `{"width": 1200, "height": 630, "fit": "cover", "format": "png", "quality": 40}`) body := decodeBody(t, recorder) if recorder.Code != 200 || body["quality"] != nil || body["purged_files"] != 244.0 { t.Errorf("response = %d %v", recorder.Code, body) } }) t.Run("Changing the quality of a png variant is a no-op", func(t *testing.T) { env := newVariantEnv(t) seedVariant(env.variants, "logo", 100, 0, domain.FitCover, domain.FormatPNG, 0) seedVariantFiles(env.files, env.storage, "logo", "src", 2, 10) recorder := env.sendAsAdmin(http.MethodPut, "/v1/image-variants/logo", `{"width": 100, "fit": "cover", "format": "png", "quality": 30}`) body := decodeBody(t, recorder) if body["purged_files"] != 0.0 || body["quality"] != nil || env.events() != 0 { t.Errorf("response = %d %v", recorder.Code, body) } }) t.Run("The name is immutable", func(t *testing.T) { env := newUpdateFixture(t).env env.sendAsAdmin(http.MethodPut, "/v1/image-variants/hero", `{"name": "banner", "width": 1200, "height": 630}`) if env.variants.stored("hero").Name != "hero" || env.variants.stored("banner").Name != "" { t.Error("the variant was renamed") } }) t.Run("Omitted switches keep their stored values", func(t *testing.T) { env := newUpdateFixture(t).env hero := env.variants.stored("hero") hero.Enabled, hero.GenerateOnUpload = false, true env.variants.variants["hero"] = hero env.sendAsAdmin(http.MethodPut, "/v1/image-variants/hero", `{"width": 1200, "height": 630, "format": "webp", "quality": 70}`) if stored := env.variants.stored("hero"); stored.Enabled || !stored.GenerateOnUpload { t.Errorf("stored = %+v", stored) } }) invalid := []string{`{"quality": 101}`, `{"width": null, "height": null}`, `{"width": 9000}`, `{"fit": "stretch"}`} for _, body := range invalid { t.Run("An invalid PUT answers like a create and changes nothing: "+body, func(t *testing.T) { env := newUpdateFixture(t).env before := env.variants.stored("hero") recorder := env.sendAsAdmin(http.MethodPut, "/v1/image-variants/hero", body) if recorder.Code != 422 || env.variants.stored("hero").UpdatedAt != before.UpdatedAt || len(env.files.rowsOf("hero")) != 244 || len(env.storage.deletedNames()) != 0 { t.Errorf("response = %d %q", recorder.Code, recorder.Body.String()) } }) } t.Run("A malformed PUT answers 400", func(t *testing.T) { env := newUpdateFixture(t).env recorder := env.sendAsAdmin(http.MethodPut, "/v1/image-variants/hero", "not json") assertStatusAndBody(t, recorder, 400, apperr.BodyErrorMessage) }) t.Run("An unknown variant answers 404", func(t *testing.T) { env := newUpdateFixture(t).env recorder := env.sendAsAdmin(http.MethodPut, "/v1/image-variants/missing", `{"width": 100}`) assertStatusAndBody(t, recorder, 404, variantNotFound) }) } func TestImageVariantsToggle(t *testing.T) { for _, row := range []struct { before, after bool }{{true, false}, {false, true}} { name := "PATCH toggles enabled and keeps the cache" t.Run(name, func(t *testing.T) { env := newUpdateFixture(t).env hero := env.variants.stored("hero") hero.Enabled = row.before env.variants.variants["hero"] = hero payload := `{"enabled": false}` if row.after { payload = `{"enabled": true}` } recorder := env.sendAsAdmin(http.MethodPatch, "/v1/image-variants/hero", payload) body := decodeBody(t, recorder) if recorder.Code != 200 || body["enabled"] != row.after || body["generated_files"] != 244.0 || len(env.storage.deletedNames()) != 0 { t.Errorf("response = %d %v", recorder.Code, body) } }) } rows := []struct { name, path, body string status int }{ {"not json", "hero", "not json", 400}, {"empty object", "hero", `{}`, 422}, {"wrong type", "hero", `{"enabled": "no"}`, 400}, {"unknown variant", "missing", `{"enabled": true}`, 404}, } for _, row := range rows { t.Run("A PATCH that is not valid or not found: "+row.name, func(t *testing.T) { env := newUpdateFixture(t).env recorder := env.sendAsAdmin(http.MethodPatch, "/v1/image-variants/"+row.path, row.body) if recorder.Code != row.status { t.Errorf("status = %d", recorder.Code) } }) } } func TestImageVariantsDeleteAndPurge(t *testing.T) { t.Run("Deleting removes the definition and its generated files", func(t *testing.T) { env := newUpdateFixture(t).env recorder := env.sendAsAdmin(http.MethodDelete, "/v1/image-variants/hero", "") if recorder.Code != 204 || recorder.Body.Len() != 0 { t.Fatalf("response = %d %q", recorder.Code, recorder.Body.String()) } if env.variants.stored("hero").Name != "" || len(env.storage.deletedNames()) != 244 || len(env.files.rowsOf("hero")) != 0 || len(env.files.rowsOf("thumb")) != 3 || env.variants.stored("thumb").Name == "" { t.Error("unexpected state after delete") } }) t.Run("Purging removes only the generated files", func(t *testing.T) { env := newUpdateFixture(t).env recorder := env.sendAsAdmin(http.MethodPost, "/v1/image-variants/hero/purge", "") body := decodeBody(t, recorder) if recorder.Code != 200 || body["purged_files"] != 244.0 || body["purged_bytes"] != float64(244*344000) { t.Errorf("response = %d %v", recorder.Code, body) } if env.variants.stored("hero").Name == "" || len(env.files.rowsOf("hero")) != 0 { t.Error("unexpected state after purge") } listed := decodeBody(t, env.sendAsAdmin(http.MethodGet, "/v1/image-variants", ""))["items"].([]any)[0].(map[string]any) if listed["name"] != "hero" || listed["generated_files"] != 0.0 || listed["cache_size"] != 0.0 { t.Errorf("listed = %v", listed) } }) t.Run("Purging an empty cache answers zero", func(t *testing.T) { env := newVariantEnv(t) seedVariant(env.variants, "hero", 100, 0, domain.FitCover, domain.FormatJPEG, 80) recorder := env.sendAsAdmin(http.MethodPost, "/v1/image-variants/hero/purge", "") assertStatusAndBody(t, recorder, 200, `{"purged_files":0,"purged_bytes":0}`) }) for _, row := range []struct{ method, suffix string }{{http.MethodDelete, ""}, {http.MethodPost, "/purge"}} { t.Run("An unknown variant answers 404: "+row.method, func(t *testing.T) { env := newVariantEnv(t) recorder := env.sendAsAdmin(row.method, "/v1/image-variants/missing"+row.suffix, "") assertStatusAndBody(t, recorder, 404, variantNotFound) }) } t.Run("A blob that cannot be deleted keeps its index row and the call fails", func(t *testing.T) { env := newVariantEnv(t) seedVariant(env.variants, "hero", 100, 0, domain.FitCover, domain.FormatJPEG, 80) rows := seedVariantFiles(env.files, env.storage, "hero", "src", 3, 10) env.storage.failDelete[rows[1].CachedName] = errVariantStorageFailure recorder := env.sendAsAdmin(http.MethodPost, "/v1/image-variants/hero/purge", "") assertStatusAndBody(t, recorder, 500, "Internal server error") remaining := env.files.rowsOf("hero") if len(remaining) != 1 || remaining[0].ID != rows[1].ID || env.storage.has(rows[0].CachedName) || env.storage.has(rows[2].CachedName) { t.Errorf("remaining = %+v", remaining) } }) t.Run("A blob already missing from storage does not stop a purge", func(t *testing.T) { env := newVariantEnv(t) seedVariant(env.variants, "hero", 100, 0, domain.FitCover, domain.FormatJPEG, 80) env.files.Save(domain.VariantFile{VariantName: "hero", SourceName: "x", CachedName: "gone.jpeg", Size: 5}) recorder := env.sendAsAdmin(http.MethodPost, "/v1/image-variants/hero/purge", "") if recorder.Code != 200 || len(env.files.rowsOf("hero")) != 0 { t.Errorf("response = %d", recorder.Code) } }) t.Run("A delete whose purge fails keeps the variant", func(t *testing.T) { env := newVariantEnv(t) seedVariant(env.variants, "hero", 100, 0, domain.FitCover, domain.FormatJPEG, 80) rows := seedVariantFiles(env.files, env.storage, "hero", "src", 2, 10) env.storage.failDelete[rows[0].CachedName] = errVariantStorageFailure recorder := env.sendAsAdmin(http.MethodDelete, "/v1/image-variants/hero", "") if recorder.Code != 500 || env.variants.stored("hero").Name == "" { t.Errorf("response = %d", recorder.Code) } }) } func TestImageVariantsSourceRemoval(t *testing.T) { t.Run("Deleting a source image purges its generated files", func(t *testing.T) { env := newVariantEnv(t) env.seedImage(1, "north-wall.png") env.seedImage(2, "other.png") env.seedSource("north-wall.png", []byte("original")) env.seedSource("other.png", []byte("original")) hero := seedCachedFile(env, "hero", "north-wall.png", "g-hero.jpeg") thumb := seedCachedFile(env, "thumb", "north-wall.png", "g-thumb.jpeg") kept := seedCachedFile(env, "hero", "other.png", "g-other.jpeg") recorder := env.send(http.MethodDelete, "/v1/media/1", "", variantAdminId) if recorder.Code != 204 { t.Fatalf("status = %d", recorder.Code) } if env.storage.has(hero.CachedName) || env.storage.has(thumb.CachedName) || !env.storage.has(kept.CachedName) || env.files.rowCount() != 1 { t.Error("unexpected generated files after the delete") } }) t.Run("Merging copies purges the generated files of the removed copies", func(t *testing.T) { env := newVariantEnv(t) for id := uint32(5); id <= 7; id++ { env.seedImage(id, "copy-"+string(rune('0'+id))+".png") env.seedSource("copy-"+string(rune('0'+id))+".png", []byte("original")) } keptFile := seedCachedFile(env, "hero", "copy-5.png", "g5.jpeg") six := seedCachedFile(env, "hero", "copy-6.png", "g6.jpeg") seven := seedCachedFile(env, "thumb", "copy-7.png", "g7.jpeg") recorder := env.send(http.MethodPost, "/v1/media/merge", `{"keep_id": 5, "merge_ids": [6, 7]}`, variantAdminId) if recorder.Code != 200 { t.Fatalf("status = %d %q", recorder.Code, recorder.Body.String()) } if env.storage.has(six.CachedName) || env.storage.has(seven.CachedName) || !env.storage.has(keptFile.CachedName) || env.files.rowCount() != 1 { t.Error("unexpected generated files after the merge") } }) } func seedCachedFile(env *variantEnv, variantName string, sourceName string, cachedName string) domain.VariantFile { env.storage.seed(cachedName, []byte("generated")) saved, _ := env.files.Save(domain.VariantFile{VariantName: variantName, SourceName: sourceName, CachedName: cachedName, ContentType: "image/jpeg", Size: 9, Fingerprint: "f"}) return saved }