package test import ( languagehandler "RenewCMS/api/handlers/language" "RenewCMS/api/pkg/validator" "RenewCMS/internal/domain/site" entity "RenewCMS/internal/infrastructure/persistence/models" persistence "RenewCMS/internal/infrastructure/persistence/repositories" "RenewCMS/internal/infrastructure/useCases" "fmt" "net/http" "net/http/httptest" "strings" "sync" "testing" "github.com/go-chi/chi/v5" "gorm.io/gorm" ) func newLanguageRouter(db *gorm.DB) http.Handler { validator.InitValidator() sites := persistence.NewSiteRepository(db) reads := persistence.NewLocalizationRepository(db) transactor := persistence.NewTransactor(db) siteLanguages := languagehandler.NewSiteLanguagesHandler(useCases.NewGetSiteLanguagesUseCase(sites, reads), useCases.NewUpdateSiteLanguagesUseCase(transactor, discardRecorder)) setup := languagehandler.NewSetupHandler(useCases.NewGetLanguageSetupUseCase(sites, reads), useCases.NewApplyLanguageSetupUseCase(transactor, discardRecorder)) router := chi.NewRouter() router.Put("/site/{key}/languages", siteLanguages.Update) router.Post("/language/setup", setup.Apply) return router } func sendTo(router http.Handler, method string, path string, body string) int { rec := httptest.NewRecorder() router.ServeHTTP(rec, httptest.NewRequest(method, path, strings.NewReader(body))) return rec.Code } func runTogether(jobs ...func()) { var group sync.WaitGroup start := make(chan struct{}) for _, job := range jobs { group.Add(1) go func() { defer group.Done() <-start job() }() } close(start) group.Wait() } func TestLanguageConcurrencyOnSQLite(t *testing.T) { t.Run("PUT languages racing a site creation answers 200 or 409, never 500, and keeps one default", func(t *testing.T) { db := openContractSQLite(t) journal := seedLocalizedSite(t, db, "journal", []entity.SiteLanguage{{Lang: "en", IsDefault: true, Enabled: true, Confirmed: true}}, map[string][]entity.Article{"en": {{Title: "a", IsOnline: true}}}) router := newLanguageRouter(db) read := useCases.NewGetSiteLanguagesUseCase(persistence.NewSiteRepository(db), persistence.NewLocalizationRepository(db)) sites := persistence.NewSiteRepository(db) extra := []string{"fr", "de", "es", "ar", "pt-BR", "en-GB"} for round, tag := range extra { view, err := read.Get("journal") if err != nil { t.Fatal(err) } body := languagesBody(view.Revision, "en", entryJSON("en", true, "null"), entryJSON(tag, true, "null")) status := 0 var createErr error runTogether( func() { status = sendTo(router, http.MethodPut, "/site/journal/languages", body) }, func() { key := fmt.Sprintf("race-%d", round) _, createErr = sites.Create(site.Site{Name: key, Key: key, BaseURL: "https://" + key + ".example.com", DefaultRobots: "index"}) }, ) if status != http.StatusOK && status != http.StatusConflict { t.Errorf("round %d: PUT status %d", round, status) } if createErr != nil { t.Logf("round %d: create refused: %v", round, createErr) } defaults := 0 for _, row := range storedLanguageRows(t, db, journal.ID) { if row.IsDefault { defaults++ } } if defaults != 1 { t.Errorf("round %d: %d defaults", round, defaults) } } }) t.Run("Two simultaneous setups answer 200 or 409, at least one 200, and assign every article exactly once", func(t *testing.T) { for round := 0; round < 5; round++ { db := openContractSQLite(t) first := seedLocalizedSite(t, db, "journal", nil, map[string][]entity.Article{"": {{Title: "a"}, {Title: "deleted"}}}) second := seedLocalizedSite(t, db, "notes", nil, map[string][]entity.Article{"": {{Title: "b"}, {Title: "c"}}}) router := newLanguageRouter(db) body := `{"sites":[{"key":"default","lang":"en"},{"key":"journal","lang":"en"},{"key":"notes","lang":"fr"}]}` statuses := make([]int, 2) runTogether( func() { statuses[0] = sendTo(router, http.MethodPost, "/language/setup", body) }, func() { statuses[1] = sendTo(router, http.MethodPost, "/language/setup", body) }, ) accepted := 0 for _, status := range statuses { switch status { case http.StatusOK: accepted++ case http.StatusConflict: default: t.Errorf("round %d: status %d", round, status) } } if accepted == 0 { t.Errorf("round %d: no setup succeeded: %v", round, statuses) } if accepted == 2 { t.Logf("round %d: the setups were serialized", round) } if sendTo(router, http.MethodPost, "/language/setup", body) != http.StatusOK { t.Errorf("round %d: a later retry did not succeed", round) } var unassigned int64 db.Unscoped().Model(&entity.Article{}).Where("lang = ?", "").Count(&unassigned) if unassigned != 0 { t.Errorf("round %d: %d articles unassigned", round, unassigned) } for siteID, tag := range map[uint32]string{first.ID: "en", second.ID: "fr"} { rows := storedLanguageRows(t, db, siteID) if len(rows) != 1 || rows[0].Lang != tag || !rows[0].IsDefault || !rows[0].Confirmed { t.Errorf("round %d: rows of site %d = %+v", round, siteID, rows) } } var mislabeled int64 db.Unscoped().Model(&entity.Article{}).Where("site_id = ? AND lang <> ?", second.ID, "fr").Count(&mislabeled) if mislabeled != 0 { t.Errorf("round %d: %d articles with the wrong language", round, mislabeled) } } }) }