feat: image variants per image

This commit is contained in:
fsylvain committed 2026-10-08 19:57:51 +02:00
1 parent f567866161
commit 951fef18ba
92 files changed
+8695 -413

No files matched your search

+37 -36
View File
@@ -104,42 +104,43 @@ func newAPIKeyEnvRecording(t *testing.T, recorder audit.Recorder) *apiKeyEnv {
purgeSettings := newMockPurgeSettingsRepo()
restartJobs := &fakeJobStore{}
env.container = &api.UseCases{
Token: env.token,
CreateArticleUseCase: useCases.NewCreateArticleUseCase(env.articles, renderer, recorder),
GetArticleUseCase: useCases.NewGetArticleUseCase(env.articles, renderer),
ListArticlesUseCase: useCases.NewListArticlesUseCase(env.articles, renderer),
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(env.articles, renderer, recorder),
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(env.articles, recorder),
GetUserUseCase: useCases.NewGetUserUseCase(env.users),
CreateUserUseCase: useCases.NewCreateUserUseCase(env.users, hasher, mail, "https://cms.example.com", recorder),
AuthenticateUserUseCase: useCases.NewAuthenticateUserUseCase(env.users, hasher, recorder),
VerifyUserUseCase: useCases.NewVerifyUserUseCase(env.users, hasher, recorder),
CancelRegistrationUseCase: useCases.NewCancelRegistrationUseCase(env.users),
RequestPasswordResetUseCase: useCases.NewRequestPasswordResetUseCase(env.users, hasher, mail, "https://cms.example.com", recorder),
ResetPasswordUseCase: useCases.NewResetPasswordUseCase(env.users, hasher, env.keys, clock, recorder),
UpdateUserProfileUseCase: useCases.NewUpdateUserProfileUseCase(env.users, recorder),
CreateInviteUseCase: useCases.NewCreateInviteUseCase(env.users, env.invites, hasher, mail, "https://cms.example.com", recorder),
CancelInviteUseCase: useCases.NewCancelInviteUseCase(env.invites, recorder),
RenewInviteUseCase: useCases.NewRenewInviteUseCase(env.users, env.invites, hasher, mail, "https://cms.example.com", recorder),
ListUsersAndInvitesUseCase: useCases.NewListUsersAndInvitesUseCase(env.users, env.invites),
GetInviteUseCase: useCases.NewGetInviteUseCase(env.invites, hasher),
AcceptInviteUseCase: useCases.NewAcceptInviteUseCase(env.users, env.invites, hasher, recorder),
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(), 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),
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(env.images, env.articles),
GetSettingsUseCase: useCases.NewGetSettingsUseCase(apiKeyRunningVersion),
GetActivityRetentionUseCase: useCases.NewGetActivityRetentionUseCase(newActivitySettingsFake(), newSizeEvents(t), time.Now),
GetUpdateStatusUseCase: newUpdateStatusUseCase(env.updates, &fakeJobStore{}, &fakeHelperDetector{}, apiKeyRunningVersion),
CheckForUpdateUseCase: useCases.NewCheckForUpdateUseCase(env.feed, env.updates, apiKeyRunningVersion, clock, recorder),
CreateAPIKeyUseCase: useCases.NewCreateAPIKeyUseCase(env.keys, env.generator, clock, recorder),
ListAPIKeysUseCase: useCases.NewListAPIKeysUseCase(env.keys, env.users, clock),
RevokeAPIKeyUseCase: useCases.NewRevokeAPIKeyUseCase(env.keys, env.users, clock, recorder),
AuthenticateAPIKeyUseCase: useCases.NewAuthenticateAPIKeyUseCase(env.keys, env.generator, env.users, clock),
Token: env.token,
CreateArticleUseCase: useCases.NewCreateArticleUseCase(env.articles, renderer, recorder),
GetArticleUseCase: useCases.NewGetArticleUseCase(env.articles, renderer),
ListArticlesUseCase: useCases.NewListArticlesUseCase(env.articles, renderer),
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(env.articles, renderer, recorder),
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(env.articles, recorder),
GetUserUseCase: useCases.NewGetUserUseCase(env.users),
CreateUserUseCase: useCases.NewCreateUserUseCase(env.users, hasher, mail, "https://cms.example.com", recorder),
AuthenticateUserUseCase: useCases.NewAuthenticateUserUseCase(env.users, hasher, recorder),
VerifyUserUseCase: useCases.NewVerifyUserUseCase(env.users, hasher, recorder),
CancelRegistrationUseCase: useCases.NewCancelRegistrationUseCase(env.users),
RequestPasswordResetUseCase: useCases.NewRequestPasswordResetUseCase(env.users, hasher, mail, "https://cms.example.com", recorder),
ResetPasswordUseCase: useCases.NewResetPasswordUseCase(env.users, hasher, env.keys, clock, recorder),
UpdateUserProfileUseCase: useCases.NewUpdateUserProfileUseCase(env.users, recorder),
CreateInviteUseCase: useCases.NewCreateInviteUseCase(env.users, env.invites, hasher, mail, "https://cms.example.com", recorder),
CancelInviteUseCase: useCases.NewCancelInviteUseCase(env.invites, recorder),
RenewInviteUseCase: useCases.NewRenewInviteUseCase(env.users, env.invites, hasher, mail, "https://cms.example.com", recorder),
ListUsersAndInvitesUseCase: useCases.NewListUsersAndInvitesUseCase(env.users, env.invites),
GetInviteUseCase: useCases.NewGetInviteUseCase(env.invites, hasher),
AcceptInviteUseCase: useCases.NewAcceptInviteUseCase(env.users, env.invites, hasher, recorder),
CreateImageUseCase: useCases.NewCreateImageUseCase(env.images, newMockImageStorage(), recorder),
GenerateVariantsOnUploadUseCase: newInertGenerateOnUploadUseCase(),
ListMediaUseCase: useCases.NewListMediaUseCase(env.images, env.articles, env.users, newMockVariantFileRepo()),
RenameMediaUseCase: useCases.NewRenameMediaUseCase(env.images, 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),
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(env.images, env.articles),
GetSettingsUseCase: useCases.NewGetSettingsUseCase(apiKeyRunningVersion),
GetActivityRetentionUseCase: useCases.NewGetActivityRetentionUseCase(newActivitySettingsFake(), newSizeEvents(t), time.Now),
GetUpdateStatusUseCase: newUpdateStatusUseCase(env.updates, &fakeJobStore{}, &fakeHelperDetector{}, apiKeyRunningVersion),
CheckForUpdateUseCase: useCases.NewCheckForUpdateUseCase(env.feed, env.updates, apiKeyRunningVersion, clock, recorder),
CreateAPIKeyUseCase: useCases.NewCreateAPIKeyUseCase(env.keys, env.generator, clock, recorder),
ListAPIKeysUseCase: useCases.NewListAPIKeysUseCase(env.keys, env.users, clock),
RevokeAPIKeyUseCase: useCases.NewRevokeAPIKeyUseCase(env.keys, env.users, clock, recorder),
AuthenticateAPIKeyUseCase: useCases.NewAuthenticateAPIKeyUseCase(env.keys, env.generator, env.users, clock),
}
router := chi.NewRouter()
router.Mount("/v1", api.InitBackendRoutes(env.container, env.settings))
+31 -2
View File
@@ -8,8 +8,11 @@ import (
domain "RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/config"
"RenewCMS/internal/infrastructure/hashing"
"RenewCMS/internal/infrastructure/useCases"
"bytes"
"encoding/json"
"log"
"net/http"
"net/http/httptest"
"strings"
@@ -42,6 +45,9 @@ type variantEnv struct {
storage *mockVariantStorage
renderer *countingRenderer
audit *activityHarness
articles *mockArticleRepo
keys *mockAPIKeyRepo
logs *bytes.Buffer
token *jwtauth.JWTAuth
now time.Time
admin http.Handler
@@ -70,6 +76,9 @@ func newVariantEnvLimits(t *testing.T, renderer *countingRenderer, limits useCas
storage: newMockVariantStorage(),
renderer: renderer,
audit: newActivityHarness(t),
articles: newMockArticleRepo(),
keys: newMockAPIKeyRepo(),
logs: &bytes.Buffer{},
token: jwtauth.New("HS256", []byte("test-secret"), nil),
now: time.Date(2026, time.October, 8, 9, 0, 0, 0, time.UTC),
}
@@ -94,22 +103,42 @@ func newVariantEnvLimits(t *testing.T, renderer *countingRenderer, limits useCas
func (e *variantEnv) container(limits useCases.ServeVariantLimits) *api.UseCases {
clock := func() time.Time { return e.now }
recorder := e.audit.recorder
serve := useCases.NewServeVariantUseCase(e.variants, e.files, e.images, e.storage, e.renderer, limits)
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),
MergeMediaUseCase: useCases.NewMergeMediaUseCase(e.images, e.articles, 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),
ServeVariantUseCase: serve,
CreateImageUseCase: useCases.NewCreateImageUseCase(e.images, e.storage, recorder),
ListMediaUseCase: useCases.NewListMediaUseCase(e.images, e.articles, e.users, e.files),
RenameMediaUseCase: useCases.NewRenameMediaUseCase(e.images, recorder),
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(e.images, e.articles),
ListImageVariantsUseCase: useCases.NewListImageVariantsUseCase(e.images, e.variants, e.files, e.renderer),
GenerateImageVariantUseCase: useCases.NewGenerateImageVariantUseCase(e.images, e.variants, e.files, e.renderer, serve),
GenerateAllImageVariantsUseCase: useCases.NewGenerateAllImageVariantsUseCase(e.images, e.variants, e.files, e.renderer, serve, recorder),
PurgeImageVariantUseCase: useCases.NewPurgeImageVariantUseCase(e.images, e.variants, e.files, e.storage, recorder),
GenerateVariantsOnUploadUseCase: useCases.NewGenerateVariantsOnUploadUseCase(e.variants, serve, log.New(e.logs, "", 0)),
AuthenticateAPIKeyUseCase: useCases.NewAuthenticateAPIKeyUseCase(e.keys, hashing.NewSHA256KeyGenerator(), e.users, clock),
ListActivityUseCase: useCases.NewListActivityUseCase(e.audit.probe, e.users, e.articles, e.images, e.keys, clock),
}
}
func (e *variantEnv) issueAPIKey() string {
generator := hashing.NewSHA256KeyGenerator()
raw, prefix, hashed := generator.Generate()
e.keys.Create(apikey.APIKey{UserID: variantOwnerId, Name: "ci", Prefix: prefix, HashedKey: hashed, CreatedAt: e.now})
return raw
}
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})
@@ -0,0 +1,176 @@
package test
import (
domain "RenewCMS/internal/domain/image"
"RenewCMS/internal/infrastructure/useCases"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
const uploadedFilename = "photo.png"
func (e *variantEnv) seedUploadVariants() {
seedVariant(e.variants, "thumb", 400, 300, domain.FitCover, domain.FormatJPEG, 80)
seedVariant(e.variants, "hero", 1200, 630, domain.FitCover, domain.FormatJPEG, 82)
seedVariant(e.variants, "og", 1200, 630, domain.FitCover, domain.FormatJPEG, 80)
seedVariant(e.variants, "card", 800, 0, domain.FitInside, domain.FormatJPEG, 80)
e.flagOnUpload("thumb", true)
e.flagOnUpload("hero", true)
e.flagOnUpload("og", true)
e.disable("og")
}
func (e *variantEnv) flagOnUpload(name string, flag bool) {
variant := e.variants.stored(name)
variant.GenerateOnUpload = flag
e.variants.variants[name] = variant
}
func (e *variantEnv) upload(filename string, contentType string, content []byte) *httptest.ResponseRecorder {
e.t.Helper()
request := newMultipartFileRequest(e.t, http.MethodPost, "/v1/media", filename, contentType, content)
request.AddCookie(e.cookie(variantOwnerId, 0))
recorder := httptest.NewRecorder()
e.admin.ServeHTTP(recorder, request)
return recorder
}
func storedNameOf(t *testing.T, recorder *httptest.ResponseRecorder) string {
t.Helper()
return strings.TrimPrefix(decodeBody(t, recorder)["url"].(string), domain.PublicPathPrefix)
}
func TestGenerateVariantsOnUpload(t *testing.T) {
t.Run("Flagged and enabled variants are generated before the response", func(t *testing.T) {
env := newVariantEnv(t)
env.seedUploadVariants()
recorder := env.upload(uploadedFilename, "image/png", solidPNG(t, 1000, 800))
stored := storedNameOf(t, recorder)
body := decodeBody(t, recorder)
thumb, hasThumb, _ := env.files.Find("thumb", stored)
hero, hasHero, _ := env.files.Find("hero", stored)
_, hasOg, _ := env.files.Find("og", stored)
_, hasCard, _ := env.files.Find("card", stored)
if recorder.Code != 201 || !hasThumb || !hasHero || hasOg || hasCard || body["variant_count"] != 2.0 || body["variant_size"] != float64(thumb.Size+hero.Size) {
t.Fatalf("response = %d %q", recorder.Code, recorder.Body.String())
}
if action, _ := env.lastEvent(); env.events() != 1 || action != "media.upload" {
t.Errorf("events = %d, last = %s", env.events(), action)
}
})
t.Run("A failing variant never fails the upload", func(t *testing.T) {
env := newVariantEnv(t)
env.seedUploadVariants()
heroFingerprint := env.variants.stored("hero").Settings().Fingerprint()
env.renderer.failFor = func(settings domain.VariantSettings) error {
if settings.Fingerprint() == heroFingerprint {
return errors.New("hero broke")
}
return nil
}
recorder := env.upload(uploadedFilename, "image/png", solidPNG(t, 1000, 800))
stored := storedNameOf(t, recorder)
_, hasThumb, _ := env.files.Find("thumb", stored)
_, hasHero, _ := env.files.Find("hero", stored)
if recorder.Code != 201 || !hasThumb || hasHero || decodeBody(t, recorder)["variant_count"] != 1.0 || len(env.images.images) != 1 {
t.Fatalf("response = %d %q", recorder.Code, recorder.Body.String())
}
if logged := env.logs.String(); !strings.Contains(logged, "variant=hero") || !strings.Contains(logged, "image=1") {
t.Errorf("log = %q", logged)
}
})
t.Run("A source beyond a cap still uploads", func(t *testing.T) {
env := newVariantEnvLimits(t, newCountingRenderer(), useCases.ServeVariantLimits{MaxSourceBytes: variantDefaultCap, MaxSourcePixels: 100, MaxConcurrentRenders: variantDefaultRenders})
env.seedUploadVariants()
recorder := env.upload(uploadedFilename, "image/png", solidPNG(t, 1000, 800))
if recorder.Code != 201 || decodeBody(t, recorder)["variant_count"] != 0.0 {
t.Errorf("response = %d %q", recorder.Code, recorder.Body.String())
}
})
t.Run("An SVG upload generates nothing", func(t *testing.T) {
env := newVariantEnv(t)
env.seedUploadVariants()
recorder := env.upload("logo.svg", "image/svg+xml", []byte(`<svg xmlns="http://www.w3.org/2000/svg"></svg>`))
if recorder.Code != 201 || env.renderer.renderCount() != 0 || decodeBody(t, recorder)["variant_count"] != 0.0 {
t.Errorf("response = %d %q, renders = %d", recorder.Code, recorder.Body.String(), env.renderer.renderCount())
}
})
t.Run("No flagged variant means no render", func(t *testing.T) {
env := newVariantEnv(t)
env.seedUploadVariants()
env.flagOnUpload("thumb", false)
env.flagOnUpload("hero", false)
recorder := env.upload(uploadedFilename, "image/png", solidPNG(t, 1000, 800))
if recorder.Code != 201 || env.renderer.renderCount() != 0 {
t.Errorf("status = %d, renders = %d", recorder.Code, env.renderer.renderCount())
}
})
t.Run("A refused upload generates nothing", func(t *testing.T) {
env := newVariantEnv(t)
env.seedUploadVariants()
recorder := env.upload("doc.pdf", "application/pdf", []byte("%PDF-1.4"))
if recorder.Code != 415 || env.renderer.renderCount() != 0 {
t.Errorf("status = %d, renders = %d", recorder.Code, env.renderer.renderCount())
}
})
t.Run("Changing the flag does not backfill existing images: creating a variant", func(t *testing.T) {
env := newVariantEnv(t)
env.seedGenerateBackground()
recorder := env.sendAsAdmin(http.MethodPost, "/v1/image-variants", `{"name": "wide", "width": 900, "generate_on_upload": true}`)
if recorder.Code != 201 || env.renderer.renderCount() != 0 || env.files.rowCount() != 0 {
t.Errorf("status = %d, renders = %d", recorder.Code, env.renderer.renderCount())
}
})
t.Run("Changing the flag does not backfill existing images: updating a variant", func(t *testing.T) {
env := newVariantEnv(t)
env.seedGenerateBackground()
recorder := env.sendAsAdmin(http.MethodPut, "/v1/image-variants/thumb", `{"width": 400, "height": 300, "format": "jpeg", "quality": 80, "generate_on_upload": true}`)
if recorder.Code != 200 || env.renderer.renderCount() != 0 || env.files.rowCount() != 0 {
t.Errorf("status = %d %q, renders = %d", recorder.Code, recorder.Body.String(), env.renderer.renderCount())
}
})
t.Run("Uploading the same bytes again generates files for the new image only", func(t *testing.T) {
env := newVariantEnv(t)
env.seedUploadVariants()
content := solidPNG(t, 1000, 800)
first := env.upload(uploadedFilename, "image/png", content)
firstStored := storedNameOf(t, first)
firstRow, _, _ := env.files.Find("thumb", firstStored)
second := env.upload(uploadedFilename, "image/png", content)
secondStored := storedNameOf(t, second)
secondRow, hasSecond, _ := env.files.Find("thumb", secondStored)
keptRow, _, _ := env.files.Find("thumb", firstStored)
if second.Code != 201 || secondStored == firstStored || !hasSecond || secondRow.CachedName == firstRow.CachedName || keptRow != firstRow {
t.Errorf("first = %q, second = %q", firstStored, secondStored)
}
})
}
+240
View File
@@ -0,0 +1,240 @@
package test
import (
domain "RenewCMS/internal/domain/image"
"errors"
"net/http"
"testing"
)
const (
tooLargeReason = "The source image is too large to generate this variant."
genericReason = "Something went wrong on our side. Please try again."
)
func (e *variantEnv) seedFiveEnabled() {
seedVariant(e.variants, "card", 800, 0, domain.FitInside, domain.FormatJPEG, 80)
seedVariant(e.variants, "hero", 1200, 630, domain.FitCover, domain.FormatJPEG, 82)
seedVariant(e.variants, "og", 1200, 630, domain.FitCover, domain.FormatJPEG, 80)
seedVariant(e.variants, "square", 600, 600, domain.FitCover, domain.FormatJPEG, 85)
seedVariant(e.variants, "thumb", 400, 300, domain.FitCover, domain.FormatJPEG, 80)
e.seedPerImage(1, "north-wall.jpg", "a1b2.png", "image/png", 1000, 800, solidPNG(e.t, 1000, 800))
}
func (e *variantEnv) failHero(cause error) {
heroFingerprint := e.variants.stored("hero").Settings().Fingerprint()
e.renderer.failFor = func(settings domain.VariantSettings) error {
if settings.Fingerprint() == heroFingerprint {
return cause
}
return nil
}
}
func assertImageTarget(t *testing.T, event map[string]any, id string, name string) {
t.Helper()
target := event["target"].(map[string]any)
if target["type"] != "image" || target["id"] != id || target["name"] != name || event["category"] != "media" {
t.Errorf("target = %v, category = %v", target, event["category"])
}
}
func TestPerImageVariantAudit(t *testing.T) {
t.Run("Generate all is recorded with the counts", func(t *testing.T) {
env := newVariantEnv(t)
env.seedFiveEnabled()
env.send(http.MethodPost, "/v1/media/1/variants", "", variantOwnerId)
action, event := env.lastEvent()
params := event["params"].(map[string]any)
causes, _ := params["causes"].([]any)
if env.events() != 1 || action != "media.variant_generate_all" || event["outcome"] != "success" || params["generated"] != 5.0 || params["failed"] != 0.0 || causes == nil || len(causes) != 0 {
t.Errorf("event = %v", event)
}
assertImageTarget(t, event, "1", "north-wall.jpg")
})
t.Run("A partial failure is a success that names its causes", func(t *testing.T) {
env := newVariantEnv(t)
env.seedFiveEnabled()
env.failHero(domain.ErrSourceTooLarge)
env.send(http.MethodPost, "/v1/media/1/variants", "", variantOwnerId)
_, event := env.lastEvent()
params := event["params"].(map[string]any)
causes := params["causes"].([]any)
if env.events() != 1 || event["outcome"] != "success" || params["generated"] != 4.0 || params["failed"] != 1.0 || len(causes) != 1 || causes[0] != "too_large" {
t.Errorf("event = %v", event)
}
})
t.Run("Every variant failing is a failed event with a plain reason", func(t *testing.T) {
env := newVariantEnv(t)
env.seedFiveEnabled()
env.renderer.failFor = func(domain.VariantSettings) error { return domain.ErrSourceTooLarge }
recorder := env.send(http.MethodPost, "/v1/media/1/variants", "", variantOwnerId)
body := decodeBody(t, recorder)
_, event := env.lastEvent()
if recorder.Code != 200 || body["generated"] != 0.0 || body["failed"] != 5.0 || env.events() != 1 || event["action"] != "media.variant_generate_all" || event["outcome"] != "failed" || event["failure_reason"] != tooLargeReason {
t.Errorf("response = %d %q, event = %v", recorder.Code, recorder.Body.String(), event)
}
})
t.Run("The failure reasons of the other causes are plain sentences", func(t *testing.T) {
causes := map[string]error{
"Variants aren't generated for this format.": domain.ErrVariantSourceUnsupported,
"The source image can't be read, so this variant can't be generated.": domain.ErrVariantSourceUnreadable,
genericReason: errors.New("boom"),
}
for reason, cause := range causes {
env := newVariantEnv(t)
env.seedFiveEnabled()
env.renderer.failFor = func(domain.VariantSettings) error { return cause }
env.send(http.MethodPost, "/v1/media/1/variants", "", variantOwnerId)
if _, event := env.lastEvent(); event["failure_reason"] != reason {
t.Errorf("reason for %v = %v, want %q", cause, event["failure_reason"], reason)
}
}
})
noEvent := []struct {
name string
prepare func(env *variantEnv)
method string
path string
}{
{"all five are already generated", func(env *variantEnv) {
env.seedFiveEnabled()
env.send(http.MethodPost, "/v1/media/1/variants", "", variantOwnerId)
}, http.MethodPost, "/v1/media/1/variants"},
{"no variant is enabled", func(env *variantEnv) { env.seedPerImage(1, "north-wall.jpg", "a1b2.png", "image/png", 10, 10, nil) }, http.MethodPost, "/v1/media/1/variants"},
{"an SVG", func(env *variantEnv) {
env.seedFiveEnabled()
env.seedPerImage(1, "logo.svg", "e5f6.svg", "image/svg+xml", 0, 0, nil)
}, http.MethodPost, "/v1/media/1/variants"},
{"a single generation", func(env *variantEnv) { env.seedFiveEnabled() }, http.MethodPost, "/v1/media/1/variants/thumb"},
{"an unknown image", func(env *variantEnv) { env.seedFiveEnabled() }, http.MethodPost, "/v1/media/9999/variants"},
{"a listing", func(env *variantEnv) { env.seedFiveEnabled() }, http.MethodGet, "/v1/media/1/variants"},
}
for _, row := range noEvent {
t.Run("Nothing was done, nothing is recorded: "+row.name, func(t *testing.T) {
env := newVariantEnv(t)
row.prepare(env)
before := env.events()
env.send(row.method, row.path, "", variantOwnerId)
if env.events() != before {
t.Errorf("events = %d, want %d", env.events(), before)
}
})
}
t.Run("Nothing was done, nothing is recorded: a public generation", func(t *testing.T) {
env := newVariantEnv(t)
env.seedFiveEnabled()
recorder := env.get("/static/uploadedImages/a1b2.png?v=thumb", nil)
if recorder.Code != 200 || env.events() != 0 {
t.Errorf("status = %d, events = %d", recorder.Code, env.events())
}
})
t.Run("Purging the files of one variant of an image is recorded", func(t *testing.T) {
env := newVariantEnv(t)
env.seedFiveEnabled()
env.seedCurrentFile("thumb", "a1b2.png", 18432)
env.send(http.MethodDelete, "/v1/media/1/variants/thumb", "", variantOwnerId)
action, event := env.lastEvent()
params := event["params"].(map[string]any)
if env.events() != 1 || action != "media.variant_purge_image" || event["outcome"] != "success" || params["purged_files"] != 1.0 || params["purged_bytes"] != 18432.0 {
t.Errorf("event = %v", event)
}
assertImageTarget(t, event, "1", "north-wall.jpg")
})
purgeNoEvent := []struct{ name, path string }{
{"nothing is generated", "/v1/media/1/variants/thumb"},
{"the variant is unknown", "/v1/media/1/variants/missing"},
{"the image is unknown", "/v1/media/9999/variants/thumb"},
}
for _, row := range purgeNoEvent {
t.Run("A purge that removes nothing, or finds nothing, is not recorded: "+row.name, func(t *testing.T) {
env := newVariantEnv(t)
env.seedFiveEnabled()
env.send(http.MethodDelete, row.path, "", variantOwnerId)
if env.events() != 0 {
t.Errorf("events = %d", env.events())
}
})
}
t.Run("A purge that fails on storage is recorded as failed", func(t *testing.T) {
env := newVariantEnv(t)
env.seedFiveEnabled()
env.seedCurrentFile("thumb", "a1b2.png", 18432)
env.storage.failDelete["cached-thumb-a1b2.png"] = errVariantStorageFailure
recorder := env.send(http.MethodDelete, "/v1/media/1/variants/thumb", "", variantOwnerId)
action, event := env.lastEvent()
if recorder.Code != 500 || env.events() != 1 || action != "media.variant_purge_image" || event["outcome"] != "failed" || event["failure_reason"] != genericReason {
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.seedFiveEnabled()
env.audit.probe.appendErr = errDiskFull
recorder := env.send(http.MethodPost, "/v1/media/1/variants", "", variantOwnerId)
if recorder.Code != 200 || decodeBody(t, recorder)["generated"] != 5.0 || env.files.rowCount() != 5 {
t.Errorf("response = %d %q", recorder.Code, recorder.Body.String())
}
})
t.Run("The new events read back through the existing activity API", func(t *testing.T) {
env := newVariantEnv(t)
env.seedFiveEnabled()
env.send(http.MethodPost, "/v1/media/1/variants", "", variantOwnerId)
env.send(http.MethodDelete, "/v1/media/1/variants/thumb", "", variantOwnerId)
recorder := env.send(http.MethodGet, "/v1/activity?category=media", "", variantOwnerId)
items := itemsOf(t, recorder)
actions := []any{}
for _, item := range items {
actions = append(actions, item["action"])
if item["target"].(map[string]any)["type"] != "image" || item["params"] == nil {
t.Errorf("item = %v", item)
}
}
if recorder.Code != 200 || len(items) != 2 || actions[0] != "media.variant_purge_image" || actions[1] != "media.variant_generate_all" {
t.Errorf("response = %d %q", recorder.Code, recorder.Body.String())
}
})
t.Run("An upload that generates files records only the upload", func(t *testing.T) {
env := newVariantEnv(t)
env.seedUploadVariants()
recorder := env.upload(uploadedFilename, "image/png", solidPNG(t, 1000, 800))
if action, _ := env.lastEvent(); recorder.Code != 201 || env.events() != 1 || action != "media.upload" {
t.Errorf("status = %d, events = %d", recorder.Code, env.events())
}
})
}
@@ -0,0 +1,138 @@
package test
import (
domain "RenewCMS/internal/domain/image"
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
"errors"
"strings"
"testing"
)
func TestOutputGeometry(t *testing.T) {
width := func(value int) *int { return &value }
rows := []struct {
name string
sw, sh int
settings domain.VariantSettings
wantW, wantH int
}{
{"cover with one side", 4032, 3024, domain.VariantSettings{Width: width(800), Fit: domain.FitCover}, 800, 600},
{"cover with both sides keeps the box ratio", 4032, 3024, domain.VariantSettings{Width: width(1000), Height: width(1000), Fit: domain.FitCover}, 1000, 1000},
{"cover shrinks the box to fit the source", 300, 200, domain.VariantSettings{Width: width(400), Height: width(300), Fit: domain.FitCover}, 267, 200},
{"contain is the box", 300, 200, domain.VariantSettings{Width: width(400), Height: width(300), Fit: domain.FitContain}, 400, 300},
{"inside never upscales", 300, 200, domain.VariantSettings{Width: width(1200), Height: width(1200), Fit: domain.FitInside}, 300, 200},
{"inside keeps the source ratio", 4032, 3024, domain.VariantSettings{Width: width(1000), Height: width(1000), Fit: domain.FitInside}, 1000, 750},
{"height only", 4032, 3024, domain.VariantSettings{Height: width(2400), Fit: domain.FitInside}, 3200, 2400},
{"unknown source size", 0, 0, domain.VariantSettings{Width: width(10), Fit: domain.FitCover}, 0, 0},
}
for _, row := range rows {
t.Run(row.name, func(t *testing.T) {
gotW, gotH := domain.OutputSize(row.sw, row.sh, row.settings)
if gotW != row.wantW || gotH != row.wantH {
t.Errorf("size = %d x %d, want %d x %d", gotW, gotH, row.wantW, row.wantH)
}
})
}
}
func TestOutputFormat(t *testing.T) {
all := []domain.VariantFormat{domain.FormatOriginal, domain.FormatJPEG, domain.FormatPNG, domain.FormatWebP}
withoutWebP := []domain.VariantFormat{domain.FormatOriginal, domain.FormatJPEG, domain.FormatPNG}
rows := []struct {
format domain.VariantFormat
contentType string
encodable []domain.VariantFormat
want domain.VariantFormat
}{
{domain.FormatOriginal, "image/jpeg", all, domain.FormatJPEG},
{domain.FormatOriginal, "image/png", all, domain.FormatPNG},
{domain.FormatOriginal, "image/webp", all, domain.FormatWebP},
{domain.FormatOriginal, "image/webp", withoutWebP, domain.FormatPNG},
{domain.FormatOriginal, "image/gif", all, domain.FormatPNG},
{domain.FormatWebP, "image/jpeg", withoutWebP, domain.FormatPNG},
{domain.FormatJPEG, "image/png", all, domain.FormatJPEG},
}
for _, row := range rows {
if got := domain.OutputFormat(row.format, row.contentType, row.encodable); got != row.want {
t.Errorf("%s over %s = %s, want %s", row.format, row.contentType, got, row.want)
}
}
}
func TestMediaListQueryVariants(t *testing.T) {
t.Run("The variants filter and the total_size sort are valid", func(t *testing.T) {
query := domain.NewListQuery()
query.Variants, query.Sort = domain.VariantsWith, domain.SortTotalSize
if err := query.Validate(); err != nil {
t.Errorf("err = %v", err)
}
})
t.Run("Any other variants filter is invalid", func(t *testing.T) {
query := domain.NewListQuery()
query.Variants = "bogus"
if err := query.Validate(); !errors.Is(err, domain.ErrInvalidQuery) {
t.Errorf("err = %v", err)
}
})
}
func TestVariantFileRepositoryTotalsBySource(t *testing.T) {
repo := persistence.NewVariantFileRepository(openActivityDatabase(t))
save := func(variant string, source string, size int64) {
_, _ = repo.Save(domain.VariantFile{VariantName: variant, SourceName: source, CachedName: variant + source, ContentType: "image/jpeg", Size: size, Fingerprint: "f"})
}
save("hero", "a.png", 1000)
save("thumb", "a.png", 500)
save("hero", "b.png", 2000)
totals, err := repo.TotalsBySource()
if err != nil || len(totals) != 2 || totals["a.png"].Files != 2 || totals["a.png"].Bytes != 1500 || totals["b.png"].Files != 1 || totals["b.png"].Bytes != 2000 {
t.Errorf("totals = %+v, err = %v", totals, err)
}
}
func TestPerImageVariantLayering(t *testing.T) {
t.Run("The new 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"}
files := []string{"ListImageVariants.go", "GenerateImageVariant.go", "GenerateAllImageVariants.go", "PurgeImageVariant.go", "GenerateVariantsOnUpload.go", "ServeVariantGenerate.go", "imageVariantView.go", "ListMedia.go"}
for _, name := range files {
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 media handler imports no persistence, mailer, models or gorm package", func(t *testing.T) {
for _, file := range []string{"api/handlers/media/mediaHandler.go", "api/handlers/media/variantsHandler.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("Only api/dependecyInjection.go constructs the new use cases", func(t *testing.T) {
composition := readRepositoryFile(t, "api/dependecyInjection.go")
for _, constructor := range []string{"NewListImageVariantsUseCase(", "NewGenerateImageVariantUseCase(", "NewGenerateAllImageVariantsUseCase(", "NewPurgeImageVariantUseCase(", "NewGenerateVariantsOnUploadUseCase("} {
if !strings.Contains(composition, constructor) {
t.Errorf("%s is not wired in the composition root", constructor)
}
if strings.Contains(readRepositoryFile(t, "api/router.go"), constructor) {
t.Errorf("api/router.go constructs %s", constructor)
}
}
})
}
+781
View File
@@ -0,0 +1,781 @@
package test
import (
"RenewCMS/api/pkg/apperr"
domain "RenewCMS/internal/domain/image"
"RenewCMS/internal/infrastructure/imaging"
"RenewCMS/internal/infrastructure/useCases"
"bytes"
"errors"
"net/http"
"net/http/httptest"
"runtime"
"strconv"
"strings"
"sync"
"testing"
"time"
)
const (
listedStoredName = "a1b2.jpg"
svgStoredName = "e5f6.svg"
imageNotFoundBody = "Image not found"
internalErrorBody = "Internal server error"
unreadableBodyBytes = "not an image at all"
)
func (e *variantEnv) seedPerImage(id uint32, filename string, storedName string, contentType string, width int, height int, content []byte) domain.Image {
img := domain.Image{
ID: id,
Path: domain.PublicPath(storedName),
Filename: filename,
Size: int64(len(content)),
ContentType: contentType,
Width: width,
Height: height,
SHA256: "hash-" + strconv.Itoa(int(id)),
CreatedAt: mockImageEpoch.Add(time.Duration(id) * time.Minute),
}
e.images.images[id] = img
if id > e.images.nextID {
e.images.nextID = id
}
if content != nil {
e.storage.seed(storedName, content)
}
return img
}
func (e *variantEnv) seedCurrentFile(variantName string, storedName string, size int64) domain.VariantFile {
cached := "cached-" + variantName + "-" + storedName
e.storage.seed(cached, []byte("generated"))
saved, _ := e.files.Save(domain.VariantFile{
VariantName: variantName,
SourceName: storedName,
CachedName: cached,
ContentType: "image/webp",
Size: size,
Fingerprint: e.variants.stored(variantName).Settings().Fingerprint(),
})
return saved
}
func (e *variantEnv) disable(name string) {
variant := e.variants.stored(name)
variant.Enabled = false
e.variants.variants[name] = variant
}
func (e *variantEnv) seedListingVariants() {
seedVariant(e.variants, "card", 800, 0, domain.FitInside, domain.FormatWebP, 80)
seedVariant(e.variants, "hero", 1200, 630, domain.FitCover, domain.FormatWebP, 82)
seedVariant(e.variants, "og", 1200, 630, domain.FitCover, domain.FormatJPEG, 80)
seedVariant(e.variants, "print", 0, 2400, domain.FitInside, domain.FormatPNG, 0)
seedVariant(e.variants, "square", 600, 600, domain.FitCover, domain.FormatJPEG, 85)
seedVariant(e.variants, "thumb", 400, 300, domain.FitCover, domain.FormatWebP, 80)
e.disable("print")
}
func (e *variantEnv) seedListingBackground() {
e.seedListingVariants()
e.seedPerImage(1, "north-wall.jpg", listedStoredName, "image/jpeg", 4032, 3024, nil)
e.images.images[1] = withSize(e.images.images[1], 4089446)
e.seedPerImage(2, "logo.svg", svgStoredName, "image/svg+xml", 0, 0, nil)
e.seedCurrentFile("thumb", listedStoredName, 18432)
e.seedCurrentFile("card", listedStoredName, 65536)
e.seedCurrentFile("hero", listedStoredName, 98304)
e.seedCurrentFile("square", listedStoredName, 83968)
e.seedCurrentFile("print", listedStoredName, 3250586)
}
func withSize(img domain.Image, size int64) domain.Image {
img.Size = size
return img
}
func (e *variantEnv) seedGenerateBackground() {
e.seedVariantsForGeneration()
e.seedPerImage(1, "north-wall.jpg", "a1b2.png", "image/png", 1000, 800, encodePNG(e.t, variantSolidImage(1000, 800, variantRed)))
}
func (e *variantEnv) seedVariantsForGeneration() {
seedVariant(e.variants, "thumb", 400, 300, domain.FitCover, domain.FormatJPEG, 80)
seedVariant(e.variants, "hero", 1200, 630, domain.FitCover, domain.FormatJPEG, 82)
seedVariant(e.variants, "off", 300, 0, domain.FitInside, domain.FormatPNG, 0)
e.disable("off")
}
func itemsOf(t *testing.T, recorder *httptest.ResponseRecorder) []map[string]any {
t.Helper()
raw, ok := decodeBody(t, recorder)["items"].([]any)
if !ok {
t.Fatalf("items missing in %q", recorder.Body.String())
}
items := make([]map[string]any, 0, len(raw))
for _, entry := range raw {
items = append(items, entry.(map[string]any))
}
return items
}
func itemNames(items []map[string]any) []string {
names := make([]string, 0, len(items))
for _, item := range items {
names = append(names, item["name"].(string))
}
return names
}
func itemNamed(t *testing.T, items []map[string]any, name string) map[string]any {
t.Helper()
for _, item := range items {
if item["name"] == name {
return item
}
}
t.Fatalf("no item named %q in %v", name, itemNames(items))
return nil
}
func TestPerImageVariantRoutesAreGuarded(t *testing.T) {
rows := []struct {
method string
path string
credentials string
status int
body string
}{
{http.MethodGet, "/v1/media/1/variants", "none", 401, apperr.UnauthorizedErrorMessage},
{http.MethodGet, "/v1/media/1/variants", "stale", 401, apperr.UnauthorizedErrorMessage},
{http.MethodGet, "/v1/media/1/variants", "unverified", 403, apperr.VerificationRequiredErrorMessage},
{http.MethodGet, "/v1/media/1/variants", "plain", 403, apperr.OwnerOrAdminRequiredErrorMessage},
{http.MethodPost, "/v1/media/1/variants", "plain", 403, apperr.OwnerOrAdminRequiredErrorMessage},
{http.MethodPost, "/v1/media/1/variants/hero", "plain", 403, apperr.OwnerOrAdminRequiredErrorMessage},
{http.MethodDelete, "/v1/media/1/variants/hero", "plain", 403, apperr.OwnerOrAdminRequiredErrorMessage},
{http.MethodGet, "/v1/media/1/variants", "apikey", 401, apperr.UnauthorizedErrorMessage},
{http.MethodPost, "/v1/media/1/variants", "apikey", 401, apperr.UnauthorizedErrorMessage},
}
for _, row := range rows {
t.Run("Every per-image variant route is guarded: "+row.method+" "+row.path+" "+row.credentials, func(t *testing.T) {
env := newVariantEnv(t)
env.seedGenerateBackground()
variantCalls, fileCalls, opens := env.variants.calls, env.files.calls, env.storage.opens
request := httptest.NewRequest(row.method, row.path, nil)
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.Set("Authorization", "Bearer "+env.issueAPIKey())
}
recorder := httptest.NewRecorder()
env.admin.ServeHTTP(recorder, request)
assertStatusAndBody(t, recorder, row.status, row.body)
if env.variants.calls != variantCalls || env.files.calls != fileCalls || env.storage.opens != opens || env.storage.putCount() != 0 || env.renderer.renderCount() != 0 || env.events() != 0 {
t.Error("a variant, index row, blob or activity event was read or changed")
}
})
}
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)
env.seedGenerateBackground()
recorder := env.send(http.MethodGet, "/v1/media/1/variants", "", userId)
if recorder.Code != http.StatusOK {
t.Errorf("status = %d", recorder.Code)
}
})
}
}
func TestPerImageVariantMalformedAndUnknown(t *testing.T) {
rows := []struct {
method string
id string
suffix string
status int
body string
}{
{http.MethodGet, "abc", "", 400, apperr.IdUint32ErrorMessage},
{http.MethodPost, "abc", "", 400, apperr.IdUint32ErrorMessage},
{http.MethodGet, "9999", "", 404, imageNotFoundBody},
{http.MethodPost, "9999", "", 404, imageNotFoundBody},
{http.MethodPost, "9999", "/hero", 404, imageNotFoundBody},
{http.MethodDelete, "9999", "/hero", 404, imageNotFoundBody},
}
for _, row := range rows {
t.Run("A malformed id or an unknown image: "+row.method+" "+row.id+row.suffix, func(t *testing.T) {
env := newVariantEnv(t)
env.seedGenerateBackground()
recorder := env.sendAsAdmin(row.method, "/v1/media/"+row.id+"/variants"+row.suffix, "")
assertStatusAndBody(t, recorder, row.status, row.body)
if env.events() != 0 {
t.Error("an activity event was recorded")
}
})
}
}
func TestPerImageVariantListing(t *testing.T) {
t.Run("The enabled variants, in name order, with their output and what is cached", func(t *testing.T) {
env := newVariantEnv(t)
env.seedListingBackground()
recorder := env.sendAsAdmin(http.MethodGet, "/v1/media/1/variants", "")
if recorder.Code != 200 || decodeBody(t, recorder)["unsupported"] != nil {
t.Fatalf("response = %d %q", recorder.Code, recorder.Body.String())
}
items := itemsOf(t, recorder)
if got := strings.Join(itemNames(items), ","); got != "card,hero,og,square,thumb" {
t.Fatalf("names = %s", got)
}
expected := []struct {
name, format string
width, height any
outWidth, outHeight float64
generated bool
size any
}{
{"card", "webp", 800.0, nil, 800, 600, true, 65536.0},
{"hero", "webp", 1200.0, 630.0, 1200, 630, true, 98304.0},
{"og", "jpeg", 1200.0, 630.0, 1200, 630, false, nil},
{"square", "jpeg", 600.0, 600.0, 600, 600, true, 83968.0},
{"thumb", "webp", 400.0, 300.0, 400, 300, true, 18432.0},
}
for _, want := range expected {
item := itemNamed(t, items, want.name)
if item["width"] != want.width || item["height"] != want.height || item["output_width"] != want.outWidth || item["output_height"] != want.outHeight || item["output_format"] != want.format || item["generated"] != want.generated || item["size"] != want.size {
t.Errorf("%s = %v", want.name, item)
}
}
if itemNamed(t, items, "thumb")["url"] != "/static/uploadedImages/a1b2.jpg?v=thumb" || itemNamed(t, items, "card")["fit"] != "inside" || itemNamed(t, items, "hero")["format"] != "webp" {
t.Errorf("thumb = %v", itemNamed(t, items, "thumb"))
}
if !strings.Contains(recorder.Body.String(), `"height":null`) || !strings.Contains(recorder.Body.String(), `"size":null`) {
t.Errorf("raw body = %q", recorder.Body.String())
}
})
t.Run("A row whose fingerprint is stale is not generated", func(t *testing.T) {
env := newVariantEnv(t)
env.seedListingBackground()
row, _, _ := env.files.Find("hero", listedStoredName)
row.Fingerprint = "older-quality"
_, _ = env.files.Save(row)
recorder := env.sendAsAdmin(http.MethodGet, "/v1/media/1/variants", "")
hero := itemNamed(t, itemsOf(t, recorder), "hero")
if hero["generated"] != false || hero["size"] != nil {
t.Errorf("hero = %v", hero)
}
})
formats := []struct {
contentType string
webp bool
want string
}{
{"image/jpeg", true, "jpeg"},
{"image/png", true, "png"},
{"image/webp", true, "webp"},
{"image/webp", false, "png"},
}
for _, row := range formats {
t.Run("A variant in the original format takes the format of the source: "+row.contentType+" webp "+strconv.FormatBool(row.webp), func(t *testing.T) {
renderer := newCountingRenderer()
if !row.webp {
renderer = newCountingRendererWithoutWebP()
}
env := newVariantEnvWith(t, renderer, variantDefaultCap)
seedVariant(env.variants, "keep", 100, 0, domain.FitInside, domain.FormatOriginal, 80)
env.seedPerImage(1, "photo", "src.bin", row.contentType, 400, 300, nil)
recorder := env.sendAsAdmin(http.MethodGet, "/v1/media/1/variants", "")
if got := itemNamed(t, itemsOf(t, recorder), "keep")["output_format"]; got != row.want {
t.Errorf("output_format = %v, want %s", got, row.want)
}
})
}
geometry := []struct {
sw, sh, w, h int
fit domain.VariantFit
ow, oh float64
}{
{4032, 3024, 800, 0, domain.FitCover, 800, 600},
{4032, 3024, 0, 2400, domain.FitInside, 3200, 2400},
{4032, 3024, 8000, 8000, domain.FitCover, 3024, 3024},
{300, 200, 800, 0, domain.FitCover, 300, 200},
{300, 200, 400, 300, domain.FitCover, 267, 200},
{300, 200, 400, 300, domain.FitContain, 400, 300},
{300, 200, 1200, 1200, domain.FitInside, 300, 200},
}
for _, row := range geometry {
name := strconv.Itoa(row.sw) + "x" + strconv.Itoa(row.sh) + " into " + strconv.Itoa(row.w) + "x" + strconv.Itoa(row.h) + " " + string(row.fit)
t.Run("The output size never exceeds the source: "+name, func(t *testing.T) {
env := newVariantEnv(t)
variant := seedVariant(env.variants, "probe", row.w, row.h, row.fit, domain.FormatPNG, 0)
env.seedPerImage(1, "photo", "src.png", "image/png", row.sw, row.sh, nil)
recorder := env.sendAsAdmin(http.MethodGet, "/v1/media/1/variants", "")
item := itemNamed(t, itemsOf(t, recorder), "probe")
if item["output_width"] != row.ow || item["output_height"] != row.oh {
t.Fatalf("output = %v x %v", item["output_width"], item["output_height"])
}
rendered, err := imaging.NewVariantRenderer().Render(bytes.NewReader(encodePNG(t, variantSolidImage(row.sw, row.sh, variantRed))), variant.Settings())
if err != nil {
t.Fatal(err)
}
width, height := decodedSize(t, rendered.Content)
if float64(width) != row.ow || float64(height) != row.oh {
t.Errorf("renderer produced %d x %d", width, height)
}
})
}
t.Run("A source that no variant can be generated for answers an empty list", func(t *testing.T) {
env := newVariantEnv(t)
env.seedListingBackground()
recorder := env.sendAsAdmin(http.MethodGet, "/v1/media/2/variants", "")
assertStatusAndBody(t, recorder, 200, `{"unsupported":"svg","items":[]}`)
})
t.Run("No enabled variant answers an empty array, not null", func(t *testing.T) {
env := newVariantEnv(t)
env.seedListingBackground()
for _, name := range []string{"card", "hero", "og", "square", "thumb"} {
env.disable(name)
}
recorder := env.sendAsAdmin(http.MethodGet, "/v1/media/1/variants", "")
if recorder.Code != 200 || !strings.Contains(recorder.Body.String(), `"items":[]`) || !strings.Contains(recorder.Body.String(), `"unsupported":null`) {
t.Errorf("response = %d %q", recorder.Code, recorder.Body.String())
}
})
t.Run("Listing reads nothing it should not change", func(t *testing.T) {
env := newVariantEnv(t)
env.seedListingBackground()
rows := env.files.rowCount()
env.sendAsAdmin(http.MethodGet, "/v1/media/1/variants", "")
if env.storage.opens != 0 || env.storage.putCount() != 0 || env.renderer.renderCount() != 0 || env.files.saves != 5 || env.files.rowCount() != rows || env.events() != 0 {
t.Error("the listing opened a blob, rendered, wrote a row or recorded an event")
}
})
}
func TestPerImageVariantGenerateOne(t *testing.T) {
t.Run("The first generation renders, stores and indexes the file", func(t *testing.T) {
env := newVariantEnv(t)
env.seedGenerateBackground()
recorder := env.sendAsAdmin(http.MethodPost, "/v1/media/1/variants/thumb", "")
body := decodeBody(t, recorder)
row, found, _ := env.files.Find("thumb", "a1b2.png")
if recorder.Code != 200 || body["name"] != "thumb" || body["generated"] != true || body["output_width"] != 400.0 || body["output_height"] != 300.0 || body["output_format"] != "jpeg" || !found || body["size"] != float64(row.Size) {
t.Fatalf("response = %d %q", recorder.Code, recorder.Body.String())
}
stored, _ := env.storage.Open(row.CachedName)
if stored.Size != row.Size || env.storage.putCount() != 1 || env.files.rowCount() != 1 || len(env.storage.deletedNames()) != 0 || !env.storage.has("a1b2.png") || env.events() != 0 {
t.Error("the generation changed more than the one blob and row")
}
})
t.Run("The public url then serves that file without rendering", func(t *testing.T) {
env := newVariantEnv(t)
env.seedGenerateBackground()
first := env.sendAsAdmin(http.MethodPost, "/v1/media/1/variants/thumb", "")
row, _, _ := env.files.Find("thumb", "a1b2.png")
public := env.get("/static/uploadedImages/a1b2.png?v=thumb", nil)
if first.Code != 200 || public.Code != 200 || !bytes.Equal(public.Body.Bytes(), env.storage.blobs[row.CachedName]) || env.renderer.renderCount() != 1 {
t.Errorf("public = %d, renders = %d", public.Code, env.renderer.renderCount())
}
})
t.Run("A file generated by the public url is a hit for the admin route", func(t *testing.T) {
env := newVariantEnv(t)
env.seedGenerateBackground()
env.get("/static/uploadedImages/a1b2.png?v=thumb", nil)
recorder := env.sendAsAdmin(http.MethodPost, "/v1/media/1/variants/thumb", "")
if recorder.Code != 200 || env.renderer.renderCount() != 1 || env.storage.putCount() != 1 {
t.Errorf("status = %d, renders = %d", recorder.Code, env.renderer.renderCount())
}
})
t.Run("Generating a variant that is already generated renders nothing", func(t *testing.T) {
env := newVariantEnv(t)
env.seedGenerateBackground()
first := decodeBody(t, env.sendAsAdmin(http.MethodPost, "/v1/media/1/variants/thumb", ""))
again := env.sendAsAdmin(http.MethodPost, "/v1/media/1/variants/thumb", "")
if again.Code != 200 || decodeBody(t, again)["size"] != first["size"] || env.renderer.renderCount() != 1 {
t.Errorf("status = %d, renders = %d", again.Code, env.renderer.renderCount())
}
})
t.Run("A public request and an admin request at the same time render once", func(t *testing.T) {
env := newVariantEnv(t)
env.seedGenerateBackground()
env.renderer.onRender = func() {
for env.storage.openCountOf("a1b2.png") < 3 {
runtime.Gosched()
}
}
codes := make([]int, 2)
start := make(chan struct{})
var finished sync.WaitGroup
finished.Add(2)
go func() {
defer finished.Done()
<-start
codes[0] = env.sendAsAdmin(http.MethodPost, "/v1/media/1/variants/thumb", "").Code
}()
go func() {
defer finished.Done()
<-start
codes[1] = env.get("/static/uploadedImages/a1b2.png?v=thumb", nil).Code
}()
close(start)
finished.Wait()
if codes[0] != 200 || codes[1] != 200 || env.renderer.renderCount() != 1 || env.storage.putCount() != 1 {
t.Errorf("codes = %v, renders = %d, puts = %d", codes, env.renderer.renderCount(), env.storage.putCount())
}
})
refused := []struct{ name, variant string }{{"unknown", "missing"}, {"disabled", "off"}, {"wrongly cased", "Thumb"}}
for _, row := range refused {
t.Run("A request that cannot be served: "+row.name, func(t *testing.T) {
env := newVariantEnv(t)
env.seedGenerateBackground()
recorder := env.sendAsAdmin(http.MethodPost, "/v1/media/1/variants/"+row.variant, "")
assertStatusAndBody(t, recorder, 404, variantNotFound)
if env.renderer.renderCount() != 0 || env.storage.putCount() != 0 {
t.Error("something was rendered or stored")
}
})
}
t.Run("An SVG answers 422", func(t *testing.T) {
env := newVariantEnv(t)
env.seedGenerateBackground()
env.seedPerImage(2, "logo.svg", svgStoredName, "image/svg+xml", 0, 0, []byte("<svg/>"))
recorder := env.sendAsAdmin(http.MethodPost, "/v1/media/2/variants/thumb", "")
assertStatusAndBody(t, recorder, 422, apperr.VariantSourceUnsupportedErrorMessage)
if env.renderer.renderCount() != 0 || env.storage.putCount() != 0 {
t.Error("something was rendered or stored")
}
})
t.Run("A source beyond the byte cap answers 413", func(t *testing.T) {
env := newVariantEnvWith(t, newCountingRenderer(), 10)
env.seedGenerateBackground()
recorder := env.sendAsAdmin(http.MethodPost, "/v1/media/1/variants/thumb", "")
assertStatusAndBody(t, recorder, 413, apperr.VariantSourceTooLargeErrorMessage)
if env.storage.putCount() != 0 {
t.Error("a blob was stored")
}
})
t.Run("A source beyond the pixel cap answers 413", func(t *testing.T) {
env := newVariantEnvLimits(t, newCountingRenderer(), useCases.ServeVariantLimits{MaxSourceBytes: variantDefaultCap, MaxSourcePixels: 100, MaxConcurrentRenders: variantDefaultRenders})
env.seedGenerateBackground()
recorder := env.sendAsAdmin(http.MethodPost, "/v1/media/1/variants/thumb", "")
assertStatusAndBody(t, recorder, 413, apperr.VariantSourceTooLargeErrorMessage)
if env.storage.putCount() != 0 {
t.Error("a blob was stored")
}
})
t.Run("A source that cannot be decoded answers 415", func(t *testing.T) {
env := newVariantEnv(t)
env.seedGenerateBackground()
env.storage.seed("a1b2.png", []byte(unreadableBodyBytes))
recorder := env.sendAsAdmin(http.MethodPost, "/v1/media/1/variants/thumb", "")
assertStatusAndBody(t, recorder, 415, apperr.VariantSourceUnreadableErrorMessage)
if env.storage.putCount() != 0 {
t.Error("a blob was stored")
}
})
t.Run("Any other render failure is a generic 500 and is not cached", func(t *testing.T) {
env := newVariantEnv(t)
env.seedGenerateBackground()
env.renderer.failFirst = true
first := env.sendAsAdmin(http.MethodPost, "/v1/media/1/variants/thumb", "")
puts, rows := env.storage.putCount(), env.files.rowCount()
second := env.sendAsAdmin(http.MethodPost, "/v1/media/1/variants/thumb", "")
assertStatusAndBody(t, first, 500, internalErrorBody)
if puts != 0 || rows != 0 || second.Code != 200 {
t.Errorf("puts = %d, rows = %d, second = %d", puts, rows, second.Code)
}
})
t.Run("A generation does not change the original, the other variants or other images", func(t *testing.T) {
env := newVariantEnv(t)
env.seedGenerateBackground()
env.seedPerImage(3, "other.png", "c3d4.png", "image/png", 10, 10, solidPNG(t, 10, 10))
other := env.seedCurrentFile("hero", "c3d4.png", 5)
before := env.images.images[1]
env.sendAsAdmin(http.MethodPost, "/v1/media/1/variants/thumb", "")
kept, _, _ := env.files.Find("hero", "c3d4.png")
if env.images.images[1] != before || kept != other || env.files.rowCount() != 2 {
t.Error("the original, another variant or another image changed")
}
})
}
func TestPerImageVariantGenerateAll(t *testing.T) {
seedFive := func(env *variantEnv) {
seedVariant(env.variants, "card", 800, 0, domain.FitInside, domain.FormatJPEG, 80)
seedVariant(env.variants, "hero", 1200, 630, domain.FitCover, domain.FormatJPEG, 82)
seedVariant(env.variants, "og", 1200, 630, domain.FitCover, domain.FormatJPEG, 80)
seedVariant(env.variants, "print", 0, 2400, domain.FitInside, domain.FormatPNG, 0)
seedVariant(env.variants, "square", 600, 600, domain.FitCover, domain.FormatJPEG, 85)
seedVariant(env.variants, "thumb", 400, 300, domain.FitCover, domain.FormatJPEG, 80)
env.disable("print")
env.seedPerImage(1, "north-wall.jpg", "a1b2.png", "image/png", 1000, 800, encodePNG(env.t, variantSolidImage(1000, 800, variantRed)))
}
t.Run("Generate all renders every enabled variant and answers the refreshed list", func(t *testing.T) {
env := newVariantEnv(t)
seedFive(env)
recorder := env.sendAsAdmin(http.MethodPost, "/v1/media/1/variants", "")
body := decodeBody(t, recorder)
items := itemsOf(t, recorder)
if recorder.Code != 200 || body["generated"] != 5.0 || body["failed"] != 0.0 || !strings.Contains(recorder.Body.String(), `"failures":[]`) || len(items) != 5 {
t.Fatalf("response = %d %q", recorder.Code, recorder.Body.String())
}
for _, item := range items {
if item["generated"] != true || item["size"] == nil {
t.Errorf("item = %v", item)
}
}
if env.storage.putCount() != 5 || env.files.rowCount() != 5 || len(env.files.rowsOf("print")) != 0 {
t.Error("expected exactly five blobs and rows, none for the disabled variant")
}
})
t.Run("Variants already generated are skipped and not counted", func(t *testing.T) {
env := newVariantEnv(t)
seedFive(env)
env.sendAsAdmin(http.MethodPost, "/v1/media/1/variants/card", "")
env.sendAsAdmin(http.MethodPost, "/v1/media/1/variants/thumb", "")
recorder := env.sendAsAdmin(http.MethodPost, "/v1/media/1/variants", "")
generated := decodeBody(t, recorder)["generated"]
if generated != 3.0 || env.renderer.renderCount() != 5 {
t.Errorf("generated = %v, renders = %d", generated, env.renderer.renderCount())
}
for _, item := range itemsOf(t, recorder) {
if item["generated"] != true {
t.Errorf("item = %v", item)
}
}
})
failures := []struct {
name string
fail error
cause string
}{
{"ErrSourceTooLarge", domain.ErrSourceTooLarge, "too_large"},
{"ErrVariantSourceUnreadable", domain.ErrVariantSourceUnreadable, "unsupported_format"},
{"a renderer error that wraps no sentinel", errors.New("boom"), "failed"},
}
for _, row := range failures {
t.Run("A failing variant is reported and the others are still generated: "+row.name, func(t *testing.T) {
env := newVariantEnv(t)
seedFive(env)
heroFingerprint := env.variants.stored("hero").Settings().Fingerprint()
env.renderer.failFor = func(settings domain.VariantSettings) error {
if settings.Fingerprint() == heroFingerprint {
return row.fail
}
return nil
}
recorder := env.sendAsAdmin(http.MethodPost, "/v1/media/1/variants", "")
body := decodeBody(t, recorder)
failed, _ := body["failures"].([]any)
if recorder.Code != 200 || body["generated"] != 4.0 || body["failed"] != 1.0 || len(failed) != 1 || failed[0].(map[string]any)["name"] != "hero" || failed[0].(map[string]any)["cause"] != row.cause {
t.Fatalf("response = %d %q", recorder.Code, recorder.Body.String())
}
for _, item := range itemsOf(t, recorder) {
if (item["name"] == "hero") == (item["generated"] == true) {
t.Errorf("item = %v", item)
}
}
})
}
t.Run("Nothing to generate answers zero", func(t *testing.T) {
env := newVariantEnv(t)
seedFive(env)
env.sendAsAdmin(http.MethodPost, "/v1/media/1/variants", "")
rendered := env.renderer.renderCount()
recorder := env.sendAsAdmin(http.MethodPost, "/v1/media/1/variants", "")
body := decodeBody(t, recorder)
if recorder.Code != 200 || body["generated"] != 0.0 || body["failed"] != 0.0 || env.renderer.renderCount() != rendered {
t.Errorf("response = %d %q", recorder.Code, recorder.Body.String())
}
})
t.Run("No enabled variant answers zero", func(t *testing.T) {
env := newVariantEnv(t)
seedFive(env)
for _, name := range []string{"card", "hero", "og", "square", "thumb"} {
env.disable(name)
}
recorder := env.sendAsAdmin(http.MethodPost, "/v1/media/1/variants", "")
body := decodeBody(t, recorder)
if recorder.Code != 200 || body["generated"] != 0.0 || body["failed"] != 0.0 || !strings.Contains(recorder.Body.String(), `"items":[]`) {
t.Errorf("response = %d %q", recorder.Code, recorder.Body.String())
}
})
t.Run("An SVG answers 422", func(t *testing.T) {
env := newVariantEnv(t)
seedFive(env)
env.seedPerImage(2, "logo.svg", svgStoredName, "image/svg+xml", 0, 0, []byte("<svg/>"))
recorder := env.sendAsAdmin(http.MethodPost, "/v1/media/2/variants", "")
assertStatusAndBody(t, recorder, 422, apperr.VariantSourceUnsupportedErrorMessage)
})
}
func TestPerImageVariantPurge(t *testing.T) {
seedBoth := func(env *variantEnv) {
seedVariant(env.variants, "thumb", 400, 300, domain.FitCover, domain.FormatJPEG, 80)
seedVariant(env.variants, "hero", 1200, 630, domain.FitCover, domain.FormatJPEG, 82)
env.seedPerImage(1, "north-wall.jpg", "a1b2.jpg", "image/jpeg", 100, 100, nil)
env.seedPerImage(3, "other.jpg", "c3d4.jpg", "image/jpeg", 100, 100, nil)
for _, source := range []string{"a1b2.jpg", "c3d4.jpg"} {
env.seedCurrentFile("thumb", source, 18432)
env.seedCurrentFile("hero", source, 98304)
}
}
t.Run("Purging a generated variant removes its file and its row", func(t *testing.T) {
env := newVariantEnv(t)
seedBoth(env)
recorder := env.sendAsAdmin(http.MethodDelete, "/v1/media/1/variants/thumb", "")
assertStatusAndBody(t, recorder, 200, `{"purged_files":1,"purged_bytes":18432}`)
if got := strings.Join(env.storage.deletedNames(), ","); got != "cached-thumb-a1b2.jpg" {
t.Errorf("deleted = %s", got)
}
if _, found, _ := env.files.Find("thumb", "a1b2.jpg"); found || env.files.rowCount() != 3 {
t.Error("the row is still there, or another row went with it")
}
})
t.Run("The next public request generates the file again", func(t *testing.T) {
env := newVariantEnv(t)
env.seedGenerateBackground()
env.sendAsAdmin(http.MethodPost, "/v1/media/1/variants/thumb", "")
env.sendAsAdmin(http.MethodDelete, "/v1/media/1/variants/thumb", "")
puts := env.storage.putCount()
public := env.get("/static/uploadedImages/a1b2.png?v=thumb", nil)
if public.Code != 200 || env.storage.putCount() != puts+1 {
t.Errorf("status = %d, puts = %d", public.Code, env.storage.putCount())
}
})
t.Run("Purging a variant that is not generated answers zero", func(t *testing.T) {
env := newVariantEnv(t)
seedBoth(env)
row, _, _ := env.files.Find("thumb", "a1b2.jpg")
_ = env.files.Delete(row.ID)
recorder := env.sendAsAdmin(http.MethodDelete, "/v1/media/1/variants/thumb", "")
assertStatusAndBody(t, recorder, 200, `{"purged_files":0,"purged_bytes":0}`)
})
t.Run("A disabled variant keeps its files and they can be purged", func(t *testing.T) {
env := newVariantEnv(t)
seedBoth(env)
env.disable("thumb")
recorder := env.sendAsAdmin(http.MethodDelete, "/v1/media/1/variants/thumb", "")
if decodeBody(t, recorder)["purged_files"] != 1.0 {
t.Errorf("body = %q", recorder.Body.String())
}
})
t.Run("An unknown variant answers 404", func(t *testing.T) {
env := newVariantEnv(t)
seedBoth(env)
recorder := env.sendAsAdmin(http.MethodDelete, "/v1/media/1/variants/missing", "")
assertStatusAndBody(t, recorder, 404, variantNotFound)
})
t.Run("A blob that cannot be deleted keeps its row and the call fails", func(t *testing.T) {
env := newVariantEnv(t)
seedBoth(env)
env.storage.failDelete["cached-thumb-a1b2.jpg"] = errVariantStorageFailure
recorder := env.sendAsAdmin(http.MethodDelete, "/v1/media/1/variants/thumb", "")
assertStatusAndBody(t, recorder, 500, internalErrorBody)
if _, found, _ := env.files.Find("thumb", "a1b2.jpg"); !found {
t.Error("the index row was removed")
}
})
}
+26
View File
@@ -7,6 +7,7 @@ import (
"bytes"
"errors"
"io"
"log"
"sort"
"strconv"
"strings"
@@ -114,6 +115,8 @@ type mockVariantFileRepo struct {
deleted []uint32
saves int
calls int
totalsBySourceCalls int
}
var _ domain.VariantFileRepository = &mockVariantFileRepo{}
@@ -195,6 +198,19 @@ func (m *mockVariantFileRepo) Totals() (map[string]domain.VariantFileTotals, err
return totals, nil
}
func (m *mockVariantFileRepo) TotalsBySource() (map[string]domain.VariantFileTotals, error) {
m.mutex.Lock()
defer m.mutex.Unlock()
m.calls++
m.totalsBySourceCalls++
totals := map[string]domain.VariantFileTotals{}
for _, row := range m.rows {
current := totals[row.SourceName]
totals[row.SourceName] = 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
@@ -290,6 +306,7 @@ type countingRenderer struct {
calls atomic.Int32
failFirst bool
onRender func()
failFor func(settings domain.VariantSettings) error
}
var _ domain.VariantRenderer = &countingRenderer{}
@@ -307,6 +324,11 @@ func (c *countingRenderer) Render(source io.Reader, settings domain.VariantSetti
if c.onRender != nil {
c.onRender()
}
if c.failFor != nil {
if err := c.failFor(settings); err != nil {
return domain.RenderedVariant{}, err
}
}
if c.failFirst && call == 1 {
return domain.RenderedVariant{}, errors.New("render failure")
}
@@ -371,6 +393,10 @@ func newInertServeVariantUseCase(storage domain.Storage) *useCases.ServeVariantU
return useCases.NewServeVariantUseCase(newMockVariantRepo(), newMockVariantFileRepo(), newMockImageRepo(), storage, newCountingRenderer(), defaultServeLimits(variantDefaultCap))
}
func newInertGenerateOnUploadUseCase() *useCases.GenerateVariantsOnUploadUseCase {
return useCases.NewGenerateVariantsOnUploadUseCase(newMockVariantRepo(), newInertServeVariantUseCase(newMockVariantStorage()), log.New(io.Discard, "", 0))
}
func (m *mockVariantStorage) openCountOf(storedName string) int {
m.mutex.Lock()
defer m.mutex.Unlock()
+18 -17
View File
@@ -63,23 +63,24 @@ func newMediaEnv(t *testing.T) *mediaEnv {
merge := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage, newMockVariantFileRepo(), discardRecorder)
now := func() time.Time { return env.clock }
container := &api.UseCases{
Token: env.token,
CreateArticleUseCase: useCases.NewCreateArticleUseCase(env.articles, &fakeBodyRenderer{}, discardRecorder),
GetArticleUseCase: useCases.NewGetArticleUseCase(env.articles, &fakeBodyRenderer{}),
ListArticlesUseCase: useCases.NewListArticlesUseCase(env.articles, &fakeBodyRenderer{}),
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(env.articles, &fakeBodyRenderer{}, discardRecorder),
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(env.articles, discardRecorder),
GetUserUseCase: useCases.NewGetUserUseCase(env.users),
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, newMockVariantFileRepo(), discardRecorder),
MergeMediaUseCase: merge,
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(env.images, env.articles),
GetPurgeSettingsUseCase: useCases.NewGetPurgeSettingsUseCase(env.purgeSettings),
UpdatePurgeSettingsUseCase: useCases.NewUpdatePurgeSettingsUseCase(env.purgeSettings, now),
PreviewPurgeUseCase: useCases.NewPreviewPurgeUseCase(env.images, env.articles),
RunPurgeUseCase: useCases.NewRunPurgeUseCase(env.images, env.articles, merge, env.purgeSettings, now, log.New(env.logs, "", 0), discardRecorder),
Token: env.token,
CreateArticleUseCase: useCases.NewCreateArticleUseCase(env.articles, &fakeBodyRenderer{}, discardRecorder),
GetArticleUseCase: useCases.NewGetArticleUseCase(env.articles, &fakeBodyRenderer{}),
ListArticlesUseCase: useCases.NewListArticlesUseCase(env.articles, &fakeBodyRenderer{}),
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(env.articles, &fakeBodyRenderer{}, discardRecorder),
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(env.articles, discardRecorder),
GetUserUseCase: useCases.NewGetUserUseCase(env.users),
CreateImageUseCase: useCases.NewCreateImageUseCase(env.images, env.storage, discardRecorder),
GenerateVariantsOnUploadUseCase: newInertGenerateOnUploadUseCase(),
ListMediaUseCase: useCases.NewListMediaUseCase(env.images, env.articles, env.users, newMockVariantFileRepo()),
RenameMediaUseCase: useCases.NewRenameMediaUseCase(env.images, discardRecorder),
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(env.images, env.storage, newMockVariantFileRepo(), discardRecorder),
MergeMediaUseCase: merge,
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(env.images, env.articles),
GetPurgeSettingsUseCase: useCases.NewGetPurgeSettingsUseCase(env.purgeSettings),
UpdatePurgeSettingsUseCase: useCases.NewUpdatePurgeSettingsUseCase(env.purgeSettings, now),
PreviewPurgeUseCase: useCases.NewPreviewPurgeUseCase(env.images, env.articles),
RunPurgeUseCase: useCases.NewRunPurgeUseCase(env.images, env.articles, merge, env.purgeSettings, now, log.New(env.logs, "", 0), discardRecorder),
}
router := chi.NewRouter()
router.Mount("/v1", api.InitBackendRoutes(container, config.Settings{}))
+266
View File
@@ -0,0 +1,266 @@
package test
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/internal/domain/article"
domain "RenewCMS/internal/domain/image"
"net/http"
"net/url"
"slices"
"strings"
"testing"
)
const libraryCounts = `{"all":5,"unused":3,"duplicates":1}`
func (e *variantEnv) saveRow(variantName string, storedName string, size int64) {
e.files.Save(domain.VariantFile{VariantName: variantName, SourceName: storedName, CachedName: "cached-" + variantName + "-" + storedName, ContentType: "image/webp", Size: size, Fingerprint: "f"})
e.storage.seed("cached-"+variantName+"-"+storedName, []byte("generated"))
}
func (e *variantEnv) seedLibrary() {
library := []struct {
id uint32
filename string
storedName string
contentType string
size int64
sha string
}{
{1, "north-wall.jpg", "a1b2.jpg", "image/jpeg", 4089446, "hash-north"},
{2, "floorboards.jpg", "f2f2.jpg", "image/jpeg", 2516582, "hash-floor"},
{3, "floorboards-copy.jpg", "f3f3.jpg", "image/jpeg", 2516582, "hash-floor"},
{4, "window-frame.jpg", "w4w4.jpg", "image/jpeg", 812345, "hash-window"},
{5, "logo.svg", "e5f6.svg", "image/svg+xml", 1204, "hash-logo"},
}
for _, entry := range library {
img := e.seedPerImage(entry.id, entry.filename, entry.storedName, entry.contentType, 100, 100, nil)
img.Size, img.SHA256 = entry.size, entry.sha
e.images.images[entry.id] = img
}
e.articles.articles[1] = article.Article{ID: 1, Title: "Walls", Body: "see /static/uploadedImages/a1b2.jpg and /static/uploadedImages/f2f2.jpg", IsOnline: true}
for variantName, size := range map[string]int64{"thumb": 18432, "card": 65536, "hero": 98304, "square": 83968} {
e.saveRow(variantName, "a1b2.jpg", size)
}
e.saveRow("thumb", "f2f2.jpg", 41000)
for _, variantName := range []string{"old-a", "old-b", "old-c"} {
e.saveRow(variantName, "w4w4.jpg", 1000000)
}
}
func (e *variantEnv) listIds(query string) ([]float64, map[string]any) {
e.t.Helper()
recorder := e.send(http.MethodGet, "/v1/media/"+query, "", variantOwnerId)
if recorder.Code != 200 {
e.t.Fatalf("list %q = %d %q", query, recorder.Code, recorder.Body.String())
}
body := decodeBody(e.t, recorder)
ids := []float64{}
for _, item := range body["items"].([]any) {
ids = append(ids, item.(map[string]any)["id"].(float64))
}
return ids, body
}
func itemWithId(t *testing.T, body map[string]any, id float64) map[string]any {
t.Helper()
for _, item := range body["items"].([]any) {
if item.(map[string]any)["id"] == id {
return item.(map[string]any)
}
}
t.Fatalf("no item %v", id)
return nil
}
func TestMediaListCarriesVariantFields(t *testing.T) {
t.Run("Every item carries variant_count and variant_size", func(t *testing.T) {
env := newVariantEnv(t)
env.seedLibrary()
ids, body := env.listIds("")
if !slices.Equal(ids, []float64{5, 4, 3, 2, 1}) {
t.Fatalf("order = %v", ids)
}
expected := map[float64][2]float64{1: {4, 266240}, 2: {1, 41000}, 3: {0, 0}, 4: {3, 3000000}, 5: {0, 0}}
for id, want := range expected {
item := itemWithId(t, body, id)
if item["variant_count"] != want[0] || item["variant_size"] != want[1] {
t.Errorf("item %v = %v", id, item)
}
}
if itemWithId(t, body, 2)["duplicate"] != true || itemWithId(t, body, 1)["used_in"] != 1.0 {
t.Error("duplicate or used_in changed")
}
})
t.Run("The list reads the totals of all sources in one call", func(t *testing.T) {
env := newVariantEnv(t)
env.seedLibrary()
env.listIds("")
if env.files.totalsBySourceCalls != 1 {
t.Errorf("calls = %d", env.files.totalsBySourceCalls)
}
})
t.Run("A rename answers the same fields", func(t *testing.T) {
env := newVariantEnv(t)
env.seedLibrary()
recorder := env.send(http.MethodPatch, "/v1/media/1", `{"filename": "north-wall-01"}`, variantOwnerId)
body := decodeBody(t, recorder)
if recorder.Code != 200 || body["variant_count"] != 4.0 || body["variant_size"] != 266240.0 {
t.Errorf("response = %d %q", recorder.Code, recorder.Body.String())
}
})
}
func TestMediaListVariantsFilterAndSort(t *testing.T) {
filters := map[string][]float64{"with": {1, 2, 4}, "without": {3, 5}}
for variants, want := range filters {
t.Run("variants narrows the list and the counts stay library-wide: "+variants, func(t *testing.T) {
env := newVariantEnv(t)
env.seedLibrary()
ids, body := env.listIds("?variants=" + variants)
slices.Sort(ids)
counts := body["counts"].(map[string]any)
if !slices.Equal(ids, want) || counts["all"] != 5.0 || counts["unused"] != 3.0 || counts["duplicates"] != 1.0 {
t.Errorf("ids = %v, counts = %v", ids, counts)
}
})
}
t.Run("variants, filter, q and sort combine", func(t *testing.T) {
env := newVariantEnv(t)
env.seedLibrary()
unused, _ := env.listIds("?variants=with&filter=unused")
searched, _ := env.listIds("?variants=with&q=floorboards")
if !slices.Equal(unused, []float64{4}) || !slices.Equal(searched, []float64{2}) {
t.Errorf("unused = %v, searched = %v", unused, searched)
}
})
t.Run("sort=total_size orders by original plus variants, largest first, then by id", func(t *testing.T) {
env := newVariantEnv(t)
env.seedLibrary()
ids, _ := env.listIds("?sort=total_size")
if !slices.Equal(ids, []float64{1, 4, 2, 3, 5}) {
t.Errorf("order = %v", ids)
}
})
t.Run("sort=largest is unchanged and sorts by the original alone", func(t *testing.T) {
env := newVariantEnv(t)
env.seedLibrary()
ids, _ := env.listIds("?sort=largest")
if !slices.Equal(ids, []float64{1, 2, 3, 4, 5}) {
t.Errorf("order = %v", ids)
}
})
t.Run("Equal totals fall back to the lowest id first", func(t *testing.T) {
env := newVariantEnv(t)
env.seedLibrary()
third := env.images.images[3]
third.Size = 2557582
env.images.images[3] = third
ids, _ := env.listIds("?sort=total_size")
if !slices.Equal(ids, []float64{1, 4, 2, 3, 5}) {
t.Errorf("order = %v", ids)
}
})
t.Run("variants=with and sort=total_size page through the list", func(t *testing.T) {
env := newVariantEnv(t)
env.seedLibrary()
first, firstBody := env.listIds("?variants=with&sort=total_size&limit=2")
cursor, _ := firstBody["next_cursor"].(string)
second, secondBody := env.listIds("?variants=with&sort=total_size&limit=2&cursor=" + url.QueryEscape(cursor))
if !slices.Equal(first, []float64{1, 4}) || cursor == "" || !slices.Equal(second, []float64{2}) || secondBody["next_cursor"] != nil {
t.Errorf("first = %v (%q), second = %v (%v)", first, cursor, second, secondBody["next_cursor"])
}
})
t.Run("variants=with on a library with an empty cache answers an empty list", func(t *testing.T) {
env := newVariantEnv(t)
env.seedLibrary()
for _, row := range env.files.rows {
_ = env.files.Delete(row.ID)
}
recorder := env.send(http.MethodGet, "/v1/media/?variants=with", "", variantOwnerId)
if recorder.Code != 200 || !strings.Contains(recorder.Body.String(), `"items":[]`) || !strings.Contains(recorder.Body.String(), `"counts":`+libraryCounts) {
t.Errorf("response = %d %q", recorder.Code, recorder.Body.String())
}
})
for _, query := range []string{"variants=bogus", "variants=all", "variants=With", "variants=", "sort=Total_size", "sort=total"} {
t.Run("A malformed query answers 400: "+query, func(t *testing.T) {
env := newVariantEnv(t)
env.seedLibrary()
recorder := env.send(http.MethodGet, "/v1/media/?"+query, "", variantOwnerId)
assertStatusAndBody(t, recorder, 400, apperr.MediaQueryErrorMessage)
})
}
}
func TestMediaLibraryVariantConsistency(t *testing.T) {
t.Run("The number shown by a delete is the number a delete removes", func(t *testing.T) {
env := newVariantEnv(t)
env.seedLibrary()
env.storage.seed("a1b2.jpg", []byte("original"))
recorder := env.send(http.MethodDelete, "/v1/media/1", "", variantOwnerId)
deleted := env.storage.deletedNames()
slices.Sort(deleted)
expected := []string{"a1b2.jpg", "cached-card-a1b2.jpg", "cached-hero-a1b2.jpg", "cached-square-a1b2.jpg", "cached-thumb-a1b2.jpg"}
if recorder.Code != 204 || !slices.Equal(deleted, expected) || len(env.files.rowsOf("hero")) != 0 || env.files.rowCount() != 4 {
t.Errorf("status = %d, deleted = %v, rows = %d", recorder.Code, deleted, env.files.rowCount())
}
})
t.Run("An article that uses a variant url still counts as using the image", func(t *testing.T) {
env := newVariantEnv(t)
env.seedLibrary()
env.articles.articles[1] = article.Article{ID: 1, Title: "Walls", Body: "![x](/static/uploadedImages/a1b2.jpg?v=hero)", IsOnline: true}
usage := env.send(http.MethodGet, "/v1/media/1/usage", "", variantOwnerId)
_, body := env.listIds("")
if usage.Code != 200 || !strings.Contains(usage.Body.String(), `"id":1`) || itemWithId(t, body, 1)["used_in"] != 1.0 {
t.Errorf("usage = %d %q", usage.Code, usage.Body.String())
}
})
t.Run("Merging keeps the variant parameter of the switched url", func(t *testing.T) {
env := newVariantEnv(t)
env.seedLibrary()
env.articles.articles[1] = article.Article{ID: 1, Title: "Floors", Body: "![x](/static/uploadedImages/f3f3.jpg?v=hero)", IsOnline: true}
recorder := env.send(http.MethodPost, "/v1/media/merge", `{"keep_id": 2, "merge_ids": [3]}`, variantOwnerId)
if recorder.Code != 200 || !strings.Contains(env.articles.articles[1].Body, "/static/uploadedImages/f2f2.jpg?v=hero") {
t.Errorf("status = %d, body = %q", recorder.Code, env.articles.articles[1].Body)
}
})
}
+19 -18
View File
@@ -128,24 +128,25 @@ func newRuntimeSettingsContainer(t *testing.T) *api.UseCases {
token := jwtauth.New("HS256", []byte("test-secret"), nil)
return &api.UseCases{
Token: token,
CreateArticleUseCase: useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder),
GetArticleUseCase: useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
ListArticlesUseCase: useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}),
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder),
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
GetUserUseCase: useCases.NewGetUserUseCase(userRepo),
CreateUserUseCase: useCases.NewCreateUserUseCase(userRepo, hasher, mailRepo, "https://cms.example.com", discardRecorder),
AuthenticateUserUseCase: useCases.NewAuthenticateUserUseCase(userRepo, hasher, discardRecorder),
CancelRegistrationUseCase: useCases.NewCancelRegistrationUseCase(userRepo),
ListUsersUseCase: useCases.NewListUsersUseCase(userRepo),
SendMailUseCase: useCases.NewSendMailUseCase(mailRepo),
CreateImageUseCase: useCases.NewCreateImageUseCase(imageRepo, newMockImageStorage(), discardRecorder),
ListMediaUseCase: useCases.NewListMediaUseCase(imageRepo, articleRepo, userRepo),
RenameMediaUseCase: useCases.NewRenameMediaUseCase(imageRepo, discardRecorder),
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(imageRepo, newMockImageStorage(), newMockVariantFileRepo(), discardRecorder),
MergeMediaUseCase: useCases.NewMergeMediaUseCase(imageRepo, articleRepo, newMockImageStorage(), newMockVariantFileRepo(), discardRecorder),
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(imageRepo, articleRepo),
Token: token,
CreateArticleUseCase: useCases.NewCreateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder),
GetArticleUseCase: useCases.NewGetArticleUseCase(articleRepo, &fakeBodyRenderer{}),
ListArticlesUseCase: useCases.NewListArticlesUseCase(articleRepo, &fakeBodyRenderer{}),
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(articleRepo, &fakeBodyRenderer{}, discardRecorder),
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
GetUserUseCase: useCases.NewGetUserUseCase(userRepo),
CreateUserUseCase: useCases.NewCreateUserUseCase(userRepo, hasher, mailRepo, "https://cms.example.com", discardRecorder),
AuthenticateUserUseCase: useCases.NewAuthenticateUserUseCase(userRepo, hasher, discardRecorder),
CancelRegistrationUseCase: useCases.NewCancelRegistrationUseCase(userRepo),
ListUsersUseCase: useCases.NewListUsersUseCase(userRepo),
SendMailUseCase: useCases.NewSendMailUseCase(mailRepo),
CreateImageUseCase: useCases.NewCreateImageUseCase(imageRepo, newMockImageStorage(), discardRecorder),
GenerateVariantsOnUploadUseCase: newInertGenerateOnUploadUseCase(),
ListMediaUseCase: useCases.NewListMediaUseCase(imageRepo, articleRepo, userRepo, newMockVariantFileRepo()),
RenameMediaUseCase: useCases.NewRenameMediaUseCase(imageRepo, discardRecorder),
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(imageRepo, newMockImageStorage(), newMockVariantFileRepo(), discardRecorder),
MergeMediaUseCase: useCases.NewMergeMediaUseCase(imageRepo, articleRepo, newMockImageStorage(), newMockVariantFileRepo(), discardRecorder),
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(imageRepo, articleRepo),
}
}