310 lines
14 KiB
Go
310 lines
14 KiB
Go
package seo_test
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"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"
|
|
)
|
|
|
|
// 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")
|
|
}
|
|
}
|