Files
RenewCMS/test/media_pagination_http_test.go
T

350 lines
12 KiB
Go

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, plainSites())
}
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)
}
}
})
}