Files
RenewCMS/test/site_persistence_test.go

368 lines
13 KiB
Go

package test
import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/site"
"RenewCMS/internal/infrastructure/persistence/database"
entity "RenewCMS/internal/infrastructure/persistence/models"
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
"errors"
"path/filepath"
"strconv"
"strings"
"sync/atomic"
"testing"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func openUnmigratedSQLite(t *testing.T) *gorm.DB {
t.Helper()
path := filepath.ToSlash(filepath.Join(t.TempDir(), "sites.db"))
connection, err := database.Connect(database.Config{Driver: database.DriverSQLite, Source: path})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
connection.DB.Logger = logger.Discard
t.Cleanup(func() { _ = connection.Close() })
return connection.DB
}
func storedSites(t *testing.T, db *gorm.DB) []entity.Site {
t.Helper()
var sites []entity.Site
if err := db.Order("id ASC").Find(&sites).Error; err != nil {
t.Fatalf("read sites: %v", err)
}
return sites
}
func TestSiteSeedAndMigration(t *testing.T) {
t.Run("A fresh database gets one default site from PUBLIC_URL", func(t *testing.T) {
db := openUnmigratedSQLite(t)
if err := database.Migrate(db, "https://www.example.com/blog"); err != nil {
t.Fatalf("migrate: %v", err)
}
sites := storedSites(t, db)
if len(sites) != 1 {
t.Fatalf("sites = %+v", sites)
}
seeded := sites[0]
if seeded.Name != "Default" || seeded.Key != "default" || seeded.BaseURL != "https://www.example.com" || seeded.DefaultRobots != "index" || seeded.Locale != nil || seeded.DefaultSocialImageID != nil || !seeded.IsDefault {
t.Errorf("seeded = %+v", seeded)
}
})
t.Run("The seed follows the base URL rule", func(t *testing.T) {
cases := [][2]string{
{"https://www.example.com/", "https://www.example.com"},
{"https://www.example.com/a/b", "https://www.example.com"},
{"http://www.example.com", "https://www.example.com"},
{"http://localhost:8080", "http://localhost:8080"},
{"http://127.0.0.1:8080/x", "http://127.0.0.1:8080"},
}
for _, c := range cases {
t.Run(c[0], func(t *testing.T) {
db := openUnmigratedSQLite(t)
if err := database.Migrate(db, c[0]); err != nil {
t.Fatalf("migrate: %v", err)
}
if got := storedSites(t, db)[0].BaseURL; got != c[1] {
t.Errorf("base_url = %q, expected %q", got, c[1])
}
})
}
})
t.Run("An unparseable PUBLIC_URL stops the migration", func(t *testing.T) {
db := openUnmigratedSQLite(t)
err := database.Migrate(db, "://not a url")
if err == nil || !strings.Contains(err.Error(), "PUBLIC_URL") {
t.Fatalf("expected an error naming PUBLIC_URL, got %v", err)
}
if sites := storedSites(t, db); len(sites) != 0 {
t.Errorf("sites were created: %+v", sites)
}
})
t.Run("Existing articles are backfilled", func(t *testing.T) {
db := openUnmigratedSQLite(t)
if err := db.AutoMigrate(&legacyArticle{}); err != nil {
t.Fatalf("legacy migrate: %v", err)
}
for index := 0; index < 4; index++ {
if err := db.Create(&legacyArticle{Title: "Article", Body: "Body"}).Error; err != nil {
t.Fatalf("seed article: %v", err)
}
}
if err := db.Delete(&legacyArticle{}, 4).Error; err != nil {
t.Fatalf("soft delete: %v", err)
}
if err := database.Migrate(db, migrationPublicURL); err != nil {
t.Fatalf("migrate: %v", err)
}
defaultSite := storedSites(t, db)[0]
var unassigned int64
db.Unscoped().Model(&entity.Article{}).Where("site_id <> ?", defaultSite.ID).Count(&unassigned)
var total int64
db.Unscoped().Model(&entity.Article{}).Where("site_id = ?", defaultSite.ID).Count(&total)
if unassigned != 0 || total != 4 {
t.Errorf("assigned %d, others %d", total, unassigned)
}
})
t.Run("The seed runs once", func(t *testing.T) {
db := openUnmigratedSQLite(t)
if err := database.Migrate(db, migrationPublicURL); err != nil {
t.Fatalf("migrate: %v", err)
}
before := storedSites(t, db)
if err := database.Migrate(db, "https://other.example.org"); err != nil {
t.Fatalf("second migrate: %v", err)
}
after := storedSites(t, db)
if len(after) != 1 || after[0] != before[0] {
t.Errorf("before %+v, after %+v", before, after)
}
})
t.Run("PUBLIC_URL is not used afterwards", func(t *testing.T) {
db := openUnmigratedSQLite(t)
if err := database.Migrate(db, migrationPublicURL); err != nil {
t.Fatalf("migrate: %v", err)
}
if err := db.Model(&entity.Site{}).Where("is_default = ?", true).Update("base_url", "https://owner.example.net").Error; err != nil {
t.Fatalf("edit: %v", err)
}
if err := database.Migrate(db, migrationPublicURL); err != nil {
t.Fatalf("restart migrate: %v", err)
}
if got := storedSites(t, db)[0].BaseURL; got != "https://owner.example.net" {
t.Errorf("base_url = %q", got)
}
})
}
func TestSiteRepositoryContract(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, sites *persistence.SiteRepository)
}{
{"the migration seeds one default site that reads back with its count", func(t *testing.T, db *gorm.DB, sites *persistence.SiteRepository) {
seeded, err := sites.GetDefault()
if err != nil || seeded.Key != "default" || !seeded.IsDefault || seeded.ArticleCount != 0 {
t.Fatalf("default = %+v, %v", seeded, err)
}
}},
{"list puts the default first then orders by name and counts articles", func(t *testing.T, db *gorm.DB, sites *persistence.SiteRepository) {
zeta := mustCreateSite(t, sites, "Zeta", "zeta", false)
alpha := mustCreateSite(t, sites, "alpha", "alpha", false)
articles := persistence.NewArticleRepository(db)
for range 2 {
if _, err := articles.Create(articleOn(alpha.ID)); err != nil {
t.Fatal(err)
}
}
listed, err := sites.List()
if err != nil || len(listed) != 3 || listed[0].Key != "default" || listed[1].ID != alpha.ID || listed[2].ID != zeta.ID {
t.Fatalf("listed = %+v, %v", listed, err)
}
if listed[1].ArticleCount != 2 || listed[2].ArticleCount != 0 {
t.Errorf("counts = %+v", listed)
}
}},
{"a duplicate key is refused", func(t *testing.T, db *gorm.DB, sites *persistence.SiteRepository) {
mustCreateSite(t, sites, "Shop", "shop", false)
if _, err := sites.Create(site.Site{Name: "Other", Key: "shop", BaseURL: "https://o.example.com", DefaultRobots: "index"}); err == nil {
t.Error("expected the duplicate key to be refused")
}
}},
{"creating a default site unflags the previous one", func(t *testing.T, db *gorm.DB, sites *persistence.SiteRepository) {
created := mustCreateSite(t, sites, "Shop", "shop", true)
defaultSite, err := sites.GetDefault()
if err != nil || defaultSite.ID != created.ID {
t.Fatalf("default = %+v, %v", defaultSite, err)
}
assertSingleDefault(t, db)
}},
{"set default swaps the flag and an unknown id is not found", func(t *testing.T, db *gorm.DB, sites *persistence.SiteRepository) {
shop := mustCreateSite(t, sites, "Shop", "shop", false)
swapped, err := sites.SetDefault(shop.ID)
_, missing := sites.SetDefault(9999)
if err != nil || !swapped.IsDefault || !errors.Is(missing, site.ErrSiteNotFound) {
t.Fatalf("swapped %+v %v, missing %v", swapped, err, missing)
}
assertSingleDefault(t, db)
}},
{"update changes the editable fields and clears nullable ones", func(t *testing.T, db *gorm.DB, sites *persistence.SiteRepository) {
shop := mustCreateSite(t, sites, "Shop", "shop", false)
shop.Name, shop.Locale = "Boutique", stringPointer("fr")
updated, err := sites.Update(shop)
shop.Locale = nil
cleared, clearErr := sites.Update(shop)
if err != nil || updated.Name != "Boutique" || updated.Locale == nil || *updated.Locale != "fr" {
t.Fatalf("updated = %+v, %v", updated, err)
}
if clearErr != nil || cleared.Locale != nil {
t.Errorf("cleared = %+v, %v", cleared, clearErr)
}
if _, err := sites.Update(site.Site{ID: 9999}); !errors.Is(err, site.ErrSiteNotFound) {
t.Errorf("expected not found, got %v", err)
}
}},
{"delete moves the articles and removes the site in one operation", func(t *testing.T, db *gorm.DB, sites *persistence.SiteRepository) {
shop := mustCreateSite(t, sites, "Shop", "shop", false)
defaultSite, _ := sites.GetDefault()
articles := persistence.NewArticleRepository(db)
for range 3 {
if _, err := articles.Create(articleOn(shop.ID)); err != nil {
t.Fatal(err)
}
}
err := sites.DeleteMovingArticles(shop.ID, &defaultSite.ID)
moved, _ := sites.Get(defaultSite.ID)
if err != nil || moved.ArticleCount != 3 {
t.Fatalf("delete: %v, moved %+v", err, moved)
}
if _, err := sites.Get(shop.ID); !errors.Is(err, site.ErrSiteNotFound) {
t.Errorf("site still readable: %v", err)
}
if err := sites.DeleteMovingArticles(shop.ID, nil); !errors.Is(err, site.ErrSiteNotFound) {
t.Errorf("expected not found, got %v", err)
}
}},
{"images are cleared and repointed", func(t *testing.T, db *gorm.DB, sites *persistence.SiteRepository) {
image := mustCreateImage(t, newContractRepositories(db), "a.png", "hash-a")
other := mustCreateImage(t, newContractRepositories(db), "b.png", "hash-a")
shop := mustCreateSite(t, sites, "Shop", "shop", false)
shop.DefaultSocialImageID = &other.ID
if _, err := sites.Update(shop); err != nil {
t.Fatal(err)
}
repointed, err := sites.RepointImage([]uint32{other.ID}, image.ID)
afterRepoint, _ := sites.Get(shop.ID)
cleared, clearErr := sites.ClearImage(image.ID)
afterClear, _ := sites.Get(shop.ID)
if err != nil || repointed != 1 || afterRepoint.DefaultSocialImageID == nil || *afterRepoint.DefaultSocialImageID != image.ID {
t.Fatalf("repoint: %d %v %+v", repointed, err, afterRepoint)
}
if clearErr != nil || len(cleared) != 1 || cleared[0] != shop.ID || afterClear.DefaultSocialImageID != nil {
t.Errorf("clear: %v %v %+v", cleared, clearErr, afterClear)
}
}},
}
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.NewSiteRepository(db))
})
}
})
}
}
func TestSiteRepositoryIsAtomicOnSQLite(t *testing.T) {
t.Run("The swap is atomic", func(t *testing.T) {
db := openContractSQLite(t)
sites := persistence.NewSiteRepository(db)
shop := mustCreateSite(t, sites, "Shop", "shop", false)
failWhenFlagged := "CREATE TRIGGER refuse_flag BEFORE UPDATE OF is_default ON sites WHEN NEW.is_default = 1 BEGIN SELECT RAISE(ABORT, 'refused'); END"
if err := db.Exec(failWhenFlagged).Error; err != nil {
t.Fatal(err)
}
if _, err := sites.SetDefault(shop.ID); err == nil {
t.Fatal("expected the swap to fail")
}
defaultSite, err := sites.GetDefault()
if err != nil || defaultSite.Key != "default" {
t.Errorf("default = %+v, %v", defaultSite, err)
}
assertSingleDefault(t, db)
})
t.Run("Reassign and delete are all-or-nothing", func(t *testing.T) {
db := openContractSQLite(t)
sites := persistence.NewSiteRepository(db)
shop := mustCreateSite(t, sites, "Shop", "shop", false)
defaultSite, _ := sites.GetDefault()
articles := persistence.NewArticleRepository(db)
for range 2 {
if _, err := articles.Create(articleOn(shop.ID)); err != nil {
t.Fatal(err)
}
}
if err := db.Exec("CREATE TRIGGER refuse_delete BEFORE DELETE ON sites BEGIN SELECT RAISE(ABORT, 'refused'); END").Error; err != nil {
t.Fatal(err)
}
if err := sites.DeleteMovingArticles(shop.ID, &defaultSite.ID); err == nil {
t.Fatal("expected the delete to fail")
}
still, err := sites.Get(shop.ID)
if err != nil || still.ArticleCount != 2 {
t.Errorf("site %+v, %v", still, err)
}
})
}
func mustCreateSite(t *testing.T, sites *persistence.SiteRepository, name string, key string, isDefault bool) site.Site {
t.Helper()
created, err := sites.Create(site.Site{Name: name, Key: key, BaseURL: "https://" + key + ".example.com", DefaultRobots: "index", IsDefault: isDefault})
if err != nil {
t.Fatalf("create site %q: %v", key, err)
}
return created
}
var persistedArticleCounter atomic.Uint32
func uniqueTestSlug() string {
return "article-" + strconv.Itoa(int(persistedArticleCounter.Add(1)))
}
func articleOn(siteID uint32) article.Article {
return article.Article{Title: "Article", Body: "Body", SiteID: siteID, Slug: uniqueTestSlug()}
}
func assertSingleDefault(t *testing.T, db *gorm.DB) {
t.Helper()
var defaults int64
if err := db.Model(&entity.Site{}).Where("is_default = ?", true).Count(&defaults).Error; err != nil || defaults != 1 {
t.Errorf("expected exactly one default site, got %d (%v)", defaults, err)
}
}