feat: publisher, local business and home crumb settings

- Publisher and Local business tabs with the C20 refusals (sameAs lines,
  person as local business, address, times, country, coordinates)
- the publisher node from the settings, as an Organization, a Person or
  the chosen local business type with address, geo and opening hours
- P8 publisher preview with Google's required and recommended findings
  (classes/schema Validate)
- the home crumb name on the General tab; every new key in the settings
  API and seo_update_settings
This commit is contained in:
Jakub Zych
2026-11-04 14:14:59 +01:00
parent ea2ce24954
commit fde5024b5a
13 changed files with 938 additions and 52 deletions

View File

@@ -3,6 +3,8 @@ package seo_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
@@ -365,3 +367,113 @@ func crumbNames(res map[string]any) string {
}
return strings.Join(names, " > ")
}
// refusal asserts a 422 save answer naming field with message.
func refusal(t *testing.T, rec *httptest.ResponseRecorder, field, message string) {
t.Helper()
body := rec.Body.String()
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(body, `"`+field+`"`) || !strings.Contains(body, message) {
t.Fatalf("save = %d %s, want 422 on %s with %q", rec.Code, body, field, message)
}
}
// TestPublisherSettings covers the Publisher and Local business tabs
// (UI-SPEC C20, D-45, D-03): the saved values, every C20 refusal (422) in
// the admin and the API, the local business node and the P8 preview.
func TestPublisherSettings(t *testing.T) {
env := bootBlog(t)
token := env.admin(t, true)
fields := env.formSchema(t, token, "golem15/seo/settings")
for _, name := range []string{"breadcrumb_home", "publisher_type", "publisher_name", "publisher_logo", "publisher_same_as", "local_enabled", "local_hours", "_publisher_preview"} {
findField(t, fields, name)
}
settings := adminAPI("/golem15/seo/settings/1")
env.expect(t, http.StatusOK, http.MethodPut, settings, token, map[string]any{
"publisher_type": "organization", "publisher_name": "Acme Sp. z o.o.", "publisher_url": "https://acme.example.test/",
"publisher_email": "office@acme.example.test", "publisher_phone": "+48 600 000 000",
"publisher_same_as": "https://www.facebook.com/acme\nhttps://pl.wikipedia.org/wiki/Acme",
})
show := env.expect(t, http.StatusOK, http.MethodGet, "/_seo/api/v1/settings", token, nil).Body.String()
for _, want := range []string{`"publisher_name":"Acme Sp. z o.o."`, `"publisher_phone":"+48 600 000 000"`, `"local_enabled":false`} {
if !jsonContains(show, want) {
t.Fatalf("settings lack %s: %s", want, show)
}
}
// P8 without a logo: the node and a recommended-logo warning.
preview := env.expect(t, http.StatusOK, http.MethodGet, adminAPI("/golem15/seo/settings/partials/publisher_preview?id=1"), token, nil).Body.String()
if !strings.Contains(preview, "Acme Sp. z o.o.") || !strings.Contains(preview, `Missing recommended property “logo” (Organization)`) {
t.Fatalf("P8 = %s", preview)
}
env.expect(t, http.StatusOK, http.MethodPut, settings, token, map[string]any{"publisher_logo": "/media/logo.png"})
preview = env.expect(t, http.StatusOK, http.MethodGet, adminAPI("/golem15/seo/settings/partials/publisher_preview?id=1"), token, nil).Body.String()
if !strings.Contains(preview, "No problems") || strings.Contains(preview, "Missing") {
t.Fatalf("P8 with a logo = %s", preview)
}
// The C20 refusals, in the admin form.
refusal(t, env.call(t, http.MethodPut, settings, token, map[string]any{"publisher_same_as": "https://ok.example.test\nx.pl"}), "publisher_same_as", "Line 2: use a full address starting with https://.")
refusal(t, env.call(t, http.MethodPut, settings, token, map[string]any{"publisher_same_as": "x.pl"}), "publisher_same_as", "Line 1: use a full address")
refusal(t, env.call(t, http.MethodPut, settings, token, map[string]any{"publisher_type": "person", "local_enabled": true, "local_street": "Prosta 1", "local_postal_code": "00-001", "local_city": "Warszawa"}), "local_enabled", "A person cannot be a local business.")
local := map[string]any{"publisher_type": "organization", "local_enabled": true, "local_street": "Prosta 1", "local_postal_code": "00-001", "local_city": "Warszawa"}
with := func(k string, v any) map[string]any {
out := map[string]any{}
for key, value := range local {
out[key] = value
}
out[k] = v
return out
}
refusal(t, env.call(t, http.MethodPut, settings, token, with("local_street", "")), "local_street", "A local business needs a street, a postal code and a city.")
refusal(t, env.call(t, http.MethodPut, settings, token, with("local_hours", []map[string]any{{"day": "mon", "opens": "9:00", "closes": "17:00"}})), "local_hours.0.opens", "Use 24-hour time, e.g. 09:00.")
refusal(t, env.call(t, http.MethodPut, settings, token, with("local_hours", []map[string]any{{"day": "mon", "opens": "17:00", "closes": "09:00"}})), "local_hours.0.closes", "Closing must come after opening.")
refusal(t, env.call(t, http.MethodPut, settings, token, with("local_country", "POL")), "local_country", "Use a two-letter country code, e.g. PL.")
refusal(t, env.call(t, http.MethodPut, settings, token, with("local_latitude", "91")), "local_latitude", "Enter decimal degrees")
// The same checks through the API (MCP seo_update_settings).
refusal(t, env.call(t, http.MethodPut, "/_seo/api/v1/settings", token, map[string]any{"local_enabled": true, "publisher_type": "person"}), "local_enabled", "A person cannot be a local business.")
refusal(t, env.call(t, http.MethodPut, "/_seo/api/v1/settings", token, map[string]any{"publisher_same_as": "x.pl"}), "publisher_same_as", "Line 1: use a full address")
// A valid local business: the publisher node becomes its type with
// the address, geo, opening hours and price range.
env.expect(t, http.StatusOK, http.MethodPut, "/_seo/api/v1/settings", token, map[string]any{
"local_enabled": true, "local_type": "FoodEstablishment", "local_street": "Prosta 1", "local_postal_code": "00-001",
"local_city": "Warszawa", "local_country": "pl", "local_latitude": "52.2297", "local_longitude": "21.0122",
"local_price_range": "$$", "local_hours": []map[string]any{{"day": "mon", "opens": "09:00", "closes": "13:00"}, {"day": "mon", "opens": "14:00", "closes": "18:00"}},
})
nodes := graphNodes(t, env.resolve(t, "/"))
biz := nodes["FoodEstablishment"]
if biz == nil || biz["@id"] != siteURL+"/#organization" || biz["priceRange"] != "$$" || biz["telephone"] != "+48 600 000 000" {
t.Fatalf("local business node = %v", nodes)
}
if got := asJSON(t, biz["address"]); got != `{"@type":"PostalAddress","addressCountry":"PL","addressLocality":"Warszawa","postalCode":"00-001","streetAddress":"Prosta 1"}` {
t.Fatalf("address = %s", got)
}
if got := asJSON(t, biz["geo"]); got != `{"@type":"GeoCoordinates","latitude":52.2297,"longitude":21.0122}` {
t.Fatalf("geo = %s", got)
}
if hours, _ := biz["openingHoursSpecification"].([]any); len(hours) != 2 || hours[1].(map[string]any)["opens"] != "14:00" || hours[0].(map[string]any)["dayOfWeek"] != "Monday" {
t.Fatalf("hours = %v", biz["openingHoursSpecification"])
}
if got := asJSON(t, biz["sameAs"]); got != `["https://www.facebook.com/acme","https://pl.wikipedia.org/wiki/Acme"]` {
t.Fatalf("sameAs = %s", got)
}
if biz["url"] != "https://acme.example.test/" || asJSON(t, biz["logo"]) != `{"@id":"https://www.example.test/#logo","@type":"ImageObject","contentUrl":"https://www.example.test/media/logo.png","url":"https://www.example.test/media/logo.png"}` {
t.Fatalf("url/logo = %v %v", biz["url"], biz["logo"])
}
// A person publisher: #person, no local business.
env.expect(t, http.StatusOK, http.MethodPut, "/_seo/api/v1/settings", token, map[string]any{"local_enabled": false, "publisher_type": "person", "publisher_name": "Jan Kowalski"})
nodes = graphNodes(t, env.resolve(t, "/"))
if p := nodes["Person"]; p == nil || p["@id"] != siteURL+"/#person" || p["name"] != "Jan Kowalski" || idOf(nodes["WebSite"]["publisher"]) != siteURL+"/#person" {
t.Fatalf("person publisher = %v", nodes)
}
// The home crumb name.
if got := crumbNames(env.resolve(t, "/blog/solo")); got != "Home > Solo" {
t.Fatalf("default home crumb trail = %s", got)
}
env.expect(t, http.StatusOK, http.MethodPut, settings, token, map[string]any{"breadcrumb_home": "Start"})
if got := crumbNames(env.resolve(t, "/blog/solo")); got != "Start > Solo" {
t.Fatalf("home crumb trail = %s", got)
}
}