Files
RenewCMS/test/article_list_localization_support_test.go
T

341 lines
11 KiB
Go

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)
}
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: "<p>body</p>", 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
}