423 lines
16 KiB
Go
423 lines
16 KiB
Go
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)
|
|
}
|