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