package test import ( domainArticle "RenewCMS/internal/domain/article" "encoding/json" "fmt" "net/http" "net/http/httptest" "net/url" "strings" "testing" "time" persistence "RenewCMS/internal/infrastructure/persistence/repositories" "RenewCMS/internal/infrastructure/useCases" "errors" "github.com/go-chi/jwtauth/v5" validator "RenewCMS/api/pkg/validator" ) const paginationBodyMismatch = "The request cannot be processed due to a mismatch in the format of the body." type paginationEnvelope struct { Items []map[string]any `json:"items"` Total int `json:"total"` Page int `json:"page"` Size int `json:"size"` Counts map[string]int `json:"counts"` } func articleItemsJSON(body []byte) []byte { var envelope struct { Items json.RawMessage `json:"items"` } if err := json.Unmarshal(body, &envelope); err != nil || envelope.Items == nil { return body } return envelope.Items } func seedPaginationArticles(repo *mockArticleRepo, online int, draft int) { base := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC) add := func(isOnline bool, title string) { id := uint32(len(repo.articles) + 1) repo.articles[id] = domainArticle.Article{ID: id, Title: title, IsOnline: isOnline, UpdatedAt: base.Add(time.Duration(id) * time.Hour)} } for i := 0; i < online; i++ { add(true, fmt.Sprintf("Online article %d", i)) } for i := 0; i < draft; i++ { add(false, fmt.Sprintf("Draft article %d", i)) } } func seedSortArticles(repo *mockArticleRepo) { day := func(month, d int) time.Time { return time.Date(2026, time.Month(month), d, 0, 0, 0, 0, time.UTC) } repo.articles[1] = domainArticle.Article{ID: 1, Title: "Banana", IsOnline: true, CreatedAt: day(1, 1), UpdatedAt: day(9, 3)} repo.articles[2] = domainArticle.Article{ID: 2, Title: "apple", IsOnline: false, CreatedAt: day(1, 3), UpdatedAt: day(9, 1)} repo.articles[3] = domainArticle.Article{ID: 3, Title: "Cherry", IsOnline: true, CreatedAt: day(1, 2), UpdatedAt: day(9, 2)} } func itemIds(envelope paginationEnvelope) []int { ids := []int{} for _, item := range envelope.Items { ids = append(ids, int(item["id"].(float64))) } return ids } func TestArticlesPaginationApiHttp(t *testing.T) { validator.InitValidator() token := jwtauth.New("HS256", []byte("test-secret"), nil) get := func(t *testing.T, repo *mockArticleRepo, query string, verified bool) (*httptest.ResponseRecorder, paginationEnvelope) { t.Helper() router := newArticlesListRouter(repo, token) req := httptest.NewRequest(http.MethodGet, "/v1/article/"+query, nil) if verified { setJwtCookie(t, req, token, 1) } rec := httptest.NewRecorder() router.ServeHTTP(rec, req) var envelope paginationEnvelope if rec.Code == http.StatusOK { if err := json.Unmarshal(rec.Body.Bytes(), &envelope); err != nil { t.Fatalf("failed to decode envelope: %v", err) } if envelope.Items == nil { t.Fatalf("items must be [] rather than null: %s", rec.Body.String()) } } return rec, envelope } assertCounts := func(t *testing.T, envelope paginationEnvelope, all, online, draft int) { t.Helper() if envelope.Counts["all"] != all || envelope.Counts["online"] != online || envelope.Counts["draft"] != draft { t.Fatalf("unexpected counts %v", envelope.Counts) } } t.Run("A bare request answers the envelope with the defaults", func(t *testing.T) { repo := newMockArticleRepo() seedPaginationArticles(repo, 30, 0) rec, envelope := get(t, repo, "", false) if rec.Code != http.StatusOK || envelope.Page != 1 || envelope.Size != 25 || envelope.Total != 30 || len(envelope.Items) != 25 { t.Fatalf("unexpected response %d %+v", rec.Code, envelope) } assertCounts(t, envelope, 30, 30, 0) }) t.Run("Defaults when only one parameter is given", func(t *testing.T) { cases := []struct { query string page, size, itemCount int }{ {"?page=2", 2, 25, 25}, {"?page=3", 3, 25, 10}, {"?size=5", 1, 5, 5}, {"?size=50", 1, 50, 50}, } for _, c := range cases { repo := newMockArticleRepo() seedPaginationArticles(repo, 60, 0) _, envelope := get(t, repo, c.query, false) if envelope.Page != c.page || envelope.Size != c.size || envelope.Total != 60 || len(envelope.Items) != c.itemCount { t.Fatalf("%s: unexpected %+v", c.query, envelope) } } }) t.Run("Items are ordered by last update, newest first, then id descending", func(t *testing.T) { repo := newMockArticleRepo() sept1 := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC) sept3 := time.Date(2026, 9, 3, 0, 0, 0, 0, time.UTC) repo.articles[1] = domainArticle.Article{ID: 1, Title: "First article", IsOnline: true, UpdatedAt: sept1} repo.articles[2] = domainArticle.Article{ID: 2, Title: "Second article", IsOnline: true, UpdatedAt: sept3} repo.articles[3] = domainArticle.Article{ID: 3, Title: "Third article", IsOnline: true, UpdatedAt: sept3} _, envelope := get(t, repo, "?size=5", false) ids := itemIds(envelope) if len(ids) != 3 || ids[0] != 3 || ids[1] != 2 || ids[2] != 1 { t.Fatalf("unexpected order %v", ids) } }) t.Run("Pages partition the list with no overlap", func(t *testing.T) { repo := newMockArticleRepo() seedPaginationArticles(repo, 12, 0) seen := map[int]bool{} for page, want := range map[int]int{1: 5, 2: 5, 3: 2} { _, envelope := get(t, repo, fmt.Sprintf("?page=%d&size=5", page), false) if len(envelope.Items) != want || envelope.Total != 12 { t.Fatalf("page %d unexpected %+v", page, envelope) } for _, id := range itemIds(envelope) { if seen[id] { t.Fatalf("id %d repeated", id) } seen[id] = true } } }) t.Run("A public caller's total and counts cover online articles only", func(t *testing.T) { repo := newMockArticleRepo() seedPaginationArticles(repo, 5, 3) _, envelope := get(t, repo, "?size=5", false) if envelope.Total != 5 { t.Fatalf("unexpected total %d", envelope.Total) } assertCounts(t, envelope, 5, 5, 0) for _, item := range envelope.Items { if item["is_online"] == false { t.Fatalf("offline item leaked") } } }) t.Run("A verified caller sees offline articles in the page, the total and the counts", func(t *testing.T) { repo := newMockArticleRepo() seedPaginationArticles(repo, 5, 3) _, envelope := get(t, repo, "?size=5", true) if envelope.Total != 8 { t.Fatalf("unexpected total %d", envelope.Total) } assertCounts(t, envelope, 8, 5, 3) }) t.Run("An unverified or invalid session is treated as public", func(t *testing.T) { repo := newMockArticleRepo() seedPaginationArticles(repo, 5, 3) for _, cookie := range []string{"not-a-jwt", ""} { router := newArticlesListRouter(repo, token) req := httptest.NewRequest(http.MethodGet, "/v1/article/?size=5", nil) if cookie != "" { req.AddCookie(&http.Cookie{Name: "jwt", Value: cookie}) } else { setJwtCookie(t, req, token, 99) } rec := httptest.NewRecorder() router.ServeHTTP(rec, req) var envelope paginationEnvelope if err := json.Unmarshal(rec.Body.Bytes(), &envelope); err != nil || envelope.Total != 5 { t.Fatalf("expected public total 5, got %s", rec.Body.String()) } } }) t.Run("A page beyond the last is empty with the real total", func(t *testing.T) { repo := newMockArticleRepo() seedPaginationArticles(repo, 12, 0) rec, envelope := get(t, repo, "?page=99&size=5", false) if rec.Code != http.StatusOK || len(envelope.Items) != 0 || envelope.Total != 12 || envelope.Page != 99 || envelope.Size != 5 || envelope.Counts["all"] != 12 { t.Fatalf("unexpected %d %+v", rec.Code, envelope) } }) t.Run("No articles at all", func(t *testing.T) { rec, envelope := get(t, newMockArticleRepo(), "", false) if rec.Code != http.StatusOK || len(envelope.Items) != 0 || envelope.Total != 0 { t.Fatalf("unexpected %d %+v", rec.Code, envelope) } assertCounts(t, envelope, 0, 0, 0) }) t.Run("Status filter", func(t *testing.T) { for status, total := range map[string]int{"online": 4, "draft": 2} { repo := newMockArticleRepo() seedPaginationArticles(repo, 4, 2) _, envelope := get(t, repo, "?status="+status, true) if envelope.Total != total || len(envelope.Items) != total { t.Fatalf("%s: unexpected %+v", status, envelope) } assertCounts(t, envelope, 6, 4, 2) for _, item := range envelope.Items { if item["is_online"] != (status == "online") { t.Fatalf("%s: item does not match", status) } } } }) t.Run("A public caller asking for drafts gets nothing", func(t *testing.T) { repo := newMockArticleRepo() seedPaginationArticles(repo, 4, 2) _, envelope := get(t, repo, "?status=draft", false) if len(envelope.Items) != 0 || envelope.Total != 0 { t.Fatalf("unexpected %+v", envelope) } assertCounts(t, envelope, 4, 4, 0) }) t.Run("Search narrows the page and the total but not the counts", func(t *testing.T) { repo := newMockArticleRepo() repo.articles[1] = domainArticle.Article{ID: 1, Title: "Choosing lime plaster for old stone walls", IsOnline: true} repo.articles[2] = domainArticle.Article{ID: 2, Title: "How we price a bathroom renovation", IsOnline: true} _, envelope := get(t, repo, "?q="+url.QueryEscape("PLÂSTER"), false) ids := itemIds(envelope) if envelope.Total != 1 || len(ids) != 1 || ids[0] != 1 { t.Fatalf("unexpected %+v", envelope) } assertCounts(t, envelope, 2, 2, 0) }) t.Run("Search and status combine with paging", func(t *testing.T) { repo := newMockArticleRepo() for i := 0; i < 7; i++ { id := uint32(i + 1) repo.articles[id] = domainArticle.Article{ID: id, Title: fmt.Sprintf("Tile guide %d", i), IsOnline: false} } for i := 0; i < 3; i++ { id := uint32(i + 8) repo.articles[id] = domainArticle.Article{ID: id, Title: fmt.Sprintf("Tile story %d", i), IsOnline: true} } _, envelope := get(t, repo, "?q=tile&status=draft&size=5&page=2", true) if envelope.Total != 7 || len(envelope.Items) != 2 || envelope.Counts["draft"] != 7 { t.Fatalf("unexpected %+v", envelope) } }) t.Run("Search that matches nothing", func(t *testing.T) { repo := newMockArticleRepo() seedPaginationArticles(repo, 3, 0) _, envelope := get(t, repo, "?q=zzzz", false) if len(envelope.Items) != 0 || envelope.Total != 0 { t.Fatalf("unexpected %+v", envelope) } assertCounts(t, envelope, 3, 3, 0) }) t.Run("Invalid parameters answer 400 like Activity", func(t *testing.T) { queries := []string{ "page=0", "page=-1", "page=abc", "page=", "page=1.5", "size=0", "size=10", "size=200", "size=abc", "status=archived", "status=", "q=" + strings.Repeat("a", 201), "page=0&size=5", "page=1&size=1000", } for _, query := range queries { repo := newMockArticleRepo() seedPaginationArticles(repo, 3, 0) repo.fail.failNext("FindPage") rec, _ := get(t, repo, "?"+query, false) if rec.Code != http.StatusBadRequest || strings.TrimSpace(rec.Body.String()) != paginationBodyMismatch { t.Fatalf("%q: unexpected %d %q", query, rec.Code, rec.Body.String()) } if err := repo.fail.trigger("FindPage"); err == nil { t.Fatalf("%q: the repository was called", query) } } }) t.Run("Invalid format is reported first", func(t *testing.T) { rec, _ := get(t, newMockArticleRepo(), "?page=0&format=pdf", false) if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "The format must be one of: markdown, html, json.") { t.Fatalf("unexpected %d %q", rec.Code, rec.Body.String()) } }) t.Run("format and paging combine", func(t *testing.T) { repo := newMockArticleRepo() seedPaginationArticles(repo, 3, 0) _, envelope := get(t, repo, "?size=5&format=html", false) if envelope.Total != 3 || len(envelope.Items) != 3 { t.Fatalf("unexpected %+v", envelope) } for _, item := range envelope.Items { if item["body_format"] != "html" { t.Fatalf("unexpected body_format %v", item["body_format"]) } } }) t.Run("Repository failure answers 500", func(t *testing.T) { repo := newMockArticleRepo() repo.fail.failNext("FindPage") rec, _ := get(t, repo, "", false) if rec.Code != http.StatusInternalServerError || strings.TrimSpace(rec.Body.String()) != "Failed to retrieve articles" { t.Fatalf("unexpected %d %q", rec.Code, rec.Body.String()) } }) t.Run("Sort orders the whole list, not just the page", func(t *testing.T) { cases := map[string][]int{ "updated_at:desc": {1, 3, 2}, "updated_at:asc": {2, 3, 1}, "created_at:desc": {2, 3, 1}, "created_at:asc": {1, 3, 2}, "title:asc": {2, 1, 3}, "title:desc": {3, 1, 2}, "status:asc": {2, 3, 1}, "status:desc": {3, 1, 2}, } for sortValue, want := range cases { repo := newMockArticleRepo() seedSortArticles(repo) _, envelope := get(t, repo, "?sort="+url.QueryEscape(sortValue), true) if got := itemIds(envelope); fmt.Sprint(got) != fmt.Sprint(want) { t.Fatalf("%s: got %v want %v", sortValue, got, want) } } }) t.Run("No sort parameter keeps the default order", func(t *testing.T) { repo := newMockArticleRepo() seedSortArticles(repo) _, envelope := get(t, repo, "", true) if got := itemIds(envelope); fmt.Sprint(got) != "[1 3 2]" { t.Fatalf("unexpected order %v", got) } }) t.Run("Equal sort keys fall back to id descending", func(t *testing.T) { repo := newMockArticleRepo() for id := uint32(1); id <= 3; id++ { repo.articles[id] = domainArticle.Article{ID: id, Title: "Same", IsOnline: true} } _, envelope := get(t, repo, "?sort=title:asc", false) if got := itemIds(envelope); fmt.Sprint(got) != "[3 2 1]" { t.Fatalf("unexpected order %v", got) } }) t.Run("Sort is applied before paging", func(t *testing.T) { repo := newMockArticleRepo() for id := uint32(1); id <= 12; id++ { repo.articles[id] = domainArticle.Article{ID: id, Title: fmt.Sprintf("Title %02d", id), IsOnline: true} } want := map[int]string{1: "[12 11 10 9 8]", 2: "[7 6 5 4 3]", 3: "[2 1]"} for page, ids := range want { _, envelope := get(t, repo, fmt.Sprintf("?sort=title:desc&size=5&page=%d", page), false) if got := fmt.Sprint(itemIds(envelope)); got != ids { t.Fatalf("page %d: got %s want %s", page, got, ids) } } }) t.Run("Sort combines with search and status", func(t *testing.T) { repo := newMockArticleRepo() repo.articles[1] = domainArticle.Article{ID: 1, Title: "Tile A", IsOnline: false} repo.articles[2] = domainArticle.Article{ID: 2, Title: "Tile C", IsOnline: false} repo.articles[3] = domainArticle.Article{ID: 3, Title: "Tile B", IsOnline: false} repo.articles[4] = domainArticle.Article{ID: 4, Title: "Tile D", IsOnline: true} _, envelope := get(t, repo, "?q=tile&status=draft&sort=title:asc", true) if got := fmt.Sprint(itemIds(envelope)); got != "[1 3 2]" || envelope.Total != 3 { t.Fatalf("unexpected %s %+v", got, envelope) } assertCounts(t, envelope, 4, 1, 3) }) t.Run("Invalid sort answers 400", func(t *testing.T) { for _, value := range []string{"title", "title:up", "body:asc", "", "updated_at", "title:asc;DROP TABLE", "TITLE:ASC"} { repo := newMockArticleRepo() repo.fail.failNext("FindPage") rec, _ := get(t, repo, "?sort="+url.QueryEscape(value), false) if rec.Code != http.StatusBadRequest || strings.TrimSpace(rec.Body.String()) != paginationBodyMismatch { t.Fatalf("%q: unexpected %d %q", value, rec.Code, rec.Body.String()) } } }) t.Run("The SQLite repository pages, counts, searches and sorts", func(t *testing.T) { db := openActivityDatabase(t) repo := persistence.NewArticleRepository(db) titles := []struct { title string online bool }{{"Banana", true}, {"apple", false}, {"Cherry", true}, {"Plâtre fin", true}} for _, entry := range titles { if _, err := repo.Create(domainArticle.Article{Title: entry.title, IsOnline: entry.online, Slug: uniqueTestSlug()}); err != nil { t.Fatalf("seed failed: %v", err) } } if _, err := repo.UpdateIsOnline(2, false); err != nil { t.Fatalf("seed failed: %v", err) } idsOf := func(page domainArticle.PageResult) string { ids := []uint32{} for _, item := range page.Items { ids = append(ids, item.ID) } return fmt.Sprint(ids) } sorted, err := repo.FindPage(domainArticle.Filters{Page: 1, Size: 5, Sort: domainArticle.Sort{Field: domainArticle.SortByTitle, Direction: domainArticle.SortAsc}}) if err != nil || idsOf(sorted) != "[2 1 3 4]" || sorted.Total != 4 { t.Fatalf("title asc: %v %v %+v", err, idsOf(sorted), sorted) } byStatus, err := repo.FindPage(domainArticle.Filters{Page: 1, Size: 5, Sort: domainArticle.Sort{Field: domainArticle.SortByStatus, Direction: domainArticle.SortAsc}}) if err != nil || idsOf(byStatus) != "[2 4 3 1]" { t.Fatalf("status asc: %v %v", err, idsOf(byStatus)) } paged, err := repo.FindPage(domainArticle.Filters{Page: 2, Size: 2, Sort: domainArticle.Sort{Field: domainArticle.SortByTitle, Direction: domainArticle.SortDesc}}) if err != nil || idsOf(paged) != "[1 2]" || paged.Total != 4 { t.Fatalf("paged: %v %v %+v", err, idsOf(paged), paged) } searched, err := repo.FindPage(domainArticle.Filters{Page: 1, Size: 5, Q: "PLATRE", Sort: domainArticle.Sort{Field: domainArticle.SortByTitle, Direction: domainArticle.SortAsc}}) if err != nil || idsOf(searched) != "[4]" || searched.Total != 1 { t.Fatalf("search: %v %v %+v", err, idsOf(searched), searched) } multi, err := repo.FindPage(domainArticle.Filters{Page: 1, Size: 5, Q: "a", Sort: domainArticle.Sort{Field: domainArticle.SortByTitle, Direction: domainArticle.SortDesc}}) if err != nil || idsOf(multi) != "[4 1 2]" { t.Fatalf("search sort: %v %v", err, idsOf(multi)) } online := true publicDraft, err := repo.FindPage(domainArticle.Filters{Page: 1, Size: 5, IsOnline: &online, Status: domainArticle.StatusDraft}) if err != nil || publicDraft.Total != 0 { t.Fatalf("draft: %v %+v", err, publicDraft) } counts, err := repo.CountByVisibility(false) if err != nil || counts != (domainArticle.Counts{All: 4, Online: 3, Draft: 1}) { t.Fatalf("counts: %v %+v", err, counts) } publicCounts, err := repo.CountByVisibility(true) if err != nil || publicCounts != (domainArticle.Counts{All: 3, Online: 3, Draft: 0}) { t.Fatalf("public counts: %v %+v", err, publicCounts) } }) t.Run("The SQLite repository pages search results and filters by visibility", func(t *testing.T) { db := openActivityDatabase(t) repo := persistence.NewArticleRepository(db) for _, entry := range []struct { title string online bool }{{"Alpha", true}, {"Bravo", false}, {"Charlie", true}, {"Delta", false}} { if _, err := repo.Create(domainArticle.Article{Title: entry.title, IsOnline: entry.online, Slug: uniqueTestSlug()}); err != nil { t.Fatalf("seed failed: %v", err) } } if _, err := repo.UpdateIsOnline(2, false); err != nil { t.Fatalf("seed failed: %v", err) } titleAsc := domainArticle.Sort{Field: domainArticle.SortByTitle, Direction: domainArticle.SortAsc} second, err := repo.FindPage(domainArticle.Filters{Page: 2, Size: 2, Q: "a", Sort: titleAsc}) if err != nil || second.Total != 4 || len(second.Items) != 2 || second.Items[0].Title != "Charlie" || second.Items[1].Title != "Delta" { t.Fatalf("search page 2: %v %+v", err, second) } beyond, err := repo.FindPage(domainArticle.Filters{Page: 9, Size: 2, Q: "a", Sort: titleAsc}) if err != nil || beyond.Total != 4 || len(beyond.Items) != 0 { t.Fatalf("search beyond: %v %+v", err, beyond) } online := true onlyOnline, err := repo.FindByFilters(domainArticle.Filters{IsOnline: &online}) if err != nil || len(onlyOnline) != 2 { t.Fatalf("online filter: %v %d", err, len(onlyOnline)) } everything, err := repo.FindByFilters(domainArticle.Filters{}) if err != nil || len(everything) != 4 { t.Fatalf("no filter: %v %d", err, len(everything)) } drafts, err := repo.FindPage(domainArticle.Filters{Page: 1, Size: 5, Status: domainArticle.StatusDraft, Sort: titleAsc}) if err != nil || drafts.Total != 2 || len(drafts.Items) != 2 { t.Fatalf("drafts: %v %+v", err, drafts) } onlineStatus, err := repo.FindPage(domainArticle.Filters{Page: 1, Size: 5, Status: domainArticle.StatusOnline, Sort: titleAsc}) if err != nil || onlineStatus.Total != 2 { t.Fatalf("online status: %v %+v", err, onlineStatus) } }) t.Run("The SQLite repository surfaces database failures", func(t *testing.T) { db := openActivityDatabase(t) repo := persistence.NewArticleRepository(db) sqlDb, err := db.DB() if err != nil { t.Fatalf("sql handle: %v", err) } if err := sqlDb.Close(); err != nil { t.Fatalf("close: %v", err) } sort := domainArticle.Sort{Field: domainArticle.SortByTitle, Direction: domainArticle.SortAsc} if _, err := repo.FindPage(domainArticle.Filters{Page: 1, Size: 5, Sort: sort}); err == nil { t.Fatal("plain page should fail") } if _, err := repo.FindPage(domainArticle.Filters{Page: 1, Size: 5, Q: "a", Sort: sort}); err == nil { t.Fatal("search page should fail") } if _, err := repo.CountByVisibility(false); err == nil { t.Fatal("counts should fail") } if _, err := repo.CountByVisibility(true); err == nil { t.Fatal("public counts should fail") } if _, err := repo.FindByFilters(domainArticle.Filters{}); err == nil { t.Fatal("find by filters should fail") } }) t.Run("The SQLite repository clamps invalid paging, trims the search and skips vanished rows", func(t *testing.T) { db := openActivityDatabase(t) repo := persistence.NewArticleRepository(db) for _, title := range []string{"Alpha", "Beta", "Gamma"} { if _, err := repo.Create(domainArticle.Article{Title: title, IsOnline: true, Slug: uniqueTestSlug()}); err != nil { t.Fatalf("seed failed: %v", err) } } titleAsc := domainArticle.Sort{Field: domainArticle.SortByTitle, Direction: domainArticle.SortAsc} for _, page := range []int{0, -3} { searched, err := repo.FindPage(domainArticle.Filters{Page: page, Size: 2, Q: "a", Sort: titleAsc}) if err != nil || searched.Total != 3 || len(searched.Items) != 2 || searched.Items[0].Title != "Alpha" { t.Fatalf("search page %d: %v %+v", page, err, searched) } plain, err := repo.FindPage(domainArticle.Filters{Page: page, Size: 2, Sort: titleAsc}) if err != nil || plain.Total != 3 || len(plain.Items) != 2 || plain.Items[0].Title != "Alpha" { t.Fatalf("plain page %d: %v %+v", page, err, plain) } } trimmed, err := repo.FindPage(domainArticle.Filters{Page: 1, Size: 5, Q: " mm ", Sort: titleAsc}) if err != nil || trimmed.Total != 1 || trimmed.Items[0].Title != "Gamma" { t.Fatalf("trimmed search: %v %+v", err, trimmed) } spaced, err := repo.FindPage(domainArticle.Filters{Page: 1, Size: 5, Q: " a", Sort: titleAsc}) if err != nil || spaced.Total != 3 { t.Fatalf("leading space search: %v %+v", err, spaced) } }) } func TestArticlesPaginationUseCase(t *testing.T) { t.Run("The typed sort reaches the repository and an absent sort keeps updated_at descending", func(t *testing.T) { repo := newMockArticleRepo() seedSortArticles(repo) uc := useCases.NewListArticlesUseCase(repo, &fakeBodyRenderer{}, plainSites()) sorted, err := uc.ListArticlesPage(useCases.ListArticlesCommand{Format: domainArticle.BodyFormatHTML, Verified: true, Page: 1, Size: 25, Sort: domainArticle.Sort{Field: domainArticle.SortByTitle, Direction: domainArticle.SortAsc}}) if err != nil || sorted.Total != 3 || sorted.Counts.All != 3 { t.Fatalf("unexpected %v %+v", err, sorted) } if sorted.Items[0].ID != 2 || sorted.Items[2].ID != 3 { t.Fatalf("title ascending expected apple first, Cherry last: %v", sorted.Items) } }) t.Run("A repository error is propagated from both queries", func(t *testing.T) { sentinel := errors.New("boom") for _, method := range []string{"FindPage", "CountByVisibility"} { repo := newMockArticleRepo() seedPaginationArticles(repo, 3, 0) repo.fail.failNextWith(method, sentinel) uc := useCases.NewListArticlesUseCase(repo, &fakeBodyRenderer{}, plainSites()) _, err := uc.ListArticlesPage(useCases.ListArticlesCommand{Format: domainArticle.BodyFormatHTML, Verified: true, Page: 1, Size: 25}) if !errors.Is(err, sentinel) { t.Fatalf("%s: expected sentinel, got %v", method, err) } } }) t.Run("The unpaged Filters call still returns every article", func(t *testing.T) { repo := newMockArticleRepo() seedPaginationArticles(repo, 30, 5) all, err := repo.FindByFilters(domainArticle.Filters{}) if err != nil || len(all) != 35 { t.Fatalf("unexpected %d %v", len(all), err) } }) }