- Site audit tab (UI-SPEC C20): crawl_schedule, crawl_max_pages (10-50 000), crawl_delay (100-5000 ms), crawl_keep (1-50) and generic_anchors seeded with the en and pl lists; ranges refuse with lagoon::validation.between on the screen and through the API - the crawler reads its limits and generic anchors from the settings; crawls beyond crawl_keep are deleted with their pages and issues - seo:crawl [--scheduled] and the nightly schedule entry at 02:30 (P33-40): off does nothing, weekly runs on Mondays in app.timezone - TestCrawlSchedule
283 lines
13 KiB
Go
283 lines
13 KiB
Go
package seo_test
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// tabsOf returns the schema's tab labels in first-appearance order.
|
|
func tabsOf(fields []schemaField) []string {
|
|
var out []string
|
|
seen := map[string]bool{}
|
|
for _, f := range fields {
|
|
if !seen[f.Tab] {
|
|
seen[f.Tab] = true
|
|
out = append(out, f.Tab)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// TestSettingsTypeTabs checks the D-104 per-type tabs on the settings
|
|
// singleton: one tab per content type in registration order between
|
|
// General and Indexing, their values in golem15_seo_type_settings with the
|
|
// D-21 defaults, template checks, and the inject switch hiding the SEO tab.
|
|
func TestSettingsTypeTabs(t *testing.T) {
|
|
// The SEO plugin's own page registry (seo-page, "Site page") is a
|
|
// content type too, registered after the fixture's.
|
|
t.Run("three content types", func(t *testing.T) {
|
|
env := bootEnv(t)
|
|
token := env.admin(t, true)
|
|
fields := env.formSchema(t, token, "golem15/seo/settings")
|
|
if got, want := strings.Join(tabsOf(fields), " | "), "General | Page | Tags | Notes | Site page | Indexing | Addresses | Pagination | Links | Publisher | Local business | Site audit | Verification"; got != want {
|
|
t.Fatalf("settings tabs = %q, want %q", got, want)
|
|
}
|
|
findField(t, fields, "type_acme_page_inject")
|
|
findField(t, fields, "type_acme_tag_robots")
|
|
|
|
show := env.expect(t, http.StatusOK, http.MethodGet, adminAPI("/golem15/seo/settings/1"), token, nil)
|
|
var body struct {
|
|
Data map[string]any `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(show.Body.Bytes(), &body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if body.Data["type_acme_page_inject"] != true || body.Data["type_acme_tag_robots"] != "noindex,follow" || body.Data["type_acme_page_robots"] != "" {
|
|
t.Fatalf("per-type defaults = %v", body.Data)
|
|
}
|
|
|
|
rec := env.call(t, http.MethodPut, adminAPI("/golem15/seo/settings/1"), token, map[string]any{
|
|
"type_acme_page_title_template": "%title% - %nope%",
|
|
})
|
|
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "type_acme_page_title_template") || !strings.Contains(rec.Body.String(), "%nope%") {
|
|
t.Fatalf("unknown placeholder = %d %s, want 422 on the type field naming %%nope%%", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
id := env.createPage(t, token, map[string]any{"title": "About", "slug": "about", "published": true, "seo_title": map[string]any{"en": "Stored"}})
|
|
env.expect(t, http.StatusOK, http.MethodPut, adminAPI("/golem15/seo/settings/1"), token, map[string]any{
|
|
"type_acme_page_inject": false,
|
|
"type_acme_page_title_template": "%title% · Pages",
|
|
})
|
|
var row struct {
|
|
Inject bool
|
|
TitleTemplate string
|
|
}
|
|
if err := env.db.Raw(`SELECT inject, title_template FROM golem15_seo_type_settings WHERE type_key = ?`, pagesTypeKey).Scan(&row).Error; err != nil || row.Inject || row.TitleTemplate != "%title% · Pages" {
|
|
t.Fatalf("type settings row = %+v err=%v", row, err)
|
|
}
|
|
for _, f := range env.formSchema(t, token, "acme/pages/pages") {
|
|
if strings.HasPrefix(f.Name, "seo_") {
|
|
t.Fatalf("SEO tab field %s shown with inject off", f.Name)
|
|
}
|
|
}
|
|
got := env.resolve(t, "/pages/about")
|
|
if seoBody, _ := got["seo"].(map[string]any); seoBody["title"] != "Stored" {
|
|
t.Fatalf("resolve with the tab off = %v, want the stored title (id %s)", got, id)
|
|
}
|
|
})
|
|
|
|
t.Run("no other content types", func(t *testing.T) {
|
|
env := bootPlugins(t, []string{"golem15.seo"})
|
|
token := env.admin(t, true)
|
|
if got, want := strings.Join(tabsOf(env.formSchema(t, token, "golem15/seo/settings")), " | "), "General | Site page | Indexing | Addresses | Pagination | Links | Publisher | Local business | Site audit | Verification"; got != want {
|
|
t.Fatalf("settings tabs = %q, want %q", got, want)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestResolverPrecedence covers each P-14 chain of the resolver: the value
|
|
// and its source per case, with URL rules (exact and pattern), records,
|
|
// per-type templates and defaults, content, the settings, the D-07
|
|
// parameter list, the never-indexed and search paths, the home page and
|
|
// the non-production lock.
|
|
func TestResolverPrecedence(t *testing.T) {
|
|
env := bootEnv(t)
|
|
token := env.admin(t, true)
|
|
|
|
env.expect(t, http.StatusOK, http.MethodPut, adminAPI("/golem15/seo/settings/1"), token, map[string]any{
|
|
"site_name": map[string]any{"en": "Acme", "pl": "Akme"},
|
|
"default_description": map[string]any{"en": "Site default description"},
|
|
"default_og_image": "/media/default.png",
|
|
"twitter_site": "@acme",
|
|
"verify_google": "g-code",
|
|
"noindex_paths": "/konto/*\n/logowanie",
|
|
"search_paths": "/szukaj",
|
|
"query_params": []map[string]any{
|
|
{"name": "page", "kind": "content", "path": ""},
|
|
{"name": "utm_*", "kind": "tracking", "path": ""},
|
|
{"name": "sort", "kind": "filter", "path": ""},
|
|
{"name": "color", "kind": "filter", "path": "/pages/*"},
|
|
},
|
|
"type_acme_tag_description_template": "Posts tagged %title%",
|
|
})
|
|
about := env.createPage(t, token, map[string]any{"title": "About", "slug": "about", "excerpt": "Who we are", "published": true})
|
|
custom := env.createPage(t, token, map[string]any{"title": "Custom", "slug": "custom", "excerpt": "Custom excerpt", "published": true})
|
|
env.createPage(t, token, map[string]any{"title": "Bare", "slug": "bare", "published": true})
|
|
env.createPage(t, token, map[string]any{"title": "Draft", "slug": "draft", "excerpt": "Secret", "published": false})
|
|
if err := env.db.Exec(`INSERT INTO golem15_seo_entries (model_type, record_id, locale, type_key, title, description, canonical, robots, og_image, created_at, updated_at)
|
|
VALUES (?, ?, 'en', ?, 'Custom SEO title', 'Custom SEO description', '/canonical-custom', 'noindex', '/media/custom.png', NOW(), NOW())
|
|
ON CONFLICT (model_type, record_id, locale) DO UPDATE SET title = EXCLUDED.title, description = EXCLUDED.description,
|
|
canonical = EXCLUDED.canonical, robots = EXCLUDED.robots, og_image = EXCLUDED.og_image`, pagesModel, custom, pagesTypeKey).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, rule := range []map[string]any{
|
|
{"url": "/pages/custom", "match": "exact", "og_title": "Rule social title"},
|
|
{"url": "/blog?tag=*", "match": "pattern", "title": "Tag archive", "robots": "noindex,follow"},
|
|
{"url": "/landing", "match": "exact", "title": "Landing", "description": "Landing description", "canonical": "https://other.example/landing", "og_image": "https://cdn.example/landing.png"},
|
|
{"url": "/konto/profile", "match": "exact", "title": "Profile"},
|
|
{"url": "/szukaj", "match": "exact", "title": "Search"},
|
|
{"url": "/pages/about", "match": "exact", "robots": "max-snippet:50"},
|
|
} {
|
|
env.expect(t, http.StatusCreated, http.MethodPost, adminAPI("/golem15/seo/rules"), token, rule)
|
|
}
|
|
_ = about
|
|
|
|
resolver := env.seoPlugin(t).Service().Resolver()
|
|
type expectation struct {
|
|
value, source string
|
|
}
|
|
cases := []struct {
|
|
name string
|
|
url string
|
|
want map[string]expectation // title, description, canonical, robots, og_title, og_description, og_image
|
|
}{
|
|
{"title from the general template, description from content, self canonical, default robots", "/pages/about", map[string]expectation{
|
|
"title": {"About | Acme", "template"},
|
|
"description": {"Who we are", "content"},
|
|
"canonical": {siteURL + "/pages/about", "default"},
|
|
"robots": {"index, follow, max-snippet:50", "default"},
|
|
"og_title": {"About | Acme", "template"},
|
|
"og_image": {siteURL + "/media/default.png", "global"},
|
|
}},
|
|
{"record values win over templates; a rule wins over the record", "/pages/custom", map[string]expectation{
|
|
"title": {"Custom SEO title", "record"},
|
|
"description": {"Custom SEO description", "record"},
|
|
"canonical": {siteURL + "/canonical-custom", "record"},
|
|
"robots": {"noindex, follow", "record"},
|
|
"og_title": {"Rule social title", "rule"},
|
|
"og_description": {"Custom SEO description", "record"},
|
|
"og_image": {siteURL + "/media/custom.png", "record"},
|
|
}},
|
|
{"description falls back to the settings", "/pages/bare", map[string]expectation{
|
|
"description": {"Site default description", "global"},
|
|
}},
|
|
{"type description template and the D-21 tag default", "/pages/tag/go", map[string]expectation{
|
|
"title": {"Tag go | Acme", "template"},
|
|
"description": {"Posts tagged Tag go", "template"},
|
|
"robots": {"noindex, follow", "template"},
|
|
}},
|
|
{"pattern rule on a URL without a record", "/blog?tag=go", map[string]expectation{
|
|
"title": {"Tag archive", "rule"},
|
|
"robots": {"noindex, follow", "rule"},
|
|
}},
|
|
{"rule-only page with an absolute canonical on another domain", "/landing", map[string]expectation{
|
|
"title": {"Landing", "rule"},
|
|
"description": {"Landing description", "rule"},
|
|
"canonical": {"https://other.example/landing", "rule"},
|
|
"og_image": {"https://cdn.example/landing.png", "rule"},
|
|
}},
|
|
{"tracking dropped, content parameter kept", "/pages/about?utm_source=news&page=2", map[string]expectation{
|
|
"canonical": {siteURL + "/pages/about?page=2", "default"},
|
|
"robots": {"index, follow, max-snippet:50", "default"},
|
|
}},
|
|
{"a filter parameter: dropped and noindex,follow", "/pages/about?sort=price&page=2", map[string]expectation{
|
|
"canonical": {siteURL + "/pages/about?page=2", "default"},
|
|
"robots": {"noindex, follow, max-snippet:50", "global"},
|
|
}},
|
|
{"a filter parameter limited to a path", "/pages/bare?color=red", map[string]expectation{
|
|
"canonical": {siteURL + "/pages/bare", "default"},
|
|
"robots": {"noindex, follow", "global"},
|
|
}},
|
|
{"never-indexed path", "/konto/profile", map[string]expectation{
|
|
"robots": {"noindex, nofollow", "global"},
|
|
}},
|
|
{"internal search path", "/szukaj", map[string]expectation{
|
|
"robots": {"noindex, follow", "global"},
|
|
}},
|
|
{"home page template", "/", map[string]expectation{
|
|
"title": {"Acme", "template"},
|
|
"description": {"Site default description", "global"},
|
|
}},
|
|
{"translated site name", "/pl/pages/about", map[string]expectation{
|
|
"title": {"About | Akme", "template"},
|
|
}},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
res, err := resolver.Resolve(t.Context(), tc.url)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if res.Action != "ok" || res.SEO == nil {
|
|
t.Fatalf("Resolve(%s) = %+v, want ok", tc.url, res)
|
|
}
|
|
got := map[string]string{
|
|
"title": res.SEO.Title, "description": res.SEO.Description, "canonical": res.SEO.Canonical,
|
|
"robots": res.SEO.Robots, "og_title": res.SEO.OG.Title, "og_description": res.SEO.OG.Description, "og_image": res.SEO.OG.Image,
|
|
}
|
|
for name, want := range tc.want {
|
|
if got[name] != want.value || res.Sources[name] != want.source {
|
|
t.Errorf("%s = %q (%s), want %q (%s)", name, got[name], res.Sources[name], want.value, want.source)
|
|
}
|
|
}
|
|
if res.SEO.XRobotsTag != res.SEO.Robots {
|
|
t.Errorf("x_robots_tag %q differs from robots %q", res.SEO.XRobotsTag, res.SEO.Robots)
|
|
}
|
|
if res.Staging {
|
|
t.Error("staging true in production")
|
|
}
|
|
})
|
|
}
|
|
|
|
t.Run("social and verification values", func(t *testing.T) {
|
|
got := env.resolve(t, "/")
|
|
seoBody := got["seo"].(map[string]any)
|
|
verification, _ := seoBody["verification"].([]any)
|
|
if len(verification) != 1 || !strings.Contains(fmtJSON(verification), `"google-site-verification"`) || !strings.Contains(fmtJSON(verification), `"g-code"`) {
|
|
t.Fatalf("home verification = %v", verification)
|
|
}
|
|
got = env.resolve(t, "/pages/about")
|
|
seoBody = got["seo"].(map[string]any)
|
|
if v, _ := seoBody["verification"].([]any); len(v) != 0 {
|
|
t.Fatalf("verification on a non-home page = %v", v)
|
|
}
|
|
twitter := seoBody["twitter"].(map[string]any)
|
|
og := seoBody["og"].(map[string]any)
|
|
if twitter["card"] != "summary_large_image" || twitter["site"] != "@acme" || og["url"] != siteURL+"/pages/about" {
|
|
t.Fatalf("social = %v %v", twitter, og)
|
|
}
|
|
})
|
|
|
|
t.Run("drafts and unknown paths are not found", func(t *testing.T) {
|
|
for _, u := range []string{"/pages/draft", "/pages/nope", "/nowhere"} {
|
|
res, err := resolver.Resolve(t.Context(), u)
|
|
if err != nil || res.Action != "not_found" || res.Status != 404 || res.SEO != nil {
|
|
t.Fatalf("Resolve(%s) = %+v err=%v, want not_found without SEO", u, res, err)
|
|
}
|
|
}
|
|
rec := env.call(t, http.MethodGet, "/_seo/api/v1/resolve?url=javascript:alert(1)", "", nil)
|
|
if rec.Code != http.StatusUnprocessableEntity {
|
|
t.Fatalf("bad url = %d, want 422", rec.Code)
|
|
}
|
|
})
|
|
|
|
t.Run("staging lock", func(t *testing.T) {
|
|
staging := bootEnv(t, "SUMMER_ENV=staging")
|
|
stoken := staging.admin(t, true)
|
|
staging.createPage(t, stoken, map[string]any{"title": "About", "slug": "about", "published": true})
|
|
got := staging.resolve(t, "/pages/about")
|
|
seoBody := got["seo"].(map[string]any)
|
|
if got["staging"] != true || seoBody["robots"] != "noindex, nofollow" || seoBody["x_robots_tag"] != "noindex, nofollow" {
|
|
t.Fatalf("staging resolve = %v", got)
|
|
}
|
|
})
|
|
}
|
|
|
|
// fmtJSON encodes v for substring checks.
|
|
func fmtJSON(v any) string {
|
|
raw, _ := json.Marshal(v)
|
|
return string(raw)
|
|
}
|