feat: Sites feature backend
This commit is contained in:
1 parent
45fcbd890d
commit
a11338094e
98 files changed
+5365
-214
No files matched your search
@@ -0,0 +1,359 @@
|
||||
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"
|
||||
"strings"
|
||||
"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
|
||||
}
|
||||
|
||||
func articleOn(siteID uint32) article.Article {
|
||||
return article.Article{Title: "Article", Body: "Body", SiteID: siteID}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user