292 lines
12 KiB
Go
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
|
|
}
|