230 lines
7.2 KiB
Go
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)
|
|
}
|
|
}
|