feat(media): pagination

This commit is contained in:
Florian Sylvain committed 2026-10-13 19:54:59 +02:00
1 parent 04dc151ed7
commit 63c1e7076e
65 files changed
+2960 -365

No files matched your search

+67
View File
@@ -0,0 +1,67 @@
package test
import (
"testing"
"time"
"RenewCMS/internal/domain/image"
)
func sortedIDs(order image.Sort, imgs []image.Image) []uint32 {
image.SortImages(imgs, order)
ids := make([]uint32, len(imgs))
for i, img := range imgs {
ids[i] = img.ID
}
return ids
}
func equalIDs(a, b []uint32) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
func TestImageSortTieBreaks(t *testing.T) {
now := time.Now()
build := func() []image.Image {
return []image.Image{
{ID: 2, Filename: "a.png", Size: 10, CreatedAt: now},
{ID: 1, Filename: "a.png", Size: 10, CreatedAt: now},
{ID: 3, Filename: "b.png", Size: 20, CreatedAt: now.Add(time.Hour)},
}
}
cases := []struct {
order image.Sort
want []uint32
}{
{image.SortOldest, []uint32{1, 2, 3}},
{image.SortNewest, []uint32{3, 2, 1}},
{image.SortName, []uint32{1, 2, 3}},
{image.SortLargest, []uint32{3, 1, 2}},
}
for _, c := range cases {
if got := sortedIDs(c.order, build()); !equalIDs(got, c.want) {
t.Errorf("%s: got %v want %v", c.order, got, c.want)
}
}
}
func TestImageSortByTotalSizeTieBreak(t *testing.T) {
imgs := []image.Image{
{ID: 2, Path: "/u/x.png", Size: 10},
{ID: 1, Path: "/u/y.png", Size: 10},
{ID: 3, Path: "/u/z.png", Size: 11},
}
image.SortImagesByTotalSize(imgs, nil)
got := []uint32{imgs[0].ID, imgs[1].ID, imgs[2].ID}
if !equalIDs(got, []uint32{3, 1, 2}) {
t.Fatalf("got %v", got)
}
}
+2
View File
@@ -75,6 +75,7 @@ type mockImageRepo struct {
images map[uint32]imageDomain.Image
nextID uint32
fail failSwitch
listCalls int
failDeleteFor map[uint32]error
deleteCalls []uint32
createCalls int
@@ -169,6 +170,7 @@ func (m *mockImageRepo) Delete(id uint32) error {
}
func (m *mockImageRepo) List(query imageDomain.SearchQuery) ([]imageDomain.Image, error) {
m.listCalls++
if err := m.fail.trigger("List"); err != nil {
return nil, err
}
-35
View File
@@ -3,9 +3,6 @@ package test
import (
domainArticle "RenewCMS/internal/domain/article"
"RenewCMS/internal/infrastructure/useCases"
"encoding/base64"
"net/http"
"net/url"
"reflect"
"testing"
)
@@ -20,38 +17,6 @@ func (r *mediaOrderRecordingArticleRepo) UpdateBody(id uint32, body string) (dom
return r.mockArticleRepo.UpdateBody(id, body)
}
func TestMediaCursorBoundaries(t *testing.T) {
seed := func(env *mediaEnv) {
env.seedImage(1, "a.jpg", 10, "image/jpeg", 3, 2, hashA, 1)
env.seedImage(2, "b.jpg", 20, "image/jpeg", 3, 2, hashB, 1)
}
encode := func(raw string) string {
return url.QueryEscape(base64.RawURLEncoding.EncodeToString([]byte(raw)))
}
t.Run("A cursor at offset zero is accepted and starts from the first item", func(t *testing.T) {
env := newMediaEnv(t)
seed(env)
rec := env.send(t, http.MethodGet, "/v1/media/?cursor="+encode("offset:0"), "", mediaVerifiedUserId)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", rec.Code)
}
})
t.Run("A negative cursor offset is refused", func(t *testing.T) {
env := newMediaEnv(t)
seed(env)
rec := env.send(t, http.MethodGet, "/v1/media/?cursor="+encode("offset:-1"), "", mediaVerifiedUserId)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected status 400, got %d", rec.Code)
}
})
}
func TestMediaMergeRewritesArticlesInIdOrder(t *testing.T) {
env := newMediaEnv(t)
env.seedImage(2, "floorboards.jpg", 20, "image/jpeg", 3, 2, hashB, 1)
+3 -1
View File
@@ -32,6 +32,7 @@ const (
type mediaEnv struct {
articles *mockArticleRepo
images *mockImageRepo
variants *mockVariantFileRepo
storage *mockImageStorage
users *mockUserRepo
purgeSettings *mockPurgeSettingsRepo
@@ -48,6 +49,7 @@ func newMediaEnv(t *testing.T) *mediaEnv {
env := &mediaEnv{
articles: newMockArticleRepo(),
images: newMockImageRepo(),
variants: newMockVariantFileRepo(),
storage: newMockImageStorage(),
users: newMockUserRepo(),
token: jwtauth.New("HS256", []byte("test-secret"), nil),
@@ -72,7 +74,7 @@ func newMediaEnv(t *testing.T) *mediaEnv {
GetUserUseCase: useCases.NewGetUserUseCase(env.users),
CreateImageUseCase: useCases.NewCreateImageUseCase(env.images, env.storage, discardRecorder),
GenerateVariantsOnUploadUseCase: newInertGenerateOnUploadUseCase(),
ListMediaUseCase: useCases.NewListMediaUseCase(env.images, env.articles, env.users, newMockVariantFileRepo()),
ListMediaUseCase: useCases.NewListMediaUseCase(env.images, env.articles, env.users, env.variants),
RenameMediaUseCase: useCases.NewRenameMediaUseCase(env.images, discardRecorder),
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(env.images, env.storage, newMockVariantFileRepo(), discardRecorder),
MergeMediaUseCase: merge,
+21 -25
View File
@@ -7,7 +7,6 @@ import (
"encoding/json"
"errors"
"net/http"
"net/url"
"os/exec"
"reflect"
"regexp"
@@ -35,9 +34,11 @@ func seedListingLibrary(env *mediaEnv) {
}
type listBody struct {
Items []map[string]any `json:"items"`
NextCursor *string `json:"next_cursor"`
Counts map[string]int `json:"counts"`
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 (e *mediaEnv) list(t *testing.T, query string) (listBody, string) {
@@ -155,11 +156,11 @@ func TestMediaListing(t *testing.T) {
if !strings.Contains(raw, `"width":null`) || !strings.Contains(raw, `"height":null`) {
t.Errorf("expected null width and height in the raw body, got %s", raw)
}
if got.NextCursor != nil {
t.Errorf("expected next_cursor null, got %v", *got.NextCursor)
if strings.Contains(raw, "next_cursor") {
t.Errorf("expected no next_cursor in the raw body")
}
if !strings.Contains(raw, `"next_cursor":null`) {
t.Errorf("expected next_cursor null in the raw body")
if got.Total != 5 || got.Page != 1 || got.Size != 25 {
t.Errorf("unexpected paging %d %d %d", got.Total, got.Page, got.Size)
}
if !reflect.DeepEqual(got.Counts, map[string]int{"all": 5, "unused": 3, "duplicates": 1}) {
t.Errorf("unexpected counts %v", got.Counts)
@@ -293,31 +294,26 @@ func TestMediaListing(t *testing.T) {
}
})
t.Run("limit and cursor page through the list", func(t *testing.T) {
t.Run("page and size page through the list", func(t *testing.T) {
env := newMediaEnv(t)
seedListingLibrary(env)
first, _ := env.list(t, "?limit=2")
expectIds(t, idsOf(first.Items), []uint32{5, 4})
if first.NextCursor == nil || *first.NextCursor == "" {
t.Fatalf("expected a next_cursor on page 1")
for id := uint32(1); id <= 12; id++ {
env.seedImage(id, "image.jpg", 10, "image/jpeg", 3, 2, hashA, 1)
}
second, _ := env.list(t, "?limit=2&cursor="+url.QueryEscape(*first.NextCursor))
expectIds(t, idsOf(second.Items), []uint32{3, 2})
if second.NextCursor == nil || *second.NextCursor == "" {
t.Fatalf("expected a next_cursor on page 2")
}
first, _ := env.list(t, "?size=5&page=1")
second, _ := env.list(t, "?size=5&page=2")
third, _ := env.list(t, "?size=5&page=3")
third, _ := env.list(t, "?limit=2&cursor="+url.QueryEscape(*second.NextCursor))
expectIds(t, idsOf(third.Items), []uint32{1})
if third.NextCursor != nil {
t.Errorf("expected next_cursor null on the last page, got %v", *third.NextCursor)
expectIds(t, idsOf(first.Items), []uint32{12, 11, 10, 9, 8})
expectIds(t, idsOf(second.Items), []uint32{7, 6, 5, 4, 3})
expectIds(t, idsOf(third.Items), []uint32{2, 1})
if third.Total != 12 || third.Page != 3 || third.Size != 5 {
t.Errorf("unexpected paging %d %d %d", third.Total, third.Page, third.Size)
}
})
t.Run("A malformed query answers 400", func(t *testing.T) {
for _, query := range []string{"filter=bogus", "sort=bogus", "limit=0", "limit=101", "limit=abc", "cursor=%%%", "cursor=bogus", "hash=notahash"} {
for _, query := range []string{"filter=bogus", "sort=bogus", "page=0", "page=abc", "size=10", "size=200", "size=", "hash=notahash"} {
t.Run(query, func(t *testing.T) {
env := newMediaEnv(t)
seedListingLibrary(env)
+349
View File
@@ -0,0 +1,349 @@
package test
import (
"RenewCMS/api/pkg/apperr"
domainImage "RenewCMS/internal/domain/image"
"RenewCMS/internal/infrastructure/useCases"
"errors"
"fmt"
"net/http"
"net/url"
"reflect"
"strings"
"testing"
"time"
)
func seedBulkImages(env *mediaEnv, count int) {
for id := uint32(1); id <= uint32(count); id++ {
env.seedImage(id, fmt.Sprintf("image-%02d.jpg", id), int64(id)*10, "image/jpeg", 3, 2, hashA, 1)
}
}
func TestMediaPaginationEnvelope(t *testing.T) {
t.Run("A bare request answers the envelope with the defaults", func(t *testing.T) {
env := newMediaEnv(t)
seedBulkImages(env, 30)
got, raw := env.list(t, "")
if got.Page != 1 || got.Size != 25 || got.Total != 30 || len(got.Items) != 25 {
t.Errorf("unexpected envelope %d %d %d %d", got.Page, got.Size, got.Total, len(got.Items))
}
if got.Counts["all"] != 30 {
t.Errorf("unexpected counts %v", got.Counts)
}
if strings.Contains(raw, "next_cursor") {
t.Errorf("unexpected next_cursor in %s", raw)
}
})
t.Run("Defaults when only one parameter is given", func(t *testing.T) {
examples := []struct {
query string
page int
size int
count int
}{
{"?page=2", 2, 25, 25},
{"?page=3", 3, 25, 10},
{"?size=5", 1, 5, 5},
{"?size=50", 1, 50, 50},
{"?size=100", 1, 100, 60},
}
for _, example := range examples {
t.Run(example.query, func(t *testing.T) {
env := newMediaEnv(t)
seedBulkImages(env, 60)
got, _ := env.list(t, example.query)
if got.Page != example.page || got.Size != example.size || got.Total != 60 || len(got.Items) != example.count {
t.Errorf("unexpected envelope %d %d %d %d", got.Page, got.Size, got.Total, len(got.Items))
}
})
}
})
t.Run("Pages partition the list with no overlap", func(t *testing.T) {
env := newMediaEnv(t)
seedBulkImages(env, 12)
seen := map[uint32]bool{}
for page, want := range map[int]int{1: 5, 2: 5, 3: 2} {
got, _ := env.list(t, fmt.Sprintf("?page=%d&size=5", page))
if len(got.Items) != want || got.Total != 12 {
t.Errorf("page %d holds %d of %d", page, len(got.Items), got.Total)
}
for _, id := range idsOf(got.Items) {
if seen[id] {
t.Errorf("id %d repeats", id)
}
seen[id] = true
}
}
if len(seen) != 12 {
t.Errorf("expected 12 distinct ids, got %d", len(seen))
}
})
t.Run("Newest first with the id tie-break", func(t *testing.T) {
env := newMediaEnv(t)
for id := uint32(1); id <= 3; id++ {
env.seedImage(id, "a.jpg", 10, "image/jpeg", 3, 2, hashA, 1)
}
sameDay := time.Date(2026, time.September, 3, 0, 0, 0, 0, time.UTC)
for _, id := range []uint32{2, 3} {
img := env.images.images[id]
img.CreatedAt = sameDay
env.images.images[id] = img
}
first := env.images.images[1]
first.CreatedAt = sameDay.AddDate(0, 0, -2)
env.images.images[1] = first
got, _ := env.list(t, "?size=5")
expectIds(t, idsOf(got.Items), []uint32{3, 2, 1})
})
t.Run("Sort orders the whole library before paging", func(t *testing.T) {
names := []string{"mango", "Apple", "cherry", "banana", "Zucchini", "date", "fig", "Grape", "kiwi", "lemon", "olive", "pear"}
sizes := []int64{50, 120, 10, 90, 30, 110, 70, 20, 100, 60, 40, 80}
examples := map[string][]uint32{
"newest": {12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1},
"oldest": {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12},
"largest": {2, 6, 9, 4, 12, 7, 10, 1, 11, 5, 8, 3},
"name": {2, 4, 3, 6, 7, 8, 9, 10, 1, 11, 12, 5},
"total_size": {3, 8, 2, 6, 9, 4, 12, 7, 10, 1, 11, 5},
}
for sortName, want := range examples {
t.Run(sortName, func(t *testing.T) {
env := newMediaEnv(t)
for index, name := range names {
env.seedImage(uint32(index+1), name+".jpg", sizes[index], "image/jpeg", 3, 2, hashA, 1)
}
for id, bytes := range map[uint32]int64{3: 500, 8: 200} {
_, _ = env.variants.Save(domainImage.VariantFile{VariantName: "hero", SourceName: domainImage.StoredName(env.images.images[id].Path), CachedName: fmt.Sprintf("c%d", id), ContentType: "image/jpeg", Size: bytes, Fingerprint: "f"})
}
var all []uint32
for page := 1; page <= 3; page++ {
got, _ := env.list(t, fmt.Sprintf("?sort=%s&size=5&page=%d", sortName, page))
all = append(all, idsOf(got.Items)...)
}
expectIds(t, all, want)
})
}
})
t.Run("Filter and search narrow total and items but not the counts", func(t *testing.T) {
env := newMediaEnv(t)
seedBulkImages(env, 40)
for id := uint32(1); id <= 34; id++ {
env.seedArticle(id, "Article", true, env.urlOf(id))
}
unused, _ := env.list(t, "?filter=unused&size=5")
search, _ := env.list(t, "?q=image-1&size=5&page=2")
if unused.Total != 6 || len(unused.Items) != 5 || unused.Counts["all"] != 40 || unused.Counts["unused"] != 6 {
t.Errorf("unexpected unused page %d %d %v", unused.Total, len(unused.Items), unused.Counts)
}
if search.Total != 10 || len(search.Items) != 5 || search.Counts["all"] != 40 {
t.Errorf("unexpected search page %d %d %v", search.Total, len(search.Items), search.Counts)
}
})
t.Run("Hash lookup returns every copy in one page", func(t *testing.T) {
env := newMediaEnv(t)
seedListingLibrary(env)
env.seedImage(6, "floorboards-2.jpg", 10, "image/jpeg", 3, 2, hashB, 1)
explicit, _ := env.list(t, "?hash="+hashB+"&page=1&size=100")
bare, _ := env.list(t, "?hash="+hashB)
if explicit.Total != 3 || len(explicit.Items) != 3 {
t.Errorf("unexpected explicit lookup %d %d", explicit.Total, len(explicit.Items))
}
if bare.Page != 1 || bare.Size != 25 || bare.Total != 3 || len(bare.Items) != 3 {
t.Errorf("unexpected bare lookup %d %d %d %d", bare.Page, bare.Size, bare.Total, len(bare.Items))
}
})
t.Run("A page beyond the last is empty with the real total", func(t *testing.T) {
env := newMediaEnv(t)
seedBulkImages(env, 12)
got, raw := env.list(t, "?page=99&size=5")
if len(got.Items) != 0 || got.Total != 12 || got.Page != 99 || got.Size != 5 || got.Counts["all"] != 12 {
t.Errorf("unexpected envelope %+v", got)
}
if !strings.Contains(raw, `"items":[]`) {
t.Errorf("expected an empty array in %s", raw)
}
})
t.Run("An empty library", func(t *testing.T) {
env := newMediaEnv(t)
got, raw := env.list(t, "")
if len(got.Items) != 0 || got.Total != 0 || !reflect.DeepEqual(got.Counts, map[string]int{"all": 0, "unused": 0, "duplicates": 0}) {
t.Errorf("unexpected envelope %+v", got)
}
if !strings.Contains(raw, `"items":[]`) {
t.Errorf("expected an empty array in %s", raw)
}
})
t.Run("counts ignores every filter and the page", func(t *testing.T) {
env := newMediaEnv(t)
seedBulkImages(env, 30)
got, _ := env.list(t, "?q=zzzz&page=3&size=5")
if len(got.Items) != 0 || got.Total != 0 || got.Counts["all"] != 30 {
t.Errorf("unexpected envelope %+v", got)
}
})
t.Run("cursor and limit are ignored", func(t *testing.T) {
for _, query := range []string{"cursor=abc", "limit=5", "limit=0", "limit=abc", "cursor=b2Zmc2V0OjU"} {
t.Run(query, func(t *testing.T) {
env := newMediaEnv(t)
seedBulkImages(env, 30)
got, _ := env.list(t, "?"+query)
bare, _ := env.list(t, "")
if got.Page != 1 || got.Size != 25 || len(got.Items) != 25 {
t.Errorf("unexpected envelope %d %d %d", got.Page, got.Size, len(got.Items))
}
expectIds(t, idsOf(got.Items), idsOf(bare.Items))
})
}
})
t.Run("Invalid parameters answer 400", func(t *testing.T) {
queries := []string{"page=0", "page=-1", "page=abc", "page=", "page=1.5", "size=0", "size=10", "size=200", "size=abc", "size=", "page=0&size=5", "page=1&size=1000", "filter=archived", "sort=biggest", "variants=", "variants=other", "hash=notahash"}
for _, query := range queries {
t.Run(query, func(t *testing.T) {
env := newMediaEnv(t)
seedBulkImages(env, 3)
rec := env.send(t, http.MethodGet, "/v1/media/?"+url.PathEscape(query), "", mediaVerifiedUserId)
if rec.Code != http.StatusBadRequest || rec.Body.String() != apperr.MediaQueryErrorMessage+"\n" {
t.Errorf("unexpected response %d %q", rec.Code, rec.Body.String())
}
if env.images.listCalls != 0 {
t.Errorf("expected the repository not to be called")
}
})
}
})
t.Run("Repository failure answers 500", func(t *testing.T) {
env := newMediaEnv(t)
env.images.fail.failNext("List")
rec := env.send(t, http.MethodGet, "/v1/media/", "", mediaVerifiedUserId)
if rec.Code != http.StatusInternalServerError || rec.Body.String() != "Internal server error\n" {
t.Errorf("unexpected response %d %q", rec.Code, rec.Body.String())
}
})
t.Run("An unauthenticated caller is refused as before", func(t *testing.T) {
env := newMediaEnv(t)
rec := env.send(t, http.MethodGet, "/v1/media/?page=1&size=5", "", 0)
if rec.Code != http.StatusUnauthorized || env.images.listCalls != 0 {
t.Errorf("unexpected response %d with %d calls", rec.Code, env.images.listCalls)
}
})
}
func TestListMediaUseCasePaging(t *testing.T) {
build := func() (*mockImageRepo, *mockVariantFileRepo, *useCases.ListMediaUseCase) {
images := newMockImageRepo()
variants := newMockVariantFileRepo()
return images, variants, useCases.NewListMediaUseCase(images, newMockArticleRepo(), newMockUserRepo(), variants)
}
seed := func(images *mockImageRepo, count int) {
for id := uint32(1); id <= uint32(count); id++ {
images.images[id] = domainImage.Image{ID: id, Path: fmt.Sprintf("/static/uploadedImages/img-%d.jpg", id), Filename: "a.jpg", Size: int64(id), CreatedAt: mockImageEpoch.Add(time.Duration(id) * time.Minute)}
}
}
t.Run("List returns the items, total, page, size and counts", func(t *testing.T) {
images, _, useCase := build()
seed(images, 12)
query := domainImage.NewListQuery()
query.Page, query.Size = 3, 5
page, err := useCase.List(query)
if err != nil || len(page.Items) != 2 || page.Total != 12 || page.Page != 3 || page.Size != 5 || page.Counts.All != 12 {
t.Errorf("unexpected page %+v err %v", page, err)
}
})
t.Run("The variants filter and total_size sort apply before slicing", func(t *testing.T) {
images, variants, useCase := build()
seed(images, 12)
for _, id := range []uint32{3, 7, 9} {
_, _ = variants.Save(domainImage.VariantFile{VariantName: "hero", SourceName: domainImage.StoredName(images.images[id].Path), CachedName: fmt.Sprintf("c%d", id), ContentType: "image/jpeg", Size: int64(id) * 1000, Fingerprint: "f"})
}
with := domainImage.NewListQuery()
with.Variants, with.Sort, with.Size = domainImage.VariantsWith, domainImage.SortTotalSize, 5
page, err := useCase.List(with)
if err != nil || page.Total != 3 || len(page.Items) != 3 || page.Items[0].Image.ID != 9 || page.Counts.All != 12 {
t.Errorf("unexpected page %+v err %v", page, err)
}
all := domainImage.NewListQuery()
all.Sort, all.Size = domainImage.SortTotalSize, 5
first, _ := useCase.List(all)
if first.Items[0].Image.ID != 9 || first.Items[1].Image.ID != 7 || first.Items[2].Image.ID != 3 {
t.Errorf("unexpected total_size order %v", first.Items)
}
})
t.Run("A repository error is propagated", func(t *testing.T) {
images, _, useCase := build()
images.fail.failNextWith("List", errRepositoryFailure)
_, err := useCase.List(domainImage.NewListQuery())
if !errors.Is(err, errRepositoryFailure) {
t.Errorf("err = %v", err)
}
})
t.Run("ListQuery.Validate rejects a bad page or size", func(t *testing.T) {
for _, mutate := range []func(*domainImage.ListQuery){
func(q *domainImage.ListQuery) { q.Page = 0 },
func(q *domainImage.ListQuery) { q.Size = 0 },
func(q *domainImage.ListQuery) { q.Size = 10 },
func(q *domainImage.ListQuery) { q.Size = 101 },
} {
query := domainImage.NewListQuery()
mutate(&query)
if err := query.Validate(); !errors.Is(err, domainImage.ErrInvalidQuery) {
t.Errorf("err = %v for %+v", err, query)
}
}
for _, size := range []int{5, 25, 50, 100} {
query := domainImage.NewListQuery()
query.Size = size
if err := query.Validate(); err != nil {
t.Errorf("size %d: err = %v", size, err)
}
}
})
}
+5 -7
View File
@@ -5,7 +5,6 @@ import (
"RenewCMS/internal/domain/article"
domain "RenewCMS/internal/domain/image"
"net/http"
"net/url"
"slices"
"strings"
"testing"
@@ -184,16 +183,15 @@ func TestMediaListVariantsFilterAndSort(t *testing.T) {
}
})
t.Run("variants=with and sort=total_size page through the list", func(t *testing.T) {
t.Run("variants=with and sort=total_size answer the ordered page with its total", func(t *testing.T) {
env := newVariantEnv(t)
env.seedLibrary()
first, firstBody := env.listIds("?variants=with&sort=total_size&limit=2")
cursor, _ := firstBody["next_cursor"].(string)
second, secondBody := env.listIds("?variants=with&sort=total_size&limit=2&cursor=" + url.QueryEscape(cursor))
ids, body := env.listIds("?variants=with&sort=total_size&size=5&page=1")
beyond, _ := env.listIds("?variants=with&sort=total_size&size=5&page=2")
if !slices.Equal(first, []float64{1, 4}) || cursor == "" || !slices.Equal(second, []float64{2}) || secondBody["next_cursor"] != nil {
t.Errorf("first = %v (%q), second = %v (%v)", first, cursor, second, secondBody["next_cursor"])
if !slices.Equal(ids, []float64{1, 4, 2}) || body["total"] != float64(3) || len(beyond) != 0 {
t.Errorf("ids = %v, total = %v, beyond = %v", ids, body["total"], beyond)
}
})