Files
RenewCMS/test/author_persistence_test.go
T

483 lines
18 KiB
Go

package test
import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/author"
"RenewCMS/internal/infrastructure/persistence/database"
entity "RenewCMS/internal/infrastructure/persistence/models"
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
"errors"
"fmt"
"reflect"
"testing"
"gorm.io/gorm"
)
func mustCreateAuthor(t *testing.T, authors *persistence.AuthorRepository, name string, slug string, userID *uint32) author.Author {
t.Helper()
created, err := authors.Create(author.Author{Name: name, Slug: slug, SocialLinks: []author.SocialLink{}, UserID: userID})
if err != nil {
t.Fatalf("create author %q: %v", slug, err)
}
return created
}
func mustCreateCreditedArticle(t *testing.T, db *gorm.DB, title string, authorIDs ...uint32) article.Article {
t.Helper()
bylines := make([]author.Byline, 0, len(authorIDs))
for _, id := range authorIDs {
bylines = append(bylines, author.Byline{ID: id})
}
created, err := persistence.NewArticleRepository(db).Create(article.Article{Title: title, Body: "Body", Authors: bylines, Slug: uniqueTestSlug()})
if err != nil {
t.Fatalf("create article: %v", err)
}
return created
}
func creditedIDs(t *testing.T, db *gorm.DB, articleID uint32) []uint32 {
t.Helper()
found, err := persistence.NewArticleRepository(db).Get(articleID)
if err != nil {
t.Fatalf("get article: %v", err)
}
ids := []uint32{}
for _, byline := range found.Authors {
ids = append(ids, byline.ID)
}
return ids
}
func TestAuthorRepositoryContract(t *testing.T) {
backends := []contractBackend{
{name: "sqlite", open: openContractSQLite},
{name: "mysql", open: openContractMySQL},
{name: "postgres", open: openContractPostgres},
}
cases := []struct {
name string
run func(t *testing.T, db *gorm.DB, authors *persistence.AuthorRepository)
}{
{"create and read back every field", func(t *testing.T, db *gorm.DB, authors *persistence.AuthorRepository) {
website := "https://example.com"
created, err := authors.Create(author.Author{Name: "Camille", Slug: "camille", Bio: "Bio", Website: &website, SocialLinks: []author.SocialLink{{Type: "x", URL: "https://x.com/c"}}})
if err != nil {
t.Fatal(err)
}
found, err := authors.Get(created.ID)
if err != nil || found.Name != "Camille" || found.Bio != "Bio" || *found.Website != website || len(found.SocialLinks) != 1 || found.IsLinked || found.ArticleCount != 0 {
t.Errorf("found %+v %v", found, err)
}
bySlug, _ := authors.GetBySlug("camille")
if bySlug.ID != created.ID {
t.Errorf("by slug %+v", bySlug)
}
}},
{"a missing author is a domain error", func(t *testing.T, db *gorm.DB, authors *persistence.AuthorRepository) {
if _, err := authors.Get(99); !errors.Is(err, author.ErrAuthorNotFound) {
t.Errorf("get: %v", err)
}
if _, err := authors.GetBySlug("nope"); !errors.Is(err, author.ErrAuthorNotFound) {
t.Errorf("slug: %v", err)
}
if _, err := authors.GetByUserID(9); !errors.Is(err, author.ErrAuthorNotFound) {
t.Errorf("user: %v", err)
}
if err := authors.DeleteMovingCredits(99, nil); !errors.Is(err, author.ErrAuthorNotFound) {
t.Errorf("delete: %v", err)
}
}},
{"slug and user are unique while many guests are allowed", func(t *testing.T, db *gorm.DB, authors *persistence.AuthorRepository) {
user := uint32(1)
mustCreateAuthor(t, authors, "A", "a", &user)
mustCreateAuthor(t, authors, "B", "b", nil)
mustCreateAuthor(t, authors, "C", "c", nil)
_, slugErr := authors.Create(author.Author{Name: "D", Slug: "a", SocialLinks: []author.SocialLink{}})
_, userErr := authors.Create(author.Author{Name: "E", Slug: "e", SocialLinks: []author.SocialLink{}, UserID: &user})
if !errors.Is(slugErr, author.ErrSlugTaken) || !errors.Is(userErr, author.ErrUserAlreadyLinked) {
t.Errorf("slug %v user %v", slugErr, userErr)
}
b, _ := authors.GetBySlug("b")
b.Slug = "a"
if _, err := authors.Update(b); !errors.Is(err, author.ErrSlugTaken) {
t.Errorf("update: %v", err)
}
}},
{"update changes every field and can clear the nullable ones", func(t *testing.T, db *gorm.DB, authors *persistence.AuthorRepository) {
user := uint32(4)
website := "https://example.com"
created := mustCreateAuthor(t, authors, "A", "a", &user)
created.Name = "Z"
created.Website = &website
created.UserID = nil
created.SocialLinks = []author.SocialLink{{Type: "custom", URL: "https://c.example"}}
updated, err := authors.Update(created)
if err != nil || updated.Name != "Z" || updated.UserID != nil || updated.IsLinked || *updated.Website != website || len(updated.SocialLinks) != 1 {
t.Errorf("updated %+v %v", updated, err)
}
updated.Website = nil
cleared, _ := authors.Update(updated)
if cleared.Website != nil {
t.Errorf("cleared %+v", cleared)
}
}},
{"the list is newest first, searchable and paginated", func(t *testing.T, db *gorm.DB, authors *persistence.AuthorRepository) {
for i := 1; i <= 7; i++ {
mustCreateAuthor(t, authors, fmt.Sprintf("Writer %d", i), fmt.Sprintf("writer-%d", i), nil)
}
mustCreateAuthor(t, authors, "Inès Duarte", "zl", nil)
page, err := authors.List(author.ListQuery{Page: 2, Size: 5})
search, _ := authors.List(author.ListQuery{Q: "INES", Page: 1, Size: 25})
bySlug, _ := authors.List(author.ListQuery{Q: "zl", Page: 1, Size: 25})
none, _ := authors.List(author.ListQuery{Q: "nobody", Page: 1, Size: 25})
if err != nil || page.Total != 8 || len(page.Items) != 3 || page.Items[0].Name != "Writer 3" {
t.Errorf("page %+v %v", page, err)
}
if search.Total != 1 || bySlug.Total != 1 || none.Total != 0 || len(none.Items) != 0 {
t.Errorf("search %+v slug %+v none %+v", search, bySlug, none)
}
}},
{"credits keep their order and are counted", func(t *testing.T, db *gorm.DB, authors *persistence.AuthorRepository) {
a := mustCreateAuthor(t, authors, "A", "a", nil)
b := mustCreateAuthor(t, authors, "B", "b", nil)
c := mustCreateAuthor(t, authors, "C", "c", nil)
first := mustCreateCreditedArticle(t, db, "First", c.ID, a.ID, b.ID)
mustCreateCreditedArticle(t, db, "Second", a.ID)
if got := creditedIDs(t, db, first.ID); !reflect.DeepEqual(got, []uint32{c.ID, a.ID, b.ID}) {
t.Errorf("order = %v", got)
}
counted, _ := authors.Get(a.ID)
if counted.ArticleCount != 2 {
t.Errorf("count = %d", counted.ArticleCount)
}
reordered := []uint32{b.ID, c.ID}
if _, err := persistence.NewArticleRepository(db).Update(first.ID, article.Update{AuthorIDs: &reordered}); err != nil {
t.Fatal(err)
}
if got := creditedIDs(t, db, first.ID); !reflect.DeepEqual(got, reordered) {
t.Errorf("after replace = %v", got)
}
}},
{"an author is credited at most once on an article", func(t *testing.T, db *gorm.DB, authors *persistence.AuthorRepository) {
a := mustCreateAuthor(t, authors, "A", "a", nil)
created := mustCreateCreditedArticle(t, db, "First", a.ID)
duplicated := []uint32{a.ID, a.ID}
_, err := persistence.NewArticleRepository(db).Update(created.ID, article.Update{AuthorIDs: &duplicated})
if err == nil {
t.Errorf("expected the unique pair to be refused")
}
if got := creditedIDs(t, db, created.ID); !reflect.DeepEqual(got, []uint32{a.ID}) {
t.Errorf("credits = %v", got)
}
}},
{"deleting an article removes its credits and keeps the author", func(t *testing.T, db *gorm.DB, authors *persistence.AuthorRepository) {
a := mustCreateAuthor(t, authors, "A", "a", nil)
first := mustCreateCreditedArticle(t, db, "First", a.ID)
mustCreateCreditedArticle(t, db, "Second", a.ID)
if err := persistence.NewArticleRepository(db).Delete(first.ID); err != nil {
t.Fatal(err)
}
kept, _ := authors.Get(a.ID)
var credits int64
db.Model(&entity.ArticleAuthor{}).Where("article_id = ?", first.ID).Count(&credits)
if kept.ArticleCount != 1 || credits != 0 {
t.Errorf("count %d credits %d", kept.ArticleCount, credits)
}
}},
{"reassigning keeps the position and never duplicates", func(t *testing.T, db *gorm.DB, authors *persistence.AuthorRepository) {
a := mustCreateAuthor(t, authors, "A", "a", nil)
b := mustCreateAuthor(t, authors, "B", "b", nil)
c := mustCreateAuthor(t, authors, "C", "c", nil)
d := mustCreateAuthor(t, authors, "D", "d", nil)
positioned := mustCreateCreditedArticle(t, db, "Positioned", a.ID, b.ID, c.ID)
shared := mustCreateCreditedArticle(t, db, "Shared", b.ID, d.ID)
if err := authors.DeleteMovingCredits(b.ID, &d.ID); err != nil {
t.Fatal(err)
}
if got := creditedIDs(t, db, positioned.ID); !reflect.DeepEqual(got, []uint32{a.ID, d.ID, c.ID}) {
t.Errorf("positioned = %v", got)
}
if got := creditedIDs(t, db, shared.ID); !reflect.DeepEqual(got, []uint32{d.ID}) {
t.Errorf("shared = %v", got)
}
if _, err := authors.Get(b.ID); !errors.Is(err, author.ErrAuthorNotFound) {
t.Errorf("the author still exists: %v", err)
}
}},
{"deleting with no destination removes only that credit", func(t *testing.T, db *gorm.DB, authors *persistence.AuthorRepository) {
a := mustCreateAuthor(t, authors, "A", "a", nil)
b := mustCreateAuthor(t, authors, "B", "b", nil)
c := mustCreateAuthor(t, authors, "C", "c", nil)
shared := mustCreateCreditedArticle(t, db, "Shared", a.ID, b.ID, c.ID)
alone := mustCreateCreditedArticle(t, db, "Alone", b.ID)
if err := authors.DeleteUncrediting(b.ID); err != nil {
t.Fatal(err)
}
if got := creditedIDs(t, db, shared.ID); !reflect.DeepEqual(got, []uint32{a.ID, c.ID}) {
t.Errorf("shared = %v", got)
}
if got := creditedIDs(t, db, alone.ID); len(got) != 0 {
t.Errorf("alone = %v", got)
}
}},
{"avatars are counted, cleared and repointed", func(t *testing.T, db *gorm.DB, authors *persistence.AuthorRepository) {
images := newContractRepositories(db)
kept := mustCreateImage(t, images, "a.png", "hash-a")
copied := mustCreateImage(t, images, "b.png", "hash-a")
a := mustCreateAuthor(t, authors, "A", "a", nil)
a.AvatarID = &copied.ID
if _, err := authors.Update(a); err != nil {
t.Fatal(err)
}
usage, _ := authors.AvatarUsage()
repointed, err := authors.RepointAvatar([]uint32{copied.ID}, kept.ID)
afterRepoint, _ := authors.Get(a.ID)
cleared, clearErr := authors.ClearAvatar(kept.ID)
afterClear, _ := authors.Get(a.ID)
restoreErr := authors.AssignAvatar(cleared, kept.ID)
restored, _ := authors.Get(a.ID)
if usage[copied.ID] != 1 || err != nil || repointed != 1 || *afterRepoint.AvatarID != kept.ID {
t.Fatalf("usage %v repoint %d %v %+v", usage, repointed, err, afterRepoint)
}
if clearErr != nil || len(cleared) != 1 || afterClear.AvatarID != nil || restoreErr != nil || *restored.AvatarID != kept.ID {
t.Errorf("clear %v %v %+v restore %v %+v", cleared, clearErr, afterClear, restoreErr, restored)
}
}},
}
for _, backend := range backends {
t.Run(backend.name, func(t *testing.T) {
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
db := backend.open(t)
c.run(t, db, persistence.NewAuthorRepository(db))
})
}
})
}
}
func TestAuthorRepositoryIsAtomicOnSQLite(t *testing.T) {
t.Run("Reassign and delete are all-or-nothing", func(t *testing.T) {
db := openContractSQLite(t)
authors := persistence.NewAuthorRepository(db)
a := mustCreateAuthor(t, authors, "A", "a", nil)
b := mustCreateAuthor(t, authors, "B", "b", nil)
first := mustCreateCreditedArticle(t, db, "First", a.ID)
if err := db.Exec("CREATE TRIGGER refuse_author_delete BEFORE DELETE ON authors BEGIN SELECT RAISE(ABORT, 'refused'); END").Error; err != nil {
t.Fatal(err)
}
if err := authors.DeleteMovingCredits(a.ID, &b.ID); err == nil {
t.Fatal("expected the delete to fail")
}
if got := creditedIDs(t, db, first.ID); !reflect.DeepEqual(got, []uint32{a.ID}) {
t.Errorf("credits = %v", got)
}
if still, err := authors.Get(a.ID); err != nil || still.ArticleCount != 1 {
t.Errorf("author %+v %v", still, err)
}
})
}
func TestAuthorRepositoryEdges(t *testing.T) {
t.Run("A searched list pages through its matches and returns them", func(t *testing.T) {
db := openContractSQLite(t)
authors := persistence.NewAuthorRepository(db)
for i := 1; i <= 5; i++ {
mustCreateAuthor(t, authors, fmt.Sprintf("Writer %d", i), fmt.Sprintf("writer-%d", i), nil)
}
mustCreateAuthor(t, authors, "Other", "other", nil)
first, _ := authors.List(author.ListQuery{Q: "writer", Page: 1, Size: 2})
second, _ := authors.List(author.ListQuery{Q: "writer", Page: 2, Size: 2})
beyond, _ := authors.List(author.ListQuery{Q: "writer", Page: 4, Size: 2})
if first.Total != 5 || len(first.Items) != 2 || first.Items[0].Name != "Writer 5" || first.Items[1].Name != "Writer 4" {
t.Errorf("first %+v", first)
}
if second.Total != 5 || len(second.Items) != 2 || second.Items[0].Name != "Writer 3" || second.Items[1].Name != "Writer 2" {
t.Errorf("second %+v", second)
}
if beyond.Total != 5 || len(beyond.Items) != 0 {
t.Errorf("beyond %+v", beyond)
}
})
t.Run("A write failure that is not a conflict is reported", func(t *testing.T) {
db := openContractSQLite(t)
authors := persistence.NewAuthorRepository(db)
if err := db.Exec("CREATE TRIGGER refuse_author_insert BEFORE INSERT ON authors BEGIN SELECT RAISE(ABORT, 'refused'); END").Error; err != nil {
t.Fatal(err)
}
created, err := authors.Create(author.Author{Name: "A", Slug: "a", SocialLinks: []author.SocialLink{}})
if err == nil || errors.Is(err, author.ErrSlugTaken) || created.ID != 0 {
t.Errorf("created %+v err %v", created, err)
}
})
t.Run("Deleting toward a missing destination changes nothing", func(t *testing.T) {
db := openContractSQLite(t)
authors := persistence.NewAuthorRepository(db)
a := mustCreateAuthor(t, authors, "A", "a", nil)
first := mustCreateCreditedArticle(t, db, "First", a.ID)
missing := uint32(999)
toMissing := authors.DeleteMovingCredits(a.ID, &missing)
fromMissing := authors.DeleteMovingCredits(missing, nil)
if !errors.Is(toMissing, author.ErrAuthorNotFound) || !errors.Is(fromMissing, author.ErrAuthorNotFound) {
t.Errorf("errors %v %v", toMissing, fromMissing)
}
if got := creditedIDs(t, db, first.ID); !reflect.DeepEqual(got, []uint32{a.ID}) {
t.Errorf("credits = %v", got)
}
})
}
func TestAuthorRepositoryPaging(t *testing.T) {
db := openContractSQLite(t)
authors := persistence.NewAuthorRepository(db)
for i := 1; i <= 5; i++ {
mustCreateAuthor(t, authors, fmt.Sprintf("Writer %d", i), fmt.Sprintf("writer-%d", i), nil)
}
for _, q := range []string{"", "writer"} {
empty, emptyErr := authors.List(author.ListQuery{Q: q, Page: 1, Size: 0})
if emptyErr != nil || len(empty.Items) != 0 || empty.Total != 5 {
t.Errorf("q=%q size 0 %+v %v", q, empty, emptyErr)
}
second, _ := authors.List(author.ListQuery{Q: q, Page: 2, Size: 2})
last, _ := authors.List(author.ListQuery{Q: q, Page: 3, Size: 2})
beyond, _ := authors.List(author.ListQuery{Q: q, Page: 4, Size: 2})
if len(second.Items) != 2 || second.Items[0].Name != "Writer 3" {
t.Errorf("q=%q second %+v", q, second)
}
if len(last.Items) != 1 || last.Items[0].Name != "Writer 1" || last.Total != 5 {
t.Errorf("q=%q last %+v", q, last)
}
if len(beyond.Items) != 0 || beyond.Total != 5 {
t.Errorf("q=%q beyond %+v", q, beyond)
}
}
}
func TestAuthorMigration(t *testing.T) {
t.Run("The new tables are migrated and existing articles get no authors", func(t *testing.T) {
db := openUnmigratedSQLite(t)
if err := db.AutoMigrate(&entity.Article{}); err != nil {
t.Fatal(err)
}
for i := 0; i < 4; i++ {
if err := db.Create(&entity.Article{Title: fmt.Sprintf("Old %d", i), Body: "Body"}).Error; err != nil {
t.Fatal(err)
}
}
if err := database.Migrate(db, migrationPublicURL); err != nil {
t.Fatalf("migrate: %v", err)
}
if err := database.Migrate(db, migrationPublicURL); err != nil {
t.Fatalf("second migrate: %v", err)
}
var authors, credits int64
db.Model(&entity.Author{}).Count(&authors)
db.Model(&entity.ArticleAuthor{}).Count(&credits)
if authors != 0 || credits != 0 || !db.Migrator().HasTable("authors") || !db.Migrator().HasTable("article_authors") {
t.Errorf("authors %d credits %d", authors, credits)
}
found, err := persistence.NewArticleRepository(db).FindPage(article.Filters{Page: 1, Size: 10})
if err != nil || found.Total != 4 || len(found.Items[0].Authors) != 0 {
t.Errorf("articles %+v %v", found, err)
}
})
t.Run("The unique indexes exist", func(t *testing.T) {
db := openContractSQLite(t)
for _, name := range []string{"idx_authors_slug", "idx_authors_user_id", "idx_article_authors_pair"} {
table := "authors"
if name == "idx_article_authors_pair" {
table = "article_authors"
}
if !db.Migrator().HasIndex(table, name) {
t.Errorf("missing index %s", name)
}
}
})
}
func TestAuthorRepositoryGuards(t *testing.T) {
t.Run("Deleting without a destination refuses a credited author", func(t *testing.T) {
db := openContractSQLite(t)
authors := persistence.NewAuthorRepository(db)
a := mustCreateAuthor(t, authors, "A", "a", nil)
first := mustCreateCreditedArticle(t, db, "First", a.ID)
err := authors.DeleteMovingCredits(a.ID, nil)
if !errors.Is(err, author.ErrAuthorHasArticles) {
t.Errorf("err = %v", err)
}
if got := creditedIDs(t, db, first.ID); !reflect.DeepEqual(got, []uint32{a.ID}) {
t.Errorf("credits = %v", got)
}
if _, err := authors.Get(a.ID); err != nil {
t.Errorf("author gone: %v", err)
}
})
t.Run("Deleting without a destination removes an uncredited author", func(t *testing.T) {
db := openContractSQLite(t)
authors := persistence.NewAuthorRepository(db)
a := mustCreateAuthor(t, authors, "A", "a", nil)
if err := authors.DeleteMovingCredits(a.ID, nil); err != nil {
t.Fatal(err)
}
if _, err := authors.Get(a.ID); !errors.Is(err, author.ErrAuthorNotFound) {
t.Errorf("get = %v", err)
}
})
t.Run("A huge page number lists nothing and keeps the total", func(t *testing.T) {
db := openContractSQLite(t)
authors := persistence.NewAuthorRepository(db)
for i := 1; i <= 3; i++ {
mustCreateAuthor(t, authors, fmt.Sprintf("Writer %d", i), fmt.Sprintf("writer-%d", i), nil)
}
huge := int(^uint(0) >> 1)
plain, plainErr := authors.List(author.ListQuery{Page: huge, Size: 100})
searched, searchErr := authors.List(author.ListQuery{Q: "writer", Page: huge, Size: 100})
if plainErr != nil || plain.Total != 3 || len(plain.Items) != 0 {
t.Errorf("plain %+v %v", plain, plainErr)
}
if searchErr != nil || searched.Total != 3 || len(searched.Items) != 0 {
t.Errorf("searched %+v %v", searched, searchErr)
}
})
}