Files
sm-seo-plugin/seo_tab_test.go
Jakub Zych d8aa989dfc fix: entry generation applies an unseen address change instead of overwriting it (WR-19)
refreshEntry set entry.url to the descriptor's address unconditionally, so
a slug change the job saw before any RecordSaved listener lost its 301 for
good. An entry whose URL differs now goes through AddressChanged (via
auto), which creates the 301 and moves the URL; an empty URL is filled.
2026-10-27 15:38:15 +01:00

616 lines
27 KiB
Go

package seo_test
import (
"bytes"
"encoding/json"
"fmt"
"io/fs"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"testing"
seo "git.golem15.com/golem15/sm-seo-plugin"
"git.golem15.com/golem15/sm-seo-plugin/classes"
"git.golem15.com/golem15/summercms/modules/bouncer"
"github.com/goccy/go-yaml"
)
// seoTabLabel is the English label of seo.TabSEO.
const seoTabLabel = "SEO"
func urlQueryEscape(s string) string { return url.QueryEscape(s) }
// seoField returns the schema field name, or fails.
func findField(t *testing.T, fields []schemaField, name string) schemaField {
t.Helper()
for _, f := range fields {
if f.Name == name {
return f
}
}
t.Fatalf("schema has no field %q: %+v", name, fields)
return schemaField{}
}
// TestSEOTabTracer is the plan's tracer: an editor sets a record's SEO
// title in the SEO tab the plugin adds to a content type it does not know,
// the value lands in the plugin's own table, and the public resolve answer
// returns it.
func TestSEOTabTracer(t *testing.T) {
env := bootEnv(t)
token := env.admin(t, true)
fields := env.formSchema(t, token, "acme/pages/pages")
// The schema serves tab labels translated: golem15.seo::lang.tab.seo
// is "SEO" in English.
if f := findField(t, fields, "seo_title"); f.Tab != seoTabLabel {
t.Fatalf("seo_title tab = %q, want %q (%s)", f.Tab, seoTabLabel, seo.TabSEO)
}
if last := fields[len(fields)-1]; last.Tab != seoTabLabel {
t.Fatalf("last field %+v, want the SEO tab last", last)
}
id := env.createPage(t, token, map[string]any{"title": "About us", "slug": "about", "excerpt": "Who we are", "published": true})
got := env.resolve(t, "/pages/about")
seoBody, _ := got["seo"].(map[string]any)
if got["action"] != "ok" || seoBody["title"] != "About us" {
t.Fatalf("resolve before the SEO title = %v, want the content title", got)
}
env.expect(t, http.StatusOK, http.MethodPut, adminAPI("/acme/pages/pages/"+id), token, map[string]any{
"title": "About us", "slug": "about", "excerpt": "Who we are", "published": true,
"seo_title": map[string]any{"en": "Custom"},
})
got = env.resolve(t, "/pages/about")
seoBody, _ = got["seo"].(map[string]any)
if got["action"] != "ok" || seoBody["title"] != "Custom" {
t.Fatalf("resolve = %v, want seo.title Custom", got)
}
if got["url"] != siteURL+"/pages/about" || seoBody["canonical"] != siteURL+"/pages/about" {
t.Fatalf("url/canonical = %v / %v, want the configured site URL, never the request Host", got["url"], seoBody["canonical"])
}
var stored string
if err := env.db.Raw(`SELECT title FROM golem15_seo_entries WHERE model_type = ? AND record_id = ? AND locale = 'en'`, pagesModel, id).Scan(&stored).Error; err != nil || stored != "Custom" {
t.Fatalf("stored entry title = %q err=%v", stored, err)
}
var seoColumns int64
if err := env.db.Raw(`SELECT COUNT(*) FROM information_schema.columns WHERE table_name = ? AND column_name LIKE 'seo%'`, pagesModel).Scan(&seoColumns).Error; err != nil || seoColumns != 0 {
t.Fatalf("owner table SEO columns = %d err=%v, want none", seoColumns, err)
}
show := env.expect(t, http.StatusOK, http.MethodGet, adminAPI("/acme/pages/pages/"+id), token, nil)
if !jsonContains(show.Body.String(), `"seo_title":{"en":"Custom"}`) {
t.Fatalf("show body lacks the stored SEO title: %s", show.Body.String())
}
got = env.resolve(t, "/pl/pages/about")
seoBody, _ = got["seo"].(map[string]any)
if got["locale"] != "pl" || seoBody["title"] != "About us" {
t.Fatalf("pl resolve = %v, want the content title (no pl SEO title)", got)
}
if got := env.resolve(t, "/pages/missing"); got["action"] != "not_found" {
t.Fatalf("unknown path = %v, want not_found", got)
}
}
// TestSEOTabPermission checks D-15 on the SEO tab: an administrator
// without golem15.seo.manage gets no SEO fields, and a posted seo_title
// changes nothing.
func TestSEOTabPermission(t *testing.T) {
env := bootEnv(t)
super := env.admin(t, true)
editor := env.admin(t, false, pagesPermission)
for _, f := range env.formSchema(t, editor, "acme/pages/pages") {
if strings.HasPrefix(f.Name, "seo_") {
t.Fatalf("editor without golem15.seo.manage sees %s", f.Name)
}
}
id := env.createPage(t, editor, map[string]any{"title": "About", "slug": "about", "published": true, "seo_title": map[string]any{"en": "Sneaky"}})
var count int64
if err := env.db.Raw(`SELECT COUNT(*) FROM golem15_seo_entries WHERE title <> ''`).Scan(&count).Error; err != nil || count != 0 {
t.Fatalf("entries with a title after an editor create = %d err=%v, want 0", count, err)
}
env.expect(t, http.StatusOK, http.MethodPut, adminAPI("/acme/pages/pages/"+id), super, map[string]any{
"title": "About", "slug": "about", "published": true, "seo_title": map[string]any{"en": "Custom"},
})
env.expect(t, http.StatusOK, http.MethodPut, adminAPI("/acme/pages/pages/"+id), editor, map[string]any{
"title": "About us", "slug": "about", "published": true, "seo_title": map[string]any{"en": "Hacked"},
})
got := env.resolve(t, "/pages/about")
if seoBody, _ := got["seo"].(map[string]any); seoBody["title"] != "Custom" {
t.Fatalf("resolve = %v, want the title set with the permission", got)
}
show := env.expect(t, http.StatusOK, http.MethodGet, adminAPI("/acme/pages/pages/"+id), editor, nil)
if strings.Contains(show.Body.String(), "seo_title") {
t.Fatalf("editor show carries SEO values: %s", show.Body.String())
}
}
// revisionRow is a history row as the tests read it.
type revisionRow struct {
Via string
SubjectKind string
SubjectID string
What string
AuthorLabel string
Before string
After string
}
func (e *testEnv) revisions(t *testing.T, kind string) []revisionRow {
t.Helper()
var rows []revisionRow
if err := e.db.Raw(`SELECT via, subject_kind, subject_id, what, author_label, COALESCE(before::text, '') AS before, COALESCE(after::text, '') AS after
FROM golem15_seo_revisions WHERE subject_kind = ? ORDER BY id`, kind).Scan(&rows).Error; err != nil {
t.Fatal(err)
}
return rows
}
// TestHistoryRecordsAdminWrite checks D-13 for admin writes: an SEO tab
// save writes one revision with via admin, the author and the changed
// field before and after; an unchanged save writes none; rule create,
// update and delete and a settings save are recorded too, and the history
// list and preview serve them.
func TestHistoryRecordsAdminWrite(t *testing.T) {
env := bootEnv(t)
token := env.admin(t, true)
id := env.createPage(t, token, map[string]any{"title": "About", "slug": "about", "published": true, "seo_title": map[string]any{"en": "First"}})
env.expect(t, http.StatusOK, http.MethodPut, adminAPI("/acme/pages/pages/"+id), token, map[string]any{
"title": "About", "slug": "about", "published": true, "seo_title": map[string]any{"en": "Second"},
})
env.expect(t, http.StatusOK, http.MethodPut, adminAPI("/acme/pages/pages/"+id), token, map[string]any{
"title": "About", "slug": "about", "published": true, "seo_title": map[string]any{"en": "Second"},
})
rows := env.revisions(t, "entry")
if len(rows) != 2 {
t.Fatalf("entry revisions = %+v, want create and one change", rows)
}
last := rows[1]
if last.Via != "admin" || last.SubjectID != pagesModel+"|"+id+"|en" || !strings.Contains(last.What, "About") || !strings.Contains(last.What, "(en)") || last.AuthorLabel == "" {
t.Fatalf("revision = %+v", last)
}
if !jsonContains(last.Before, `"title": "First"`) || !jsonContains(last.After, `"title": "Second"`) || strings.Contains(last.After, "description") {
t.Fatalf("revision before/after = %s / %s, want the title only", last.Before, last.After)
}
rec := env.expect(t, http.StatusCreated, http.MethodPost, adminAPI("/golem15/seo/rules"), token, map[string]any{"url": "/landing", "match": "exact", "title": "Landing"})
var created struct {
Data struct {
ID json.Number `json:"id"`
} `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &created); err != nil {
t.Fatal(err)
}
ruleID := created.Data.ID.String()
env.expect(t, http.StatusOK, http.MethodPut, adminAPI("/golem15/seo/rules/"+ruleID), token, map[string]any{"url": "/landing", "match": "exact", "title": "Landing page"})
env.expect(t, http.StatusOK, http.MethodDelete, adminAPI("/golem15/seo/rules/"+ruleID), token, nil)
rules := env.revisions(t, "rule")
if len(rules) != 3 || rules[0].Before != "{}" || !jsonContains(rules[1].After, `"title": "Landing page"`) || rules[2].After != "{}" || !jsonContains(rules[2].Before, `"url": "/landing"`) {
t.Fatalf("rule revisions = %+v", rules)
}
env.expect(t, http.StatusOK, http.MethodPut, adminAPI("/golem15/seo/settings/1"), token, map[string]any{"twitter_site": "@acme", "type_acme_page_robots": "noindex,follow"})
settings := env.revisions(t, "settings")
if len(settings) != 2 || !jsonContains(settings[0].After, `"twitter_site": "@acme"`) || settings[1].SubjectID != "type:"+pagesTypeKey {
t.Fatalf("settings revisions = %+v", settings)
}
list := env.expect(t, http.StatusOK, http.MethodGet, adminAPI("/golem15/seo/history"), token, nil)
if !strings.Contains(list.Body.String(), "SEO title: First → Second") || !strings.Contains(list.Body.String(), `"via_label":"Admin"`) {
t.Fatalf("history list = %s", list.Body.String())
}
var firstID uint
if err := env.db.Raw(`SELECT id FROM golem15_seo_revisions WHERE subject_kind = 'entry' ORDER BY id DESC LIMIT 1`).Scan(&firstID).Error; err != nil {
t.Fatal(err)
}
partial := env.expect(t, http.StatusOK, http.MethodGet, adminAPI(fmt.Sprintf("/golem15/seo/history/partials/history_diff?id=%d", firstID)), token, nil)
if !strings.Contains(partial.Body.String(), "Second") {
t.Fatalf("history diff partial = %s", partial.Body.String())
}
mcp := bouncer.WithDispatch(t.Context(), &bouncer.Dispatch{Guards: []string{"backend"}, Principal: &bouncer.Principal{ID: 1, Backend: true}})
if got := classes.ViaOf(mcp); got != "mcp" {
t.Fatalf("ViaOf(MCP dispatch) = %q, want mcp", got)
}
if got := classes.ViaOf(classes.WithVia(mcp, "import")); got != "import" {
t.Fatalf("ViaOf(WithVia import) = %q", got)
}
}
// TestEntryDeletedWithRecord checks D-92/D-94: a created record gets its
// address snapshot per locale, and deleting it removes its entries after
// commit while history keeps the values (via auto).
func TestEntryDeletedWithRecord(t *testing.T) {
env := bootEnv(t)
token := env.admin(t, true)
id := env.createPage(t, token, map[string]any{"title": "About", "slug": "about", "published": true, "seo_title": map[string]any{"en": "Kept in history"}})
var urls []string
if err := env.db.Raw(`SELECT url FROM golem15_seo_entries WHERE model_type = ? AND record_id = ? ORDER BY locale`, pagesModel, id).Scan(&urls).Error; err != nil {
t.Fatal(err)
}
if strings.Join(urls, " ") != "/pages/about /pl/pages/about" {
t.Fatalf("entry URLs after create = %v, want both locales", urls)
}
env.expect(t, http.StatusOK, http.MethodDelete, adminAPI("/acme/pages/pages/"+id), token, nil)
var count int64
if err := env.db.Raw(`SELECT COUNT(*) FROM golem15_seo_entries WHERE record_id = ?`, id).Scan(&count).Error; err != nil || count != 0 {
t.Fatalf("entries after delete = %d err=%v, want 0", count, err)
}
var deleted []revisionRow
for _, r := range env.revisions(t, "entry") {
if r.Via == "auto" {
deleted = append(deleted, r)
}
}
if len(deleted) != 1 || deleted[0].After != "{}" || !jsonContains(deleted[0].Before, `"title": "Kept in history"`) || deleted[0].SubjectID != pagesModel+"|"+id+"|en" {
t.Fatalf("deletion revisions = %+v, want one with the title before", deleted)
}
if got := env.resolve(t, "/pages/about"); got["action"] != "not_found" {
t.Fatalf("resolve after delete = %v", got)
}
}
// TestGenerateEntries checks the D-94 generation job's work: every
// published record gets an entry per locale with its URL, drafts none, and
// a second run applies address changes (with their 301) without touching
// editor values.
func TestGenerateEntries(t *testing.T) {
env := bootEnv(t)
if err := env.db.Exec(`INSERT INTO acme_pages_pages (title, slug, excerpt, published) VALUES ('One', 'one', 'First', TRUE), ('Two', 'two', '', TRUE), ('Draft', 'draft', '', FALSE)`).Error; err != nil {
t.Fatal(err)
}
svc := env.seoPlugin(t).Service()
res, err := svc.GenerateEntries(t.Context(), pagesTypeKey)
if err != nil {
t.Fatal(err)
}
if res.Created != 4 || res.Updated != 0 {
t.Fatalf("first run = %+v, want 4 created (2 records x 2 locales)", res)
}
type row struct {
RecordID string
Locale string
URL string
Title string
EffectiveTitle string
}
var rows []row
if err := env.db.Raw(`SELECT record_id, locale, url, title, effective_title FROM golem15_seo_entries ORDER BY record_id, locale`).Scan(&rows).Error; err != nil {
t.Fatal(err)
}
if len(rows) != 4 || rows[0].URL != "/pages/one" || rows[1].URL != "/pl/pages/one" || rows[0].EffectiveTitle != "One" || rows[2].URL != "/pages/two" {
t.Fatalf("entries = %+v", rows)
}
if err := env.db.Exec(`UPDATE golem15_seo_entries SET title = 'Editor title' WHERE record_id = ? AND locale = 'en'`, rows[0].RecordID).Error; err != nil {
t.Fatal(err)
}
if err := env.db.Exec(`UPDATE acme_pages_pages SET slug = 'one-renamed' WHERE slug = 'one'`).Error; err != nil {
t.Fatal(err)
}
if res, err = svc.GenerateEntries(t.Context(), ""); err != nil || res.Created != 0 || res.Updated != 4 {
t.Fatalf("second run = %+v err=%v, want 4 updated", res, err)
}
var again row
if err := env.db.Raw(`SELECT record_id, locale, url, title, effective_title FROM golem15_seo_entries WHERE record_id = ? AND locale = 'en'`, rows[0].RecordID).Scan(&again).Error; err != nil {
t.Fatal(err)
}
if again.Title != "Editor title" || again.URL != "/pages/one-renamed" || again.EffectiveTitle != "Editor title" {
t.Fatalf("after the second run = %+v, want the editor title kept and the URL refreshed", again)
}
// The job applied the address change no RecordSaved announced, with
// its 301 (WR-19), in both locales.
redirects := env.redirects(t)
if redirectTo(redirects, "/pages/one") != "/pages/one-renamed" || redirectTo(redirects, "/pl/pages/one") != "/pl/pages/one-renamed" {
t.Fatalf("redirects after the job saw the slug change = %+v, want /pages/one and /pl/pages/one to 301", redirects)
}
if res, err = svc.GenerateEntries(t.Context(), ""); err != nil || res.Updated != 4 || len(env.redirects(t)) != len(redirects) {
t.Fatalf("third run = %+v err=%v, want no new redirect", res, err)
}
if _, err := svc.GenerateEntries(t.Context(), "nope"); err == nil {
t.Fatal("unknown type: want an error")
}
}
// pngBytes is a 1x1 PNG.
var pngBytes = []byte{
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4,
0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00,
0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae,
0x42, 0x60, 0x82,
}
// upload posts one file to a plugin field's upload route.
func (e *testEnv) upload(t *testing.T, token, path, filename string, body []byte) *httptest.ResponseRecorder {
t.Helper()
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
part, err := mw.CreateFormFile("file", filename)
if err != nil {
t.Fatal(err)
}
if _, err := part.Write(body); err != nil {
t.Fatal(err)
}
if err := mw.Close(); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, path, &buf)
req.Header.Set("Content-Type", mw.FormDataContentType())
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("X-Requested-With", "XMLHttpRequest")
req.RemoteAddr = "198.51.100.7:4000"
rec := httptest.NewRecorder()
e.h.ServeHTTP(rec, req)
return rec
}
// TestSEOTab checks the complete SEO tab (UI-SPEC C3): the field order in
// the tab placed last on a tabbed form and after the default tab on a
// tab-less one, the robots and canonical refusals, the social image upload
// and its one value for every locale, the result-preview widget action and
// the P1/P2 partials.
func TestSEOTab(t *testing.T) {
env := bootEnv(t)
token := env.admin(t, true)
want := "seo_status seo_snippet seo_title seo_description seo_keyphrase seo_canonical seo_robots seo_og_title seo_og_description seo_og_image seo_effective"
fields := env.formSchema(t, token, "acme/pages/pages")
var seoFields []string
for _, f := range fields {
if strings.HasPrefix(f.Name, "seo_") {
seoFields = append(seoFields, f.Name)
if f.Tab != seoTabLabel {
t.Fatalf("%s is in tab %q", f.Name, f.Tab)
}
}
}
if strings.Join(seoFields, " ") != want {
t.Fatalf("SEO tab fields = %v, want %s", seoFields, want)
}
if tabs := tabsOf(fields); tabs[len(tabs)-1] != seoTabLabel || len(tabs) != 4 {
t.Fatalf("tabs = %q, want the owner's three tabs then SEO", tabs)
}
if tabs := tabsOf(env.formSchema(t, token, "acme/pages/notes")); len(tabs) != 2 || tabs[1] != seoTabLabel {
t.Fatalf("tab-less form tabs = %q, want default then SEO", tabs)
}
id := env.createPage(t, token, map[string]any{"title": "Grzyby leśne", "slug": "grzyby", "excerpt": "O grzybach", "published": true})
other := env.createPage(t, token, map[string]any{"title": "Other", "slug": "other", "published": true, "seo_title": map[string]any{"en": "Duplicate title"}})
path := adminAPI("/acme/pages/pages/" + id)
base := map[string]any{"title": "Grzyby leśne", "slug": "grzyby", "excerpt": "O grzybach", "published": true}
with := func(extra map[string]any) map[string]any {
out := map[string]any{}
for k, v := range base {
out[k] = v
}
for k, v := range extra {
out[k] = v
}
return out
}
rec := env.call(t, http.MethodPut, path, token, with(map[string]any{"seo_robots": map[string]any{"en": "index,banana"}}))
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "seo_robots") {
t.Fatalf("malformed robots = %d %s, want 422 on seo_robots", rec.Code, rec.Body.String())
}
rec = env.call(t, http.MethodPut, path, token, with(map[string]any{"seo_canonical": map[string]any{"en": "javascript:alert(1)"}}))
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "seo_canonical") {
t.Fatalf("bad canonical = %d %s, want 422 on seo_canonical", rec.Code, rec.Body.String())
}
uploadPath := adminAPI("/acme/pages/pages/" + id + "/fields/seo_og_image/upload")
if rec := env.upload(t, token, uploadPath, "evil.png", []byte("<svg onload=alert(1)>")); rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("non-image upload = %d %s, want 422", rec.Code, rec.Body.String())
}
editor := env.admin(t, false, pagesPermission)
if rec := env.upload(t, editor, uploadPath, "ok.png", pngBytes); rec.Code == http.StatusCreated {
t.Fatalf("upload without golem15.seo.manage = %d, want a refusal", rec.Code)
}
rec = env.upload(t, token, uploadPath, "Social Image.PNG", pngBytes)
if rec.Code != http.StatusCreated {
t.Fatalf("image upload = %d %s", rec.Code, rec.Body.String())
}
var uploaded struct {
Data struct {
URL string `json:"url"`
} `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &uploaded); err != nil || !strings.Contains(uploaded.Data.URL, "/media/seo/") || !strings.HasSuffix(uploaded.Data.URL, "-social-image.png") {
t.Fatalf("upload answer %s err=%v", rec.Body.String(), err)
}
env.expect(t, http.StatusOK, http.MethodPut, path, token, with(map[string]any{
"seo_title": map[string]any{"en": "Grzyby leśne z Podlasia"},
"seo_description": map[string]any{"en": "Duplicate description", "pl": "Opis"},
"seo_robots": map[string]any{"en": "NOINDEX, max-snippet:50"},
"seo_canonical": map[string]any{"en": "/canonical"},
"seo_og_image": uploaded.Data.URL,
}))
var images []string
if err := env.db.Raw(`SELECT og_image FROM golem15_seo_entries WHERE record_id = ? ORDER BY locale`, id).Scan(&images).Error; err != nil {
t.Fatal(err)
}
if len(images) != 2 || images[0] != uploaded.Data.URL || images[1] != uploaded.Data.URL {
t.Fatalf("og_image per locale = %v, want the one image in en and pl", images)
}
var robots string
if err := env.db.Raw(`SELECT robots FROM golem15_seo_entries WHERE record_id = ? AND locale = 'en'`, id).Scan(&robots).Error; err != nil || robots != "noindex,max-snippet:50" {
t.Fatalf("stored robots = %q err=%v, want the normalised value", robots, err)
}
show := env.expect(t, http.StatusOK, http.MethodGet, path, token, nil)
if !strings.Contains(show.Body.String(), `"seo_og_image":"`+uploaded.Data.URL+`"`) {
t.Fatalf("show lacks the social image: %s", show.Body.String())
}
env.expect(t, http.StatusOK, http.MethodPut, adminAPI("/acme/pages/pages/"+other), token, map[string]any{
"title": "Other", "slug": "other", "published": true, "seo_description": map[string]any{"en": "Duplicate description"},
})
numericID := func(s string) int { n, _ := strconv.Atoi(s); return n }
widget := func(payload map[string]any) map[string]any {
t.Helper()
rec := env.expect(t, http.StatusOK, http.MethodPost, adminAPI("/acme/pages/pages/widgets/seo_snippet"), token, map[string]any{"record_id": numericID(id), "payload": payload})
var out struct {
Data struct {
Data map[string]any `json:"data"`
} `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
if out.Data.Data == nil {
t.Fatalf("widget answer %s", rec.Body.String())
}
return out.Data.Data
}
ctxData := widget(map[string]any{"op": "context"})
locales, _ := ctxData["locales"].([]any)
first, _ := locales[0].(map[string]any)
if ctxData["host"] != "www.example.test" || len(locales) != 2 || first["locale"] != "en" || first["url"] != "/pages/grzyby" || first["titleTemplate"] != "%title% | %site%" || first["descriptionSource"] != "content" {
t.Fatalf("context = %v", ctxData)
}
dups := widget(map[string]any{"op": "duplicates", "locale": "en", "title": "Grzyby leśne z Podlasia", "description": "Duplicate description"})
if d, _ := dups["description"].([]any); len(d) != 1 || !strings.Contains(fmtJSON(d), "/pages/other") || !strings.Contains(fmtJSON(d), "/backend/acme/pages/pages/update/"+other) {
t.Fatalf("duplicates = %v, want the other page", dups)
}
if d, _ := dups["title"].([]any); len(d) != 0 {
t.Fatalf("title duplicates = %v, want none", d)
}
env.expect(t, http.StatusCreated, http.MethodPost, adminAPI("/golem15/seo/rules"), token, map[string]any{"url": "/pages/grzyby", "match": "exact", "title": "Rule title", "robots": "index"})
status := env.expect(t, http.StatusOK, http.MethodGet, adminAPI("/acme/pages/pages/partials/seo_status?id="+id), token, nil)
if !strings.Contains(status.Body.String(), "A URL rule overrides part of this tab") || !strings.Contains(status.Body.String(), "SEO title, Search engine indexing") {
t.Fatalf("seo_status partial = %s", status.Body.String())
}
effective := env.expect(t, http.StatusOK, http.MethodGet, adminAPI("/acme/pages/pages/partials/seo_effective?id="+id), token, nil)
for _, part := range []string{"Rule title", "URL rule", "/canonical", "This record"} {
if !strings.Contains(effective.Body.String(), part) {
t.Fatalf("seo_effective partial lacks %q: %s", part, effective.Body.String())
}
}
}
// TestRuleFormPreview checks the URL rule form's partials and preview
// widget: an exact rule resolves at its address, a pattern rule lists its
// own values, and the preview context carries the saved rule.
func TestRuleFormPreview(t *testing.T) {
env := bootEnv(t)
token := env.admin(t, true)
create := func(body map[string]any) string {
rec := env.expect(t, http.StatusCreated, http.MethodPost, adminAPI("/golem15/seo/rules"), token, body)
var out struct {
Data struct {
ID json.Number `json:"id"`
} `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
return out.Data.ID.String()
}
exact := create(map[string]any{"url": "/", "match": "exact", "title": "Home rule", "robots": "noindex,follow"})
pattern := create(map[string]any{"url": "/blog?tag=*", "match": "pattern", "description": "Tag pages"})
rec := env.call(t, http.MethodPost, adminAPI("/golem15/seo/rules"), token, map[string]any{"url": "/x", "match": "exact", "robots": "index,banana"})
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "robots") {
t.Fatalf("malformed rule robots = %d %s, want 422", rec.Code, rec.Body.String())
}
body := env.expect(t, http.StatusOK, http.MethodGet, adminAPI("/golem15/seo/rules/partials/seo_effective?id="+exact), token, nil).Body.String()
if !strings.Contains(body, "Home rule") || !strings.Contains(body, "noindex, follow") {
t.Fatalf("exact rule P2 = %s", body)
}
body = env.expect(t, http.StatusOK, http.MethodGet, adminAPI("/golem15/seo/rules/partials/seo_effective?id="+pattern), token, nil).Body.String()
if !strings.Contains(body, "Tag pages") || !strings.Contains(body, "URL rule") {
t.Fatalf("pattern rule P2 = %s", body)
}
n, _ := strconv.Atoi(exact)
body = env.expect(t, http.StatusOK, http.MethodPost, adminAPI("/golem15/seo/rules/widgets/_seo_snippet"), token, map[string]any{"record_id": n, "payload": map[string]any{"op": "context"}}).Body.String()
if !strings.Contains(body, `"defaultTitle":"Home rule"`) || !strings.Contains(body, `"url":"/"`) {
t.Fatalf("rule preview context = %s", body)
}
}
// langKeyPattern finds golem15.seo::lang.<key> references.
var langKeyPattern = regexp.MustCompile(`golem15\.seo::lang\.([a-z0-9_.]+[a-z0-9_])`)
// TestLangKeysComplete checks that every golem15.seo::lang key the plugin's
// Go, YAML and template files name exists in both phrasebooks.
func TestLangKeysComplete(t *testing.T) {
books := map[string]map[string]any{}
for _, locale := range []string{"en", "pl"} {
raw, err := os.ReadFile(filepath.Join("lang", locale, "lang.yaml"))
if err != nil {
t.Fatal(err)
}
var book map[string]any
if err := yaml.Unmarshal(raw, &book); err != nil {
t.Fatal(err)
}
books[locale] = book
}
has := func(book map[string]any, key string) bool {
var cur any = book
for _, part := range strings.Split(key, ".") {
m, ok := cur.(map[string]any)
if !ok {
return false
}
if cur, ok = m[part]; !ok {
return false
}
}
return cur != nil
}
seen := map[string]bool{}
err := filepath.WalkDir(".", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() && (path == "node_modules" || path == ".git" || path == "assets") {
return filepath.SkipDir
}
ext := filepath.Ext(path)
if d.IsDir() || (ext != ".go" && ext != ".yaml" && ext != ".htm") || strings.HasPrefix(path, "lang") {
return nil
}
raw, err := os.ReadFile(path)
if err != nil {
return err
}
for _, m := range langKeyPattern.FindAllStringSubmatch(string(raw), -1) {
key := m[1]
if strings.HasSuffix(key, "via_") || strings.HasSuffix(key, "source_") {
continue
}
seen[key] = true
for locale, book := range books {
if !has(book, key) {
t.Errorf("%s: golem15.seo::lang.%s is missing from lang/%s/lang.yaml", path, key, locale)
}
}
}
return nil
})
if err != nil {
t.Fatal(err)
}
if len(seen) < 50 {
t.Fatalf("found only %d keys; the scan is broken", len(seen))
}
}