diff --git a/classes/alternates_test.go b/classes/alternates_test.go new file mode 100644 index 0000000..79ee1ce --- /dev/null +++ b/classes/alternates_test.go @@ -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) + } + } +} diff --git a/classes/autolinks_test.go b/classes/autolinks_test.go new file mode 100644 index 0000000..e63b1d5 --- /dev/null +++ b/classes/autolinks_test.go @@ -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) + } +} diff --git a/classes/breadcrumbs_test.go b/classes/breadcrumbs_test.go new file mode 100644 index 0000000..3344a21 --- /dev/null +++ b/classes/breadcrumbs_test.go @@ -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 :") + } +} + +// TestHomePath pins the home crumb's address: / 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) + } +} diff --git a/classes/crawl/checks_test.go b/classes/crawl/checks_test.go new file mode 100644 index 0000000..91a39e3 --- /dev/null +++ b/classes/crawl/checks_test.go @@ -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") + } +} diff --git a/classes/crawl/compare_test.go b/classes/crawl/compare_test.go new file mode 100644 index 0000000..7fa0799 --- /dev/null +++ b/classes/crawl/compare_test.go @@ -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) + } +} diff --git a/classes/crawl/fix_test.go b/classes/crawl/fix_test.go new file mode 100644 index 0000000..916e0d4 --- /dev/null +++ b/classes/crawl/fix_test.go @@ -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") + } +} diff --git a/classes/crawl/parse_test.go b/classes/crawl/parse_test.go new file mode 100644 index 0000000..5cc6b2b --- /dev/null +++ b/classes/crawl/parse_test.go @@ -0,0 +1,128 @@ +package crawl + +import ( + "net/url" + "strings" + "testing" +) + +const fixtureHTML = ` + + Grzyby lecznicze +Second + + + + + + + + + + + + +
Start
+
+

Grzyby lecznicze

+

+

Detail

+

Raz dwa trzy.

+Chaga +jsmailteltop +Other + + + + +
+
Stopka
+` + +// 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(`

One two

`)) + 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("
") + for i := 0; i < MaxLinksPerPage+10; i++ { + b.WriteString(`x`) + } + b.WriteString("
") + if got := len(Parse(nil, []byte(b.String())).Links); got != MaxLinksPerPage { + t.Fatalf("%d links, want the %d cap", got, MaxLinksPerPage) + } + long := "
" + strings.Repeat("żółw ", maxText/4) + "
" + f := Parse(nil, []byte(long)) + if len(f.Text) > maxText || !strings.HasPrefix(f.Text, "żółw") || strings.ContainsRune(f.Text, '�') { + t.Fatalf("text of %d bytes", len(f.Text)) + } + if truncateUTF8("żółw", 2) != "ż" || truncateUTF8("ab", 5) != "ab" { + t.Fatal("truncateUTF8 is wrong") + } +} diff --git a/classes/crawl/render_test.go b/classes/crawl/render_test.go new file mode 100644 index 0000000..a8a76f2 --- /dev/null +++ b/classes/crawl/render_test.go @@ -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) + } +} diff --git a/classes/pagination_test.go b/classes/pagination_test.go new file mode 100644 index 0000000..ab38eca --- /dev/null +++ b/classes/pagination_test.go @@ -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 +// //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") + } +} diff --git a/classes/registry_test.go b/classes/registry_test.go new file mode 100644 index 0000000..ba40b3f --- /dev/null +++ b/classes/registry_test.go @@ -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) + } +} diff --git a/classes/schema/custom_test.go b/classes/schema/custom_test.go new file mode 100644 index 0000000..53e3353 --- /dev/null +++ b/classes/schema/custom_test.go @@ -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