feat: image variants per image
This commit is contained in:
1 parent
f567866161
commit
951fef18ba
92 files changed
+8695
-413
No files matched your search
+37
-36
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
@@ -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{}))
|
||||
|
||||
@@ -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: "", 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: "", 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user