refac: atomic merge, lost-update race, RunPurge cost, duplicated walks

This commit is contained in:
Florian Sylvain committed 2026-10-21 22:47:46 +02:00
1 parent 0fc3511e2b
commit 074944c99a
45 files changed
+2204 -267

No files matched your search

+8 -8
View File
@@ -58,7 +58,7 @@ type producerPurgeRunner struct {
var _ useCases.PurgeRunner = &producerPurgeRunner{}
func (r *producerPurgeRunner) Execute(*string) (purge.RunResult, error) {
func (r *producerPurgeRunner) Execute(context.Context, *string) (purge.RunResult, error) {
r.calls++
return r.result, r.err
}
@@ -245,7 +245,7 @@ func TestActivityLogProducersFailureReasons(t *testing.T) {
t.Helper()
h := newActivityHarness(t)
scheduler := useCases.NewPurgeSchedulerUseCase(dueSettings(), &producerPurgeRunner{err: err}, h.recorder)
if _, _, tickErr := scheduler.Tick(*purgeTime("2026-10-05T09:00:00Z")); !errors.Is(tickErr, err) {
if _, _, tickErr := scheduler.Tick(testCtx, *purgeTime("2026-10-05T09:00:00Z")); !errors.Is(tickErr, err) {
t.Fatalf("tick err = %v", tickErr)
}
return onlyEvent(t, h)
@@ -322,7 +322,7 @@ func TestActivityLogProducersScheduledPurge(t *testing.T) {
runner := &producerPurgeRunner{result: purge.RunResult{GroupsTotal: 2, GroupsPurged: 2, Merged: 3, ArticlesUpdated: 5}}
scheduler := useCases.NewPurgeSchedulerUseCase(due(), runner, h.recorder)
if _, ran, err := scheduler.Tick(now); err != nil || !ran {
if _, ran, err := scheduler.Tick(testCtx, now); err != nil || !ran {
t.Fatalf("ran = %v, err = %v", ran, err)
}
@@ -341,7 +341,7 @@ func TestActivityLogProducersScheduledPurge(t *testing.T) {
runner := &producerPurgeRunner{result: purge.RunResult{GroupsTotal: 3, GroupsPurged: 2, FailedGroups: 1, Merged: 2, ArticlesUpdated: 2}}
scheduler := useCases.NewPurgeSchedulerUseCase(due(), runner, h.recorder)
if _, _, err := scheduler.Tick(now); err != nil {
if _, _, err := scheduler.Tick(testCtx, now); err != nil {
t.Fatal(err)
}
@@ -356,10 +356,10 @@ func TestActivityLogProducersScheduledPurge(t *testing.T) {
h := newActivityHarness(t)
notDue := newMockPurgeSettingsRepo()
runner := &producerPurgeRunner{}
if _, ran, err := useCases.NewPurgeSchedulerUseCase(notDue, runner, h.recorder).Tick(now); err != nil || ran {
if _, ran, err := useCases.NewPurgeSchedulerUseCase(notDue, runner, h.recorder).Tick(testCtx, now); err != nil || ran {
t.Fatalf("ran = %v, err = %v", ran, err)
}
if _, ran, err := useCases.NewPurgeSchedulerUseCase(due(), runner, h.recorder).Tick(now); err != nil || !ran {
if _, ran, err := useCases.NewPurgeSchedulerUseCase(due(), runner, h.recorder).Tick(testCtx, now); err != nil || !ran {
t.Fatalf("ran = %v, err = %v", ran, err)
}
assertNoEvents(t, h)
@@ -369,7 +369,7 @@ func TestActivityLogProducersScheduledPurge(t *testing.T) {
h := newActivityHarness(t)
runner := &producerPurgeRunner{err: errRepositoryFailure}
if _, _, err := useCases.NewPurgeSchedulerUseCase(due(), runner, h.recorder).Tick(now); err == nil {
if _, _, err := useCases.NewPurgeSchedulerUseCase(due(), runner, h.recorder).Tick(testCtx, now); err == nil {
t.Fatal("expected an error")
}
@@ -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(), newMockContentTypeRepo(), newMockEntryRepo())
merge := newTestMergeMedia(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{})
+1 -1
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(), newMockContentTypeRepo(), newMockEntryRepo())
mergeMedia := newTestMergeMedia(env.images, env.articles, newMockImageStorage(), newMockVariantFileRepo(), recorder, plainSites(), plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo())
purgeSettings := newMockPurgeSettingsRepo()
restartJobs := &fakeJobStore{}
env.container = &api.UseCases{
+6
View File
@@ -25,6 +25,8 @@ type mockArticleRepo struct {
authors *mockAuthorRepo
reportSlugRace bool
createAlwaysRaces bool
afterFindByBody func()
updateBodyIDs []uint32
}
func newMockArticleRepo() *mockArticleRepo {
@@ -315,6 +317,7 @@ func (m *mockArticleRepo) Create(a article.Article) (article.Article, error) {
}
func (m *mockArticleRepo) UpdateBody(id uint32, body string) (article.Article, error) {
m.updateBodyIDs = append(m.updateBodyIDs, id)
if err := m.fail.trigger("UpdateBody"); err != nil {
return article.Article{}, err
}
@@ -427,6 +430,9 @@ func (m *mockArticleRepo) FindByBodyContaining(filename string) ([]article.Artic
result = append(result, a)
}
}
if m.afterFindByBody != nil {
defer m.afterFindByBody()
}
return result, nil
}
+4 -1
View File
@@ -321,6 +321,9 @@ func (m *mockAuthorRepo) AssignAvatar(authorIDs []uint32, imageID uint32) error
}
func (m *mockAuthorRepo) RepointAvatar(fromImageIDs []uint32, toImageID uint32) (int, error) {
if err := m.fail.trigger("RepointAvatar"); err != nil {
return 0, err
}
changed := 0
for id, a := range m.authors {
if a.AvatarID != nil && containsID(fromImageIDs, *a.AvatarID) {
@@ -355,7 +358,7 @@ 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, newMockContentTypeRepo(), newMockEntryRepo())
mergeMedia := newTestMergeMedia(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)
+24 -4
View File
@@ -37,6 +37,7 @@ type mockContentTypeRepo struct {
createCalls int
updateCalls int
deleteCalls int
listCalls int
fail failSwitch
}
@@ -70,6 +71,7 @@ func (m *mockContentTypeRepo) GetByKey(key string) (contenttype.ContentType, err
}
func (m *mockContentTypeRepo) List() ([]contenttype.ContentType, error) {
m.listCalls++
if err := m.fail.trigger("List"); err != nil {
return nil, err
}
@@ -100,10 +102,16 @@ func (m *mockContentTypeRepo) Delete(id uint32) error {
}
type mockEntryRepo struct {
entries map[uint32]entry.Entry
nextID uint32
createCalls int
updateCalls int
entries map[uint32]entry.Entry
nextID uint32
createCalls int
updateCalls int
getCalls int
listPageCalls int
failGetOn int
failUpdateOn int
updatedIDs []uint32
afterListPage func()
}
var _ entry.Repository = &mockEntryRepo{}
@@ -121,6 +129,10 @@ func (m *mockEntryRepo) Create(record entry.Entry) (entry.Entry, error) {
}
func (m *mockEntryRepo) Get(contentTypeID uint32, id uint32) (entry.Entry, error) {
m.getCalls++
if m.failGetOn > 0 && m.getCalls == m.failGetOn {
return entry.Entry{}, errRepositoryFailure
}
if stored, found := m.entries[id]; found && stored.ContentTypeID == contentTypeID {
return stored, nil
}
@@ -128,6 +140,10 @@ func (m *mockEntryRepo) Get(contentTypeID uint32, id uint32) (entry.Entry, error
}
func (m *mockEntryRepo) ListPage(filters entry.Filters) (entry.PageResult, error) {
m.listPageCalls++
if m.afterListPage != nil {
defer m.afterListPage()
}
matching := []entry.Entry{}
for _, stored := range m.entries {
if stored.ContentTypeID == filters.ContentTypeID {
@@ -169,6 +185,10 @@ func sortEntries(items []entry.Entry, order entry.Sort) {
func (m *mockEntryRepo) Update(record entry.Entry) (entry.Entry, error) {
m.updateCalls++
m.updatedIDs = append(m.updatedIDs, record.ID)
if m.failUpdateOn > 0 && m.updateCalls == m.failUpdateOn {
return entry.Entry{}, errRepositoryFailure
}
m.entries[record.ID] = record
return record, nil
}
+1 -1
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(), newMockContentTypeRepo(), newMockEntryRepo()),
MergeMediaUseCase: newTestMergeMedia(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),
+1 -2
View File
@@ -2,7 +2,6 @@ package test
import (
domainArticle "RenewCMS/internal/domain/article"
"RenewCMS/internal/infrastructure/useCases"
"reflect"
"testing"
)
@@ -25,7 +24,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(), newMockContentTypeRepo(), newMockEntryRepo())
uc := newTestMergeMedia(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)
+1 -1
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(), newMockContentTypeRepo(), newMockEntryRepo())
merge := newTestMergeMedia(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,
+1 -1
View File
@@ -65,7 +65,7 @@ func newMediaFieldEnv(t *testing.T) *mediaFieldEnv {
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)
merge := newTestMergeMedia(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)
+6 -6
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(), newMockContentTypeRepo(), newMockEntryRepo())
uc := newTestMergeMedia(env.images, env.articles, env.storage, newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo())
_, err := uc.Merge(testCtx, 2, []uint32{3, 3})
@@ -971,18 +971,18 @@ func TestMediaMerging(t *testing.T) {
expectIds(t, env.imageIds(), []uint32{1, 2, 3, 6})
})
t.Run("A failure deleting a copy after the rewrite is 500 and the bodies stay switched", func(t *testing.T) {
t.Run("A failure deleting a copy after the rewrite is 500 and the bodies are rolled back", func(t *testing.T) {
env := newEnv(t)
env.images.failDeleteFor = map[uint32]error{3: errRepositoryFailure}
keep := env.urlOf(2)
bodiesBefore := env.bodies()
rec := env.send(t, http.MethodPost, "/v1/media/merge", `{"keep_id": 2, "merge_ids": [3]}`, mediaVerifiedUserId)
if rec.Code != http.StatusInternalServerError {
t.Fatalf("expected status 500, got %d", rec.Code)
}
if !strings.Contains(env.articles.articles[1].Body, keep) || strings.Contains(env.articles.articles[1].Body, env.urlOf(3)) {
t.Errorf("expected article 1 to use image 2's url, got %q", env.articles.articles[1].Body)
if !reflect.DeepEqual(env.bodies(), bodiesBefore) {
t.Errorf("expected every body back to its original text, got %v", env.bodies())
}
if _, ok := env.images.images[3]; !ok {
t.Errorf("expected image 3 to still exist")
@@ -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(), newMockContentTypeRepo(), newMockEntryRepo())
uc := newTestMergeMedia(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)
+97
View File
@@ -0,0 +1,97 @@
package test
import (
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/infrastructure/useCases"
"context"
"io"
"log"
"net/http"
"strconv"
"testing"
)
func TestMergeMediaReportsEntriesAndTargetsTheKeptImage(t *testing.T) {
t.Run("A merge counts repointed entries and targets the kept image", func(t *testing.T) {
env := newHardeningEnv(t)
env.seedMergeFixture()
merge := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage, env.variants, env.recorder, env.types, env.entries, env.transactor, log.New(io.Discard, "", 0))
result, err := merge.Merge(context.Background(), 7, []uint32{9})
if err != nil || result.EntriesUpdated != 2 {
t.Fatalf("expected 2 entries updated, got %+v %v", result, err)
}
event := env.expectEvents(t, audit.ActionMediaMerge, audit.OutcomeSuccess, 1)
if event.Target.ID != strconv.Itoa(7) || event.Target.Name != env.images.images[7].Filename {
t.Errorf("unexpected target %+v", event.Target)
}
})
t.Run("A failed merge still targets the kept image", func(t *testing.T) {
env := newHardeningEnv(t)
env.seedMergeFixture()
env.transactor.failCommit = errRepositoryFailure
merge := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage, env.variants, env.recorder, env.types, env.entries, env.transactor, log.New(io.Discard, "", 0))
_, err := merge.Merge(context.Background(), 7, []uint32{9})
if err == nil {
t.Fatalf("expected an error")
}
event := env.expectEvents(t, audit.ActionMediaMerge, audit.OutcomeFailed, 1)
if event.Target.ID != "7" {
t.Errorf("expected the kept image as target, got %+v", event.Target)
}
})
t.Run("A rejected request has no target", func(t *testing.T) {
env := newHardeningEnv(t)
env.seedMergeFixture()
merge := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage, env.variants, env.recorder, env.types, env.entries, env.transactor, log.New(io.Discard, "", 0))
_, err := merge.Merge(context.Background(), 0, []uint32{9})
if err == nil {
t.Fatalf("expected an error")
}
for _, event := range env.recorder.events {
if event.Target != (audit.Target{}) {
t.Errorf("expected no target, got %+v", event.Target)
}
}
})
}
func TestPurgeAuditReportsFailedGroupsOnlyWhenAny(t *testing.T) {
t.Run("A clean run omits failed_groups", func(t *testing.T) {
env := newHardeningEnv(t)
env.seedPurgeGroups(1)
rec := env.send(t, http.MethodPost, "/v1/media/purge", `{}`)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rec.Code)
}
event := env.expectEvents(t, audit.ActionMediaPurge, audit.OutcomeSuccess, 1)
if _, present := event.Params[audit.ParamFailedGroups]; present {
t.Errorf("unexpected failed_groups in %+v", event.Params)
}
})
t.Run("A run with a failed group reports the count", func(t *testing.T) {
env := newHardeningEnv(t)
env.seedPurgeGroups(2)
env.transactor.failCommit = errRepositoryFailure
rec := env.send(t, http.MethodPost, "/v1/media/purge", `{}`)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rec.Code)
}
event := env.expectEvents(t, audit.ActionMediaPurge, audit.OutcomeSuccess, 1)
if event.Params[audit.ParamFailedGroups] != 2 {
t.Errorf("expected failed_groups 2, got %v", event.Params)
}
})
}
File diff suppressed because it is too large. Load diff
+160
View File
@@ -0,0 +1,160 @@
package test
import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/author"
"RenewCMS/internal/domain/contenttype"
"RenewCMS/internal/domain/entry"
"RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/site"
"RenewCMS/internal/domain/transaction"
"RenewCMS/internal/infrastructure/useCases"
"context"
"io"
"log"
"maps"
)
type mergeSnapshotter interface {
mergeSnapshot() func()
}
func (m *mockImageRepo) mergeSnapshot() func() {
saved, nextID := maps.Clone(m.images), m.nextID
return func() { m.images, m.nextID = maps.Clone(saved), nextID }
}
func (m *mockArticleRepo) mergeSnapshot() func() {
saved := maps.Clone(m.articles)
return func() { m.articles = maps.Clone(saved) }
}
func (m *mockSiteRepo) mergeSnapshot() func() {
saved := maps.Clone(m.sites)
return func() { m.sites = maps.Clone(saved) }
}
func (m *mockAuthorRepo) mergeSnapshot() func() {
saved := maps.Clone(m.authors)
return func() { m.authors = maps.Clone(saved) }
}
func (m *mockEntryRepo) mergeSnapshot() func() {
saved := maps.Clone(m.entries)
return func() { m.entries = maps.Clone(saved) }
}
type mockMergeTransactor struct {
repos transaction.Repositories
failBegin error
failCommit error
runCalls int
committed bool
afterCommit func()
}
var _ transaction.Transactor = &mockMergeTransactor{}
func newMergeTransactor(images image.Repository, articles article.Repository, sites site.Repository, authors author.Repository, entries entry.Repository) *mockMergeTransactor {
return &mockMergeTransactor{repos: transaction.Repositories{Articles: articles, Sites: sites, Authors: authors, Entries: entries, Images: images}}
}
func (m *mockMergeTransactor) snapshots() []func() {
var restores []func()
for _, repo := range []any{m.repos.Articles, m.repos.Sites, m.repos.Authors, m.repos.Entries, m.repos.Images} {
if snapshotter, ok := repo.(mergeSnapshotter); ok {
restores = append(restores, snapshotter.mergeSnapshot())
}
}
return restores
}
func restoreAll(restores []func()) {
for _, restore := range restores {
restore()
}
}
func (m *mockMergeTransactor) Run(ctx context.Context, work func(transaction.Repositories) error) (err error) {
m.runCalls++
m.committed = false
if m.failBegin != nil {
return m.failBegin
}
restores := m.snapshots()
defer func() {
if recovered := recover(); recovered != nil {
restoreAll(restores)
panic(recovered)
}
}()
if err := work(m.repos); err != nil {
restoreAll(restores)
return err
}
if m.failCommit != nil {
restoreAll(restores)
return m.failCommit
}
m.committed = true
if m.afterCommit != nil {
m.afterCommit()
}
return nil
}
type mergeFileCall struct {
name string
committed bool
}
type mergeRecordingStorage struct {
*mockImageStorage
transactor *mockMergeTransactor
calls []mergeFileCall
failFor map[string]error
}
var _ image.Storage = &mergeRecordingStorage{}
func newMergeRecordingStorage(transactor *mockMergeTransactor) *mergeRecordingStorage {
return &mergeRecordingStorage{mockImageStorage: newMockImageStorage(), transactor: transactor, failFor: map[string]error{}}
}
func (s *mergeRecordingStorage) Delete(name string) error {
s.calls = append(s.calls, mergeFileCall{name: name, committed: s.transactor.committed})
if err, armed := s.failFor[name]; armed {
return err
}
return s.mockImageStorage.Delete(name)
}
type mergeRecordingVariantFiles struct {
*mockVariantFileRepo
transactor *mockMergeTransactor
calls []mergeFileCall
failSource map[string]error
}
var _ image.VariantFileRepository = &mergeRecordingVariantFiles{}
func newMergeRecordingVariantFiles(transactor *mockMergeTransactor) *mergeRecordingVariantFiles {
return &mergeRecordingVariantFiles{mockVariantFileRepo: newMockVariantFileRepo(), transactor: transactor, failSource: map[string]error{}}
}
func (v *mergeRecordingVariantFiles) ListBySource(sourceName string) ([]image.VariantFile, error) {
if err, armed := v.failSource[sourceName]; armed {
return nil, err
}
return v.mockVariantFileRepo.ListBySource(sourceName)
}
func (v *mergeRecordingVariantFiles) Delete(id uint32) error {
v.calls = append(v.calls, mergeFileCall{name: v.rows[id].SourceName, committed: v.transactor.committed})
return v.mockVariantFileRepo.Delete(id)
}
func newTestMergeMedia(images image.Repository, articles article.Repository, storage image.Storage, variantFiles image.VariantFileRepository, recorder audit.Recorder, sites site.Repository, authors author.Repository, types contenttype.Repository, entries entry.Repository) *useCases.MergeMediaUseCase {
return useCases.NewMergeMediaUseCase(images, articles, storage, variantFiles, recorder, types, entries, newMergeTransactor(images, articles, sites, authors, entries), log.New(io.Discard, "", 0))
}
+9 -3
View File
@@ -743,16 +743,22 @@ func TestPurgeRun(t *testing.T) {
env.useImages(22, 3, 4)
env.useImages(23, 3)
env.images.failDeleteFor = map[uint32]error{2: errRepositoryFailure}
originalA := env.bodies()[20]
copyURL := env.urlOf(2)
expectRaw(t, env.run(t, `{}`), 200, counters(2, 1, 1, 1, 1))
if strings.Contains(env.bodies()[20], env.urlOf(2)) {
t.Errorf("expected article 20 to stay switched to the kept copy")
if env.bodies()[20] != originalA {
t.Errorf("expected group A's bodies back to their original text")
}
if got := numberOf(env.preview(t)["groups"]); got != 1 {
t.Errorf("expected group A to be planned again, got %d groups", got)
}
env.images.failDeleteFor = nil
expectRaw(t, env.run(t, `{}`), 200, counters(1, 1, 1, 0, 0))
expectRaw(t, env.run(t, `{}`), 200, counters(1, 1, 1, 1, 0))
if strings.Contains(env.bodies()[20], copyURL) {
t.Errorf("expected article 20 to use the kept copy after the next run")
}
expectIds(t, env.imageIds(), []uint32{1, 3})
})
t.Run("Every group failing is still a 200 with the counters", func(t *testing.T) {
+5 -5
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(), newMockContentTypeRepo(), newMockEntryRepo())
merge := newTestMergeMedia(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{
@@ -64,7 +64,7 @@ func (e *schedulerEnv) seedPlannedGroup() {
func (e *schedulerEnv) tick(t *testing.T, at string) (bool, error) {
t.Helper()
_, ran, err := e.scheduler.Tick(*purgeTime(at))
_, ran, err := e.scheduler.Tick(testCtx, *purgeTime(at))
return ran, err
}
@@ -294,7 +294,7 @@ func TestPurgeSchedulerReportsTheRunResult(t *testing.T) {
env.configureDue()
env.seedPlannedGroup()
result, ran, err := env.scheduler.Tick(*purgeTime(schedulerClock))
result, ran, err := env.scheduler.Tick(testCtx, *purgeTime(schedulerClock))
want := purge.RunResult{GroupsTotal: 1, GroupsPurged: 1, Merged: 1, ArticlesUpdated: 1}
if err != nil || !ran || result != want {
@@ -305,7 +305,7 @@ func TestPurgeSchedulerReportsTheRunResult(t *testing.T) {
t.Run("A Tick that is not due returns an empty result", func(t *testing.T) {
env := newSchedulerEnv(t)
result, ran, err := env.scheduler.Tick(*purgeTime(schedulerClock))
result, ran, err := env.scheduler.Tick(testCtx, *purgeTime(schedulerClock))
if err != nil || ran || result != (purge.RunResult{}) {
t.Errorf("unexpected %+v, %v, %v", result, ran, err)
@@ -466,7 +466,7 @@ func newSchedulerJobFake(results ...schedulerJobResult) *schedulerJobFake {
return &schedulerJobFake{results: results, entered: make(chan time.Time, 16)}
}
func (f *schedulerJobFake) Tick(now time.Time) (purge.RunResult, bool, error) {
func (f *schedulerJobFake) Tick(_ context.Context, now time.Time) (purge.RunResult, bool, error) {
f.mu.Lock()
f.calls = append(f.calls, now)
var result schedulerJobResult
+3 -3
View File
@@ -32,7 +32,7 @@ func TestUpdatePurgeSettingsRejectsOutOfRangeIntervals(t *testing.T) {
func TestRunPurgeDistinguishesBusyFromConflict(t *testing.T) {
newRun := func(env *mediaEnv) *useCases.RunPurgeUseCase {
merge := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage, newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo())
merge := newTestMergeMedia(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())
}
@@ -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(), newMockContentTypeRepo(), newMockEntryRepo())
merge := newTestMergeMedia(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(), newMockContentTypeRepo(), newMockEntryRepo())
merge := newTestMergeMedia(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(), newMockContentTypeRepo(), newMockEntryRepo()).Merge(testCtx, kept.ID, []uint32{copyOne.ID, copyTwo.ID})
result, err := newTestMergeMedia(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)
+1 -1
View File
@@ -146,7 +146,7 @@ func newRuntimeSettingsContainer(t *testing.T) *api.UseCases {
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(), newMockContentTypeRepo(), newMockEntryRepo()),
MergeMediaUseCase: newTestMergeMedia(imageRepo, articleRepo, newMockImageStorage(), newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo()),
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(imageRepo, articleRepo, plainSites(), plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo()),
}
}
+8 -4
View File
@@ -283,7 +283,7 @@ func TestSeoDataCleanerCompensationFailuresAreLogged(t *testing.T) {
}
})
t.Run("A failed merge logs the body it could not restore", func(t *testing.T) {
t.Run("A failed merge rolls the bodies back and logs no compensation", func(t *testing.T) {
e := newSeoEnv(t)
e.seedBanner(5, "a.png", "same-hash")
e.seedBanner(6, "b.png", "same-hash")
@@ -292,14 +292,18 @@ func TestSeoDataCleanerCompensationFailuresAreLogged(t *testing.T) {
e.updateArticle(8, func(a *article.Article) { a.Body = e.bodyUsing(6) })
e.updateArticle(9, func(a *article.Article) { a.Body = e.bodyUsing(6) })
buffer := captureLog(t)
bodiesBefore := map[uint32]string{8: e.articles.articles[8].Body, 9: e.articles.articles[9].Body}
flaky := &seoFlakyBodyArticles{mockArticleRepo: e.articles}
merge := useCases.NewMergeMediaUseCase(e.images, flaky, newMockImageStorage(), newMockVariantFileRepo(), discardRecorder, e.sites, e.authors, newMockContentTypeRepo(), newMockEntryRepo())
merge := newTestMergeMedia(e.images, flaky, newMockImageStorage(), newMockVariantFileRepo(), discardRecorder, e.sites, e.authors, newMockContentTypeRepo(), newMockEntryRepo())
_, err := merge.Merge(t.Context(), 5, []uint32{6})
if err == nil || !strings.Contains(buffer.String(), "restore article body 8: body write 3 down") {
if err == nil || buffer.Len() != 0 {
t.Errorf("err %v log %q", err, buffer.String())
}
if e.articles.articles[8].Body != bodiesBefore[8] || e.articles.articles[9].Body != bodiesBefore[9] {
t.Errorf("expected both bodies back to their original text")
}
})
}
@@ -330,7 +334,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, newMockContentTypeRepo(), newMockEntryRepo())
merge := newTestMergeMedia(e.images, seoCaseInsensitiveBodyArticles{e.articles}, newMockImageStorage(), newMockVariantFileRepo(), discardRecorder, e.sites, e.authors, newMockContentTypeRepo(), newMockEntryRepo())
result, err := merge.Merge(t.Context(), 5, []uint32{6})
+1 -2
View File
@@ -2,7 +2,6 @@ package test
import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
"net/http"
@@ -355,7 +354,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, newMockContentTypeRepo(), newMockEntryRepo())
merge := newTestMergeMedia(e.images, e.articles, newMockImageStorage(), newMockVariantFileRepo(), discardRecorder, e.sites, e.authors, newMockContentTypeRepo(), newMockEntryRepo())
result, err := merge.Merge(testCtx, 5, []uint32{6})
+1 -1
View File
@@ -53,7 +53,7 @@ func newSeoEnv(t *testing.T) *seoEnv {
}
recorder := p.audit.recorder
renderer := &fakeBodyRenderer{}
mergeMedia := useCases.NewMergeMediaUseCase(p.images, p.articles, newMockImageStorage(), newMockVariantFileRepo(), recorder, sites, authors, newMockContentTypeRepo(), newMockEntryRepo())
mergeMedia := newTestMergeMedia(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)
+1 -1
View File
@@ -298,7 +298,7 @@ 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(), newMockContentTypeRepo(), newMockEntryRepo())
mergeMedia := newTestMergeMedia(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)