feat(API): media field type

This commit is contained in:
Florian Sylvain committed 2026-10-21 05:07:36 +02:00
1 parent ad5faf3d19
commit a2d596f4ca
63 files changed
+2567 -259

No files matched your search

+1 -1
View File
@@ -383,7 +383,7 @@ func TestActivityLogProducersScheduledPurge(t *testing.T) {
images := &producerBlockingImageRepo{mockImageRepo: newMockImageRepo(), entered: make(chan struct{}, 2), release: make(chan struct{})}
articles := newMockArticleRepo()
storage := newMockImageStorage()
merge := useCases.NewMergeMediaUseCase(images, articles, storage, newMockVariantFileRepo(), h.recorder, plainSites(), plainAuthors())
merge := useCases.NewMergeMediaUseCase(images, articles, storage, newMockVariantFileRepo(), h.recorder, plainSites(), plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo())
run := useCases.NewRunPurgeUseCase(images, articles, merge, newMockPurgeSettingsRepo(), func() time.Time { return now }, log.New(io.Discard, "", 0), h.recorder, plainSites(), plainAuthors())
finished := make(chan struct{})
+3 -3
View File
@@ -100,7 +100,7 @@ func newAPIKeyEnvRecording(t *testing.T, recorder audit.Recorder) *apiKeyEnv {
mail := newMockMailRepo()
env.mail = mail
renderer := &fakeBodyRenderer{}
mergeMedia := useCases.NewMergeMediaUseCase(env.images, env.articles, newMockImageStorage(), newMockVariantFileRepo(), recorder, plainSites(), plainAuthors())
mergeMedia := useCases.NewMergeMediaUseCase(env.images, env.articles, newMockImageStorage(), newMockVariantFileRepo(), recorder, plainSites(), plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo())
purgeSettings := newMockPurgeSettingsRepo()
restartJobs := &fakeJobStore{}
env.container = &api.UseCases{
@@ -127,13 +127,13 @@ func newAPIKeyEnvRecording(t *testing.T, recorder audit.Recorder) *apiKeyEnv {
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(), plainSites(), plainAuthors()),
ListMediaUseCase: useCases.NewListMediaUseCase(env.images, env.articles, env.users, newMockVariantFileRepo(), plainSites(), plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo()),
RenameMediaUseCase: useCases.NewRenameMediaUseCase(env.images, recorder),
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(env.images, newMockImageStorage(), newMockVariantFileRepo(), recorder, plainSites(), plainAuthors(), env.articles),
MergeMediaUseCase: mergeMedia,
RunPurgeUseCase: useCases.NewRunPurgeUseCase(env.images, env.articles, mergeMedia, purgeSettings, clock, log.New(io.Discard, "", 0), recorder, plainSites(), plainAuthors()),
RequestRestartUseCase: useCases.NewRequestRestartUseCase(env.updates, restartJobs, &fakeHelperDetector{installed: true}, &fakeResultStore{}, &fakeRestartRequestStore{}, apiKeyRunningVersion, clock, recorder),
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(env.images, env.articles, plainSites(), plainAuthors()),
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(env.images, env.articles, plainSites(), plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo()),
GetSettingsUseCase: useCases.NewGetSettingsUseCase(apiKeyRunningVersion),
GetActivityRetentionUseCase: useCases.NewGetActivityRetentionUseCase(newActivitySettingsFake(), newSizeEvents(t), time.Now),
GetUpdateStatusUseCase: newUpdateStatusUseCase(env.updates, &fakeJobStore{}, &fakeHelperDetector{}, apiKeyRunningVersion),
+3 -3
View File
@@ -355,14 +355,14 @@ func newAuthorEnv(t *testing.T) *authorEnv {
sites := plainSites()
recorder := p.audit.recorder
renderer := &fakeBodyRenderer{}
mergeMedia := useCases.NewMergeMediaUseCase(p.images, p.articles, newMockImageStorage(), newMockVariantFileRepo(), recorder, sites, authors)
mergeMedia := useCases.NewMergeMediaUseCase(p.images, p.articles, newMockImageStorage(), newMockVariantFileRepo(), recorder, sites, authors, newMockContentTypeRepo(), newMockEntryRepo())
c := p.container
c.CreateArticleUseCase = useCases.NewCreateArticleUseCase(p.articles, renderer, recorder, sites, authors)
c.UpdateArticleUseCase = useCases.NewUpdateArticleUseCase(p.articles, renderer, recorder, sites, authors, p.images)
c.ListMediaUseCase = useCases.NewListMediaUseCase(p.images, p.articles, p.users, newMockVariantFileRepo(), sites, authors)
c.ListMediaUseCase = useCases.NewListMediaUseCase(p.images, p.articles, p.users, newMockVariantFileRepo(), sites, authors, newMockContentTypeRepo(), newMockEntryRepo())
c.DeleteMediaUseCase = useCases.NewDeleteMediaUseCase(p.images, newMockImageStorage(), newMockVariantFileRepo(), recorder, sites, authors, p.articles)
c.MergeMediaUseCase = mergeMedia
c.GetMediaUsageUseCase = useCases.NewGetMediaUsageUseCase(p.images, p.articles, sites, authors)
c.GetMediaUsageUseCase = useCases.NewGetMediaUsageUseCase(p.images, p.articles, sites, authors, newMockContentTypeRepo(), newMockEntryRepo())
c.RunPurgeUseCase = useCases.NewRunPurgeUseCase(p.images, p.articles, mergeMedia, newMockPurgeSettingsRepo(), func() time.Time { return p.now }, log.New(io.Discard, "", 0), recorder, sites, authors)
c.ListActivityUseCase = useCases.NewListActivityUseCase(p.audit.store, p.users, p.articles, p.images, p.keys, p.audit.clock, sites, authors)
c.ListAuthorsUseCase = useCases.NewListAuthorsUseCase(authors, p.users)
@@ -424,7 +424,7 @@ func TestBuiltinTypeHTTP(t *testing.T) {
ListBuiltinTypesUseCase: useCases.NewListBuiltinTypesUseCase(),
GetBuiltinTypeUseCase: useCases.NewGetBuiltinTypeUseCase(),
CreateContentTypeUseCase: useCases.NewCreateContentTypeUseCase(panickingContentTypeRepo{}, now),
CreateEntryUseCase: useCases.NewCreateEntryUseCase(panickingContentTypeRepo{}, panickingEntryRepo{}, now),
CreateEntryUseCase: useCases.NewCreateEntryUseCase(panickingContentTypeRepo{}, panickingEntryRepo{}, newMockImageRepo(), now),
}
router := chi.NewRouter()
router.Mount("/v1", api.InitBackendRoutes(container, config.Settings{}))
+15 -7
View File
@@ -213,6 +213,8 @@ func (m *mockEntryRepo) FindByFieldValue(contentTypeID uint32, fieldKey string,
type contentTypeEnv struct {
types *mockContentTypeRepo
entries *mockEntryRepo
images *mockImageRepo
container *api.UseCases
users *mockUserRepo
keys *mockAPIKeyRepo
generator *hashing.SHA256KeyGenerator
@@ -228,6 +230,7 @@ func newContentTypeEnv(t *testing.T) *contentTypeEnv {
env := &contentTypeEnv{
types: newMockContentTypeRepo(),
entries: entries,
images: newMockImageRepo(),
users: newMockUserRepo(),
keys: newMockAPIKeyRepo(),
generator: hashing.NewSHA256KeyGenerator(),
@@ -253,18 +256,23 @@ func newContentTypeEnv(t *testing.T) *contentTypeEnv {
ListBuiltinTypesUseCase: useCases.NewListBuiltinTypesUseCase(),
GetBuiltinTypeUseCase: useCases.NewGetBuiltinTypeUseCase(),
LookupUserNamesUseCase: useCases.NewLookupUserNamesUseCase(env.users),
CreateEntryUseCase: useCases.NewCreateEntryUseCase(env.types, env.entries, now),
GetEntryUseCase: useCases.NewGetEntryUseCase(env.types, env.entries),
ListEntriesUseCase: useCases.NewListEntriesUseCase(env.types, env.entries),
UpdateEntryUseCase: useCases.NewUpdateEntryUseCase(env.types, env.entries, now),
CreateEntryUseCase: useCases.NewCreateEntryUseCase(env.types, env.entries, env.images, now),
GetEntryUseCase: useCases.NewGetEntryUseCase(env.types, env.entries, env.images),
ListEntriesUseCase: useCases.NewListEntriesUseCase(env.types, env.entries, env.images),
UpdateEntryUseCase: useCases.NewUpdateEntryUseCase(env.types, env.entries, env.images, now),
DeleteEntryUseCase: useCases.NewDeleteEntryUseCase(env.types, env.entries),
}
router := chi.NewRouter()
router.Mount("/v1", api.InitBackendRoutes(container, config.Settings{}))
env.router = router
env.container = container
env.mountRouter()
return env
}
func (e *contentTypeEnv) mountRouter() {
router := chi.NewRouter()
router.Mount("/v1", api.InitBackendRoutes(e.container, config.Settings{}))
e.router = router
}
func (e *contentTypeEnv) request(t *testing.T, method string, path string, body string, mutate func(*http.Request)) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(method, path, strings.NewReader(body))
+1 -1
View File
@@ -13,7 +13,7 @@ import (
var (
contentTypeKeys = []string{"created_at", "fields", "icon", "id", "key", "name", "updated_at"}
fieldKeys = []string{"default", "key", "label", "max", "min", "options", "required", "type", "unique"}
entryKeys = []string{"content_type", "created_at", "created_by", "data", "id", "updated_at", "updated_by"}
entryKeys = []string{"content_type", "created_at", "created_by", "data", "id", "media", "updated_at", "updated_by"}
)
func TestCreatingAContentType(t *testing.T) {
+4 -4
View File
@@ -64,7 +64,7 @@ func TestCreateEntryUseCase(t *testing.T) {
}
for _, data := range breaking {
fixture, _ := newContentTypeFixture(t)
create := useCases.NewCreateEntryUseCase(fixture.types, fixture.entries, fixture.now)
create := useCases.NewCreateEntryUseCase(fixture.types, fixture.entries, newMockImageRepo(), fixture.now)
_, err := create.Create(useCases.CreateEntryCommand{ContentTypeKey: "recipe", Data: data, UserID: 2})
@@ -76,7 +76,7 @@ func TestCreateEntryUseCase(t *testing.T) {
t.Run("Use case: a duplicate unique value returns ErrUniqueViolation", func(t *testing.T) {
fixture, _ := newContentTypeFixture(t)
create := useCases.NewCreateEntryUseCase(fixture.types, fixture.entries, fixture.now)
create := useCases.NewCreateEntryUseCase(fixture.types, fixture.entries, newMockImageRepo(), fixture.now)
data := func() map[string]any { return map[string]any{"title": "Pad thai", "difficulty": "easy"} }
if _, err := create.Create(useCases.CreateEntryCommand{ContentTypeKey: "recipe", Data: data(), UserID: 2}); err != nil {
t.Fatalf("first create: %v", err)
@@ -91,7 +91,7 @@ func TestCreateEntryUseCase(t *testing.T) {
t.Run("Use case: the injected clock stamps the entry", func(t *testing.T) {
fixture, _ := newContentTypeFixture(t)
create := useCases.NewCreateEntryUseCase(fixture.types, fixture.entries, fixture.now)
create := useCases.NewCreateEntryUseCase(fixture.types, fixture.entries, newMockImageRepo(), fixture.now)
created, err := create.Create(useCases.CreateEntryCommand{ContentTypeKey: "recipe", Data: map[string]any{"title": "Pad thai", "difficulty": "easy"}, UserID: 2})
@@ -551,7 +551,7 @@ func TestEntrySortRules(t *testing.T) {
func TestEntryPagingAndEquality(t *testing.T) {
t.Run("Pages beyond the maximum are refused by the use case", func(t *testing.T) {
fixture, _ := newContentTypeFixture(t)
list := useCases.NewListEntriesUseCase(fixture.types, fixture.entries)
list := useCases.NewListEntriesUseCase(fixture.types, fixture.entries, newMockImageRepo())
cases := []struct {
page, size int
sort entry.Sort
+3 -3
View File
@@ -115,7 +115,7 @@ func (e *variantEnv) container(limits useCases.ServeVariantLimits) *api.UseCases
GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()),
OpenMediaUseCase: useCases.NewOpenMediaUseCase(e.storage),
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(e.images, e.storage, e.files, recorder, plainSites(), plainAuthors(), e.articles),
MergeMediaUseCase: useCases.NewMergeMediaUseCase(e.images, e.articles, e.storage, e.files, recorder, plainSites(), plainAuthors()),
MergeMediaUseCase: useCases.NewMergeMediaUseCase(e.images, e.articles, e.storage, e.files, recorder, plainSites(), plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo()),
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),
@@ -129,9 +129,9 @@ func (e *variantEnv) container(limits useCases.ServeVariantLimits) *api.UseCases
VariantUsageTracker: e.tracker,
CreateImageUseCase: useCases.NewCreateImageUseCase(e.images, e.storage, recorder),
ListMediaUseCase: useCases.NewListMediaUseCase(e.images, e.articles, e.users, e.files, plainSites(), plainAuthors()),
ListMediaUseCase: useCases.NewListMediaUseCase(e.images, e.articles, e.users, e.files, plainSites(), plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo()),
RenameMediaUseCase: useCases.NewRenameMediaUseCase(e.images, recorder),
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(e.images, e.articles, plainSites(), plainAuthors()),
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(e.images, e.articles, plainSites(), plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo()),
ListImageVariantsUseCase: useCases.NewListImageVariantsUseCase(e.images, e.variants, e.files, e.renderer),
GenerateImageVariantUseCase: useCases.NewGenerateImageVariantUseCase(e.images, e.variants, e.files, e.renderer, serve),
+1 -1
View File
@@ -25,7 +25,7 @@ func TestMediaMergeRewritesArticlesInIdOrder(t *testing.T) {
env.seedArticle(id, "Article", true, env.urlOf(3))
}
recorder := &mediaOrderRecordingArticleRepo{mockArticleRepo: env.articles}
uc := useCases.NewMergeMediaUseCase(env.images, recorder, env.storage, newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors())
uc := useCases.NewMergeMediaUseCase(env.images, recorder, env.storage, newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo())
if _, err := uc.Merge(testCtx, 2, []uint32{3}); err != nil {
t.Fatalf("unexpected error: %v", err)
+4 -4
View File
@@ -62,7 +62,7 @@ func newMediaEnv(t *testing.T) *mediaEnv {
env.users.users[mediaUnverifiedUserId] = user.User{ID: 3, Username: "pending", IsVerified: false}
env.users.users[mediaStaleUserId] = user.User{ID: 4, Username: "stale", IsVerified: true, TokenVersion: 1}
merge := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage, newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors())
merge := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage, newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo())
now := func() time.Time { return env.clock }
container := &api.UseCases{
Token: env.token,
@@ -75,14 +75,14 @@ func newMediaEnv(t *testing.T) *mediaEnv {
GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()),
CreateImageUseCase: useCases.NewCreateImageUseCase(env.images, env.storage, discardRecorder),
GenerateVariantsOnUploadUseCase: newInertGenerateOnUploadUseCase(),
ListMediaUseCase: useCases.NewListMediaUseCase(env.images, env.articles, env.users, env.variants, plainSites(), plainAuthors()),
ListMediaUseCase: useCases.NewListMediaUseCase(env.images, env.articles, env.users, env.variants, plainSites(), plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo()),
RenameMediaUseCase: useCases.NewRenameMediaUseCase(env.images, discardRecorder),
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(env.images, env.storage, newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors(), env.articles),
MergeMediaUseCase: merge,
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(env.images, env.articles, plainSites(), plainAuthors()),
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(env.images, env.articles, plainSites(), plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo()),
GetPurgeSettingsUseCase: useCases.NewGetPurgeSettingsUseCase(env.purgeSettings),
UpdatePurgeSettingsUseCase: useCases.NewUpdatePurgeSettingsUseCase(env.purgeSettings, now),
PreviewPurgeUseCase: useCases.NewPreviewPurgeUseCase(env.images, env.articles, plainSites(), plainAuthors()),
PreviewPurgeUseCase: useCases.NewPreviewPurgeUseCase(env.images, env.articles, plainSites(), plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo()),
RunPurgeUseCase: useCases.NewRunPurgeUseCase(env.images, env.articles, merge, env.purgeSettings, now, log.New(env.logs, "", 0), discardRecorder, plainSites(), plainAuthors()),
}
router := chi.NewRouter()
+632
View File
@@ -0,0 +1,632 @@
package test
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/internal/domain/entry"
"net/http"
"reflect"
"strconv"
"strings"
"testing"
)
func limeMedia() map[string]any {
return map[string]any{"id": float64(mfLimeID), "filename": "lime-plaster.jpg", "width": float64(1600), "height": float64(900), "size": float64(240000), "thumbnail_url": mfImagePath}
}
func missingMedia(id int) map[string]any {
return map[string]any{"id": float64(id), "missing": true}
}
func entryPath(id uint32) string {
return "/v1/content-type/event/entry/" + strconv.Itoa(int(id))
}
func (e *mediaFieldEnv) storedFieldsJSON(t *testing.T, key string) string {
t.Helper()
stored, err := e.types.GetByKey(key)
if err != nil {
t.Fatalf("stored type %s: %v", key, err)
}
return jsonOf(t, stored.Fields)
}
func TestMediaFieldTypeContentTypes(t *testing.T) {
t.Run("Creating a content type with a media field", func(t *testing.T) {
env := newMediaFieldEnv(t)
rec := env.admin(t, http.MethodPost, "/v1/content-type", typeBody("event", "Events", eventFields(false)))
if rec.Code != http.StatusCreated {
t.Fatalf("status %d %s", rec.Code, rec.Body.String())
}
fields := decodeContentObject(t, rec)["fields"].([]any)
thumbnail := fields[1].(map[string]any)
if thumbnail["type"] != "media" || thumbnail["unique"] != false || thumbnail["default"] != nil || thumbnail["min"] != nil || thumbnail["max"] != nil || !reflect.DeepEqual(thumbnail["options"], []any{}) {
t.Errorf("unexpected field %v", thumbnail)
}
})
t.Run("A media field refuses options it can't have", func(t *testing.T) {
options := map[string]map[string]any{"unique true": map[string]any{"unique": true}, "default 7": map[string]any{"default": 7}, "default lime": map[string]any{"default": "lime"}, "min 1": map[string]any{"min": 1}, "max 5": map[string]any{"max": 5}, "options a": map[string]any{"options": []string{"a"}}}
for name, override := range options {
t.Run(name, func(t *testing.T) {
env := newMediaFieldEnv(t)
field := mediaField("thumbnail", false)
for key, value := range override {
field[key] = value
}
rec := env.admin(t, http.MethodPost, "/v1/content-type", typeBody("event", "Events", []map[string]any{{"key": "title", "label": "Title", "type": "text", "required": true}, field}))
expectStatusAndBody(t, rec, http.StatusUnprocessableEntity, "invalid_field")
if env.types.createCalls != 0 {
t.Errorf("a content type was stored")
}
})
}
})
t.Run("The type of a saved media field never changes", func(t *testing.T) {
env := newMediaFieldEnv(t)
env.createEventType(t, false)
env.seedEntry(t, "event", map[string]any{"title": "A"})
before := env.storedFieldsJSON(t, "event")
toText := []map[string]any{{"key": "title", "label": "Title", "type": "text", "required": true}, {"key": "thumbnail", "label": "Thumbnail", "type": "text"}}
toMedia := []map[string]any{{"key": "title", "label": "Title", "type": "media"}, mediaField("thumbnail", false)}
for _, fields := range [][]map[string]any{toText, toMedia} {
rec := env.admin(t, http.MethodPut, "/v1/content-type/event", typeBody("event", "Events", fields))
expectStatusAndBody(t, rec, http.StatusUnprocessableEntity, "invalid_field")
if env.storedFieldsJSON(t, "event") != before {
t.Errorf("schema changed")
}
}
})
t.Run("Adding a media field to a type that has entries", func(t *testing.T) {
examples := []struct {
name string
required bool
status int
message string
updated bool
}{{"required", true, http.StatusConflict, "schema_incompatible", false}, {"optional", false, http.StatusOK, "", true}}
for _, example := range examples {
t.Run(example.name, func(t *testing.T) {
env := newMediaFieldEnv(t)
env.createType(t, "event", []map[string]any{{"key": "title", "label": "Title", "type": "text", "required": true}})
for range 12 {
env.seedEntry(t, "event", map[string]any{"title": "A"})
}
before := env.storedFieldsJSON(t, "event")
rec := env.admin(t, http.MethodPut, "/v1/content-type/event", typeBody("event", "Events", eventFields(example.required)))
if rec.Code != example.status || (example.message != "" && strings.TrimSpace(rec.Body.String()) != example.message) {
t.Errorf("status %d body %q", rec.Code, rec.Body.String())
}
if (env.storedFieldsJSON(t, "event") != before) != example.updated {
t.Errorf("stored schema updated mismatch")
}
})
}
})
t.Run("Making a media field required", func(t *testing.T) {
env := newMediaFieldEnv(t)
env.createEventType(t, false)
env.seedEntry(t, "event", map[string]any{"title": "A", "thumbnail": float64(mfLimeID)})
nullEntry := env.seedEntry(t, "event", map[string]any{"title": "B", "thumbnail": nil})
rec := env.admin(t, http.MethodPut, "/v1/content-type/event", typeBody("event", "Events", eventFields(true)))
expectStatusAndBody(t, rec, http.StatusConflict, "schema_incompatible")
env.admin(t, http.MethodDelete, entryPath(nullEntry.ID), "")
rec = env.admin(t, http.MethodPut, "/v1/content-type/event", typeBody("event", "Events", eventFields(true)))
expectStatusAndBody(t, rec, http.StatusOK, "")
})
t.Run("Making a media field required while a stored image is gone", func(t *testing.T) {
env := newMediaFieldEnv(t)
env.createEventType(t, false)
env.seedEntry(t, "event", map[string]any{"title": "A", "thumbnail": float64(999)})
rec := env.admin(t, http.MethodPut, "/v1/content-type/event", typeBody("event", "Events", eventFields(true)))
expectStatusAndBody(t, rec, http.StatusOK, "")
})
}
func TestMediaFieldTypeEntries(t *testing.T) {
t.Run("Creating an entry with an image", func(t *testing.T) {
env := newMediaFieldEnv(t)
env.createEventType(t, false)
rec := env.createEntry(t, "event", ctAdminId, `{"title":"Lime plaster workshop","thumbnail":7}`)
if rec.Code != http.StatusCreated {
t.Fatalf("status %d %s", rec.Code, rec.Body.String())
}
body := decodeContentObject(t, rec)
if body["data"].(map[string]any)["thumbnail"] != float64(mfLimeID) || !reflect.DeepEqual(body["media"], map[string]any{"thumbnail": limeMedia()}) {
t.Errorf("unexpected %v", body)
}
})
t.Run("An empty optional media field", func(t *testing.T) {
examples := map[string]string{`null`: `{"title":"A","thumbnail":null}`, `absent`: `{"title":"A"}`}
for name, data := range examples {
t.Run(name, func(t *testing.T) {
env := newMediaFieldEnv(t)
env.createEventType(t, false)
rec := env.createEntry(t, "event", ctAdminId, data)
body := decodeContentObject(t, rec)
stored, present := body["data"].(map[string]any)["thumbnail"]
if rec.Code != http.StatusCreated || !reflect.DeepEqual(body["media"], map[string]any{}) || (name == "null" && (!present || stored != nil)) || (name == "absent" && present) {
t.Errorf("status %d body %v", rec.Code, body)
}
})
}
})
t.Run("Entry values that break a media field are refused and store nothing", func(t *testing.T) {
examples := []struct {
required bool
data string
message string
}{
{true, `{"title":"A"}`, "invalid_entry: thumbnail: required"},
{true, `{"title":"A","thumbnail":null}`, "invalid_entry: thumbnail: required"},
{false, `{"title":"A","thumbnail":999}`, "invalid_entry: thumbnail: type"},
{false, `{"title":"A","thumbnail":"7"}`, "invalid_entry: thumbnail: type"},
{false, `{"title":"A","thumbnail":0}`, "invalid_entry: thumbnail: type"},
{false, `{"title":"A","thumbnail":-1}`, "invalid_entry: thumbnail: type"},
{false, `{"title":"A","thumbnail":7.5}`, "invalid_entry: thumbnail: type"},
{false, `{"title":"A","thumbnail":4294967296}`, "invalid_entry: thumbnail: type"},
{false, `{"title":"A","thumbnail":true}`, "invalid_entry: thumbnail: type"},
{false, `{"title":"A","thumbnail":[7]}`, "invalid_entry: thumbnail: type"},
}
for _, example := range examples {
t.Run(example.data, func(t *testing.T) {
env := newMediaFieldEnv(t)
env.createEventType(t, example.required)
rec := env.createEntry(t, "event", ctAdminId, example.data)
expectStatusAndBody(t, rec, http.StatusUnprocessableEntity, example.message)
if len(env.entries.entries) != 0 {
t.Errorf("an entry was stored")
}
})
}
})
t.Run("Replacing the image of an entry", func(t *testing.T) {
env := newMediaFieldEnv(t)
env.createEventType(t, false)
stored := env.seedEntry(t, "event", map[string]any{"title": "Lime plaster workshop", "thumbnail": float64(mfLimeID)})
rec := env.member(t, http.MethodPut, entryPath(stored.ID), `{"data":{"title":"Lime plaster workshop","thumbnail":8}}`)
body := decodeContentObject(t, rec)
thumbnail, _ := body["media"].(map[string]any)["thumbnail"].(map[string]any)
if rec.Code != http.StatusOK || body["data"].(map[string]any)["thumbnail"] != float64(mfBathroomID) || thumbnail["filename"] != "bathroom-tiles.png" {
t.Errorf("status %d body %v", rec.Code, body)
}
rec = env.member(t, http.MethodPut, entryPath(stored.ID), `{"data":{"title":"Lime plaster workshop","thumbnail":null}}`)
body = decodeContentObject(t, rec)
if rec.Code != http.StatusOK || body["data"].(map[string]any)["thumbnail"] != nil || !reflect.DeepEqual(body["media"], map[string]any{}) {
t.Errorf("status %d body %v", rec.Code, body)
}
})
t.Run("An image deleted after the entry was saved", func(t *testing.T) {
env := newMediaFieldEnv(t)
env.createEventType(t, false)
stored := env.seedEntry(t, "event", map[string]any{"title": "A", "thumbnail": float64(mfLimeID)})
deleted := env.member(t, http.MethodDelete, "/v1/media/7", "")
rec := env.member(t, http.MethodGet, entryPath(stored.ID), "")
body := decodeContentObject(t, rec)
if deleted.Code != http.StatusNoContent || body["data"].(map[string]any)["thumbnail"] != float64(mfLimeID) || !reflect.DeepEqual(body["media"], map[string]any{"thumbnail": missingMedia(mfLimeID)}) {
t.Errorf("delete %d body %v", deleted.Code, body)
}
})
t.Run("An unchanged id whose image is gone can still be saved when the field is optional", func(t *testing.T) {
env := newMediaFieldEnv(t)
env.createEventType(t, false)
stored := env.seedEntry(t, "event", map[string]any{"title": "A", "thumbnail": float64(mfLimeID)})
delete(env.images.images, mfLimeID)
rec := env.member(t, http.MethodPut, entryPath(stored.ID), `{"data":{"title":"New title","thumbnail":7}}`)
body := decodeContentObject(t, rec)
if rec.Code != http.StatusOK || body["data"].(map[string]any)["thumbnail"] != float64(mfLimeID) || !reflect.DeepEqual(body["media"], map[string]any{"thumbnail": missingMedia(mfLimeID)}) {
t.Errorf("status %d body %v", rec.Code, body)
}
})
t.Run("A changed value must exist even when the old one is gone", func(t *testing.T) {
examples := []struct {
value string
status int
message string
}{{"999", http.StatusUnprocessableEntity, "invalid_entry: thumbnail: type"}, {"8", http.StatusOK, ""}, {"null", http.StatusOK, ""}}
for _, example := range examples {
t.Run(example.value, func(t *testing.T) {
env := newMediaFieldEnv(t)
env.createEventType(t, false)
stored := env.seedEntry(t, "event", map[string]any{"title": "A", "thumbnail": float64(mfLimeID)})
delete(env.images.images, mfLimeID)
rec := env.member(t, http.MethodPut, entryPath(stored.ID), `{"data":{"title":"A","thumbnail":`+example.value+`}}`)
expectStatusAndBody(t, rec, example.status, example.message)
})
}
})
t.Run("A required media field with a missing image can't be saved as is", func(t *testing.T) {
examples := []struct {
value string
status int
message string
updated bool
}{{"7", http.StatusUnprocessableEntity, "invalid_entry: thumbnail: required", false}, {"null", http.StatusUnprocessableEntity, "invalid_entry: thumbnail: required", false}, {"8", http.StatusOK, "", true}}
for _, example := range examples {
t.Run(example.value, func(t *testing.T) {
env := newMediaFieldEnv(t)
env.createEventType(t, true)
stored := env.seedEntry(t, "event", map[string]any{"title": "A", "thumbnail": float64(mfLimeID)})
delete(env.images.images, mfLimeID)
rec := env.member(t, http.MethodPut, entryPath(stored.ID), `{"data":{"title":"A","thumbnail":`+example.value+`}}`)
expectStatusAndBody(t, rec, example.status, example.message)
if (env.entries.updateCalls == 1) != example.updated {
t.Errorf("updates %d", env.entries.updateCalls)
}
})
}
})
t.Run("A create always treats the value as changed", func(t *testing.T) {
env := newMediaFieldEnv(t)
env.createEventType(t, false)
delete(env.images.images, mfLimeID)
rec := env.createEntry(t, "event", ctAdminId, `{"title":"A","thumbnail":7}`)
expectStatusAndBody(t, rec, http.StatusUnprocessableEntity, "invalid_entry: thumbnail: type")
})
t.Run("Two broken fields name only one", func(t *testing.T) {
env := newMediaFieldEnv(t)
env.createType(t, "event", []map[string]any{{"key": "title", "label": "Title", "type": "text", "required": true}, mediaField("thumbnail", false)})
rec := env.createEntry(t, "event", ctAdminId, `{"thumbnail":999}`)
expectStatusAndBody(t, rec, http.StatusUnprocessableEntity, "invalid_entry: title: required")
})
t.Run("Entry list responses carry the media object per item", func(t *testing.T) {
env := newMediaFieldEnv(t)
env.createEventType(t, false)
env.seedEntry(t, "event", map[string]any{"title": "A", "thumbnail": float64(mfLimeID)})
env.seedEntry(t, "event", map[string]any{"title": "B", "thumbnail": float64(999)})
env.seedEntry(t, "event", map[string]any{"title": "C", "thumbnail": nil})
rec := env.member(t, http.MethodGet, "/v1/content-type/event/entry?sort=id:asc", "")
page := decodeContentObject(t, rec)
items := page["items"].([]any)
want := []any{map[string]any{"thumbnail": limeMedia()}, map[string]any{"thumbnail": missingMedia(999)}, map[string]any{}}
for position, item := range items {
if !reflect.DeepEqual(objectKeys(item.(map[string]any)), entryKeys) || !reflect.DeepEqual(item.(map[string]any)["media"], want[position]) {
t.Errorf("item %d: %v", position, item)
}
}
if len(items) != 3 || page["total"] != float64(3) || page["page"] != float64(1) || page["size"] != float64(25) {
t.Errorf("unexpected page %v", page)
}
})
t.Run("Types without a media field are not enriched", func(t *testing.T) {
env := newMediaFieldEnv(t)
env.createType(t, "recipe", recipeFields())
created := env.createEntry(t, "recipe", ctAdminId, `{"title":"Pad thai","difficulty":"easy"}`)
rec := env.member(t, http.MethodGet, "/v1/content-type/recipe/entry/"+strconv.Itoa(int(decodeContentObject(t, created)["id"].(float64))), "")
body := decodeContentObject(t, rec)
if rec.Code != http.StatusOK || !reflect.DeepEqual(objectKeys(body), entryKeys) || !reflect.DeepEqual(body["media"], map[string]any{}) || env.lookups.gets != 0 {
t.Errorf("status %d body %v lookups %d", rec.Code, body, env.lookups.gets)
}
})
t.Run("Each distinct image is looked up once per request", func(t *testing.T) {
env := newMediaFieldEnv(t)
env.createType(t, "gallery", []map[string]any{mediaField("cover", false), mediaField("banner", false)})
env.seedEntry(t, "gallery", map[string]any{"cover": float64(mfLimeID), "banner": float64(mfLimeID)})
env.seedEntry(t, "gallery", map[string]any{"cover": float64(mfLimeID), "banner": float64(mfBathroomID)})
rec := env.member(t, http.MethodGet, "/v1/content-type/gallery/entry", "")
if rec.Code != http.StatusOK || env.lookups.gets != 2 {
t.Errorf("status %d lookups %d", rec.Code, env.lookups.gets)
}
})
t.Run("A repository failure while resolving media is a 500", func(t *testing.T) {
env := newMediaFieldEnv(t)
env.createEventType(t, false)
stored := env.seedEntry(t, "event", map[string]any{"title": "A", "thumbnail": float64(mfLimeID)})
env.images.fail.failNext("Get")
rec := env.member(t, http.MethodGet, entryPath(stored.ID), "")
if rec.Code != http.StatusInternalServerError {
t.Errorf("status %d", rec.Code)
}
})
}
func TestMediaFieldTypeLibrary(t *testing.T) {
t.Run("Media list items count entries as uses", func(t *testing.T) {
env := newMediaFieldEnv(t)
env.createType(t, "event", []map[string]any{{"key": "title", "label": "Title", "type": "text"}, mediaField("thumbnail", false), mediaField("banner", false)})
env.seedArticleUsing(12, "Heat pumps", mfLimeID)
env.seedArticleUsing(13, "Insulation", mfLimeID)
for range 3 {
env.seedEntry(t, "event", map[string]any{"thumbnail": float64(mfBathroomID)})
}
env.entries.entries[1] = withData(env.entries.entries[1], map[string]any{"thumbnail": float64(mfBathroomID), "banner": float64(mfBathroomID)})
for range 5 {
env.seedEntry(t, "event", map[string]any{"thumbnail": float64(mfLimeID)})
}
list := decodeContentObject(t, env.member(t, http.MethodGet, "/v1/media/", ""))
one := decodeContentObject(t, env.member(t, http.MethodGet, "/v1/media/8", ""))
unused := decodeContentObject(t, env.member(t, http.MethodGet, "/v1/media/?filter=unused", ""))
usedIn := map[float64]float64{}
for _, item := range list["items"].([]any) {
usedIn[item.(map[string]any)["id"].(float64)] = item.(map[string]any)["used_in"].(float64)
}
if usedIn[mfBathroomID] != 3 || usedIn[mfLimeID] != 7 || one["used_in"] != float64(3) {
t.Errorf("used_in %v, one %v", usedIn, one["used_in"])
}
if len(unused["items"].([]any)) != 0 || unused["counts"].(map[string]any)["unused"] != float64(0) {
t.Errorf("unused %v", unused)
}
})
t.Run("Usage of an image lists articles and entries", func(t *testing.T) {
env := newMediaFieldEnv(t)
env.createType(t, "workshop", []map[string]any{{"key": "seats", "label": "Seats", "type": "number"}, mediaField("thumbnail", false)})
env.createEventType(t, false)
env.seedArticleUsing(12, "Heat pumps", mfLimeID)
workshop := env.seedEntry(t, "workshop", map[string]any{"seats": float64(18), "thumbnail": float64(mfLimeID)})
first := env.seedEntry(t, "event", map[string]any{"title": "Lime plaster workshop", "thumbnail": float64(mfLimeID)})
env.seedEntry(t, "event", map[string]any{"title": "Other", "thumbnail": float64(mfBathroomID)})
second := env.seedEntry(t, "event", map[string]any{"title": "Second", "thumbnail": float64(mfLimeID)})
third := env.seedEntry(t, "event", map[string]any{"title": "Third", "thumbnail": float64(mfLimeID)})
rec := env.member(t, http.MethodGet, "/v1/media/7/usage", "")
items := decodeContentArray(t, rec)
if rec.Code != http.StatusOK || len(items) != 5 {
t.Fatalf("status %d items %v", rec.Code, items)
}
wantArticle := map[string]any{"type": "article", "id": float64(12), "title": "Heat pumps", "published": true, "fields": []any{"cover"}}
if !reflect.DeepEqual(items[0], wantArticle) {
t.Errorf("article item %v", items[0])
}
wantWorkshop := map[string]any{"type": "entry", "id": float64(workshop.ID), "fields": []any{"thumbnail"}, "content_type_key": "workshop", "content_type_name": "Workshop", "first_field": map[string]any{"key": "seats", "type": "number", "value": float64(18)}}
if !reflect.DeepEqual(items[4], wantWorkshop) {
t.Errorf("workshop item %v", items[4])
}
for _, item := range items[1:4] {
if item["content_type_key"] != "event" || item["content_type_name"] != "Event" || item["title"] != nil || item["published"] != nil || item["type"] != "entry" {
t.Errorf("event item %v", item)
}
if len(item) != 6 {
t.Errorf("event item keys %v", item)
}
}
gotOrder := []float64{items[1]["id"].(float64), items[2]["id"].(float64), items[3]["id"].(float64), items[4]["id"].(float64)}
wantOrder := []float64{float64(first.ID), float64(second.ID), float64(third.ID), float64(workshop.ID)}
if !reflect.DeepEqual(gotOrder, wantOrder) {
t.Errorf("order after the article should be event %v then workshop: got %v", wantOrder[:3], gotOrder)
}
})
t.Run("The raw first field in usage", func(t *testing.T) {
examples := []struct {
name string
fieldType string
stored map[string]any
value any
}{
{"title", "text", map[string]any{"title": "Lime plaster workshop"}, "Lime plaster workshop"},
{"body", "richtext", map[string]any{"body": "\n\nFirst line\n\nSecond"}, "\n\nFirst line\n\nSecond"},
{"seats", "number", map[string]any{"seats": float64(18)}, float64(18)},
{"online", "boolean", map[string]any{"online": false}, false},
{"starts_on", "date", map[string]any{"starts_on": "2026-11-21"}, "2026-11-21"},
{"title", "text", map[string]any{"title": nil}, nil},
{"title", "text", map[string]any{}, nil},
}
for _, example := range examples {
t.Run(example.name+" "+example.fieldType, func(t *testing.T) {
env := newMediaFieldEnv(t)
env.createType(t, "event", []map[string]any{{"key": example.name, "label": "First", "type": example.fieldType}, mediaField("thumbnail", false)})
data := map[string]any{"thumbnail": float64(mfLimeID)}
for key, value := range example.stored {
data[key] = value
}
env.seedEntry(t, "event", data)
items := decodeContentArray(t, env.member(t, http.MethodGet, "/v1/media/7/usage", ""))
want := map[string]any{"key": example.name, "type": example.fieldType, "value": example.value}
if len(items) != 1 || !reflect.DeepEqual(items[0]["first_field"], want) || items[0]["title"] != nil {
t.Errorf("unexpected %v", items)
}
})
}
})
t.Run("The raw first field in usage for a media field", func(t *testing.T) {
env := newMediaFieldEnv(t)
env.createType(t, "event", []map[string]any{mediaField("cover", false)})
env.seedEntry(t, "event", map[string]any{"cover": float64(mfLimeID)})
items := decodeContentArray(t, env.member(t, http.MethodGet, "/v1/media/7/usage", ""))
want := map[string]any{"key": "cover", "type": "media", "value": float64(mfLimeID)}
if len(items) != 1 || !reflect.DeepEqual(items[0]["first_field"], want) || !reflect.DeepEqual(items[0]["fields"], []any{"cover"}) {
t.Errorf("unexpected %v", items)
}
})
t.Run("Usage of an unused or unknown image", func(t *testing.T) {
env := newMediaFieldEnv(t)
env.createEventType(t, false)
empty := env.member(t, http.MethodGet, "/v1/media/8/usage", "")
unknown := env.member(t, http.MethodGet, "/v1/media/999/usage", "")
malformed := env.member(t, http.MethodGet, "/v1/media/abc/usage", "")
expectStatusAndBody(t, empty, http.StatusOK, "[]")
expectStatusAndBody(t, unknown, http.StatusNotFound, apperr.ImageNotFoundErrorMessage)
expectStatusAndBody(t, malformed, http.StatusBadRequest, apperr.IdUint32ErrorMessage)
})
t.Run("Deleting an image used by entries is allowed", func(t *testing.T) {
env := newMediaFieldEnv(t)
env.createEventType(t, false)
env.seedArticleUsing(12, "Heat pumps", mfLimeID)
var before []entry.Entry
for range 5 {
before = append(before, env.seedEntry(t, "event", map[string]any{"title": "A", "thumbnail": float64(mfLimeID)}))
}
rec := env.member(t, http.MethodDelete, "/v1/media/7", "")
if rec.Code != http.StatusNoContent || env.entries.updateCalls != 0 {
t.Fatalf("status %d updates %d", rec.Code, env.entries.updateCalls)
}
for _, snapshot := range before {
if !reflect.DeepEqual(env.entries.entries[snapshot.ID], snapshot) {
t.Errorf("entry changed %v", env.entries.entries[snapshot.ID])
}
}
if _, gone := env.images.images[mfLimeID]; gone {
t.Errorf("image should be deleted")
}
})
t.Run("Merging duplicates repoints entry media", func(t *testing.T) {
env := newMergeEnv(t)
first := env.seedEntry(t, "event", map[string]any{"title": "A", "thumbnail": float64(mfCopyID)})
second := env.seedEntry(t, "event", map[string]any{"title": "B", "thumbnail": float64(mfCopyID)})
rec := env.member(t, http.MethodPost, "/v1/media/merge", `{"keep_id":7,"merge_ids":[9]}`)
if rec.Code != http.StatusOK {
t.Fatalf("status %d %s", rec.Code, rec.Body.String())
}
for _, original := range []entry.Entry{first, second} {
stored := env.entries.entries[original.ID]
if stored.Data["thumbnail"] != float64(mfLimeID) || stored.CreatedBy != original.CreatedBy || stored.UpdatedBy != original.UpdatedBy || !stored.UpdatedAt.Equal(original.UpdatedAt) {
t.Errorf("entry %d: %v", original.ID, stored)
}
}
if _, exists := env.images.images[mfCopyID]; exists {
t.Errorf("image 9 should be gone")
}
})
t.Run("A failure while repointing entries stops the merge", func(t *testing.T) {
env := newMergeEnv(t)
first := env.seedEntry(t, "event", map[string]any{"title": "A", "thumbnail": float64(mfCopyID)})
second := env.seedEntry(t, "event", map[string]any{"title": "B", "thumbnail": float64(mfCopyID)})
env.spy.failOnUpdate = 2
rec := env.member(t, http.MethodPost, "/v1/media/merge", `{"keep_id":7,"merge_ids":[9]}`)
if rec.Code != http.StatusInternalServerError {
t.Fatalf("status %d %s", rec.Code, rec.Body.String())
}
for _, original := range []entry.Entry{first, second} {
if !reflect.DeepEqual(env.entries.entries[original.ID], original) {
t.Errorf("entry %d not restored: %v", original.ID, env.entries.entries[original.ID])
}
}
if _, exists := env.images.images[mfCopyID]; !exists || len(env.images.deleteCalls) != 0 {
t.Errorf("no image should be removed")
}
})
t.Run("Entry uses decide which duplicate is kept", func(t *testing.T) {
env := newMergeEnv(t)
env.seedArticleUsing(12, "Heat pumps", mfLimeID)
baseline := decodeContentObject(t, env.admin(t, http.MethodGet, "/v1/media/purge-preview", ""))
baseList := baseline["list"].([]any)
if len(baseList) != 1 || baseList[0].(map[string]any)["keep_id"] != float64(mfLimeID) {
t.Fatalf("baseline %v", baseline)
}
env.seedEntry(t, "event", map[string]any{"title": "A", "thumbnail": float64(mfCopyID)})
tie := decodeContentObject(t, env.admin(t, http.MethodGet, "/v1/media/purge-preview", ""))
tieList := tie["list"].([]any)
if tieList[0].(map[string]any)["keep_id"] != float64(mfLimeID) || tieList[0].(map[string]any)["used_in"] != float64(1) || tie["fingerprint"] != baseline["fingerprint"] {
t.Errorf("one article against one entry is a tie, the older copy stays: %v", tie)
}
env.seedEntry(t, "event", map[string]any{"title": "B", "thumbnail": float64(mfCopyID)})
decided := decodeContentObject(t, env.admin(t, http.MethodGet, "/v1/media/purge-preview", ""))
decidedList := decided["list"].([]any)
if decidedList[0].(map[string]any)["keep_id"] != float64(mfCopyID) || decidedList[0].(map[string]any)["used_in"] != float64(2) || decided["fingerprint"] == baseline["fingerprint"] {
t.Errorf("two entries outweigh one article: %v", decided)
}
})
t.Run("The purge plan counts entries as uses", func(t *testing.T) {
env := newMergeEnv(t)
used := env.seedEntry(t, "event", map[string]any{"title": "A", "thumbnail": float64(mfCopyID)})
preview := decodeContentObject(t, env.admin(t, http.MethodGet, "/v1/media/purge-preview", ""))
run := env.admin(t, http.MethodPost, "/v1/media/purge", "")
list := preview["list"].([]any)
if len(list) != 1 || list[0].(map[string]any)["keep_id"] != float64(mfCopyID) || list[0].(map[string]any)["used_in"] != float64(1) {
t.Errorf("preview %v", preview)
}
if run.Code != http.StatusOK || env.entries.entries[used.ID].Data["thumbnail"] != float64(mfCopyID) {
t.Errorf("run %d %s, entry %v", run.Code, run.Body.String(), env.entries.entries[used.ID])
}
if _, exists := env.images.images[mfLimeID]; exists {
t.Errorf("image 7 should be merged away")
}
})
}
func newMergeEnv(t *testing.T) *mediaFieldEnv {
t.Helper()
env := newMediaFieldEnv(t)
env.createEventType(t, false)
env.seedImage(mfCopyID, "lime-plaster-copy.jpg", 1600, 900, 240000, "hash-lime")
return env
}
func withData(record entry.Entry, data map[string]any) entry.Entry {
record.Data = data
return record
}
+133
View File
@@ -0,0 +1,133 @@
package test
import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/entry"
imageDomain "RenewCMS/internal/domain/image"
"RenewCMS/internal/infrastructure/useCases"
"bytes"
"encoding/json"
"errors"
"log"
"net/http/httptest"
"testing"
"time"
)
const (
mfLimeID = 7
mfBathroomID = 8
mfCopyID = 9
mfImagePath = "/v1/static/uploadedImages/lime-plaster.jpg"
)
var errMediaFieldEntryUpdate = errors.New("entry update failure")
type mfEntrySpyRepo struct {
*mockEntryRepo
failOnUpdate int
updates int
}
var _ entry.Repository = &mfEntrySpyRepo{}
func (s *mfEntrySpyRepo) Update(record entry.Entry) (entry.Entry, error) {
s.updates++
if s.failOnUpdate > 0 && s.updates == s.failOnUpdate {
return entry.Entry{}, errMediaFieldEntryUpdate
}
return s.mockEntryRepo.Update(record)
}
type mfImageSpyRepo struct {
*mockImageRepo
gets int
}
var _ imageDomain.Repository = &mfImageSpyRepo{}
func (s *mfImageSpyRepo) Get(id uint32) (imageDomain.Image, error) {
s.gets++
return s.mockImageRepo.Get(id)
}
type mediaFieldEnv struct {
*contentTypeEnv
articles *mockArticleRepo
spy *mfEntrySpyRepo
lookups *mfImageSpyRepo
logs *bytes.Buffer
}
func newMediaFieldEnv(t *testing.T) *mediaFieldEnv {
t.Helper()
base := newContentTypeEnv(t)
env := &mediaFieldEnv{contentTypeEnv: base, articles: newMockArticleRepo(), spy: &mfEntrySpyRepo{mockEntryRepo: base.entries}, lookups: &mfImageSpyRepo{mockImageRepo: base.images}, logs: &bytes.Buffer{}}
settings := newMockPurgeSettingsRepo()
now := func() time.Time { return base.clock }
merge := useCases.NewMergeMediaUseCase(base.images, env.articles, newMockImageStorage(), newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors(), base.types, env.spy)
container := base.container
container.CreateEntryUseCase = useCases.NewCreateEntryUseCase(base.types, base.entries, env.lookups, now)
container.GetEntryUseCase = useCases.NewGetEntryUseCase(base.types, base.entries, env.lookups)
container.ListEntriesUseCase = useCases.NewListEntriesUseCase(base.types, base.entries, env.lookups)
container.UpdateEntryUseCase = useCases.NewUpdateEntryUseCase(base.types, base.entries, env.lookups, now)
container.ListMediaUseCase = useCases.NewListMediaUseCase(base.images, env.articles, base.users, newMockVariantFileRepo(), plainSites(), plainAuthors(), base.types, base.entries)
container.DeleteMediaUseCase = useCases.NewDeleteMediaUseCase(base.images, newMockImageStorage(), newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors(), env.articles)
container.MergeMediaUseCase = merge
container.GetMediaUsageUseCase = useCases.NewGetMediaUsageUseCase(base.images, env.articles, plainSites(), plainAuthors(), base.types, base.entries)
container.PreviewPurgeUseCase = useCases.NewPreviewPurgeUseCase(base.images, env.articles, plainSites(), plainAuthors(), base.types, base.entries)
container.RunPurgeUseCase = useCases.NewRunPurgeUseCase(base.images, env.articles, merge, settings, now, log.New(env.logs, "", 0), discardRecorder, plainSites(), plainAuthors())
base.mountRouter()
env.seedImage(mfLimeID, "lime-plaster.jpg", 1600, 900, 240000, "hash-lime")
env.seedImage(mfBathroomID, "bathroom-tiles.png", 800, 600, 51200, "hash-bathroom")
return env
}
func (e *mediaFieldEnv) seedImage(id uint32, filename string, width int, height int, size int64, sha string) {
e.images.images[id] = imageDomain.Image{ID: id, Path: "/v1/static/uploadedImages/" + filename, Filename: filename, Size: size, ContentType: "image/jpeg", Width: width, Height: height, SHA256: sha, UploadedByID: ctAdminId, CreatedAt: e.clock.Add(time.Duration(id) * time.Minute)}
}
func mediaField(key string, required bool) map[string]any {
return map[string]any{"key": key, "label": "Thumbnail", "type": "media", "required": required, "unique": false, "default": nil, "min": nil, "max": nil, "options": []string{}}
}
func eventFields(required bool) []map[string]any {
return []map[string]any{{"key": "title", "label": "Title", "type": "text", "required": true}, mediaField("thumbnail", required)}
}
func (e *mediaFieldEnv) createEventType(t *testing.T, required bool) {
t.Helper()
e.createType(t, "event", eventFields(required))
}
func (e *mediaFieldEnv) seedEntry(t *testing.T, typeKey string, data map[string]any) entry.Entry {
t.Helper()
owner, err := e.types.GetByKey(typeKey)
if err != nil {
t.Fatalf("seed type %s: %v", typeKey, err)
}
stored, err := e.entries.Create(entry.Entry{ContentTypeID: owner.ID, Data: data, CreatedBy: ctMemberId, UpdatedBy: ctMemberId, CreatedAt: e.clock, UpdatedAt: e.clock})
if err != nil {
t.Fatalf("seed entry: %v", err)
}
return stored
}
func (e *mediaFieldEnv) member(t *testing.T, method string, path string, body string) *httptest.ResponseRecorder {
t.Helper()
return e.as(t, ctMemberId, method, path, body)
}
func (e *mediaFieldEnv) seedArticleUsing(id uint32, title string, imageID uint32) {
cover := imageID
e.articles.articles[id] = article.Article{ID: id, Title: title, IsOnline: true, CoverImageID: &cover}
}
func jsonOf(t *testing.T, value any) string {
t.Helper()
encoded, err := json.Marshal(value)
if err != nil {
t.Fatalf("encode: %v", err)
}
return string(encoded)
}
+229
View File
@@ -0,0 +1,229 @@
package test
import (
"RenewCMS/internal/domain/contenttype"
"RenewCMS/internal/domain/entry"
imageDomain "RenewCMS/internal/domain/image"
"RenewCMS/internal/infrastructure/useCases"
"errors"
"math"
"testing"
"time"
)
func mediaThumbnailSchema(t *testing.T, required bool) []contenttype.Field {
t.Helper()
fields, err := contenttype.ValidateFields([]contenttype.Field{
{Key: "title", Label: "Title", Type: contenttype.TypeText, Required: true},
{Key: "thumbnail", Label: "Thumbnail", Type: contenttype.TypeMedia, Required: required},
})
if err != nil {
t.Fatalf("schema: %v", err)
}
return fields
}
func expectFieldError(t *testing.T, err error, key string, rule string) {
t.Helper()
var fieldError entry.FieldError
if !errors.Is(err, entry.ErrInvalidEntry) || !errors.As(err, &fieldError) || fieldError.Key != key || fieldError.Rule != rule {
t.Errorf("want %s %s, got %v", key, rule, err)
}
}
func TestMediaFieldDomain(t *testing.T) {
t.Run("contenttype.ValidateFields refuses what a media field can't have", func(t *testing.T) {
refused := map[string]contenttype.Field{
"unique": {Unique: true},
"default int": {Default: float64(7)},
"default str": {Default: "lime"},
"min": {Min: float64(1)},
"max": {Max: float64(5)},
"options": {Options: []string{"a"}},
}
for name, base := range refused {
base.Key, base.Label, base.Type = "thumbnail", "Thumbnail", contenttype.TypeMedia
if _, err := contenttype.ValidateFields([]contenttype.Field{base}); !errors.Is(err, contenttype.ErrInvalidField) {
t.Errorf("%s: %v", name, err)
}
}
plain := contenttype.Field{Key: "thumbnail", Label: "Thumbnail", Type: contenttype.TypeMedia, Required: true}
if _, err := contenttype.ValidateFields([]contenttype.Field{plain}); err != nil {
t.Errorf("a plain media field should pass: %v", err)
}
})
t.Run("contenttype.MediaID accepts integers from 1 to the uint32 maximum only", func(t *testing.T) {
accepted := map[any]uint32{float64(1): 1, float64(7): 7, float64(math.MaxUint32): math.MaxUint32, 9: 9, int64(8): 8, uint32(3): 3}
for value, want := range accepted {
if got, ok := contenttype.MediaID(value); !ok || got != want {
t.Errorf("%v: %d %v", value, got, ok)
}
}
for _, value := range []any{float64(0), float64(-1), 7.5, float64(math.MaxUint32) + 1, "7", true, []any{7.0}, nil, 0, -3, uint32(0), math.NaN()} {
if _, ok := contenttype.MediaID(value); ok {
t.Errorf("%v should be refused", value)
}
}
})
t.Run("entry.Validate applies required and type to a media field", func(t *testing.T) {
schema := mediaThumbnailSchema(t, true)
expectFieldError(t, entry.Validate(schema, map[string]any{"title": "A"}), "thumbnail", entry.RuleRequired)
expectFieldError(t, entry.Validate(schema, map[string]any{"title": "A", "thumbnail": nil}), "thumbnail", entry.RuleRequired)
expectFieldError(t, entry.Validate(schema, map[string]any{"title": "A", "thumbnail": "7"}), "thumbnail", contenttype.RuleType)
expectFieldError(t, entry.Validate(schema, map[string]any{"title": "A", "thumbnail": 7.5}), "thumbnail", contenttype.RuleType)
if err := entry.Validate(schema, map[string]any{"title": "A", "thumbnail": float64(7)}); err != nil {
t.Errorf("valid media: %v", err)
}
if err := entry.Validate(mediaThumbnailSchema(t, false), map[string]any{"title": "A"}); err != nil {
t.Errorf("optional media: %v", err)
}
})
}
type mediaFieldFixture struct {
types *mockContentTypeRepo
entries *mockEntryRepo
images *mockImageRepo
now func() time.Time
}
func newMediaFieldFixture(t *testing.T, required bool) *mediaFieldFixture {
t.Helper()
fixture := &mediaFieldFixture{types: newMockContentTypeRepo(), entries: newMockEntryRepo(), images: newMockImageRepo(), now: func() time.Time { return time.Date(2026, time.October, 5, 9, 0, 0, 0, time.UTC) }}
if _, err := fixture.types.Create(contenttype.ContentType{Key: "event", Name: "Events", Fields: mediaThumbnailSchema(t, required)}); err != nil {
t.Fatalf("type: %v", err)
}
fixture.images.images[7] = imageDomain.Image{ID: 7, Filename: "lime-plaster.jpg", Path: mfImagePath, Width: 1600, Height: 900, Size: 240000}
fixture.images.images[8] = imageDomain.Image{ID: 8, Filename: "bathroom-tiles.png", Path: "/v1/static/uploadedImages/bathroom-tiles.png"}
fixture.types.createCalls = 0
return fixture
}
func TestMediaFieldUseCases(t *testing.T) {
t.Run("CreateEntry returns the same errors for a missing library image and creates nothing", func(t *testing.T) {
fixture := newMediaFieldFixture(t, false)
create := useCases.NewCreateEntryUseCase(fixture.types, fixture.entries, fixture.images, fixture.now)
_, err := create.Create(useCases.CreateEntryCommand{ContentTypeKey: "event", Data: map[string]any{"title": "A", "thumbnail": float64(999)}, UserID: 2})
expectFieldError(t, err, "thumbnail", contenttype.RuleType)
if fixture.entries.createCalls != 0 {
t.Errorf("creates %d", fixture.entries.createCalls)
}
})
t.Run("CreateEntry checks Validate before library existence", func(t *testing.T) {
fixture := newMediaFieldFixture(t, false)
create := useCases.NewCreateEntryUseCase(fixture.types, fixture.entries, fixture.images, fixture.now)
_, err := create.Create(useCases.CreateEntryCommand{ContentTypeKey: "event", Data: map[string]any{"thumbnail": float64(999)}, UserID: 2})
expectFieldError(t, err, "title", entry.RuleRequired)
})
t.Run("CreateEntry surfaces a library failure", func(t *testing.T) {
fixture := newMediaFieldFixture(t, false)
fixture.images.fail.failNext("Get")
create := useCases.NewCreateEntryUseCase(fixture.types, fixture.entries, fixture.images, fixture.now)
_, err := create.Create(useCases.CreateEntryCommand{ContentTypeKey: "event", Data: map[string]any{"title": "A", "thumbnail": float64(7)}, UserID: 2})
if !errors.Is(err, errRepositoryFailure) || fixture.entries.createCalls != 0 {
t.Errorf("err %v", err)
}
})
t.Run("UpdateEntry treats only the stored id as unchanged", func(t *testing.T) {
examples := []struct {
name string
required bool
value any
rule string
}{
{"unchanged and gone, optional", false, float64(7), ""},
{"unchanged and gone, required", true, float64(7), entry.RuleRequired},
{"changed to a missing image", false, float64(999), contenttype.RuleType},
{"changed to an existing image", true, float64(8), ""},
{"cleared, optional", false, nil, ""},
}
for _, example := range examples {
t.Run(example.name, func(t *testing.T) {
fixture := newMediaFieldFixture(t, example.required)
delete(fixture.images.images, 7)
stored, _ := fixture.entries.Create(entry.Entry{ContentTypeID: 1, Data: map[string]any{"title": "A", "thumbnail": float64(7)}})
fixture.entries.createCalls = 0
update := useCases.NewUpdateEntryUseCase(fixture.types, fixture.entries, fixture.images, fixture.now)
_, err := update.Update(useCases.UpdateEntryCommand{ContentTypeKey: "event", ID: stored.ID, Data: map[string]any{"title": "A", "thumbnail": example.value}, UserID: 3})
if example.rule == "" {
if err != nil || fixture.entries.updateCalls != 1 {
t.Errorf("err %v updates %d", err, fixture.entries.updateCalls)
}
return
}
expectFieldError(t, err, "thumbnail", example.rule)
if fixture.entries.updateCalls != 0 {
t.Errorf("updates %d", fixture.entries.updateCalls)
}
})
}
})
t.Run("UpdateContentType returns ErrSchemaIncompatible for a new required media field and does not update", func(t *testing.T) {
fixture := newMediaFieldFixture(t, false)
fixture.types.types[1] = contenttype.ContentType{ID: 1, Key: "event", Name: "Events", Fields: mediaThumbnailSchema(t, false)[:1]}
fixture.entries.Create(entry.Entry{ContentTypeID: 1, Data: map[string]any{"title": "A"}})
update := useCases.NewUpdateContentTypeUseCase(fixture.types, fixture.entries, fixture.now)
_, err := update.Update(useCases.UpdateContentTypeCommand{Key: "event", Name: "Events", Fields: mediaThumbnailSchema(t, true)})
if !errors.Is(err, contenttype.ErrSchemaIncompatible) || fixture.types.updateCalls != 0 {
t.Errorf("err %v updates %d", err, fixture.types.updateCalls)
}
})
t.Run("GetEntry and ListEntries resolve media once per distinct image and mark missing ones", func(t *testing.T) {
fixture := newMediaFieldFixture(t, false)
for _, id := range []float64{7, 7, 999} {
fixture.entries.Create(entry.Entry{ContentTypeID: 1, Data: map[string]any{"title": "A", "thumbnail": id}})
}
spy := &mfImageSpyRepo{mockImageRepo: fixture.images}
list := useCases.NewListEntriesUseCase(fixture.types, fixture.entries, spy)
page, err := list.ListPage(useCases.ListEntriesCommand{ContentTypeKey: "event", Page: 1, Size: 25, Sort: entry.Sort{Field: entry.SortByID, Direction: entry.SortAsc}})
if err != nil || spy.gets != 2 || len(page.Items) != 3 {
t.Fatalf("err %v lookups %d", err, spy.gets)
}
if page.Items[0].Media["thumbnail"].Filename != "lime-plaster.jpg" || !page.Items[2].Media["thumbnail"].Missing || page.Items[2].Media["thumbnail"].ID != 999 {
t.Errorf("media %+v %+v", page.Items[0].Media, page.Items[2].Media)
}
})
}
func TestMediaFieldUnknownDimensions(t *testing.T) {
fixture := newMediaFieldFixture(t, false)
owner, err := fixture.types.GetByKey("event")
if err != nil {
t.Fatalf("type: %v", err)
}
created, err := fixture.entries.Create(entry.Entry{ContentTypeID: owner.ID, Data: map[string]any{"title": "A", "thumbnail": float64(8)}})
if err != nil {
t.Fatalf("entry: %v", err)
}
get := useCases.NewGetEntryUseCase(fixture.types, fixture.entries, fixture.images)
found, err := get.Get("event", created.ID)
if err != nil {
t.Fatalf("get: %v", err)
}
media := found.Media["thumbnail"]
if media.Width != nil || media.Height != nil {
t.Errorf("dimensions %v %v, want nil", media.Width, media.Height)
}
}
+2 -2
View File
@@ -943,7 +943,7 @@ func TestMediaMerging(t *testing.T) {
t.Run("A duplicated id reaching the use case is refused before anything is read", func(t *testing.T) {
env := newEnv(t)
uc := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage, newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors())
uc := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage, newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo())
_, err := uc.Merge(testCtx, 2, []uint32{3, 3})
@@ -1038,7 +1038,7 @@ func TestMediaSentinels(t *testing.T) {
env.seedImage(2, "b.png", 1, "image/png", 1, 1, hashB, 1)
env.seedImage(3, "c.png", 1, "image/png", 1, 1, "", 1)
env.seedImage(4, "d.png", 1, "image/png", 1, 1, "", 1)
uc := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage, newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors())
uc := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage, newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo())
if _, err := uc.Merge(testCtx, 1, []uint32{9999}); !errors.Is(err, domainImage.ErrMergeCopyMissing) {
t.Errorf("expected ErrMergeCopyMissing, got %v", err)
+1 -1
View File
@@ -270,7 +270,7 @@ func TestListMediaUseCasePaging(t *testing.T) {
build := func() (*mockImageRepo, *mockVariantFileRepo, *useCases.ListMediaUseCase) {
images := newMockImageRepo()
variants := newMockVariantFileRepo()
return images, variants, useCases.NewListMediaUseCase(images, newMockArticleRepo(), newMockUserRepo(), variants, plainSites(), plainAuthors())
return images, variants, useCases.NewListMediaUseCase(images, newMockArticleRepo(), newMockUserRepo(), variants, plainSites(), plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo())
}
seed := func(images *mockImageRepo, count int) {
for id := uint32(1); id <= uint32(count); id++ {
+1 -1
View File
@@ -31,7 +31,7 @@ type schedulerEnv struct {
func newSchedulerEnv(t *testing.T) *schedulerEnv {
t.Helper()
env := newPurgeEnv(t)
merge := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage, newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors())
merge := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage, newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo())
now := func() time.Time { return env.clock }
runPurge := useCases.NewRunPurgeUseCase(env.images, env.articles, merge, env.purgeSettings, now, log.New(env.logs, "", 0), discardRecorder, plainSites(), plainAuthors())
return &schedulerEnv{
+5 -5
View File
@@ -32,7 +32,7 @@ func TestUpdatePurgeSettingsRejectsOutOfRangeIntervals(t *testing.T) {
func TestRunPurgeDistinguishesBusyFromConflict(t *testing.T) {
newRun := func(env *mediaEnv) *useCases.RunPurgeUseCase {
merge := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage, newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors())
merge := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage, newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo())
return useCases.NewRunPurgeUseCase(env.images, env.articles, merge, env.purgeSettings, func() time.Time { return env.clock }, log.New(&bytes.Buffer{}, "", 0), discardRecorder, plainSites(), plainAuthors())
}
@@ -114,7 +114,7 @@ func TestPurgePlanOrderingAndFingerprint(t *testing.T) {
env.useImages(11, 5)
env.useImages(12, 3)
preview, err := useCases.NewPreviewPurgeUseCase(env.images, env.articles, plainSites(), plainAuthors()).Preview()
preview, err := useCases.NewPreviewPurgeUseCase(env.images, env.articles, plainSites(), plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo()).Preview()
var kept []uint32
for _, group := range preview.List {
@@ -132,7 +132,7 @@ func TestPurgePlanOrderingAndFingerprint(t *testing.T) {
env.useImages(10, 1, 2, 3)
env.useImages(11, 5)
preview, err := useCases.NewPreviewPurgeUseCase(env.images, env.articles, plainSites(), plainAuthors()).Preview()
preview, err := useCases.NewPreviewPurgeUseCase(env.images, env.articles, plainSites(), plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo()).Preview()
sum := sha256.Sum256([]byte("1:2,3;5:4;"))
if err != nil || preview.Fingerprint != hex.EncodeToString(sum[:]) {
@@ -148,7 +148,7 @@ func TestMergeMediaExposesTheRewrittenArticleIds(t *testing.T) {
env.useImages(1, 2)
env.useImages(2, 1, 2)
env.useImages(3, 1)
merge := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage, newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors())
merge := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage, newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo())
result, err := merge.Merge(testCtx, 1, []uint32{2})
@@ -163,7 +163,7 @@ func TestMergeMediaExposesTheRewrittenArticleIds(t *testing.T) {
env.useImages(1, 2)
env.useImages(2, 2)
env.articles.failUpdateBodyFor = map[uint32]error{2: errRepositoryFailure}
merge := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage, newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors())
merge := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage, newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo())
result, err := merge.Merge(testCtx, 1, []uint32{2})
+1 -1
View File
@@ -112,7 +112,7 @@ func TestMergeMediaRemovesTheMergedBlobs(t *testing.T) {
create := useCases.NewCreateImageUseCase(repo, store, discardRecorder)
kept, copyOne, copyTwo := uploadPng(t, create), uploadPng(t, create), uploadPng(t, create)
result, err := useCases.NewMergeMediaUseCase(repo, articles, store, newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors()).Merge(testCtx, kept.ID, []uint32{copyOne.ID, copyTwo.ID})
result, err := useCases.NewMergeMediaUseCase(repo, articles, store, newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo()).Merge(testCtx, kept.ID, []uint32{copyOne.ID, copyTwo.ID})
if err != nil || result.Merged != 2 {
t.Fatalf("merge returned %+v, %v", result, err)
+3 -3
View File
@@ -143,11 +143,11 @@ func newRuntimeSettingsContainer(t *testing.T) *api.UseCases {
SendMailUseCase: useCases.NewSendMailUseCase(mailRepo),
CreateImageUseCase: useCases.NewCreateImageUseCase(imageRepo, newMockImageStorage(), discardRecorder),
GenerateVariantsOnUploadUseCase: newInertGenerateOnUploadUseCase(),
ListMediaUseCase: useCases.NewListMediaUseCase(imageRepo, articleRepo, userRepo, newMockVariantFileRepo(), plainSites(), plainAuthors()),
ListMediaUseCase: useCases.NewListMediaUseCase(imageRepo, articleRepo, userRepo, newMockVariantFileRepo(), plainSites(), plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo()),
RenameMediaUseCase: useCases.NewRenameMediaUseCase(imageRepo, discardRecorder),
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(imageRepo, newMockImageStorage(), newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors(), newMockArticleRepo()),
MergeMediaUseCase: useCases.NewMergeMediaUseCase(imageRepo, articleRepo, newMockImageStorage(), newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors()),
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(imageRepo, articleRepo, plainSites(), plainAuthors()),
MergeMediaUseCase: useCases.NewMergeMediaUseCase(imageRepo, articleRepo, newMockImageStorage(), newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo()),
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(imageRepo, articleRepo, plainSites(), plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo()),
}
}
+3 -3
View File
@@ -293,7 +293,7 @@ func TestSeoDataCleanerCompensationFailuresAreLogged(t *testing.T) {
e.updateArticle(9, func(a *article.Article) { a.Body = e.bodyUsing(6) })
buffer := captureLog(t)
flaky := &seoFlakyBodyArticles{mockArticleRepo: e.articles}
merge := useCases.NewMergeMediaUseCase(e.images, flaky, newMockImageStorage(), newMockVariantFileRepo(), discardRecorder, e.sites, e.authors)
merge := useCases.NewMergeMediaUseCase(e.images, flaky, newMockImageStorage(), newMockVariantFileRepo(), discardRecorder, e.sites, e.authors, newMockContentTypeRepo(), newMockEntryRepo())
_, err := merge.Merge(t.Context(), 5, []uint32{6})
@@ -330,7 +330,7 @@ func TestSeoDataCleanerCaseInsensitiveBodyMatches(t *testing.T) {
e := newSeoEnv(t)
seed(e)
before := e.stored(9)
merge := useCases.NewMergeMediaUseCase(e.images, seoCaseInsensitiveBodyArticles{e.articles}, newMockImageStorage(), newMockVariantFileRepo(), discardRecorder, e.sites, e.authors)
merge := useCases.NewMergeMediaUseCase(e.images, seoCaseInsensitiveBodyArticles{e.articles}, newMockImageStorage(), newMockVariantFileRepo(), discardRecorder, e.sites, e.authors, newMockContentTypeRepo(), newMockEntryRepo())
result, err := merge.Merge(t.Context(), 5, []uint32{6})
@@ -342,7 +342,7 @@ func TestSeoDataCleanerCaseInsensitiveBodyMatches(t *testing.T) {
t.Run("Usage only lists real body references", func(t *testing.T) {
e := newSeoEnv(t)
seed(e)
usage := useCases.NewGetMediaUsageUseCase(e.images, seoCaseInsensitiveBodyArticles{e.articles}, e.sites, e.authors)
usage := useCases.NewGetMediaUsageUseCase(e.images, seoCaseInsensitiveBodyArticles{e.articles}, e.sites, e.authors, newMockContentTypeRepo(), newMockEntryRepo())
items, err := usage.Usage(6)
+1 -1
View File
@@ -355,7 +355,7 @@ func TestSeoDataMediaMerge(t *testing.T) {
seedDuplicates(e)
e.updateArticle(8, func(a *article.Article) { a.CoverImageID = idPointer(6) })
e.updateArticle(9, func(a *article.Article) { a.SocialImageID = idPointer(6) })
merge := useCases.NewMergeMediaUseCase(e.images, e.articles, newMockImageStorage(), newMockVariantFileRepo(), discardRecorder, e.sites, e.authors)
merge := useCases.NewMergeMediaUseCase(e.images, e.articles, newMockImageStorage(), newMockVariantFileRepo(), discardRecorder, e.sites, e.authors, newMockContentTypeRepo(), newMockEntryRepo())
result, err := merge.Merge(testCtx, 5, []uint32{6})
+4 -4
View File
@@ -53,17 +53,17 @@ func newSeoEnv(t *testing.T) *seoEnv {
}
recorder := p.audit.recorder
renderer := &fakeBodyRenderer{}
mergeMedia := useCases.NewMergeMediaUseCase(p.images, p.articles, newMockImageStorage(), newMockVariantFileRepo(), recorder, sites, authors)
mergeMedia := useCases.NewMergeMediaUseCase(p.images, p.articles, newMockImageStorage(), newMockVariantFileRepo(), recorder, sites, authors, newMockContentTypeRepo(), newMockEntryRepo())
c := p.container
c.CreateArticleUseCase = useCases.NewCreateArticleUseCase(p.articles, renderer, recorder, sites, authors)
c.UpdateArticleUseCase = useCases.NewUpdateArticleUseCase(p.articles, renderer, recorder, sites, authors, p.images)
c.ListArticlesUseCase = useCases.NewListArticlesUseCase(p.articles, renderer, sites)
c.ListMediaUseCase = useCases.NewListMediaUseCase(p.images, p.articles, p.users, newMockVariantFileRepo(), sites, authors)
c.ListMediaUseCase = useCases.NewListMediaUseCase(p.images, p.articles, p.users, newMockVariantFileRepo(), sites, authors, newMockContentTypeRepo(), newMockEntryRepo())
c.DeleteMediaUseCase = useCases.NewDeleteMediaUseCase(p.images, newMockImageStorage(), newMockVariantFileRepo(), recorder, sites, authors, p.articles)
c.GetMediaUsageUseCase = useCases.NewGetMediaUsageUseCase(p.images, p.articles, sites, authors)
c.GetMediaUsageUseCase = useCases.NewGetMediaUsageUseCase(p.images, p.articles, sites, authors, newMockContentTypeRepo(), newMockEntryRepo())
c.MergeMediaUseCase = mergeMedia
c.RunPurgeUseCase = useCases.NewRunPurgeUseCase(p.images, p.articles, mergeMedia, newMockPurgeSettingsRepo(), func() time.Time { return p.now }, log.New(io.Discard, "", 0), recorder, sites, authors)
c.PreviewPurgeUseCase = useCases.NewPreviewPurgeUseCase(p.images, p.articles, sites, authors)
c.PreviewPurgeUseCase = useCases.NewPreviewPurgeUseCase(p.images, p.articles, sites, authors, newMockContentTypeRepo(), newMockEntryRepo())
c.ListSitesUseCase = useCases.NewListSitesUseCase(sites)
c.CreateSiteUseCase = useCases.NewCreateSiteUseCase(sites, p.images, recorder)
c.UpdateSiteUseCase = useCases.NewUpdateSiteUseCase(sites, p.images, recorder)
+3 -3
View File
@@ -298,15 +298,15 @@ func newSiteEnv(t *testing.T) *siteEnv {
sites := newMockSiteRepo(p.articles)
recorder := p.audit.recorder
renderer := &fakeBodyRenderer{}
mergeMedia := useCases.NewMergeMediaUseCase(p.images, p.articles, newMockImageStorage(), newMockVariantFileRepo(), recorder, sites, plainAuthors())
mergeMedia := useCases.NewMergeMediaUseCase(p.images, p.articles, newMockImageStorage(), newMockVariantFileRepo(), recorder, sites, plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo())
c := p.container
c.CreateArticleUseCase = useCases.NewCreateArticleUseCase(p.articles, renderer, recorder, sites, plainAuthors())
c.UpdateArticleUseCase = useCases.NewUpdateArticleUseCase(p.articles, renderer, recorder, sites, plainAuthors(), p.images)
c.ListArticlesUseCase = useCases.NewListArticlesUseCase(p.articles, renderer, sites)
c.ListMediaUseCase = useCases.NewListMediaUseCase(p.images, p.articles, p.users, newMockVariantFileRepo(), sites, plainAuthors())
c.ListMediaUseCase = useCases.NewListMediaUseCase(p.images, p.articles, p.users, newMockVariantFileRepo(), sites, plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo())
c.DeleteMediaUseCase = useCases.NewDeleteMediaUseCase(p.images, newMockImageStorage(), newMockVariantFileRepo(), recorder, sites, plainAuthors(), p.articles)
c.MergeMediaUseCase = mergeMedia
c.GetMediaUsageUseCase = useCases.NewGetMediaUsageUseCase(p.images, p.articles, sites, plainAuthors())
c.GetMediaUsageUseCase = useCases.NewGetMediaUsageUseCase(p.images, p.articles, sites, plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo())
c.RunPurgeUseCase = useCases.NewRunPurgeUseCase(p.images, p.articles, mergeMedia, newMockPurgeSettingsRepo(), func() time.Time { return p.now }, log.New(io.Discard, "", 0), recorder, sites, plainAuthors())
c.ListSitesUseCase = useCases.NewListSitesUseCase(sites)
c.CreateSiteUseCase = useCases.NewCreateSiteUseCase(sites, p.images, recorder)