Files
RenewCMS/test/article_test.go

479 lines
13 KiB
Go

package test
import (
"RenewCMS/internal/domain/article"
"RenewCMS/internal/domain/author"
"RenewCMS/internal/infrastructure/useCases"
"errors"
"sort"
"strings"
"testing"
"unicode"
"golang.org/x/text/runes"
"golang.org/x/text/transform"
"golang.org/x/text/unicode/norm"
)
type mockArticleRepo struct {
articles map[uint32]article.Article
failUpdateBodyFor map[uint32]error
nextID uint32
fail failSwitch
createCalled bool
credits map[uint32][]uint32
authors *mockAuthorRepo
reportSlugRace bool
createAlwaysRaces bool
}
func newMockArticleRepo() *mockArticleRepo {
return &mockArticleRepo{
articles: make(map[uint32]article.Article),
credits: make(map[uint32][]uint32),
}
}
func (m *mockArticleRepo) withAuthors(a article.Article) article.Article {
ids := m.credits[a.ID]
if len(ids) == 0 || m.authors == nil {
return a
}
bylines := make([]author.Byline, 0, len(ids))
for _, id := range ids {
if found, ok := m.authors.authors[id]; ok {
bylines = append(bylines, found.Byline())
}
}
a.Authors = bylines
return a
}
func (m *mockArticleRepo) credit(articleID uint32, authorIDs ...uint32) {
m.credits[articleID] = append([]uint32{}, authorIDs...)
}
func (m *mockArticleRepo) sortedIds() []uint32 {
ids := make([]uint32, 0, len(m.articles))
for id := range m.articles {
ids = append(ids, id)
}
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
return ids
}
func (m *mockArticleRepo) FindByFilters(filters article.Filters) ([]article.Article, error) {
if err := m.fail.trigger("FindByFilters"); err != nil {
return nil, err
}
result := make([]article.Article, 0)
for _, id := range m.sortedIds() {
a := m.articles[id]
if filters.IsOnline != nil && a.IsOnline != *filters.IsOnline {
continue
}
result = append(result, a)
}
return result, nil
}
func (m *mockArticleRepo) FindPage(filters article.Filters) (article.PageResult, error) {
if err := m.fail.trigger("FindPage"); err != nil {
return article.PageResult{}, err
}
matching := make([]article.Article, 0)
for _, a := range m.articles {
if filters.IsOnline != nil && a.IsOnline != *filters.IsOnline {
continue
}
if filters.SiteID != nil && a.SiteID != *filters.SiteID {
continue
}
if filters.Status == article.StatusOnline && !a.IsOnline {
continue
}
if filters.Status == article.StatusDraft && a.IsOnline {
continue
}
if filters.Q != "" && !strings.Contains(foldArticleSearch(a.Title), foldArticleSearch(filters.Q)) {
continue
}
matching = append(matching, a)
}
sort.Slice(matching, func(i, j int) bool { return articleSortsBefore(matching[i], matching[j], filters.Sort) })
start := min((filters.Page-1)*filters.Size, len(matching))
end := min(start+filters.Size, len(matching))
if filters.Unpaged {
start, end = 0, len(matching)
}
items := make([]article.Article, 0)
for _, a := range matching[start:end] {
items = append(items, m.withAuthors(a))
}
return article.PageResult{Items: items, Total: len(matching)}, nil
}
func (m *mockArticleRepo) CountByVisibility(onlineOnly bool) (article.Counts, error) {
if err := m.fail.trigger("CountByVisibility"); err != nil {
return article.Counts{}, err
}
counts := article.Counts{}
for _, a := range m.articles {
if a.IsOnline {
counts.Online++
} else if !onlineOnly {
counts.Draft++
}
}
counts.All = counts.Online + counts.Draft
return counts, nil
}
func articleSortsBefore(left, right article.Article, order article.Sort) bool {
if order.Field == "" {
order = article.DefaultSort()
}
var comparison int
switch order.Field {
case article.SortByTitle:
comparison = strings.Compare(strings.ToLower(left.Title), strings.ToLower(right.Title))
case article.SortByStatus:
if left.IsOnline != right.IsOnline {
comparison = 1
if !left.IsOnline {
comparison = -1
}
}
case article.SortByCreatedAt:
comparison = left.CreatedAt.Compare(right.CreatedAt)
case article.SortByID:
comparison = int(left.ID) - int(right.ID)
default:
comparison = left.UpdatedAt.Compare(right.UpdatedAt)
}
if order.Direction == article.SortDesc {
comparison = -comparison
}
if comparison != 0 {
return comparison < 0
}
return left.ID > right.ID
}
func foldArticleSearch(value string) string {
folded, _, err := transform.String(transform.Chain(norm.NFD, runes.Remove(runes.In(unicode.Mn)), norm.NFC), value)
if err != nil {
return strings.ToLower(value)
}
return strings.ToLower(folded)
}
func (m *mockArticleRepo) Get(id uint32) (article.Article, error) {
if err := m.fail.trigger("Get"); err != nil {
return article.Article{}, err
}
a, ok := m.articles[id]
if !ok {
return article.Article{}, article.ErrArticleNotFound
}
return m.withAuthors(a), nil
}
func (m *mockArticleRepo) GetByName(name string) (article.Article, error) {
if err := m.fail.trigger("GetByName"); err != nil {
return article.Article{}, err
}
for _, id := range m.sortedIds() {
a := m.articles[id]
if a.Title == name {
return a, nil
}
}
return article.Article{}, article.ErrArticleNotFound
}
func (m *mockArticleRepo) GetAll() []article.Article {
result := make([]article.Article, 0)
for _, id := range m.sortedIds() {
result = append(result, m.articles[id])
}
return result
}
func (m *mockArticleRepo) slugUsedOn(siteID uint32, slug string, exceptID uint32) bool {
if slug == "" {
return false
}
for id, other := range m.articles {
if id != exceptID && other.SiteID == siteID && other.Slug == slug {
return true
}
}
return false
}
func (m *mockArticleRepo) SlugTaken(siteID uint32, slug string, exceptID uint32) (bool, error) {
if err := m.fail.trigger("SlugTaken"); err != nil {
return false, err
}
return m.slugUsedOn(siteID, slug, exceptID), nil
}
func (m *mockArticleRepo) FindByImage(imageID uint32) ([]article.Article, error) {
if err := m.fail.trigger("FindByImage"); err != nil {
return nil, err
}
result := make([]article.Article, 0)
for _, id := range m.sortedIds() {
if a := m.articles[id]; a.UsesImage(imageID) {
result = append(result, a)
}
}
return result, nil
}
func (m *mockArticleRepo) ClearImage(imageID uint32) (article.ImageReferences, error) {
if err := m.fail.trigger("ClearImage"); err != nil {
return article.ImageReferences{}, err
}
references := article.ImageReferences{Cover: []uint32{}, Social: []uint32{}}
for _, id := range m.sortedIds() {
a := m.articles[id]
if a.CoverImageID != nil && *a.CoverImageID == imageID {
a.CoverImageID = nil
references.Cover = append(references.Cover, id)
}
if a.SocialImageID != nil && *a.SocialImageID == imageID {
a.SocialImageID = nil
references.Social = append(references.Social, id)
}
m.articles[id] = a
}
return references, nil
}
func (m *mockArticleRepo) RestoreImage(references article.ImageReferences, imageID uint32) error {
for _, id := range references.Cover {
a := m.articles[id]
a.CoverImageID = idPointer(imageID)
m.articles[id] = a
}
for _, id := range references.Social {
a := m.articles[id]
a.SocialImageID = idPointer(imageID)
m.articles[id] = a
}
return nil
}
func (m *mockArticleRepo) RepointImage(fromImageIDs []uint32, toImageID uint32) ([]uint32, error) {
if err := m.fail.trigger("RepointImage"); err != nil {
return nil, err
}
changed := []uint32{}
for _, id := range m.sortedIds() {
a := m.articles[id]
touched := false
if a.CoverImageID != nil && containsID(fromImageIDs, *a.CoverImageID) {
a.CoverImageID = idPointer(toImageID)
touched = true
}
if a.SocialImageID != nil && containsID(fromImageIDs, *a.SocialImageID) {
a.SocialImageID = idPointer(toImageID)
touched = true
}
if touched {
m.articles[id] = a
changed = append(changed, id)
}
}
return changed, nil
}
func (m *mockArticleRepo) Create(a article.Article) (article.Article, error) {
m.createCalled = true
if err := m.fail.trigger("Create"); err != nil {
return article.Article{}, err
}
if m.createAlwaysRaces {
return article.Article{}, article.ErrSlugTaken
}
if m.slugUsedOn(a.SiteID, a.Slug, 0) {
return article.Article{}, article.ErrSlugTaken
}
m.nextID++
a.ID = m.nextID
m.articles[a.ID] = a
if len(a.Authors) > 0 {
ids := make([]uint32, 0, len(a.Authors))
for _, byline := range a.Authors {
ids = append(ids, byline.ID)
}
m.credits[a.ID] = ids
}
return m.withAuthors(a), nil
}
func (m *mockArticleRepo) UpdateBody(id uint32, body string) (article.Article, error) {
if err := m.fail.trigger("UpdateBody"); err != nil {
return article.Article{}, err
}
if err, ok := m.failUpdateBodyFor[id]; ok {
return article.Article{}, err
}
a, ok := m.articles[id]
if !ok {
return article.Article{}, article.ErrArticleNotFound
}
a.Body = body
m.articles[id] = a
return m.withAuthors(a), nil
}
func (m *mockArticleRepo) Update(id uint32, update article.Update) (article.Article, error) {
if err := m.fail.trigger("Update"); err != nil {
return article.Article{}, err
}
a, ok := m.articles[id]
if !ok {
return article.Article{}, article.ErrArticleNotFound
}
if update.Body != nil {
if err, failing := m.failUpdateBodyFor[id]; failing {
return article.Article{}, err
}
a.Body = *update.Body
}
if update.SiteID != nil {
a.SiteID = *update.SiteID
}
if update.Slug != nil {
a.Slug = *update.Slug
}
if m.reportSlugRace && (update.Slug != nil || update.SiteID != nil) {
return article.Article{}, article.ErrSlugTaken
}
if m.slugUsedOn(a.SiteID, a.Slug, id) {
return article.Article{}, article.ErrSlugTaken
}
applySeoUpdate(&a, update)
if update.AuthorIDs != nil {
m.credits[id] = append([]uint32{}, *update.AuthorIDs...)
}
m.articles[id] = a
return m.withAuthors(a), nil
}
func applySeoUpdate(a *article.Article, update article.Update) {
assign := func(target *string, value *string) {
if value != nil {
*target = *value
}
}
assign(&a.Excerpt, update.Excerpt)
assign(&a.SEOTitle, update.SEOTitle)
assign(&a.SEODescription, update.SEODescription)
assign(&a.CoverImageAlt, update.CoverImageAlt)
assign(&a.SocialTitle, update.SocialTitle)
assign(&a.SocialDescription, update.SocialDescription)
assign(&a.CanonicalURL, update.CanonicalURL)
if update.CoverImageID.Set {
a.CoverImageID = update.CoverImageID.Value
}
if update.SocialImageID.Set {
a.SocialImageID = update.SocialImageID.Value
}
if update.Noindex != nil {
a.Noindex = *update.Noindex
}
if update.SEODismissed != nil {
a.SEODismissed = append([]string{}, *update.SEODismissed...)
}
}
func (m *mockArticleRepo) UpdateIsOnline(id uint32, isOnline bool) (article.Article, error) {
if err := m.fail.trigger("UpdateIsOnline"); err != nil {
return article.Article{}, err
}
a, ok := m.articles[id]
if !ok {
return article.Article{}, article.ErrArticleNotFound
}
a.IsOnline = isOnline
m.articles[id] = a
return m.withAuthors(a), nil
}
func (m *mockArticleRepo) Delete(id uint32) error {
if err := m.fail.trigger("Delete"); err != nil {
return err
}
if _, ok := m.articles[id]; !ok {
return article.ErrArticleNotFound
}
delete(m.articles, id)
delete(m.credits, id)
return nil
}
func (m *mockArticleRepo) FindByBodyContaining(filename string) ([]article.Article, error) {
if err := m.fail.trigger("FindByBodyContaining"); err != nil {
return nil, err
}
result := make([]article.Article, 0)
for _, id := range m.sortedIds() {
a := m.articles[id]
if strings.Contains(a.Body, filename) {
result = append(result, a)
}
}
return result, nil
}
var _ article.Repository = &mockArticleRepo{}
func TestCreateArticle(t *testing.T) {
repo := newMockArticleRepo()
uc := useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors())
t.Run("Valid Title", func(t *testing.T) {
cmd := useCases.CreateArticleCommand{Title: "Valid Title", Body: "Content"}
res, err := uc.CreateArticle(testCtx, cmd)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if res.Title != cmd.Title {
t.Errorf("Expected %s, got %s", cmd.Title, res.Title)
}
})
t.Run("Short Title", func(t *testing.T) {
cmd := useCases.CreateArticleCommand{Title: "Ab", Body: "Content"}
_, err := uc.CreateArticle(testCtx, cmd)
if !errors.Is(err, article.ErrInvalidTitle) {
t.Errorf("Expected ErrInvalidTitle")
}
})
}
func TestGetOnlineArticle(t *testing.T) {
repo := newMockArticleRepo()
repo.articles[1] = article.Article{ID: 1, IsOnline: true}
repo.articles[2] = article.Article{ID: 2, IsOnline: false}
uc := useCases.NewGetArticleUseCase(repo, &fakeBodyRenderer{})
t.Run("Article Online", func(t *testing.T) {
_, err := uc.GetOnlineArticle(1, article.BodyFormatHTML)
if err != nil {
t.Errorf("Expected article to be found")
}
})
t.Run("Article Offline", func(t *testing.T) {
_, err := uc.GetOnlineArticle(2, article.BodyFormatHTML)
if !errors.Is(err, article.ErrArticleNotFound) {
t.Errorf("Offline article should return not found")
}
})
}