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:
68
classes/alternates_test.go
Normal file
68
classes/alternates_test.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package classes
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
)
|
||||
|
||||
// TestLangTag pins the language tags of locale codes.
|
||||
func TestLangTag(t *testing.T) {
|
||||
for in, want := range map[string]string{"pl": "pl", "PL": "pl", "pt_BR": "pt-BR", "pt-br": "pt-BR", " en-gb ": "en-GB", "de-": "de", "": ""} {
|
||||
if got := LangTag(in); got != want {
|
||||
t.Errorf("LangTag(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAlternates pins D-55 to D-57: each translated locale with its
|
||||
// absolute URL (the page itself included), x-default on the default
|
||||
// locale's URL, none for one locale, a locale without an address or a
|
||||
// repeated locale absent, and no x-default when the default locale is not
|
||||
// translated.
|
||||
func TestAlternates(t *testing.T) {
|
||||
const site = "https://example.test"
|
||||
rec := &pact.ContentRecord{Locales: []pact.ContentLocale{
|
||||
{Locale: "pl", URL: "/blog/grzyby"},
|
||||
{Locale: "en", URL: "/en/blog/mushrooms"},
|
||||
{Locale: "de", URL: " "},
|
||||
{Locale: "pl", URL: "/blog/again"},
|
||||
{Locale: "pt_BR", URL: "https://example.test/pt/cogumelos"},
|
||||
}}
|
||||
got := Alternates(site, rec, "pl")
|
||||
want := []Alternate{
|
||||
{Hreflang: "pl", URL: site + "/blog/grzyby"},
|
||||
{Hreflang: "en", URL: site + "/en/blog/mushrooms"},
|
||||
{Hreflang: "pt-BR", URL: site + "/pt/cogumelos"},
|
||||
{Hreflang: "x-default", URL: site + "/blog/grzyby"},
|
||||
}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("alternates = %+v", got)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("alternate %d = %+v, want %+v", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
if noDefault := Alternates(site, rec, "fr"); len(noDefault) != 3 || noDefault[2].Hreflang == "x-default" {
|
||||
t.Fatalf("without the default locale = %+v", noDefault)
|
||||
}
|
||||
one := &pact.ContentRecord{Locales: []pact.ContentLocale{{Locale: "pl", URL: "/a"}, {Locale: "en", URL: ""}}}
|
||||
if got := Alternates(site, one, "pl"); got == nil || len(got) != 0 {
|
||||
t.Fatalf("one locale = %+v, want []", got)
|
||||
}
|
||||
if got := Alternates(site, nil, "pl"); len(got) != 0 {
|
||||
t.Fatal("no record, no alternates")
|
||||
}
|
||||
}
|
||||
|
||||
// TestOGLocale pins og:locale without configuration: the default table,
|
||||
// the region form and the doubled language.
|
||||
func TestOGLocale(t *testing.T) {
|
||||
s := &Service{}
|
||||
for in, want := range map[string]string{"pl": "pl_PL", "en": "en_US", "de": "de_DE", "pt_BR": "pt_BR", "fr": "fr_FR", "": ""} {
|
||||
if got := s.OGLocale(in); got != want {
|
||||
t.Errorf("OGLocale(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
60
classes/autolinks_test.go
Normal file
60
classes/autolinks_test.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package classes
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/sm-seo-plugin/models"
|
||||
)
|
||||
|
||||
// TestValidAutolinkURL pins T-33-50: a site path from one / or an
|
||||
// absolute http(s) address; javascript:, data:, protocol-relative, a
|
||||
// backslash path, credentials, whitespace and control characters refused.
|
||||
func TestValidAutolinkURL(t *testing.T) {
|
||||
for u, want := range map[string]bool{
|
||||
"/blog/chaga": true, "/": true, "https://example.test/a": true, "HTTP://example.test": true,
|
||||
"": false, "javascript:alert(1)": false, "data:text/html,x": false, "//evil.test/a": false, `/\evil.test`: false,
|
||||
"https://user:pw@example.test/": false, "/a b": false, "/a\tb": false, "/a\x00": false, "mailto:a@b.c": false,
|
||||
"https://": false, "relative/path": false, "/" + strings.Repeat("a", models.MaxAutolinkURL): false,
|
||||
} {
|
||||
if got := ValidAutolinkURL(u); got != want {
|
||||
t.Errorf("ValidAutolinkURL(%q) = %v, want %v", u, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalizeAutolink pins the stored form: the phrase's inner
|
||||
// whitespace collapsed, the address, locale and note trimmed, "all" as
|
||||
// every locale (""), and the manual origin by default.
|
||||
func TestNormalizeAutolink(t *testing.T) {
|
||||
a := &models.Autolink{Phrase: " lwia \n grzywa ", URL: " /blog/lwia ", Locale: models.AutolinkLocaleAll, Note: " n "}
|
||||
NormalizeAutolink(a)
|
||||
if a.Phrase != "lwia grzywa" || a.URL != "/blog/lwia" || a.Locale != "" || a.Note != "n" || a.Origin != models.AutolinkOriginManual {
|
||||
t.Fatalf("normalised = %+v", a)
|
||||
}
|
||||
b := &models.Autolink{Locale: " pl ", Origin: "keyphrase"}
|
||||
NormalizeAutolink(b)
|
||||
if b.Locale != "pl" || b.Origin != "keyphrase" {
|
||||
t.Fatalf("kept = %+v", b)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAutolinkWrite pins a partial write: only the fields set change, and
|
||||
// the values for history carry every field.
|
||||
func TestAutolinkWrite(t *testing.T) {
|
||||
a := &models.Autolink{Phrase: "chaga", URL: "/a", Locale: "pl", Enabled: true, Note: "x"}
|
||||
phrase, off := "inonotus", false
|
||||
AutolinkWrite{Phrase: &phrase, Enabled: &off}.Apply(a)
|
||||
if a.Phrase != "inonotus" || a.Enabled || a.URL != "/a" || a.Locale != "pl" || a.Note != "x" {
|
||||
t.Fatalf("after the write = %+v", a)
|
||||
}
|
||||
v := AutolinkValues(*a)
|
||||
for _, k := range []string{"phrase", "url", "locale", "enabled", "origin", "note"} {
|
||||
if _, ok := v[k]; !ok {
|
||||
t.Errorf("history values lack %s", k)
|
||||
}
|
||||
}
|
||||
if v["phrase"] != "inonotus" || v["enabled"] != false {
|
||||
t.Fatalf("values = %v", v)
|
||||
}
|
||||
}
|
||||
72
classes/breadcrumbs_test.go
Normal file
72
classes/breadcrumbs_test.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package classes
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/sm-seo-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
)
|
||||
|
||||
// TestPrimaryParent pins D-86: the stored choice while the record still
|
||||
// has that parent, else the first parent with gone set for a stale
|
||||
// choice, and no parent at all (gone only when a choice was stored).
|
||||
func TestPrimaryParent(t *testing.T) {
|
||||
a := pact.ContentRef{Type: "cat", ID: "1"}
|
||||
b := pact.ContentRef{Type: "cat", ID: "2"}
|
||||
rec := &pact.ContentRecord{Parents: []pact.ContentRef{a, b}}
|
||||
for _, tc := range []struct {
|
||||
stored string
|
||||
want pact.ContentRef
|
||||
ok, gone bool
|
||||
}{
|
||||
{"", a, true, false}, {"cat:2", b, true, false}, {"cat:9", a, true, true}, {"tag:2", a, true, true},
|
||||
} {
|
||||
got, ok, gone := PrimaryParent(rec, tc.stored)
|
||||
if got != tc.want || ok != tc.ok || gone != tc.gone {
|
||||
t.Errorf("PrimaryParent(%q) = %v %v %v, want %v %v %v", tc.stored, got, ok, gone, tc.want, tc.ok, tc.gone)
|
||||
}
|
||||
}
|
||||
if _, ok, gone := PrimaryParent(&pact.ContentRecord{}, ""); ok || gone {
|
||||
t.Fatal("no parents, nothing stored")
|
||||
}
|
||||
if _, ok, gone := PrimaryParent(nil, "cat:1"); ok || !gone {
|
||||
t.Fatal("no record with a stored choice is gone")
|
||||
}
|
||||
if ParentValue(b) != "cat:2" {
|
||||
t.Fatal("ParentValue is <type>:<id>")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHomePath pins the home crumb's address: /<locale> when the page is
|
||||
// under that prefix (any case), else /.
|
||||
func TestHomePath(t *testing.T) {
|
||||
for _, tc := range []struct{ locale, path, want string }{
|
||||
{"pl", "/blog/a", "/"}, {"en", "/en/blog/a", "/en"}, {"en", "/EN", "/en"}, {"en", "/english", "/"}, {"", "/en/a", "/"},
|
||||
} {
|
||||
if got := homePath(tc.locale, tc.path); got != tc.want {
|
||||
t.Errorf("homePath(%q, %q) = %q, want %q", tc.locale, tc.path, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestLocaleOfAndEntryData pins the exact locale lookup (an empty address
|
||||
// does not count) and the entry data reader, and the depth cap.
|
||||
func TestLocaleOfAndEntryData(t *testing.T) {
|
||||
rec := &pact.ContentRecord{Locales: []pact.ContentLocale{{Locale: "pl", URL: "/a"}, {Locale: "en", URL: " "}}}
|
||||
if l, ok := localeOf(rec, "pl"); !ok || l.URL != "/a" {
|
||||
t.Fatal("pl is there")
|
||||
}
|
||||
if _, ok := localeOf(rec, "en"); ok {
|
||||
t.Fatal("en has no address")
|
||||
}
|
||||
if _, ok := localeOf(nil, "pl"); ok {
|
||||
t.Fatal("no record")
|
||||
}
|
||||
e := models.Entry{Data: map[string]any{"primary_parent": " cat:1 ", "n": 5}}
|
||||
if entryData(e, "primary_parent") != "cat:1" || entryData(e, "n") != "" || entryData(models.Entry{}, "x") != "" {
|
||||
t.Fatal("entryData is wrong")
|
||||
}
|
||||
if MaxCrumbDepth != 6 {
|
||||
t.Fatalf("MaxCrumbDepth = %d", MaxCrumbDepth)
|
||||
}
|
||||
}
|
||||
205
classes/crawl/checks_test.go
Normal file
205
classes/crawl/checks_test.go
Normal file
@@ -0,0 +1,205 @@
|
||||
package crawl
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/sm-seo-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
)
|
||||
|
||||
func okPage(key string, facts *PageFacts) *Page {
|
||||
return &Page{Key: key, Resp: &Response{Status: 200, ContentType: "text/html"}, Facts: facts}
|
||||
}
|
||||
|
||||
func kinds(fs []Finding) map[string]Finding {
|
||||
out := map[string]Finding{}
|
||||
for _, f := range fs {
|
||||
out[f.Kind] = f
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestSeverityAndKinds pins the kinds table: every listed kind has a
|
||||
// severity, an unknown kind is a warning, and each kind is listed once.
|
||||
func TestSeverityAndKinds(t *testing.T) {
|
||||
seen := map[string]bool{}
|
||||
for _, k := range Kinds() {
|
||||
if seen[k] {
|
||||
t.Fatalf("%s is listed twice", k)
|
||||
}
|
||||
seen[k] = true
|
||||
if _, ok := severities[k]; !ok {
|
||||
t.Errorf("%s has no severity", k)
|
||||
}
|
||||
}
|
||||
if len(seen) != len(severities) {
|
||||
t.Fatalf("%d kinds listed, %d severities", len(seen), len(severities))
|
||||
}
|
||||
if Severity("nope") != models.SeverityWarning || Severity(KindPageStatus) != models.SeverityError || Severity(KindSitemapMissing) != models.SeverityNotice {
|
||||
t.Fatal("Severity is wrong")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckStatus pins page_status: a fetch error (not a cancel) and an
|
||||
// error status other than 404 and 410; nothing for 200, 301, 404, 410.
|
||||
func TestCheckStatus(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
resp *Response
|
||||
want bool
|
||||
}{
|
||||
{&Response{Err: "timeout"}, true}, {&Response{Err: "canceled"}, false}, {&Response{Status: 500}, true}, {&Response{Status: 403}, true},
|
||||
{&Response{Status: 404}, false}, {&Response{Status: 410}, false}, {&Response{Status: 200}, false}, {&Response{Status: 301}, false}, {nil, false},
|
||||
} {
|
||||
got := checkStatus(&Result{}, &Page{Key: "/a", Resp: tc.resp}, nil)
|
||||
if (len(got) == 1) != tc.want || (tc.want && (got[0].Kind != KindPageStatus || got[0].Tool != ToolCreateRedirect)) {
|
||||
t.Errorf("%+v: %+v", tc.resp, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckHead pins missing title, description and canonical (positive)
|
||||
// and their absence on a complete head (negative), and the H1/title
|
||||
// mismatch only when neither contains the other.
|
||||
func TestCheckHead(t *testing.T) {
|
||||
bad := kinds(checkHead(&Result{}, okPage("/a", &PageFacts{Headings: []Heading{{1, "Kanię"}}, Title: "Grzyby"}), nil))
|
||||
for _, k := range []string{KindMissingDesc, KindCanonicalMissing, KindH1TitleMismatch} {
|
||||
if _, ok := bad[k]; !ok {
|
||||
t.Errorf("missing %s in %v", k, bad)
|
||||
}
|
||||
}
|
||||
if bad[KindMissingDesc].Tool != ToolSetURLSEO {
|
||||
t.Fatal("a missing description is fixed with seo_set_url_seo")
|
||||
}
|
||||
if _, ok := kinds(checkHead(&Result{}, okPage("/a", &PageFacts{}), nil))[KindMissingTitle]; !ok {
|
||||
t.Fatal("no missing_title for an empty title")
|
||||
}
|
||||
good := checkHead(&Result{}, okPage("/a", &PageFacts{Title: "Grzyby lecznicze | Acme", Description: "d", Canonical: "https://example.test/a", Headings: []Heading{{1, "Grzyby LECZNICZE"}}}), nil)
|
||||
if len(good) != 0 {
|
||||
t.Fatalf("a complete head: %+v", good)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckHeadings pins h1_missing, h1_multiple, heading_skipped (first
|
||||
// skip with every skip counted) and heading_empty, and the fitting tool:
|
||||
// an H1 in the record's content routes to the headings tool, a skipped
|
||||
// level the record holds to the edit tool, a template heading to code.
|
||||
func TestCheckHeadings(t *testing.T) {
|
||||
rec := &Record{Headings: []pact.ContentHeading{{Level: 1, Text: "In body"}, {Level: 4, Text: "Deep"}}, Tools: map[string]string{pact.ContentToolHeadings: "journal_fix_headings", pact.ContentToolEdit: "journal_update_post"}}
|
||||
got := kinds(checkHeadings(okPage("/a", &PageFacts{Headings: []Heading{{1, "Title"}, {1, "In body"}, {2, "A"}, {4, "Deep"}, {2, "B"}, {5, "Deeper"}, {3, ""}}}), rec))
|
||||
multi, skip, empty := got[KindH1Multiple], got[KindHeadingSkipped], got[KindHeadingEmpty]
|
||||
if multi.Details["count"] != 2 || len(multi.Content) != 2 || multi.Content[0] != pact.ContentToolHeadings {
|
||||
t.Fatalf("h1_multiple = %+v", multi)
|
||||
}
|
||||
if fix, tool, _ := Route(multi); fix != models.FixTool || tool != "journal_fix_headings" {
|
||||
t.Fatalf("h1_multiple routes to %s %s, want the headings tool", fix, tool)
|
||||
}
|
||||
if skip.Details["from"] != 2 || skip.Details["to"] != 4 || skip.Details["count"] != 2 || len(skip.Content) != 1 || skip.Content[0] != pact.ContentToolEdit {
|
||||
t.Fatalf("heading_skipped = %+v", skip)
|
||||
}
|
||||
if fix, tool, _ := Route(skip); fix != models.FixTool || tool != "journal_update_post" {
|
||||
t.Fatalf("heading_skipped routes to %s %s, want the edit tool", fix, tool)
|
||||
}
|
||||
if empty.Details["level"] != 3 || empty.Details["count"] != 1 || len(empty.Content) != 0 {
|
||||
t.Fatalf("heading_empty = %+v", empty)
|
||||
}
|
||||
if fix, _, what := Route(empty); fix != models.FixCode || what == "" {
|
||||
t.Fatalf("a template heading routes to code, got %s %q", fix, what)
|
||||
}
|
||||
// A category's skipped level the record does not hold is fixed in code.
|
||||
cat := &Record{Tools: map[string]string{pact.ContentToolEdit: "journal_update_category"}}
|
||||
skipCat := kinds(checkHeadings(okPage("/c", &PageFacts{Headings: []Heading{{1, "Cat"}, {3, "Post card"}}}), cat))[KindHeadingSkipped]
|
||||
if fix, _, what := Route(skipCat); fix != models.FixCode || what != defaultWhat[KindHeadingSkipped] {
|
||||
t.Fatalf("category skip routes to %s %q, want code", fix, what)
|
||||
}
|
||||
if _, ok := kinds(checkHeadings(okPage("/b", &PageFacts{}), nil))[KindH1Missing]; !ok {
|
||||
t.Fatal("no h1_missing")
|
||||
}
|
||||
if clean := checkHeadings(okPage("/b", &PageFacts{Headings: []Heading{{1, "T"}, {2, "a"}, {3, "b"}, {2, "c"}}}), nil); len(clean) != 0 {
|
||||
t.Fatalf("clean headings: %+v", clean)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckJSONLD pins jsonld_invalid (the custom tool when the record
|
||||
// stores custom JSON-LD), jsonld_multiple, jsonld_required and
|
||||
// jsonld_recommended (the settings tool for a publisher node), and nothing
|
||||
// for a valid single graph.
|
||||
func TestCheckJSONLD(t *testing.T) {
|
||||
got := kinds(checkJSONLD(okPage("/a", &PageFacts{JSONLD: []string{`{"@type":`, `{"@graph":[{"@type":"BlogPosting","headline":"h"},{"@type":"Organization"}]}`}}), &Record{CustomJSONLD: true}))
|
||||
if got[KindJSONLDInvalid].Tool != ToolSetURLSEO || got[KindJSONLDMultiple].Details["count"] != 2 {
|
||||
t.Fatalf("invalid/multiple = %+v", got)
|
||||
}
|
||||
if rec := got[KindJSONLDRecommended]; rec.Details["type"] != "BlogPosting" || rec.Tool != ToolSetURLSEO {
|
||||
t.Fatalf("recommended = %+v", rec)
|
||||
}
|
||||
pub := kinds(checkJSONLD(okPage("/a", &PageFacts{JSONLD: []string{`[{"@type":"Person"}]`}}), nil))[KindJSONLDRequired]
|
||||
if pub.Details["property"] != "name" || pub.Tool != ToolUpdateSettings {
|
||||
t.Fatalf("publisher required = %+v", pub)
|
||||
}
|
||||
if none := checkJSONLD(okPage("/a", &PageFacts{JSONLD: []string{`{"@type":"WebSite","name":"n","url":"u"}`}}), nil); len(none) != 0 {
|
||||
t.Fatalf("a valid graph: %+v", none)
|
||||
}
|
||||
if none := checkJSONLD(okPage("/a", &PageFacts{}), nil); none != nil {
|
||||
t.Fatal("no scripts, no findings")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckImages pins image_alt (an empty alt is decorative, not a
|
||||
// finding; a record image routes to the image_alt tool), image_size and
|
||||
// featured_lazy, and the list cap.
|
||||
func TestCheckImages(t *testing.T) {
|
||||
facts := &PageFacts{OGImage: "https://example.test/m/cover.jpg", Images: []Image{
|
||||
{Src: "https://example.test/m/cover.jpg", Alt: "c", HasAlt: true, Width: "10", Height: "10", Loading: "lazy"},
|
||||
{Src: "https://example.test/m/a.jpg", HasAlt: false, Width: "1", Height: "1"},
|
||||
{Src: "https://example.test/m/deco.png", Alt: "", HasAlt: true},
|
||||
}}
|
||||
rec := &Record{Images: []pact.ContentImage{{URL: "/m/a.jpg"}}, Tools: map[string]string{pact.ContentToolImageAlt: "journal_set_image_alt"}}
|
||||
got := kinds(checkImages(okPage("/a", facts), rec))
|
||||
if alt := got[KindImageAlt]; alt.Details["count"] != 1 || len(alt.Content) != 2 {
|
||||
t.Fatalf("image_alt = %+v", alt)
|
||||
}
|
||||
if _, tool, _ := Route(got[KindImageAlt]); tool != "journal_set_image_alt" {
|
||||
t.Fatalf("image_alt routes to %q", tool)
|
||||
}
|
||||
if got[KindImageSize].Details["count"] != 1 || got[KindFeaturedLazy].Details["image"] != "https://example.test/m/cover.jpg" {
|
||||
t.Fatalf("size/lazy = %+v", got)
|
||||
}
|
||||
many := &PageFacts{}
|
||||
for i := 0; i < 15; i++ {
|
||||
many.Images = append(many.Images, Image{Src: "/x.jpg", Width: "1", Height: "1"})
|
||||
}
|
||||
if alt := kinds(checkImages(okPage("/b", many), nil))[KindImageAlt]; alt.Details["count"] != 15 || len(alt.Details["images"].([]string)) != maxListed || len(alt.Content) != 0 {
|
||||
t.Fatalf("capped image_alt = %+v", alt)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckContentCMS pins content_js (check cms): the record title
|
||||
// missing from the H1, under half the record's words; nothing when both
|
||||
// are met or without a record.
|
||||
func TestCheckContentCMS(t *testing.T) {
|
||||
words := 100
|
||||
rec := &Record{Title: "Chaga", Words: &words}
|
||||
got := checkContentCMS(&Result{}, okPage("/a", &PageFacts{Headings: []Heading{{1, "Other"}}, Words: 40}), rec)
|
||||
if len(got) != 1 || got[0].Kind != KindContentJS || got[0].Check != checkCMS || got[0].Details["words_cms"] != 100 || got[0].Details["title"] != "Chaga" {
|
||||
t.Fatalf("content_js = %+v", got)
|
||||
}
|
||||
if ok := checkContentCMS(&Result{}, okPage("/a", &PageFacts{Headings: []Heading{{1, "Chaga — grzyb"}}, Words: 50}), rec); ok != nil {
|
||||
t.Fatalf("met: %+v", ok)
|
||||
}
|
||||
if none := checkContentCMS(&Result{}, okPage("/a", &PageFacts{}), nil); none != nil {
|
||||
t.Fatal("no record, no finding")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnvNoindex pins the staging lock: on a non-production environment
|
||||
// no page counts as noindex; otherwise meta robots and X-Robots-Tag do.
|
||||
func TestEnvNoindex(t *testing.T) {
|
||||
meta := okPage("/a", &PageFacts{Robots: "noindex"})
|
||||
header := &Page{Key: "/b", Resp: &Response{Status: 200, XRobots: "noindex, nofollow"}, Facts: &PageFacts{}}
|
||||
if !(&Env{}).noindex(meta) || !(&Env{}).noindex(header) || (&Env{Staging: true}).noindex(meta) || (&Env{}).noindex(okPage("/c", &PageFacts{})) {
|
||||
t.Fatal("noindex is wrong")
|
||||
}
|
||||
var nilEnv *Env
|
||||
if nilEnv.record("x") != nil || locale(nil) != "" || locale(&Record{Locale: "pl"}) != "pl" {
|
||||
t.Fatal("nil env and record helpers")
|
||||
}
|
||||
}
|
||||
74
classes/crawl/compare_test.go
Normal file
74
classes/crawl/compare_test.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package crawl
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func linkFacts(words int, h1 string, hrefs ...string) *PageFacts {
|
||||
f := &PageFacts{Words: words}
|
||||
if h1 != "" {
|
||||
f.Headings = []Heading{{Level: 1, Text: h1}}
|
||||
}
|
||||
for _, h := range hrefs {
|
||||
u, _ := url.Parse(h)
|
||||
f.Links = append(f.Links, Link{URL: u, Href: h})
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
var siteHosts = []string{"example.test", "www.example.test"}
|
||||
|
||||
// TestCompareRendered pins the rendered comparison (P33-55): a same-host
|
||||
// link (the www twin too), an H1 and growth of at least 50 words and 20 %
|
||||
// present only after JavaScript are one content_js finding with check
|
||||
// rendered; other-host links, smaller growth and an identical rendering
|
||||
// find nothing.
|
||||
func TestCompareRendered(t *testing.T) {
|
||||
raw := linkFacts(100, "", "https://example.test/a")
|
||||
rendered := linkFacts(160, "Grzyby", "https://example.test/a", "https://www.example.test/b?x=1", "https://other.test/c", "https://example.test/b?x=1")
|
||||
got := compareRendered(raw, rendered, siteHosts)
|
||||
if len(got) != 1 || got[0].Kind != KindContentJS || got[0].Check != checkRendered {
|
||||
t.Fatalf("findings = %+v", got)
|
||||
}
|
||||
d := got[0].Details
|
||||
if links := d["links"].([]string); len(links) != 1 || links[0] != "/b?x=1" || d["h1"] != "Grzyby" || d["words_raw"] != 100 || d["words_rendered"] != 160 {
|
||||
t.Fatalf("details = %v", d)
|
||||
}
|
||||
if !strings.Contains(got[0].What, "links /b?x=1") || !strings.Contains(got[0].What, "H1 “Grzyby”") || !strings.Contains(got[0].What, "60 words of text") {
|
||||
t.Fatalf("what = %q", got[0].What)
|
||||
}
|
||||
|
||||
for name, pair := range map[string][2]*PageFacts{
|
||||
"identical": {linkFacts(100, "T", "https://example.test/a"), linkFacts(100, "T", "https://example.test/a")},
|
||||
"other host only": {linkFacts(100, "T"), linkFacts(100, "T", "https://other.test/x")},
|
||||
"49 words": {linkFacts(100, "T"), linkFacts(149, "T")},
|
||||
"50 words under 20 %": {linkFacts(300, "T"), linkFacts(350, "T")},
|
||||
"H1 already there": {linkFacts(10, "Raw"), linkFacts(10, "Rendered")},
|
||||
} {
|
||||
if got := compareRendered(pair[0], pair[1], siteHosts); got != nil {
|
||||
t.Errorf("%s: %+v", name, got)
|
||||
}
|
||||
}
|
||||
if got := compareRendered(linkFacts(0, "T"), linkFacts(50, "T"), siteHosts); len(got) != 1 {
|
||||
t.Fatalf("50 words from an empty page: %+v", got)
|
||||
}
|
||||
if compareRendered(nil, rendered, siteHosts) != nil || compareRendered(raw, nil, siteHosts) != nil {
|
||||
t.Fatal("a missing side compares to nothing")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCompareRenderedCap pins the cap of the links a finding names, and
|
||||
// that a path without a slash counts as /.
|
||||
func TestCompareRenderedCap(t *testing.T) {
|
||||
var hrefs []string
|
||||
for _, p := range []string{"", "/1", "/2", "/3", "/4", "/5", "/6"} {
|
||||
hrefs = append(hrefs, "https://example.test"+p)
|
||||
}
|
||||
got := compareRendered(linkFacts(10, "T"), linkFacts(10, "T", hrefs...), siteHosts)
|
||||
links := got[0].Details["links"].([]string)
|
||||
if len(links) != maxJSOnlyURLs || links[0] != "/" {
|
||||
t.Fatalf("links = %v", links)
|
||||
}
|
||||
}
|
||||
80
classes/crawl/fix_test.go
Normal file
80
classes/crawl/fix_test.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package crawl
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/sm-seo-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
)
|
||||
|
||||
// TestRoute pins P33-38: a content finding names the record type's first
|
||||
// fitting tool in the order given; without one a fixed CMS tool; else
|
||||
// code with the finding's own what, the kind's default, or the page
|
||||
// template.
|
||||
func TestRoute(t *testing.T) {
|
||||
both := &Record{Tools: map[string]string{pact.ContentToolHeadings: "acme_fix_headings", pact.ContentToolEdit: "acme_update"}}
|
||||
editOnly := &Record{Tools: map[string]string{pact.ContentToolEdit: " acme_update "}}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
f Finding
|
||||
fix, tool, what string
|
||||
}{
|
||||
{"headings first", Finding{Kind: KindH1Multiple, Content: []string{pact.ContentToolHeadings, pact.ContentToolEdit}, Record: both}, models.FixTool, "acme_fix_headings", ""},
|
||||
{"edit fallback", Finding{Kind: KindH1Multiple, Content: []string{pact.ContentToolHeadings, pact.ContentToolEdit}, Record: editOnly}, models.FixTool, "acme_update", ""},
|
||||
{"no record tool, fixed tool", Finding{Kind: KindJSONLDInvalid, Content: []string{pact.ContentToolEdit}, Record: &Record{}, Tool: ToolSetURLSEO}, models.FixTool, ToolSetURLSEO, ""},
|
||||
{"nil record", Finding{Kind: KindImageAlt, Content: []string{pact.ContentToolImageAlt}}, models.FixCode, "", defaultWhat[KindImageAlt]},
|
||||
{"fixed tool", Finding{Kind: KindMissingTitle, Tool: ToolSetURLSEO}, models.FixTool, ToolSetURLSEO, ""},
|
||||
{"own what", Finding{Kind: KindContentJS, What: "links /a"}, models.FixCode, "", "links /a"},
|
||||
{"default what", Finding{Kind: KindCanonicalMissing}, models.FixCode, "", defaultWhat[KindCanonicalMissing]},
|
||||
{"page template", Finding{Kind: "unknown"}, models.FixCode, "", "the page template"},
|
||||
} {
|
||||
fix, tool, what := Route(tc.f)
|
||||
if fix != tc.fix || tool != tc.tool || what != tc.what {
|
||||
t.Errorf("%s: %s %q %q, want %s %q %q", tc.name, fix, tool, what, tc.fix, tc.tool, tc.what)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRouteEveryKind pins that every kind gets a tool or a code fix.
|
||||
func TestRouteEveryKind(t *testing.T) {
|
||||
for _, k := range Kinds() {
|
||||
fix, tool, what := Route(Finding{Kind: k})
|
||||
if (fix == models.FixTool && tool == "") || (fix == models.FixCode && what == "") || (fix != models.FixTool && fix != models.FixCode) {
|
||||
t.Errorf("%s: %s %q %q", k, fix, tool, what)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestFingerprint pins the identity of a finding across crawls: stable,
|
||||
// 32 hex characters, and different for each of its four parts.
|
||||
func TestFingerprint(t *testing.T) {
|
||||
a := Fingerprint(KindContentJS, "/a", "pl", "cms")
|
||||
if a != Fingerprint(KindContentJS, "/a", "pl", "cms") || len(a) != 32 {
|
||||
t.Fatalf("fingerprint %q", a)
|
||||
}
|
||||
for _, other := range []string{
|
||||
Fingerprint(KindH1Missing, "/a", "pl", "cms"), Fingerprint(KindContentJS, "/b", "pl", "cms"),
|
||||
Fingerprint(KindContentJS, "/a", "en", "cms"), Fingerprint(KindContentJS, "/a", "pl", "rendered"),
|
||||
Fingerprint(KindContentJS, "/a\x00pl", "", "cms"),
|
||||
} {
|
||||
if other == a {
|
||||
t.Fatal("a different finding has the same fingerprint")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecordHeadingHelpers pins hasHeading (any level or text, compared
|
||||
// without case and extra space) and hasEmptyHeading on a nil record too.
|
||||
func TestRecordHeadingHelpers(t *testing.T) {
|
||||
rec := &Record{Headings: []pact.ContentHeading{{Level: 2, Text: "Lwia Grzywa"}, {Level: 3, Text: " "}}}
|
||||
if !rec.hasHeading(2, "lwia grzywa") || !rec.hasHeading(0, "") || rec.hasHeading(1, "") || rec.hasHeading(2, "Chaga") {
|
||||
t.Fatal("hasHeading is wrong")
|
||||
}
|
||||
if !hasEmptyHeading(rec, 3) || hasEmptyHeading(rec, 2) || hasEmptyHeading(nil, 1) {
|
||||
t.Fatal("hasEmptyHeading is wrong")
|
||||
}
|
||||
var none *Record
|
||||
if none.hasHeading(0, "") || none.tool(pact.ContentToolEdit) != "" {
|
||||
t.Fatal("a nil record has no headings and no tools")
|
||||
}
|
||||
}
|
||||
128
classes/crawl/parse_test.go
Normal file
128
classes/crawl/parse_test.go
Normal file
@@ -0,0 +1,128 @@
|
||||
package crawl
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const fixtureHTML = `<!doctype html>
|
||||
<html lang="pl-PL"><head>
|
||||
<title> Grzyby lecznicze </title>
|
||||
<title>Second</title>
|
||||
<meta name="description" content=" O grzybach ">
|
||||
<meta name="robots" content="index, follow">
|
||||
<meta name="googlebot" content="noimageindex">
|
||||
<meta property="og:image" content="/media/cover.jpg">
|
||||
<link rel="canonical" href="/blog/grzyby#top">
|
||||
<link rel="canonical" href="/other">
|
||||
<link rel="alternate" hreflang="en" href="/en/blog/mushrooms">
|
||||
<link rel="alternate" hreflang="x-default" href="https://example.test/blog/grzyby">
|
||||
<link rel="alternate" type="application/rss+xml" href="/feed.xml">
|
||||
<script type="application/ld+json">{"@type":"WebPage"}</script>
|
||||
<script>var x = "<h1>not a heading</h1>";</script>
|
||||
</head><body>
|
||||
<header><a href="/">Start</a></header>
|
||||
<main>
|
||||
<h1>Grzyby <img src="/i/logo.png" alt="lecznicze"></h1>
|
||||
<h2></h2>
|
||||
<h4>Detail</h4>
|
||||
<p>Raz dwa trzy.</p>
|
||||
<a href="/blog/chaga?x=1#frag" rel="NoFollow">Chaga</a>
|
||||
<a href="javascript:void(0)">js</a><a href="mailto:a@b.c">mail</a><a href="tel:1">tel</a><a href="#top">top</a>
|
||||
<a href="https://other.test/x">Other</a>
|
||||
<img src="/media/a.jpg" alt="" width="10" height="5" loading="LAZY">
|
||||
<img src="/media/b.jpg">
|
||||
<noscript>hidden text</noscript>
|
||||
<style>.x{}</style>
|
||||
</main>
|
||||
<footer>Stopka</footer>
|
||||
</body></html>`
|
||||
|
||||
// TestParseFacts pins Parse on an HTML fixture: language, the first title
|
||||
// collapsed, description, robots joined from robots and googlebot,
|
||||
// og:image and the first canonical resolved without the fragment,
|
||||
// hreflang alternates only, JSON-LD scripts only, headings in order (an
|
||||
// image alt counts as heading text, script content never), links without
|
||||
// javascript:, mailto:, tel: and fragment-only ones, images with and
|
||||
// without alt, and the main text without noscript and style.
|
||||
func TestParseFacts(t *testing.T) {
|
||||
base, _ := url.Parse("https://example.test/blog/grzyby")
|
||||
f := Parse(base, []byte(fixtureHTML))
|
||||
if f.Lang != "pl-PL" || !f.HasTitle || f.Title != "Grzyby lecznicze" || !f.HasDescription || f.Description != "O grzybach" {
|
||||
t.Fatalf("head = %+v", f)
|
||||
}
|
||||
if f.Robots != "index, follow, noimageindex" || f.Noindex() {
|
||||
t.Fatalf("robots = %q", f.Robots)
|
||||
}
|
||||
if f.OGImage != "https://example.test/media/cover.jpg" || f.Canonical != "https://example.test/blog/grzyby" {
|
||||
t.Fatalf("og:image %q canonical %q", f.OGImage, f.Canonical)
|
||||
}
|
||||
if len(f.Alternates) != 2 || f.Alternates[0].Lang != "en" || f.Alternates[0].URL.String() != "https://example.test/en/blog/mushrooms" || f.Alternates[1].Lang != "x-default" {
|
||||
t.Fatalf("alternates = %+v", f.Alternates)
|
||||
}
|
||||
if len(f.JSONLD) != 1 || f.JSONLD[0] != `{"@type":"WebPage"}` {
|
||||
t.Fatalf("json-ld = %q", f.JSONLD)
|
||||
}
|
||||
if len(f.Headings) != 3 || f.Headings[0] != (Heading{1, "Grzyby lecznicze"}) || f.Headings[1] != (Heading{2, ""}) || f.Headings[2] != (Heading{4, "Detail"}) {
|
||||
t.Fatalf("headings = %+v", f.Headings)
|
||||
}
|
||||
if h1 := f.H1(); len(h1) != 1 || h1[0] != "Grzyby lecznicze" {
|
||||
t.Fatalf("H1 = %v", h1)
|
||||
}
|
||||
var links []string
|
||||
for _, l := range f.Links {
|
||||
links = append(links, l.URL.String())
|
||||
}
|
||||
if strings.Join(links, " ") != "https://example.test/ https://example.test/blog/chaga?x=1 https://other.test/x" || f.Links[1].Rel != "nofollow" || f.Links[1].Text != "Chaga" {
|
||||
t.Fatalf("links = %v (%+v)", links, f.Links)
|
||||
}
|
||||
if len(f.Images) != 3 || !f.Images[1].HasAlt || f.Images[1].Alt != "" || f.Images[1].Loading != "lazy" || f.Images[2].HasAlt || f.Images[2].Src != "https://example.test/media/b.jpg" {
|
||||
t.Fatalf("images = %+v", f.Images)
|
||||
}
|
||||
if strings.Contains(f.Text, "hidden text") || strings.Contains(f.Text, ".x{}") || strings.Contains(f.Text, "Stopka") || !strings.Contains(f.Text, "Raz dwa trzy.") || f.Words != len(strings.Fields(f.Text)) {
|
||||
t.Fatalf("text = %q (%d words)", f.Text, f.Words)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseBodyText pins the text root: main when there is one, else the
|
||||
// body; and an empty document parses to empty facts.
|
||||
func TestParseBodyText(t *testing.T) {
|
||||
f := Parse(nil, []byte(`<html><body><nav>Menu</nav><p>One two</p></body></html>`))
|
||||
if f.Text != "Menu One two" || f.Words != 3 || f.HasTitle {
|
||||
t.Fatalf("body text = %q", f.Text)
|
||||
}
|
||||
if empty := Parse(nil, nil); empty.Words != 0 || len(empty.Links) != 0 {
|
||||
t.Fatalf("empty = %+v", empty)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNoindexValues pins the robots values that mean noindex.
|
||||
func TestNoindexValues(t *testing.T) {
|
||||
for v, want := range map[string]bool{"noindex": true, "NOINDEX, follow": true, "none": true, "index;noindex": true, "index, follow": false, "": false, "noindexing": false} {
|
||||
if got := hasNoindex(v); got != want {
|
||||
t.Errorf("hasNoindex(%q) = %v, want %v", v, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseCaps pins the link cap and the text cap with a UTF-8 safe cut.
|
||||
func TestParseCaps(t *testing.T) {
|
||||
var b strings.Builder
|
||||
b.WriteString("<main>")
|
||||
for i := 0; i < MaxLinksPerPage+10; i++ {
|
||||
b.WriteString(`<a href="/x">x</a>`)
|
||||
}
|
||||
b.WriteString("</main>")
|
||||
if got := len(Parse(nil, []byte(b.String())).Links); got != MaxLinksPerPage {
|
||||
t.Fatalf("%d links, want the %d cap", got, MaxLinksPerPage)
|
||||
}
|
||||
long := "<main>" + strings.Repeat("żółw ", maxText/4) + "</main>"
|
||||
f := Parse(nil, []byte(long))
|
||||
if len(f.Text) > maxText || !strings.HasPrefix(f.Text, "żółw") || strings.ContainsRune(f.Text, '<27>') {
|
||||
t.Fatalf("text of %d bytes", len(f.Text))
|
||||
}
|
||||
if truncateUTF8("żółw", 2) != "ż" || truncateUTF8("ab", 5) != "ab" {
|
||||
t.Fatal("truncateUTF8 is wrong")
|
||||
}
|
||||
}
|
||||
126
classes/crawl/render_test.go
Normal file
126
classes/crawl/render_test.go
Normal file
@@ -0,0 +1,126 @@
|
||||
package crawl
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/compass"
|
||||
)
|
||||
|
||||
// TestAllowRequest pins what the headless browser may load: https on the
|
||||
// site host and its www twin, data: and blob:; http only in trusted mode;
|
||||
// other hosts, credentials in the URL and every other scheme refused.
|
||||
func TestAllowRequest(t *testing.T) {
|
||||
hosts := []string{"example.test", "www.example.test"}
|
||||
for _, tc := range []struct {
|
||||
url string
|
||||
trusted bool
|
||||
want bool
|
||||
}{
|
||||
{"https://example.test/a.js", false, true},
|
||||
{"https://WWW.example.test/b.css", false, true},
|
||||
{"https://example.test:8443/x", false, true},
|
||||
{"data:image/png;base64,AAAA", false, true},
|
||||
{"blob:https://example.test/uuid", false, true},
|
||||
{"http://example.test/a.js", false, false},
|
||||
{"http://example.test/a.js", true, true},
|
||||
{"https://cdn.other.test/lib.js", false, false},
|
||||
{"https://example.test.evil.test/x", false, false},
|
||||
{"https://user:pw@example.test/x", false, false},
|
||||
{"ws://example.test/socket", false, false},
|
||||
{"file:///etc/passwd", false, false},
|
||||
{"javascript:alert(1)", true, false},
|
||||
{"://bad", false, false},
|
||||
} {
|
||||
if got := allowRequest(tc.url, hosts, tc.trusted); got != tc.want {
|
||||
t.Errorf("allowRequest(%q, trusted=%v) = %v, want %v", tc.url, tc.trusted, got, tc.want)
|
||||
}
|
||||
}
|
||||
if !allowRequest("https://[::1]/x", []string{"[::1]"}, false) {
|
||||
t.Fatal("a bracketed IPv6 host must match")
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolverRules pins Chrome's --host-resolver-rules: one MAP per
|
||||
// allowed host, sorted, to its first IPv4 address (else the first one, in
|
||||
// brackets for IPv6), a host without addresses left out, and MAP *
|
||||
// ~NOTFOUND last, since Chrome takes the first matching rule.
|
||||
func TestResolverRules(t *testing.T) {
|
||||
got := resolverRules(map[string][]netip.Addr{
|
||||
"www.example.test": {netip.MustParseAddr("2001:db8::1")},
|
||||
"example.test": {netip.MustParseAddr("2001:db8::2"), netip.MustParseAddr("192.0.2.10"), netip.MustParseAddr("192.0.2.11")},
|
||||
"empty.test": nil,
|
||||
})
|
||||
want := "MAP example.test 192.0.2.10, MAP www.example.test [2001:db8::1], MAP * ~NOTFOUND"
|
||||
if got != want {
|
||||
t.Fatalf("rules = %q\nwant %q", got, want)
|
||||
}
|
||||
if got := resolverRules(nil); got != "MAP * ~NOTFOUND" {
|
||||
t.Fatalf("no hosts = %q", got)
|
||||
}
|
||||
if !strings.HasSuffix(got, "MAP * ~NOTFOUND") || strings.Count(got, "~NOTFOUND") != 1 {
|
||||
t.Fatal("the catch-all must come once and last")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewRenderer pins the comparison's start: no renderer and no error
|
||||
// without a Chrome path (skipped), an error for a relative path, a
|
||||
// missing file, a directory or a file that is not executable (failed).
|
||||
func TestNewRenderer(t *testing.T) {
|
||||
site, _ := url.Parse("https://example.test")
|
||||
cfg := func(path string) *compass.Config {
|
||||
c, err := compass.Open(compass.Options{Dir: t.TempDir()})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if path != "" {
|
||||
if err := c.Set(ChromePathConfigKey, path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return c
|
||||
}
|
||||
if r, err := NewRenderer(context.Background(), cfg(""), site, false, "ua"); r != nil || err != nil {
|
||||
t.Fatalf("no path: %v %v, want skipped", r, err)
|
||||
}
|
||||
if r, err := NewRenderer(context.Background(), nil, site, false, "ua"); r != nil || err != nil {
|
||||
t.Fatal("no config: skipped")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
plain := filepath.Join(dir, "chrome.txt")
|
||||
if err := os.WriteFile(plain, []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for name, path := range map[string]string{"relative": "bin/chrome", "missing": filepath.Join(dir, "nope"), "directory": dir, "not executable": plain} {
|
||||
r, err := NewRenderer(context.Background(), cfg(path), site, false, "ua")
|
||||
if r != nil || err == nil || !strings.Contains(err.Error(), "Chrome path") {
|
||||
t.Errorf("%s: %v %v, want a Chrome path error", name, r, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenderMax pins the page cap: the default 200, a positive config
|
||||
// value, and the default for 0 or less.
|
||||
func TestRenderMax(t *testing.T) {
|
||||
if RenderMax(nil) != DefaultRenderMax || DefaultRenderMax != 200 {
|
||||
t.Fatal("default cap")
|
||||
}
|
||||
for v, want := range map[int]int{50: 50, 0: DefaultRenderMax, -1: DefaultRenderMax} {
|
||||
c, _ := compass.Open(compass.Options{Dir: t.TempDir()})
|
||||
_ = c.Set(RenderMaxConfigKey, v)
|
||||
if got := RenderMax(c); got != want {
|
||||
t.Errorf("RenderMax(%d) = %d, want %d", v, got, want)
|
||||
}
|
||||
}
|
||||
if got := SiteHosts("WWW.Example.test"); len(got) != 2 || got[0] != "www.example.test" || got[1] != "example.test" {
|
||||
t.Fatalf("SiteHosts = %v", got)
|
||||
}
|
||||
if got := SiteHosts("example.test"); got[1] != "www.example.test" || SiteHosts(" ") != nil {
|
||||
t.Fatalf("SiteHosts = %v", got)
|
||||
}
|
||||
}
|
||||
108
classes/pagination_test.go
Normal file
108
classes/pagination_test.go
Normal file
@@ -0,0 +1,108 @@
|
||||
package classes
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/sm-seo-plugin/models"
|
||||
)
|
||||
|
||||
var pathForm = PageConfig{Form: models.PageFormPath, Segments: map[string]string{"pl": "strona", "en": "page"}}
|
||||
var queryForm = PageConfig{Form: models.PageFormQuery, Segments: map[string]string{"pl": "strona", "en": "page"}}
|
||||
|
||||
// TestPageNumber pins the page value: digits only, at least 1, at most
|
||||
// nine digits.
|
||||
func TestPageNumber(t *testing.T) {
|
||||
for v, want := range map[string]int{"1": 1, "2": 2, "007": 7, "999999999": 999999999} {
|
||||
if n, ok := pageNumber(v); !ok || n != want {
|
||||
t.Errorf("pageNumber(%q) = %d %v", v, n, ok)
|
||||
}
|
||||
}
|
||||
for _, v := range []string{"", "0", "-1", "1.5", "a", "1e3", "1234567890", " 2"} {
|
||||
if _, ok := pageNumber(v); ok {
|
||||
t.Errorf("pageNumber(%q) accepted", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestParsePage pins both forms: ?page=N (other parameters kept, a
|
||||
// non-canonical number not exact, an invalid value), and a trailing
|
||||
// /<segment>/N with any locale's segment, an unknown segment ignored, and
|
||||
// a non-number suffix invalid only in the path form.
|
||||
func TestParsePage(t *testing.T) {
|
||||
q := url.Values{"page": {"3"}, "sort": {"new"}}
|
||||
pr := parsePage("/blog", q, queryForm)
|
||||
if !pr.carried || pr.invalid || pr.page != 3 || pr.form != models.PageFormQuery || !pr.exact || pr.rest.Get("sort") != "new" || pr.rest.Has("page") || pr.base != "/blog" {
|
||||
t.Fatalf("query form = %+v", pr)
|
||||
}
|
||||
if pr := parsePage("/blog", url.Values{"page": {"03"}}, queryForm); pr.exact || pr.page != 3 {
|
||||
t.Fatalf("leading zero = %+v, want not exact", pr)
|
||||
}
|
||||
if pr := parsePage("/blog", url.Values{"page": {"2", "3"}}, queryForm); pr.exact {
|
||||
t.Fatal("two values are not exact")
|
||||
}
|
||||
if pr := parsePage("/blog", url.Values{"page": {"x"}}, queryForm); !pr.carried || !pr.invalid {
|
||||
t.Fatalf("invalid query value = %+v", pr)
|
||||
}
|
||||
if pr := parsePage("/blog", url.Values{"page": {""}}, queryForm); !pr.invalid {
|
||||
t.Fatal("an empty page value is invalid")
|
||||
}
|
||||
|
||||
pr = parsePage("/blog/strona/2", nil, pathForm)
|
||||
if !pr.carried || pr.invalid || pr.page != 2 || pr.form != models.PageFormPath || pr.segment != "strona" || pr.base != "/blog" || !pr.exact {
|
||||
t.Fatalf("path form = %+v", pr)
|
||||
}
|
||||
if pr := parsePage("/page/4/", nil, pathForm); pr.page != 4 || pr.base != "/" || pr.segment != "page" {
|
||||
t.Fatalf("root path form = %+v", pr)
|
||||
}
|
||||
if pr := parsePage("/blog/seite/2", nil, pathForm); pr.carried {
|
||||
t.Fatalf("an unknown segment = %+v", pr)
|
||||
}
|
||||
if pr := parsePage("/blog/strona/x", nil, pathForm); !pr.carried || !pr.invalid {
|
||||
t.Fatalf("a non-number suffix in the path form = %+v", pr)
|
||||
}
|
||||
if pr := parsePage("/blog/strona/x", nil, queryForm); pr.carried {
|
||||
t.Fatalf("a non-number suffix in the query form = %+v", pr)
|
||||
}
|
||||
if pr := parsePage("/blog/strona/02", nil, pathForm); pr.exact || pr.page != 2 {
|
||||
t.Fatalf("a leading zero in the path = %+v", pr)
|
||||
}
|
||||
if pr := parsePage("/blog", nil, pathForm); pr.carried {
|
||||
t.Fatal("no page")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPageLocation pins the 301 targets: page 1 is the address without a
|
||||
// number, page 2 and later in the site's form with the other parameters.
|
||||
func TestPageLocation(t *testing.T) {
|
||||
rest := url.Values{"sort": {"new"}}
|
||||
if got := pageLocation("/blog", rest, 1, queryForm, "pl", NormalizeOptions{}); got != "/blog?sort=new" {
|
||||
t.Fatalf("page 1 query = %q", got)
|
||||
}
|
||||
if got := pageLocation("/blog", rest, 3, queryForm, "pl", NormalizeOptions{}); got != "/blog?page=3&sort=new" {
|
||||
t.Fatalf("page 3 query = %q", got)
|
||||
}
|
||||
if got := pageLocation("/blog/", nil, 2, pathForm, "pl", NormalizeOptions{}); got != "/blog/strona/2" {
|
||||
t.Fatalf("page 2 path pl = %q", got)
|
||||
}
|
||||
if got := pageLocation("/blog", nil, 2, pathForm, "de", NormalizeOptions{}); got != "/blog/seite/2" {
|
||||
t.Fatalf("page 2 path de = %q", got)
|
||||
}
|
||||
if got := pagedPath("/", "page", 5); got != "/page/5" {
|
||||
t.Fatalf("pagedPath = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPageSegments pins the segment per locale and the placeholders.
|
||||
func TestPageSegments(t *testing.T) {
|
||||
cfg := PageConfig{Segments: map[string]string{"pl": "str"}}
|
||||
if cfg.Segment("pl") != "str" || cfg.Segment("de") != "seite" || cfg.Segment("en") != "page" || cfg.Segment("fr") != "page" {
|
||||
t.Fatal("Segment is wrong")
|
||||
}
|
||||
if !ValidPageSegment("strona-2") || ValidPageSegment("Strona") || ValidPageSegment("") || ValidPageSegment("a/b") {
|
||||
t.Fatal("ValidPageSegment is wrong")
|
||||
}
|
||||
if fillPage(" – strona %page% z %pages%", 2) != " – strona 2 z %pages%" {
|
||||
t.Fatal("fillPage must fill the page number and keep the pages placeholder")
|
||||
}
|
||||
}
|
||||
110
classes/registry_test.go
Normal file
110
classes/registry_test.go
Normal file
@@ -0,0 +1,110 @@
|
||||
package classes
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/sm-seo-plugin/models"
|
||||
)
|
||||
|
||||
// TestValidPagePath pins the registry address rule: a path from one /,
|
||||
// no domain, query, fragment, backslash or space, valid UTF-8 without
|
||||
// control characters, at most MaxRouteBytes.
|
||||
func TestValidPagePath(t *testing.T) {
|
||||
for p, want := range map[string]bool{
|
||||
"/": true, "/kontakt": true, "/trwają-prace": true, "/a/b/c": true, "/" + strings.Repeat("a", MaxRouteBytes-1): true,
|
||||
"": false, "kontakt": false, "//evil.test": false, "/a?b": false, "/a#b": false, `/a\b`: false, "/a b": false,
|
||||
"/a\x01": false, "/\xff": false, "https://example.test/a": false, "/" + strings.Repeat("a", MaxRouteBytes): false,
|
||||
} {
|
||||
if got := ValidPagePath(p); got != want {
|
||||
t.Errorf("ValidPagePath(%q) = %v, want %v", p, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalPagePath pins the site's address rules on a registry path: the
|
||||
// path trimmed, lower-cased and slashed as the settings say, an invalid
|
||||
// path returned trimmed.
|
||||
func TestNormalPagePath(t *testing.T) {
|
||||
if got := normalPagePath(" /Kontakt/ ", NormalizeOptions{Lowercase: true, Slash: "remove"}); got != "/kontakt" {
|
||||
t.Fatalf("lowercase, no slash = %q", got)
|
||||
}
|
||||
if got := normalPagePath("/Kontakt", NormalizeOptions{}); got != "/Kontakt" {
|
||||
t.Fatalf("no rules = %q", got)
|
||||
}
|
||||
if got := normalPagePath(" kontakt ", NormalizeOptions{Lowercase: true}); got != "kontakt" {
|
||||
t.Fatalf("invalid path = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyPageFields pins the scalar fields of a page write: kind and
|
||||
// note trimmed, the parent set and cleared, the content date parsed,
|
||||
// cleared by "" and refused when invalid; unset fields stay.
|
||||
func TestApplyPageFields(t *testing.T) {
|
||||
kind, note, date, parent := " offer ", " n ", "2026-11-04", uint(7)
|
||||
p := &models.Page{Kind: models.PageKindPage}
|
||||
if problems := ApplyPageFields(p, PageWrite{Kind: &kind, Note: ¬e, ContentUpdatedAt: &date, ParentSet: true, ParentID: &parent}); len(problems) != 0 {
|
||||
t.Fatalf("problems = %v", problems)
|
||||
}
|
||||
if p.Kind != "offer" || p.Note != "n" || p.ContentUpdatedAt == nil || p.ContentUpdatedAt.String() != "2026-11-04" || p.ParentID == nil || uint(*p.ParentID) != 7 {
|
||||
t.Fatalf("page = %+v", p)
|
||||
}
|
||||
empty, bad, zero := "", "04.11.2026", uint(0)
|
||||
if problems := ApplyPageFields(p, PageWrite{ContentUpdatedAt: &empty, ParentSet: true, ParentID: &zero}); len(problems) != 0 || p.ContentUpdatedAt != nil || p.ParentID != nil || p.Kind != "offer" {
|
||||
t.Fatalf("cleared = %+v %v", p, problems)
|
||||
}
|
||||
if problems := ApplyPageFields(p, PageWrite{ContentUpdatedAt: &bad}); len(problems["content_updated_at"]) != 1 {
|
||||
t.Fatalf("an invalid date: %v", problems)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPageGroupAndMissing pins the sitemap group per kind (unknown kinds
|
||||
// with the pages) and the Not on the site flag: a reported page the
|
||||
// newest report did not name; never a manual page or before any report.
|
||||
func TestPageGroupAndMissing(t *testing.T) {
|
||||
for kind, want := range map[string]string{models.PageKindPage: "pages", models.PageKindOffer: "offers", models.PageKindService: "services", models.PageKindLanding: "landing-pages", "other": "pages"} {
|
||||
if got := PageGroup(kind); got != want {
|
||||
t.Errorf("PageGroup(%q) = %q, want %q", kind, got, want)
|
||||
}
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
older := now.Add(-time.Hour)
|
||||
for _, tc := range []struct {
|
||||
p models.Page
|
||||
reported *time.Time
|
||||
want bool
|
||||
}{
|
||||
{models.Page{Origin: models.PageOriginReported, ReportedAt: &older}, &now, true},
|
||||
{models.Page{Origin: models.PageOriginReported}, &now, true},
|
||||
{models.Page{Origin: models.PageOriginReported, ReportedAt: &now}, &now, false},
|
||||
{models.Page{Origin: models.PageOriginManual, ReportedAt: &older}, &now, false},
|
||||
{models.Page{Origin: models.PageOriginReported, ReportedAt: &older}, nil, false},
|
||||
} {
|
||||
if got := PageMissing(tc.p, tc.reported); got != tc.want {
|
||||
t.Errorf("PageMissing(%+v) = %v, want %v", tc.p, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPageBeforeSaveUTC pins the registry page's timestamps in UTC
|
||||
// whatever the process's zone: a new row gets both, a saved row keeps its
|
||||
// created_at.
|
||||
func TestPageBeforeSaveUTC(t *testing.T) {
|
||||
local := time.Local
|
||||
time.Local = time.FixedZone("UTC+3", 3*3600)
|
||||
defer func() { time.Local = local }()
|
||||
p := &models.Page{}
|
||||
if err := p.BeforeSave(nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p.CreatedAt == nil || p.UpdatedAt == nil || p.CreatedAt.Location() != time.UTC || p.UpdatedAt.Location() != time.UTC {
|
||||
t.Fatalf("stamps = %v %v", p.CreatedAt, p.UpdatedAt)
|
||||
}
|
||||
created := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
p.CreatedAt = &created
|
||||
_ = p.BeforeSave(nil)
|
||||
if !p.CreatedAt.Equal(created) || !p.UpdatedAt.After(created) {
|
||||
t.Fatalf("a saved row = %v %v", p.CreatedAt, p.UpdatedAt)
|
||||
}
|
||||
}
|
||||
148
classes/schema/custom_test.go
Normal file
148
classes/schema/custom_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
214
classes/schema/graph_test.go
Normal file
214
classes/schema/graph_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
86
classes/schema/validate_test.go
Normal file
86
classes/schema/validate_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
64
classes/sitemap_filter_test.go
Normal file
64
classes/sitemap_filter_test.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package classes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/sm-sitemap-plugin/contract"
|
||||
)
|
||||
|
||||
// TestCandidatePath pins the addresses the filter judges: a path and an
|
||||
// absolute URL on the site or its www twin (path and query split), and
|
||||
// not another host, a non-http scheme or a blank address. The reasons per
|
||||
// record are TestFilterReasons' on the database.
|
||||
func TestCandidatePath(t *testing.T) {
|
||||
b := &filterBatch{site: "https://example.test"}
|
||||
for _, tc := range []struct {
|
||||
raw, path, query string
|
||||
ok bool
|
||||
}{
|
||||
{"/blog/a", "/blog/a", "", true},
|
||||
{"/blog?page=2", "/blog", "page=2", true},
|
||||
{"https://example.test/kontakt", "/kontakt", "", true},
|
||||
{"https://www.example.test/kontakt?x=1", "/kontakt", "x=1", true},
|
||||
{"https://other.test/kontakt", "", "", false},
|
||||
{"ftp://example.test/a", "", "", false},
|
||||
{" ", "", "", false},
|
||||
} {
|
||||
path, _, raw, ok := b.candidatePath(tc.raw)
|
||||
if ok != tc.ok || (ok && (path != tc.path || raw != tc.query)) {
|
||||
t.Errorf("candidatePath(%q) = %q %q %v, want %q %q %v", tc.raw, path, raw, ok, tc.path, tc.query, tc.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSameAddress pins the comparison of a canonical with the page's
|
||||
// address: the host without case, the path and the query as written.
|
||||
func TestSameAddress(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
a, b string
|
||||
want bool
|
||||
}{
|
||||
{"https://example.test/a", "https://example.test/a", true},
|
||||
{"https://EXAMPLE.test/a", "https://example.test/a", true},
|
||||
{"https://example.test/a", "https://example.test/A", false},
|
||||
{"https://example.test/a?x=1", "https://example.test/a?x=2", false},
|
||||
{"https://example.test/a", "https://www.example.test/a", false},
|
||||
{"https://example.test/a%20b", "https://example.test/a b", true},
|
||||
{"%zz", "https://example.test/", false},
|
||||
} {
|
||||
if got := sameAddress(tc.a, tc.b); got != tc.want {
|
||||
t.Errorf("sameAddress(%q, %q) = %v, want %v", tc.a, tc.b, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestFilterWithoutService pins the fail-open filter: without a booted
|
||||
// service every candidate stays listed, one decision each, in order.
|
||||
func TestFilterWithoutService(t *testing.T) {
|
||||
f := SitemapFilter{Service: func() *Service { return nil }}
|
||||
got, err := f.Decide(context.Background(), []contract.Candidate{{URL: "/a"}, {URL: "/b"}})
|
||||
if err != nil || len(got) != 2 || got[0].Exclude || got[1].Exclude {
|
||||
t.Fatalf("decisions = %+v err=%v", got, err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user