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 starting map[uint32]string } 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() starting := map[uint32]string{storyJournal: "en", storyAtelier: "en"} if s.starting != nil { starting = s.starting } err := work(transaction.Repositories{Articles: w.articles, Sites: w.sites, Authors: w.authors, Localization: w.rows, Languages: &mockStartingLanguages{confirmed: starting}}) 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: "
Costs
", 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: "Couts
", CreatedBy: storyWriter1, Slug: "couts", Lang: "fr", StoryID: storyStory, CreatedAt: storyEpoch.Add(time.Minute)}) w.put(article.Article{ID: 11, Title: "Kosten", Body: "Kosten
", CreatedBy: 2, Slug: "kosten", Lang: "de", StoryID: storyStory, CreatedAt: storyEpoch.Add(2 * time.Minute)}) w.put(article.Article{ID: 13, Title: "Takalif", Body: "Takalif
", 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, } 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 }