Files
RenewCMS/test/seo_data_persistence_test.go
T

436 lines
18 KiB
Go

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