package test import ( "RenewCMS/api" "RenewCMS/api/pkg/apperr" "RenewCMS/internal/domain/article" "encoding/json" "net/http" "net/http/httptest" "reflect" "strconv" "strings" "testing" ) func (e *seoOutEnv) sitemap(t *testing.T, who string, query string) (*httptest.ResponseRecorder, []map[string]any) { t.Helper() rec := e.call(t, who, http.MethodGet, "/v1/site/renew/sitemap"+query, "") var items []map[string]any if rec.Code == http.StatusOK { if err := json.Unmarshal(rec.Body.Bytes(), &items); err != nil { t.Fatalf("decode %q: %v", rec.Body.String(), err) } } return rec, items } func (e *seoOutEnv) seedOnline(count int) { e.cleanArticles() for id := 1; id <= count; id++ { e.articles.articles[uint32(id)] = article.Article{ ID: uint32(id), Title: "Article " + strconv.Itoa(id), IsOnline: true, SiteID: siteMainID, Slug: "slug-" + strconv.Itoa(id), CreatedAt: seoOutCreated, UpdatedAt: seoOutModified, } } } func TestSeoOutputSitemap(t *testing.T) { t.Run("The sitemap lists the online articles of one site", func(t *testing.T) { e := newSeoOutEnv(t) e.articles.articles[16] = article.Article{ID: 16, Title: "Sixteen", IsOnline: true, SiteID: siteMainID, Slug: "sixteen", CreatedAt: seoOutCreated, UpdatedAt: seoOutModified} e.articles.articles[20] = article.Article{ID: 20, Title: "Twenty", IsOnline: true, SiteID: seoOutJournalID, Slug: "twenty", CreatedAt: seoOutCreated, UpdatedAt: seoOutModified} rec, items := e.sitemap(t, "", "") want := []map[string]any{ {"url": seoOutBase + "/" + seoOutArticleSlug, "modified_at": "2026-09-10T09:30:00Z", "noindex": false}, {"url": seoOutBase + "/sixteen", "modified_at": "2026-09-10T09:30:00Z", "noindex": false}, } if rec.Code != http.StatusOK || !reflect.DeepEqual(items, want) || rec.Header().Get("X-Total-Count") != "2" { t.Errorf("status %d total %q items %v", rec.Code, rec.Header().Get("X-Total-Count"), items) } }) t.Run("Noindex articles are included with the flag", func(t *testing.T) { e := newSeoOutEnv(t) e.change(12, func(a *article.Article) { a.Noindex = true }) _, items := e.sitemap(t, "", "") if len(items) != 1 || items[0]["noindex"] != true { t.Errorf("items = %v", items) } }) t.Run("The url uses the slug and not the canonical override", func(t *testing.T) { e := newSeoOutEnv(t) e.change(12, func(a *article.Article) { a.CanonicalURL = "https://other.example/a" }) _, items := e.sitemap(t, "", "") if items[0]["url"] != seoOutBase+"/"+seoOutArticleSlug { t.Errorf("items = %v", items) } }) t.Run("Drafts are never listed", func(t *testing.T) { e := newSeoOutEnv(t) rec, items := e.sitemap(t, "owner", "") if rec.Header().Get("X-Total-Count") != "1" || len(items) != 1 { t.Errorf("items = %v", items) } }) t.Run("An unpublished article leaves the sitemap", func(t *testing.T) { e := newSeoOutEnv(t) e.call(t, "owner", http.MethodPatch, "/v1/article/12/unpublish", "") rec, items := e.sitemap(t, "", "") if rec.Header().Get("X-Total-Count") != "0" || len(items) != 0 { t.Errorf("items = %v", items) } }) t.Run("An empty site answers an empty array", func(t *testing.T) { e := newSeoOutEnv(t) e.change(12, func(a *article.Article) { a.IsOnline = false }) rec, _ := e.sitemap(t, "", "") if rec.Code != http.StatusOK || strings.TrimSpace(rec.Body.String()) != "[]" || rec.Header().Get("X-Total-Count") != "0" { t.Errorf("%d %q", rec.Code, rec.Body.String()) } }) t.Run("Each item has exactly the documented keys", func(t *testing.T) { e := newSeoOutEnv(t) _, items := e.sitemap(t, "", "") if sortedKeys(items[0]) != "modified_at,noindex,url" { t.Errorf("keys = %s", sortedKeys(items[0])) } }) t.Run("Paging and the total header", func(t *testing.T) { cases := []struct { query string count int }{{"", 100}, {"?size=25", 25}, {"?page=3", 50}, {"?page=2&size=100", 100}, {"?size=500", 250}, {"?page=99&size=25", 0}} e := newSeoOutEnv(t) e.seedOnline(250) for _, c := range cases { rec, items := e.sitemap(t, "", c.query) if len(items) != c.count || rec.Header().Get("X-Total-Count") != "250" { t.Errorf("%q: %d items, total %q", c.query, len(items), rec.Header().Get("X-Total-Count")) } } }) t.Run("Pages partition the sitemap in a stable order", func(t *testing.T) { e := newSeoOutEnv(t) e.seedOnline(60) seen := []string{} for page, want := range map[int]int{1: 25, 2: 25, 3: 10} { _, items := e.sitemap(t, "", "?size=25&page="+strconv.Itoa(page)) if len(items) != want { t.Errorf("page %d holds %d", page, len(items)) } for index, item := range items { id := (page-1)*25 + index + 1 if item["url"] != seoOutBase+"/slug-"+strconv.Itoa(id) { t.Errorf("page %d item %d = %v", page, index, item["url"]) } seen = append(seen, item["url"].(string)) } } if len(seen) != 60 { t.Errorf("seen %d", len(seen)) } }) t.Run("Invalid paging answers 400", func(t *testing.T) { for _, query := range []string{"page=0", "page=-1", "page=abc", "page=", "size=0", "size=10", "size=1000", "size=abc"} { e := newSeoOutEnv(t) before := e.counted.readCalls() expectStatusAndText(t, e.call(t, "", http.MethodGet, "/v1/site/renew/sitemap?"+query, ""), 400, apperr.BodyErrorMessage) if e.counted.readCalls() != before { t.Errorf("%s: the repository was called", query) } } }) t.Run("An unknown key is not found", func(t *testing.T) { e := newSeoOutEnv(t) expectStatusAndText(t, e.call(t, "", http.MethodGet, "/v1/site/nope/sitemap", ""), 404, apperr.SiteNotFoundErrorMessage) }) t.Run("The key is not an id", func(t *testing.T) { e := newSeoOutEnv(t) if rec := e.call(t, "", http.MethodGet, "/v1/site/1/sitemap", ""); rec.Code != http.StatusNotFound { t.Errorf("status %d", rec.Code) } }) t.Run("The route does not disturb the owner-only site routes", func(t *testing.T) { e := newSeoOutEnv(t) expectStatusAndText(t, e.call(t, "admin", http.MethodPatch, "/v1/site/2", `{"site_name": "x"}`), 403, apperr.OwnerRequiredErrorMessage) }) t.Run("The browser can read the total header", func(t *testing.T) { e := newSeoOutEnv(t) settings := e.settings settings.CorsAllowedOrigins = []string{"https://app.example"} req := httptest.NewRequest(http.MethodGet, "/v1/site/renew/sitemap", nil) req.Header.Set("Origin", "https://app.example") rec := httptest.NewRecorder() api.InitRoutes(e.container, settings).ServeHTTP(rec, req) exposed := rec.Header().Get("Access-Control-Expose-Headers") if rec.Code != http.StatusOK || !strings.Contains(exposed, "X-Total-Count") || !strings.Contains(exposed, "Link") { t.Errorf("status %d exposed %q", rec.Code, exposed) } }) t.Run("A repository failure answers 500", func(t *testing.T) { e := newSeoOutEnv(t) e.articles.fail.failNext("FindPage") if rec := e.call(t, "", http.MethodGet, "/v1/site/renew/sitemap", ""); rec.Code != http.StatusInternalServerError { t.Errorf("status %d", rec.Code) } }) }