Files
RenewCMS/test/articles_pagination_api_http_test.go

614 lines
24 KiB
Go

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