feat: auto-links screen, keyphrase feed and MCP tools

- SEO › Auto-links (golem15.seo.autolinks, after Pages): list by phrase
  with locale, origin and active filters, the disabled row state, the
  form with its refusals, history through a field-less extension
- toolbar Add focus keyphrases (from_keyphrases) and
  POST /autolinks/from-keyphrases
- GET/POST /autolinks, PUT /autolinks/{id}, DELETE /autolinks and the
  tools seo_list_autolinks, seo_create_autolink, seo_update_autolink,
  seo_delete_autolinks, seo_autolinks_from_keyphrases
- the settings tile description and the permission comment name
  auto-links and the site audit
This commit is contained in:
Jakub Zych
2026-11-04 16:30:56 +01:00
parent 6203aec2bc
commit ab4e83416d
19 changed files with 852 additions and 13 deletions

View File

@@ -4,6 +4,9 @@ import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"git.golem15.com/golem15/sm-seo-plugin/classes"
@@ -32,6 +35,17 @@ func autolinksOf(t *testing.T, got map[string]any) (map[string]string, int) {
return out, most
}
// numID is a decoded JSON id as text.
func numID(v any) string {
if n, ok := v.(float64); ok {
return strconv.FormatFloat(n, 'f', 0, 64)
}
if n, ok := v.(json.Number); ok {
return n.String()
}
return ""
}
// saveAutolink writes an auto-link through the service, as the screen and
// the API do.
func (e *testEnv) saveAutolink(t *testing.T, a models.Autolink) models.Autolink {
@@ -97,3 +111,114 @@ func TestAutolinksTracer(t *testing.T) {
t.Fatalf("autolink_max 21 = %d %s, want 422", rec.Code, rec.Body.String())
}
}
// autolinksAPI is the Auto-links admin controller's API.
var autolinksAPI = adminAPI("/golem15/seo/autolinks")
// TestAutolinks walks the Auto-links screen and its API (UI-SPEC C22,
// D-82, T-33-50): the refusals (short phrase, a phrase the locale has
// naming its address, javascript:, data: and protocol-relative
// addresses), the list by phrase with the disabled row state, labels and
// filters, history, Add focus keyphrases twice (the second run adds
// nothing), the MCP origin, update and delete.
func TestAutolinks(t *testing.T) {
env := bootEnv(t)
token := env.admin(t, true)
refused := func(rec *httptest.ResponseRecorder, field, text string) {
t.Helper()
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), field) || !strings.Contains(rec.Body.String(), text) {
t.Fatalf("refusal = %d %s, want 422 on %s with %q", rec.Code, rec.Body.String(), field, text)
}
}
refused(env.call(t, http.MethodPost, autolinksAPI, token, map[string]any{"phrase": "ab", "url": "/x"}), "phrase", "Use at least 3 characters.")
for _, bad := range []string{"javascript:alert(1)", "data:text/html,x", "//evil.example/x", "ftp://example.com/x", "/a b"} {
refused(env.call(t, http.MethodPost, autolinksAPI, token, map[string]any{"phrase": "boczniak", "url": bad}), "url", "Use a path starting with / or a full http(s) address.")
}
env.expect(t, http.StatusCreated, http.MethodPost, autolinksAPI, token, map[string]any{"phrase": "Lwia grzywa", "url": "/grzyby/lwia-grzywa", "locale": "", "enabled": true})
refused(env.call(t, http.MethodPost, autolinksAPI, token, map[string]any{"phrase": "lwia GRZYWA", "url": "/inna", "locale": "pl", "enabled": true}), "phrase", "This phrase already links to “/grzyby/lwia-grzywa” in this language.")
env.expect(t, http.StatusCreated, http.MethodPost, autolinksAPI, token, map[string]any{"phrase": "boczniak", "url": "https://example.com/boczniak", "locale": "pl", "enabled": false, "note": "later"})
list := env.adminList(t, autolinksAPI, token)
rows := map[string]map[string]any{}
for _, row := range list.Data {
rows[row["phrase"].(string)] = row
}
if len(list.Data) != 2 || rows["boczniak"] == nil || rows["Lwia grzywa"] == nil {
t.Fatalf("list = %v, want boczniak and Lwia grzywa", list.Data)
}
if rows["boczniak"]["locale"] != "PL" || rows["Lwia grzywa"]["locale"] != "All languages" || rows["boczniak"]["origin"] != "Manual" {
t.Fatalf("list labels = %v", list.Data)
}
off := numID(rows["boczniak"]["id"])
if states := list.Meta.RowStates[off]; len(states) != 1 || states[0] != "disabled" {
t.Fatalf("row states = %v, want %s disabled", list.Meta.RowStates, off)
}
if states := list.Meta.RowStates[numID(rows["Lwia grzywa"]["id"])]; len(states) != 0 {
t.Fatalf("the active row has states %v", states)
}
if got := env.adminList(t, autolinksAPI+"?filter[active]=true", token); len(got.Data) != 1 || got.Data[0]["phrase"] != "Lwia grzywa" {
t.Fatalf("active filter = %v", got.Data)
}
if got := env.adminList(t, autolinksAPI+"?filter[locale]=all", token); len(got.Data) != 1 || got.Data[0]["phrase"] != "Lwia grzywa" {
t.Fatalf("locale all filter = %v", got.Data)
}
var revisions int64
env.db.Raw(`SELECT COUNT(*) FROM golem15_seo_revisions WHERE subject_kind = 'autolink'`).Scan(&revisions)
if revisions != 2 {
t.Fatalf("history rows = %d, want 2", revisions)
}
// The admin form: create through the screen, refused like the API.
rec := env.call(t, http.MethodPost, autolinksAPI, token, map[string]any{"phrase": "xy", "url": "/x"})
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "Use at least 3 characters.") {
t.Fatalf("admin refusal = %d %s", rec.Code, rec.Body.String())
}
// Focus keyphrases: two records with one, one shared with a phrase.
env.createPage(t, token, map[string]any{"title": "Shiitake", "slug": "shiitake", "published": true})
env.createPage(t, token, map[string]any{"title": "Lion", "slug": "lion", "published": true})
env.expect(t, http.StatusOK, http.MethodPut, "/_seo/api/v1/seo", token, map[string]any{"url": "/pages/shiitake", "values": map[string]any{"keyphrase": "twardnik japoński"}})
env.expect(t, http.StatusOK, http.MethodPut, "/_seo/api/v1/seo", token, map[string]any{"url": "/pages/lion", "values": map[string]any{"keyphrase": "lwia grzywa"}})
rec = env.expect(t, http.StatusOK, http.MethodPost, autolinksAPI+"/toolbar/from_keyphrases", token, map[string]any{})
if !strings.Contains(rec.Body.String(), "1 phrase added from focus keyphrases.") {
t.Fatalf("from_keyphrases = %s", rec.Body.String())
}
rec = env.expect(t, http.StatusOK, http.MethodPost, autolinksAPI+"/toolbar/from_keyphrases", token, map[string]any{})
if !strings.Contains(rec.Body.String(), "No new focus keyphrases to add.") {
t.Fatalf("second from_keyphrases = %s", rec.Body.String())
}
got := env.expect(t, http.StatusOK, http.MethodGet, "/_seo/api/v1/autolinks?origin=keyphrase", token, nil).Body.String()
if !jsonContains(got, `"total":1`) || !jsonContains(got, `"phrase":"twardnik japoński"`) || !jsonContains(got, `"url":"/pages/shiitake"`) || !jsonContains(got, `"locale":"en"`) {
t.Fatalf("keyphrase rows = %s", got)
}
if out := env.expect(t, http.StatusOK, http.MethodPost, "/_seo/api/v1/autolinks/from-keyphrases", token, map[string]any{}).Body.String(); !jsonContains(out, `"added":0`) {
t.Fatalf("API from-keyphrases again = %s", out)
}
// MCP: the origin is mcp; update and delete.
agent := env.principal(t, env.admin(t, false, "golem15.seo.manage"))
created := env.mcpExpect(t, http.StatusCreated, http.MethodPost, "/_seo/api/v1/autolinks", agent, map[string]any{"phrase": "maitake", "url": "/maitake"})
data, _ := created["data"].(map[string]any)
if data["origin"] != "mcp" || data["enabled"] != true || data["locale"] != "" {
t.Fatalf("MCP create = %v", created)
}
id := numID(data["id"])
updated := env.mcpExpect(t, http.StatusOK, http.MethodPut, "/_seo/api/v1/autolinks/"+id, agent, map[string]any{"enabled": false})
if d, _ := updated["data"].(map[string]any); d["enabled"] != false || d["phrase"] != "maitake" {
t.Fatalf("MCP update = %v", updated)
}
env.mcpExpect(t, http.StatusOK, http.MethodDelete, "/_seo/api/v1/autolinks?ids="+id+","+off, agent, nil)
if got := env.adminList(t, autolinksAPI, token); len(got.Data) != 2 {
t.Fatalf("after delete = %v", got.Data)
}
env.db.Raw(`SELECT COUNT(*) FROM golem15_seo_revisions WHERE subject_kind = 'autolink'`).Scan(&revisions)
if revisions != 7 {
t.Fatalf("history rows = %d, want 7 (2 created, 1 from keyphrases, mcp create, update, 2 deletes)", revisions)
}
var vias []string
env.db.Raw(`SELECT DISTINCT via FROM golem15_seo_revisions WHERE subject_kind = 'autolink' ORDER BY via`).Scan(&vias)
if strings.Join(vias, ",") != "admin,mcp" {
t.Fatalf("history vias = %v", vias)
}
}