package test import ( "RenewCMS/api" "RenewCMS/internal/domain/article" "RenewCMS/internal/domain/language" "RenewCMS/internal/domain/localization" "RenewCMS/internal/domain/site" "RenewCMS/internal/domain/transaction" "RenewCMS/internal/infrastructure/useCases" "context" "encoding/json" "errors" "maps" "net/http" "net/http/httptest" "slices" "strings" "sync" "testing" "github.com/go-chi/chi/v5" ) const ( journalSiteID = 1 notesSiteID = 2 archiveSiteID = 3 gallerySiteID = 4 readerUserID = 7 ) var errInjectedConfirm = errors.New("injected confirm failure") type fakeLocalizationStore struct { mu sync.Mutex articles *mockArticleRepo rows map[uint32][]localization.Row trashed map[uint32]map[string]int failConfirmAt int confirmCalls int replaceCalls int nextArticleID uint32 confirmedOrder []uint32 } var _ localization.Repository = &fakeLocalizationStore{} func newFakeLocalizationStore(articles *mockArticleRepo) *fakeLocalizationStore { return &fakeLocalizationStore{articles: articles, rows: map[uint32][]localization.Row{}, trashed: map[uint32]map[string]int{}, nextArticleID: 100} } func (f *fakeLocalizationStore) Rows(siteID uint32) ([]localization.Row, error) { f.mu.Lock() defer f.mu.Unlock() return slices.Clone(f.rows[siteID]), nil } func (f *fakeLocalizationStore) ConfirmedSites() (map[uint32]bool, error) { f.mu.Lock() defer f.mu.Unlock() confirmed := map[uint32]bool{} for siteID, rows := range f.rows { if !localization.NeedsSetup(rows) { confirmed[siteID] = true } } return confirmed, nil } func (f *fakeLocalizationStore) VersionCounts(siteID uint32) (map[string]localization.VersionCount, error) { f.mu.Lock() defer f.mu.Unlock() counts := map[string]localization.VersionCount{} for _, stored := range f.articles.articles { if stored.SiteID != siteID { continue } count := counts[stored.Lang] count.Total++ if stored.IsOnline { count.Published++ } counts[stored.Lang] = count } for lang, trashed := range f.trashed[siteID] { count := counts[lang] count.Trashed = trashed counts[lang] = count } return counts, nil } func (f *fakeLocalizationStore) Replace(siteID uint32, rows []localization.Row) error { f.mu.Lock() defer f.mu.Unlock() f.replaceCalls++ f.rows[siteID] = slices.Clone(rows) return nil } func (f *fakeLocalizationStore) ConfirmSetup(siteID uint32, tag string) error { f.mu.Lock() defer f.mu.Unlock() f.confirmCalls++ if f.failConfirmAt == f.confirmCalls { return errInjectedConfirm } f.confirmedOrder = append(f.confirmedOrder, siteID) f.rows[siteID] = []localization.Row{{Tag: tag, IsDefault: true, Enabled: true, Confirmed: true}} return nil } func (f *fakeLocalizationStore) snapshot() (map[uint32][]localization.Row, map[uint32]article.Article) { rows := make(map[uint32][]localization.Row, len(f.rows)) for siteID, stored := range f.rows { rows[siteID] = slices.Clone(stored) } return rows, maps.Clone(f.articles.articles) } type rollbackLocalizationTransactor struct { store *fakeLocalizationStore sites site.Repository runs int } var _ transaction.Transactor = &rollbackLocalizationTransactor{} func (r *rollbackLocalizationTransactor) Run(ctx context.Context, work func(transaction.Repositories) error) error { r.runs++ rows, articles := r.store.snapshot() err := work(transaction.Repositories{Articles: r.store.articles, Sites: r.sites, Localization: r.store, ArticleSEO: r.store.articles}) if err != nil { r.store.rows = rows r.store.articles.articles = articles } return err } type patternSiteRepo struct { *mockSiteRepo } func (p patternSiteRepo) Update(s site.Site) (site.Site, error) { saved, err := p.mockSiteRepo.Update(s) if err != nil { return saved, err } stored := p.mockSiteRepo.sites[s.ID] stored.PublicURLPattern = s.PublicURLPattern p.mockSiteRepo.sites[s.ID] = stored return p.mockSiteRepo.withCount(stored), nil } type localizationEnv struct { *siteEnv store *fakeLocalizationStore transactor *rollbackLocalizationTransactor nextID uint32 } func newLocalizationEnv(t *testing.T) *localizationEnv { t.Helper() e := newSiteEnv(t) store := newFakeLocalizationStore(e.articles) sites := patternSiteRepo{e.sites} transactor := &rollbackLocalizationTransactor{store: store, sites: sites} c := e.container c.ListSitesUseCase = useCases.NewListSitesUseCase(sites) c.UpdateSiteUseCase = useCases.NewUpdateSiteUseCase(sites, e.images, e.audit.recorder, siteWriteTransactor(sites, e.articles)) c.GetSiteLanguagesUseCase = useCases.NewGetSiteLanguagesUseCase(sites, store) c.UpdateSiteLanguagesUseCase = useCases.NewUpdateSiteLanguagesUseCase(transactor, e.audit.recorder) c.GetLanguageSetupUseCase = useCases.NewGetLanguageSetupUseCase(sites, store) c.ApplyLanguageSetupUseCase = useCases.NewApplyLanguageSetupUseCase(transactor, e.audit.recorder) router := chi.NewRouter() router.Mount("/v1", api.InitBackendRoutes(c, e.settings)) e.router = router e.addUser(readerUserID, writerRoleID, true) return &localizationEnv{siteEnv: e, store: store, transactor: transactor, nextID: 1} } func (e *localizationEnv) addArticles(siteID uint32, lang string, total int, published int) { for index := 0; index < total; index++ { e.store.nextArticleID++ e.articles.articles[e.store.nextArticleID] = article.Article{ID: e.store.nextArticleID, Title: "Article " + lang, SiteID: siteID, Lang: lang, IsOnline: index < published} } } func (e *localizationEnv) seedRows(siteID uint32, rows ...localization.Row) { e.store.rows[siteID] = rows } func (e *localizationEnv) seedJournal() { e.sites.seed(journalSiteID, "Journal", "journal", true) english := "en" site := e.sites.sites[journalSiteID] site.Locale = &english e.sites.sites[journalSiteID] = site frenchSocial := "fr_FR" e.seedRows(journalSiteID, localization.Row{Tag: "en", IsDefault: true, Enabled: true, Confirmed: true}, localization.Row{Tag: "fr", Enabled: true, Confirmed: true, SocialLocale: &frenchSocial}, localization.Row{Tag: "de", Enabled: true}, localization.Row{Tag: "ar", Enabled: true}, localization.Row{Tag: "es"}, localization.Row{Tag: "en-US"}, ) e.addArticles(journalSiteID, "en", 7, 6) e.addArticles(journalSiteID, "fr", 4, 2) e.addArticles(journalSiteID, "de", 4, 3) e.addArticles(journalSiteID, "ar", 1, 0) e.addArticles(journalSiteID, "es", 1, 0) } func (e *localizationEnv) seedPendingSites() { e.seedJournalPending() e.seedSite(notesSiteID, "Notes", "notes", nil, nil, 3) pt := "pt-BR" e.seedSite(archiveSiteID, "Archive", "archive", &pt, &localization.Row{Tag: "pt-BR", IsDefault: true, Enabled: true}, 5) gb := "en-GB" e.seedSite(gallerySiteID, "Gallery", "gallery", &gb, &localization.Row{Tag: "en-GB", IsDefault: true, Enabled: true}, 0) } func (e *localizationEnv) seedJournalPending() { en := "en" e.seedSite(journalSiteID, "Journal", "journal", &en, &localization.Row{Tag: "en", IsDefault: true, Enabled: true}, 8) } func (e *localizationEnv) seedSite(id uint32, name string, key string, locale *string, row *localization.Row, articles int) { e.sites.seed(id, name, key, id == journalSiteID) stored := e.sites.sites[id] stored.Locale = locale e.sites.sites[id] = stored if row != nil { e.seedRows(id, *row) } e.addArticles(id, language.Unassigned, articles, 0) } func (e *localizationEnv) read(t *testing.T, key string) (int, map[string]json.RawMessage) { t.Helper() rec := e.owner(t, http.MethodGet, "/v1/site/"+key+"/languages", "") if rec.Code != http.StatusOK { return rec.Code, nil } return rec.Code, decodeFields(t, rec) } func decodeFields(t *testing.T, rec *httptest.ResponseRecorder) map[string]json.RawMessage { t.Helper() var fields map[string]json.RawMessage if err := json.Unmarshal(rec.Body.Bytes(), &fields); err != nil { t.Fatalf("decode %q: %v", rec.Body.String(), err) } return fields } func (e *localizationEnv) revision(t *testing.T, key string) string { t.Helper() _, fields := e.read(t, key) var revision string if err := json.Unmarshal(fields["revision"], &revision); err != nil { t.Fatalf("revision: %v", err) } return revision } type languagesView struct { Revision string `json:"revision"` Default *string `json:"default"` NeedsSetup bool `json:"needs_setup"` Languages []struct { Lang string `json:"lang"` Enabled bool `json:"enabled"` IsDefault bool `json:"is_default"` VersionCount int `json:"version_count"` PublishedCount int `json:"published_count"` SocialLocale *string `json:"social_locale"` State string `json:"social_locale_state"` } `json:"languages"` Available []struct { Lang string `json:"lang"` Substitute *string `json:"substitute"` } `json:"available"` } func decodeLanguagesView(t *testing.T, rec *httptest.ResponseRecorder) languagesView { t.Helper() var view languagesView if err := json.Unmarshal(rec.Body.Bytes(), &view); err != nil { t.Fatalf("decode %q: %v", rec.Body.String(), err) } return view } func (v languagesView) tags() []string { tags := []string{} for _, entry := range v.Languages { tags = append(tags, entry.Lang) } return tags } func keysOf(fields map[string]json.RawMessage) []string { keys := slices.Sorted(maps.Keys(fields)) return keys } func languagesBody(revision string, defaultTag string, entries ...string) string { return `{"revision":"` + revision + `","default":"` + defaultTag + `","languages":[` + strings.Join(entries, ",") + `]}` } func entryJSON(lang string, enabled bool, social string) string { enabledText := "false" if enabled { enabledText = "true" } return `{"lang":"` + lang + `","enabled":` + enabledText + `,"social_locale":` + social + `}` } func journalEntries(overrides map[string]string, omit ...string) []string { base := []struct { lang string enabled bool social string }{{"en", true, "null"}, {"fr", true, `"fr_FR"`}, {"de", true, "null"}, {"ar", true, "null"}, {"es", false, "null"}, {"en-US", false, "null"}} entries := []string{} for _, row := range base { if slices.Contains(omit, row.lang) { continue } if replacement, ok := overrides[row.lang]; ok { entries = append(entries, replacement) continue } entries = append(entries, entryJSON(row.lang, row.enabled, row.social)) } return entries }