feat: image variants engine

This commit is contained in:
fsylvain committed 2026-10-08 02:33:32 +02:00
1 parent f0845de643
commit f1d56ef50e
80 files changed
+7312 -84

No files matched your search

+1 -1
View File
@@ -383,7 +383,7 @@ func TestActivityLogProducersScheduledPurge(t *testing.T) {
images := &producerBlockingImageRepo{mockImageRepo: newMockImageRepo(), entered: make(chan struct{}, 2), release: make(chan struct{})}
articles := newMockArticleRepo()
storage := newMockImageStorage()
merge := useCases.NewMergeMediaUseCase(images, articles, storage, h.recorder)
merge := useCases.NewMergeMediaUseCase(images, articles, storage, newMockVariantFileRepo(), h.recorder)
run := useCases.NewRunPurgeUseCase(images, articles, merge, newMockPurgeSettingsRepo(), func() time.Time { return now }, log.New(io.Discard, "", 0), h.recorder)
finished := make(chan struct{})
+2 -2
View File
@@ -100,7 +100,7 @@ func newAPIKeyEnvRecording(t *testing.T, recorder audit.Recorder) *apiKeyEnv {
mail := newMockMailRepo()
env.mail = mail
renderer := &fakeBodyRenderer{}
mergeMedia := useCases.NewMergeMediaUseCase(env.images, env.articles, newMockImageStorage(), recorder)
mergeMedia := useCases.NewMergeMediaUseCase(env.images, env.articles, newMockImageStorage(), newMockVariantFileRepo(), recorder)
purgeSettings := newMockPurgeSettingsRepo()
restartJobs := &fakeJobStore{}
env.container = &api.UseCases{
@@ -127,7 +127,7 @@ func newAPIKeyEnvRecording(t *testing.T, recorder audit.Recorder) *apiKeyEnv {
CreateImageUseCase: useCases.NewCreateImageUseCase(env.images, newMockImageStorage(), recorder),
ListMediaUseCase: useCases.NewListMediaUseCase(env.images, env.articles, env.users),
RenameMediaUseCase: useCases.NewRenameMediaUseCase(env.images, recorder),
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(env.images, newMockImageStorage(), recorder),
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(env.images, newMockImageStorage(), newMockVariantFileRepo(), recorder),
MergeMediaUseCase: mergeMedia,
RunPurgeUseCase: useCases.NewRunPurgeUseCase(env.images, env.articles, mergeMedia, purgeSettings, clock, log.New(io.Discard, "", 0), recorder),
RequestRestartUseCase: useCases.NewRequestRestartUseCase(env.updates, restartJobs, &fakeHelperDetector{installed: true}, &fakeResultStore{}, &fakeRestartRequestStore{}, apiKeyRunningVersion, clock, recorder),
+13 -1
View File
@@ -245,12 +245,24 @@ func (m *mockImageRepo) Counts() (imageDomain.Counts, error) {
return imageDomain.Counts{All: len(m.images), DuplicateGroups: groups}, nil
}
func (m *mockImageRepo) ExistsByStoredName(storedName string) (bool, error) {
if err := m.fail.trigger("ExistsByStoredName"); err != nil {
return false, err
}
for _, img := range m.images {
if imageDomain.StoredName(img.Path) == storedName {
return true, nil
}
}
return false, nil
}
var _ imageDomain.Repository = &mockImageRepo{}
func TestImageUseCases(t *testing.T) {
repo := newMockImageRepo()
createUc := useCases.NewCreateImageUseCase(repo, newMockImageStorage(), discardRecorder)
deleteUc := useCases.NewDeleteMediaUseCase(repo, newMockImageStorage(), discardRecorder)
deleteUc := useCases.NewDeleteMediaUseCase(repo, newMockImageStorage(), newMockVariantFileRepo(), discardRecorder)
t.Run("Create Image", func(t *testing.T) {
input := imageDomain.ImageInput{
+640
View File
@@ -0,0 +1,640 @@
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
}
+278
View File
@@ -0,0 +1,278 @@
package test
import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/audit"
domain "RenewCMS/internal/domain/image"
"encoding/json"
"errors"
"net/http"
"slices"
"testing"
)
func eventsOf(env *variantEnv, action string) []audit.Event {
matching := []audit.Event{}
for _, event := range env.audit.all(env.t) {
if event.Action == action {
matching = append(matching, event)
}
}
return matching
}
func onlyEventOf(t *testing.T, env *variantEnv, action string) audit.Event {
t.Helper()
matching := eventsOf(env, action)
if len(matching) != 1 || env.events() != 1 {
t.Fatalf("%d %s events among %d", len(matching), action, env.events())
}
return matching[0]
}
func marshalled(t *testing.T, value any) string {
t.Helper()
encoded, err := json.Marshal(value)
if err != nil {
t.Fatal(err)
}
return string(encoded)
}
func assertVariantTarget(t *testing.T, event audit.Event, name string) {
t.Helper()
if got := marshalled(t, event.Target); got != `{"type":"image_variant","id":"`+name+`","name":"`+name+`"}` {
t.Errorf("target = %s", got)
}
}
func ownerHeroEnv(t *testing.T) *variantEnv {
t.Helper()
env := newVariantEnv(t)
seedVariant(env.variants, "hero", 1200, 630, domain.FitCover, domain.FormatWebP, 82)
seedVariantFiles(env.files, env.storage, "hero", "src", 244, 344000)
return env
}
func TestImageVariantsActivityLog(t *testing.T) {
t.Run("Creating a variant is recorded", func(t *testing.T) {
env := newVariantEnv(t)
env.send(http.MethodPost, "/v1/image-variants", `{"name": "hero", "width": 1200, "height": 630, "fit": "cover", "format": "webp", "quality": 82}`, variantOwnerId)
event := onlyEventOf(t, env, "media.variant_create")
assertVariantTarget(t, event, "hero")
if event.Outcome != audit.OutcomeSuccess || event.Category != audit.CategoryMedia || event.Source != audit.SourceWeb || event.Actor.Name != "Marta Alves" || *event.Actor.ID != variantOwnerId {
t.Errorf("event = %+v", event)
}
if got := marshalled(t, event.Params); got != `{"fit":"cover","format":"webp","height":630,"quality":82,"width":1200}` {
t.Errorf("params = %s", got)
}
})
t.Run("Creating a width-only png variant records no quality and no height", func(t *testing.T) {
env := newVariantEnv(t)
env.send(http.MethodPost, "/v1/image-variants", `{"name": "logo", "width": 100, "format": "png"}`, variantOwnerId)
event := onlyEventOf(t, env, "media.variant_create")
if got := marshalled(t, event.Params); got != `{"fit":"cover","format":"png","width":100}` {
t.Errorf("params = %s", got)
}
})
t.Run("Updating a variant is recorded with the diff and the purge count", func(t *testing.T) {
env := ownerHeroEnv(t)
env.send(http.MethodPut, "/v1/image-variants/hero", `{"width": 1200, "height": 630, "fit": "cover", "format": "webp", "quality": 70}`, variantOwnerId)
event := onlyEventOf(t, env, "media.variant_update")
assertVariantTarget(t, event, "hero")
if got := marshalled(t, event.Changes); got != `[{"field":"quality","kind":"text","before":"82","after":"70"}]` {
t.Errorf("changes = %s", got)
}
if got := marshalled(t, event.Params); got != `{"purged_files":244}` {
t.Errorf("params = %s", got)
}
})
t.Run("A removed dimension is recorded as auto", func(t *testing.T) {
env := ownerHeroEnv(t)
env.send(http.MethodPut, "/v1/image-variants/hero", `{"width": 1200, "fit": "cover", "format": "webp", "quality": 82}`, variantOwnerId)
event := onlyEventOf(t, env, "media.variant_update")
if got := marshalled(t, event.Changes); got != `[{"field":"height","kind":"text","before":"630","after":"auto"}]` {
t.Errorf("changes = %s", got)
}
})
t.Run("A switch-only update does not claim a purge", func(t *testing.T) {
env := ownerHeroEnv(t)
env.send(http.MethodPut, "/v1/image-variants/hero", `{"width": 1200, "height": 630, "fit": "cover", "format": "webp", "quality": 82, "generate_on_upload": true}`, variantOwnerId)
if env.events() != 0 {
t.Errorf("events = %d", env.events())
}
})
t.Run("Disabling through a PUT is recorded as a disable and not as an update", func(t *testing.T) {
env := ownerHeroEnv(t)
env.send(http.MethodPut, "/v1/image-variants/hero", `{"width": 1200, "height": 630, "fit": "cover", "format": "webp", "quality": 82, "enabled": false}`, variantOwnerId)
assertVariantTarget(t, onlyEventOf(t, env, "media.variant_disable"), "hero")
})
t.Run("Deleting a variant is recorded with the removed files", func(t *testing.T) {
env := ownerHeroEnv(t)
env.send(http.MethodDelete, "/v1/image-variants/hero", "", variantOwnerId)
event := onlyEventOf(t, env, "media.variant_delete")
assertVariantTarget(t, event, "hero")
if got := marshalled(t, event.Params); got != `{"purged_bytes":83936000,"purged_files":244}` {
t.Errorf("params = %s", got)
}
})
for _, row := range []struct {
before, after bool
action string
}{{false, true, "media.variant_enable"}, {true, false, "media.variant_disable"}} {
t.Run("Enabling and disabling are recorded: "+row.action, func(t *testing.T) {
env := ownerHeroEnv(t)
hero := env.variants.stored("hero")
hero.Enabled = row.before
env.variants.variants["hero"] = hero
payload := `{"enabled": false}`
if row.after {
payload = `{"enabled": true}`
}
env.send(http.MethodPatch, "/v1/image-variants/hero", payload, variantOwnerId)
assertVariantTarget(t, onlyEventOf(t, env, row.action), "hero")
})
}
t.Run("A PATCH to the current value records nothing", func(t *testing.T) {
env := ownerHeroEnv(t)
recorder := env.send(http.MethodPatch, "/v1/image-variants/hero", `{"enabled": true}`, variantOwnerId)
if recorder.Code != 200 || env.events() != 0 {
t.Errorf("status = %d, events = %d", recorder.Code, env.events())
}
})
t.Run("A refused create is recorded as failed", func(t *testing.T) {
env := newVariantEnv(t)
env.seedThumb()
env.send(http.MethodPost, "/v1/image-variants", `{"name": "thumb", "width": 10}`, variantOwnerId)
event := onlyEventOf(t, env, "media.variant_create")
assertVariantTarget(t, event, "thumb")
if event.Outcome != audit.OutcomeFailed || event.FailureReason == nil || *event.FailureReason != "A variant with that name already exists." {
t.Errorf("event = %+v", event)
}
})
nothing := []struct{ name, method, path, body string }{
{"validation failure", http.MethodPost, "/v1/image-variants", `{"name": "hero", "width": 9000}`},
{"unknown PUT", http.MethodPut, "/v1/image-variants/missing", `{"width": 100}`},
{"unknown DELETE", http.MethodDelete, "/v1/image-variants/missing", ""},
{"unknown PATCH", http.MethodPatch, "/v1/image-variants/missing", `{"enabled": true}`},
{"unknown purge", http.MethodPost, "/v1/image-variants/missing/purge", ""},
{"list", http.MethodGet, "/v1/image-variants", ""},
}
for _, row := range nothing {
t.Run("Requests that fail validation or find nothing record nothing: "+row.name, func(t *testing.T) {
env := ownerHeroEnv(t)
env.send(row.method, row.path, row.body, variantOwnerId)
if env.events() != 0 {
t.Errorf("events = %d", env.events())
}
})
}
t.Run("A public GET that generates a file records nothing", func(t *testing.T) {
env := servingEnv(t)
env.get(thumbURL, nil)
if env.events() != 0 {
t.Errorf("events = %d", env.events())
}
})
t.Run("Purging a variant's cache is recorded", func(t *testing.T) {
env := ownerHeroEnv(t)
env.send(http.MethodPost, "/v1/image-variants/hero/purge", "", variantOwnerId)
event := onlyEventOf(t, env, "media.variant_purge")
assertVariantTarget(t, event, "hero")
if got := marshalled(t, event.Params); event.Outcome != audit.OutcomeSuccess || got != `{"purged_bytes":83936000,"purged_files":244}` {
t.Errorf("params = %s", got)
}
})
t.Run("Purging an empty cache records nothing", func(t *testing.T) {
env := newVariantEnv(t)
seedVariant(env.variants, "hero", 100, 0, domain.FitCover, domain.FormatJPEG, 80)
recorder := env.send(http.MethodPost, "/v1/image-variants/hero/purge", "", variantOwnerId)
if recorder.Code != 200 || env.events() != 0 {
t.Errorf("status = %d, events = %d", recorder.Code, env.events())
}
})
t.Run("A purge that fails on storage is recorded as failed", 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.send(http.MethodPost, "/v1/image-variants/hero/purge", "", variantOwnerId)
event := onlyEventOf(t, env, "media.variant_purge")
if recorder.Code != 500 || event.Outcome != audit.OutcomeFailed || event.FailureReason == nil || *event.FailureReason != "Something went wrong on our side. Please try again." {
t.Errorf("status = %d, event = %+v", recorder.Code, event)
}
})
t.Run("A failing audit write never changes the response", func(t *testing.T) {
env := newVariantEnv(t)
env.audit.probe.appendErr = errors.New("audit store down")
recorder := env.send(http.MethodPost, "/v1/image-variants", `{"name": "hero", "width": 100}`, variantOwnerId)
if recorder.Code != 201 || env.variants.stored("hero").Name != "hero" {
t.Errorf("status = %d", recorder.Code)
}
})
t.Run("The existing allowlist scenario is updated", func(t *testing.T) {
allowed := []string{"title", "online", "body", "filename", "display_name", "width", "height", "fit", "format", "quality"}
emitted := []audit.Change{}
emitted = append(emitted, audit.ArticleChanges(article.Article{Title: "a", Body: "x"}, article.Article{Title: "b", Body: "y", IsOnline: true})...)
before := domain.VariantSettings{Width: intPointer(1), Height: intPointer(2), Fit: domain.FitCover, Format: domain.FormatJPEG, Quality: intPointer(80)}
after := domain.VariantSettings{Width: intPointer(3), Fit: domain.FitInside, Format: domain.FormatPNG}
emitted = append(emitted, audit.VariantChanges(before, after)...)
for _, change := range emitted {
if !slices.Contains(allowed, change.Field) || change.Kind != audit.ChangeText && change.Kind != audit.ChangeBody {
t.Errorf("unexpected change %+v", change)
}
}
if len(emitted) != 8 {
t.Errorf("emitted %d changes", len(emitted))
}
})
}
+102
View File
@@ -0,0 +1,102 @@
package test
import (
"RenewCMS/internal/infrastructure/config"
"os"
"strings"
"testing"
)
const maxSourceEnv = "IMAGE_VARIANT_MAX_SOURCE_BYTES"
func readRepositoryFile(t *testing.T, relative string) string {
t.Helper()
content, err := os.ReadFile("../" + relative)
if err != nil {
t.Fatalf("cannot read %s: %v", relative, err)
}
return string(content)
}
func between(source string, opening string, closing string) string {
start := strings.Index(source, opening)
if start < 0 {
return ""
}
rest := source[start+len(opening):]
end := strings.Index(rest, closing)
if end < 0 {
return rest
}
return rest[:end]
}
func TestImageVariantMaxSourceBytesSetting(t *testing.T) {
rows := []struct {
value string
unset bool
want int64
failure bool
}{
{unset: true, want: 10485760},
{value: "2048", want: 2048},
{value: "1", want: 1},
{value: "0", failure: true},
{value: "-5", failure: true},
{value: "many", failure: true},
{value: "1.5", failure: true},
}
for _, row := range rows {
name := row.value
if row.unset {
name = "unset"
}
t.Run("The env var is loaded: "+name, func(t *testing.T) {
setBaseSettingsEnv(t)
if row.unset {
t.Setenv(maxSourceEnv, "")
} else {
t.Setenv(maxSourceEnv, row.value)
}
settings, err := config.NewSettingsFromEnv()
if row.failure {
if err == nil || !strings.Contains(err.Error(), maxSourceEnv) {
t.Errorf("err = %v", err)
}
return
}
if err != nil || settings.ImageVariantMaxSourceBytes != row.want {
t.Errorf("value = %d, err = %v", settings.ImageVariantMaxSourceBytes, err)
}
})
}
}
func TestImageVariantMaxSourceBytesDocumentation(t *testing.T) {
t.Run("The default is listed in envDefaults and not in requiredEnvVars", func(t *testing.T) {
main := readRepositoryFile(t, "cmd/server/main.go")
defaults := between(main, "var envDefaults = map[string]string{", "\n}")
required := between(main, "var requiredEnvVars = []string{", "\n}")
if !strings.Contains(defaults, "config.EnvImageVariantMaxSourceBytes") || strings.Contains(required, "ImageVariant") || strings.Contains(required, maxSourceEnv) {
t.Error("the default is misplaced")
}
})
t.Run("The constant name and default are the documented ones", func(t *testing.T) {
if config.EnvImageVariantMaxSourceBytes != maxSourceEnv || config.DefaultImageVariantMaxSourceBytes != "10485760" {
t.Errorf("constants = %q %q", config.EnvImageVariantMaxSourceBytes, config.DefaultImageVariantMaxSourceBytes)
}
})
for _, document := range []string{".env.example", "docs/configuration.md"} {
t.Run("The env var is documented in "+document, func(t *testing.T) {
if !strings.Contains(readRepositoryFile(t, document), maxSourceEnv) {
t.Errorf("%s does not mention %s", document, maxSourceEnv)
}
})
}
}
+242
View File
@@ -0,0 +1,242 @@
package test
import (
domain "RenewCMS/internal/domain/image"
"bytes"
"errors"
"strings"
"testing"
"time"
)
func TestVariantNames(t *testing.T) {
rows := []struct {
name string
valid bool
}{
{"ab", true},
{"a-b-c", true},
{"q1", true},
{strings.Repeat("a", 32), true},
{strings.Repeat("a", 33), false},
{"a", false},
{"", false},
{"Hero", false},
{"he ro", false},
{"he_ro", false},
{"hé", false},
}
for _, row := range rows {
t.Run("name "+row.name, func(t *testing.T) {
if got := domain.IsValidVariantName(row.name); got != row.valid {
t.Errorf("valid = %v", got)
}
})
}
}
func TestVariantSettingsNormalization(t *testing.T) {
settings := func(width *int, height *int, quality *int, format domain.VariantFormat) domain.VariantSettings {
return domain.VariantSettings{Width: width, Height: height, Quality: quality, Format: format}
}
valid := []struct {
name string
in domain.VariantSettings
}{
{"width 1", settings(intPointer(1), nil, nil, "")},
{"width 8000", settings(intPointer(8000), nil, nil, "")},
{"height 1", settings(nil, intPointer(1), nil, "")},
{"height 8000", settings(nil, intPointer(8000), nil, "")},
{"quality 1", settings(intPointer(5), nil, intPointer(1), "")},
{"quality 100", settings(intPointer(5), nil, intPointer(100), "")},
}
for _, row := range valid {
t.Run("accepts "+row.name, func(t *testing.T) {
if _, err := row.in.Normalized(); err != nil {
t.Errorf("err = %v", err)
}
})
}
invalid := []struct {
name string
in domain.VariantSettings
}{
{"no dimension", settings(nil, nil, nil, "")},
{"width 0", settings(intPointer(0), nil, nil, "")},
{"width 8001", settings(intPointer(8001), nil, nil, "")},
{"height 0", settings(nil, intPointer(0), nil, "")},
{"height 8001", settings(nil, intPointer(8001), nil, "")},
{"height negative", settings(intPointer(10), intPointer(-1), nil, "")},
{"quality 0", settings(intPointer(5), nil, intPointer(0), "")},
{"quality 101", settings(intPointer(5), nil, intPointer(101), "")},
{"unknown format", settings(intPointer(5), nil, nil, "avif")},
{"unknown fit", domain.VariantSettings{Width: intPointer(5), Fit: "stretch"}},
}
for _, row := range invalid {
t.Run("refuses "+row.name, func(t *testing.T) {
if _, err := row.in.Normalized(); !errors.Is(err, domain.ErrVariantInvalid) {
t.Errorf("err = %v", err)
}
})
}
t.Run("Defaults are cover, original and quality 82", func(t *testing.T) {
normalized, err := settings(intPointer(400), nil, nil, "").Normalized()
if err != nil || normalized.Fit != domain.FitCover || normalized.Format != domain.FormatOriginal || *normalized.Quality != 82 {
t.Errorf("normalized = %+v, err = %v", normalized, err)
}
})
t.Run("A png variant stores no quality", func(t *testing.T) {
normalized, _ := settings(intPointer(400), nil, intPointer(30), domain.FormatPNG).Normalized()
if normalized.Quality != nil {
t.Errorf("quality = %v", *normalized.Quality)
}
})
t.Run("Normalizing does not alter the input", func(t *testing.T) {
quality := 40
input := settings(intPointer(400), nil, &quality, domain.FormatPNG)
_, _ = input.Normalized()
if input.Quality == nil || *input.Quality != 40 {
t.Error("the input quality was cleared")
}
})
}
func TestVariantFingerprint(t *testing.T) {
base := domain.VariantSettings{Width: intPointer(400), Height: intPointer(300), Fit: domain.FitCover, Format: domain.FormatJPEG, Quality: intPointer(80)}
variations := map[string]domain.VariantSettings{
"width": {Width: intPointer(401), Height: intPointer(300), Fit: domain.FitCover, Format: domain.FormatJPEG, Quality: intPointer(80)},
"height": {Width: intPointer(400), Height: nil, Fit: domain.FitCover, Format: domain.FormatJPEG, Quality: intPointer(80)},
"fit": {Width: intPointer(400), Height: intPointer(300), Fit: domain.FitInside, Format: domain.FormatJPEG, Quality: intPointer(80)},
"format": {Width: intPointer(400), Height: intPointer(300), Fit: domain.FitCover, Format: domain.FormatWebP, Quality: intPointer(80)},
"quality": {Width: intPointer(400), Height: intPointer(300), Fit: domain.FitCover, Format: domain.FormatJPEG, Quality: intPointer(79)},
}
t.Run("Equal settings share a fingerprint", func(t *testing.T) {
same := domain.VariantSettings{Width: intPointer(400), Height: intPointer(300), Fit: domain.FitCover, Format: domain.FormatJPEG, Quality: intPointer(80)}
if !base.IsSameAs(same) {
t.Error("fingerprints differ")
}
})
for field, changed := range variations {
t.Run("A different "+field+" changes the fingerprint", func(t *testing.T) {
if base.IsSameAs(changed) {
t.Error("fingerprints match")
}
})
}
t.Run("An absent dimension differs from a present one", func(t *testing.T) {
widthOnly := domain.VariantSettings{Width: intPointer(300)}
heightOnly := domain.VariantSettings{Height: intPointer(300)}
if widthOnly.IsSameAs(heightOnly) {
t.Error("fingerprints match")
}
})
}
func TestVariantConstruction(t *testing.T) {
now := time.Date(2026, 10, 8, 9, 0, 0, 0, time.UTC)
t.Run("A new variant carries the normalized settings and timestamps", func(t *testing.T) {
variant, err := domain.NewVariant("hero", domain.VariantSettings{Width: intPointer(100), Format: domain.FormatPNG, Quality: intPointer(5)}, true, false, now)
if err != nil || variant.Quality != nil || variant.Fit != domain.FitCover || !variant.GenerateOnUpload || variant.Enabled || !variant.CreatedAt.Equal(now) || !variant.UpdatedAt.Equal(now) {
t.Errorf("variant = %+v, err = %v", variant, err)
}
})
t.Run("A new variant refuses an invalid name", func(t *testing.T) {
_, err := domain.NewVariant("A", domain.VariantSettings{Width: intPointer(100)}, false, true, now)
if !errors.Is(err, domain.ErrVariantInvalid) {
t.Errorf("err = %v", err)
}
})
}
func TestVariantETagAndRequestErrors(t *testing.T) {
settings := domain.VariantSettings{Width: intPointer(400), Fit: domain.FitCover, Format: domain.FormatJPEG, Quality: intPointer(80)}
t.Run("The ETag is quoted and depends on the source", func(t *testing.T) {
first, second := domain.VariantETag(settings, "a.png"), domain.VariantETag(settings, "b.png")
if !strings.HasPrefix(first, `"`) || !strings.HasSuffix(first, `"`) || first == second || first != domain.VariantETag(settings, "a.png") {
t.Errorf("etags = %q %q", first, second)
}
})
matches := []struct {
header string
want bool
}{
{`"abc"`, true},
{`W/"abc"`, true},
{`"x", "abc"`, true},
{`*`, true},
{`"abd"`, false},
{``, false},
}
for _, row := range matches {
t.Run("If-None-Match "+row.header, func(t *testing.T) {
if got := domain.ETagMatches(row.header, `"abc"`); got != row.want {
t.Errorf("matches = %v", got)
}
})
}
t.Run("Request errors unwrap to their sentinel and carry the detail", func(t *testing.T) {
unknown := domain.NewUnknownVariantError("banner")
unsupported := domain.NewUnsupportedParameterError("width")
if !errors.Is(unknown, domain.ErrUnknownVariant) || errors.Is(unknown, domain.ErrUnsupportedParameter) || domain.VariantRequestDetail(unknown) != "banner" {
t.Errorf("unknown = %v", unknown)
}
if !errors.Is(unsupported, domain.ErrUnsupportedParameter) || domain.VariantRequestDetail(unsupported) != "width" || domain.VariantRequestDetail(errors.New("other")) != "" {
t.Errorf("unsupported = %v", unsupported)
}
})
}
func TestAnimatedGIFDetection(t *testing.T) {
t.Run("A two-frame GIF is animated", func(t *testing.T) {
animated, err := domain.IsAnimatedGIF(bytes.NewReader(animatedGIF(t)))
if err != nil || !animated {
t.Errorf("animated = %v, err = %v", animated, err)
}
})
t.Run("A one-frame GIF is not animated", func(t *testing.T) {
animated, err := domain.IsAnimatedGIF(bytes.NewReader(stillGIF(t)))
if err != nil || animated {
t.Errorf("animated = %v, err = %v", animated, err)
}
})
t.Run("Bytes that are not a GIF are refused", func(t *testing.T) {
_, err := domain.IsAnimatedGIF(bytes.NewReader(solidPNG(t, 4, 4)))
if !errors.Is(err, domain.ErrNotAGif) {
t.Errorf("err = %v", err)
}
})
t.Run("A truncated GIF reports an error", func(t *testing.T) {
truncated := animatedGIF(t)[:20]
if _, err := domain.IsAnimatedGIF(bytes.NewReader(truncated)); err == nil {
t.Error("expected an error")
}
})
}
+204
View File
@@ -0,0 +1,204 @@
package test
import (
"RenewCMS/api"
"RenewCMS/api/handlers/staticimages"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/apikey"
domain "RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/config"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/go-chi/chi/v5"
chimiddleware "github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/jwtauth/v5"
)
const (
variantOwnerId uint32 = 1
variantAdminId uint32 = 2
variantPlainUserId uint32 = 3
variantUnverifiedId uint32 = 4
variantStaleId uint32 = 5
variantDefaultCap int64 = 10485760
variantDefaultPixels int64 = 50000000
variantDefaultRenders = 4
variantSourceName = "a1b2.png"
)
type variantEnv struct {
t *testing.T
users *mockUserRepo
images *mockImageRepo
variants *mockVariantRepo
files *mockVariantFileRepo
storage *mockVariantStorage
renderer *countingRenderer
audit *activityHarness
token *jwtauth.JWTAuth
now time.Time
admin http.Handler
public http.Handler
}
func newVariantEnv(t *testing.T) *variantEnv {
t.Helper()
return newVariantEnvWith(t, newCountingRenderer(), variantDefaultCap)
}
func newVariantEnvWith(t *testing.T, renderer *countingRenderer, maxSourceBytes int64) *variantEnv {
t.Helper()
return newVariantEnvLimits(t, renderer, useCases.ServeVariantLimits{MaxSourceBytes: maxSourceBytes, MaxSourcePixels: variantDefaultPixels, MaxConcurrentRenders: variantDefaultRenders})
}
func newVariantEnvLimits(t *testing.T, renderer *countingRenderer, limits useCases.ServeVariantLimits) *variantEnv {
t.Helper()
validator.InitValidator()
env := &variantEnv{
t: t,
users: newMockUserRepo(),
images: newMockImageRepo(),
variants: newMockVariantRepo(),
files: newMockVariantFileRepo(),
storage: newMockVariantStorage(),
renderer: renderer,
audit: newActivityHarness(t),
token: jwtauth.New("HS256", []byte("test-secret"), nil),
now: time.Date(2026, time.October, 8, 9, 0, 0, 0, time.UTC),
}
env.users.users[variantOwnerId] = user.User{ID: variantOwnerId, Username: "marta", DisplayName: "Marta Alves", IsVerified: true, Role: user.RoleOwner}
env.users.users[variantAdminId] = user.User{ID: variantAdminId, Username: "dev", IsVerified: true, Role: user.RoleAdmin}
env.users.users[variantPlainUserId] = user.User{ID: variantPlainUserId, Username: "pat", IsVerified: true}
env.users.users[variantUnverifiedId] = user.User{ID: variantUnverifiedId, Username: "quinn", IsVerified: false, Role: user.RoleAdmin}
env.users.users[variantStaleId] = user.User{ID: variantStaleId, Username: "stale", IsVerified: true, Role: user.RoleAdmin, TokenVersion: 1}
container := env.container(limits)
admin := chi.NewRouter()
admin.Mount("/v1", api.InitBackendRoutes(container, config.Settings{}))
env.admin = admin
public := chi.NewRouter()
public.Use(chimiddleware.GetHead)
public.Mount("/static/uploadedImages", staticimages.NewHandler(container.OpenMediaUseCase, container.ServeVariantUseCase))
env.public = public
return env
}
func (e *variantEnv) container(limits useCases.ServeVariantLimits) *api.UseCases {
clock := func() time.Time { return e.now }
recorder := e.audit.recorder
return &api.UseCases{
Token: e.token,
GetUserUseCase: useCases.NewGetUserUseCase(e.users),
OpenMediaUseCase: useCases.NewOpenMediaUseCase(e.storage),
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(e.images, e.storage, e.files, recorder),
MergeMediaUseCase: useCases.NewMergeMediaUseCase(e.images, newMockArticleRepo(), e.storage, e.files, recorder),
ListVariantsUseCase: useCases.NewListVariantsUseCase(e.variants, e.files),
CreateVariantUseCase: useCases.NewCreateVariantUseCase(e.variants, e.renderer, clock, recorder),
UpdateVariantUseCase: useCases.NewUpdateVariantUseCase(e.variants, e.files, e.storage, e.renderer, clock, recorder),
SetVariantEnabledUseCase: useCases.NewSetVariantEnabledUseCase(e.variants, e.files, clock, recorder),
DeleteVariantUseCase: useCases.NewDeleteVariantUseCase(e.variants, e.files, e.storage, recorder),
PurgeVariantUseCase: useCases.NewPurgeVariantUseCase(e.variants, e.files, e.storage, recorder),
ServeVariantUseCase: useCases.NewServeVariantUseCase(e.variants, e.files, e.images, e.storage, e.renderer, limits),
}
}
func (e *variantEnv) cookie(userId uint32, tokenVersion uint32) *http.Cookie {
e.t.Helper()
_, signed, err := e.token.Encode(map[string]any{"user_id": userId, "token_version": tokenVersion})
if err != nil {
e.t.Fatalf("failed to encode jwt: %v", err)
}
return &http.Cookie{Name: "jwt", Value: signed}
}
func (e *variantEnv) send(method string, path string, body string, userId uint32) *httptest.ResponseRecorder {
e.t.Helper()
request := httptest.NewRequest(method, path, strings.NewReader(body))
if body != "" {
request.Header.Set("Content-Type", "application/json")
}
if userId != 0 {
request.AddCookie(e.cookie(userId, 0))
}
recorder := httptest.NewRecorder()
e.admin.ServeHTTP(recorder, request)
return recorder
}
func (e *variantEnv) sendAsAdmin(method string, path string, body string) *httptest.ResponseRecorder {
e.t.Helper()
return e.send(method, path, body, variantAdminId)
}
func (e *variantEnv) get(path string, header http.Header) *httptest.ResponseRecorder {
e.t.Helper()
request := httptest.NewRequest(http.MethodGet, path, nil)
for name, values := range header {
request.Header[name] = values
}
recorder := httptest.NewRecorder()
e.public.ServeHTTP(recorder, request)
return recorder
}
func (e *variantEnv) seedSource(storedName string, content []byte) {
e.storage.seed(storedName, content)
if known, _ := e.images.ExistsByStoredName(storedName); !known {
e.seedImage(e.images.nextID+1, storedName)
}
}
func (e *variantEnv) seedImage(id uint32, storedName string) domain.Image {
image := domain.Image{ID: id, Path: domain.PublicPath(storedName), Filename: storedName, ContentType: "image/png", SHA256: "same"}
e.images.images[id] = image
if id > e.images.nextID {
e.images.nextID = id
}
return image
}
func (e *variantEnv) seedThumb() domain.Variant {
return seedVariant(e.variants, "thumb", 400, 300, domain.FitCover, domain.FormatJPEG, 80)
}
func (e *variantEnv) seedPhoto() {
e.seedSource(variantSourceName, encodePNG(e.t, variantSolidImage(1000, 800, variantRed)))
}
func (e *variantEnv) events() int {
e.t.Helper()
return len(e.audit.all(e.t))
}
func (e *variantEnv) lastEvent() (string, map[string]any) {
e.t.Helper()
events := e.audit.all(e.t)
if len(events) == 0 {
e.t.Fatal("no event recorded")
}
encoded, _ := json.Marshal(events[0])
var decoded map[string]any
_ = json.Unmarshal(encoded, &decoded)
return events[0].Action, decoded
}
func decodeBody(t *testing.T, recorder *httptest.ResponseRecorder) map[string]any {
t.Helper()
var decoded map[string]any
if err := json.Unmarshal(recorder.Body.Bytes(), &decoded); err != nil {
t.Fatalf("body is not a json object: %q", recorder.Body.String())
}
return decoded
}
func bearerKeyHeader() http.Header {
return http.Header{"Authorization": []string{"Bearer " + apikey.RawKeyPrefix + "validkey"}}
}
+323
View File
@@ -0,0 +1,323 @@
package test
import (
domain "RenewCMS/internal/domain/image"
"RenewCMS/internal/infrastructure/imaging"
"bytes"
"image/color"
"strconv"
"testing"
)
func variantSettings(width int, height int, fit domain.VariantFit, format domain.VariantFormat) domain.VariantSettings {
settings := domain.VariantSettings{Fit: fit, Format: format}
if width > 0 {
settings.Width = &width
}
if height > 0 {
settings.Height = &height
}
return settings
}
func renderVariant(t *testing.T, source []byte, settings domain.VariantSettings) domain.RenderedVariant {
t.Helper()
rendered, err := imaging.NewVariantRenderer().Render(bytes.NewReader(source), settings)
if err != nil {
t.Fatalf("render failed: %v", err)
}
return rendered
}
func TestVariantOutputGeometry(t *testing.T) {
rows := []struct {
sourceWidth, sourceHeight, width, height int
fit domain.VariantFit
wantWidth, wantHeight int
}{
{4032, 3024, 400, 300, domain.FitCover, 400, 300},
{4032, 3024, 800, 0, domain.FitCover, 800, 600},
{4032, 3024, 0, 600, domain.FitContain, 800, 600},
{4032, 3024, 1000, 1000, domain.FitInside, 1000, 750},
{4032, 3024, 1000, 1000, domain.FitCover, 1000, 1000},
{4032, 3024, 1000, 1000, domain.FitContain, 1000, 1000},
{300, 200, 800, 0, domain.FitCover, 300, 200},
{300, 200, 0, 800, domain.FitInside, 300, 200},
{300, 200, 400, 300, domain.FitCover, 267, 200},
{300, 200, 400, 300, domain.FitContain, 400, 300},
{300, 200, 1200, 1200, domain.FitContain, 1200, 1200},
{300, 200, 1200, 1200, domain.FitInside, 300, 200},
}
for _, row := range rows {
name := strconv.Itoa(row.sourceWidth) + "x" + strconv.Itoa(row.sourceHeight) + " to " + strconv.Itoa(row.width) + "x" + strconv.Itoa(row.height) + " " + string(row.fit)
t.Run(name, func(t *testing.T) {
source := solidPNG(t, row.sourceWidth, row.sourceHeight)
rendered := renderVariant(t, source, variantSettings(row.width, row.height, row.fit, domain.FormatPNG))
width, height := decodedSize(t, rendered.Content)
if width != row.wantWidth || height != row.wantHeight {
t.Errorf("output = %dx%d, want %dx%d", width, height, row.wantWidth, row.wantHeight)
}
})
}
}
func TestVariantFitPixels(t *testing.T) {
t.Run("Cover crops around the centre", func(t *testing.T) {
rendered := renderVariant(t, encodePNG(t, stripedImage(t)), variantSettings(20, 20, domain.FitCover, domain.FormatPNG))
decoded, _ := decodeImage(t, rendered.Content)
if decoded.Bounds().Dx() != 20 || decoded.Bounds().Dy() != 20 {
t.Fatalf("output = %v", decoded.Bounds())
}
for y := 0; y < 20; y++ {
for x := 0; x < 20; x++ {
if got := pixelAt(decoded, x, y); got != variantGreen {
t.Fatalf("pixel %d,%d = %v, want green", x, y, got)
}
}
}
})
t.Run("Contain pads transparent on png", func(t *testing.T) {
source := encodePNG(t, variantSolidImage(40, 20, variantRed))
rendered := renderVariant(t, source, variantSettings(40, 40, domain.FitContain, domain.FormatPNG))
decoded, _ := decodeImage(t, rendered.Content)
if decoded.Bounds().Dx() != 40 || decoded.Bounds().Dy() != 40 {
t.Fatalf("output = %v", decoded.Bounds())
}
for x := 0; x < 40; x++ {
for _, y := range []int{0, 9, 30, 39} {
if got := pixelAt(decoded, x, y); got.A != 0 {
t.Fatalf("pixel %d,%d = %v, want transparent", x, y, got)
}
}
for _, y := range []int{10, 19, 29} {
if got := pixelAt(decoded, x, y); got != variantRed {
t.Fatalf("pixel %d,%d = %v, want red", x, y, got)
}
}
}
})
t.Run("Contain never enlarges a small source and centres it on the box", func(t *testing.T) {
source := encodePNG(t, variantSolidImage(30, 20, variantRed))
rendered := renderVariant(t, source, variantSettings(40, 30, domain.FitContain, domain.FormatPNG))
decoded, _ := decodeImage(t, rendered.Content)
if decoded.Bounds().Dx() != 40 || decoded.Bounds().Dy() != 30 {
t.Fatalf("output = %v", decoded.Bounds())
}
for _, inside := range [][2]int{{5, 5}, {34, 24}, {20, 15}} {
if got := pixelAt(decoded, inside[0], inside[1]); got != variantRed {
t.Errorf("pixel %v = %v, want red", inside, got)
}
}
for _, outside := range [][2]int{{4, 15}, {35, 15}, {20, 4}, {20, 25}} {
if got := pixelAt(decoded, outside[0], outside[1]); got.A != 0 {
t.Errorf("pixel %v = %v, want transparent", outside, got)
}
}
})
t.Run("Contain with a single dimension adds no padding", func(t *testing.T) {
source := encodePNG(t, variantSolidImage(40, 20, variantRed))
rendered := renderVariant(t, source, variantSettings(0, 10, domain.FitContain, domain.FormatPNG))
decoded, _ := decodeImage(t, rendered.Content)
if decoded.Bounds().Dx() != 20 || decoded.Bounds().Dy() != 10 {
t.Fatalf("output = %v", decoded.Bounds())
}
for y := 0; y < 10; y++ {
if got := pixelAt(decoded, 0, y); got.A == 0 {
t.Fatalf("pixel 0,%d is transparent", y)
}
}
})
t.Run("Contain pads transparent on webp", func(t *testing.T) {
source := encodePNG(t, variantSolidImage(40, 20, variantRed))
rendered := renderVariant(t, source, variantSettings(40, 40, domain.FitContain, domain.FormatWebP))
decoded, _ := decodeImage(t, rendered.Content)
if got := pixelAt(decoded, 20, 2); got.A != 0 {
t.Errorf("padded pixel = %v, want transparent", got)
}
})
t.Run("Contain pads white on jpeg", func(t *testing.T) {
source := encodePNG(t, variantSolidImage(40, 20, variantRed))
rendered := renderVariant(t, source, variantSettings(40, 40, domain.FitContain, domain.FormatJPEG))
decoded, _ := decodeImage(t, rendered.Content)
for _, y := range []int{0, 5, 9, 30, 35, 39} {
assertNearColor(t, pixelAt(decoded, 20, y), color.NRGBA{R: 255, G: 255, B: 255, A: 255})
}
})
t.Run("Inside adds no padding", func(t *testing.T) {
source := encodePNG(t, variantSolidImage(40, 20, variantRed))
rendered := renderVariant(t, source, variantSettings(40, 40, domain.FitInside, domain.FormatPNG))
decoded, _ := decodeImage(t, rendered.Content)
if decoded.Bounds().Dx() != 40 || decoded.Bounds().Dy() != 20 {
t.Fatalf("output = %v", decoded.Bounds())
}
for y := 0; y < 20; y++ {
if got := pixelAt(decoded, 3, y); got.A != 255 {
t.Fatalf("pixel 3,%d = %v, want opaque", y, got)
}
}
})
t.Run("A transparent png source flattened to jpeg becomes white", func(t *testing.T) {
source := encodePNG(t, variantSolidImage(10, 10, color.NRGBA{}))
rendered := renderVariant(t, source, variantSettings(10, 10, domain.FitCover, domain.FormatJPEG))
decoded, _ := decodeImage(t, rendered.Content)
for y := 0; y < 10; y++ {
for x := 0; x < 10; x++ {
assertNearColor(t, pixelAt(decoded, x, y), color.NRGBA{R: 255, G: 255, B: 255, A: 255})
}
}
})
}
func assertNearColor(t *testing.T, got color.NRGBA, want color.NRGBA) {
t.Helper()
const tolerance = 8
for _, pair := range [][2]uint8{{got.R, want.R}, {got.G, want.G}, {got.B, want.B}} {
difference := int(pair[0]) - int(pair[1])
if difference > tolerance || difference < -tolerance {
t.Fatalf("color = %v, want near %v", got, want)
}
}
}
func TestVariantOutputFormat(t *testing.T) {
photo := encodePNG(t, noisyImage(64, 64))
rows := []struct {
name string
source []byte
format domain.VariantFormat
contentType string
signature string
}{
{"png original", photo, domain.FormatOriginal, "image/png", "\x89PNG"},
{"jpeg original", encodeJPEG(t, noisyImage(64, 64)), domain.FormatOriginal, "image/jpeg", "\xff\xd8"},
{"png to jpeg", photo, domain.FormatJPEG, "image/jpeg", "\xff\xd8"},
{"jpeg to png", encodeJPEG(t, noisyImage(64, 64)), domain.FormatPNG, "image/png", "\x89PNG"},
{"png to webp", photo, domain.FormatWebP, "image/webp", "RIFF"},
}
for _, row := range rows {
t.Run(row.name, func(t *testing.T) {
rendered := renderVariant(t, row.source, variantSettings(32, 0, domain.FitCover, row.format))
if rendered.ContentType != row.contentType {
t.Errorf("content type = %q, want %q", rendered.ContentType, row.contentType)
}
if !bytes.HasPrefix(rendered.Content, []byte(row.signature)) {
t.Errorf("signature = %q", rendered.Content[:4])
}
})
}
t.Run("A webp output carries the WEBP marker and the right dimensions", func(t *testing.T) {
rendered := renderVariant(t, photo, variantSettings(32, 0, domain.FitCover, domain.FormatWebP))
width, height := webpSize(t, rendered.Content)
if string(rendered.Content[8:12]) != "WEBP" || width != 32 || height != 32 {
t.Errorf("webp = %q %dx%d", rendered.Content[8:12], width, height)
}
})
}
func TestVariantQuality(t *testing.T) {
photo := encodePNG(t, noisyImage(300, 300))
t.Run("Quality changes the size of a lossy output", func(t *testing.T) {
for _, format := range []domain.VariantFormat{domain.FormatJPEG, domain.FormatWebP} {
low, high := 20, 95
lowSettings := variantSettings(300, 0, domain.FitCover, format)
highSettings := lowSettings
lowSettings.Quality, highSettings.Quality = &low, &high
lowSize := len(renderVariant(t, photo, lowSettings).Content)
highSize := len(renderVariant(t, photo, highSettings).Content)
if lowSize >= highSize {
t.Errorf("%s: quality 20 gave %d bytes, quality 95 gave %d", format, lowSize, highSize)
}
}
})
t.Run("A png output ignores quality", func(t *testing.T) {
low, high := 30, 95
settings := variantSettings(100, 0, domain.FitCover, domain.FormatPNG)
first, second := settings, settings
first.Quality, second.Quality = &low, &high
if !bytes.Equal(renderVariant(t, photo, first).Content, renderVariant(t, photo, second).Content) {
t.Error("png outputs differ")
}
})
}
func TestVariantSourceHandling(t *testing.T) {
t.Run("A webp source still decodes and renders to jpeg", func(t *testing.T) {
webpSource := renderVariant(t, solidPNG(t, 40, 40), variantSettings(40, 0, domain.FitCover, domain.FormatWebP)).Content
rendered := renderVariant(t, webpSource, variantSettings(20, 0, domain.FitCover, domain.FormatJPEG))
if rendered.ContentType != "image/jpeg" {
t.Errorf("content type = %q", rendered.ContentType)
}
})
t.Run("A webp source with format original stays webp when an encoder exists", func(t *testing.T) {
webpSource := renderVariant(t, solidPNG(t, 40, 40), variantSettings(40, 0, domain.FitCover, domain.FormatWebP)).Content
rendered := renderVariant(t, webpSource, variantSettings(20, 0, domain.FitCover, domain.FormatOriginal))
if rendered.ContentType != "image/webp" {
t.Errorf("content type = %q", rendered.ContentType)
}
})
t.Run("Without a webp encoder a webp source with format original falls back to png", func(t *testing.T) {
webpSource := renderVariant(t, solidPNG(t, 40, 40), variantSettings(40, 0, domain.FitCover, domain.FormatWebP)).Content
renderer := imaging.NewVariantRendererEncoding(domain.FormatOriginal, domain.FormatJPEG, domain.FormatPNG)
rendered, err := renderer.Render(bytes.NewReader(webpSource), variantSettings(20, 0, domain.FitCover, domain.FormatOriginal))
if err != nil || rendered.ContentType != "image/png" {
t.Errorf("rendered = %q, err = %v", rendered.ContentType, err)
}
})
t.Run("The renderer reports the formats it can encode", func(t *testing.T) {
all := imaging.NewVariantRenderer().EncodableFormats()
without := imaging.NewVariantRendererEncoding(domain.FormatOriginal, domain.FormatJPEG, domain.FormatPNG).EncodableFormats()
if len(all) != 4 || len(without) != 3 {
t.Errorf("formats = %v and %v", all, without)
}
})
t.Run("A one-frame gif renders like any raster source", func(t *testing.T) {
rendered := renderVariant(t, stillGIF(t), variantSettings(4, 0, domain.FitCover, domain.FormatPNG))
if rendered.ContentType != "image/png" {
t.Errorf("content type = %q", rendered.ContentType)
}
})
t.Run("Bytes that are not an image fail to render", func(t *testing.T) {
_, err := imaging.NewVariantRenderer().Render(bytes.NewReader([]byte("not an image")), variantSettings(4, 0, domain.FitCover, domain.FormatPNG))
if err == nil {
t.Error("expected an error")
}
})
}
+189
View File
@@ -0,0 +1,189 @@
package test
import (
"bytes"
"go/parser"
"go/token"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
var variantUseCaseFiles = []string{
"ListVariants.go", "CreateVariant.go", "UpdateVariant.go", "SetVariantEnabled.go",
"DeleteVariant.go", "PurgeVariant.go", "ServeVariant.go", "variantCache.go",
"variantSettings.go", "variantSummary.go", "mediaRemover.go", "DeleteMedia.go", "MergeMedia.go",
}
var newVariantGoFiles = []string{
"internal/domain/image/variantDomain.go",
"internal/domain/image/variantRepositoryInterface.go",
"internal/domain/image/variantRequest.go",
"internal/domain/image/animatedGif.go",
"internal/infrastructure/imaging/variantEncoders.go",
"internal/infrastructure/imaging/variantGeometry.go",
"internal/infrastructure/imaging/variantRenderer.go",
"internal/infrastructure/config/imageVariantSettings.go",
"internal/infrastructure/persistence/models/imageVariantModel.go",
"internal/infrastructure/persistence/models/imageVariantFileModel.go",
"internal/infrastructure/persistence/mappers/imageVariantMapper.go",
"internal/infrastructure/persistence/repositories/imageVariantRepository.go",
"internal/infrastructure/persistence/repositories/imageVariantFileRepository.go",
"api/handlers/imagevariant/imageVariantHandler.go",
"api/handlers/staticimages/staticImagesHandler.go",
}
func importsOf(t *testing.T, relative string) []string {
t.Helper()
parsed, err := parser.ParseFile(token.NewFileSet(), "../"+relative, nil, parser.ImportsOnly)
if err != nil {
t.Fatalf("cannot parse %s: %v", relative, err)
}
imports := []string{}
for _, spec := range parsed.Imports {
imports = append(imports, strings.Trim(spec.Path.Value, `"`))
}
return imports
}
func isStandardLibrary(path string) bool {
return !strings.Contains(strings.Split(path, "/")[0], ".")
}
func TestImageVariantLayering(t *testing.T) {
t.Run("internal/domain/image imports only the stdlib and other domain packages", func(t *testing.T) {
files, _ := filepath.Glob("../internal/domain/image/*.go")
for _, file := range files {
for _, path := range importsOf(t, strings.TrimPrefix(filepath.ToSlash(file), "../")) {
if !isStandardLibrary(path) && !strings.HasPrefix(path, "RenewCMS/internal/domain/") {
t.Errorf("%s imports %s", file, path)
}
}
}
})
t.Run("internal/domain/audit still imports only the stdlib and domain packages", func(t *testing.T) {
files, _ := filepath.Glob("../internal/domain/audit/*.go")
for _, file := range files {
for _, path := range importsOf(t, strings.TrimPrefix(filepath.ToSlash(file), "../")) {
if !isStandardLibrary(path) && !strings.HasPrefix(path, "RenewCMS/internal/domain/") {
t.Errorf("%s imports %s", file, path)
}
}
}
})
t.Run("The variant use cases import no persistence, mailer, models, gorm, net/http, chi or api package", func(t *testing.T) {
forbidden := []string{"persistence", "mailer", "models", "gorm", "net/http", "go-chi", "RenewCMS/api"}
for _, name := range variantUseCaseFiles {
for _, path := range importsOf(t, "internal/infrastructure/useCases/"+name) {
for _, fragment := range forbidden {
if strings.Contains(path, fragment) {
t.Errorf("%s imports %s", name, path)
}
}
}
}
})
t.Run("The variant handlers import no persistence, mailer, models or gorm package", func(t *testing.T) {
for _, file := range []string{"api/handlers/imagevariant/imageVariantHandler.go", "api/handlers/staticimages/staticImagesHandler.go"} {
for _, path := range importsOf(t, file) {
for _, fragment := range []string{"persistence", "mailer", "models", "gorm"} {
if strings.Contains(path, fragment) {
t.Errorf("%s imports %s", file, path)
}
}
}
}
})
t.Run("The renderer is free of gorm and http", func(t *testing.T) {
for _, name := range []string{"variantEncoders.go", "variantGeometry.go", "variantRenderer.go"} {
for _, path := range importsOf(t, "internal/infrastructure/imaging/"+name) {
if strings.Contains(path, "gorm") || strings.Contains(path, "net/http") {
t.Errorf("%s imports %s", name, path)
}
}
}
})
t.Run("Only api/dependecyInjection.go constructs the variant repositories, the renderer and the use cases", func(t *testing.T) {
constructors := []string{"NewVariantRepository(", "NewVariantFileRepository(", "NewVariantRenderer(", "NewListVariantsUseCase(", "NewCreateVariantUseCase(", "NewUpdateVariantUseCase(", "NewSetVariantEnabledUseCase(", "NewDeleteVariantUseCase(", "NewPurgeVariantUseCase(", "NewServeVariantUseCase("}
for _, root := range []string{"../api", "../internal", "../cmd"} {
_ = filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() || !strings.HasSuffix(path, ".go") || filepath.Base(path) == "dependecyInjection.go" {
return err
}
content, _ := os.ReadFile(path)
for _, constructor := range constructors {
if bytes.Contains(content, []byte(constructor)) && !definesConstructor(content, constructor) {
t.Errorf("%s calls %s", path, constructor)
}
}
return nil
})
}
})
t.Run("The new GORM models are registered in migrate.go", func(t *testing.T) {
migrate := readRepositoryFile(t, "internal/infrastructure/persistence/database/migrate.go")
if !strings.Contains(migrate, "&models.ImageVariant{}") || !strings.Contains(migrate, "&models.ImageVariantFile{}") {
t.Error("a variant model is missing from AutoMigrate")
}
})
}
func definesConstructor(content []byte, constructor string) bool {
return bytes.Contains(content, []byte("func "+constructor))
}
func TestImageVariantFilesHaveNoComments(t *testing.T) {
for _, relative := range newVariantGoFiles {
t.Run(relative, func(t *testing.T) {
parsed, err := parser.ParseFile(token.NewFileSet(), "../"+relative, nil, parser.ParseComments)
if err != nil {
t.Fatal(err)
}
for _, group := range parsed.Comments {
for _, comment := range group.List {
if !strings.HasPrefix(comment.Text, "//go:") {
t.Errorf("comment %q", comment.Text)
}
}
}
})
}
}
func TestImageVariantBuildStaysCGOFree(t *testing.T) {
if testing.Short() {
t.Skip("builds the module")
}
environment := append(os.Environ(), "CGO_ENABLED=0")
t.Run("The module builds with CGO_ENABLED=0 and the nofrontend tag", func(t *testing.T) {
build := exec.Command("go", "build", "-tags", "nofrontend", "./...")
build.Dir, build.Env = "..", environment
if output, err := build.CombinedOutput(); err != nil {
t.Fatalf("build failed: %v\n%s", err, output)
}
})
t.Run("No dependency compiles cgo files", func(t *testing.T) {
list := exec.Command("go", "list", "-tags", "nofrontend", "-deps", "-f", "{{if .CgoFiles}}{{.ImportPath}}{{end}}", "./...")
list.Dir, list.Env = "..", environment
output, err := list.Output()
if err != nil {
t.Fatalf("go list failed: %v", err)
}
if strings.TrimSpace(string(output)) != "" {
t.Errorf("packages with cgo files: %s", output)
}
})
}
+147
View File
@@ -0,0 +1,147 @@
package test
import (
"bytes"
"image"
"image/color"
"image/draw"
"image/gif"
"image/jpeg"
"image/png"
"math/rand"
"testing"
"golang.org/x/image/webp"
)
var (
variantRed = color.NRGBA{R: 255, A: 255}
variantGreen = color.NRGBA{G: 255, A: 255}
variantBlue = color.NRGBA{B: 255, A: 255}
)
func variantSolidImage(width int, height int, fill color.NRGBA) *image.NRGBA {
canvas := image.NewNRGBA(image.Rect(0, 0, width, height))
draw.Draw(canvas, canvas.Bounds(), image.NewUniform(fill), image.Point{}, draw.Src)
return canvas
}
func encodePNG(t *testing.T, source image.Image) []byte {
t.Helper()
var buffer bytes.Buffer
if err := png.Encode(&buffer, source); err != nil {
t.Fatalf("png encode failed: %v", err)
}
return buffer.Bytes()
}
func encodeJPEG(t *testing.T, source image.Image) []byte {
t.Helper()
var buffer bytes.Buffer
if err := jpeg.Encode(&buffer, source, &jpeg.Options{Quality: 90}); err != nil {
t.Fatalf("jpeg encode failed: %v", err)
}
return buffer.Bytes()
}
func solidPNG(t *testing.T, width int, height int) []byte {
t.Helper()
return encodePNG(t, variantSolidImage(width, height, variantRed))
}
func stripedImage(t *testing.T) *image.NRGBA {
t.Helper()
canvas := image.NewNRGBA(image.Rect(0, 0, 40, 20))
for x := 0; x < 40; x++ {
fill := variantGreen
if x < 10 {
fill = variantRed
}
if x >= 30 {
fill = variantBlue
}
for y := 0; y < 20; y++ {
canvas.SetNRGBA(x, y, fill)
}
}
return canvas
}
func noisyImage(width int, height int) *image.NRGBA {
generator := rand.New(rand.NewSource(7))
canvas := image.NewNRGBA(image.Rect(0, 0, width, height))
for offset := 0; offset < len(canvas.Pix); offset += 4 {
canvas.Pix[offset] = uint8(generator.Intn(256))
canvas.Pix[offset+1] = uint8(generator.Intn(256))
canvas.Pix[offset+2] = uint8(generator.Intn(256))
canvas.Pix[offset+3] = 255
}
return canvas
}
func animatedGIF(t *testing.T) []byte {
t.Helper()
return encodeGIF(t, 2)
}
func stillGIF(t *testing.T) []byte {
t.Helper()
return encodeGIF(t, 1)
}
func encodeGIF(t *testing.T, frames int) []byte {
t.Helper()
palette := color.Palette{color.White, color.Black}
animation := &gif.GIF{}
for frame := 0; frame < frames; frame++ {
paletted := image.NewPaletted(image.Rect(0, 0, 8, 8), palette)
paletted.SetColorIndex(frame, frame, 1)
animation.Image = append(animation.Image, paletted)
animation.Delay = append(animation.Delay, 10)
}
var buffer bytes.Buffer
if err := gif.EncodeAll(&buffer, animation); err != nil {
t.Fatalf("gif encode failed: %v", err)
}
return buffer.Bytes()
}
func decodeImage(t *testing.T, content []byte) (image.Image, string) {
t.Helper()
decoded, format, err := image.Decode(bytes.NewReader(content))
if err != nil {
t.Fatalf("decode failed: %v", err)
}
return decoded, format
}
func decodedSize(t *testing.T, content []byte) (int, int) {
t.Helper()
config, _, err := image.DecodeConfig(bytes.NewReader(content))
if err != nil {
t.Fatalf("decode config failed: %v", err)
}
return config.Width, config.Height
}
func webpSize(t *testing.T, content []byte) (int, int) {
t.Helper()
config, err := webp.DecodeConfig(bytes.NewReader(content))
if err != nil {
t.Fatalf("webp decode config failed: %v", err)
}
return config.Width, config.Height
}
func pixelAt(decoded image.Image, x int, y int) color.NRGBA {
return color.NRGBAModel.Convert(decoded.At(x, y)).(color.NRGBA)
}
func padSizedPNG(t *testing.T, size int) []byte {
t.Helper()
content := solidPNG(t, 8, 8)
if len(content) > size {
t.Fatalf("base png is larger than %d bytes", size)
}
return append(content, make([]byte, size-len(content))...)
}
+444
View File
@@ -0,0 +1,444 @@
package test
import (
domain "RenewCMS/internal/domain/image"
"RenewCMS/internal/infrastructure/config"
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
"RenewCMS/internal/infrastructure/useCases"
"bytes"
"context"
"encoding/binary"
"hash/crc32"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
)
const (
tooLargeBody = "The source image is too large to generate this variant."
pngSignatureLength = 8
pngChunkTypeLength = 4
pngHeaderLength = 13
gateTimeout = 5 * time.Second
settleDelay = 150 * time.Millisecond
)
func declaringPNG(t *testing.T, width int, height int) []byte {
t.Helper()
content := solidPNG(t, 1, 1)
headerStart := pngSignatureLength + pngChunkTypeLength
binary.BigEndian.PutUint32(content[headerStart+pngChunkTypeLength:], uint32(width))
binary.BigEndian.PutUint32(content[headerStart+pngChunkTypeLength+4:], uint32(height))
checksumStart := headerStart + pngChunkTypeLength + pngHeaderLength
binary.BigEndian.PutUint32(content[checksumStart:], crc32.ChecksumIEEE(content[headerStart:checksumStart]))
return content
}
func pixelLimitedEnv(t *testing.T, pixels int64) *variantEnv {
t.Helper()
env := newVariantEnvLimits(t, newCountingRenderer(), useCases.ServeVariantLimits{MaxSourceBytes: variantDefaultCap, MaxSourcePixels: pixels, MaxConcurrentRenders: variantDefaultRenders})
env.seedThumb()
return env
}
func TestImageVariantsSourcePixelCap(t *testing.T) {
rows := []struct {
width, height, status int
real bool
}{
{100, 100, 200, true},
{101, 100, 413, true},
{30000, 30000, 413, false},
}
for _, row := range rows {
t.Run("A source beyond IMAGE_VARIANT_MAX_SOURCE_PIXELS answers 413 without decoding it: "+strconv.Itoa(row.width)+"x"+strconv.Itoa(row.height), func(t *testing.T) {
env := pixelLimitedEnv(t, 10000)
content := declaringPNG(t, row.width, row.height)
if row.real {
content = solidPNG(t, row.width, row.height)
}
env.seedSource("wide.png", content)
recorder := env.get("/static/uploadedImages/wide.png?v=thumb", nil)
if recorder.Code != row.status {
t.Fatalf("status = %d", recorder.Code)
}
if row.status == 413 && (env.renderer.renderCount() != 0 || env.storage.putCount() != 0 || env.files.rowCount() != 0) {
t.Errorf("renders = %d, puts = %d, rows = %d", env.renderer.renderCount(), env.storage.putCount(), env.files.rowCount())
}
})
}
t.Run("The pixel 413 body is plain text and nothing is generated", func(t *testing.T) {
env := pixelLimitedEnv(t, 10000)
env.seedSource("wide.png", declaringPNG(t, 30000, 30000))
recorder := env.get("/static/uploadedImages/wide.png?v=thumb", nil)
assertStatusAndBody(t, recorder, 413, tooLargeBody)
if !strings.HasPrefix(recorder.Header().Get("Content-Type"), "text/plain") || env.renderer.renderCount() != 0 || env.storage.putCount() != 0 {
t.Errorf("content type = %q, renders = %d", recorder.Header().Get("Content-Type"), env.renderer.renderCount())
}
})
t.Run("The pixel cap never affects the original", func(t *testing.T) {
env := pixelLimitedEnv(t, 10000)
content := declaringPNG(t, 30000, 30000)
env.seedSource("wide.png", content)
recorder := env.get("/static/uploadedImages/wide.png", nil)
if recorder.Code != 200 || !bytes.Equal(recorder.Body.Bytes(), content) {
t.Errorf("status = %d", recorder.Code)
}
})
t.Run("The pixel cap never affects an SVG", func(t *testing.T) {
env := pixelLimitedEnv(t, 1)
svg := `<svg xmlns="http://www.w3.org/2000/svg" width="9000" height="9000"></svg>`
env.seedSource("e5f6.svg", []byte(svg))
recorder := env.get("/static/uploadedImages/e5f6.svg?v=thumb", nil)
if recorder.Code != 200 || recorder.Body.String() != svg || env.renderer.renderCount() != 0 {
t.Errorf("status = %d", recorder.Code)
}
})
t.Run("A header that cannot be read answers 500 and caches nothing", func(t *testing.T) {
env := pixelLimitedEnv(t, 10000)
env.seedSource("bad.png", []byte("these bytes are not an image"))
recorder := env.get("/static/uploadedImages/bad.png?v=thumb", nil)
if recorder.Code != 500 || env.renderer.renderCount() != 0 || env.storage.putCount() != 0 || env.files.rowCount() != 0 {
t.Errorf("status = %d, renders = %d", recorder.Code, env.renderer.renderCount())
}
})
}
func TestImageVariantsKnownSource(t *testing.T) {
t.Run("An image record with that stored name is a source", func(t *testing.T) {
env := servingEnv(t)
if recorder := env.get(thumbURL, nil); recorder.Code != 200 {
t.Errorf("status = %d", recorder.Code)
}
})
for _, name := range []string{"gen-1.jpg", "orphan.png"} {
t.Run("A blob without an image record answers 404 and nothing is read: "+name, func(t *testing.T) {
env := servingEnv(t)
env.storage.seed(name, encodePNG(t, variantSolidImage(10, 10, variantRed)))
recorder := env.get("/static/uploadedImages/"+name+"?v=thumb", nil)
if recorder.Code != 404 || env.storage.openCountOf(name) != 0 || env.renderer.renderCount() != 0 || env.storage.putCount() != 0 || env.files.rowCount() != 0 {
t.Errorf("status = %d, opens = %d", recorder.Code, env.storage.openCountOf(name))
}
})
}
t.Run("The record lookup comes after the parameter checks and before any storage read", func(t *testing.T) {
env := servingEnv(t)
env.storage.seed("orphan.png", []byte("orphan"))
unknown := env.get("/static/uploadedImages/orphan.png?v=banner", nil)
orphan := env.get("/static/uploadedImages/orphan.png?v=thumb", nil)
assertRequestError(t, unknown, "unknown_variant", unknownMessage, "banner")
if orphan.Code != 404 || env.storage.openCountOf("orphan.png") != 0 {
t.Errorf("status = %d, opens = %d", orphan.Code, env.storage.openCountOf("orphan.png"))
}
})
t.Run("The original of any stored blob is still served without a variant", func(t *testing.T) {
env := servingEnv(t)
env.storage.seed("gen-1.jpg", []byte("generated bytes"))
recorder := env.get("/static/uploadedImages/gen-1.jpg", nil)
if recorder.Code != 200 || recorder.Body.String() != "generated bytes" {
t.Errorf("status = %d", recorder.Code)
}
})
t.Run("A failing record lookup answers 500 and reads nothing", func(t *testing.T) {
env := servingEnv(t)
env.images.fail.failNext("ExistsByStoredName")
recorder := env.get(thumbURL, nil)
if recorder.Code != 500 || env.storage.openCountOf(variantSourceName) != 0 || env.renderer.renderCount() != 0 {
t.Errorf("status = %d", recorder.Code)
}
})
}
func TestImageRepositoryExistsByStoredName(t *testing.T) {
repo := persistence.NewImageRepository(openActivityDatabase(t))
if _, err := repo.Create(domain.Image{Path: domain.PublicPath("a1b2.png"), Filename: "photo.png"}); err != nil {
t.Fatal(err)
}
for name, want := range map[string]bool{"a1b2.png": true, "a1b2": false, "b2.png": false, "gen-1.jpg": false, "": false} {
found, err := repo.ExistsByStoredName(name)
if err != nil || found != want {
t.Errorf("%q: found = %v, err = %v", name, found, err)
}
}
}
type renderGate struct {
mutex sync.Mutex
inFlight int
peak int
started chan struct{}
release chan struct{}
}
func newRenderGate() *renderGate {
return &renderGate{started: make(chan struct{}, 64), release: make(chan struct{}, 64)}
}
func (g *renderGate) hold() {
g.mutex.Lock()
g.inFlight++
g.peak = max(g.peak, g.inFlight)
g.mutex.Unlock()
g.started <- struct{}{}
<-g.release
g.mutex.Lock()
g.inFlight--
g.mutex.Unlock()
}
func (g *renderGate) running() int {
g.mutex.Lock()
defer g.mutex.Unlock()
return g.inFlight
}
func (g *renderGate) peakRunning() int {
g.mutex.Lock()
defer g.mutex.Unlock()
return g.peak
}
func (g *renderGate) awaitStarts(t *testing.T, count int) {
t.Helper()
for index := 0; index < count; index++ {
select {
case <-g.started:
case <-time.After(gateTimeout):
t.Fatalf("only %d renders started, want %d", index, count)
}
}
}
func (g *renderGate) letRun(count int) {
for index := 0; index < count; index++ {
g.release <- struct{}{}
}
}
func concurrencyEnv(t *testing.T, slots int, sources int) (*variantEnv, *renderGate, []string) {
t.Helper()
renderer := newCountingRenderer()
env := newVariantEnvLimits(t, renderer, useCases.ServeVariantLimits{MaxSourceBytes: variantDefaultCap, MaxSourcePixels: variantDefaultPixels, MaxConcurrentRenders: slots})
env.seedThumb()
gate := newRenderGate()
urls := make([]string, 0, sources)
for index := 0; index < sources; index++ {
name := "src" + strconv.Itoa(index) + ".png"
env.seedSource(name, encodePNG(t, variantSolidImage(60, 40, variantRed)))
urls = append(urls, "/static/uploadedImages/"+name+"?v=thumb")
}
renderer.onRender = gate.hold
return env, gate, urls
}
func getAsync(env *variantEnv, ctx context.Context, url string, codes chan<- int) {
go func() {
request := httptest.NewRequest(http.MethodGet, url, nil).WithContext(ctx)
recorder := httptest.NewRecorder()
env.public.ServeHTTP(recorder, request)
codes <- recorder.Code
}()
}
func TestImageVariantsBoundedRenders(t *testing.T) {
t.Run("At most IMAGE_VARIANT_MAX_CONCURRENT_RENDERS renders run at once", func(t *testing.T) {
env, gate, urls := concurrencyEnv(t, 2, 5)
codes := make(chan int, len(urls))
for _, url := range urls {
getAsync(env, context.Background(), url, codes)
}
gate.awaitStarts(t, 2)
time.Sleep(settleDelay)
if gate.running() != 2 || env.renderer.renderCount() != 2 {
t.Fatalf("in flight = %d, renders = %d", gate.running(), env.renderer.renderCount())
}
gate.letRun(len(urls))
for range urls {
select {
case code := <-codes:
if code != 200 {
t.Errorf("status = %d", code)
}
case <-time.After(gateTimeout):
t.Fatal("a request never finished")
}
}
if gate.peakRunning() != 2 || env.renderer.renderCount() != 5 {
t.Errorf("peak = %d, renders = %d", gate.peakRunning(), env.renderer.renderCount())
}
})
t.Run("A cached file never takes a render slot", func(t *testing.T) {
env, gate, urls := concurrencyEnv(t, 1, 2)
gate.letRun(1)
warm := make(chan int, 1)
getAsync(env, context.Background(), urls[0], warm)
gate.awaitStarts(t, 1)
if code := <-warm; code != 200 {
t.Fatalf("warm-up status = %d", code)
}
blocked := make(chan int, 1)
getAsync(env, context.Background(), urls[1], blocked)
gate.awaitStarts(t, 1)
cached := make(chan int, 1)
getAsync(env, context.Background(), urls[0], cached)
select {
case code := <-cached:
if code != 200 {
t.Errorf("status = %d", code)
}
case <-time.After(gateTimeout):
t.Fatal("the cached request waited for a slot")
}
gate.letRun(1)
<-blocked
})
t.Run("A waiting request whose client has gone does not render", func(t *testing.T) {
env, gate, urls := concurrencyEnv(t, 1, 2)
holder := make(chan int, 1)
getAsync(env, context.Background(), urls[0], holder)
gate.awaitStarts(t, 1)
ctx, cancel := context.WithCancel(context.Background())
waiter := make(chan int, 1)
getAsync(env, ctx, urls[1], waiter)
time.Sleep(settleDelay)
cancel()
select {
case <-waiter:
case <-time.After(gateTimeout):
t.Fatal("the waiting request kept waiting")
}
gate.letRun(1)
<-holder
if env.renderer.renderCount() != 1 || env.storage.putCount() != 1 || env.files.rowCount() != 1 {
t.Errorf("renders = %d, puts = %d, rows = %d", env.renderer.renderCount(), env.storage.putCount(), env.files.rowCount())
}
})
t.Run("Identical concurrent requests still share one render and one slot", func(t *testing.T) {
env, gate, urls := concurrencyEnv(t, 1, 1)
codes := make(chan int, 20)
for index := 0; index < 20; index++ {
getAsync(env, context.Background(), urls[0], codes)
}
gate.awaitStarts(t, 1)
time.Sleep(settleDelay)
gate.letRun(20)
var served atomic.Int32
for index := 0; index < 20; index++ {
if <-codes == 200 {
served.Add(1)
}
}
if served.Load() != 20 || env.renderer.renderCount() != 1 || gate.peakRunning() != 1 {
t.Errorf("served = %d, renders = %d, peak = %d", served.Load(), env.renderer.renderCount(), gate.peakRunning())
}
})
}
func TestImageVariantPixelAndConcurrencySettings(t *testing.T) {
rows := []struct {
name string
env string
value string
unset bool
want int64
failure bool
}{
{"pixels unset", "IMAGE_VARIANT_MAX_SOURCE_PIXELS", "", true, 50000000, false},
{"pixels 2048", "IMAGE_VARIANT_MAX_SOURCE_PIXELS", "2048", false, 2048, false},
{"pixels 0", "IMAGE_VARIANT_MAX_SOURCE_PIXELS", "0", false, 0, true},
{"pixels -5", "IMAGE_VARIANT_MAX_SOURCE_PIXELS", "-5", false, 0, true},
{"pixels many", "IMAGE_VARIANT_MAX_SOURCE_PIXELS", "many", false, 0, true},
{"renders unset", "IMAGE_VARIANT_MAX_CONCURRENT_RENDERS", "", true, 4, false},
{"renders 1", "IMAGE_VARIANT_MAX_CONCURRENT_RENDERS", "1", false, 1, false},
{"renders 0", "IMAGE_VARIANT_MAX_CONCURRENT_RENDERS", "0", false, 0, true},
{"renders -1", "IMAGE_VARIANT_MAX_CONCURRENT_RENDERS", "-1", false, 0, true},
{"renders many", "IMAGE_VARIANT_MAX_CONCURRENT_RENDERS", "many", false, 0, true},
}
for _, row := range rows {
t.Run("The env var is loaded: "+row.name, func(t *testing.T) {
setBaseSettingsEnv(t)
if row.unset {
t.Setenv(row.env, "")
} else {
t.Setenv(row.env, row.value)
}
settings, err := config.NewSettingsFromEnv()
if row.failure {
if err == nil || !strings.Contains(err.Error(), row.env) {
t.Errorf("err = %v", err)
}
return
}
got := settings.ImageVariantMaxSourcePixels
if row.env == "IMAGE_VARIANT_MAX_CONCURRENT_RENDERS" {
got = int64(settings.ImageVariantMaxConcurrentRenders)
}
if err != nil || got != row.want {
t.Errorf("value = %d, err = %v", got, err)
}
})
}
t.Run("Both defaults are listed in envDefaults, .env.example and docs/configuration.md", func(t *testing.T) {
defaults := between(readRepositoryFile(t, "cmd/server/main.go"), "var envDefaults = map[string]string{", "\n}")
required := between(readRepositoryFile(t, "cmd/server/main.go"), "var requiredEnvVars = []string{", "\n}")
for _, name := range []string{"EnvImageVariantMaxSourcePixels", "EnvImageVariantMaxConcurrentRenders"} {
if !strings.Contains(defaults, "config."+name) || strings.Contains(required, name) {
t.Errorf("%s is misplaced", name)
}
}
for _, document := range []string{".env.example", "docs/configuration.md"} {
content := readRepositoryFile(t, document)
for _, name := range []string{"IMAGE_VARIANT_MAX_SOURCE_PIXELS", "IMAGE_VARIANT_MAX_CONCURRENT_RENDERS"} {
if !strings.Contains(content, name) {
t.Errorf("%s does not mention %s", document, name)
}
}
}
})
}
+129
View File
@@ -0,0 +1,129 @@
package test
import (
domain "RenewCMS/internal/domain/image"
"bytes"
"errors"
"image"
"image/color"
"image/gif"
"testing"
)
func bandedTallImage() *image.NRGBA {
canvas := image.NewNRGBA(image.Rect(0, 0, 20, 60))
for y := 0; y < 60; y++ {
fill := variantGreen
if y < 15 {
fill = variantRed
}
if y >= 45 {
fill = variantBlue
}
for x := 0; x < 20; x++ {
canvas.SetNRGBA(x, y, fill)
}
}
return canvas
}
func globalPaletteGIF(t *testing.T, frames int) []byte {
t.Helper()
palette := color.Palette{color.White, color.Black, color.RGBA{R: 255, A: 255}, color.RGBA{B: 255, A: 255}}
animation := &gif.GIF{Config: image.Config{ColorModel: palette, Width: 8, Height: 8}}
for frame := 0; frame < frames; frame++ {
paletted := image.NewPaletted(image.Rect(0, 0, 8, 8), palette)
paletted.SetColorIndex(frame, frame, 1)
animation.Image = append(animation.Image, paletted)
animation.Delay = append(animation.Delay, 10)
}
var buffer bytes.Buffer
if err := gif.EncodeAll(&buffer, animation); err != nil {
t.Fatalf("gif encode failed: %v", err)
}
return buffer.Bytes()
}
func TestVariantGeometryPixelGaps(t *testing.T) {
t.Run("Cover on a tall source keeps the middle band", func(t *testing.T) {
rendered := renderVariant(t, encodePNG(t, bandedTallImage()), variantSettings(20, 20, domain.FitCover, domain.FormatPNG))
decoded, _ := decodeImage(t, rendered.Content)
if decoded.Bounds().Dx() != 20 || decoded.Bounds().Dy() != 20 {
t.Fatalf("output = %v", decoded.Bounds())
}
for _, y := range []int{0, 10, 19} {
if got := pixelAt(decoded, 10, y); got != variantGreen {
t.Errorf("pixel 10,%d = %v, want green", y, got)
}
}
})
t.Run("Inside on a tall source scales the whole picture", func(t *testing.T) {
rendered := renderVariant(t, encodePNG(t, bandedTallImage()), variantSettings(10, 30, domain.FitInside, domain.FormatPNG))
decoded, _ := decodeImage(t, rendered.Content)
if decoded.Bounds().Dx() != 10 || decoded.Bounds().Dy() != 30 {
t.Fatalf("output = %v", decoded.Bounds())
}
want := map[int]color.NRGBA{1: variantRed, 15: variantGreen, 28: variantBlue}
for y, expected := range want {
assertNearColor(t, pixelAt(decoded, 5, y), expected)
}
})
t.Run("Contain pads horizontally around a narrow source", func(t *testing.T) {
rendered := renderVariant(t, encodePNG(t, variantSolidImage(20, 40, variantRed)), variantSettings(40, 40, domain.FitContain, domain.FormatPNG))
decoded, _ := decodeImage(t, rendered.Content)
for y := 0; y < 40; y += 13 {
for _, x := range []int{0, 9, 30, 39} {
if got := pixelAt(decoded, x, y); got.A != 0 {
t.Fatalf("pixel %d,%d = %v, want transparent", x, y, got)
}
}
for _, x := range []int{10, 19, 29} {
if got := pixelAt(decoded, x, y); got != variantRed {
t.Fatalf("pixel %d,%d = %v, want red", x, y, got)
}
}
}
})
t.Run("Contain keeps a box that is larger in one direction only", func(t *testing.T) {
for _, row := range []struct{ width, height, wantWidth, wantHeight int }{
{200, 100, 200, 100},
{100, 200, 100, 200},
} {
rendered := renderVariant(t, solidPNG(t, 100, 100), variantSettings(row.width, row.height, domain.FitContain, domain.FormatPNG))
width, height := decodedSize(t, rendered.Content)
if width != row.wantWidth || height != row.wantHeight {
t.Errorf("output = %dx%d, want %dx%d", width, height, row.wantWidth, row.wantHeight)
}
}
})
}
func TestAnimatedGIFDetectionWithGlobalPalette(t *testing.T) {
animated, err := domain.IsAnimatedGIF(bytes.NewReader(globalPaletteGIF(t, 2)))
if err != nil || !animated {
t.Errorf("two frames: animated = %v, err = %v", animated, err)
}
animated, err = domain.IsAnimatedGIF(bytes.NewReader(globalPaletteGIF(t, 1)))
if err != nil || animated {
t.Errorf("one frame: animated = %v, err = %v", animated, err)
}
}
func TestVariantRequestErrorMessages(t *testing.T) {
unknown := domain.NewUnknownVariantError("thumb")
if unknown.Error() != "unknown variant: thumb" || !errors.Is(unknown, domain.ErrUnknownVariant) {
t.Errorf("unknown = %q", unknown.Error())
}
unsupported := domain.NewUnsupportedParameterError("w")
if unsupported.Error() != "unsupported parameter: w" || !errors.Is(unsupported, domain.ErrUnsupportedParameter) {
t.Errorf("unsupported = %q", unsupported.Error())
}
}
+195
View File
@@ -0,0 +1,195 @@
package test
import (
domain "RenewCMS/internal/domain/image"
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
"errors"
"testing"
"time"
)
func persistedVariant(name string, width *int, height *int) domain.Variant {
quality := 80
return domain.Variant{Name: name, Width: width, Height: height, Fit: domain.FitCover, Format: domain.FormatJPEG, Quality: &quality, Enabled: true}
}
func intPointer(value int) *int { return &value }
func TestVariantRepository(t *testing.T) {
t.Run("A created variant is read back with absent dimensions as nil", func(t *testing.T) {
repo := persistence.NewVariantRepository(openActivityDatabase(t))
if _, err := repo.Create(persistedVariant("card", intPointer(800), nil)); err != nil {
t.Fatal(err)
}
stored, err := repo.Get("card")
if err != nil || stored.Width == nil || *stored.Width != 800 || stored.Height != nil || *stored.Quality != 80 || !stored.Enabled {
t.Errorf("stored = %+v, err = %v", stored, err)
}
})
t.Run("A png variant keeps a nil quality and a disabled variant stays disabled", func(t *testing.T) {
repo := persistence.NewVariantRepository(openActivityDatabase(t))
logo := persistedVariant("logo", intPointer(100), nil)
logo.Format, logo.Quality, logo.Enabled = domain.FormatPNG, nil, false
if _, err := repo.Create(logo); err != nil {
t.Fatal(err)
}
stored, _ := repo.Get("logo")
if stored.Quality != nil || stored.Enabled || stored.GenerateOnUpload {
t.Errorf("stored = %+v", stored)
}
})
t.Run("A duplicate name is refused with ErrVariantExists", func(t *testing.T) {
repo := persistence.NewVariantRepository(openActivityDatabase(t))
_, _ = repo.Create(persistedVariant("thumb", intPointer(400), nil))
_, err := repo.Create(persistedVariant("thumb", intPointer(800), nil))
if !errors.Is(err, domain.ErrVariantExists) {
t.Errorf("err = %v", err)
}
})
t.Run("The list is ordered by name", func(t *testing.T) {
repo := persistence.NewVariantRepository(openActivityDatabase(t))
for _, name := range []string{"hero", "card", "thumb"} {
_, _ = repo.Create(persistedVariant(name, intPointer(100), nil))
}
listed, err := repo.List()
if err != nil || len(listed) != 3 || listed[0].Name != "card" || listed[1].Name != "hero" || listed[2].Name != "thumb" {
t.Errorf("listed = %+v, err = %v", listed, err)
}
})
t.Run("An update replaces the rendering settings and nulls a removed dimension", func(t *testing.T) {
repo := persistence.NewVariantRepository(openActivityDatabase(t))
_, _ = repo.Create(persistedVariant("hero", intPointer(1200), intPointer(630)))
changed := persistedVariant("hero", intPointer(1000), nil)
changed.UpdatedAt = time.Date(2026, 10, 8, 9, 0, 0, 0, time.UTC)
if _, err := repo.Update(changed); err != nil {
t.Fatal(err)
}
stored, _ := repo.Get("hero")
if *stored.Width != 1000 || stored.Height != nil || !stored.UpdatedAt.Equal(changed.UpdatedAt) {
t.Errorf("stored = %+v", stored)
}
})
t.Run("An unknown variant is reported as ErrVariantNotFound on get, update and delete", func(t *testing.T) {
repo := persistence.NewVariantRepository(openActivityDatabase(t))
_, getErr := repo.Get("missing")
_, updateErr := repo.Update(persistedVariant("missing", intPointer(1), nil))
deleteErr := repo.Delete("missing")
for _, err := range []error{getErr, updateErr, deleteErr} {
if !errors.Is(err, domain.ErrVariantNotFound) {
t.Errorf("err = %v", err)
}
}
})
t.Run("A deleted variant is gone", func(t *testing.T) {
repo := persistence.NewVariantRepository(openActivityDatabase(t))
_, _ = repo.Create(persistedVariant("hero", intPointer(100), nil))
if err := repo.Delete("hero"); err != nil {
t.Fatal(err)
}
if _, err := repo.Get("hero"); !errors.Is(err, domain.ErrVariantNotFound) {
t.Errorf("err = %v", err)
}
})
}
func TestVariantFileRepository(t *testing.T) {
file := func(variant string, source string, cached string, size int64) domain.VariantFile {
return domain.VariantFile{VariantName: variant, SourceName: source, CachedName: cached, ContentType: "image/jpeg", Size: size, Fingerprint: "f1"}
}
t.Run("A saved file is found by variant and source", func(t *testing.T) {
repo := persistence.NewVariantFileRepository(openActivityDatabase(t))
_, _ = repo.Save(file("hero", "a.png", "g1.jpeg", 1000))
found, ok, err := repo.Find("hero", "a.png")
if err != nil || !ok || found.CachedName != "g1.jpeg" || found.Size != 1000 || found.Fingerprint != "f1" || found.ContentType != "image/jpeg" || found.ID == 0 {
t.Errorf("found = %+v, ok = %v, err = %v", found, ok, err)
}
})
t.Run("Finding nothing is not an error", func(t *testing.T) {
repo := persistence.NewVariantFileRepository(openActivityDatabase(t))
_, ok, err := repo.Find("hero", "a.png")
if ok || err != nil {
t.Errorf("ok = %v, err = %v", ok, err)
}
})
t.Run("Saving again for the same variant and source replaces the row", func(t *testing.T) {
repo := persistence.NewVariantFileRepository(openActivityDatabase(t))
_, _ = repo.Save(file("hero", "a.png", "g1.jpeg", 1000))
if _, err := repo.Save(file("hero", "a.png", "g2.jpeg", 500)); err != nil {
t.Fatal(err)
}
rows, _ := repo.ListByVariant("hero")
if len(rows) != 1 || rows[0].CachedName != "g2.jpeg" || rows[0].Size != 500 {
t.Errorf("rows = %+v", rows)
}
})
t.Run("Rows are listed by variant and by source", func(t *testing.T) {
repo := persistence.NewVariantFileRepository(openActivityDatabase(t))
_, _ = repo.Save(file("hero", "a.png", "g1.jpeg", 1))
_, _ = repo.Save(file("hero", "b.png", "g2.jpeg", 2))
_, _ = repo.Save(file("thumb", "a.png", "g3.jpeg", 3))
byVariant, _ := repo.ListByVariant("hero")
bySource, _ := repo.ListBySource("a.png")
if len(byVariant) != 2 || len(bySource) != 2 {
t.Errorf("by variant = %+v, by source = %+v", byVariant, bySource)
}
})
t.Run("Totals count files and bytes per variant", func(t *testing.T) {
repo := persistence.NewVariantFileRepository(openActivityDatabase(t))
_, _ = repo.Save(file("hero", "a.png", "g1.jpeg", 1000))
_, _ = repo.Save(file("hero", "b.png", "g2.jpeg", 2000))
_, _ = repo.Save(file("hero", "c.png", "g3.jpeg", 3000))
totals, err := repo.Totals()
if err != nil || totals["hero"].Files != 3 || totals["hero"].Bytes != 6000 || len(totals) != 1 {
t.Errorf("totals = %+v, err = %v", totals, err)
}
})
t.Run("Deleting a row removes only that row and tolerates a missing one", func(t *testing.T) {
repo := persistence.NewVariantFileRepository(openActivityDatabase(t))
first, _ := repo.Save(file("hero", "a.png", "g1.jpeg", 1))
_, _ = repo.Save(file("hero", "b.png", "g2.jpeg", 2))
firstErr := repo.Delete(first.ID)
secondErr := repo.Delete(first.ID)
rows, _ := repo.ListByVariant("hero")
if firstErr != nil || secondErr != nil || len(rows) != 1 {
t.Errorf("rows = %+v, errs = %v %v", rows, firstErr, secondErr)
}
})
}
+116
View File
@@ -0,0 +1,116 @@
package test
import (
domain "RenewCMS/internal/domain/image"
"RenewCMS/internal/infrastructure/useCases"
"errors"
"io"
"testing"
)
type understatedSizeVariantStorage struct {
*mockVariantStorage
}
func (s understatedSizeVariantStorage) Open(storedName string) (domain.Blob, error) {
blob, err := s.mockVariantStorage.Open(storedName)
blob.Size = 1
return blob, err
}
type sizingVariantRenderer struct {
received int
calls int
}
func (r *sizingVariantRenderer) Render(source io.Reader, settings domain.VariantSettings) (domain.RenderedVariant, error) {
data, err := io.ReadAll(source)
r.received = len(data)
r.calls++
return domain.RenderedVariant{Content: []byte("rendered"), ContentType: "image/png"}, err
}
func (r *sizingVariantRenderer) DeclaredSize(source io.Reader) (int, int, error) {
return 1, 1, nil
}
func (r *sizingVariantRenderer) EncodableFormats() []domain.VariantFormat {
return []domain.VariantFormat{domain.FormatOriginal, domain.FormatJPEG, domain.FormatPNG}
}
type lateStaleVariantFileRepo struct {
*mockVariantFileRepo
finds int
stale domain.VariantFile
}
func (r *lateStaleVariantFileRepo) Find(variantName string, sourceName string) (domain.VariantFile, bool, error) {
r.finds++
if r.finds == 1 {
return domain.VariantFile{}, false, nil
}
return r.stale, true, nil
}
func TestImageVariantsServeInternals(t *testing.T) {
const cap = 1000
serve := func(storage domain.Storage, files domain.VariantFileRepository, renderer domain.VariantRenderer, variants *mockVariantRepo) (useCases.ServedVariant, error) {
name := "thumb"
images := newMockImageRepo()
images.images[1] = domain.Image{ID: 1, Path: domain.PublicPath("a.png")}
return useCases.NewServeVariantUseCase(variants, files, images, storage, renderer, defaultServeLimits(cap)).Serve(useCases.ServeVariantRequest{StoredName: "a.png", Variant: &name})
}
t.Run("A streamed source of exactly the cap is rendered whole", func(t *testing.T) {
variants := newMockVariantRepo()
seedVariant(variants, "thumb", 400, 300, domain.FitCover, domain.FormatJPEG, 80)
storage := newMockVariantStorage()
storage.seed("a.png", make([]byte, cap))
renderer := &sizingVariantRenderer{}
_, err := serve(understatedSizeVariantStorage{storage}, newMockVariantFileRepo(), renderer, variants)
if err != nil || renderer.calls != 1 || renderer.received != cap {
t.Errorf("err = %v, calls = %d, received = %d", err, renderer.calls, renderer.received)
}
})
t.Run("A streamed source beyond the cap is rejected even when its reported size is small", func(t *testing.T) {
variants := newMockVariantRepo()
seedVariant(variants, "thumb", 400, 300, domain.FitCover, domain.FormatJPEG, 80)
storage := newMockVariantStorage()
storage.seed("a.png", make([]byte, cap+1))
renderer := &sizingVariantRenderer{}
_, err := serve(understatedSizeVariantStorage{storage}, newMockVariantFileRepo(), renderer, variants)
if !errors.Is(err, domain.ErrSourceTooLarge) || renderer.calls != 0 {
t.Errorf("err = %v, calls = %d", err, renderer.calls)
}
})
t.Run("A stale row that appears during generation is regenerated, not served", func(t *testing.T) {
variants := newMockVariantRepo()
seedVariant(variants, "thumb", 400, 300, domain.FitCover, domain.FormatJPEG, 80)
storage := newMockVariantStorage()
storage.seed("a.png", []byte("source"))
storage.seed("old.png", []byte("old"))
files := &lateStaleVariantFileRepo{
mockVariantFileRepo: newMockVariantFileRepo(),
stale: domain.VariantFile{VariantName: "thumb", SourceName: "a.png", CachedName: "old.png", ContentType: "image/png", Size: 3, Fingerprint: "stale"},
}
renderer := &sizingVariantRenderer{}
served, err := serve(storage, files, renderer, variants)
if err != nil || renderer.calls != 1 {
t.Fatalf("err = %v, calls = %d", err, renderer.calls)
}
defer served.Content.Close()
body, _ := io.ReadAll(served.Content)
if string(body) != "rendered" {
t.Errorf("body = %q", body)
}
})
}
+588
View File
@@ -0,0 +1,588 @@
package test
import (
domain "RenewCMS/internal/domain/image"
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"runtime"
"strings"
"sync"
"testing"
"time"
)
const (
thumbURL = "/static/uploadedImages/a1b2.png?v=thumb"
unknownMessage = "Unknown variant"
unsupportedText = "Unsupported parameter"
cacheHeader = "public, no-cache"
privateBlobName = "a1b2.png"
)
func servingEnv(t *testing.T) *variantEnv {
t.Helper()
env := newVariantEnv(t)
env.seedThumb()
env.seedPhoto()
return env
}
func assertRequestError(t *testing.T, recorder *httptest.ResponseRecorder, code string, message string, detail string) {
t.Helper()
if recorder.Code != 400 || !strings.HasPrefix(recorder.Header().Get("Content-Type"), "application/json") {
t.Fatalf("response = %d %q", recorder.Code, recorder.Header().Get("Content-Type"))
}
var body struct {
Status int `json:"status"`
Error string `json:"error"`
Message string `json:"message"`
Detail string `json:"detail"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil {
t.Fatalf("body = %q", recorder.Body.String())
}
if body.Status != 400 || body.Error != code || body.Message != message || body.Detail != detail {
t.Errorf("body = %+v", body)
}
}
func TestImageVariantsFirstAndCachedRequests(t *testing.T) {
t.Run("The first request generates and caches", func(t *testing.T) {
env := servingEnv(t)
original := append([]byte(nil), env.storage.blobs[privateBlobName]...)
recorder := env.get(thumbURL, nil)
if recorder.Code != 200 || recorder.Header().Get("Content-Type") != "image/jpeg" {
t.Fatalf("response = %d %q", recorder.Code, recorder.Header().Get("Content-Type"))
}
width, height := decodedSize(t, recorder.Body.Bytes())
_, format := decodeImage(t, recorder.Body.Bytes())
if width != 400 || height != 300 || format != "jpeg" {
t.Errorf("output = %dx%d %s", width, height, format)
}
rows := env.files.rowsOf("thumb")
if env.storage.putCount() != 1 || len(rows) != 1 || rows[0].Size != int64(recorder.Body.Len()) {
t.Errorf("puts = %d, rows = %+v", env.storage.putCount(), rows)
}
if !bytes.Equal(env.storage.blobs[privateBlobName], original) {
t.Error("the original blob changed")
}
})
t.Run("The second request serves the cached file without rendering", func(t *testing.T) {
env := servingEnv(t)
first := env.get(thumbURL, nil)
second := env.get(thumbURL, nil)
if !bytes.Equal(first.Body.Bytes(), second.Body.Bytes()) || env.renderer.renderCount() != 1 || env.storage.putCount() != 1 {
t.Errorf("renders = %d, puts = %d", env.renderer.renderCount(), env.storage.putCount())
}
})
t.Run("A request without ?v= is the unchanged original", func(t *testing.T) {
env := servingEnv(t)
recorder := env.get("/static/uploadedImages/a1b2.png", nil)
if recorder.Code != 200 || recorder.Header().Get("Content-Type") != "image/png" || recorder.Header().Get("Cache-Control") != cacheHeader || recorder.Header().Get("X-Content-Type-Options") != "nosniff" || recorder.Header().Get("ETag") != "" {
t.Errorf("headers = %v", recorder.Header())
}
if !bytes.Equal(recorder.Body.Bytes(), env.storage.blobs[privateBlobName]) || env.renderer.renderCount() != 0 {
t.Error("the original was altered or the renderer ran")
}
})
t.Run("Other query parameters are ignored", func(t *testing.T) {
env := servingEnv(t)
recorder := env.get(thumbURL+"&t=123", nil)
if recorder.Code != 200 || recorder.Header().Get("Content-Type") != "image/jpeg" {
t.Errorf("response = %d", recorder.Code)
}
})
t.Run("A HEAD request answers the headers of the variant", func(t *testing.T) {
env := servingEnv(t)
request := httptest.NewRequest(http.MethodHead, thumbURL, nil)
recorder := httptest.NewRecorder()
env.public.ServeHTTP(recorder, request)
if recorder.Code != 200 || recorder.Header().Get("Content-Type") != "image/jpeg" || recorder.Body.Len() != 0 {
t.Errorf("response = %d %q", recorder.Code, recorder.Header().Get("Content-Type"))
}
})
}
func TestImageVariantsETag(t *testing.T) {
t.Run("The ETag derives from the variant settings", func(t *testing.T) {
env := servingEnv(t)
first := env.get(thumbURL, nil)
etag := first.Header().Get("ETag")
again := env.get(thumbURL, nil)
conditional := env.get(thumbURL, http.Header{"If-None-Match": []string{etag}})
if len(etag) < 3 || !strings.HasPrefix(etag, `"`) || !strings.HasSuffix(etag, `"`) || again.Header().Get("ETag") != etag {
t.Errorf("etag = %q then %q", etag, again.Header().Get("ETag"))
}
if conditional.Code != 304 || conditional.Body.Len() != 0 || env.renderer.renderCount() != 1 {
t.Errorf("conditional = %d %q, renders = %d", conditional.Code, conditional.Body.String(), env.renderer.renderCount())
}
})
t.Run("A 304 does not touch the cache", func(t *testing.T) {
env := servingEnv(t)
etag := env.get(thumbURL, nil).Header().Get("ETag")
callsBefore := env.files.calls
env.get(thumbURL, http.Header{"If-None-Match": []string{etag}})
if env.files.calls != callsBefore {
t.Error("the file index was read")
}
})
changes := []struct {
setting string
body string
changes bool
}{
{"quality", `{"width": 400, "height": 300, "fit": "cover", "format": "jpeg", "quality": 70}`, true},
{"width", `{"width": 500, "height": 300, "fit": "cover", "format": "jpeg", "quality": 80}`, true},
{"height", `{"width": 400, "height": 200, "fit": "cover", "format": "jpeg", "quality": 80}`, true},
{"fit", `{"width": 400, "height": 300, "fit": "inside", "format": "jpeg", "quality": 80}`, true},
{"format", `{"width": 400, "height": 300, "fit": "cover", "format": "png"}`, true},
{"generate_on_upload", `{"width": 400, "height": 300, "fit": "cover", "format": "jpeg", "quality": 80, "generate_on_upload": true}`, false},
}
for _, row := range changes {
t.Run("The ETag changes when a setting changes and stays when a switch changes: "+row.setting, func(t *testing.T) {
env := servingEnv(t)
before := env.get(thumbURL, nil).Header().Get("ETag")
update := env.sendAsAdmin(http.MethodPut, "/v1/image-variants/thumb", row.body)
after := env.get(thumbURL, nil).Header().Get("ETag")
if update.Code != 200 || (before != after) != row.changes {
t.Errorf("update = %d, before = %q, after = %q", update.Code, before, after)
}
})
}
t.Run("A stale If-None-Match after an edit gets a fresh 200", func(t *testing.T) {
env := servingEnv(t)
stale := env.get(thumbURL, nil).Header().Get("ETag")
env.sendAsAdmin(http.MethodPut, "/v1/image-variants/thumb", `{"width": 400, "height": 300, "fit": "cover", "format": "jpeg", "quality": 70}`)
recorder := env.get(thumbURL, http.Header{"If-None-Match": []string{stale}})
if recorder.Code != 200 || recorder.Header().Get("ETag") == stale || recorder.Body.Len() == 0 {
t.Errorf("response = %d %q", recorder.Code, recorder.Header().Get("ETag"))
}
})
t.Run("Different images get different ETags for the same variant", func(t *testing.T) {
env := servingEnv(t)
env.seedSource("c3d4.png", encodePNG(t, variantSolidImage(900, 700, variantBlue)))
first := env.get(thumbURL, nil).Header().Get("ETag")
second := env.get("/static/uploadedImages/c3d4.png?v=thumb", nil).Header().Get("ETag")
if first == second {
t.Error("both images share one ETag")
}
})
t.Run("Editing a variant regenerates on the next request", func(t *testing.T) {
env := servingEnv(t)
env.get(thumbURL, nil)
oldName := env.files.rowsOf("thumb")[0].CachedName
env.sendAsAdmin(http.MethodPut, "/v1/image-variants/thumb", `{"width": 400, "height": 300, "fit": "cover", "format": "jpeg", "quality": 70}`)
env.get(thumbURL, nil)
rows := env.files.rowsOf("thumb")
if env.storage.has(oldName) || len(rows) != 1 || rows[0].CachedName == oldName || env.storage.putCount() != 2 {
t.Errorf("rows = %+v, puts = %d", rows, env.storage.putCount())
}
})
t.Run("A row with an old fingerprint is replaced and its blob removed", func(t *testing.T) {
env := servingEnv(t)
env.storage.seed("old.jpeg", []byte("old"))
env.files.Save(domain.VariantFile{VariantName: "thumb", SourceName: privateBlobName, CachedName: "old.jpeg", ContentType: "image/jpeg", Size: 3, Fingerprint: "outdated"})
recorder := env.get(thumbURL, nil)
if recorder.Code != 200 || env.storage.has("old.jpeg") || env.files.rowsOf("thumb")[0].CachedName == "old.jpeg" {
t.Error("the stale row was served or kept")
}
})
t.Run("A cached file that vanished from storage is generated again", func(t *testing.T) {
env := servingEnv(t)
env.get(thumbURL, nil)
vanished := env.files.rowsOf("thumb")[0].CachedName
delete(env.storage.blobs, vanished)
recorder := env.get(thumbURL, nil)
rows := env.files.rowsOf("thumb")
if recorder.Code != 200 || recorder.Body.Len() == 0 || len(rows) != 1 || rows[0].CachedName == vanished || !env.storage.has(rows[0].CachedName) {
t.Errorf("response = %d, rows = %+v", recorder.Code, rows)
}
})
}
func TestImageVariantsConcurrency(t *testing.T) {
t.Run("Concurrent first requests generate once", func(t *testing.T) {
const clients = 20
env := servingEnv(t)
env.renderer.onRender = func() {
for env.storage.openCountOf(privateBlobName) < clients {
runtime.Gosched()
}
}
bodies := make([][]byte, clients)
codes := make([]int, clients)
start := make(chan struct{})
var finished sync.WaitGroup
for index := 0; index < clients; index++ {
finished.Add(1)
go func() {
defer finished.Done()
<-start
recorder := env.get(thumbURL, nil)
codes[index], bodies[index] = recorder.Code, recorder.Body.Bytes()
}()
}
close(start)
finished.Wait()
for index := range bodies {
if codes[index] != 200 || !bytes.Equal(bodies[index], bodies[0]) {
t.Fatalf("client %d got %d", index, codes[index])
}
}
if env.renderer.renderCount() != 1 || env.storage.putCount() != 1 {
t.Errorf("renders = %d, puts = %d", env.renderer.renderCount(), env.storage.putCount())
}
})
t.Run("Different variants and different images are not serialised together", func(t *testing.T) {
env := servingEnv(t)
seedVariant(env.variants, "card", 200, 0, domain.FitInside, domain.FormatJPEG, 80)
env.seedSource("c3d4.png", encodePNG(t, variantSolidImage(900, 700, variantBlue)))
urls := []string{
"/static/uploadedImages/a1b2.png?v=thumb",
"/static/uploadedImages/a1b2.png?v=card",
"/static/uploadedImages/c3d4.png?v=thumb",
"/static/uploadedImages/c3d4.png?v=card",
}
rendezvous := newRendezvous(len(urls))
env.renderer.onRender = rendezvous.arrive
var finished sync.WaitGroup
codes := make([]int, len(urls))
for index, url := range urls {
finished.Add(1)
go func() {
defer finished.Done()
codes[index] = env.get(url, nil).Code
}()
}
finished.Wait()
if !rendezvous.allInFlight() {
t.Error("the renders were serialised")
}
for _, code := range codes {
if code != 200 {
t.Errorf("codes = %v", codes)
}
}
})
t.Run("A failed generation is not cached and is retried", func(t *testing.T) {
env := servingEnv(t)
env.renderer.failFirst = true
first := env.get(thumbURL, nil)
blobsAfterFailure, rowsAfterFailure := env.storage.putCount(), env.files.rowCount()
second := env.get(thumbURL, nil)
if first.Code != 500 || blobsAfterFailure != 0 || rowsAfterFailure != 0 || second.Code != 200 {
t.Errorf("statuses = %d then %d, puts = %d, rows = %d", first.Code, second.Code, blobsAfterFailure, rowsAfterFailure)
}
})
}
type rendezvous struct {
expected int
mutex sync.Mutex
arrived int
all chan struct{}
timedOut bool
}
func newRendezvous(expected int) *rendezvous {
return &rendezvous{expected: expected, all: make(chan struct{})}
}
func (r *rendezvous) arrive() {
r.mutex.Lock()
r.arrived++
if r.arrived == r.expected {
close(r.all)
}
r.mutex.Unlock()
select {
case <-r.all:
case <-time.After(10 * time.Second):
r.mutex.Lock()
r.timedOut = true
r.mutex.Unlock()
}
}
func (r *rendezvous) allInFlight() bool {
r.mutex.Lock()
defer r.mutex.Unlock()
return r.arrived == r.expected && !r.timedOut
}
func TestImageVariantsRequestContract(t *testing.T) {
rows := []struct {
name, url, code, message, detail string
}{
{"unknown name", "/static/uploadedImages/a1b2.png?v=banner", "unknown_variant", unknownMessage, "banner"},
{"uppercase name", "/static/uploadedImages/a1b2.png?v=Thumb", "unknown_variant", unknownMessage, "Thumb"},
{"empty name", "/static/uploadedImages/a1b2.png?v=", "unknown_variant", unknownMessage, ""},
{"disabled variant", "/static/uploadedImages/a1b2.png?v=hero", "unknown_variant", unknownMessage, "hero"},
{"width", "/static/uploadedImages/a1b2.png?width=400", "unsupported_parameter", unsupportedText, "width"},
{"height", "/static/uploadedImages/a1b2.png?height=300", "unsupported_parameter", unsupportedText, "height"},
{"format", "/static/uploadedImages/a1b2.png?format=webp", "unsupported_parameter", unsupportedText, "format"},
{"quality", "/static/uploadedImages/a1b2.png?quality=50", "unsupported_parameter", unsupportedText, "quality"},
{"variant and width", "/static/uploadedImages/a1b2.png?v=thumb&width=400", "unsupported_parameter", unsupportedText, "width"},
{"unknown variant and quality", "/static/uploadedImages/a1b2.png?v=banner&quality=50", "unsupported_parameter", unsupportedText, "quality"},
{"parameter before missing source", "/static/uploadedImages/missing.png?width=400", "unsupported_parameter", unsupportedText, "width"},
{"unknown variant before missing source", "/static/uploadedImages/missing.png?v=banner", "unknown_variant", unknownMessage, "banner"},
}
for _, row := range rows {
t.Run("A request outside the contract answers 400 with the JSON error body: "+row.name, func(t *testing.T) {
env := servingEnv(t)
hero := seedVariant(env.variants, "hero", 100, 0, domain.FitCover, domain.FormatJPEG, 80)
hero.Enabled = false
env.variants.variants["hero"] = hero
recorder := env.get(row.url, nil)
assertRequestError(t, recorder, row.code, row.message, row.detail)
if env.renderer.renderCount() != 0 || env.storage.putCount() != 0 {
t.Error("something was generated")
}
})
}
t.Run("A disabled variant serves again once enabled, from its kept cache", func(t *testing.T) {
env := servingEnv(t)
env.get(thumbURL, nil)
env.sendAsAdmin(http.MethodPatch, "/v1/image-variants/thumb", `{"enabled": false}`)
disabled := env.get(thumbURL, nil)
env.sendAsAdmin(http.MethodPatch, "/v1/image-variants/thumb", `{"enabled": true}`)
enabled := env.get(thumbURL, nil)
assertRequestError(t, disabled, "unknown_variant", unknownMessage, "thumb")
if enabled.Code != 200 || env.storage.putCount() != 1 {
t.Errorf("response = %d, puts = %d", enabled.Code, env.storage.putCount())
}
})
t.Run("A deleted variant answers 400", func(t *testing.T) {
env := servingEnv(t)
env.get(thumbURL, nil)
env.sendAsAdmin(http.MethodDelete, "/v1/image-variants/thumb", "")
recorder := env.get(thumbURL, nil)
assertRequestError(t, recorder, "unknown_variant", unknownMessage, "thumb")
})
}
func TestImageVariantsMissingAndUnreadableSources(t *testing.T) {
urls := []string{
"/static/uploadedImages/missing.png?v=thumb",
"/static/uploadedImages/../secret.png?v=thumb",
"/static/uploadedImages/nested/a1b2.png?v=thumb",
}
for _, url := range urls {
t.Run("A missing source answers 404: "+url, func(t *testing.T) {
env := servingEnv(t)
recorder := env.get(url, nil)
if recorder.Code != 404 || env.renderer.renderCount() != 0 || env.storage.putCount() != 0 {
t.Errorf("response = %d", recorder.Code)
}
})
}
t.Run("A source that cannot be decoded answers 500 and caches nothing", func(t *testing.T) {
env := servingEnv(t)
env.seedSource("bad.png", []byte("these bytes are not an image"))
recorder := env.get("/static/uploadedImages/bad.png?v=thumb", nil)
if recorder.Code != 500 || env.storage.putCount() != 0 || env.files.rowCount() != 0 {
t.Errorf("response = %d", recorder.Code)
}
})
}
func TestImageVariantsSourceCap(t *testing.T) {
for _, row := range []struct {
size int
status int
}{{1000, 200}, {1001, 413}} {
t.Run("A source beyond IMAGE_VARIANT_MAX_SOURCE_BYTES answers 413: "+http.StatusText(row.status), func(t *testing.T) {
env := newVariantEnvWith(t, newCountingRenderer(), 1000)
env.seedThumb()
env.seedSource("big.png", padSizedPNG(t, row.size))
recorder := env.get("/static/uploadedImages/big.png?v=thumb", nil)
if recorder.Code != row.status {
t.Errorf("status = %d", recorder.Code)
}
})
}
t.Run("The 413 body is plain text and nothing is generated", func(t *testing.T) {
env := newVariantEnvWith(t, newCountingRenderer(), 1000)
env.seedThumb()
env.seedSource("big.png", padSizedPNG(t, 1001))
recorder := env.get("/static/uploadedImages/big.png?v=thumb", nil)
assertStatusAndBody(t, recorder, 413, "The source image is too large to generate this variant.")
if env.renderer.renderCount() != 0 || env.storage.putCount() != 0 {
t.Error("something was generated")
}
})
t.Run("The cap never affects the original", func(t *testing.T) {
env := newVariantEnvWith(t, newCountingRenderer(), 1000)
content := padSizedPNG(t, 1001)
env.seedSource("big.png", content)
recorder := env.get("/static/uploadedImages/big.png", nil)
if recorder.Code != 200 || !bytes.Equal(recorder.Body.Bytes(), content) {
t.Errorf("status = %d", recorder.Code)
}
})
}
func TestImageVariantsAsIsSources(t *testing.T) {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"></svg>`
t.Run("An SVG with a valid variant is served unchanged", func(t *testing.T) {
env := servingEnv(t)
env.seedSource("e5f6.svg", []byte(svg))
recorder := env.get("/static/uploadedImages/e5f6.svg?v=thumb", nil)
if recorder.Code != 200 || recorder.Body.String() != svg || recorder.Header().Get("Content-Type") != "image/svg+xml" || recorder.Header().Get("Content-Security-Policy") != "default-src 'none'; style-src 'unsafe-inline'; sandbox" {
t.Errorf("response = %d %v", recorder.Code, recorder.Header())
}
if env.renderer.renderCount() != 0 || env.storage.putCount() != 0 || env.files.rowCount() != 0 {
t.Error("something was generated")
}
})
t.Run("An SVG with an unknown variant is still a 400", func(t *testing.T) {
env := servingEnv(t)
env.seedSource("e5f6.svg", []byte(svg))
recorder := env.get("/static/uploadedImages/e5f6.svg?v=banner", nil)
assertRequestError(t, recorder, "unknown_variant", unknownMessage, "banner")
})
t.Run("An SVG larger than the cap is still served", func(t *testing.T) {
env := newVariantEnvWith(t, newCountingRenderer(), 10)
env.seedThumb()
env.seedSource("e5f6.svg", []byte(svg))
recorder := env.get("/static/uploadedImages/e5f6.svg?v=thumb", nil)
if recorder.Code != 200 || recorder.Body.String() != svg {
t.Errorf("response = %d", recorder.Code)
}
})
t.Run("An animated GIF is served unchanged", func(t *testing.T) {
env := servingEnv(t)
animation := animatedGIF(t)
env.seedSource("anim.gif", animation)
recorder := env.get("/static/uploadedImages/anim.gif?v=thumb", nil)
if recorder.Code != 200 || !bytes.Equal(recorder.Body.Bytes(), animation) || recorder.Header().Get("Content-Type") != "image/gif" || env.files.rowCount() != 0 || env.renderer.renderCount() != 0 {
t.Errorf("response = %d %q", recorder.Code, recorder.Header().Get("Content-Type"))
}
})
t.Run("A single-frame GIF is treated like any raster source", func(t *testing.T) {
env := servingEnv(t)
env.seedSource("still.gif", stillGIF(t))
recorder := env.get("/static/uploadedImages/still.gif?v=thumb", nil)
if recorder.Code != 200 || recorder.Header().Get("Content-Type") != "image/jpeg" || env.renderer.renderCount() != 1 {
t.Errorf("response = %d %q", recorder.Code, recorder.Header().Get("Content-Type"))
}
})
t.Run("A stored GIF is served with the image/gif content type", func(t *testing.T) {
env := servingEnv(t)
env.seedSource("anim.gif", animatedGIF(t))
recorder := env.get("/static/uploadedImages/anim.gif", nil)
if recorder.Header().Get("Content-Type") != "image/gif" {
t.Errorf("content type = %q", recorder.Header().Get("Content-Type"))
}
})
}
func TestImageVariantsServeFormats(t *testing.T) {
t.Run("A webp variant is served as image/webp with the right dimensions", func(t *testing.T) {
env := servingEnv(t)
seedVariant(env.variants, "wp", 200, 0, domain.FitCover, domain.FormatWebP, 70)
recorder := env.get("/static/uploadedImages/a1b2.png?v=wp", nil)
width, height := webpSize(t, recorder.Body.Bytes())
if recorder.Header().Get("Content-Type") != "image/webp" || width != 200 || height != 160 {
t.Errorf("response = %q %dx%d", recorder.Header().Get("Content-Type"), width, height)
}
})
t.Run("The content type follows the generated file, not the source", func(t *testing.T) {
env := servingEnv(t)
seedVariant(env.variants, "logo", 100, 0, domain.FitCover, domain.FormatPNG, 0)
recorder := env.get("/static/uploadedImages/a1b2.png?v=logo", nil)
if recorder.Header().Get("Content-Type") != "image/png" || !bytes.HasPrefix(recorder.Body.Bytes(), []byte("\x89PNG")) {
t.Errorf("content type = %q", recorder.Header().Get("Content-Type"))
}
})
}
+378
View File
@@ -0,0 +1,378 @@
package test
import (
domain "RenewCMS/internal/domain/image"
"RenewCMS/internal/infrastructure/imaging"
"RenewCMS/internal/infrastructure/useCases"
"bytes"
"errors"
"io"
"sort"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
)
var errVariantStorageFailure = errors.New("variant storage failure")
var variantStorageExtensions = map[string]string{
"image/png": ".png",
"image/jpeg": ".jpeg",
"image/webp": ".webp",
}
type mockVariantRepo struct {
mutex sync.Mutex
variants map[string]domain.Variant
failGet error
calls int
}
var _ domain.VariantRepository = &mockVariantRepo{}
func newMockVariantRepo() *mockVariantRepo {
return &mockVariantRepo{variants: map[string]domain.Variant{}}
}
func (m *mockVariantRepo) List() ([]domain.Variant, error) {
m.mutex.Lock()
defer m.mutex.Unlock()
m.calls++
listed := make([]domain.Variant, 0, len(m.variants))
for _, variant := range m.variants {
listed = append(listed, variant)
}
sort.Slice(listed, func(i, j int) bool { return listed[i].Name < listed[j].Name })
return listed, nil
}
func (m *mockVariantRepo) Get(name string) (domain.Variant, error) {
m.mutex.Lock()
defer m.mutex.Unlock()
m.calls++
if m.failGet != nil {
return domain.Variant{}, m.failGet
}
variant, found := m.variants[name]
if !found {
return domain.Variant{}, domain.ErrVariantNotFound
}
return variant, nil
}
func (m *mockVariantRepo) Create(variant domain.Variant) (domain.Variant, error) {
m.mutex.Lock()
defer m.mutex.Unlock()
m.calls++
if _, found := m.variants[variant.Name]; found {
return domain.Variant{}, domain.ErrVariantExists
}
m.variants[variant.Name] = variant
return variant, nil
}
func (m *mockVariantRepo) Update(variant domain.Variant) (domain.Variant, error) {
m.mutex.Lock()
defer m.mutex.Unlock()
m.calls++
if _, found := m.variants[variant.Name]; !found {
return domain.Variant{}, domain.ErrVariantNotFound
}
m.variants[variant.Name] = variant
return variant, nil
}
func (m *mockVariantRepo) Delete(name string) error {
m.mutex.Lock()
defer m.mutex.Unlock()
m.calls++
if _, found := m.variants[name]; !found {
return domain.ErrVariantNotFound
}
delete(m.variants, name)
return nil
}
func (m *mockVariantRepo) stored(name string) domain.Variant {
m.mutex.Lock()
defer m.mutex.Unlock()
return m.variants[name]
}
func (m *mockVariantRepo) count() int {
m.mutex.Lock()
defer m.mutex.Unlock()
return len(m.variants)
}
type mockVariantFileRepo struct {
mutex sync.Mutex
rows map[uint32]domain.VariantFile
nextID uint32
deleted []uint32
saves int
calls int
}
var _ domain.VariantFileRepository = &mockVariantFileRepo{}
func newMockVariantFileRepo() *mockVariantFileRepo {
return &mockVariantFileRepo{rows: map[uint32]domain.VariantFile{}}
}
func (m *mockVariantFileRepo) Find(variantName string, sourceName string) (domain.VariantFile, bool, error) {
m.mutex.Lock()
defer m.mutex.Unlock()
m.calls++
for _, row := range m.rows {
if row.VariantName == variantName && row.SourceName == sourceName {
return row, true, nil
}
}
return domain.VariantFile{}, false, nil
}
func (m *mockVariantFileRepo) Save(file domain.VariantFile) (domain.VariantFile, error) {
m.mutex.Lock()
defer m.mutex.Unlock()
m.calls++
m.saves++
for id, row := range m.rows {
if row.VariantName == file.VariantName && row.SourceName == file.SourceName {
file.ID = id
m.rows[id] = file
return file, nil
}
}
m.nextID++
file.ID = m.nextID
m.rows[file.ID] = file
return file, nil
}
func (m *mockVariantFileRepo) ListByVariant(variantName string) ([]domain.VariantFile, error) {
return m.filtered(func(row domain.VariantFile) bool { return row.VariantName == variantName }), nil
}
func (m *mockVariantFileRepo) ListBySource(sourceName string) ([]domain.VariantFile, error) {
return m.filtered(func(row domain.VariantFile) bool { return row.SourceName == sourceName }), nil
}
func (m *mockVariantFileRepo) filtered(keep func(domain.VariantFile) bool) []domain.VariantFile {
m.mutex.Lock()
defer m.mutex.Unlock()
m.calls++
kept := []domain.VariantFile{}
for _, row := range m.rows {
if keep(row) {
kept = append(kept, row)
}
}
sort.Slice(kept, func(i, j int) bool { return kept[i].ID < kept[j].ID })
return kept
}
func (m *mockVariantFileRepo) Delete(id uint32) error {
m.mutex.Lock()
defer m.mutex.Unlock()
m.calls++
m.deleted = append(m.deleted, id)
delete(m.rows, id)
return nil
}
func (m *mockVariantFileRepo) Totals() (map[string]domain.VariantFileTotals, error) {
m.mutex.Lock()
defer m.mutex.Unlock()
m.calls++
totals := map[string]domain.VariantFileTotals{}
for _, row := range m.rows {
current := totals[row.VariantName]
totals[row.VariantName] = domain.VariantFileTotals{Files: current.Files + 1, Bytes: current.Bytes + row.Size}
}
return totals, nil
}
func (m *mockVariantFileRepo) rowsOf(variantName string) []domain.VariantFile {
rows, _ := m.ListByVariant(variantName)
return rows
}
func (m *mockVariantFileRepo) rowCount() int {
m.mutex.Lock()
defer m.mutex.Unlock()
return len(m.rows)
}
type mockVariantStorage struct {
mutex sync.Mutex
blobs map[string][]byte
puts int
opens int
openCounts map[string]int
nextName int
deleted []string
failDelete map[string]error
}
var _ domain.Storage = &mockVariantStorage{}
func newMockVariantStorage() *mockVariantStorage {
return &mockVariantStorage{blobs: map[string][]byte{}, failDelete: map[string]error{}, openCounts: map[string]int{}}
}
func (m *mockVariantStorage) Put(contentType string, content io.Reader) (string, error) {
data, err := io.ReadAll(content)
if err != nil {
return "", err
}
m.mutex.Lock()
defer m.mutex.Unlock()
m.puts++
m.nextName++
name := "gen-" + strconv.Itoa(m.nextName) + variantStorageExtensions[contentType]
m.blobs[name] = data
return name, nil
}
func (m *mockVariantStorage) Open(storedName string) (domain.Blob, error) {
m.mutex.Lock()
defer m.mutex.Unlock()
m.opens++
m.openCounts[storedName]++
data, found := m.blobs[storedName]
if !found || strings.Contains(storedName, "/") {
return domain.Blob{}, domain.ErrBlobNotFound
}
return domain.Blob{Content: io.NopCloser(bytes.NewReader(data)), Size: int64(len(data)), ModTime: time.Date(2026, 10, 8, 9, 0, 0, 0, time.UTC)}, nil
}
func (m *mockVariantStorage) Delete(storedName string) error {
m.mutex.Lock()
defer m.mutex.Unlock()
m.deleted = append(m.deleted, storedName)
if err := m.failDelete[storedName]; err != nil {
return err
}
delete(m.blobs, storedName)
return nil
}
func (m *mockVariantStorage) seed(storedName string, content []byte) {
m.mutex.Lock()
defer m.mutex.Unlock()
m.blobs[storedName] = content
}
func (m *mockVariantStorage) has(storedName string) bool {
m.mutex.Lock()
defer m.mutex.Unlock()
_, found := m.blobs[storedName]
return found
}
func (m *mockVariantStorage) putCount() int {
m.mutex.Lock()
defer m.mutex.Unlock()
return m.puts
}
func (m *mockVariantStorage) deletedNames() []string {
m.mutex.Lock()
defer m.mutex.Unlock()
return append([]string(nil), m.deleted...)
}
type countingRenderer struct {
inner domain.VariantRenderer
calls atomic.Int32
failFirst bool
onRender func()
}
var _ domain.VariantRenderer = &countingRenderer{}
func newCountingRenderer() *countingRenderer {
return &countingRenderer{inner: imaging.NewVariantRenderer()}
}
func newCountingRendererWithoutWebP() *countingRenderer {
return &countingRenderer{inner: imaging.NewVariantRendererEncoding(domain.FormatOriginal, domain.FormatJPEG, domain.FormatPNG)}
}
func (c *countingRenderer) Render(source io.Reader, settings domain.VariantSettings) (domain.RenderedVariant, error) {
call := c.calls.Add(1)
if c.onRender != nil {
c.onRender()
}
if c.failFirst && call == 1 {
return domain.RenderedVariant{}, errors.New("render failure")
}
return c.inner.Render(source, settings)
}
func (c *countingRenderer) DeclaredSize(source io.Reader) (int, int, error) {
return c.inner.DeclaredSize(source)
}
func defaultServeLimits(maxSourceBytes int64) useCases.ServeVariantLimits {
return useCases.ServeVariantLimits{MaxSourceBytes: maxSourceBytes, MaxSourcePixels: variantDefaultPixels, MaxConcurrentRenders: variantDefaultRenders}
}
func (c *countingRenderer) EncodableFormats() []domain.VariantFormat {
return c.inner.EncodableFormats()
}
func (c *countingRenderer) renderCount() int {
return int(c.calls.Load())
}
func seedVariant(repo *mockVariantRepo, name string, width int, height int, fit domain.VariantFit, format domain.VariantFormat, quality int) domain.Variant {
settings := domain.VariantSettings{Fit: fit, Format: format}
if width > 0 {
settings.Width = &width
}
if height > 0 {
settings.Height = &height
}
if format != domain.FormatPNG {
settings.Quality = &quality
}
stamp := time.Date(2026, 10, 7, 8, 0, 0, 0, time.UTC)
variant, err := domain.NewVariant(name, settings, false, true, stamp)
if err != nil {
panic(err)
}
repo.variants[name] = variant
return variant
}
func seedVariantFiles(files *mockVariantFileRepo, storage *mockVariantStorage, variantName string, sourceName string, count int, size int64) []domain.VariantFile {
seeded := []domain.VariantFile{}
for index := 0; index < count; index++ {
cached := "seed-" + variantName + "-" + sourceName + "-" + strconv.Itoa(index) + ".jpeg"
storage.seed(cached, []byte("generated"))
saved, _ := files.Save(domain.VariantFile{
VariantName: variantName,
SourceName: sourceName + "-" + strconv.Itoa(index),
CachedName: cached,
ContentType: "image/jpeg",
Size: size,
Fingerprint: "seeded",
})
seeded = append(seeded, saved)
}
return seeded
}
func newInertServeVariantUseCase(storage domain.Storage) *useCases.ServeVariantUseCase {
return useCases.NewServeVariantUseCase(newMockVariantRepo(), newMockVariantFileRepo(), newMockImageRepo(), storage, newCountingRenderer(), defaultServeLimits(variantDefaultCap))
}
func (m *mockVariantStorage) openCountOf(storedName string) int {
m.mutex.Lock()
defer m.mutex.Unlock()
return m.openCounts[storedName]
}
+1 -1
View File
@@ -60,7 +60,7 @@ func TestMediaMergeRewritesArticlesInIdOrder(t *testing.T) {
env.seedArticle(id, "Article", true, env.urlOf(3))
}
recorder := &mediaOrderRecordingArticleRepo{mockArticleRepo: env.articles}
uc := useCases.NewMergeMediaUseCase(env.images, recorder, env.storage, discardRecorder)
uc := useCases.NewMergeMediaUseCase(env.images, recorder, env.storage, newMockVariantFileRepo(), discardRecorder)
if _, err := uc.Merge(testCtx, 2, []uint32{3}); err != nil {
t.Fatalf("unexpected error: %v", err)
+2 -2
View File
@@ -60,7 +60,7 @@ func newMediaEnv(t *testing.T) *mediaEnv {
env.users.users[mediaUnverifiedUserId] = user.User{ID: 3, Username: "pending", IsVerified: false}
env.users.users[mediaStaleUserId] = user.User{ID: 4, Username: "stale", IsVerified: true, TokenVersion: 1}
merge := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage, discardRecorder)
merge := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage, newMockVariantFileRepo(), discardRecorder)
now := func() time.Time { return env.clock }
container := &api.UseCases{
Token: env.token,
@@ -73,7 +73,7 @@ func newMediaEnv(t *testing.T) *mediaEnv {
CreateImageUseCase: useCases.NewCreateImageUseCase(env.images, env.storage, discardRecorder),
ListMediaUseCase: useCases.NewListMediaUseCase(env.images, env.articles, env.users),
RenameMediaUseCase: useCases.NewRenameMediaUseCase(env.images, discardRecorder),
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(env.images, env.storage, discardRecorder),
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(env.images, env.storage, newMockVariantFileRepo(), discardRecorder),
MergeMediaUseCase: merge,
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(env.images, env.articles),
GetPurgeSettingsUseCase: useCases.NewGetPurgeSettingsUseCase(env.purgeSettings),
+2 -2
View File
@@ -947,7 +947,7 @@ func TestMediaMerging(t *testing.T) {
t.Run("A duplicated id reaching the use case is refused before anything is read", func(t *testing.T) {
env := newEnv(t)
uc := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage, discardRecorder)
uc := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage, newMockVariantFileRepo(), discardRecorder)
_, err := uc.Merge(testCtx, 2, []uint32{3, 3})
@@ -1042,7 +1042,7 @@ func TestMediaSentinels(t *testing.T) {
env.seedImage(2, "b.png", 1, "image/png", 1, 1, hashB, 1)
env.seedImage(3, "c.png", 1, "image/png", 1, 1, "", 1)
env.seedImage(4, "d.png", 1, "image/png", 1, 1, "", 1)
uc := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage, discardRecorder)
uc := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage, newMockVariantFileRepo(), discardRecorder)
if _, err := uc.Merge(testCtx, 1, []uint32{9999}); !errors.Is(err, domainImage.ErrMergeCopyMissing) {
t.Errorf("expected ErrMergeCopyMissing, got %v", err)
+1 -1
View File
@@ -31,7 +31,7 @@ type schedulerEnv struct {
func newSchedulerEnv(t *testing.T) *schedulerEnv {
t.Helper()
env := newPurgeEnv(t)
merge := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage, discardRecorder)
merge := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage, newMockVariantFileRepo(), discardRecorder)
now := func() time.Time { return env.clock }
runPurge := useCases.NewRunPurgeUseCase(env.images, env.articles, merge, env.purgeSettings, now, log.New(env.logs, "", 0), discardRecorder)
return &schedulerEnv{
+3 -3
View File
@@ -32,7 +32,7 @@ func TestUpdatePurgeSettingsRejectsOutOfRangeIntervals(t *testing.T) {
func TestRunPurgeDistinguishesBusyFromConflict(t *testing.T) {
newRun := func(env *mediaEnv) *useCases.RunPurgeUseCase {
merge := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage, discardRecorder)
merge := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage, newMockVariantFileRepo(), discardRecorder)
return useCases.NewRunPurgeUseCase(env.images, env.articles, merge, env.purgeSettings, func() time.Time { return env.clock }, log.New(&bytes.Buffer{}, "", 0), discardRecorder)
}
@@ -148,7 +148,7 @@ func TestMergeMediaExposesTheRewrittenArticleIds(t *testing.T) {
env.useImages(1, 2)
env.useImages(2, 1, 2)
env.useImages(3, 1)
merge := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage, discardRecorder)
merge := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage, newMockVariantFileRepo(), discardRecorder)
result, err := merge.Merge(testCtx, 1, []uint32{2})
@@ -163,7 +163,7 @@ func TestMergeMediaExposesTheRewrittenArticleIds(t *testing.T) {
env.useImages(1, 2)
env.useImages(2, 2)
env.articles.failUpdateBodyFor = map[uint32]error{2: errRepositoryFailure}
merge := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage, discardRecorder)
merge := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage, newMockVariantFileRepo(), discardRecorder)
result, err := merge.Merge(testCtx, 1, []uint32{2})
+4 -4
View File
@@ -76,7 +76,7 @@ func TestDeleteMediaRemovesTheBlobAndTheRow(t *testing.T) {
repo, store := newMockImageRepo(), newMockImageStorage()
created := uploadPng(t, useCases.NewCreateImageUseCase(repo, store, discardRecorder))
if err := useCases.NewDeleteMediaUseCase(repo, store, discardRecorder).Delete(testCtx, created.ID); err != nil {
if err := useCases.NewDeleteMediaUseCase(repo, store, newMockVariantFileRepo(), discardRecorder).Delete(testCtx, created.ID); err != nil {
t.Fatalf("Delete failed: %v", err)
}
if len(store.blobs) != 0 || len(repo.images) != 0 {
@@ -87,7 +87,7 @@ func TestDeleteMediaRemovesTheBlobAndTheRow(t *testing.T) {
t.Run("An unknown id is ErrImageNotFound and touches no blob", func(t *testing.T) {
repo, store := newMockImageRepo(), newMockImageStorage()
err := useCases.NewDeleteMediaUseCase(repo, store, discardRecorder).Delete(testCtx, 7)
err := useCases.NewDeleteMediaUseCase(repo, store, newMockVariantFileRepo(), discardRecorder).Delete(testCtx, 7)
if !errors.Is(err, domainImage.ErrImageNotFound) || len(store.deleted) != 0 {
t.Errorf("got %v, deleted %v", err, store.deleted)
@@ -99,7 +99,7 @@ func TestDeleteMediaRemovesTheBlobAndTheRow(t *testing.T) {
created := uploadPng(t, useCases.NewCreateImageUseCase(repo, store, discardRecorder))
store.failDelete = errRepositoryFailure
err := useCases.NewDeleteMediaUseCase(repo, store, discardRecorder).Delete(testCtx, created.ID)
err := useCases.NewDeleteMediaUseCase(repo, store, newMockVariantFileRepo(), discardRecorder).Delete(testCtx, created.ID)
if !errors.Is(err, errRepositoryFailure) || len(repo.images) != 1 || len(repo.deleteCalls) != 0 {
t.Errorf("got %v, rows=%d deleteCalls=%v", err, len(repo.images), repo.deleteCalls)
@@ -112,7 +112,7 @@ func TestMergeMediaRemovesTheMergedBlobs(t *testing.T) {
create := useCases.NewCreateImageUseCase(repo, store, discardRecorder)
kept, copyOne, copyTwo := uploadPng(t, create), uploadPng(t, create), uploadPng(t, create)
result, err := useCases.NewMergeMediaUseCase(repo, articles, store, discardRecorder).Merge(testCtx, kept.ID, []uint32{copyOne.ID, copyTwo.ID})
result, err := useCases.NewMergeMediaUseCase(repo, articles, store, newMockVariantFileRepo(), discardRecorder).Merge(testCtx, kept.ID, []uint32{copyOne.ID, copyTwo.ID})
if err != nil || result.Merged != 2 {
t.Fatalf("merge returned %+v, %v", result, err)
+23
View File
@@ -0,0 +1,23 @@
package test
import (
"RenewCMS/internal/infrastructure/persistence/database"
"testing"
)
func TestMigrateTwiceKeepsSingleImagePathIndex(t *testing.T) {
db := openActivityDatabase(t)
if err := database.Migrate(db); err != nil {
t.Fatalf("second migrate failed: %v", err)
}
if err := database.Migrate(db); err != nil {
t.Fatalf("third migrate failed: %v", err)
}
var count int64
if err := db.Raw("SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = ?", "idx_images_path").Scan(&count).Error; err != nil {
t.Fatalf("index lookup failed: %v", err)
}
if count != 1 {
t.Fatalf("expected idx_images_path exactly once, got %d", count)
}
}
+2 -2
View File
@@ -143,8 +143,8 @@ func newRuntimeSettingsContainer(t *testing.T) *api.UseCases {
CreateImageUseCase: useCases.NewCreateImageUseCase(imageRepo, newMockImageStorage(), discardRecorder),
ListMediaUseCase: useCases.NewListMediaUseCase(imageRepo, articleRepo, userRepo),
RenameMediaUseCase: useCases.NewRenameMediaUseCase(imageRepo, discardRecorder),
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(imageRepo, newMockImageStorage(), discardRecorder),
MergeMediaUseCase: useCases.NewMergeMediaUseCase(imageRepo, articleRepo, newMockImageStorage(), discardRecorder),
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(imageRepo, newMockImageStorage(), newMockVariantFileRepo(), discardRecorder),
MergeMediaUseCase: useCases.NewMergeMediaUseCase(imageRepo, articleRepo, newMockImageStorage(), newMockVariantFileRepo(), discardRecorder),
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(imageRepo, articleRepo),
}
}
+1 -1
View File
@@ -24,7 +24,7 @@ const s3ObjectBody = "0123456789"
func newS3StorageRouter(store domainImage.Storage) *chi.Mux {
router := chi.NewRouter()
router.Mount("/static/uploadedImages", staticimages.NewHandler(useCases.NewOpenMediaUseCase(store)))
router.Mount("/static/uploadedImages", staticimages.NewHandler(useCases.NewOpenMediaUseCase(store), newInertServeVariantUseCase(store)))
return router
}
+1 -1
View File
@@ -20,7 +20,7 @@ const spaIndexMarker = "SPA-INDEX-MARKER"
func newServeUploadedImagesRouter(uploadDir string) *chi.Mux {
r := chi.NewRouter()
r.Use(chimiddleware.GetHead)
r.Mount("/static/uploadedImages", staticimages.NewHandler(useCases.NewOpenMediaUseCase(storage.NewLocalStorage(uploadDir))))
r.Mount("/static/uploadedImages", staticimages.NewHandler(useCases.NewOpenMediaUseCase(storage.NewLocalStorage(uploadDir)), newInertServeVariantUseCase(storage.NewLocalStorage(uploadDir))))
r.Mount("/", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(spaIndexMarker))
+10 -1
View File
@@ -169,7 +169,7 @@ func TestSQLiteSchemaIsUntouchedByTheModelChanges(t *testing.T) {
after := takeSqliteSnapshot(t, db)
for name := range after.schema {
if strings.HasPrefix(name, "audit_") || strings.HasPrefix(name, "idx_audit_") {
if isNewTableObject(name) {
delete(after.schema, name)
}
}
@@ -181,3 +181,12 @@ func TestSQLiteSchemaIsUntouchedByTheModelChanges(t *testing.T) {
}
})
}
func isNewTableObject(name string) bool {
for _, prefix := range []string{"audit_", "idx_audit_", "image_variant", "idx_image_variant", "idx_images_path"} {
if strings.HasPrefix(name, prefix) {
return true
}
}
return false
}
+1 -1
View File
@@ -43,7 +43,7 @@ func (failingStorage) Open(string) (domainImage.Blob, error) {
func serveFrom(t *testing.T, store domainImage.Storage, method, target string) *httptest.ResponseRecorder {
t.Helper()
handler := staticimages.NewHandler(useCases.NewOpenMediaUseCase(store))
handler := staticimages.NewHandler(useCases.NewOpenMediaUseCase(store), newInertServeVariantUseCase(store))
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequest(method, target, nil))
return rec
+2 -2
View File
@@ -385,7 +385,7 @@ func TestDeleteImageRequiresExistence(t *testing.T) {
t.Run("id_1", func(t *testing.T) {
repo := newMockImageRepo()
repo.images[1] = imageDomain.Image{ID: 1}
uc := useCases.NewDeleteMediaUseCase(repo, newMockImageStorage(), discardRecorder)
uc := useCases.NewDeleteMediaUseCase(repo, newMockImageStorage(), newMockVariantFileRepo(), discardRecorder)
err := uc.Delete(testCtx, 1)
if err != nil {
@@ -399,7 +399,7 @@ func TestDeleteImageRequiresExistence(t *testing.T) {
t.Run("id_9", func(t *testing.T) {
repo := newMockImageRepo()
repo.images[1] = imageDomain.Image{ID: 1}
uc := useCases.NewDeleteMediaUseCase(repo, newMockImageStorage(), discardRecorder)
uc := useCases.NewDeleteMediaUseCase(repo, newMockImageStorage(), newMockVariantFileRepo(), discardRecorder)
err := uc.Delete(testCtx, 9)
if !errors.Is(err, imageDomain.ErrImageNotFound) {