package test import ( "RenewCMS/api/pkg/apperr" "RenewCMS/internal/domain/site" "fmt" "net/http" "net/http/httptest" "net/url" "reflect" "strconv" "strings" "testing" ) func seedPagedSites(e *siteEnv, count int, shops int) { e.sites.seed(1, "Renew", "renew", true) for id := uint32(2); id <= uint32(count); id++ { name := fmt.Sprintf("Site %02d", id) key := fmt.Sprintf("site%02d", id) if int(id) <= shops+1 { name = fmt.Sprintf("Shop %02d", id) key = fmt.Sprintf("shop%02d", id) } e.sites.seed(id, name, key, false) } } func siteNames(sites []site.Site) []string { names := []string{} for _, s := range sites { names = append(names, s.Name) } return names } func siteIDs(sites []site.Site) []uint32 { ids := []uint32{} for _, s := range sites { ids = append(ids, s.ID) } return ids } func TestSitesPagination(t *testing.T) { list := func(t *testing.T, e *siteEnv, query string) (*httptest.ResponseRecorder, []site.Site) { t.Helper() rec := e.owner(t, http.MethodGet, "/v1/site/"+query, "") if rec.Code != http.StatusOK { return rec, nil } return rec, decodeSiteList(t, rec) } t.Run("With no paging parameter every site is returned", func(t *testing.T) { e := newSiteEnv(t) seedPagedSites(e, 61, 0) rec, sites := list(t, e, "") if len(sites) != 61 || sites[0].Name != "Renew" || rec.Header().Get("X-Total-Count") != "61" { t.Fatalf("got %d sites, first %q, total %q", len(sites), sites[0].Name, rec.Header().Get("X-Total-Count")) } }) t.Run("q alone returns every match", func(t *testing.T) { e := newSiteEnv(t) seedPagedSites(e, 61, 40) rec, sites := list(t, e, "?q=shop") if len(sites) != 40 || rec.Header().Get("X-Total-Count") != "40" { t.Fatalf("got %d sites, total %q", len(sites), rec.Header().Get("X-Total-Count")) } }) t.Run("Paging and sizes", func(t *testing.T) { cases := []struct { params string count int }{{"page=1", 25}, {"page=2", 25}, {"page=3", 11}, {"size=5", 5}, {"size=25", 25}, {"size=50&page=2", 11}, {"size=100", 61}} for _, c := range cases { t.Run(c.params, func(t *testing.T) { e := newSiteEnv(t) seedPagedSites(e, 61, 0) rec, sites := list(t, e, "?"+c.params) if len(sites) != c.count || rec.Header().Get("X-Total-Count") != "61" { t.Fatalf("got %d sites, total %q", len(sites), rec.Header().Get("X-Total-Count")) } }) } }) t.Run("Pages never overlap and keep the order", func(t *testing.T) { e := newSiteEnv(t) seedPagedSites(e, 61, 0) _, unpaged := list(t, e, "") joined := []site.Site{} for page := 1; page <= 3; page++ { _, part := list(t, e, "?page="+strconv.Itoa(page)) joined = append(joined, part...) } if !reflect.DeepEqual(siteIDs(joined), siteIDs(unpaged)) || joined[0].Name != "Renew" { t.Fatalf("pages differ from the unpaged list") } }) t.Run("A page past the end is an empty array with the total", func(t *testing.T) { e := newSiteEnv(t) seedPagedSites(e, 61, 0) rec, _ := list(t, e, "?page=99") if strings.TrimSpace(rec.Body.String()) != "[]" || rec.Header().Get("X-Total-Count") != "61" { t.Fatalf("body %q total %q", rec.Body.String(), rec.Header().Get("X-Total-Count")) } }) t.Run("No match is an empty array", func(t *testing.T) { e := newSiteEnv(t) seedPagedSites(e, 3, 0) rec, _ := list(t, e, "?q=nomatch") if strings.TrimSpace(rec.Body.String()) != "[]" || rec.Header().Get("X-Total-Count") != "0" { t.Fatalf("body %q total %q", rec.Body.String(), rec.Header().Get("X-Total-Count")) } }) t.Run("Search matches name, key and base URL", func(t *testing.T) { cases := []struct { q string names []string }{ {"renew", []string{"Renew"}}, {"JOURNAL", []string{"Journal"}}, {"blog", []string{"Journal"}}, {"example.org", []string{"Journal"}}, {"example", []string{"Renew", "Archive", "Journal"}}, {"nobody", []string{}}, } for _, c := range cases { t.Run(c.q, func(t *testing.T) { e := newSiteEnv(t) e.sites.seed(1, "Renew", "renew", true) journal := e.sites.seed(2, "Journal", "blog", false) journal.BaseURL = "https://journal.example.org" e.sites.sites[2] = journal archive := e.sites.seed(3, "Archive", "archive", false) archive.BaseURL = "https://old.example.net" e.sites.sites[3] = archive rec, sites := list(t, e, "?q="+url.QueryEscape(c.q)) if !reflect.DeepEqual(siteNames(sites), c.names) || rec.Header().Get("X-Total-Count") != strconv.Itoa(len(c.names)) { t.Fatalf("names %v total %q", siteNames(sites), rec.Header().Get("X-Total-Count")) } }) } }) t.Run("Search ignores accents", func(t *testing.T) { e := newSiteEnv(t) e.sites.seed(1, "Café", "cafe", true) for _, q := range []string{"cafe", "CAFÉ"} { rec, sites := list(t, e, "?q="+url.QueryEscape(q)) if !reflect.DeepEqual(siteNames(sites), []string{"Café"}) || rec.Header().Get("X-Total-Count") != "1" { t.Fatalf("%s: names %v", q, siteNames(sites)) } } }) t.Run("Search and paging compose and the total follows the search", func(t *testing.T) { e := newSiteEnv(t) seedPagedSites(e, 61, 27) rec, sites := list(t, e, "?q=shop&page=2") if len(sites) != 2 || rec.Header().Get("X-Total-Count") != "27" { t.Fatalf("got %d sites, total %q", len(sites), rec.Header().Get("X-Total-Count")) } }) t.Run("The default site is not forced into the results", func(t *testing.T) { e := newSiteEnv(t) e.sites.seed(1, "Renew", "renew", true) e.sites.seed(2, "Journal", "blog", false) _, sites := list(t, e, "?q=journal") if !reflect.DeepEqual(siteNames(sites), []string{"Journal"}) { t.Fatalf("names %v", siteNames(sites)) } }) t.Run("A wildcard in q is literal", func(t *testing.T) { e := newSiteEnv(t) e.sites.seed(1, "Main", "main", true) e.sites.seed(2, "100% Shop", "shop", false) _, sites := list(t, e, "?q=%25") if !reflect.DeepEqual(siteNames(sites), []string{"100% Shop"}) { t.Fatalf("names %v", siteNames(sites)) } }) t.Run("Invalid page, size or q", func(t *testing.T) { for _, params := range []string{"page=0", "page=-1", "page=abc", "page=", "size=0", "size=30", "size=101", "size=abc", "size=", "q=" + strings.Repeat("a", 201)} { t.Run(params[:min(len(params), 12)], func(t *testing.T) { e := newSiteEnvWithMain(t) rec := e.owner(t, http.MethodGet, "/v1/site/?"+params, "") expectSiteStatusAndBody(t, rec, http.StatusBadRequest, apperr.BodyErrorMessage) if _, present := rec.Header()["X-Total-Count"]; present { t.Errorf("X-Total-Count sent on a refusal") } }) } }) t.Run("q at the limit is accepted", func(t *testing.T) { e := newSiteEnvWithMain(t) rec, _ := list(t, e, "?q="+strings.Repeat("a", 200)) if rec.Code != http.StatusOK || strings.TrimSpace(rec.Body.String()) != "[]" || rec.Header().Get("X-Total-Count") != "0" { t.Fatalf("status %d body %q", rec.Code, rec.Body.String()) } }) t.Run("The other fields are unchanged", func(t *testing.T) { e := newSiteEnvWithMain(t) e.seedArticle(1, siteMainID, true) rec, sites := list(t, e, "?size=5") if len(sites) != 1 || sites[0].ArticleCount != 1 || !strings.Contains(rec.Body.String(), `"article_count"`) { t.Fatalf("body %s", rec.Body.String()) } }) t.Run("Readers and refusals", func(t *testing.T) { cases := []struct { credentials string send func(e *siteEnv, t *testing.T) *httptest.ResponseRecorder status int }{ {"the owner's jwt cookie", func(e *siteEnv, t *testing.T) *httptest.ResponseRecorder { return e.as(t, apiKeyOwnerId, http.MethodGet, "/v1/site/?size=5", "") }, http.StatusOK}, {"a verified admin's jwt cookie", func(e *siteEnv, t *testing.T) *httptest.ResponseRecorder { return e.as(t, apiKeyDevId, http.MethodGet, "/v1/site/?size=5", "") }, http.StatusOK}, {"a valid API key", func(e *siteEnv, t *testing.T) *httptest.ResponseRecorder { raw, _ := e.issueKey(apiKeyDevId, "Deploy bot") return e.asKey(t, raw, http.MethodGet, "/v1/site/?size=5", "") }, http.StatusOK}, {"an unverified account's jwt cookie", func(e *siteEnv, t *testing.T) *httptest.ResponseRecorder { return e.as(t, apiKeyQuinnId, http.MethodGet, "/v1/site/?size=5", "") }, http.StatusForbidden}, {"no credentials", func(e *siteEnv, t *testing.T) *httptest.ResponseRecorder { return e.anonymous(t, http.MethodGet, "/v1/site/?size=5", "") }, http.StatusUnauthorized}, } for _, c := range cases { t.Run(c.credentials, func(t *testing.T) { e := newSiteEnvWithMain(t) rec := c.send(e, t) _, present := rec.Header()["X-Total-Count"] if rec.Code != c.status || present != (c.status == http.StatusOK) { t.Errorf("status %d, header present %v", rec.Code, present) } }) } }) }