refac: atomic merge, lost-update race, RunPurge cost, duplicated walks
This commit is contained in:
1 parent
0fc3511e2b
commit
074944c99a
45 files changed
+2204
-267
No files matched your search
@@ -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{})
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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))
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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})
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
|
||||
|
||||
@@ -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})
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user