Files
sm-seo-plugin/mcp_test.go
Jakub Zych 24e6f384ca fix: an unknown redirect status, match or query is refused, not defaulted (WR-17)
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.
2026-10-27 15:36:21 +01:00

410 lines
21 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}