Files
RenewCMS/test/image_variants_admin_http_test.go
2026-10-08 02:33:32 +02:00

641 lines
26 KiB
Go

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
}