feat: editor write path

This commit is contained in:
Florian Sylvain committed 2026-11-02 03:24:30 +01:00
1 parent d9158025d4
commit a0b3c58c52
88 files changed
+5738 -263

No files matched your search

+298
View File
@@ -0,0 +1,298 @@
package test
import (
"RenewCMS/api"
"RenewCMS/api/pkg/authtoken"
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/audit"
imageDomain "RenewCMS/internal/domain/image"
"RenewCMS/internal/domain/localization"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/domain/transaction"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"context"
"encoding/json"
"maps"
"net/http"
"net/http/httptest"
"slices"
"strconv"
"strings"
"testing"
"time"
"RenewCMS/api/pkg/validator"
"github.com/go-chi/chi/v5"
)
const (
storyJournal uint32 = 1
storyAtelier uint32 = 2
storyWriter1 uint32 = 1
storyOwner uint32 = 50
storyReader uint32 = 51
storyWriter uint32 = 52
storyRestrict uint32 = 53
storyUpdater uint32 = 54
storyPublish uint32 = 55
storyNobody uint32 = 56
storyPartial uint32 = 57
storyAnon uint32 = 0
storyStory uint32 = 5
)
type storyWorld struct {
t *testing.T
token *authtoken.Auth
users *mockUserRepo
roles *mockRoleRepo
articles *mockArticleRepo
sites *mockSiteRepo
rows *fakeLocalizationStore
authors *mockAuthorRepo
images *mockImageRepo
recorder *contentScopeRecorder
transactor *storyTransactor
router http.Handler
}
type storyTransactor struct {
world *storyWorld
runs int
external map[uint32]article.Article
}
var _ transaction.Transactor = &storyTransactor{}
func (s *storyTransactor) Run(ctx context.Context, work func(transaction.Repositories) error) error {
s.runs++
w := s.world
articles := maps.Clone(w.articles.articles)
credits := maps.Clone(w.articles.credits)
rows, _ := w.rows.snapshot()
err := work(transaction.Repositories{Articles: w.articles, Sites: w.sites, Authors: w.authors, Localization: w.rows, Languages: &mockStartingLanguages{confirmed: map[uint32]string{storyJournal: "en", storyAtelier: "en"}}})
if err != nil {
w.articles.articles = articles
w.articles.credits = credits
w.rows.rows = rows
maps.Copy(w.articles.articles, s.external)
}
return err
}
var storyEpoch = time.Date(2026, time.October, 1, 9, 0, 0, 0, time.UTC)
func storyRows(defaultTag string, enabled []string, disabled []string) []localization.Row {
rows := []localization.Row{}
for _, tag := range enabled {
rows = append(rows, localization.Row{Tag: tag, IsDefault: tag == defaultTag, Enabled: true, Confirmed: tag == defaultTag})
}
for _, tag := range disabled {
rows = append(rows, localization.Row{Tag: tag, Enabled: false})
}
return rows
}
func newStoryWorld(t *testing.T) *storyWorld {
t.Helper()
validator.InitValidator()
w := &storyWorld{t: t, token: authtoken.New([]byte("test-secret")), users: newMockUserRepo(), roles: newSeededMockRoleRepo(), articles: newMockArticleRepo(), recorder: &contentScopeRecorder{}}
w.sites = newMockSiteRepo(w.articles)
w.sites.seed(storyJournal, "Journal", "journal", true)
w.sites.seed(storyAtelier, "Atelier", "atelier", false)
w.authors = newMockAuthorRepo(w.articles)
w.images = newMockImageRepo()
for _, id := range []uint32{3, 4, 7, 8} {
w.images.images[id] = imageDomain.Image{ID: id, Filename: "image" + strconv.FormatUint(uint64(id), 10) + ".png"}
}
w.authors.seed(1, "Camille", "camille")
w.authors.seed(2, "Noor", "noor")
w.rows = newFakeLocalizationStore(w.articles)
w.rows.rows[storyJournal] = storyRows("en", []string{"en", "fr", "de", "ar"}, []string{"es"})
w.rows.rows[storyAtelier] = storyRows("en", []string{"en", "fr", "de", "ar"}, nil)
w.transactor = &storyTransactor{world: w}
w.seedUsers()
w.seedStory()
w.wire()
return w
}
func (w *storyWorld) seedUsers() {
w.addUser(storyOwner, ownerRoleID, "owner")
custom := map[uint32][]string{
storyWriter1: {"content.create=all", "content.read=own", "content.update=own", "content.publish=own", "content.delete=own"},
storyReader: {"content.read=all"},
storyWriter: {"content.create=all", "content.update=all", "content.read=all"},
storyRestrict: {"content.create=all", "content.read=own"},
storyUpdater: {"content.update=all", "content.read=all"},
storyPublish: {"content.publish=all", "content.read=all"},
storyNobody: {"schema.view=all"},
storyPartial: {"content.update=all", "content.read=own"},
}
for id, grants := range custom {
created, _ := w.roles.Create(role.Role{Name: "story-role-" + strconv.FormatUint(uint64(id), 10), Version: 1, Grants: grantRows(grants...)})
w.addUser(id, created.ID, "user"+strconv.FormatUint(uint64(id), 10))
}
}
func (w *storyWorld) addUser(id uint32, roleID uint32, name string) {
w.users.users[id] = user.User{ID: id, Username: name, Email: name + "@example.com", IsVerified: true, RoleID: roleID}
}
func (w *storyWorld) put(a article.Article) article.Article {
if a.SiteID == 0 {
a.SiteID = storyJournal
}
if a.EditRevision == 0 {
a.EditRevision = 1
}
if a.ContentRevision == 0 {
a.ContentRevision = 1
}
if a.SEODismissed == nil {
a.SEODismissed = []string{}
}
w.articles.articles[a.ID] = a
if a.ID > w.articles.nextID {
w.articles.nextID = a.ID
}
return a
}
func (w *storyWorld) putCommitted(a article.Article) {
w.put(a)
if w.transactor.external == nil {
w.transactor.external = map[uint32]article.Article{}
}
w.transactor.external[a.ID] = w.stored(a.ID)
}
func (w *storyWorld) seedStory() {
cover, social := uint32(3), uint32(4)
w.put(article.Article{ID: 10, Title: "Renovation costs", Body: "<p>Costs</p>", IsOnline: true, CreatedBy: storyWriter1, Slug: "renovation-costs", Lang: "en", StoryID: storyStory,
Excerpt: "Excerpt", SEOTitle: "SEO title", SEODescription: "SEO description", SocialTitle: "Social title", SocialDescription: "Social description",
CoverImageID: &cover, CoverImageAlt: "Cover alt", SocialImageID: &social, CanonicalURL: "https://journal.example/renovation", Noindex: true, SEODismissed: []string{"short"},
EditRevision: 3, ContentRevision: 4, CreatedAt: storyEpoch})
w.articles.credit(10, 1)
w.put(article.Article{ID: 12, Title: "Couts", Body: "<p>Couts</p>", CreatedBy: storyWriter1, Slug: "couts", Lang: "fr", StoryID: storyStory, CreatedAt: storyEpoch.Add(time.Minute)})
w.put(article.Article{ID: 11, Title: "Kosten", Body: "<p>Kosten</p>", CreatedBy: 2, Slug: "kosten", Lang: "de", StoryID: storyStory, CreatedAt: storyEpoch.Add(2 * time.Minute)})
w.put(article.Article{ID: 13, Title: "Takalif", Body: "<p>Takalif</p>", IsOnline: true, CreatedBy: 3, Slug: "takalif", Lang: "ar", StoryID: storyStory, CreatedAt: storyEpoch.Add(3 * time.Minute)})
}
func (w *storyWorld) wire() {
renderer := &fakeBodyRenderer{}
keys := newMockAPIKeyRepo()
moveStory := useCases.NewMoveStoryUseCase(w.transactor, w.articles, w.recorder)
container := &api.UseCases{
Token: w.token,
GetUserUseCase: useCases.NewGetUserUseCase(w.users),
CheckPermissionUseCase: useCases.NewCheckPermissionUseCase(w.roles),
AuthenticateAPIKeyUseCase: useCases.NewAuthenticateAPIKeyUseCase(keys, nil, w.users, time.Now),
CreateArticleUseCase: useCases.NewCreateArticleUseCase(w.transactor, renderer, w.recorder, w.sites, w.authors),
GetArticleUseCase: useCases.NewGetArticleUseCase(w.articles, renderer),
ListArticlesUseCase: useCases.NewListArticlesUseCase(w.articles, renderer, w.sites),
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(w.articles, w.recorder),
UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(w.articles, renderer, w.recorder, w.sites, w.authors, w.images, w.rows, moveStory),
CreateTranslationUseCase: useCases.NewCreateTranslationUseCase(w.transactor, w.articles, w.rows, renderer, w.recorder),
DescribeArticleUseCase: useCases.NewDescribeArticleUseCase(w.articles, w.rows),
MoveStoryUseCase: moveStory,
}
router := chi.NewRouter()
router.Use(authtoken.Verifier(w.token))
router.Mount("/v1/article", api.NewArticleRouter(container, nil))
router.Mount("/v1/story", api.NewStoryRouter(container, nil))
w.router = router
}
type storyResponse struct {
*httptest.ResponseRecorder
}
func (w *storyWorld) send(userID uint32, method string, path string, body string) storyResponse {
req := httptest.NewRequest(method, path, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.RemoteAddr = activityPeer
if userID != storyAnon {
tokenString, _ := w.token.Encode(map[string]any{"user_id": userID, "token_version": 0})
req.AddCookie(&http.Cookie{Name: "jwt", Value: tokenString})
}
rec := httptest.NewRecorder()
w.router.ServeHTTP(rec, req)
return storyResponse{rec}
}
func (r storyResponse) text() string {
return r.Body.String()
}
func (r storyResponse) fields(t *testing.T) map[string]json.RawMessage {
t.Helper()
return decodeArticleFields(t, r.Body.Bytes())
}
func (r storyResponse) article(t *testing.T) article.Article {
t.Helper()
var decoded article.Article
if err := json.Unmarshal(r.Body.Bytes(), &decoded); err != nil {
t.Fatalf("invalid article json %s: %v", r.Body, err)
}
return decoded
}
func (r storyResponse) expect(t *testing.T, status int, body string) {
t.Helper()
if r.Code != status || (body != "" && r.text() != body+"\n") {
t.Fatalf("expected %d %q, got %d %q", status, body, r.Code, r.text())
}
}
func (r storyResponse) header(name string) string {
return r.Header().Get(name)
}
func (w *storyWorld) stored(id uint32) article.Article {
return w.articles.articles[id]
}
func (w *storyWorld) snapshot() map[uint32]article.Article {
return maps.Clone(w.articles.articles)
}
func (w *storyWorld) expectUnchanged(before map[uint32]article.Article, added ...uint32) {
w.t.Helper()
if len(before)+len(added) != len(w.articles.articles) {
w.t.Fatalf("the article count changed from %d to %d", len(before), len(w.articles.articles))
}
for id, stored := range before {
if current := w.articles.articles[id]; !articlesEqual(stored, current) {
w.t.Fatalf("article %d changed: %+v -> %+v", id, stored, current)
}
}
}
func articlesEqual(left article.Article, right article.Article) bool {
left.SEODismissed, right.SEODismissed = slices.Clone(left.SEODismissed), slices.Clone(right.SEODismissed)
leftJSON, _ := json.Marshal(left)
rightJSON, _ := json.Marshal(right)
return string(leftJSON) == string(rightJSON) && left.EditRevision == right.EditRevision && left.ContentRevision == right.ContentRevision && left.UpdatedAt.Equal(right.UpdatedAt) && left.StoryID == right.StoryID && left.Lang == right.Lang
}
func (w *storyWorld) eventsOf(action string) []audit.Event {
events := []audit.Event{}
for _, event := range w.recorder.events {
if event.Action == action {
events = append(events, event)
}
}
return events
}
func newTranslationWorld(t *testing.T) *storyWorld {
t.Helper()
w := newStoryWorld(t)
delete(w.articles.articles, 12)
delete(w.articles.articles, 13)
return w
}