package test import ( "RenewCMS/api" "RenewCMS/internal/domain/article" "RenewCMS/internal/domain/author" "RenewCMS/internal/domain/user" "RenewCMS/internal/infrastructure/useCases" "encoding/json" "io" "log" "net/http/httptest" "sort" "strings" "testing" "time" "github.com/go-chi/chi/v5" ) type authorDeleteCall struct { id uint32 reassignTo *uint32 } type mockAuthorRepo struct { authors map[uint32]author.Author articles *mockArticleRepo nextID uint32 fail failSwitch createCalls int deleteCalls []authorDeleteCall } func newMockAuthorRepo(articles *mockArticleRepo) *mockAuthorRepo { repo := &mockAuthorRepo{authors: map[uint32]author.Author{}, articles: articles} articles.authors = repo return repo } func plainAuthors() *mockAuthorRepo { return newMockAuthorRepo(newMockArticleRepo()) } func (m *mockAuthorRepo) seed(id uint32, name string, slug string) author.Author { seeded := author.Author{ID: id, Name: name, Slug: slug, SocialLinks: []author.SocialLink{}} m.authors[id] = seeded if id > m.nextID { m.nextID = id } return seeded } func (m *mockAuthorRepo) seedLinked(id uint32, name string, slug string, userID uint32) author.Author { seeded := m.seed(id, name, slug) seeded.UserID = idPointer(userID) seeded.IsLinked = true m.authors[id] = seeded return seeded } func (m *mockAuthorRepo) update(id uint32, change func(*author.Author)) { found := m.authors[id] change(&found) m.authors[id] = found } func (m *mockAuthorRepo) withCount(a author.Author) author.Author { a.ArticleCount = 0 for articleID, ids := range m.articles.credits { if _, exists := m.articles.articles[articleID]; !exists { continue } for _, id := range ids { if id == a.ID { a.ArticleCount++ } } } a.IsLinked = a.UserID != nil return a } func (m *mockAuthorRepo) snapshot() map[uint32]author.Author { copied := make(map[uint32]author.Author, len(m.authors)) for id, a := range m.authors { copied[id] = m.withCount(a) } return copied } func (m *mockAuthorRepo) sortedIDsDescending() []uint32 { ids := make([]uint32, 0, len(m.authors)) for id := range m.authors { ids = append(ids, id) } sort.Slice(ids, func(i, j int) bool { return ids[i] > ids[j] }) return ids } func (m *mockAuthorRepo) List(query author.ListQuery) (author.Page, error) { if err := m.fail.trigger("List"); err != nil { return author.Page{}, err } needle := foldArticleSearch(strings.TrimSpace(query.Q)) matching := []author.Author{} for _, id := range m.sortedIDsDescending() { a := m.authors[id] if needle != "" && !strings.Contains(foldArticleSearch(a.Name), needle) && !strings.Contains(foldArticleSearch(a.Slug), needle) { continue } matching = append(matching, m.withCount(a)) } start := len(matching) if query.Size > 0 && query.Page-1 <= len(matching)/query.Size { start = min((query.Page-1)*query.Size, len(matching)) } end := min(start+query.Size, len(matching)) return author.Page{Items: matching[start:end], Total: len(matching)}, nil } func (m *mockAuthorRepo) Get(id uint32) (author.Author, error) { if err := m.fail.trigger("Get"); err != nil { return author.Author{}, err } found, ok := m.authors[id] if !ok { return author.Author{}, author.ErrAuthorNotFound } return m.withCount(found), nil } func (m *mockAuthorRepo) GetBySlug(slug string) (author.Author, error) { if err := m.fail.trigger("GetBySlug"); err != nil { return author.Author{}, err } for _, a := range m.authors { if a.Slug == slug { return m.withCount(a), nil } } return author.Author{}, author.ErrAuthorNotFound } func (m *mockAuthorRepo) GetByUserID(userID uint32) (author.Author, error) { if err := m.fail.trigger("GetByUserID"); err != nil { return author.Author{}, err } for _, a := range m.authors { if a.UserID != nil && *a.UserID == userID { return m.withCount(a), nil } } return author.Author{}, author.ErrAuthorNotFound } func (m *mockAuthorRepo) conflictFor(candidate author.Author) error { for _, a := range m.authors { if a.ID == candidate.ID { continue } if a.Slug == candidate.Slug { return author.ErrSlugTaken } if candidate.UserID != nil && a.UserID != nil && *a.UserID == *candidate.UserID { return author.ErrUserAlreadyLinked } } return nil } func (m *mockAuthorRepo) Create(a author.Author) (author.Author, error) { m.createCalls++ if err := m.fail.trigger("Create"); err != nil { return author.Author{}, err } a.ID = 0 if err := m.conflictFor(a); err != nil { return author.Author{}, err } m.nextID++ a.ID = m.nextID a.IsLinked = a.UserID != nil m.authors[a.ID] = a return m.withCount(a), nil } func (m *mockAuthorRepo) Update(a author.Author) (author.Author, error) { if err := m.fail.trigger("Update"); err != nil { return author.Author{}, err } if _, ok := m.authors[a.ID]; !ok { return author.Author{}, author.ErrAuthorNotFound } if err := m.conflictFor(a); err != nil { return author.Author{}, err } a.UserEmail = nil m.authors[a.ID] = a return m.withCount(a), nil } func (m *mockAuthorRepo) DeleteMovingCredits(id uint32, reassignTo *uint32) error { m.deleteCalls = append(m.deleteCalls, authorDeleteCall{id: id, reassignTo: reassignTo}) if err := m.fail.trigger("DeleteMovingCredits"); err != nil { return err } if _, ok := m.authors[id]; !ok { return author.ErrAuthorNotFound } if reassignTo == nil && m.creditCount(id) > 0 { return author.ErrAuthorHasArticles } if reassignTo != nil { if _, ok := m.authors[*reassignTo]; !ok { return author.ErrAuthorNotFound } } m.removeWithCredits(id, reassignTo) return nil } func (m *mockAuthorRepo) DeleteUncrediting(id uint32) error { m.deleteCalls = append(m.deleteCalls, authorDeleteCall{id: id}) if err := m.fail.trigger("DeleteMovingCredits"); err != nil { return err } if _, ok := m.authors[id]; !ok { return author.ErrAuthorNotFound } m.removeWithCredits(id, nil) return nil } func (m *mockAuthorRepo) creditCount(id uint32) int { total := 0 for _, ids := range m.articles.credits { if containsID(ids, id) { total++ } } return total } func (m *mockAuthorRepo) removeWithCredits(id uint32, reassignTo *uint32) { for articleID, ids := range m.articles.credits { m.articles.credits[articleID] = replaceCredit(ids, id, reassignTo) } delete(m.authors, id) } func replaceCredit(ids []uint32, from uint32, to *uint32) []uint32 { result := []uint32{} for _, id := range ids { if id != from { result = append(result, id) continue } if to != nil && !containsID(ids, *to) { result = append(result, *to) } } return result } func containsID(ids []uint32, wanted uint32) bool { for _, id := range ids { if id == wanted { return true } } return false } func (m *mockAuthorRepo) AvatarUsage() (map[uint32]int, error) { usage := map[uint32]int{} for _, a := range m.authors { if a.AvatarID != nil { usage[*a.AvatarID]++ } } return usage, nil } func (m *mockAuthorRepo) ClearAvatar(imageID uint32) ([]uint32, error) { cleared := []uint32{} for _, id := range m.sortedIDsDescending() { a := m.authors[id] if a.AvatarID != nil && *a.AvatarID == imageID { a.AvatarID = nil m.authors[id] = a cleared = append(cleared, id) } } return cleared, nil } func (m *mockAuthorRepo) AssignAvatar(authorIDs []uint32, imageID uint32) error { for _, id := range authorIDs { a := m.authors[id] a.AvatarID = idPointer(imageID) m.authors[id] = a } return nil } func (m *mockAuthorRepo) RepointAvatar(fromImageIDs []uint32, toImageID uint32) (int, error) { changed := 0 for id, a := range m.authors { if a.AvatarID != nil && containsID(fromImageIDs, *a.AvatarID) { a.AvatarID = idPointer(toImageID) m.authors[id] = a changed++ } } return changed, nil } var _ author.Repository = &mockAuthorRepo{} const ( authorEditorID = 5 authorOtherEditorID = 6 authorEditorRole = user.Role("editor") ) type authorEnv struct { *producersEnv authors *mockAuthorRepo } func newAuthorEnv(t *testing.T) *authorEnv { t.Helper() p := newProducersEnv(t) p.users.users[authorEditorID] = user.User{ID: authorEditorID, Username: "edith", DisplayName: "Edith Moreau", Email: "edith@example.com", IsVerified: true, Role: authorEditorRole} p.users.users[authorOtherEditorID] = user.User{ID: authorOtherEditorID, Username: "frank", Email: "frank@example.com", IsVerified: true, Role: authorEditorRole} p.users.nextID = authorOtherEditorID authors := newMockAuthorRepo(p.articles) sites := plainSites() recorder := p.audit.recorder renderer := &fakeBodyRenderer{} mergeMedia := useCases.NewMergeMediaUseCase(p.images, p.articles, newMockImageStorage(), newMockVariantFileRepo(), recorder, sites, authors) c := p.container c.CreateArticleUseCase = useCases.NewCreateArticleUseCase(p.articles, renderer, recorder, sites, authors) c.UpdateArticleUseCase = useCases.NewUpdateArticleUseCase(p.articles, renderer, recorder, sites, authors) c.ListMediaUseCase = useCases.NewListMediaUseCase(p.images, p.articles, p.users, newMockVariantFileRepo(), sites, authors) c.DeleteMediaUseCase = useCases.NewDeleteMediaUseCase(p.images, newMockImageStorage(), newMockVariantFileRepo(), recorder, sites, authors) c.MergeMediaUseCase = mergeMedia c.RunPurgeUseCase = useCases.NewRunPurgeUseCase(p.images, p.articles, mergeMedia, newMockPurgeSettingsRepo(), func() time.Time { return p.now }, log.New(io.Discard, "", 0), recorder, sites, authors) c.ListActivityUseCase = useCases.NewListActivityUseCase(p.audit.store, p.users, p.articles, p.images, p.keys, p.audit.clock, sites, authors) c.ListAuthorsUseCase = useCases.NewListAuthorsUseCase(authors, p.users) c.CreateAuthorUseCase = useCases.NewCreateAuthorUseCase(authors, p.users, p.images, recorder) c.CreateMyAuthorUseCase = useCases.NewCreateMyAuthorUseCase(authors, p.users, recorder) c.UpdateAuthorUseCase = useCases.NewUpdateAuthorUseCase(authors, p.users, p.images, recorder) c.DeleteAuthorUseCase = useCases.NewDeleteAuthorUseCase(authors, recorder) c.GetMyAuthorUseCase = useCases.NewGetMyAuthorUseCase(authors) router := chi.NewRouter() router.Mount("/v1", api.InitBackendRoutes(c, p.settings)) p.router = router return &authorEnv{producersEnv: p, authors: authors} } func (e *authorEnv) owner(t *testing.T, method string, path string, body string) *httptest.ResponseRecorder { t.Helper() return e.as(t, apiKeyOwnerId, method, path, body) } func (e *authorEnv) seedOnlineArticle(id uint32, title string, authorIDs ...uint32) { e.articles.articles[id] = article.Article{ID: id, Title: title, Body: "Body", IsOnline: true, SiteID: 1} e.articles.credit(id, authorIDs...) } func (e *authorEnv) seedOfflineArticle(id uint32, title string, authorIDs ...uint32) { e.articles.articles[id] = article.Article{ID: id, Title: title, Body: "Body", SiteID: 1} e.articles.credit(id, authorIDs...) } func (e *authorEnv) creditsOf(articleID uint32) []uint32 { return append([]uint32{}, e.articles.credits[articleID]...) } func decodeAuthorBody(t *testing.T, rec *httptest.ResponseRecorder) map[string]any { t.Helper() var decoded map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &decoded); err != nil { t.Fatalf("decode %q: %v", rec.Body.String(), err) } return decoded } type authorListBody struct { Items []map[string]any `json:"items"` Total int `json:"total"` Page int `json:"page"` Size int `json:"size"` } func decodeAuthorList(t *testing.T, rec *httptest.ResponseRecorder) authorListBody { t.Helper() var decoded authorListBody if err := json.Unmarshal(rec.Body.Bytes(), &decoded); err != nil { t.Fatalf("decode list %q: %v", rec.Body.String(), err) } return decoded } func listedNames(list authorListBody) []string { names := []string{} for _, item := range list.Items { names = append(names, item["name"].(string)) } return names } func joinNames(names []string) string { return strings.Join(names, "|") } func userWithEmail(id uint32, username string, email string) user.User { return user.User{ID: id, Username: username, Email: email, IsVerified: true, Role: authorEditorRole} } func userNamed(id uint32, username string, displayName string) user.User { return user.User{ID: id, Username: username, DisplayName: displayName, Email: username + "@example.com", IsVerified: true, Role: authorEditorRole} }