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:
125
linking_test.go
125
linking_test.go
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user