303 lines
11 KiB
Go
303 lines
11 KiB
Go
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
|
|
storyDeleter uint32 = 58
|
|
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
|
|
removed map[uint32]bool
|
|
}
|
|
|
|
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: removableSites{mockSiteRepo: w.sites, removed: s.removed}, Authors: w.authors, Localization: w.rows, ArticleSEO: w.articles})
|
|
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"},
|
|
storyDeleter: {"content.delete=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, plainLocalization()),
|
|
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,
|
|
DeleteStoryUseCase: useCases.NewDeleteStoryUseCase(w.transactor, w.articles, w.recorder),
|
|
}
|
|
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
|
|
}
|