package test import ( "RenewCMS/internal/domain/article" "RenewCMS/internal/domain/site" "RenewCMS/internal/domain/slug" entity "RenewCMS/internal/infrastructure/persistence/models" persistence "RenewCMS/internal/infrastructure/persistence/repositories" "errors" "reflect" "strings" "testing" "gorm.io/gorm" ) func legacyWithSlugs(t *testing.T, slugs map[uint32]string, rows ...seoLegacyRow) *gorm.DB { t.Helper() db := openPreSliceDatabase(t, rows...) migrateSeo(t, db) if err := db.Exec("DROP INDEX idx_articles_site_slug").Error; err != nil { t.Fatal(err) } for id, value := range slugs { if err := db.Unscoped().Model(&entity.Article{}).Where("id = ?", id).UpdateColumn("slug", value).Error; err != nil { t.Fatal(err) } } return db } func TestSeoDataCleanerBackfillRepair(t *testing.T) { rows := func(count int) []seoLegacyRow { result := []seoLegacyRow{} for id := 1; id <= count; id++ { result = append(result, seoLegacyRow{id: uint32(id), siteID: 1, title: "Row"}) } return result } t.Run("Live duplicates keep the lowest id and the others are suffixed", func(t *testing.T) { db := legacyWithSlugs(t, map[uint32]string{1: "news", 2: "news", 3: "news", 4: "news-2", 5: "news"}, rows(5)...) if err := db.Model(&entity.Article{}).Where("id = ?", 5).UpdateColumn("site_id", 2).Error; err != nil { t.Fatal(err) } migrateSeo(t, db) want := map[uint32]string{1: "news", 2: "news-3", 3: "news-4", 4: "news-2", 5: "news"} if got := storedSlugs(t, db); !reflect.DeepEqual(got, want) { t.Errorf("slugs = %v", got) } if !db.Migrator().HasIndex(&entity.Article{}, "idx_articles_site_slug") { t.Error("the index is missing") } }) t.Run("A soft-deleted row sharing a slug with a live row is tombstoned", func(t *testing.T) { db := legacyWithSlugs(t, map[uint32]string{1: "news", 2: "news"}, rows(2)...) if err := db.Delete(&entity.Article{}, 1).Error; err != nil { t.Fatal(err) } migrateSeo(t, db) if got := storedSlugs(t, db); !reflect.DeepEqual(got, map[uint32]string{1: article.Tombstone(1), 2: "news"}) { t.Errorf("slugs = %v", got) } }) t.Run("Every soft-deleted row is tombstoned whatever its slug", func(t *testing.T) { db := legacyWithSlugs(t, map[uint32]string{1: "custom", 2: "", 3: "other"}, rows(3)...) if err := db.Delete(&entity.Article{}, []uint32{1, 2}).Error; err != nil { t.Fatal(err) } migrateSeo(t, db) if got := storedSlugs(t, db); !reflect.DeepEqual(got, map[uint32]string{1: "#deleted-1", 2: "#deleted-2", 3: "other"}) { t.Errorf("slugs = %v", got) } }) t.Run("A second run changes nothing", func(t *testing.T) { db := legacyWithSlugs(t, map[uint32]string{1: "news", 2: "news", 3: "gone"}, rows(3)...) if err := db.Delete(&entity.Article{}, 3).Error; err != nil { t.Fatal(err) } migrateSeo(t, db) first := storedSlugs(t, db) migrateSeo(t, db) if second := storedSlugs(t, db); !reflect.DeepEqual(first, second) || first[2] != "news-2" || first[3] != "#deleted-3" { t.Errorf("first %v second %v", first, second) } }) } func TestSeoDataCleanerBackfillAlgorithmIsFrozen(t *testing.T) { titles := []string{"Café Déjà Vu", "Straße & Æon", "!!!", "Ça va?", strings.Repeat("a", 120), strings.Repeat("ab-", 40), "日本語", " Spaced Out ", "Ø Ł Ñ", "Hello, World 2024"} legacy := []seoLegacyRow{} for index, title := range titles { legacy = append(legacy, seoLegacyRow{id: uint32(index + 1), siteID: 1, title: title}) } db := openPreSliceDatabase(t, legacy...) migrateSeo(t, db) want := map[uint32]string{ 1: "cafe-deja-vu", 2: "strasse-aeon", 3: "article", 4: "ca-va", 5: strings.Repeat("a", 100), 6: strings.TrimRight(strings.Repeat("ab-", 40)[:100], "-"), 7: "article-2", 8: "spaced-out", 9: "o-l-n", 10: "hello-world-2024", } if got := storedSlugs(t, db); !reflect.DeepEqual(got, want) { t.Errorf("slugs = %v", got) } } func TestSeoDataCleanerTombstone(t *testing.T) { if article.Tombstone(7) != "#deleted-7" || slug.IsValid(article.Tombstone(7)) || slug.IsValid(article.Tombstone(123456)) { t.Errorf("tombstone %q", article.Tombstone(7)) } db := openPreSliceDatabase(t) migrateSeo(t, db) articles := persistence.NewArticleRepository(db) created, err := articles.Create(article.Article{Title: "Gone", SiteID: 1, Slug: "gone", SEODismissed: []string{}}) if err != nil { t.Fatal(err) } if err := articles.Delete(created.ID); err != nil { t.Fatal(err) } if got := storedSlugs(t, db)[created.ID]; got != article.Tombstone(created.ID) { t.Errorf("slug after delete %q", got) } } func TestSeoDataCleanerEmptyValues(t *testing.T) { t.Run("Empty SEO text and site text insert after two migrations", func(t *testing.T) { db := openPreSliceDatabase(t) migrateSeo(t, db) migrateSeo(t, db) sites := persistence.NewSiteRepository(db) articles := persistence.NewArticleRepository(db) created, siteErr := sites.Create(site.Site{Name: "Blank", Key: "blank", BaseURL: "https://blank.example.com", DefaultRobots: site.RobotsIndex, TitleTemplate: site.DefaultTitleTemplate}) stored, articleErr := articles.Create(article.Article{Title: "Blank", SiteID: created.ID, Slug: "blank", SEODismissed: []string{}}) if siteErr != nil || articleErr != nil { t.Fatalf("site %v article %v", siteErr, articleErr) } reloaded, err := sites.Get(created.ID) found, foundErr := articles.Get(stored.ID) if err != nil || reloaded.SiteName != "" || reloaded.DefaultDescription != "" || foundErr != nil || found.SEOTitle != "" || found.SEODescription != "" || found.Excerpt != "" { t.Errorf("site %+v (%v) article %+v (%v)", reloaded, err, found, foundErr) } }) t.Run("An empty stored title template reads as the default", func(t *testing.T) { db := openPreSliceDatabase(t) migrateSeo(t, db) sites := persistence.NewSiteRepository(db) created := mustCreateSite(t, sites, "Blank", "blank", false) if err := db.Model(&entity.Site{}).Where("id = ?", created.ID).UpdateColumn("title_template", "").Error; err != nil { t.Fatal(err) } got, err := sites.Get(created.ID) listed, listErr := sites.List() if err != nil || got.TitleTemplate != site.DefaultTitleTemplate || listErr != nil { t.Fatalf("got %q %v %v", got.TitleTemplate, err, listErr) } for _, entry := range listed { if entry.TitleTemplate == "" { t.Errorf("listed site %d has an empty template", entry.ID) } } }) } func TestSeoDataCleanerSiteKeyRace(t *testing.T) { db := openPreSliceDatabase(t) migrateSeo(t, db) sites := persistence.NewSiteRepository(db) mustCreateSite(t, sites, "Shop", "shop", false) _, err := sites.Create(site.Site{Name: "Again", Key: "shop", BaseURL: "https://again.example.com", DefaultRobots: site.RobotsIndex}) if !errors.Is(err, site.ErrSiteKeyTaken) { t.Errorf("err = %v", err) } }