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 := []localizationCase{ {"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) } }}, } cases = append(cases, localizationConcurrencyCases()...) 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 }