package test import ( "RenewCMS/internal/domain/article" "RenewCMS/internal/domain/author" "RenewCMS/internal/domain/site" "RenewCMS/internal/infrastructure/persistence/database" entity "RenewCMS/internal/infrastructure/persistence/models" persistence "RenewCMS/internal/infrastructure/persistence/repositories" "errors" "reflect" "strings" "testing" "time" "gorm.io/gorm" ) type seoPreSliceArticle struct { gorm.Model ID uint32 `gorm:"primary_key;auto_increment;not_null"` Title string Body string IsOnline bool `gorm:"not_null;default:false"` SiteID uint32 CreatedAt time.Time `gorm:"autoCreateTime"` UpdatedAt time.Time `gorm:"autoUpdateTime"` } func (seoPreSliceArticle) TableName() string { return "articles" } type seoPreSliceSite struct { ID uint32 `gorm:"primaryKey;autoIncrement"` Name string `gorm:"size:100;not null"` Key string `gorm:"column:site_key;size:32;not null;uniqueIndex"` BaseURL string `gorm:"size:255;not null"` DefaultSocialImageID *uint32 DefaultRobots string `gorm:"size:16;not null"` Locale *string `gorm:"size:35"` IsDefault bool `gorm:"not null"` CreatedAt time.Time `gorm:"autoCreateTime"` UpdatedAt time.Time `gorm:"autoUpdateTime"` } func (seoPreSliceSite) TableName() string { return "sites" } type seoLegacyRow struct { id uint32 siteID uint32 title string slug string } func openPreSliceDatabase(t *testing.T, rows ...seoLegacyRow) *gorm.DB { t.Helper() db := openUnmigratedSQLite(t) if err := db.AutoMigrate(&seoPreSliceArticle{}); err != nil { t.Fatalf("pre-slice migrate: %v", err) } for _, row := range rows { if err := db.Create(&seoPreSliceArticle{ID: row.id, Title: row.title, SiteID: row.siteID, Body: "Body"}).Error; err != nil { t.Fatalf("seed %d: %v", row.id, err) } } return db } func migrateSeo(t *testing.T, db *gorm.DB) { t.Helper() if err := database.Migrate(db, migrationPublicURL); err != nil { t.Fatalf("migrate: %v", err) } } func storedSlugs(t *testing.T, db *gorm.DB) map[uint32]string { t.Helper() var rows []entity.Article if err := db.Unscoped().Order("id ASC").Find(&rows).Error; err != nil { t.Fatalf("read articles: %v", err) } slugs := map[uint32]string{} for _, row := range rows { slugs[row.ID] = row.Slug } return slugs } func TestSeoDataMigrationBackfill(t *testing.T) { t.Run("Every existing article gets a slug from its title", func(t *testing.T) { db := openPreSliceDatabase(t, seoLegacyRow{id: 1, siteID: 1, title: "Spring release"}, seoLegacyRow{id: 2, siteID: 1, title: "Autumn notes"}) migrateSeo(t, db) if got := storedSlugs(t, db); !reflect.DeepEqual(got, map[uint32]string{1: "spring-release", 2: "autumn-notes"}) { t.Errorf("slugs = %v", got) } }) t.Run("Conflicts get suffixes in id order within a site", func(t *testing.T) { db := openPreSliceDatabase(t, seoLegacyRow{id: 1, siteID: 1, title: "News"}, seoLegacyRow{id: 2, siteID: 1, title: "News"}, seoLegacyRow{id: 3, siteID: 1, title: "News"}) migrateSeo(t, db) if got := storedSlugs(t, db); !reflect.DeepEqual(got, map[uint32]string{1: "news", 2: "news-2", 3: "news-3"}) { t.Errorf("slugs = %v", got) } }) t.Run("A suffix skips slugs that exist", func(t *testing.T) { db := openPreSliceDatabase(t, seoLegacyRow{id: 1, siteID: 1, title: "News"}, seoLegacyRow{id: 2, siteID: 1, title: "News 2"}, seoLegacyRow{id: 3, siteID: 1, title: "News"}) migrateSeo(t, db) got := storedSlugs(t, db) if got[1] != "news" || got[2] != "news-2" || got[3] != "news-3" { t.Errorf("slugs = %v", got) } }) t.Run("Slugs are unique per site, not globally", func(t *testing.T) { db := openPreSliceDatabase(t, seoLegacyRow{id: 1, siteID: 1, title: "News"}, seoLegacyRow{id: 2, siteID: 2, title: "News"}) migrateSeo(t, db) if got := storedSlugs(t, db); got[1] != "news" || got[2] != "news" { t.Errorf("slugs = %v", got) } }) t.Run("A title with no letter or digit becomes article, suffixed on conflict", func(t *testing.T) { db := openPreSliceDatabase(t, seoLegacyRow{id: 5, siteID: 1, title: "!!!"}, seoLegacyRow{id: 6, siteID: 1, title: "???"}, seoLegacyRow{id: 7, siteID: 1, title: "..."}) migrateSeo(t, db) if got := storedSlugs(t, db); !reflect.DeepEqual(got, map[uint32]string{5: "article", 6: "article-2", 7: "article-3"}) { t.Errorf("slugs = %v", got) } }) t.Run("Long titles stay within 100 characters", func(t *testing.T) { title := strings.Repeat("a", 150) db := openPreSliceDatabase(t, seoLegacyRow{id: 1, siteID: 1, title: title}, seoLegacyRow{id: 2, siteID: 1, title: title}, seoLegacyRow{id: 3, siteID: 1, title: title}) migrateSeo(t, db) seen := map[string]bool{} for id, slug := range storedSlugs(t, db) { if len(slug) > 100 || !article.IsValidSlug(slug) || seen[slug] { t.Errorf("article %d slug %q (%d)", id, slug, len(slug)) } seen[slug] = true } }) t.Run("The migration is idempotent", func(t *testing.T) { db := openPreSliceDatabase(t, seoLegacyRow{id: 1, siteID: 1, title: "News"}, seoLegacyRow{id: 2, siteID: 1, title: "News"}) migrateSeo(t, db) first := storedSlugs(t, db) migrateSeo(t, db) if second := storedSlugs(t, db); !reflect.DeepEqual(first, second) { t.Errorf("first %v, second %v", first, second) } }) t.Run("An edited slug survives a restart", func(t *testing.T) { db := openPreSliceDatabase(t, seoLegacyRow{id: 1, siteID: 1, title: "News"}) migrateSeo(t, db) if err := db.Model(&entity.Article{}).Where("id = ?", 1).UpdateColumn("slug", "custom").Error; err != nil { t.Fatal(err) } migrateSeo(t, db) if got := storedSlugs(t, db)[1]; got != "custom" { t.Errorf("slug = %q", got) } }) t.Run("A database with a partially backfilled table completes", func(t *testing.T) { db := openPreSliceDatabase(t, seoLegacyRow{id: 1, siteID: 1, title: "News"}, seoLegacyRow{id: 2, siteID: 1, title: "News"}, seoLegacyRow{id: 3, siteID: 1, title: "Other"}) migrateSeo(t, db) if err := db.Exec("DROP INDEX idx_articles_site_slug").Error; err != nil { t.Fatal(err) } if err := db.Model(&entity.Article{}).Where("id = ?", 2).UpdateColumn("slug", "").Error; err != nil { t.Fatal(err) } if err := db.Model(&entity.Article{}).Where("id = ?", 1).UpdateColumn("slug", "kept").Error; err != nil { t.Fatal(err) } migrateSeo(t, db) got := storedSlugs(t, db) if got[1] != "kept" || got[3] != "other" || got[2] != "news" { t.Errorf("slugs = %v", got) } }) t.Run("Soft-deleted legacy articles get a slug that frees the title", func(t *testing.T) { db := openPreSliceDatabase(t, seoLegacyRow{id: 1, siteID: 1, title: "News"}, seoLegacyRow{id: 2, siteID: 1, title: "News"}) if err := db.Delete(&seoPreSliceArticle{}, 1).Error; err != nil { t.Fatal(err) } migrateSeo(t, db) got := storedSlugs(t, db) if got[2] != "news" || got[1] != article.DeletedSlug+"1" { t.Errorf("slugs = %v", got) } }) t.Run("New site columns get defaults", func(t *testing.T) { db := openUnmigratedSQLite(t) if err := db.AutoMigrate(&seoPreSliceSite{}); err != nil { t.Fatal(err) } if err := db.Create(&seoPreSliceSite{Name: "Shop", Key: "shop", BaseURL: "https://shop.example.com", DefaultRobots: "index", IsDefault: true}).Error; err != nil { t.Fatal(err) } migrateSeo(t, db) migrateSeo(t, db) sites := storedSites(t, db) if len(sites) != 1 || sites[0].SiteName != "" || sites[0].TitleTemplate != "%title% | %site%" || sites[0].DefaultDescription != "" { t.Errorf("sites = %+v", sites) } }) t.Run("Existing articles get neutral SEO defaults", func(t *testing.T) { db := openPreSliceDatabase(t, seoLegacyRow{id: 1, siteID: 1, title: "News"}) migrateSeo(t, db) found, err := persistence.NewArticleRepository(db).Get(1) want := article.Article{ID: 1, Title: "News", Body: "Body", SiteID: 1, Slug: "news", SEODismissed: []string{}, Authors: found.Authors, CreatedAt: found.CreatedAt, UpdatedAt: found.UpdatedAt} if err != nil || !reflect.DeepEqual(found, want) { t.Errorf("found %+v, %v", found, err) } }) t.Run("The migration creates the per-site unique index", func(t *testing.T) { db := openPreSliceDatabase(t, seoLegacyRow{id: 1, siteID: 1, title: "News"}) migrateSeo(t, db) if !db.Migrator().HasIndex(&entity.Article{}, "idx_articles_site_slug") { t.Fatal("the index is missing") } duplicate := entity.Article{Title: "Again", SiteID: 1, Slug: "news"} if err := db.Create(&duplicate).Error; err == nil { t.Error("a duplicate (site_id, slug) was accepted") } }) } func TestSeoDataArticleRepositoryContract(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, articles *persistence.ArticleRepository, siteID uint32) }{ {"every new column round-trips with null image ids and the dismissed list", func(t *testing.T, db *gorm.DB, articles *persistence.ArticleRepository, siteID uint32) { images := newContractRepositories(db) cover := mustCreateImage(t, images, "cover.png", "hash-1") social := mustCreateImage(t, images, "social.png", "hash-2") created, err := articles.Create(article.Article{Title: "Spring", Body: "Body", SiteID: siteID, Slug: "spring", Excerpt: "Teaser", SEOTitle: "SEO", SEODescription: "Description", CoverImageID: &cover.ID, CoverImageAlt: "Alt", SocialTitle: "Social", SocialDescription: "Social description", SocialImageID: &social.ID, CanonicalURL: "https://example.com/a", Noindex: true, SEODismissed: []string{"short", "cover_alt"}}) plain, plainErr := articles.Create(article.Article{Title: "Plain", Body: "Body", SiteID: siteID, Slug: "plain"}) read, readErr := articles.Get(created.ID) readPlain, _ := articles.Get(plain.ID) if err != nil || readErr != nil || plainErr != nil { t.Fatalf("errors: %v %v %v", err, readErr, plainErr) } if read.Slug != "spring" || read.Excerpt != "Teaser" || read.SEOTitle != "SEO" || read.SEODescription != "Description" || read.CoverImageID == nil || *read.CoverImageID != cover.ID || read.CoverImageAlt != "Alt" || read.SocialTitle != "Social" || read.SocialDescription != "Social description" || read.SocialImageID == nil || *read.SocialImageID != social.ID || read.CanonicalURL != "https://example.com/a" || !read.Noindex || !reflect.DeepEqual(read.SEODismissed, []string{"short", "cover_alt"}) { t.Errorf("read = %+v", read) } if readPlain.CoverImageID != nil || readPlain.SocialImageID != nil || readPlain.Noindex || readPlain.SEODismissed == nil || len(readPlain.SEODismissed) != 0 { t.Errorf("plain = %+v", readPlain) } }}, {"a second article with the same site and slug is refused, another site is allowed", func(t *testing.T, db *gorm.DB, articles *persistence.ArticleRepository, siteID uint32) { sites := persistence.NewSiteRepository(db) other := mustCreateSite(t, sites, "Shop", "shop", false) if _, err := articles.Create(article.Article{Title: "One", SiteID: siteID, Slug: "spring"}); err != nil { t.Fatal(err) } _, duplicate := articles.Create(article.Article{Title: "Two", SiteID: siteID, Slug: "spring"}) _, elsewhere := articles.Create(article.Article{Title: "Three", SiteID: other.ID, Slug: "spring"}) rawDuplicate := db.Create(&entity.Article{Title: "Four", SiteID: siteID, Slug: "spring"}).Error if !errors.Is(duplicate, article.ErrSlugTaken) || elsewhere != nil || rawDuplicate == nil { t.Errorf("duplicate %v, elsewhere %v, raw %v", duplicate, elsewhere, rawDuplicate) } }}, {"update applies the SEO fields, clears the image ids and refuses a taken slug", func(t *testing.T, db *gorm.DB, articles *persistence.ArticleRepository, siteID uint32) { images := newContractRepositories(db) cover := mustCreateImage(t, images, "cover.png", "hash-1") first, _ := articles.Create(article.Article{Title: "One", SiteID: siteID, Slug: "one"}) second, _ := articles.Create(article.Article{Title: "Two", SiteID: siteID, Slug: "two"}) text, flag, codes := "Teaser", true, []string{"short"} updated, err := articles.Update(first.ID, article.Update{Excerpt: &text, Noindex: &flag, SEODismissed: &codes, CoverImageID: article.Nullable[uint32]{Set: true, Value: &cover.ID}}) cleared, clearErr := articles.Update(first.ID, article.Update{CoverImageID: article.Nullable[uint32]{Set: true}}) taken := "two" _, conflict := articles.Update(first.ID, article.Update{Slug: &taken}) own := "two" _, keeping := articles.Update(second.ID, article.Update{Slug: &own}) if err != nil || updated.Excerpt != "Teaser" || !updated.Noindex || !reflect.DeepEqual(updated.SEODismissed, codes) || updated.CoverImageID == nil { t.Fatalf("updated = %+v, %v", updated, err) } if clearErr != nil || cleared.CoverImageID != nil || cleared.Excerpt != "Teaser" { t.Errorf("cleared = %+v, %v", cleared, clearErr) } if !errors.Is(conflict, article.ErrSlugTaken) || keeping != nil { t.Errorf("conflict %v, keeping %v", conflict, keeping) } }}, {"moving an article re-checks its slug on the target site", func(t *testing.T, db *gorm.DB, articles *persistence.ArticleRepository, siteID uint32) { other := mustCreateSite(t, persistence.NewSiteRepository(db), "Shop", "shop", false) moving, _ := articles.Create(article.Article{Title: "One", SiteID: siteID, Slug: "spring"}) if _, err := articles.Create(article.Article{Title: "Two", SiteID: other.ID, Slug: "spring"}); err != nil { t.Fatal(err) } _, err := articles.Update(moving.ID, article.Update{SiteID: &other.ID}) stored, _ := articles.Get(moving.ID) if !errors.Is(err, article.ErrSlugTaken) || stored.SiteID != siteID { t.Errorf("err %v stored %+v", err, stored) } }}, {"slug taken ignores the article itself and a deleted article frees its slug", func(t *testing.T, db *gorm.DB, articles *persistence.ArticleRepository, siteID uint32) { created, _ := articles.Create(article.Article{Title: "One", SiteID: siteID, Slug: "spring"}) own, ownErr := articles.SlugTaken(siteID, "spring", created.ID) other, otherErr := articles.SlugTaken(siteID, "spring", 0) deleteErr := articles.Delete(created.ID) afterDelete, afterErr := articles.SlugTaken(siteID, "spring", 0) _, recreated := articles.Create(article.Article{Title: "Again", SiteID: siteID, Slug: "spring"}) if ownErr != nil || otherErr != nil || afterErr != nil || own || !other || deleteErr != nil || afterDelete || recreated != nil { t.Errorf("own %v other %v delete %v after %v recreated %v", own, other, deleteErr, afterDelete, recreated) } if err := articles.Delete(created.ID); !errors.Is(err, article.ErrArticleNotFound) { t.Errorf("second delete: %v", err) } }}, {"image references are found, cleared, restored and repointed", func(t *testing.T, db *gorm.DB, articles *persistence.ArticleRepository, siteID uint32) { images := newContractRepositories(db) kept := mustCreateImage(t, images, "kept.png", "hash-a") copied := mustCreateImage(t, images, "copy.png", "hash-a") covered, _ := articles.Create(article.Article{Title: "Covered", SiteID: siteID, Slug: "covered", CoverImageID: &copied.ID}) shared, _ := articles.Create(article.Article{Title: "Shared", SiteID: siteID, Slug: "shared", CoverImageID: &copied.ID, SocialImageID: &copied.ID}) articles.Create(article.Article{Title: "Unrelated", SiteID: siteID, Slug: "unrelated"}) found, findErr := articles.FindByImage(copied.ID) repointed, repointErr := articles.RepointImage([]uint32{copied.ID}, kept.ID) references, clearErr := articles.ClearImage(kept.ID) afterClear, _ := articles.Get(shared.ID) restoreErr := articles.RestoreImage(references, kept.ID) afterRestore, _ := articles.Get(shared.ID) if findErr != nil || len(found) != 2 { t.Fatalf("found %+v, %v", found, findErr) } if repointErr != nil || !reflect.DeepEqual(repointed, []uint32{covered.ID, shared.ID}) { t.Fatalf("repointed %v, %v", repointed, repointErr) } if clearErr != nil || !reflect.DeepEqual(references.Cover, []uint32{covered.ID, shared.ID}) || !reflect.DeepEqual(references.Social, []uint32{shared.ID}) || afterClear.CoverImageID != nil || afterClear.SocialImageID != nil { t.Errorf("references %+v, %v, after %+v", references, clearErr, afterClear) } if restoreErr != nil || afterRestore.CoverImageID == nil || *afterRestore.CoverImageID != kept.ID || afterRestore.SocialImageID == nil || *afterRestore.SocialImageID != kept.ID { t.Errorf("restore %v, after %+v", restoreErr, afterRestore) } }}, {"the site repository stores and updates the SEO columns", func(t *testing.T, db *gorm.DB, articles *persistence.ArticleRepository, siteID uint32) { sites := persistence.NewSiteRepository(db) created, err := sites.Create(site.Site{Name: "Shop", Key: "shop", BaseURL: "https://shop.example.com", DefaultRobots: "index", SiteName: "Renew Shop", TitleTemplate: "%title% - %site%", DefaultDescription: "A shop."}) created.SiteName = "Renew" updated, updateErr := sites.Update(created) seeded, _ := sites.GetDefault() if err != nil || updateErr != nil || updated.SiteName != "Renew" || updated.TitleTemplate != "%title% - %site%" || updated.DefaultDescription != "A shop." { t.Errorf("updated %+v, %v %v", updated, err, updateErr) } if seeded.TitleTemplate != "%title% | %site%" { t.Errorf("seeded template = %q", seeded.TitleTemplate) } }}, {"the author repository finds the authors using an avatar", func(t *testing.T, db *gorm.DB, articles *persistence.ArticleRepository, siteID uint32) { authors := persistence.NewAuthorRepository(db) image := mustCreateImage(t, newContractRepositories(db), "avatar.png", "hash-avatar") withAvatar, _ := authors.Create(authorWithAvatar("Camille", "camille", &image.ID)) authors.Create(authorWithAvatar("Other", "other", nil)) found, err := authors.FindByAvatar(image.ID) if err != nil || len(found) != 1 || found[0].ID != withAvatar.ID { t.Errorf("found %+v, %v", found, err) } }}, } 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) defaultSite, err := persistence.NewSiteRepository(db).GetDefault() if err != nil { t.Fatal(err) } c.run(t, db, persistence.NewArticleRepository(db), defaultSite.ID) }) } }) } } func authorWithAvatar(name string, slug string, avatarID *uint32) author.Author { return author.Author{Name: name, Slug: slug, AvatarID: avatarID, SocialLinks: []author.SocialLink{}} }