Files
RenewCMS/test/article_repository_edges_test.go
T

230 lines
7.2 KiB
Go

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)
}
}