426 lines
12 KiB
Go
426 lines
12 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) 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}
|
|
}
|