192 lines
6.9 KiB
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)
|
|
}
|
|
})
|
|
}
|