feat: site languages, setup and site URL settings
This commit is contained in:
1 parent
0806733d5c
commit
d9158025d4
106 files changed
+10184
-56
No files matched your search
@@ -0,0 +1,249 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"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)
|
||||
}
|
||||
}},
|
||||
}
|
||||
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
|
||||
}
|
||||
Reference in new issue
Block a user