package test import ( "RenewCMS/internal/domain/article" "RenewCMS/internal/domain/transaction" "RenewCMS/internal/infrastructure/useCases" "context" "errors" "slices" "testing" ) const storyStudio uint32 = 3 type shiftingStoryRepo struct { *mockArticleRepo listings int shiftFrom int shift func() } func (r *shiftingStoryRepo) ListByStory(storyID uint32) ([]article.Article, error) { r.listings++ if r.listings >= r.shiftFrom { r.shift() } return r.mockArticleRepo.ListByStory(storyID) } type repositoriesTransactor struct { repositories transaction.Repositories } func (r repositoriesTransactor) Run(ctx context.Context, work func(transaction.Repositories) error) error { return work(r.repositories) } func TestMoveStoryRelocksUntilTheSiteSetIsStable(t *testing.T) { t.Run("A version that appears on another site after the first lock gets that site locked too", func(t *testing.T) { w := newStoryWorld(t) w.sites.seed(storyStudio, "Studio", "studio", false) listing := &shiftingStoryRepo{mockArticleRepo: w.articles, shiftFrom: 2} listing.shift = func() { shifted := w.articles.articles[13] shifted.SiteID = storyStudio w.articles.articles[13] = shifted } mover := useCases.NewMoveStoryUseCase(repositoriesTransactor{transaction.Repositories{Articles: listing, Sites: w.sites, Authors: w.authors, Localization: w.rows}}, listing, discardRecorder) _, _ = mover.MoveStory(context.Background(), useCases.MoveStoryCommand{StoryID: storyStory, SiteKey: "atelier", Caller: ownerCaller(storyOwner)}) if !slices.Contains(w.sites.lockedSites, storyStudio) { t.Errorf("site %d must be locked, locked %v", storyStudio, w.sites.lockedSites) } }) t.Run("A set that never settles is refused as lock contention", func(t *testing.T) { w := newStoryWorld(t) listing := &shiftingStoryRepo{mockArticleRepo: w.articles, shiftFrom: 2} listing.shift = func() { shifted := w.articles.articles[13] shifted.SiteID = 100 + uint32(listing.listings) w.articles.articles[13] = shifted } mover := useCases.NewMoveStoryUseCase(repositoriesTransactor{transaction.Repositories{Articles: listing, Sites: w.sites, Authors: w.authors, Localization: w.rows}}, listing, discardRecorder) _, err := mover.MoveStory(context.Background(), useCases.MoveStoryCommand{StoryID: storyStory, SiteKey: "atelier", Caller: ownerCaller(storyOwner)}) if !errors.Is(err, transaction.ErrLockContention) { t.Errorf("expected lock contention, got %v", err) } if listing.listings != 7 { t.Errorf("expected two listings plus five relock rounds, got %d listings", listing.listings) } }) } func TestDeleteStoryRelocksUntilTheSiteSetIsStable(t *testing.T) { t.Run("A set that never settles is refused as lock contention after the last round", func(t *testing.T) { w := newStoryWorld(t) listing := &shiftingStoryRepo{mockArticleRepo: w.articles, shiftFrom: 1} listing.shift = func() { shifted := w.articles.articles[13] shifted.SiteID = 100 + uint32(listing.listings) w.articles.articles[13] = shifted } deleter := useCases.NewDeleteStoryUseCase(repositoriesTransactor{transaction.Repositories{Articles: listing, Sites: w.sites}}, listing, discardRecorder) err := deleter.DeleteStory(context.Background(), useCases.DeleteStoryCommand{StoryID: storyStory, Caller: ownerCaller(storyOwner)}) if !errors.Is(err, transaction.ErrLockContention) { t.Errorf("expected lock contention, got %v", err) } if listing.listings != 6 { t.Errorf("expected five lock rounds plus the refusal lookup, got %d listings", listing.listings) } if _, kept := w.articles.articles[13]; !kept { t.Error("nothing may be deleted") } }) }