package test import ( domainArticle "RenewCMS/internal/domain/article" "RenewCMS/internal/domain/localization" "RenewCMS/internal/infrastructure/persistence/database" entity "RenewCMS/internal/infrastructure/persistence/models" persistence "RenewCMS/internal/infrastructure/persistence/repositories" "RenewCMS/internal/infrastructure/useCases" "context" "errors" "strings" "sync/atomic" "testing" "gorm.io/gorm" ) const ( writeCallbackName = "test:fail-third-move" writeInjectedFailure = "injected move failure" writeRaceWriters = 6 concurrentEditCallbackName = "test:concurrent-title-edit" ) type storyDatabase struct { db *gorm.DB articles *persistence.ArticleRepository journal entity.Site atelier entity.Site } func newStoryDatabase(t *testing.T, open func(*testing.T) *gorm.DB) *storyDatabase { t.Helper() db := open(t) sites := persistence.NewSiteRepository(db) journal, atelier := mustCreateSite(t, sites, "Journal", "journal", false), mustCreateSite(t, sites, "Atelier", "atelier", false) for _, siteID := range []uint32{journal.ID, atelier.ID} { for _, tag := range []string{"en", "fr", "de"} { row := entity.SiteLanguage{SiteID: siteID, Lang: tag, IsDefault: tag == "en", Enabled: true, Confirmed: tag == "en"} if err := db.Create(&row).Error; err != nil { t.Fatal(err) } } } return &storyDatabase{db: db, articles: persistence.NewArticleRepository(db), journal: entity.Site{ID: journal.ID, Key: "journal"}, atelier: entity.Site{ID: atelier.ID, Key: "atelier"}} } func (s *storyDatabase) add(t *testing.T, siteID uint32, storyID uint32, lang string, slug string) domainArticle.Article { t.Helper() created, err := s.articles.Create(domainArticle.Article{Title: "Article " + slug, Body: "Body", SiteID: siteID, StoryID: storyID, Lang: lang, Slug: slug, SEODismissed: []string{}, CreatedBy: 1}) if err != nil { t.Fatalf("create %s/%s: %v", lang, slug, err) } return created } func (s *storyDatabase) stories(t *testing.T) int64 { t.Helper() var count int64 if err := s.db.Model(&entity.Story{}).Count(&count).Error; err != nil { t.Fatal(err) } return count } func (s *storyDatabase) translations() *useCases.CreateTranslationUseCase { return useCases.NewCreateTranslationUseCase(persistence.NewTransactor(s.db), s.articles, persistence.NewLocalizationRepository(s.db), &fakeBodyRenderer{}, discardRecorder) } func editTitleBeforeNextWrite(t *testing.T, s *storyDatabase, id uint32, title string) { t.Helper() armed := true err := s.db.Callback().Update().Before("gorm:update").Register(concurrentEditCallbackName, func(tx *gorm.DB) { if !armed || tx.Statement.Table != "articles" { return } armed = false if _, err := s.articles.Update(id, domainArticle.Update{Title: &title}); err != nil { _ = tx.AddError(err) } }) if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = s.db.Callback().Update().Remove(concurrentEditCallbackName) }) } func yes() *bool { value := true return &value } func runArticleStoryWritePath(t *testing.T, open func(*testing.T) *gorm.DB) { t.Run("A second version in the same language is refused, another language is stored", func(t *testing.T) { s := newStoryDatabase(t, open) first := s.add(t, s.journal.ID, 0, "en", "alpha") _, duplicate := s.articles.Create(domainArticle.Article{Title: "Beta", SiteID: s.journal.ID, StoryID: first.StoryID, Lang: "en", Slug: "beta", SEODismissed: []string{}}) other := s.add(t, s.journal.ID, first.StoryID, "fr", "alpha-fr") if !errors.Is(duplicate, domainArticle.ErrLanguageTaken) || other.StoryID != first.StoryID { t.Errorf("expected ErrLanguageTaken then a stored fr version, got %v and %+v", duplicate, other) } }) t.Run("A deleted language can be created again and still counts as in use", func(t *testing.T) { s := newStoryDatabase(t, open) english := s.add(t, s.journal.ID, 0, "en", "alpha") for round := 0; round < 3; round++ { french := s.add(t, s.journal.ID, english.StoryID, "fr", "alpha-fr") if err := s.articles.Delete(french.ID); err != nil { t.Fatalf("round %d: delete: %v", round, err) } } counts, err := persistence.NewLocalizationRepository(s.db).VersionCounts(s.journal.ID) if err != nil || counts["fr"].Trashed != 3 || counts["fr"].Total != 0 { t.Errorf("deleted fr versions must still count as in use: %+v %v", counts["fr"], err) } if _, err := s.articles.Create(domainArticle.Article{Title: "Again", SiteID: s.journal.ID, StoryID: english.StoryID, Lang: "fr", Slug: "alpha-fr", SEODismissed: []string{}}); err != nil { t.Errorf("fr can be created again: %v", err) } }) t.Run("Deleting one version keeps the story, deleting the last removes it", func(t *testing.T) { s := newStoryDatabase(t, open) english := s.add(t, s.journal.ID, 0, "en", "alpha") french := s.add(t, s.journal.ID, english.StoryID, "fr", "alpha-fr") alone := s.add(t, s.journal.ID, 0, "en", "alone") stories := s.stories(t) if err := s.articles.Delete(french.ID); err != nil || s.stories(t) != stories { t.Fatalf("deleting one of two versions keeps the story: %v %d", err, s.stories(t)) } if err := s.articles.Delete(english.ID); err != nil || s.stories(t) != stories-1 { t.Fatalf("deleting the last version removes the story: %v %d", err, s.stories(t)) } if err := s.articles.Delete(alone.ID); err != nil || s.stories(t) != stories-2 { t.Fatalf("deleting a single version removes its story: %v %d", err, s.stories(t)) } if _, err := s.articles.Create(domainArticle.Article{Title: "Orphan", SiteID: s.journal.ID, StoryID: english.StoryID, Lang: "de", Slug: "orphan", SEODismissed: []string{}}); !errors.Is(err, domainArticle.ErrArticleNotFound) { t.Errorf("creating in a removed story is refused: %v", err) } }) t.Run("The free-slug retry gives the next suffixed slug", func(t *testing.T) { s := newStoryDatabase(t, open) first := s.add(t, s.journal.ID, 0, "en", "alpha") second := s.add(t, s.journal.ID, 0, "en", "other") caller := ownerCaller(1) one, err := s.translations().CreateTranslation(context.Background(), useCases.CreateTranslationCommand{ArticleID: first.ID, Lang: "fr", Title: "Meme titre", Copy: yes(), Caller: caller}) two, errTwo := s.translations().CreateTranslation(context.Background(), useCases.CreateTranslationCommand{ArticleID: second.ID, Lang: "fr", Title: "Meme titre", Copy: yes(), Caller: caller}) if err != nil || errTwo != nil || one.Slug != "meme-titre" || two.Slug != "meme-titre-2" { t.Errorf("unexpected slugs %q %q (%v, %v)", one.Slug, two.Slug, err, errTwo) } if one.SourceArticleID == nil || *one.SourceArticleID != first.ID || one.SourceRevision != 1 { t.Errorf("the source must be captured with the copy: %+v", one) } }) t.Run("Concurrent translation creates keep exactly one version", func(t *testing.T) { s := newStoryDatabase(t, open) english := s.add(t, s.journal.ID, 0, "en", "alpha") var created atomic.Int32 var refused atomic.Int32 jobs := []func(){} for range writeRaceWriters { jobs = append(jobs, func() { _, err := s.translations().CreateTranslation(context.Background(), useCases.CreateTranslationCommand{ArticleID: english.ID, Lang: "fr", Title: "Couts", Copy: yes(), Caller: ownerCaller(1)}) var existing *domainArticle.LanguageExistsError switch { case err == nil: created.Add(1) case errors.Is(err, domainArticle.ErrLanguageUnavailable), errors.As(err, &existing): refused.Add(1) default: t.Errorf("unexpected error: %v", err) } }) } runTogether(jobs...) versions, err := s.articles.ListByStory(english.StoryID) french := 0 for _, version := range versions { if version.Lang == "fr" { french++ } } if err != nil || created.Load() != 1 || refused.Load() != writeRaceWriters-1 || french != 1 { t.Errorf("created %d, refused %d, fr versions %d (%v)", created.Load(), refused.Load(), french, err) } }) t.Run("The compare and the write of an edit are one atomic step", func(t *testing.T) { s := newStoryDatabase(t, open) created := s.add(t, s.journal.ID, 0, "en", "alpha") var succeeded atomic.Int32 var conflicted atomic.Int32 jobs := []func(){} for range writeRaceWriters { jobs = append(jobs, func() { body := "edited" revision := created.EditRevision _, err := s.articles.Update(created.ID, domainArticle.Update{Body: &body, IfRevision: &revision}) var conflict *domainArticle.RevisionConflictError switch { case err == nil: succeeded.Add(1) case errors.As(err, &conflict): conflicted.Add(1) default: t.Errorf("unexpected error: %v", err) } }) } runTogether(jobs...) stored := storedArticle(t, s.db, created.ID) if succeeded.Load() != 1 || conflicted.Load() != writeRaceWriters-1 || stored.EditRevision != created.EditRevision+1 || stored.ContentRevision != created.ContentRevision+1 { t.Errorf("succeeded %d, conflicted %d, stored %+v", succeeded.Load(), conflicted.Load(), stored) } }) t.Run("A story moves as a whole with a revision bump per version", func(t *testing.T) { s := newStoryDatabase(t, open) english := s.add(t, s.journal.ID, 0, "en", "alpha") s.add(t, s.journal.ID, english.StoryID, "fr", "alpha-fr") s.add(t, s.journal.ID, english.StoryID, "de", "alpha-de") mover := useCases.NewMoveStoryUseCase(persistence.NewTransactor(s.db), s.articles, discardRecorder) result, err := mover.MoveStory(context.Background(), useCases.MoveStoryCommand{StoryID: english.StoryID, SiteKey: "atelier", Caller: ownerCaller(1)}) if err != nil || len(result.ArticleIDs) != 3 || result.Site.ID != s.atelier.ID { t.Fatalf("move: %+v %v", result, err) } versions, _ := s.articles.ListByStory(english.StoryID) for _, version := range versions { if version.SiteID != s.atelier.ID || version.EditRevision != 2 || version.ContentRevision != 1 { t.Errorf("version %d: %+v", version.ID, version) } } }) t.Run("A unique address violation at the destination rolls the whole move back", func(t *testing.T) { s := newStoryDatabase(t, open) english := s.add(t, s.journal.ID, 0, "en", "alpha") s.add(t, s.journal.ID, english.StoryID, "fr", "alpha-fr") s.add(t, s.journal.ID, english.StoryID, "de", "alpha-de") s.add(t, s.atelier.ID, 0, "de", "alpha-de") mover := useCases.NewMoveStoryUseCase(persistence.NewTransactor(s.db), s.articles, discardRecorder) _, err := mover.MoveStory(context.Background(), useCases.MoveStoryCommand{StoryID: english.StoryID, SiteKey: "atelier", Caller: ownerCaller(1)}) var conflict *domainArticle.SlugConflictError if !errors.As(err, &conflict) { t.Fatalf("expected a slug conflict, got %v", err) } versions, _ := s.articles.ListByStory(english.StoryID) for _, version := range versions { if version.SiteID != s.journal.ID || version.EditRevision != 1 { t.Errorf("version %d must not move: %+v", version.ID, version) } } }) t.Run("A write failing on the third version leaves every version unchanged", func(t *testing.T) { s := newStoryDatabase(t, open) english := s.add(t, s.journal.ID, 0, "en", "alpha") s.add(t, s.journal.ID, english.StoryID, "fr", "alpha-fr") s.add(t, s.journal.ID, english.StoryID, "de", "alpha-de") updates := 0 err := s.db.Callback().Update().Before("gorm:update").Register(writeCallbackName, func(tx *gorm.DB) { if tx.Statement.Table != "articles" { return } updates++ if updates == 3 { _ = tx.AddError(errors.New(writeInjectedFailure)) } }) if err != nil { t.Fatal(err) } defer func() { _ = s.db.Callback().Update().Remove(writeCallbackName) }() mover := useCases.NewMoveStoryUseCase(persistence.NewTransactor(s.db), s.articles, discardRecorder) _, failure := mover.MoveStory(context.Background(), useCases.MoveStoryCommand{StoryID: english.StoryID, SiteKey: "atelier", Caller: ownerCaller(1)}) if failure == nil { t.Fatal("the injected failure must abort the move") } versions, _ := s.articles.ListByStory(english.StoryID) for _, version := range versions { if version.SiteID != s.journal.ID || version.EditRevision != 1 { t.Errorf("version %d must be rolled back: %+v", version.ID, version) } } }) t.Run("A publish does not clobber a concurrent title edit", func(t *testing.T) { s := newStoryDatabase(t, open) created := s.add(t, s.journal.ID, 0, "en", "alpha") editTitleBeforeNextWrite(t, s, created.ID, "Edited while publishing") published, err := s.articles.UpdateIsOnline(created.ID, true) stored := storedArticle(t, s.db, created.ID) if err != nil || !published.IsOnline || !stored.IsOnline || stored.Title != "Edited while publishing" { t.Errorf("publish %+v %v, stored %+v", published, err, stored) } if stored.EditRevision != created.EditRevision+1 || stored.ContentRevision != created.ContentRevision+1 { t.Errorf("only the edit raises the revisions: %+v", stored) } }) t.Run("A body rewrite does not clobber a concurrent title edit", func(t *testing.T) { s := newStoryDatabase(t, open) created := s.add(t, s.journal.ID, 0, "en", "alpha") editTitleBeforeNextWrite(t, s, created.ID, "Edited while rewriting") rewritten, err := s.articles.UpdateBody(created.ID, "Rewritten body") stored := storedArticle(t, s.db, created.ID) if err != nil || rewritten.Body != "Rewritten body" || stored.Body != "Rewritten body" || stored.Title != "Edited while rewriting" { t.Errorf("rewrite %+v %v, stored %+v", rewritten, err, stored) } }) t.Run("A publish of a missing article is not found", func(t *testing.T) { s := newStoryDatabase(t, open) if _, err := s.articles.UpdateIsOnline(9999, true); !errors.Is(err, domainArticle.ErrArticleNotFound) { t.Errorf("expected not found, got %v", err) } }) t.Run("Migration gives existing rows revision 1 and retires soft-deleted rows", func(t *testing.T) { db, _ := legacyDatabase(t, open, localeOf("fr-CA"), 2) migrateStorage(t, db) for _, row := range storedArticles(t, db) { if row.EditRevision != 1 || row.SourceArticleID != nil || (row.ID == 2) != (row.RetiredID == row.ID) { t.Errorf("article %d: %+v", row.ID, row) } } }) t.Run("The retired-id backfill runs once per database", func(t *testing.T) { db, _ := legacyDatabase(t, open, localeOf("fr-CA"), 2) migrateStorage(t, db) if err := db.Unscoped().Model(&entity.Article{}).Where("id = ?", 2).UpdateColumn("retired_id", 0).Error; err != nil { t.Fatal(err) } migrateStorage(t, db) if retired := storedArticle(t, db, 2).RetiredID; retired != 0 { t.Errorf("the second start must not run the backfill again, retired_id %d", retired) } }) t.Run("Duplicate versions of a language stop the unique index with a descriptive error", func(t *testing.T) { s := newStoryDatabase(t, open) english := s.add(t, s.journal.ID, 0, "en", "alpha") if err := s.db.Migrator().DropIndex(&entity.Article{}, "idx_articles_story_lang"); err != nil { t.Fatal(err) } if err := s.db.Exec("DELETE FROM migration_markers WHERE name = 'article_story_language_retired'").Error; err != nil { t.Fatal(err) } duplicate := entity.Article{StoryID: english.StoryID, Title: "Duplicate", SiteID: s.journal.ID, Lang: "en", Slug: "alpha-duplicate"} if err := s.db.Create(&duplicate).Error; err != nil { t.Fatal(err) } err := database.Migrate(s.db, migrationPublicURL) if err == nil || !strings.Contains(err.Error(), "cannot create the unique index") || !strings.Contains(err.Error(), "en") { t.Errorf("expected a descriptive refusal, got %v", err) } }) t.Run("A language row stays in use while only a deleted version exists", func(t *testing.T) { s := newStoryDatabase(t, open) english := s.add(t, s.journal.ID, 0, "en", "alpha") french := s.add(t, s.journal.ID, english.StoryID, "fr", "alpha-fr") if err := s.articles.Delete(french.ID); err != nil { t.Fatal(err) } repository := persistence.NewLocalizationRepository(s.db) rows, _ := repository.Rows(s.journal.ID) counts, _ := repository.VersionCounts(s.journal.ID) _, err := localization.Plan(rows, counts, localization.Change{Default: "en", Languages: []localization.Entry{{Tag: "en", Enabled: true}, {Tag: "de", Enabled: true}}}) if !errors.Is(err, localization.ErrLanguageInUse) { t.Errorf("removing fr while a deleted fr version exists must stay refused, got %v", err) } }) } func TestArticleStoryWritePathSQLite(t *testing.T) { runArticleStoryWritePath(t, openContractSQLite) } func TestArticleStoryWritePathMySQL(t *testing.T) { runArticleStoryWritePath(t, openContractMySQL) } func TestArticleStoryWritePathPostgres(t *testing.T) { runArticleStoryWritePath(t, openContractPostgres) }