Files
sm-seo-plugin/classes/schema/graph_test.go
Jakub Zych 82b0dacff9 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
2026-11-04 19:43:31 +01:00

215 lines
10 KiB
Go

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")
}
}