feat: editor write path

This commit is contained in:
Florian Sylvain committed 2026-11-02 03:24:30 +01:00
1 parent d9158025d4
commit a0b3c58c52
88 files changed
+5738 -263

No files matched your search

@@ -0,0 +1,422 @@
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)
}