614 lines
24 KiB
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)
|
|
}
|
|
})
|
|
}
|