feat: image variants engine
This commit is contained in:
1 parent
f0845de643
commit
f1d56ef50e
80 files changed
+7312
-84
No files matched your search
@@ -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{})
|
||||
|
||||
@@ -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
@@ -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{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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"}}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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))...)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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})
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user