641 lines
26 KiB
Go
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
|
|
}
|