test: unit tests for the schema builder, crawler and page helpers

- schema: types per kind, @id links, every node kind, the publisher and
  local business, every validator rule, custom JSON-LD caps and merge
- crawl: HTML parse, each check's positive and negative case, fix routes
  with the fitting tool, the rendered comparison, allowRequest,
  resolverRules and NewRenderer
- pagination, alternates and x-default, primary parents, the filter's
  addresses, registry paths and fields, auto-link addresses
This commit is contained in:
Jakub Zych
2026-11-04 19:43:31 +01:00
parent dfc85bc805
commit 82b0dacff9
14 changed files with 1543 additions and 0 deletions

View File

@@ -0,0 +1,148 @@
package schema
import (
"encoding/json"
"errors"
"strings"
"testing"
)
func codeOf(err error) string {
var e *Error
if errors.As(err, &e) {
return e.Code
}
return ""
}
// TestDecodeCustom pins the C17 caps: an object or a non-empty list of
// objects, 16 KB, 8 levels, no <script in any case or spacing, the
// parser's line and column for invalid JSON, nothing after the value, and
// @context dropped.
func TestDecodeCustom(t *testing.T) {
nodes, err := DecodeCustom(` {"@context":"https://schema.org","@type":"FAQPage","n":1.50} `)
if err != nil || len(nodes) != 1 || nodes[0]["@context"] != nil || nodes[0]["n"].(json.Number).String() != "1.50" {
t.Fatalf("object = %v err=%v", nodes, err)
}
if nodes, err := DecodeCustom(`[{"@type":"A"},{"@type":"B"}]`); err != nil || len(nodes) != 2 {
t.Fatalf("list = %v err=%v", nodes, err)
}
if nodes, err := DecodeCustom(" "); err != nil || nodes != nil {
t.Fatalf("blank = %v err=%v", nodes, err)
}
deep := strings.Repeat(`{"a":`, 8) + "1" + strings.Repeat("}", 8)
if _, err := DecodeCustom(deep); err != nil {
t.Fatalf("8 levels must pass: %v", err)
}
for name, tc := range map[string]struct{ text, code string }{
"too large": {`{"a":"` + strings.Repeat("x", MaxCustomBytes) + `"}`, CodeTooLarge},
"too deep": {strings.Repeat(`{"a":`, 9) + "1" + strings.Repeat("}", 9), CodeTooDeep},
"script": {`{"a":"</script><script>x"}`, CodeScriptTag},
"script case": {`{"a":"< SCRIPT src=x>"}`, CodeScriptTag},
"string": {`"x"`, CodeNotObject},
"number": {`5`, CodeNotObject},
"empty list": {`[]`, CodeNotObject},
"mixed list": {`[{"a":1},2]`, CodeNotObject},
"syntax": {"{\n \"a\": 1,\n \"b\" 2\n}", CodeInvalidJSON},
"truncated": {`{"a":`, CodeInvalidJSON},
"trailing": {`{"a":1} {"b":2}`, CodeInvalidJSON},
} {
if _, err := DecodeCustom(tc.text); codeOf(err) != tc.code {
t.Errorf("%s: err %v, want code %s", name, err, tc.code)
}
}
_, err = DecodeCustom("{\n \"a\": 1,\n \"b\" 2\n}")
if e := err.(*Error); !strings.HasPrefix(e.Detail, "line 3, column ") {
t.Fatalf("syntax detail = %q, want line 3", e.Detail)
}
if _, err := DecodeCustom(`{"a":`); !strings.Contains(err.Error(), "unexpected end of JSON input") {
t.Fatalf("truncated = %v", err)
}
}
// TestNormalize pins the stored form of the seo_schema value: the type
// checked against the kind, Service fields only for Service (price and
// currency normalised and checked), custom JSON-LD re-serialised, and the
// empty value stored as "".
func TestNormalize(t *testing.T) {
stored, v, err := Normalize(KindPage, VariantOffer, `{"type":"","fields":{"price":" 149,99 ","currency":"pln","areaServed":" Kraków "},"custom":"{\"@type\":\"FAQPage\"}"}`)
if err != nil || v.Fields.Price != "149.99" || v.Fields.Currency != "PLN" || v.Fields.AreaServed != "Kraków" || !strings.Contains(v.Custom, "\n \"@type\": \"FAQPage\"\n") {
t.Fatalf("service value = %+v err=%v", v, err)
}
var back Value
if json.Unmarshal([]byte(stored), &back) != nil || back != v {
t.Fatalf("stored = %s, want the normalised value", stored)
}
if _, v, _ := Normalize(KindArticle, "", `{"type":"Article","fields":{"price":"5"}}`); v.Fields != (ServiceFields{}) || v.Type != TypeArticle {
t.Fatalf("an article keeps no Service fields: %+v", v)
}
if stored, _, err := Normalize(KindPage, "", `{"type":" ","fields":{"price":"5"}}`); err != nil || stored != "" {
t.Fatalf("empty value = %q err=%v", stored, err)
}
if stored, _, err := Normalize(KindPage, "", ""); err != nil || stored != "" {
t.Fatalf("blank = %q err=%v", stored, err)
}
if s, _, _ := Normalize(KindPage, "", `{"custom":"[{\"@type\":\"A\"},{\"@type\":\"B\"}]"}`); !strings.Contains(s, `[\n {`) {
t.Fatalf("a list stays a list: %s", s)
}
for name, tc := range map[string]struct{ kind, variant, raw, code, field string }{
"type": {KindPage, "", `{"type":"BlogPosting"}`, CodeTypeUnknown, ""},
"price": {KindPage, VariantService, `{"fields":{"price":"abc"}}`, CodeFieldInvalid, "price"},
"currency": {KindPage, VariantService, `{"fields":{"currency":"zloty"}}`, CodeFieldInvalid, "currency"},
"json": {KindPage, "", `{"type":`, CodeInvalidJSON, ""},
"custom": {KindPage, "", `{"custom":"<script>"}`, CodeScriptTag, ""},
} {
_, _, err := Normalize(tc.kind, tc.variant, tc.raw)
var e *Error
if !errors.As(err, &e) || e.Code != tc.code || e.Field != tc.field {
t.Errorf("%s: err %v, want %s %s", name, err, tc.code, tc.field)
}
}
}
// TestMerge pins the merge of custom nodes: a node with a generated @id is
// merged into it (custom values win, the replaced properties reported
// sorted), a node without @id or with a new @id is appended, and an equal
// value is not reported.
func TestMerge(t *testing.T) {
g := map[string]any{"@context": Context, "@graph": []any{
map[string]any{"@type": TypeWebPage, "@id": "p#webpage", "name": "Old", "url": "u"},
map[string]any{"@type": TypeWebSite, "@id": "s#website", "name": "Site"},
}}
out, merged := Merge(g, []map[string]any{
{"@id": "p#webpage", "name": "New", "url": "u", "about": "x", "description": "d"},
{"@type": "FAQPage"},
{"@id": "p#faq", "@type": "FAQPage"},
{"@id": "s#website", "name": "Site"},
})
nodes := Nodes(out)
if len(nodes) != 4 || nodes[0]["name"] != "New" || nodes[0]["description"] != "d" || nodes[2]["@type"] != "FAQPage" || nodes[3]["@id"] != "p#faq" {
t.Fatalf("merged graph = %v", nodes)
}
if len(merged) != 1 || merged[0].ID != "p#webpage" || merged[0].Type != TypeWebPage || strings.Join(merged[0].Props, ",") != "name" {
t.Fatalf("merges = %+v", merged)
}
if out["@context"] != Context {
t.Fatal("the merged graph keeps its context")
}
_, none := Merge(g, nil)
if none != nil {
t.Fatalf("no custom nodes reported %v", none)
}
}
// TestParseValue pins the stored value's reading.
func TestParseValue(t *testing.T) {
if v, err := ParseValue(""); err != nil || v != (Value{}) {
t.Fatal("blank is the zero value")
}
if _, err := ParseValue("{"); codeOf(err) != CodeInvalidJSON {
t.Fatalf("invalid = %v", err)
}
if v, _ := ParseValue(`{"type":"Article","custom":"{}"}`); v.Type != "Article" || v.Custom != "{}" {
t.Fatalf("value = %+v", v)
}
if (&Error{Code: "x"}).Error() != "schema: x" || (&Error{Code: "x", Detail: "d"}).Error() != "schema: x: d" {
t.Fatal("Error() is wrong")
}
}

View File

@@ -0,0 +1,214 @@
package schema
import (
"context"
"encoding/json"
"strings"
"testing"
"time"
)
const site = "https://example.test"
func nodesByType(t *testing.T, g map[string]any) map[string]map[string]any {
t.Helper()
if g["@context"] != Context {
t.Fatalf("@context = %v", g["@context"])
}
out := map[string]map[string]any{}
for _, n := range Nodes(g) {
out[NodeType(n)] = n
}
return out
}
func refID(v any) string {
m, _ := v.(map[string]any)
s, _ := m["@id"].(string)
return s
}
// TestDefaultAndAllowedTypes pins the type table (UI-SPEC C17): the
// default per kind and registry variant, the choices per kind, and an
// editor's choice outside them falling back to the default.
func TestDefaultAndAllowedTypes(t *testing.T) {
for _, tc := range []struct{ kind, variant, want string }{
{KindArticle, "", TypeBlogPosting}, {KindCollection, "", TypeCollectionPage}, {KindProfile, "", TypeProfilePage},
{KindPage, "", TypeWebPage}, {"", "", TypeWebPage}, {KindPage, VariantOffer, TypeService}, {KindPage, VariantService, TypeService},
{KindPage, "landing", TypeWebPage}, {KindArticle, VariantOffer, TypeBlogPosting},
} {
if got := DefaultType(tc.kind, tc.variant); got != tc.want {
t.Errorf("DefaultType(%q, %q) = %q, want %q", tc.kind, tc.variant, got, tc.want)
}
}
for _, tc := range []struct {
kind, typ string
want bool
}{
{KindArticle, TypeNewsArticle, true}, {KindArticle, TypeService, false}, {KindCollection, TypeCollectionPage, true},
{KindCollection, TypeWebPage, false}, {KindProfile, TypeProfilePage, true}, {KindPage, TypeContactPage, true},
{KindPage, TypeBlogPosting, false}, {KindPage, "Product", false}, {KindArticle, "", true},
} {
if got := Allowed(tc.kind, tc.typ); got != tc.want {
t.Errorf("Allowed(%q, %q) = %v, want %v", tc.kind, tc.typ, got, tc.want)
}
}
if EffectiveType(KindPage, "", TypeBlogPosting) != TypeWebPage || EffectiveType(KindPage, VariantOffer, "") != TypeService || EffectiveType(KindArticle, "", TypeArticle) != TypeArticle {
t.Fatal("EffectiveType is wrong")
}
}
// TestPageIDs pins P33-22's @id values: site-level ids on the origin, the
// person publisher's own fragment, page-level ids on the page URL.
func TestPageIDs(t *testing.T) {
ids := PageIDs(site, site+"/blog/a", PublisherOrganization)
if ids.WebSite != site+"/#website" || ids.Publisher != site+"/#organization" || ids.Logo != site+"/#logo" ||
ids.WebPage != site+"/blog/a#webpage" || ids.Breadcrumb != site+"/blog/a#breadcrumb" || ids.Article != site+"/blog/a#article" ||
ids.ItemList != site+"/blog/a#itemlist" || ids.Profile != site+"/blog/a#profile" || ids.Service != site+"/blog/a#service" {
t.Fatalf("ids = %+v", ids)
}
if PageIDs(site, site+"/", PublisherPerson).Publisher != site+"/#person" {
t.Fatal("a person publisher is /#person")
}
}
// TestBuildArticle pins an article page's graph: WebPage, BlogPosting,
// BreadcrumbList, VideoObject, WebSite and the publisher, linked by @id;
// headline, authors with profile ids, images, dates (dateModified falling
// back to datePublished), word count and section.
func TestBuildArticle(t *testing.T) {
at := time.Date(2026, 11, 1, 10, 0, 0, 0, time.FixedZone("CET", 3600))
words := 420
g, err := Build(context.Background(), Input{
Site: site, URL: site + "/blog/a", Lang: "pl", Kind: KindArticle, Name: "Grzyby", Description: "O grzybach",
Images: []string{site + "/a.jpg", site + "/b.jpg"}, PublishedAt: &at, Words: &words, Section: "Lecznicze",
Authors: []Person{{Name: "Ada", URL: site + "/autor/ada"}, {Name: " "}, {Name: "Gość"}},
Videos: []Video{{Provider: "YouTube", ID: "abc 1"}, {Provider: "vimeo", ID: "9"}, {Provider: "youtube", ID: ""}},
Crumbs: []Crumb{{Name: "Start", URL: site + "/"}, {Name: "Grzyby", URL: site + "/blog/a"}},
SiteName: "Acme", SearchURL: "/szukaj?q={search_term_string}",
Publisher: Publisher{Type: PublisherOrganization, Logo: site + "/logo.png"},
})
if err != nil {
t.Fatal(err)
}
by := nodesByType(t, g)
for _, typ := range []string{TypeWebPage, TypeBlogPosting, TypeBreadcrumbList, TypeVideoObject, TypeWebSite, TypeOrganization} {
if by[typ] == nil {
t.Fatalf("graph lacks %s: %v", typ, g)
}
}
if len(Nodes(g)) != 6 {
t.Fatalf("%d nodes, want 6 (one video)", len(Nodes(g)))
}
page, art := by[TypeWebPage], by[TypeBlogPosting]
if refID(page["isPartOf"]) != site+"/#website" || refID(page["breadcrumb"]) != site+"/blog/a#breadcrumb" || page["inLanguage"] != "pl" {
t.Fatalf("page = %v", page)
}
if art["headline"] != "Grzyby" || refID(art["mainEntityOfPage"]) != site+"/blog/a#webpage" || refID(art["publisher"]) != site+"/#organization" ||
art["datePublished"] != "2026-11-01T09:00:00Z" || art["dateModified"] != "2026-11-01T09:00:00Z" || art["wordCount"] != 420 || art["articleSection"] != "Lecznicze" {
t.Fatalf("article = %v", art)
}
authors := art["author"].([]any)
if len(authors) != 2 || authors[0].(map[string]any)["@id"] != site+"/autor/ada#profile" || authors[1].(map[string]any)["url"] != nil {
t.Fatalf("authors = %v", authors)
}
video := by[TypeVideoObject]
if video["embedUrl"] != "https://www.youtube.com/embed/abc%201" || video["@id"] != site+"/blog/a#video-1" || video["uploadDate"] != "2026-11-01T09:00:00Z" || video["description"] != "O grzybach" {
t.Fatalf("video = %v", video)
}
ws := by[TypeWebSite]
action := ws["potentialAction"].(map[string]any)
if ws["url"] != site+"/" || action["target"].(map[string]any)["urlTemplate"] != site+"/szukaj?q={search_term_string}" {
t.Fatalf("website = %v", ws)
}
org := by[TypeOrganization]
if org["name"] != "Acme" || refID(org["image"]) != site+"/#logo" {
t.Fatalf("publisher = %v", org)
}
if _, err := json.Marshal(g); err != nil {
t.Fatalf("graph does not marshal: %v", err)
}
}
// TestBuildPerKind pins the page node and main entity per kind: a
// collection's ItemList, a profile's Person, a service's offer with the
// normalised price, a contact page about the publisher, the home page
// about the publisher, and no BreadcrumbList for a single crumb.
func TestBuildPerKind(t *testing.T) {
build := func(in Input) map[string]map[string]any {
in.Site, in.SiteName = site, "Acme"
if in.URL == "" {
in.URL = site + "/x"
}
g, err := Build(context.Background(), in)
if err != nil {
t.Fatal(err)
}
return nodesByType(t, g)
}
col := build(Input{Kind: KindCollection, Name: "Lecznicze"})
if refID(col[TypeCollectionPage]["mainEntity"]) != site+"/x#itemlist" {
t.Fatalf("collection = %v", col)
}
prof := build(Input{Kind: KindProfile, Name: "Ada page", Profile: &Person{Name: "Ada"}, Images: []string{site + "/ada.jpg"}})
if refID(prof[TypeProfilePage]["mainEntity"]) != site+"/x#profile" || prof[TypePerson]["name"] != "Ada" || prof[TypePerson]["image"] != site+"/ada.jpg" {
t.Fatalf("profile = %v", prof)
}
svc := build(Input{Kind: KindPage, Variant: VariantOffer, Name: "Audyt", Service: ServiceFields{ServiceType: "SEO", AreaServed: "Kraków, Warszawa", Price: "149,99", Currency: "pln"}})
offer := svc[TypeService]["offers"].(map[string]any)
if refID(svc[TypeWebPage]["mainEntity"]) != site+"/x#service" || offer["price"] != "149.99" || offer["priceCurrency"] != "PLN" || len(svc[TypeService]["areaServed"].([]any)) != 2 {
t.Fatalf("service = %v", svc)
}
one := build(Input{Kind: KindPage, Variant: VariantService, Service: ServiceFields{AreaServed: " Kraków ", Price: "abc"}})
if one[TypeService]["areaServed"] != "Kraków" || one[TypeService]["offers"] != nil {
t.Fatalf("service with one area and no price = %v", one[TypeService])
}
contact := build(Input{Type: TypeContactPage, Crumbs: []Crumb{{Name: "Start", URL: site + "/"}}})
if refID(contact[TypeContactPage]["mainEntity"]) != site+"/#organization" || refID(contact[TypeContactPage]["about"]) != site+"/#organization" || contact[TypeBreadcrumbList] != nil {
t.Fatalf("contact = %v", contact)
}
home := build(Input{URL: site + "/", Home: true})
if refID(home[TypeWebPage]["about"]) != site+"/#organization" {
t.Fatalf("home = %v", home)
}
}
// TestPublisherNode pins the publisher: named after the site when unnamed,
// nil without any name, a person's image instead of a logo, and a local
// business with its address, geo, hours (bad rows dropped) and price
// range.
func TestPublisherNode(t *testing.T) {
if PublisherNode(site, Publisher{}, " ") != nil {
t.Fatal("no name must give no node")
}
person := PublisherNode(site, Publisher{Type: PublisherPerson, Name: "Ada", Logo: site + "/ada.jpg", SameAs: []string{"https://x.test/ada"}}, "Acme")
if person["@type"] != TypePerson || person["@id"] != site+"/#person" || person["logo"] != nil || person["image"].(map[string]any)["url"] != site+"/ada.jpg" || len(person["sameAs"].([]any)) != 1 {
t.Fatalf("person = %v", person)
}
lat, lng := 50.06, 19.94
local := PublisherNode(site, Publisher{Name: "Sklep", Phone: "+48 1", Local: &Local{
Type: "Store", PriceRange: "$$", Street: "Rynek 1", City: "Kraków", Country: "pl", Latitude: &lat, Longitude: &lng,
Hours: []Hours{{Day: "mon", Opens: "09:00", Closes: "17:00"}, {Day: "xyz", Opens: "1", Closes: "2"}, {Day: "tue", Opens: "09:00"}},
}}, "")
addr := local["address"].(map[string]any)
if local["@type"] != "Store" || addr["addressCountry"] != "PL" || addr["streetAddress"] != "Rynek 1" || local["geo"] == nil ||
len(local["openingHoursSpecification"].([]any)) != 1 || local["priceRange"] != "$$" || local["telephone"] != "+48 1" || local["url"] != site+"/" {
t.Fatalf("local business = %v", local)
}
bare := PublisherNode(site, Publisher{Name: "Sklep", Local: &Local{}}, "")
if bare["@type"] != TypeLocalBusiness || bare["address"] != nil {
t.Fatalf("local business without data = %v", bare)
}
}
// TestNormalizePrice pins the price forms an editor may type.
func TestNormalizePrice(t *testing.T) {
for in, want := range map[string]string{"150": "150", "149,99": "149.99", " 149.99 ": "149.99", "0": "0", "": "", "abc": "", "-5": "", "1e3": "", "+5": "", "12,5,0": ""} {
if got := NormalizePrice(in); got != want {
t.Errorf("NormalizePrice(%q) = %q, want %q", in, got, want)
}
}
if strings.Join(splitList(" a, ,b ,"), "|") != "a|b" {
t.Fatal("splitList is wrong")
}
}

View File

@@ -0,0 +1,86 @@
package schema
import (
"strings"
"testing"
)
func findings(r Report) string {
var out []string
for _, f := range r.Findings {
out = append(out, f.Level+":"+f.Property)
}
return strings.Join(out, " ")
}
// TestValidateNodeRules pins every rule of the table: the required and
// recommended properties per type, the LocalBusiness subtypes checked as
// LocalBusiness, and a type without Google rules reported as Rules false
// with no findings (RESEARCH Pitfall 11).
func TestValidateNodeRules(t *testing.T) {
for _, tc := range []struct {
node map[string]any
rules bool
want string
}{
{map[string]any{"@type": TypeBlogPosting}, true, "recommended:author recommended:datePublished recommended:dateModified recommended:headline recommended:image"},
{map[string]any{"@type": TypeArticle, "headline": "x", "author": []any{map[string]any{"name": "a"}}, "datePublished": "d", "dateModified": "d", "image": []any{"i"}}, true, ""},
{map[string]any{"@type": TypeNewsArticle, "headline": " "}, true, "recommended:author recommended:datePublished recommended:dateModified recommended:headline recommended:image"},
{map[string]any{"@type": TypeBreadcrumbList, "itemListElement": []any{}}, true, "required:itemListElement"},
{map[string]any{"@type": TypeVideoObject, "name": "v"}, true, "required:thumbnailUrl required:uploadDate recommended:description recommended:embedUrl"},
{map[string]any{"@type": TypeOrganization, "name": "o", "url": "u", "logo": map[string]any{"url": "l"}}, true, ""},
{map[string]any{"@type": TypePerson}, true, "required:name recommended:url recommended:image"},
{map[string]any{"@type": TypeWebSite, "url": "u"}, true, "required:name"},
{map[string]any{"@type": TypeProfilePage, "mainEntity": map[string]any{}}, true, "required:mainEntity"},
{map[string]any{"@type": "Store", "name": "s"}, true, "required:address recommended:telephone recommended:url recommended:geo recommended:openingHoursSpecification recommended:priceRange recommended:image"},
{map[string]any{"@type": "FinancialService", "name": "s", "address": map[string]any{"a": 1}, "telephone": "1", "url": "u", "geo": map[string]any{"x": 1}, "openingHoursSpecification": []any{1}, "priceRange": "$", "image": "i"}, true, ""},
{map[string]any{"@type": TypeWebPage}, false, ""},
{map[string]any{"@type": TypeService}, false, ""},
{map[string]any{"@type": "FAQPage"}, false, ""},
{map[string]any{"@type": TypeCollectionPage}, false, ""},
} {
r := ValidateNode(tc.node)
if r.Rules != tc.rules || findings(r) != tc.want || r.Findings == nil {
t.Errorf("%v: rules=%v findings=%q, want rules=%v %q", tc.node["@type"], r.Rules, findings(r), tc.rules, tc.want)
}
}
}
// TestNodeType pins the first @type of a node in each JSON form.
func TestNodeType(t *testing.T) {
for _, tc := range []struct {
node map[string]any
want string
}{
{map[string]any{"@type": "Article"}, "Article"},
{map[string]any{"@type": []any{1, "Store", "LocalBusiness"}}, "Store"},
{map[string]any{"@type": []string{"Person"}}, "Person"},
{map[string]any{"@type": []string{}}, ""},
{map[string]any{}, ""},
{map[string]any{"@type": 5}, ""},
} {
if got := NodeType(tc.node); got != tc.want {
t.Errorf("NodeType(%v) = %q, want %q", tc.node, got, tc.want)
}
}
}
// TestValidateGraph pins Validate over a graph: one report per node with
// its @id, in order, and Nodes reading both graph forms and skipping
// non-objects.
func TestValidateGraph(t *testing.T) {
g := map[string]any{"@graph": []any{map[string]any{"@type": TypeWebSite, "@id": "w", "name": "n", "url": "u"}, "junk", map[string]any{"@type": TypePerson, "@id": "p"}}}
reps := Validate(g)
if len(reps) != 2 || reps[0].ID != "w" || len(reps[0].Findings) != 0 || reps[1].ID != "p" || findings(reps[1]) != "required:name recommended:url recommended:image" {
t.Fatalf("reports = %+v", reps)
}
if len(Nodes(map[string]any{"@graph": []map[string]any{{"@id": "a"}}})) != 1 || Nodes(map[string]any{}) != nil {
t.Fatal("Nodes is wrong")
}
if got := Validate(map[string]any{}); got == nil || len(got) != 0 {
t.Fatalf("empty graph = %v, want []", got)
}
if r, rec, ok := RulesFor("Unknown"); ok || r != nil || rec != nil {
t.Fatal("an unknown type has no rules")
}
}