Files
RenewCMS/test/author_support_test.go
T

443 lines
13 KiB
Go

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) FindByAvatar(imageID uint32) ([]author.Author, error) {
found := []author.Author{}
for _, id := range m.sortedIDsAscending() {
if a := m.authors[id]; a.AvatarID != nil && *a.AvatarID == imageID {
found = append(found, a)
}
}
return found, nil
}
func (m *mockAuthorRepo) sortedIDsAscending() []uint32 {
ids := m.sortedIDsDescending()
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
return ids
}
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, p.images)
c.ListMediaUseCase = useCases.NewListMediaUseCase(p.images, p.articles, p.users, newMockVariantFileRepo(), sites, authors)
c.DeleteMediaUseCase = useCases.NewDeleteMediaUseCase(p.images, newMockImageStorage(), newMockVariantFileRepo(), recorder, sites, authors, p.articles)
c.MergeMediaUseCase = mergeMedia
c.GetMediaUsageUseCase = useCases.NewGetMediaUsageUseCase(p.images, p.articles, sites, authors)
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}
}