feat: SEO backend data (1/4)
This commit is contained in:
1 parent
cb1410f0b0
commit
0eb46b0d74
90 files changed
+6094
-350
No files matched your search
@@ -0,0 +1,334 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"RenewCMS/internal/domain/site"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSeoDataSiteFields(t *testing.T) {
|
||||
const validator = "validator"
|
||||
validSite := func(extra string) string {
|
||||
return `{"name": "Blog", "key": "blog", "base_url": "https://blog.example.com"` + extra + `}`
|
||||
}
|
||||
|
||||
t.Run("The owner sets the site SEO fields", func(t *testing.T) {
|
||||
e := newSeoEnv(t)
|
||||
before := e.sites.sites[siteShopID]
|
||||
|
||||
rec := e.owner(t, http.MethodPatch, "/v1/site/2", `{"site_name": "Renew Shop", "title_template": "%title% - %site%", "default_description": "A shop about renewal."}`)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
got := decodeSiteBody(t, rec)
|
||||
want := before
|
||||
want.SiteName, want.TitleTemplate, want.DefaultDescription = "Renew Shop", "%title% - %site%", "A shop about renewal."
|
||||
if !reflect.DeepEqual(got, want) || e.sites.sites[siteShopID].SiteName != "Renew Shop" {
|
||||
t.Errorf("got %+v, expected %+v", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A site created without the new fields has the defaults", func(t *testing.T) {
|
||||
e := newSeoEnv(t)
|
||||
|
||||
rec := e.owner(t, http.MethodPost, "/v1/site/", `{"name": "Blog", "key": "blog", "base_url": "https://blog.example.com"}`)
|
||||
|
||||
created := decodeSiteBody(t, rec)
|
||||
if rec.Code != http.StatusCreated || created.SiteName != "" || created.TitleTemplate != "%title% | %site%" || created.DefaultDescription != "" {
|
||||
t.Errorf("status %d site %+v", rec.Code, created)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A site is created with the new fields", func(t *testing.T) {
|
||||
e := newSeoEnv(t)
|
||||
|
||||
rec := e.owner(t, http.MethodPost, "/v1/site/", validSite(`, "site_name": "Renew Shop", "title_template": "%title% - %site%", "default_description": "A shop about renewal."`))
|
||||
|
||||
created := decodeSiteBody(t, rec)
|
||||
stored := e.sites.sites[created.ID]
|
||||
if rec.Code != http.StatusCreated || created.SiteName != "Renew Shop" || created.TitleTemplate != "%title% - %site%" || created.DefaultDescription != "A shop about renewal." {
|
||||
t.Fatalf("status %d site %+v", rec.Code, created)
|
||||
}
|
||||
if stored.SiteName != "Renew Shop" || stored.TitleTemplate != "%title% - %site%" || stored.DefaultDescription != "A shop about renewal." {
|
||||
t.Errorf("stored %+v", stored)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Creation validates the new fields like editing", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
extra string
|
||||
status int
|
||||
text string
|
||||
}{
|
||||
{"template with title", `, "title_template": "%title%"`, 201, ""},
|
||||
{"template without title", `, "title_template": "just text"`, 400, "invalid_title_template"},
|
||||
{"template with site only", `, "title_template": "%site%"`, 400, "invalid_title_template"},
|
||||
{"empty template", `, "title_template": ""`, 400, "invalid_title_template"},
|
||||
{"template 201", `, "title_template": "%title%` + strings.Repeat("a", 194) + `"`, 400, validator},
|
||||
{"description 320", `, "default_description": "` + strings.Repeat("a", 320) + `"`, 201, ""},
|
||||
{"description 321", `, "default_description": "` + strings.Repeat("a", 321) + `"`, 400, validator},
|
||||
{"site name 100", `, "site_name": "` + strings.Repeat("a", 100) + `"`, 201, ""},
|
||||
{"site name 101", `, "site_name": "` + strings.Repeat("a", 101) + `"`, 400, validator},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
e := newSeoEnv(t)
|
||||
sitesBefore := len(e.sites.sites)
|
||||
|
||||
rec := e.owner(t, http.MethodPost, "/v1/site/", validSite(c.extra))
|
||||
|
||||
switch {
|
||||
case c.status == http.StatusCreated:
|
||||
if rec.Code != c.status {
|
||||
t.Errorf("status %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
case c.text == validator:
|
||||
expectStatusAndValidatorText(t, rec, c.status)
|
||||
default:
|
||||
expectStatusAndText(t, rec, c.status, c.text)
|
||||
}
|
||||
if c.status != http.StatusCreated && len(e.sites.sites) != sitesBefore {
|
||||
t.Errorf("a site was added")
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The list carries the new fields", func(t *testing.T) {
|
||||
e := newSeoEnv(t)
|
||||
|
||||
rec := e.as(t, apiKeyDevId, http.MethodGet, "/v1/site/", "")
|
||||
|
||||
var items []map[string]any
|
||||
for _, item := range decodeSiteList(t, rec) {
|
||||
items = append(items, map[string]any{"site_name": item.SiteName, "title_template": item.TitleTemplate})
|
||||
}
|
||||
if rec.Code != http.StatusOK || len(items) != 2 || !strings.Contains(rec.Body.String(), `"site_name":""`) || !strings.Contains(rec.Body.String(), `"title_template":"%title% | %site%"`) || !strings.Contains(rec.Body.String(), `"default_description":""`) {
|
||||
t.Errorf("status %d body %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Absent fields are untouched", func(t *testing.T) {
|
||||
e := newSeoEnv(t)
|
||||
shop := e.sites.sites[siteShopID]
|
||||
shop.TitleTemplate, shop.DefaultDescription = "%title% :: %site%", "Kept."
|
||||
e.sites.sites[siteShopID] = shop
|
||||
|
||||
rec := e.owner(t, http.MethodPatch, "/v1/site/2", `{"site_name": "Renew"}`)
|
||||
|
||||
want := shop
|
||||
want.SiteName = "Renew"
|
||||
if rec.Code != http.StatusOK || !reflect.DeepEqual(e.sites.sites[siteShopID], want) {
|
||||
t.Errorf("status %d stored %+v", rec.Code, e.sites.sites[siteShopID])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The template must contain %title%", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
template string
|
||||
status int
|
||||
text string
|
||||
}{
|
||||
{"title only", `"%title%"`, 200, ""},
|
||||
{"title and site", `"%title% | %site%"`, 200, ""},
|
||||
{"site first", `"%site% - %title%"`, 200, ""},
|
||||
{"prefix", `"News: %title%"`, 200, ""},
|
||||
{"site only", `"%site%"`, 400, "invalid_title_template"},
|
||||
{"plain text", `"just text"`, 400, "invalid_title_template"},
|
||||
{"upper case placeholder", `"%TITLE%"`, 400, "invalid_title_template"},
|
||||
{"empty", `""`, 400, "invalid_title_template"},
|
||||
{"201 characters", `"%title%` + strings.Repeat("a", 194) + `"`, 400, validator},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
e := newSeoEnv(t)
|
||||
|
||||
rec := e.owner(t, http.MethodPatch, "/v1/site/2", `{"title_template": `+c.template+`}`)
|
||||
|
||||
switch {
|
||||
case c.status == http.StatusOK:
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("status %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
case c.text == validator:
|
||||
expectStatusAndValidatorText(t, rec, c.status)
|
||||
default:
|
||||
expectStatusAndText(t, rec, c.status, c.text)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Other site SEO fields are validated", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
status int
|
||||
}{
|
||||
{"description 321", `{"default_description": "` + strings.Repeat("a", 321) + `"}`, 400},
|
||||
{"description 320", `{"default_description": "` + strings.Repeat("a", 320) + `"}`, 200},
|
||||
{"description empty", `{"default_description": ""}`, 200},
|
||||
{"site name 101", `{"site_name": "` + strings.Repeat("a", 101) + `"}`, 400},
|
||||
{"site name empty", `{"site_name": ""}`, 200},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
e := newSeoEnv(t)
|
||||
|
||||
rec := e.owner(t, http.MethodPatch, "/v1/site/2", c.body)
|
||||
|
||||
if rec.Code != c.status {
|
||||
t.Errorf("status %d: %.120s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if c.status == http.StatusBadRequest {
|
||||
expectStatusAndValidatorText(t, rec, c.status)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("A refused site update changes no other field", func(t *testing.T) {
|
||||
e := newSeoEnv(t)
|
||||
before := e.sites.sites[siteShopID]
|
||||
|
||||
rec := e.owner(t, http.MethodPatch, "/v1/site/2", `{"name": "Boutique", "title_template": "nope"}`)
|
||||
|
||||
expectStatusAndText(t, rec, http.StatusBadRequest, "invalid_title_template")
|
||||
if e.sites.sites[siteShopID].Name != before.Name {
|
||||
t.Errorf("the name changed to %q", e.sites.sites[siteShopID].Name)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The existing site fields keep their names", func(t *testing.T) {
|
||||
e := newSeoEnv(t)
|
||||
|
||||
rec := e.owner(t, http.MethodPatch, "/v1/site/2", `{"default_social_image_id": 5, "default_robots": "noindex"}`)
|
||||
|
||||
got := decodeSiteBody(t, rec)
|
||||
if rec.Code != http.StatusOK || got.DefaultSocialImageID == nil || *got.DefaultSocialImageID != 5 || got.DefaultRobots != "noindex" {
|
||||
t.Errorf("status %d site %+v", rec.Code, got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Other roles and keys are refused", func(t *testing.T) {
|
||||
const ownerText, sessionText = "Only the owner can do this.", "This action needs a real session."
|
||||
cases := []struct {
|
||||
name string
|
||||
method string
|
||||
path string
|
||||
send func(e *seoEnv, t *testing.T, method string, path string, body string) *seoResult
|
||||
text string
|
||||
}{
|
||||
{"PATCH with a verified admin", http.MethodPatch, "/v1/site/2", seoAsUser(apiKeyDevId), ownerText},
|
||||
{"PATCH with a verified account with another role", http.MethodPatch, "/v1/site/2", seoAsUser(seoEditorID), ownerText},
|
||||
{"PATCH with a valid API key", http.MethodPatch, "/v1/site/2", seoAsKeyOf(apiKeyDevId), sessionText},
|
||||
{"POST with a verified admin", http.MethodPost, "/v1/site/", seoAsUser(apiKeyDevId), ownerText},
|
||||
{"POST with a valid API key", http.MethodPost, "/v1/site/", seoAsKeyOf(apiKeyDevId), sessionText},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
e := newSeoEnv(t)
|
||||
before := e.sites.snapshot()
|
||||
body := `{"site_name": "Hijack"}`
|
||||
if c.method == http.MethodPost {
|
||||
body = validSite(`, "site_name": "Hijack"`)
|
||||
}
|
||||
|
||||
rec := c.send(e, t, c.method, c.path, body)
|
||||
|
||||
if rec.code != http.StatusForbidden || rec.text != c.text || !reflect.DeepEqual(e.sites.snapshot(), before) {
|
||||
t.Errorf("status %d body %q", rec.code, rec.text)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("No credentials is unauthorised", func(t *testing.T) {
|
||||
e := newSeoEnv(t)
|
||||
|
||||
rec := e.as(t, 0, http.MethodPatch, "/v1/site/2", `{"site_name": "x"}`)
|
||||
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("status %d", rec.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The role is checked before the body", func(t *testing.T) {
|
||||
e := newSeoEnv(t)
|
||||
|
||||
rec := e.as(t, apiKeyDevId, http.MethodPatch, "/v1/site/2", "{not json")
|
||||
|
||||
expectStatusAndText(t, rec, http.StatusForbidden, "Only the owner can do this.")
|
||||
})
|
||||
|
||||
t.Run("A no-op site edit records nothing", func(t *testing.T) {
|
||||
e := newSeoEnv(t)
|
||||
shop := e.sites.sites[siteShopID]
|
||||
shop.SiteName, shop.DefaultDescription = "Renew", "A shop."
|
||||
e.sites.sites[siteShopID] = shop
|
||||
|
||||
rec := e.owner(t, http.MethodPatch, "/v1/site/2", `{"site_name": "Renew", "title_template": "%title% | %site%", "default_description": "A shop."}`)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status %d", rec.Code)
|
||||
}
|
||||
e.noEvent(t)
|
||||
})
|
||||
|
||||
t.Run("A site SEO edit is recorded", func(t *testing.T) {
|
||||
cases := []struct{ body, field, from, to string }{
|
||||
{`{"site_name": "Renew"}`, "site_name", "", "Renew"},
|
||||
{`{"title_template": "%title% - %site%"}`, "title_template", "%title% | %site%", "%title% - %site%"},
|
||||
{`{"default_description": "A shop."}`, "default_description", "", "A shop."},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.field, func(t *testing.T) {
|
||||
e := newSeoEnv(t)
|
||||
|
||||
rec := e.owner(t, http.MethodPatch, "/v1/site/2", c.body)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status %d", rec.Code)
|
||||
}
|
||||
event := e.single(t)
|
||||
assertProducerEvent(t, event, audit.ActionSiteEdit, audit.CategorySites, audit.OutcomeSuccess)
|
||||
want := `[{"field":"` + c.field + `","kind":"text","before":"` + c.from + `","after":"` + c.to + `"}]`
|
||||
if got := changesJSON(t, event); got != want {
|
||||
t.Errorf("changes = %s, expected %s", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("The site defaults are the documented ones", func(t *testing.T) {
|
||||
if site.DefaultTitleTemplate != "%title% | %site%" {
|
||||
t.Errorf("template = %q", site.DefaultTitleTemplate)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type seoResult struct {
|
||||
code int
|
||||
text string
|
||||
}
|
||||
|
||||
func seoAsUser(userID uint32) func(e *seoEnv, t *testing.T, method string, path string, body string) *seoResult {
|
||||
return func(e *seoEnv, t *testing.T, method string, path string, body string) *seoResult {
|
||||
rec := e.as(t, userID, method, path, body)
|
||||
return &seoResult{rec.Code, strings.TrimSpace(rec.Body.String())}
|
||||
}
|
||||
}
|
||||
|
||||
func seoAsKeyOf(userID uint32) func(e *seoEnv, t *testing.T, method string, path string, body string) *seoResult {
|
||||
return func(e *seoEnv, t *testing.T, method string, path string, body string) *seoResult {
|
||||
raw, _ := e.issueKey(userID, "Deploy bot")
|
||||
rec := e.asKey(t, raw, method, path, body)
|
||||
return &seoResult{rec.Code, strings.TrimSpace(rec.Body.String())}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user