feat: site languages, setup and site URL settings

This commit is contained in:
Florian Sylvain committed 2026-11-01 22:13:02 +01:00
1 parent 0806733d5c
commit d9158025d4
106 files changed
+10184 -56

No files matched your search

+328
View File
@@ -0,0 +1,328 @@
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"
"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 {
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) {
return slices.Clone(f.rows[siteID]), nil
}
func (f *fakeLocalizationStore) ConfirmedSites() (map[uint32]bool, error) {
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) {
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.replaceCalls++
f.rows[siteID] = slices.Clone(rows)
return nil
}
func (f *fakeLocalizationStore) ConfirmSetup(siteID uint32, tag string) error {
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})
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)
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
}