The API, MCP, batches and the CSV import copied any value and Normalize
replaced it with the default, answering 201 for a 307 saved as a 301.
RedirectFields.Problems refuses values outside {301, 302, 410}, {exact,
pattern, regex} and {ignore, pass} with value_invalid; the import marks
such rows refused.
410 lines
21 KiB
Go
410 lines
21 KiB
Go
package seo_test
|
||
|
||
import (
|
||
"bytes"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"strings"
|
||
"testing"
|
||
|
||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||
"git.golem15.com/golem15/summercms/modules/cabana"
|
||
)
|
||
|
||
// principal resolves the backend administrator of an access token, the
|
||
// identity an MCP session acts as.
|
||
func (e *testEnv) principal(t *testing.T, token string) *bouncer.Principal {
|
||
t.Helper()
|
||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||
req.Header.Set("Authorization", "Bearer "+token)
|
||
_, p, err := cabana.ResolveBackend(e.app, req)
|
||
if err != nil || p == nil {
|
||
t.Fatalf("resolve principal: %v", err)
|
||
}
|
||
return p
|
||
}
|
||
|
||
// mcp runs one request the way pier dispatches a tool call: in-process,
|
||
// without an Authorization header, with the backend dispatch marker.
|
||
func (e *testEnv) mcp(t *testing.T, method, path string, p *bouncer.Principal, body any) *httptest.ResponseRecorder {
|
||
t.Helper()
|
||
var reader io.Reader
|
||
if body != nil {
|
||
raw, err := json.Marshal(body)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
reader = bytes.NewReader(raw)
|
||
}
|
||
req := httptest.NewRequest(method, path, reader)
|
||
req.RemoteAddr = fmt.Sprintf("198.51.100.%d:4000", seq.Add(1)%250+1)
|
||
req.Header.Set("Accept-Language", "en")
|
||
if body != nil {
|
||
req.Header.Set("Content-Type", "application/json")
|
||
}
|
||
req = req.WithContext(bouncer.WithDispatch(req.Context(), &bouncer.Dispatch{Guards: []string{"backend"}, Principal: p}))
|
||
rec := httptest.NewRecorder()
|
||
e.h.ServeHTTP(rec, req)
|
||
return rec
|
||
}
|
||
|
||
// mcpExpect runs an MCP-dispatched request and fails unless it answers
|
||
// status; it returns the decoded JSON body.
|
||
func (e *testEnv) mcpExpect(t *testing.T, status int, method, path string, p *bouncer.Principal, body any) map[string]any {
|
||
t.Helper()
|
||
rec := e.mcp(t, method, path, p, body)
|
||
if rec.Code != status {
|
||
t.Fatalf("MCP %s %s = %d, want %d: %s", method, path, rec.Code, status, rec.Body.String())
|
||
}
|
||
out := map[string]any{}
|
||
if rec.Body.Len() > 0 && strings.HasPrefix(strings.TrimSpace(rec.Body.String()), "{") {
|
||
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
||
t.Fatalf("decode %s: %v", rec.Body.String(), err)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// TestMCPSetURLSeoTracer is the end-to-end slice of D-10/D-11: an MCP
|
||
// client (an administrator dispatched for the backend guard) sets noindex
|
||
// on a URL a record is behind; the change lands in the record's SEO,
|
||
// resolve serves it as robots and X-Robots-Tag, and history records it
|
||
// with via mcp. A URL without a record gets an exact URL rule, and an
|
||
// administrator without golem15.seo.manage is refused.
|
||
func TestMCPSetURLSeoTracer(t *testing.T) {
|
||
env := bootEnv(t)
|
||
token := env.admin(t, true)
|
||
id := env.createPage(t, token, map[string]any{"title": "Page A", "slug": "a", "excerpt": "About A", "published": true})
|
||
agent := env.principal(t, env.admin(t, false, "golem15.seo.manage"))
|
||
|
||
got := env.mcpExpect(t, http.StatusOK, http.MethodPut, "/_seo/api/v1/seo", agent, map[string]any{
|
||
"url": "/pages/a", "values": map[string]any{"robots": "noindex"},
|
||
})
|
||
if got["written_to"] != "record" || got["type"] != pagesTypeKey || got["id"] != id || got["locale"] != "en" {
|
||
t.Fatalf("set record = %v, want written_to record %s/%s en", got, pagesTypeKey, id)
|
||
}
|
||
|
||
res := env.resolve(t, "/pages/a")
|
||
seoBody, _ := res["seo"].(map[string]any)
|
||
if res["action"] != "ok" || !strings.HasPrefix(fmt.Sprint(seoBody["robots"]), "noindex") || !strings.HasPrefix(fmt.Sprint(seoBody["x_robots_tag"]), "noindex") {
|
||
t.Fatalf("resolve /pages/a = %v, want robots and x_robots_tag noindex", res)
|
||
}
|
||
|
||
revs := env.revisions(t, "entry")
|
||
if len(revs) != 1 || revs[0].Via != "mcp" || !strings.HasPrefix(revs[0].AuthorLabel, "MCP: ") || !strings.Contains(revs[0].After, "noindex") {
|
||
t.Fatalf("entry revisions = %+v, want one via mcp with the MCP author and noindex", revs)
|
||
}
|
||
|
||
got = env.mcpExpect(t, http.StatusOK, http.MethodPut, "/_seo/api/v1/seo", agent, map[string]any{
|
||
"url": "/landing", "values": map[string]any{"title": "Landing", "robots": "noindex,follow"},
|
||
})
|
||
ruleID, _ := got["rule_id"].(float64)
|
||
if got["written_to"] != "rule" || ruleID == 0 || got["url"] != "/landing" {
|
||
t.Fatalf("set /landing = %v, want written_to rule with a rule id", got)
|
||
}
|
||
var rules []struct {
|
||
URL, Match, Title, Robots string
|
||
}
|
||
if err := env.db.Raw(`SELECT url, match, title, robots FROM golem15_seo_rules`).Scan(&rules).Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(rules) != 1 || rules[0].URL != "/landing" || rules[0].Match != "exact" || rules[0].Title != "Landing" {
|
||
t.Fatalf("rules = %+v, want one exact /landing rule", rules)
|
||
}
|
||
if revs := env.revisions(t, "rule"); len(revs) != 1 || revs[0].Via != "mcp" {
|
||
t.Fatalf("rule revisions = %+v, want one via mcp", revs)
|
||
}
|
||
res = env.resolve(t, "/landing")
|
||
seoBody, _ = res["seo"].(map[string]any)
|
||
if res["action"] != "ok" || seoBody["title"] != "Landing" {
|
||
t.Fatalf("resolve /landing = %v, want the rule's title", res)
|
||
}
|
||
|
||
// force_rule writes a rule even with a record behind the URL.
|
||
got = env.mcpExpect(t, http.StatusOK, http.MethodPut, "/_seo/api/v1/seo", agent, map[string]any{
|
||
"url": "/pages/a", "values": map[string]any{"title": "Forced"}, "force_rule": true,
|
||
})
|
||
if got["written_to"] != "rule" {
|
||
t.Fatalf("force_rule = %v, want written_to rule", got)
|
||
}
|
||
|
||
// Without golem15.seo.manage: 403 over MCP and over a bearer token.
|
||
plain := env.admin(t, false)
|
||
if rec := env.mcp(t, http.MethodPut, "/_seo/api/v1/seo", env.principal(t, plain), map[string]any{"url": "/pages/a", "values": map[string]any{"robots": "index"}}); rec.Code != http.StatusForbidden {
|
||
t.Fatalf("MCP without the permission = %d %s, want 403", rec.Code, rec.Body.String())
|
||
}
|
||
if rec := env.call(t, http.MethodPut, "/_seo/api/v1/seo", plain, map[string]any{"url": "/pages/a", "values": map[string]any{"robots": "index"}}); rec.Code != http.StatusForbidden {
|
||
t.Fatalf("bearer without the permission = %d %s, want 403", rec.Code, rec.Body.String())
|
||
}
|
||
if rec := env.call(t, http.MethodPut, "/_seo/api/v1/seo", "", map[string]any{"url": "/pages/a"}); rec.Code != http.StatusUnauthorized {
|
||
t.Fatalf("anonymous = %d, want 401", rec.Code)
|
||
}
|
||
if revs := env.revisions(t, "entry"); len(revs) != 1 {
|
||
t.Fatalf("refused writes changed history: %+v", revs)
|
||
}
|
||
}
|
||
|
||
// issueKinds indexes an issue report by kind and URL.
|
||
func issueKinds(t *testing.T, body map[string]any) map[string]map[string]map[string]any {
|
||
t.Helper()
|
||
out := map[string]map[string]map[string]any{}
|
||
list, _ := body["data"].([]any)
|
||
for _, item := range list {
|
||
issue, _ := item.(map[string]any)
|
||
kind, _ := issue["kind"].(string)
|
||
u, _ := issue["url"].(string)
|
||
if out[kind] == nil {
|
||
out[kind] = map[string]map[string]any{}
|
||
}
|
||
out[kind][u] = issue
|
||
}
|
||
return out
|
||
}
|
||
|
||
// TestIssues checks the CMS-data issue report (D-13, D-14 CMS part):
|
||
// missing descriptions, duplicate titles across records, a URL rule over a
|
||
// record, a redirect chain with its final target, a canonical pointing to
|
||
// a redirected address and a noindex page robots.txt blocks, each with
|
||
// the tool that fixes it.
|
||
func TestIssues(t *testing.T) {
|
||
env := bootEnv(t)
|
||
token := env.admin(t, true)
|
||
agent := env.principal(t, env.admin(t, false, "golem15.seo.manage"))
|
||
env.createPage(t, token, map[string]any{"title": "Same", "slug": "a", "excerpt": "First page", "published": true})
|
||
env.createPage(t, token, map[string]any{"title": "Same", "slug": "b", "excerpt": "", "published": true})
|
||
if _, err := env.seoPlugin(t).Service().GenerateEntries(t.Context(), ""); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
env.mcpExpect(t, http.StatusCreated, http.MethodPost, "/_seo/api/v1/rules", agent, map[string]any{"url": "/pages/a", "description": "Rule description"})
|
||
env.mcpExpect(t, http.StatusCreated, http.MethodPost, "/_seo/api/v1/redirects", agent, map[string]any{"source": "/old2", "target": "/pages/a"})
|
||
env.mcpExpect(t, http.StatusCreated, http.MethodPost, "/_seo/api/v1/redirects", agent, map[string]any{"source": "/old1", "target": "/old2"})
|
||
env.mcpExpect(t, http.StatusOK, http.MethodPut, "/_seo/api/v1/seo", agent, map[string]any{"url": "/pages/b", "values": map[string]any{"canonical": "/old2"}})
|
||
env.mcpExpect(t, http.StatusOK, http.MethodPut, "/_seo/api/v1/seo", agent, map[string]any{"url": "/pages/a", "values": map[string]any{"robots": "noindex"}, "force_rule": false})
|
||
env.mcpExpect(t, http.StatusOK, http.MethodPut, "/_seo/api/v1/robots", agent, map[string]any{"content": "User-agent: *\nDisallow: /pages/a\n"})
|
||
|
||
body := env.mcpExpect(t, http.StatusOK, http.MethodGet, "/_seo/api/v1/issues", agent, nil)
|
||
kinds := issueKinds(t, body)
|
||
expect := func(kind, u, tool string) map[string]any {
|
||
t.Helper()
|
||
issue, ok := kinds[kind][u]
|
||
if !ok {
|
||
t.Fatalf("no %s issue for %s in %v", kind, u, body["data"])
|
||
}
|
||
if issue["tool"] != tool {
|
||
t.Fatalf("%s %s tool = %v, want %s", kind, u, issue["tool"], tool)
|
||
}
|
||
return issue
|
||
}
|
||
expect("missing_description", "/pages/b", "seo_set_url_seo")
|
||
dup := expect("duplicate_title", "/pages/a", "seo_set_url_seo")
|
||
if also := fmt.Sprint(dup["details"]); !strings.Contains(also, "/pages/b") {
|
||
t.Fatalf("duplicate details = %s, want /pages/b", also)
|
||
}
|
||
expect("rule_overrides_record", "/pages/a", "seo_update_rule")
|
||
chain := expect("redirect_chain", "/old1", "seo_update_redirect")
|
||
if details, _ := chain["details"].(map[string]any); details["final"] != "/pages/a" {
|
||
t.Fatalf("chain details = %v, want final /pages/a", chain["details"])
|
||
}
|
||
expect("canonical_redirected", "/pages/b", "seo_set_url_seo")
|
||
expect("noindex_blocked", "/pages/a", "seo_update_robots")
|
||
if _, ok := kinds["missing_title"]; ok {
|
||
t.Fatalf("missing_title reported though every page has a title: %v", kinds["missing_title"])
|
||
}
|
||
|
||
only := env.mcpExpect(t, http.StatusOK, http.MethodGet, "/_seo/api/v1/issues?kind=redirect_chain", agent, nil)
|
||
if list, _ := only["data"].([]any); len(list) != 1 {
|
||
t.Fatalf("kind filter = %v, want one redirect_chain", only["data"])
|
||
}
|
||
}
|
||
|
||
// TestManagementAPI walks the management routes the tools bind to as an
|
||
// MCP client: the effective SEO with sources and owner, URL rules,
|
||
// redirects (a loop refused with redirect_loop, a chain saved with a
|
||
// warning, origin mcp), the 404 log actions, robots.txt and its tester,
|
||
// settings with refusals, and history.
|
||
func TestManagementAPI(t *testing.T) {
|
||
env := bootEnv(t, "SUMMER_GOLEM15__SEO__NOTFOUND_TOKEN=report-token")
|
||
token := env.admin(t, true)
|
||
agent := env.principal(t, env.admin(t, false, "golem15.seo.manage"))
|
||
env.createPage(t, token, map[string]any{"title": "Page A", "slug": "a", "excerpt": "About A", "published": true})
|
||
|
||
got := env.mcpExpect(t, http.StatusOK, http.MethodGet, "/_seo/api/v1/seo?url=/pages/a", agent, nil)
|
||
owner, _ := got["owner"].(map[string]any)
|
||
sources, _ := got["sources"].(map[string]any)
|
||
if got["action"] != "ok" || owner["written_to"] != "record" || sources["title"] == nil || sources["canonical"] != "default" {
|
||
t.Fatalf("get_url_seo = %v", got)
|
||
}
|
||
if rec := env.mcp(t, http.MethodGet, "/_seo/api/v1/seo?url=nope", agent, nil); rec.Code != http.StatusUnprocessableEntity {
|
||
t.Fatalf("bad url = %d, want 422", rec.Code)
|
||
}
|
||
|
||
// A social image is a path or an http(s) URL on every write path, as
|
||
// in the admin field (WR-16).
|
||
for _, bad := range []string{"javascript:alert(1)", "data:image/png;base64,AAAA", "//evil.example/x.png", "a b.png"} {
|
||
if rec := env.mcp(t, http.MethodPut, "/_seo/api/v1/seo", agent, map[string]any{"url": "/pages/a", "values": map[string]any{"og_image": bad}}); rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), `"og_image"`) {
|
||
t.Fatalf("set_url_seo og_image %q = %d %s, want 422 on og_image", bad, rec.Code, rec.Body.String())
|
||
}
|
||
if rec := env.mcp(t, http.MethodPost, "/_seo/api/v1/rules", agent, map[string]any{"url": "/img-rule", "og_image": bad}); rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), `"og_image"`) {
|
||
t.Fatalf("create_rule og_image %q = %d %s, want 422 on og_image", bad, rec.Code, rec.Body.String())
|
||
}
|
||
if rec := env.mcp(t, http.MethodPut, "/_seo/api/v1/settings", agent, map[string]any{"default_og_image": bad}); rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), `"default_og_image"`) {
|
||
t.Fatalf("update_settings default_og_image %q = %d %s, want 422", bad, rec.Code, rec.Body.String())
|
||
}
|
||
}
|
||
env.mcpExpect(t, http.StatusOK, http.MethodPut, "/_seo/api/v1/seo", agent, map[string]any{"url": "/pages/a", "values": map[string]any{"og_image": "https://cdn.example/a.png"}})
|
||
|
||
// URL rules.
|
||
rule := env.mcpExpect(t, http.StatusCreated, http.MethodPost, "/_seo/api/v1/rules", agent, map[string]any{"url": "/blog?tag=*", "match": "pattern", "robots": "noindex"})
|
||
data, _ := rule["data"].(map[string]any)
|
||
ruleID := fmt.Sprint(data["id"])
|
||
if data["robots"] == "" || data["match"] != "pattern" {
|
||
t.Fatalf("create_rule = %v", rule)
|
||
}
|
||
if rec := env.mcp(t, http.MethodPost, "/_seo/api/v1/rules", agent, map[string]any{"url": "/blog?tag=*", "match": "pattern"}); rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "validation_failed") {
|
||
t.Fatalf("duplicate rule = %d %s, want 422 validation_failed", rec.Code, rec.Body.String())
|
||
}
|
||
env.mcpExpect(t, http.StatusOK, http.MethodPut, "/_seo/api/v1/rules/"+ruleID, agent, map[string]any{"title": "Tagged"})
|
||
shown := env.mcpExpect(t, http.StatusOK, http.MethodGet, "/_seo/api/v1/rules/"+ruleID, agent, nil)
|
||
if d, _ := shown["data"].(map[string]any); d["title"] != "Tagged" || d["robots"] == "" {
|
||
t.Fatalf("show_rule = %v, want the update to keep robots", shown)
|
||
}
|
||
list := env.mcpExpect(t, http.StatusOK, http.MethodGet, "/_seo/api/v1/rules?search=blog", agent, nil)
|
||
if list["total"] != float64(1) {
|
||
t.Fatalf("list_rules = %v", list)
|
||
}
|
||
env.mcpExpect(t, http.StatusOK, http.MethodDelete, "/_seo/api/v1/rules/"+ruleID, agent, nil)
|
||
if rec := env.mcp(t, http.MethodGet, "/_seo/api/v1/rules/"+ruleID, agent, nil); rec.Code != http.StatusNotFound {
|
||
t.Fatalf("deleted rule = %d, want 404", rec.Code)
|
||
}
|
||
|
||
// A status, match or query outside the allowed values is refused, not
|
||
// replaced by the default (WR-17).
|
||
for field, value := range map[string]any{"status": 308, "match": "Regex", "query": "keep"} {
|
||
rec := env.mcp(t, http.MethodPost, "/_seo/api/v1/redirects", agent, map[string]any{"source": "/coerced", "target": "/y", field: value})
|
||
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), `"`+field+`"`) {
|
||
t.Fatalf("create_redirect %s=%v = %d %s, want 422 on %s", field, value, rec.Code, rec.Body.String(), field)
|
||
}
|
||
}
|
||
|
||
// Redirects: a chain is saved with a warning, a loop refused.
|
||
first := env.mcpExpect(t, http.StatusCreated, http.MethodPost, "/_seo/api/v1/redirects", agent, map[string]any{"source": "/x", "target": "/y"})
|
||
if d, _ := first["data"].(map[string]any); d["origin"] != "mcp" || d["status"] != float64(301) {
|
||
t.Fatalf("create_redirect = %v, want origin mcp and 301", first)
|
||
}
|
||
chain := env.mcpExpect(t, http.StatusCreated, http.MethodPost, "/_seo/api/v1/redirects", agent, map[string]any{"source": "/y", "target": "/z"})
|
||
if chain["warnings"] == nil {
|
||
t.Fatalf("chain = %v, want a chain warning", chain)
|
||
}
|
||
loop := env.mcp(t, http.MethodPost, "/_seo/api/v1/redirects", agent, map[string]any{"source": "/z", "target": "/x"})
|
||
if loop.Code != http.StatusUnprocessableEntity || !strings.Contains(loop.Body.String(), `"error":"redirect_loop"`) || !strings.Contains(loop.Body.String(), `"path":[`) {
|
||
t.Fatalf("loop = %d %s, want 422 redirect_loop with the path", loop.Code, loop.Body.String())
|
||
}
|
||
if n := env.redirectCount(t); n != 2 {
|
||
t.Fatalf("redirects after the refused loop = %d, want 2", n)
|
||
}
|
||
firstID := fmt.Sprint(first["data"].(map[string]any)["id"])
|
||
if rec := env.mcp(t, http.MethodPut, "/_seo/api/v1/redirects/"+firstID, agent, map[string]any{"source": "/z"}); rec.Code != http.StatusUnprocessableEntity {
|
||
t.Fatalf("update into a loop = %d %s, want 422", rec.Code, rec.Body.String())
|
||
}
|
||
env.mcpExpect(t, http.StatusOK, http.MethodPut, "/_seo/api/v1/redirects/"+firstID, agent, map[string]any{"status": 302})
|
||
if d, _ := env.mcpExpect(t, http.StatusOK, http.MethodGet, "/_seo/api/v1/redirects/"+firstID, agent, nil)["data"].(map[string]any); d["status"] != float64(302) || d["target"] != "/y" {
|
||
t.Fatalf("show_redirect = %v", d)
|
||
}
|
||
if l := env.mcpExpect(t, http.StatusOK, http.MethodGet, "/_seo/api/v1/redirects?status=302", agent, nil); l["total"] != float64(1) {
|
||
t.Fatalf("list_redirects?status=302 = %v", l)
|
||
}
|
||
env.mcpExpect(t, http.StatusOK, http.MethodDelete, "/_seo/api/v1/redirects/"+firstID, agent, nil)
|
||
|
||
// 404 log.
|
||
for _, u := range []string{"/missing-1", "/missing-2", "/missing-3"} {
|
||
if rec := env.report(t, "report-token", map[string]string{"url": u}); rec.Code != http.StatusNoContent {
|
||
t.Fatalf("report %s = %d", u, rec.Code)
|
||
}
|
||
}
|
||
nf := env.mcpExpect(t, http.StatusOK, http.MethodGet, "/_seo/api/v1/notfound?status=open", agent, nil)
|
||
rows, _ := nf["data"].([]any)
|
||
if len(rows) != 3 {
|
||
t.Fatalf("list_not_found = %v", nf)
|
||
}
|
||
ids := map[string]string{}
|
||
for _, row := range rows {
|
||
m := row.(map[string]any)
|
||
ids[fmt.Sprint(m["url"])] = fmt.Sprint(m["id"])
|
||
}
|
||
if rec := env.mcp(t, http.MethodPost, "/_seo/api/v1/notfound/"+ids["/missing-1"]+"/redirect", agent, map[string]any{}); rec.Code != http.StatusUnprocessableEntity {
|
||
t.Fatalf("redirect without target = %d, want 422", rec.Code)
|
||
}
|
||
env.mcpExpect(t, http.StatusCreated, http.MethodPost, "/_seo/api/v1/notfound/"+ids["/missing-1"]+"/redirect", agent, map[string]any{"target": "/pages/a"})
|
||
if rec := env.mcp(t, http.MethodPost, "/_seo/api/v1/notfound/"+ids["/missing-1"]+"/gone", agent, nil); rec.Code != http.StatusConflict {
|
||
t.Fatalf("gone on a resolved address = %d %s, want 409", rec.Code, rec.Body.String())
|
||
}
|
||
gone := env.mcpExpect(t, http.StatusCreated, http.MethodPost, "/_seo/api/v1/notfound/"+ids["/missing-2"]+"/gone", agent, nil)
|
||
if d, _ := gone["data"].(map[string]any); d["status"] != float64(410) || d["origin"] != "notfound" {
|
||
t.Fatalf("gone_not_found = %v", gone)
|
||
}
|
||
env.mcpExpect(t, http.StatusOK, http.MethodDelete, "/_seo/api/v1/notfound/"+ids["/missing-3"], agent, nil)
|
||
if l := env.mcpExpect(t, http.StatusOK, http.MethodGet, "/_seo/api/v1/notfound?status=resolved", agent, nil); l["total"] != float64(2) {
|
||
t.Fatalf("resolved = %v, want 2", l)
|
||
}
|
||
|
||
// robots.txt.
|
||
if rec := env.mcp(t, http.MethodPut, "/_seo/api/v1/robots", agent, map[string]any{"bots": map[string]any{"NoSuchBot": false}}); rec.Code != http.StatusUnprocessableEntity {
|
||
t.Fatalf("unknown bot = %d, want 422", rec.Code)
|
||
}
|
||
robots := env.mcpExpect(t, http.StatusOK, http.MethodPut, "/_seo/api/v1/robots", agent, map[string]any{"content": "User-agent: *\nDisallow: /private\n", "bots": map[string]any{"GPTBot": false}})
|
||
if d, _ := robots["data"].(map[string]any); !strings.Contains(fmt.Sprint(d["production"]), "GPTBot") {
|
||
t.Fatalf("update_robots = %v, want a GPTBot group in production", robots)
|
||
}
|
||
test := env.mcpExpect(t, http.StatusOK, http.MethodGet, "/_seo/api/v1/robots/test?url=/private/x&agent=Googlebot", agent, nil)
|
||
if res, _ := test["result"].(map[string]any); res["allowed"] != false || !strings.Contains(fmt.Sprint(res["rule"]), "/private") {
|
||
t.Fatalf("test_robots = %v", test)
|
||
}
|
||
if d, _ := env.mcpExpect(t, http.StatusOK, http.MethodGet, "/_seo/api/v1/robots", agent, nil)["data"].(map[string]any); !strings.Contains(fmt.Sprint(d["content"]), "/private") {
|
||
t.Fatalf("show_robots = %v", d)
|
||
}
|
||
|
||
// Settings.
|
||
if rec := env.mcp(t, http.MethodPut, "/_seo/api/v1/settings", agent, map[string]any{"title_template": "%nope% | %site%"}); rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "title_template") {
|
||
t.Fatalf("bad template = %d %s, want 422 on title_template", rec.Code, rec.Body.String())
|
||
}
|
||
if rec := env.mcp(t, http.MethodPut, "/_seo/api/v1/settings", agent, map[string]any{"url_host": "sometimes"}); rec.Code != http.StatusUnprocessableEntity {
|
||
t.Fatalf("bad url_host = %d, want 422", rec.Code)
|
||
}
|
||
settings := env.mcpExpect(t, http.StatusOK, http.MethodPut, "/_seo/api/v1/settings", agent, map[string]any{
|
||
"site_name": "Example", "csv_delimiter": ";",
|
||
"types": map[string]any{pagesTypeKey: map[string]any{"title_template": "%title% – %site%"}},
|
||
})
|
||
d, _ := settings["data"].(map[string]any)
|
||
values, _ := d["settings"].(map[string]any)
|
||
types, _ := d["types"].(map[string]any)
|
||
pageType, _ := types[pagesTypeKey].(map[string]any)
|
||
if values["site_name"] != "Example" || values["csv_delimiter"] != ";" || pageType["title_template"] != "%title% – %site%" {
|
||
t.Fatalf("update_settings = %v", settings)
|
||
}
|
||
if v, _ := env.mcpExpect(t, http.StatusOK, http.MethodGet, "/_seo/api/v1/settings", agent, nil)["data"].(map[string]any); fmt.Sprint(v["settings"].(map[string]any)["site_name"]) != "Example" {
|
||
t.Fatalf("show_settings = %v", v)
|
||
}
|
||
|
||
// History: every write above is recorded with via mcp.
|
||
hist := env.mcpExpect(t, http.StatusOK, http.MethodGet, "/_seo/api/v1/history?via=mcp&per_page=200", agent, nil)
|
||
entries, _ := hist["data"].([]any)
|
||
kinds := map[string]bool{}
|
||
for _, e := range entries {
|
||
kinds[fmt.Sprint(e.(map[string]any)["kind"])] = true
|
||
}
|
||
for _, k := range []string{"rule", "redirect", "robots", "settings", "notfound"} {
|
||
if !kinds[k] {
|
||
t.Fatalf("history via mcp lacks %s: %v", k, kinds)
|
||
}
|
||
}
|
||
firstEntry := entries[0].(map[string]any)
|
||
one := env.mcpExpect(t, http.StatusOK, http.MethodGet, "/_seo/api/v1/history/"+fmt.Sprint(firstEntry["id"]), agent, nil)
|
||
if one["data"].(map[string]any)["via"] != "mcp" {
|
||
t.Fatalf("show_history = %v", one)
|
||
}
|
||
}
|