Files
RenewCMS/test/seo_output_sitemap_http_test.go

192 lines
6.9 KiB
Go

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)
}
})
}