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") } }) }