feat: Authors feature backend (and upgraded deprecated mods)
This commit is contained in:
1 parent
a11338094e
commit
b34a173d83
103 files changed
+6454
-240
No files matched your search
@@ -0,0 +1,425 @@
|
||||
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}
|
||||
}
|
||||
Reference in new issue
Block a user