Files
sm-seo-plugin/seo_tab_test.go

606 lines
26 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 refreshes addresses 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)
}
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))
}
}