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