package test import ( "RenewCMS/api/handlers/article" "RenewCMS/api/pkg/access" "RenewCMS/api/pkg/authtoken" "RenewCMS/api/pkg/claims" domainArticle "RenewCMS/internal/domain/article" "RenewCMS/internal/domain/role" "RenewCMS/internal/domain/site" entity "RenewCMS/internal/infrastructure/persistence/models" persistence "RenewCMS/internal/infrastructure/persistence/repositories" "RenewCMS/internal/infrastructure/useCases" "encoding/json" "fmt" "net/http" "net/http/httptest" "testing" "time" "github.com/go-chi/chi/v5" "gorm.io/gorm" ) const ( localizationOwnerID uint32 = 1 localizationReaderID uint32 = 2 localizationWriterID uint32 = 3 localizationUnverifiedID uint32 = 4 localizationOtherUserID uint32 = 9 localizationMalformed uint32 = 999 ) type localizationVersion struct { id uint32 lang string online bool title string creator uint32 updated time.Time byline string siteKey string noindex bool storyKey uint32 } type storyListingRepository struct { *persistence.ArticleRepository calls int failMethod string } func (r *storyListingRepository) failure(method string) error { r.calls++ if r.failMethod == method { return errRepositoryFailure } return nil } func (r *storyListingRepository) FindPage(filters domainArticle.Filters) (domainArticle.PageResult, error) { if err := r.failure("FindPage"); err != nil { return domainArticle.PageResult{}, err } return r.ArticleRepository.FindPage(filters) } func (r *storyListingRepository) CountVersions(filters domainArticle.Filters) (domainArticle.Counts, error) { if err := r.failure("CountVersions"); err != nil { return domainArticle.Counts{}, err } return r.ArticleRepository.CountVersions(filters) } func (r *storyListingRepository) FindStoryPage(filters domainArticle.StoryFilters) (domainArticle.StoryPage, error) { if err := r.failure("FindStoryPage"); err != nil { return domainArticle.StoryPage{}, err } return r.ArticleRepository.FindStoryPage(filters) } func (r *storyListingRepository) CountStories(filters domainArticle.StoryFilters) (domainArticle.StoryCounts, error) { if err := r.failure("CountStories"); err != nil { return domainArticle.StoryCounts{}, err } return r.ArticleRepository.CountStories(filters) } func (r *storyListingRepository) ListVersions(ids []uint32) ([]domainArticle.Article, error) { if err := r.failure("ListVersions"); err != nil { return nil, err } return r.ArticleRepository.ListVersions(ids) } type listCountingSites struct { *persistence.SiteRepository lists int } func (r *listCountingSites) List() ([]site.Site, error) { r.lists++ return r.SiteRepository.List() } type localizationListEnv struct { siteRepo *listCountingSites t *testing.T db *gorm.DB repo *storyListingRepository router *chi.Mux token *authtoken.Auth authors map[string]uint32 sites map[string]uint32 nextID uint32 } func localizationGrants(userID uint32) (role.GrantSet, bool) { switch userID { case localizationOwnerID: return role.Role{Grants: []role.Grant{ {Key: role.KeyContentRead, Scope: role.ScopeAll}, {Key: role.KeyContentCreate, Scope: role.ScopeAll}, {Key: role.KeyContentUpdate, Scope: role.ScopeAll}, {Key: role.KeyContentPublish, Scope: role.ScopeAll}, {Key: role.KeyContentDelete, Scope: role.ScopeAll}, }}.GrantSet(), true case localizationReaderID: return role.Role{}.GrantSet(), true case localizationWriterID: return role.Role{Grants: []role.Grant{ {Key: role.KeyContentRead, Scope: role.ScopeOwn}, {Key: role.KeyContentCreate, Scope: role.ScopeOwn}, }}.GrantSet(), true } return role.GrantSet{}, false } func localizationAccess(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if userID, ok := claims.UserId(r); ok { if grants, known := localizationGrants(userID); known { r = r.WithContext(access.WithCaller(r.Context(), access.Caller{UserID: userID, Grants: grants})) } } next.ServeHTTP(w, r) }) } func newLocalizationListEnv(t *testing.T, open func(*testing.T) *gorm.DB) *localizationListEnv { t.Helper() db := open(t) repo := &storyListingRepository{ArticleRepository: persistence.NewArticleRepository(db)} siteRepo := &listCountingSites{SiteRepository: persistence.NewSiteRepository(db)} list := useCases.NewListArticlesUseCase(repo, &fakeBodyRenderer{}, siteRepo, persistence.NewLocalizationRepository(db)) handler := article.NewHandler(nil, nil, list, nil, nil, nil, nil) token := authtoken.New([]byte("test-secret")) router := chi.NewRouter() router.Route("/v1", func(r chi.Router) { r.Route("/article", func(r chi.Router) { r.Use(authtoken.Verifier(token)) r.Use(localizationAccess) r.Get("/", handler.ListArticles) }) }) env := &localizationListEnv{siteRepo: siteRepo, t: t, db: db, repo: repo, router: router, token: token, authors: map[string]uint32{}, sites: map[string]uint32{}} env.seedJournal() return env } func (e *localizationListEnv) must(err error) { e.t.Helper() if err != nil { e.t.Fatalf("seed: %v", err) } } func (e *localizationListEnv) addSite(key string, defaultLang string, enabled []string, disabled []string) { e.t.Helper() site := entity.Site{Name: key, Key: key, BaseURL: "https://" + key + ".example.com", DefaultRobots: "index"} e.must(e.db.Create(&site).Error) e.sites[key] = site.ID for _, tag := range append(append([]string{}, enabled...), disabled...) { row := entity.SiteLanguage{SiteID: site.ID, Lang: tag, IsDefault: tag == defaultLang, Confirmed: true} e.must(e.db.Create(&row).Error) e.must(e.db.Model(&entity.SiteLanguage{}).Where("site_id = ? AND lang = ?", site.ID, tag).UpdateColumn("enabled", !listHasTag(disabled, tag)).Error) } } func listHasTag(tags []string, tag string) bool { for _, candidate := range tags { if candidate == tag { return true } } return false } func (e *localizationListEnv) addAuthor(name string) { e.t.Helper() row := entity.Author{Name: name, Slug: fmt.Sprintf("author-%d", len(e.authors)+1), SocialLinks: "[]"} e.must(e.db.Create(&row).Error) e.authors[name] = row.ID } func (e *localizationListEnv) addVersion(version localizationVersion) { e.t.Helper() siteKey := version.siteKey if siteKey == "" { siteKey = "journal" } creator := version.creator if creator == 0 { creator = localizationOwnerID } var story entity.Story if err := e.db.First(&story, version.storyKey).Error; err != nil { e.must(e.db.Create(&entity.Story{ID: version.storyKey}).Error) } row := entity.Article{ ID: version.id, Title: version.title, Body: "
body
", IsOnline: version.online, SiteID: e.sites[siteKey], CreatedBy: creator, Slug: fmt.Sprintf("a-%d", version.id), Noindex: version.noindex, Lang: version.lang, StoryID: version.storyKey, CreatedAt: version.updated.Add(-24 * time.Hour), UpdatedAt: version.updated, ContentRevision: 1, EditRevision: 1, } e.must(e.db.Create(&row).Error) if version.byline != "" { e.must(e.db.Create(&entity.ArticleAuthor{ArticleID: version.id, AuthorID: e.authors[version.byline], Position: 0}).Error) } } func localizationDay(day int, hour int) time.Time { return time.Date(2026, time.October, day, hour, 0, 0, 0, time.UTC) } func (e *localizationListEnv) seedJournal() { e.addSite("journal", "en", []string{"en", "fr", "de", "ar"}, []string{"es", "it"}) e.addSite("press", "en", []string{"en", "fr"}, nil) e.addSite("atelier", "en", []string{"en", "en-GB", "fr"}, nil) for _, name := range []string{"Marta Alves", "Camille Roche", "Luis Ferrer"} { e.addAuthor(name) } type journalVersion struct { id uint32 lang string online bool } stories := []struct { id uint32 byline string latest time.Time latestID uint32 versions []journalVersion }{ {1, "Marta Alves", localizationDay(30, 10), 102, []journalVersion{{101, "en", true}, {102, "fr", false}, {103, "de", true}, {104, "ar", false}}}, {2, "Camille Roche", localizationDay(29, 9), 202, []journalVersion{{201, "en", true}, {202, "fr", true}, {203, "de", true}}}, {3, "Luis Ferrer", localizationDay(28, 9), 302, []journalVersion{{301, "en", true}, {302, "fr", true}}}, {4, "Marta Alves", localizationDay(27, 9), 401, []journalVersion{{401, "en", false}}}, {5, "Luis Ferrer", localizationDay(14, 9), 502, []journalVersion{{501, "en", true}, {502, "de", false}}}, {6, "Camille Roche", localizationDay(13, 9), 602, []journalVersion{{601, "en", true}, {602, "fr", false}}}, {7, "Marta Alves", localizationDay(12, 9), 702, []journalVersion{{701, "en", true}, {702, "de", true}}}, {8, "Luis Ferrer", localizationDay(10, 9), 801, []journalVersion{{801, "es", true}}}, } for _, story := range stories { for _, version := range story.versions { updated := story.latest if version.id != story.latestID { updated = story.latest.Add(-time.Hour) } e.addVersion(localizationVersion{id: version.id, lang: version.lang, online: version.online, title: fmt.Sprintf("Journal %d", version.id), updated: updated, byline: story.byline, noindex: true, storyKey: story.id}) } } e.must(e.db.Model(&entity.Article{}).Where("id = ?", 101).UpdateColumn("title", "How we price a bathroom renovation").Error) e.must(e.db.Model(&entity.Article{}).Where("id = ?", 102).UpdateColumn("title", "Comment nous chiffrons une rénovation").Error) e.must(e.db.Model(&entity.Article{}).Where("id = ?", 401).UpdateColumn("title", "Spring maintenance checklist").Error) e.nextID = 900 } type storyListBody struct { Items []map[string]any `json:"items"` Total int `json:"total"` Page int `json:"page"` Size int `json:"size"` Counts map[string]int `json:"counts"` } func (e *localizationListEnv) get(userID uint32, query string) (*httptest.ResponseRecorder, storyListBody) { e.t.Helper() req := httptest.NewRequest(http.MethodGet, "/v1/article/"+query, nil) switch userID { case 0: case localizationMalformed: req.AddCookie(&http.Cookie{Name: "jwt", Value: "garbage"}) default: setJwtCookie(e.t, req, e.token, userID) } rec := httptest.NewRecorder() e.router.ServeHTTP(rec, req) var body storyListBody if rec.Code == http.StatusOK { if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { e.t.Fatalf("decode %s: %v", rec.Body.String(), err) } } return rec, body } func (b storyListBody) storyIDs() []int { ids := []int{} for _, item := range b.Items { ids = append(ids, int(item["story_id"].(float64))) } return ids } func (b storyListBody) articleIDs() []int { ids := []int{} for _, item := range b.Items { ids = append(ids, int(item["id"].(float64))) } return ids } func (b storyListBody) item(storyID int) map[string]any { for _, item := range b.Items { if int(item["story_id"].(float64)) == storyID { return item } } return nil } func stringsOf(value any) []string { listed, _ := value.([]any) out := []string{} for _, entry := range listed { out = append(out, entry.(string)) } return out } func coverageLangs(item map[string]any) []string { langs := []string{} for _, entry := range item["coverage"].([]any) { langs = append(langs, entry.(map[string]any)["lang"].(string)) } return langs }