Files
RenewCMS/test/localization_persistence_test.go
T

292 lines
12 KiB
Go

package test
import (
domainArticle "RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/localization"
"RenewCMS/internal/domain/site"
"RenewCMS/internal/infrastructure/persistence/database"
entity "RenewCMS/internal/infrastructure/persistence/models"
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
"RenewCMS/internal/infrastructure/useCases"
"context"
"errors"
"reflect"
"testing"
"gorm.io/gorm"
)
const localizationFailureCallback = "test:fail-language-write"
var errInjectedLanguageWrite = errors.New("injected language write failure")
func failLanguageCreate(t *testing.T, db *gorm.DB, lang string) {
t.Helper()
err := db.Callback().Create().Before("gorm:create").Register(localizationFailureCallback, func(tx *gorm.DB) {
if row, ok := tx.Statement.Dest.(*entity.SiteLanguage); ok && row.Lang == lang {
_ = tx.AddError(errInjectedLanguageWrite)
}
})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Callback().Create().Remove(localizationFailureCallback) })
}
func seedLocalizedSite(t *testing.T, db *gorm.DB, key string, rows []entity.SiteLanguage, articles map[string][]entity.Article) site.Site {
t.Helper()
created := mustCreateSite(t, persistence.NewSiteRepository(db), key, key, false)
for _, row := range rows {
row.SiteID = created.ID
if err := db.Create(&row).Error; err != nil {
t.Fatal(err)
}
}
for lang, group := range articles {
for _, stored := range group {
stored.SiteID = created.ID
stored.Lang = lang
stored.StoryID = fixtureStory()
stored.Slug = uniqueTestSlug()
if err := db.Create(&stored).Error; err != nil {
t.Fatal(err)
}
if stored.Title == "deleted" {
if err := db.Delete(&stored).Error; err != nil {
t.Fatal(err)
}
}
}
}
return created
}
func storedLanguageRows(t *testing.T, db *gorm.DB, siteID uint32) []entity.SiteLanguage {
t.Helper()
var rows []entity.SiteLanguage
if err := db.Where("site_id = ?", siteID).Order("lang ASC").Find(&rows).Error; err != nil {
t.Fatal(err)
}
for index := range rows {
rows[index].CreatedAt, rows[index].UpdatedAt = rows[index].CreatedAt.UTC(), rows[index].UpdatedAt.UTC()
}
return rows
}
func TestLocalizationPersistence(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)
}{
{"Persistence additions are safe to migrate", func(t *testing.T, db *gorm.DB) {
migrator := db.Migrator()
created := seedLocalizedSite(t, db, "migrating", []entity.SiteLanguage{{Lang: "en", IsDefault: true, Enabled: true, Confirmed: true}}, nil)
before := storedLanguageRows(t, db, created.ID)
for _, drop := range []struct {
model any
column string
}{{&entity.SiteLanguage{}, "social_locale"}, {&entity.Site{}, "public_url_pattern"}} {
if err := migrator.DropColumn(drop.model, drop.column); err != nil {
t.Fatalf("drop %s: %v", drop.column, err)
}
}
if err := database.Migrate(db, migrationPublicURL); err != nil {
t.Fatal(err)
}
if !migrator.HasColumn(&entity.SiteLanguage{}, "social_locale") || !migrator.HasColumn(&entity.Site{}, "public_url_pattern") {
t.Fatalf("columns missing")
}
if pool, err := db.DB(); err == nil {
pool.SetMaxIdleConns(0)
}
after := storedLanguageRows(t, db, created.ID)
if len(after) != 1 || after[0].SocialLocale != nil || after[0].Lang != before[0].Lang {
t.Errorf("rows = %+v", after)
}
if err := database.Migrate(db, migrationPublicURL); err != nil {
t.Fatalf("second migrate: %v", err)
}
if again := storedLanguageRows(t, db, created.ID); !reflect.DeepEqual(again, after) {
t.Errorf("second migrate changed rows")
}
}},
{"The public url pattern is stored and cleared", func(t *testing.T, db *gorm.DB) {
sites := persistence.NewSiteRepository(db)
created := mustCreateSite(t, sites, "Pattern", "pattern", false)
created.PublicURLPattern = localeOf("https://pattern.example.com/{lang}/{slug}")
saved, err := sites.Update(created)
if err != nil || !sameText(saved.PublicURLPattern, created.PublicURLPattern) {
t.Fatalf("saved = %+v, %v", saved, err)
}
created.PublicURLPattern = nil
cleared, err := sites.Update(created)
if err != nil || cleared.PublicURLPattern != nil {
t.Fatalf("cleared = %+v, %v", cleared, err)
}
}},
{"PUT replaces the set atomically, shows live versions and protects soft-deleted ones", func(t *testing.T, db *gorm.DB) {
fr := "fr_FR"
created := seedLocalizedSite(t, db, "journal", []entity.SiteLanguage{
{Lang: "en", IsDefault: true, Enabled: true, Confirmed: true},
{Lang: "fr", Enabled: true, SocialLocale: &fr},
{Lang: "de", Enabled: true},
{Lang: "ar", Enabled: true},
{Lang: "es"},
}, map[string][]entity.Article{
"en": {{Title: "a", IsOnline: true}},
"ar": {{Title: "draft"}},
"es": {{Title: "deleted"}},
})
update := useCases.NewUpdateSiteLanguagesUseCase(persistence.NewTransactor(db), discardRecorder)
read := useCases.NewGetSiteLanguagesUseCase(persistence.NewSiteRepository(db), persistence.NewLocalizationRepository(db))
view, err := read.Get("journal")
if err != nil {
t.Fatal(err)
}
entries := func(omit ...string) []useCases.SiteLanguageEntry {
all := []useCases.SiteLanguageEntry{{Lang: "en", Enabled: true}, {Lang: "fr", Enabled: true, SocialLocale: &fr}, {Lang: "de", Enabled: false}, {Lang: "ar", Enabled: true}, {Lang: "es"}}
kept := []useCases.SiteLanguageEntry{}
for _, entry := range all {
if !containsTag(omit, entry.Lang) {
kept = append(kept, entry)
}
}
return kept
}
command := func(def string, list []useCases.SiteLanguageEntry) useCases.UpdateSiteLanguagesCommand {
return useCases.UpdateSiteLanguagesCommand{Key: "journal", Revision: view.Revision, Default: def, Languages: list}
}
if _, err := update.Update(context.Background(), command("en", entries("ar"))); !errors.Is(err, localization.ErrLanguageInUse) {
t.Fatalf("draft language: %v", err)
}
if _, err := update.Update(context.Background(), command("en", entries("es"))); !errors.Is(err, localization.ErrLanguageInUse) {
t.Fatalf("soft-deleted language: %v", err)
}
for _, entry := range view.Languages {
if entry.Lang == "es" && entry.VersionCount != 0 {
t.Errorf("soft-deleted version shown: %+v", entry)
}
}
before := storedLanguageRows(t, db, created.ID)
failLanguageCreate(t, db, "pt")
withNew := append(entries(), useCases.SiteLanguageEntry{Lang: "pt", Enabled: true})
if _, err := update.Update(context.Background(), command("fr", withNew)); !errors.Is(err, errInjectedLanguageWrite) {
t.Fatalf("injected failure: %v", err)
}
if after := storedLanguageRows(t, db, created.ID); !reflect.DeepEqual(before, after) {
t.Errorf("rows changed after a failed write")
}
done, err := update.Update(context.Background(), command("fr", entries("de")))
if err != nil {
t.Fatal(err)
}
if done.Default == nil || *done.Default != "fr" || len(done.Languages) != 4 {
t.Errorf("view = %+v", done)
}
if _, err := update.Update(context.Background(), command("en", entries())); !errors.Is(err, localization.ErrLanguagesChanged) {
t.Errorf("stale: %v", err)
}
}},
{"Setup assigns every article including soft-deleted ones in one transaction", func(t *testing.T, db *gorm.DB) {
first := seedLocalizedSite(t, db, "journal", []entity.SiteLanguage{{Lang: "en", IsDefault: true, Enabled: true}}, map[string][]entity.Article{"": {{Title: "a"}, {Title: "deleted"}}})
second := seedLocalizedSite(t, db, "notes", nil, map[string][]entity.Article{"": {{Title: "b"}}})
apply := useCases.NewApplyLanguageSetupUseCase(persistence.NewTransactor(db), discardRecorder)
choices := []localization.SetupChoice{{Key: "default", Lang: "en"}, {Key: "journal", Lang: "en"}, {Key: "notes", Lang: "fr"}}
failLanguageCreate(t, db, "fr")
if _, err := apply.Apply(context.Background(), choices); !errors.Is(err, errInjectedLanguageWrite) {
t.Fatalf("injected failure: %v", err)
}
var unassigned int64
db.Unscoped().Model(&entity.Article{}).Where("lang = ?", "").Count(&unassigned)
if unassigned != 3 || len(storedLanguageRows(t, db, first.ID)) != 1 || storedLanguageRows(t, db, first.ID)[0].Confirmed {
t.Fatalf("failed setup left a trace: %d unassigned", unassigned)
}
_ = db.Callback().Create().Remove(localizationFailureCallback)
result, err := apply.Apply(context.Background(), choices)
if err != nil || result.Assigned != 3 {
t.Fatalf("result = %+v, %v", result, err)
}
rows := storedLanguageRows(t, db, second.ID)
if len(rows) != 1 || rows[0].Lang != "fr" || !rows[0].IsDefault || !rows[0].Confirmed {
t.Errorf("rows = %+v", rows)
}
db.Unscoped().Model(&entity.Article{}).Where("lang = ?", "").Count(&unassigned)
if unassigned != 0 {
t.Errorf("%d articles unassigned", unassigned)
}
again, err := apply.Apply(context.Background(), choices)
if err != nil || again.Assigned != 0 || len(again.Sites) != 0 {
t.Errorf("retry = %+v, %v", again, err)
}
}},
{"Exact lookup and story resolution read the same rows", func(t *testing.T, db *gorm.DB) {
created := seedLocalizedSite(t, db, "lookups", nil, nil)
repository := persistence.NewArticleRepository(db)
story := fixtureStory()
published := entity.Article{Title: "Published", SiteID: created.ID, Lang: "fr", StoryID: story, Slug: "publie", IsOnline: true}
offline := entity.Article{Title: "Offline", SiteID: created.ID, Lang: "en", StoryID: story, Slug: "offline"}
unassigned := entity.Article{Title: "Unassigned", SiteID: created.ID, StoryID: fixtureStory(), Slug: "legacy", IsOnline: true}
removed := entity.Article{Title: "Removed", SiteID: created.ID, Lang: "de", StoryID: story, Slug: "weg", IsOnline: true}
for _, stored := range []*entity.Article{&published, &offline, &unassigned, &removed} {
if err := db.Create(stored).Error; err != nil {
t.Fatal(err)
}
}
if err := db.Delete(&removed).Error; err != nil {
t.Fatal(err)
}
found, err := repository.GetByLangSlug(created.ID, "fr", "publie")
missingLanguage, missingErr := repository.GetByLangSlug(created.ID, "en", "publie")
_, removedErr := repository.GetByLangSlug(created.ID, "de", "weg")
_, unassignedErr := repository.GetByLangSlug(created.ID, "en", "legacy")
versions, listErr := repository.ListByStory(story)
if err != nil || found.ID != published.ID || !found.IsOnline {
t.Errorf("exact lookup = %+v, %v", found, err)
}
if !errors.Is(missingErr, domainArticle.ErrArticleNotFound) || missingLanguage.ID != 0 || !errors.Is(removedErr, domainArticle.ErrArticleNotFound) || !errors.Is(unassignedErr, domainArticle.ErrArticleNotFound) {
t.Errorf("misses = %v %v %v", missingErr, removedErr, unassignedErr)
}
if listErr != nil || len(versions) != 2 {
t.Errorf("story versions = %d, %v", len(versions), listErr)
}
for _, name := range []string{"idx_articles_site_slug", "idx_articles_story_lang"} {
if !db.Migrator().HasIndex(&entity.Article{}, name) {
t.Errorf("index %s is missing", name)
}
}
if err := database.Migrate(db, migrationPublicURL); err != nil {
t.Errorf("a second migration failed: %v", err)
}
}},
}
for _, backend := range backends {
for _, c := range cases {
t.Run(backend.name+"/"+c.name, func(t *testing.T) {
c.run(t, backend.open(t))
})
}
}
}
func containsTag(tags []string, tag string) bool {
for _, candidate := range tags {
if candidate == tag {
return true
}
}
return false
}