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