package test import ( domainArticle "RenewCMS/internal/domain/article" "RenewCMS/internal/domain/transaction" entity "RenewCMS/internal/infrastructure/persistence/models" persistence "RenewCMS/internal/infrastructure/persistence/repositories" "errors" "testing" "gorm.io/gorm" ) const sqliteBusyCode = 5 type sqliteBusyError struct{} func (sqliteBusyError) Error() string { return "database is busy" } func (sqliteBusyError) Code() int { return sqliteBusyCode } func onArticleUpdate(t *testing.T, db *gorm.DB, name string, hook func(*gorm.DB)) { t.Helper() if err := db.Callback().Update().Before("gorm:update").Register(name, hook); err != nil { t.Fatal(err) } t.Cleanup(func() { _ = db.Callback().Update().Remove(name) }) } func onArticleQuery(t *testing.T, db *gorm.DB, name string, hook func(*gorm.DB)) { t.Helper() if err := db.Callback().Query().After("gorm:query").Register(name, hook); err != nil { t.Fatal(err) } t.Cleanup(func() { _ = db.Callback().Query().Remove(name) }) } type articleEdgesEnv struct { db *gorm.DB articles *persistence.ArticleRepository siteID uint32 } func newArticleEdgesEnv(t *testing.T) *articleEdgesEnv { t.Helper() db := openContractSQLite(t) return &articleEdgesEnv{db: db, articles: persistence.NewArticleRepository(db), siteID: storageDefaultSite(t, db).ID} } func (e *articleEdgesEnv) create(t *testing.T, lang string, slug string, online bool) domainArticle.Article { t.Helper() created, err := e.articles.Create(newStorageArticle(e.siteID, lang, slug, 0)) if err != nil { t.Fatal(err) } if online { if created, err = e.articles.UpdateIsOnline(created.ID, true); err != nil { t.Fatal(err) } } return created } func TestArticleRepositoryLanguageScope(t *testing.T) { e := newArticleEdgesEnv(t) e.create(t, "fr", "bonjour", true) e.create(t, "en", "hello", true) t.Run("An empty language list matches nothing", func(t *testing.T) { page, err := e.articles.FindPage(domainArticle.Filters{Langs: &[]string{}, Unpaged: true}) if err != nil || page.Total != 0 || len(page.Items) != 0 { t.Fatalf("page = %+v, err = %v", page, err) } }) t.Run("A language list keeps only its languages", func(t *testing.T) { page, err := e.articles.FindPage(domainArticle.Filters{Langs: &[]string{"fr"}, Unpaged: true}) if err != nil || page.Total != 1 || len(page.Items) != 1 || page.Items[0].Lang != "fr" { t.Fatalf("page = %+v, err = %v", page, err) } }) } func TestArticleRepositoryCountVersions(t *testing.T) { e := newArticleEdgesEnv(t) e.create(t, "fr", "en-ligne", true) draft := e.create(t, "fr", "brouillon", false) e.create(t, "en", "other", false) online := true stranger := draft.CreatedBy + 1 cases := []struct { name string filters domainArticle.Filters want domainArticle.Counts }{ {"every version of the language", domainArticle.Filters{Lang: "fr"}, domainArticle.Counts{All: 2, Online: 1, Draft: 1}}, {"online only skips the drafts", domainArticle.Filters{Lang: "fr", IsOnline: &online}, domainArticle.Counts{All: 1, Online: 1}}, {"drafts are counted for their reader only", domainArticle.Filters{Lang: "fr", VisibleTo: &stranger}, domainArticle.Counts{All: 1, Online: 1}}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { counts, err := e.articles.CountVersions(c.filters) if err != nil || counts != c.want { t.Fatalf("counts = %+v, err = %v, want %+v", counts, err, c.want) } }) } } func TestArticleRepositorySlugTaken(t *testing.T) { e := newArticleEdgesEnv(t) owner := e.create(t, "fr", "bonjour", false) cases := []struct { name string slug string exceptID uint32 want bool }{ {"a slug in use is taken", "bonjour", 0, true}, {"a free slug is not taken", "salut", 0, false}, {"the slug of the article itself is not taken", "bonjour", owner.ID, false}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { taken, err := e.articles.SlugTaken(e.siteID, "fr", c.slug, c.exceptID) if err != nil || taken != c.want { t.Fatalf("taken = %v, err = %v", taken, err) } }) } } func TestArticleRepositoryUpdateRetriesLockContention(t *testing.T) { cases := []struct { name string failures int wantErr bool attempts int }{ {"a write that is busy twice succeeds on the third attempt", 2, false, 3}, {"a write that stays busy gives up after five attempts", 1000, true, 5}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { e := newArticleEdgesEnv(t) created := e.create(t, "fr", "bonjour", false) attempts := 0 onArticleUpdate(t, e.db, "test:busy_article_writes", func(tx *gorm.DB) { if tx.Statement.Table != "articles" { return } attempts++ if attempts <= c.failures { _ = tx.AddError(sqliteBusyError{}) } }) updated, err := e.articles.Update(created.ID, domainArticle.Update{Excerpt: localeOf("Renamed")}) if c.wantErr != (err != nil) || attempts != c.attempts { t.Fatalf("err = %v after %d attempts", err, attempts) } if !c.wantErr && updated.Excerpt != "Renamed" { t.Fatalf("updated = %+v", updated) } }) } } func TestArticleRepositoryUpdateGivesUpOnASiteThatKeepsMoving(t *testing.T) { e := newArticleEdgesEnv(t) created := e.create(t, "fr", "bonjour", false) probes := 0 onArticleQuery(t, e.db, "test:moving_article_site", func(tx *gorm.DB) { if tx.Statement.Table != "articles" || len(tx.Statement.Selects) != 2 { return } probes++ _ = tx.Session(&gorm.Session{NewDB: true, SkipHooks: true}).Model(&entity.Article{}).Where("id = ?", created.ID).UpdateColumn("site_id", uint32(probes+1000)).Error }) _, err := e.articles.Update(created.ID, domainArticle.Update{Excerpt: localeOf("Renamed")}) if !errors.Is(err, transaction.ErrLockContention) || probes != 5 { t.Fatalf("err = %v after %d probes", err, probes) } } func TestArticleRepositoryAuthorOnlyChangeIsAnEdit(t *testing.T) { e := newArticleEdgesEnv(t) created := e.create(t, "fr", "bonjour", false) writer := entity.Author{Name: "Ada", Slug: "ada", SocialLinks: "[]"} if err := e.db.Create(&writer).Error; err != nil { t.Fatal(err) } credited := []uint32{writer.ID} updated, err := e.articles.Update(created.ID, domainArticle.Update{AuthorIDs: &credited}) if err != nil || updated.EditRevision != created.EditRevision+1 || updated.ContentRevision != created.ContentRevision { t.Fatalf("after crediting: %+v, err = %v", updated, err) } again, err := e.articles.Update(created.ID, domainArticle.Update{AuthorIDs: &credited}) if err != nil || again.EditRevision != updated.EditRevision { t.Fatalf("the same authors bumped the revision: %+v, err = %v", again, err) } } func TestArticleRepositoryUnrelatedUniqueViolationIsNotALanguageClash(t *testing.T) { e := newArticleEdgesEnv(t) created := e.create(t, "fr", "bonjour", false) writer := entity.Author{Name: "Ada", Slug: "ada", SocialLinks: "[]"} if err := e.db.Create(&writer).Error; err != nil { t.Fatal(err) } twice := []uint32{writer.ID, writer.ID} _, err := e.articles.Update(created.ID, domainArticle.Update{AuthorIDs: &twice}) if err == nil || errors.Is(err, domainArticle.ErrLanguageTaken) || errors.Is(err, domainArticle.ErrSlugTaken) { t.Fatalf("err = %v, the duplicate credit must surface as itself", err) } }