feat: editorial article list
This commit is contained in:
1 parent
a0b3c58c52
commit
a671b81f80
103 files changed
+6493
-1805
No files matched your search
@@ -0,0 +1,340 @@
|
||||
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
|
||||
}
|
||||
Reference in new issue
Block a user