test(17-06): sitemap parity unit tests

- TestURLTo (every Url::to golden case, BackendURL, URLPath),
  TestXMLEscaping (PHP XmlEscapingTest as behaviour), TestTiers (each
  default rule, first match, off, bad pattern fails Boot), TestRewrite,
  TestHooks, TestURLCap, TestManifest (fail-safe reads, key
  normalization, dates), TestFillable (PHP MassAssignmentTest),
  TestDefinitionItemJSON, TestBackendURL (trusted forwarded proto)
- TestImportWinter on Postgres: maps and drops with report, --theme,
  --dry-run, --force, settings upsert, refusals
This commit is contained in:
2026-10-12 19:38:04 +02:00
parent 5a260db51b
commit 4e55939a1f
9 changed files with 957 additions and 0 deletions

151
classes/generator_test.go Normal file
View File

@@ -0,0 +1,151 @@
package classes_test
import (
"context"
"errors"
"strconv"
"strings"
"testing"
"git.golem15.com/golem15/sm-sitemap-plugin/classes"
"git.golem15.com/golem15/sm-sitemap-plugin/models"
"git.golem15.com/golem15/summercms/modules/festival"
)
func urlItems(paths ...string) []models.DefinitionItem {
out := make([]models.DefinitionItem, 0, len(paths))
for _, p := range paths {
out = append(out, models.DefinitionItem{Type: models.ItemTypeURL, URL: text(p), Changefreq: text("daily"), Priority: text("0.5")})
}
return out
}
// TestHooks pins the typed successors of the PHP events (D-14): a
// BeforeAddItem listener that sets Skip drops the URL before it is counted,
// the first skipping listener stops the rest, AddItem listeners see the
// written loc and their Extra is written before the url closing tag in
// order, and a listener error stops the generation.
func TestHooks(t *testing.T) {
bus := festival.New()
var seenBefore, seenAdd, afterSkip []string
bus.Listen("acme.first", func(_ context.Context, e *classes.BeforeAddItem) error {
seenBefore = append(seenBefore, e.Info.URL)
if strings.HasSuffix(e.Info.URL, "/skip") {
e.Skip = true
}
return nil
})
bus.Listen("acme.second", func(_ context.Context, e *classes.BeforeAddItem) error {
afterSkip = append(afterSkip, e.Info.URL)
return nil
})
bus.Listen("acme.extra", func(_ context.Context, e *classes.AddItem) error {
seenAdd = append(seenAdd, e.Info.URL)
e.Extra = append(e.Extra, "<image:image><image:loc>"+classes.EscapeText(e.Info.URL+"?a&b")+"</image:loc></image:image>")
return nil
})
bus.Listen("acme.more", func(_ context.Context, e *classes.AddItem) error {
e.Extra = append(e.Extra, "<x/>")
return nil
})
gen := &classes.Generator{Root: "https://h.test", Events: bus}
xml, err := gen.Generate(context.Background(), urlItems("/a", "/skip", "/b"))
if err != nil {
t.Fatal(err)
}
if strings.Join(seenBefore, ",") != "https://h.test/a,https://h.test/skip,https://h.test/b" {
// The url item's URL is filled through Url::to before the event.
t.Fatalf("BeforeAddItem saw %v", seenBefore)
}
if strings.Join(afterSkip, ",") != "https://h.test/a,https://h.test/b" {
t.Fatalf("a listener after the skipping one ran for the skipped URL: %v", afterSkip)
}
if strings.Contains(xml, "/skip") {
t.Fatalf("the skipped URL is written:\n%s", xml)
}
if strings.Join(seenAdd, ",") != "https://h.test/a,https://h.test/b" {
t.Fatalf("AddItem saw %v", seenAdd)
}
want := "<url><loc>https://h.test/a</loc><changefreq>daily</changefreq><priority>0.5</priority><image:image><image:loc>https://h.test/a?a&amp;b</image:loc></image:image><x/></url>"
if !strings.Contains(xml, want) {
t.Fatalf("extra not written before the closing tag:\n%s", xml)
}
// A skip on the exact URL the event carries.
bus2 := festival.New()
var second []string
bus2.Listen("acme.skip", func(_ context.Context, e *classes.BeforeAddItem) error {
e.Skip = strings.HasSuffix(e.Info.URL, "/skip")
return nil
})
bus2.Listen("acme.later", func(_ context.Context, e *classes.BeforeAddItem) error {
second = append(second, e.Info.URL)
return nil
})
gen = &classes.Generator{Root: "https://h.test", Events: bus2}
xml, err = gen.Generate(context.Background(), urlItems("/a", "/skip"))
if err != nil {
t.Fatal(err)
}
if strings.Contains(xml, "/skip<") || strings.Join(second, ",") != "https://h.test/a" {
t.Fatalf("UntilHandled did not stop at the skipping listener: %v\n%s", second, xml)
}
// Without listeners or a bus nothing changes.
gen = &classes.Generator{Root: "https://h.test"}
if xml, err := gen.Generate(context.Background(), urlItems("/a")); err != nil || !strings.Contains(xml, "<loc>https://h.test/a</loc>") {
t.Fatalf("no bus: %v\n%s", err, xml)
}
// A listener error stops the generation.
failing := festival.New()
failing.Listen("acme.fail", func(_ context.Context, e *classes.AddItem) error { return errors.New("listener failed") })
gen = &classes.Generator{Root: "https://h.test", Events: failing}
if _, err := gen.Generate(context.Background(), urlItems("/a")); err == nil || !strings.Contains(err.Error(), "listener failed") {
t.Fatalf("listener error = %v", err)
}
}
// TestURLCap pins the protocol limit: of 50,001 URLs the 50,000th is
// written and the 50,001st is not; skipped and empty URLs are not counted;
// no items is an empty document.
func TestURLCap(t *testing.T) {
paths := make([]string, classes.MaxURLs+1)
for i := range paths {
paths[i] = "/u/" + strconv.Itoa(i+1)
}
gen := &classes.Generator{Root: "https://api.example.test"}
xml, err := gen.Generate(context.Background(), urlItems(paths...))
if err != nil {
t.Fatal(err)
}
if n := strings.Count(xml, "<url>"); n != classes.MaxURLs {
t.Fatalf("%d url elements, want %d", n, classes.MaxURLs)
}
if !strings.Contains(xml, "<loc>https://api.example.test/u/50000</loc>") || strings.Contains(xml, "<loc>https://api.example.test/u/50001</loc>") {
t.Fatal("the 50000th URL must be written and the 50001st must not")
}
// Skipped URLs do not count toward the cap.
bus := festival.New()
bus.Listen("acme.skip", func(_ context.Context, e *classes.BeforeAddItem) error {
e.Skip = strings.HasSuffix(e.Info.URL, "/u/1")
return nil
})
gen = &classes.Generator{Root: "https://api.example.test", Events: bus}
xml, err = gen.Generate(context.Background(), urlItems(paths...))
if err != nil {
t.Fatal(err)
}
if strings.Count(xml, "<url>") != classes.MaxURLs || !strings.Contains(xml, "/u/50001</loc>") {
t.Fatal("a skipped URL took a place under the cap")
}
// A generator is reusable: a second run starts its count again.
if again, err := gen.Generate(context.Background(), urlItems("/x")); err != nil || strings.Count(again, "<url>") != 1 {
t.Fatalf("second run = %v, %d urls", err, strings.Count(again, "<url>"))
}
if empty, err := (&classes.Generator{Root: "https://h"}).Generate(context.Background(), nil); err != nil || empty != "" {
t.Fatalf("no items = %q %v", empty, err)
}
}

81
classes/manifest_test.go Normal file
View File

@@ -0,0 +1,81 @@
package classes_test
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
"git.golem15.com/golem15/sm-sitemap-plugin/classes"
)
// TestManifest pins the updates manifest reader: it fails safe (a missing,
// empty-path, directory, oversized, non-object or invalid file is no
// manifest, so lastmod is omitted), normalizes keys, ignores non-string
// values, and parses the ISO dates strtotime reads, a date without a zone
// as UTC.
func TestManifest(t *testing.T) {
dir := t.TempDir()
write := func(name, body string) string {
t.Helper()
path := filepath.Join(dir, name)
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
return path
}
for name, path := range map[string]string{
"empty path": "",
"spaces": " ",
"missing": filepath.Join(dir, "missing.json"),
"directory": dir,
"list": write("list.json", `["/a"]`),
"string": write("string.json", `"x"`),
"invalid": write("invalid.json", `{"/a": `),
"empty file": write("empty.json", ""),
"oversized": write("big.json", `{"/a":"2026-01-01","pad":"`+strings.Repeat("x", classes.ManifestMaxBytes)+`"}`),
"null": write("null.json", "null"),
"leading space": write("space.json", " \n"),
} {
if m := classes.LoadManifest(path); m != nil {
t.Errorf("%s: manifest = %v, want none", name, m)
}
}
if fifo := filepath.Join(dir, "link.json"); os.Symlink(dir, fifo) == nil {
if m := classes.LoadManifest(fifo); m != nil {
t.Error("a symlink to a directory is not a manifest")
}
}
path := write("updates.json", ` {"blog/a/":"2026-01-02T03:04:05+02:00","/b":"2026-02-03","c":"2026-03-04 05:06:07","/":"2026-04-05T06:07","/num":12,"/bad":"yesterday","/epoch":"1970-01-01T00:00:00Z","/nested":{"x":1}}`)
m := classes.LoadManifest(path)
if m == nil {
t.Fatal("valid manifest not read")
}
if _, ok := m["/num"]; ok {
t.Fatal("a non-string value was kept")
}
for url, want := range map[string]string{
"https://h/blog/a": "2026-01-02T01:04:05Z",
"https://h/blog/a/": "2026-01-02T01:04:05Z",
"https://h/b?x=1": "2026-02-03T00:00:00Z",
"/c": "2026-03-04T05:06:07Z",
"https://h": "2026-04-05T06:07:00Z",
"https://h/": "2026-04-05T06:07:00Z",
} {
got, ok := m.Lastmod(url)
if !ok || got.UTC().Format(time.RFC3339) != want {
t.Errorf("Lastmod(%s) = %v %v, want %s", url, got, ok, want)
}
}
for _, url := range []string{"https://h/bad", "https://h/epoch", "https://h/unlisted", "https://h/num"} {
if got, ok := m.Lastmod(url); ok {
t.Errorf("Lastmod(%s) = %v, want none", url, got)
}
}
var none classes.Manifest
if _, ok := none.Lastmod("https://h/b"); ok {
t.Fatal("a nil manifest answered")
}
}

52
classes/rewrite_test.go Normal file
View File

@@ -0,0 +1,52 @@
package classes_test
import (
"testing"
"git.golem15.com/golem15/sm-sitemap-plugin/classes"
)
// TestRewrite pins the PHP routes.php frontend_url rewrite: the backend URL
// is replaced inside loc, inside xhtml:link href and in the exact stylesheet
// href, nowhere else; slashes are trimmed on both; an empty or "0" frontend
// URL and equal URLs leave the document unchanged.
func TestRewrite(t *testing.T) {
const backend = "https://api.ex.pl"
doc := "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" +
`<?xml-stylesheet type="text/xsl" href="https://api.ex.pl/sitemap.xsl"?>` + "\n" +
`<urlset><url><loc>https://api.ex.pl/a</loc><lastmod>2026</lastmod><changefreq>https://api.ex.pl</changefreq><priority>1</priority>` +
`<xhtml:link rel="alternate" hreflang="pl" href="https://api.ex.pl/a"/><xhtml:link rel="alternate" hreflang="en" href="https://api.ex.pl/en/a"/>` +
`<extra>https://api.ex.pl/x</extra></url><url><loc>https://other.pl/https://api.ex.pl</loc><changefreq>c</changefreq><priority>p</priority></url></urlset>` + "\n"
want := "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" +
`<?xml-stylesheet type="text/xsl" href="https://www.ex.pl/sitemap.xsl"?>` + "\n" +
`<urlset><url><loc>https://www.ex.pl/a</loc><lastmod>2026</lastmod><changefreq>https://api.ex.pl</changefreq><priority>1</priority>` +
`<xhtml:link rel="alternate" hreflang="pl" href="https://www.ex.pl/a"/><xhtml:link rel="alternate" hreflang="en" href="https://www.ex.pl/en/a"/>` +
`<extra>https://api.ex.pl/x</extra></url><url><loc>https://other.pl/https://www.ex.pl</loc><changefreq>c</changefreq><priority>p</priority></url></urlset>` + "\n"
for _, frontend := range []string{"https://www.ex.pl", "https://www.ex.pl/", "https://www.ex.pl///"} {
for _, back := range []string{backend, backend + "/"} {
if got := classes.Rewrite(doc, back, frontend); got != want {
t.Fatalf("Rewrite(%q, %q):\n%s\nwant:\n%s", back, frontend, got, want)
}
}
}
for _, frontend := range []string{"", "0"} {
if got := classes.Rewrite(doc, backend, frontend); got != doc {
t.Fatalf("frontend %q changed the document", frontend)
}
}
if got := classes.Rewrite(doc, backend, backend); got != doc {
t.Fatal("equal URLs changed the document")
}
if got := classes.Rewrite(doc, backend+"/", backend); got != doc {
t.Fatal("equal URLs up to a slash changed the document")
}
// An empty backend URL replaces nothing.
if got := classes.Rewrite(doc, "", "https://www.ex.pl"); got != doc {
t.Fatal("an empty backend URL changed the document")
}
// A stylesheet on another path is not the PHP pattern.
other := `<?xml-stylesheet type="text/xsl" href="https://api.ex.pl/other.xsl"?>`
if got := classes.Rewrite(other, backend, "https://www.ex.pl"); got != other {
t.Fatalf("other stylesheet = %q", got)
}
}

95
classes/serialize_test.go Normal file
View File

@@ -0,0 +1,95 @@
package classes_test
import (
"context"
"strings"
"testing"
"git.golem15.com/golem15/sm-sitemap-plugin/classes"
"git.golem15.com/golem15/sm-sitemap-plugin/models"
)
// TestXMLEscaping ports the PHP XmlEscapingTest (SITE-002) as behaviour:
// every value the sitemap writes is escaped exactly as libxml escapes it,
// text nodes (loc, lastmod, changefreq, priority) and attribute values
// (hreflang, href), and no raw value reaches the output.
func TestXMLEscaping(t *testing.T) {
const raw = "a&b<c>d\"e'f\rg\th\ni"
if got := classes.EscapeText(raw); got != "a&amp;b&lt;c&gt;d\"e'f&#13;g\th\ni" {
t.Fatalf("EscapeText = %q", got)
}
if got := classes.EscapeAttr(raw); got != "a&amp;b&lt;c&gt;d&quot;e'f&#13;g&#9;h&#10;i" {
t.Fatalf("EscapeAttr = %q", got)
}
if got := classes.EscapeText("zażółć — &amp;"); got != "zażółć — &amp;amp;" {
t.Fatalf("EscapeText keeps UTF-8 and escapes an existing entity again: %q", got)
}
xml := classes.Serialize("https://h/sitemap.xsl", []classes.URLEntry{{
Loc: "https://h/a?x=1&y=<2>",
Lastmod: "2026-01-02T03:04:05+00:00",
Changefreq: "weekly&<>",
Priority: "0.5\r",
Alternates: []classes.Alternate{
{Hreflang: "pl\"x", Href: "https://h/pl?a=1&b=\"2\"\t\n"},
{Hreflang: "en", Href: "https://h/en'x"},
},
}})
want := "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" +
"<?xml-stylesheet type=\"text/xsl\" href=\"https://h/sitemap.xsl\"?>\n" +
`<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9 http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd">` +
"<url><loc>https://h/a?x=1&amp;y=&lt;2&gt;</loc><lastmod>2026-01-02T03:04:05+00:00</lastmod><changefreq>weekly&amp;&lt;&gt;</changefreq><priority>0.5&#13;</priority>" +
`<xhtml:link rel="alternate" hreflang="pl&quot;x" href="https://h/pl?a=1&amp;b=&quot;2&quot;&#9;&#10;"/>` +
`<xhtml:link rel="alternate" hreflang="en" href="https://h/en'x"/>` +
"</url></urlset>\n"
if xml != want {
t.Fatalf("serialized:\n%q\nwant:\n%q", xml, want)
}
// One alternate is not written; an empty urlset is self-closed.
single := classes.Serialize("s", []classes.URLEntry{{Loc: "l", Changefreq: "c", Priority: "p", Alternates: []classes.Alternate{{Hreflang: "pl", Href: "x"}}}})
if strings.Contains(single, "xhtml:link") || strings.Contains(single, "<lastmod>") {
t.Fatalf("single alternate or empty lastmod written: %q", single)
}
if empty := classes.Serialize("s", nil); !strings.HasSuffix(empty, `sitemap.xsd"/>`+"\n") {
t.Fatalf("empty urlset = %q", empty)
}
// Through the generator: stored changefreq and priority, a resolved
// alternate's locale and a url item with every special character.
url := "/q?a=1&b=<x>&c=\"y\""
changefreq := "daily&<weekly>"
priority := "0.9\"'"
gen := &classes.Generator{Root: "https://api.example.test", Settings: models.Settings{}, Resolver: fakeResolver{t: t, types: map[string]map[string]goldenResolved{
"acme-post": {"1": {URL: "/p?x=1&y=2", Alternates: [][2]string{{"pl&<", "/p?x=1&y=2"}, {"en\"", "/en/p?z=<1>"}}}},
}}}
out, err := gen.Generate(context.Background(), []models.DefinitionItem{
{Type: models.ItemTypeURL, URL: &url, Changefreq: &changefreq, Priority: &priority},
{Type: "acme-post", Reference: text("1"), Changefreq: &changefreq, Priority: &priority},
})
if err != nil {
t.Fatal(err)
}
body := out[strings.Index(out, "<url>"):]
for _, bad := range []string{"<x>", "&b", "&y", `"y"`, "<weekly>", "daily&<", `hreflang="pl&<"`, `en""`, "<1>"} {
if strings.Contains(body, bad) {
t.Fatalf("raw value %q reached the output:\n%s", bad, body)
}
}
for _, good := range []string{
"<changefreq>daily&amp;&lt;weekly&gt;</changefreq>",
"<priority>0.9\"'</priority>",
`hreflang="pl&amp;&lt;"`,
`hreflang="en&quot;"`,
"y=2",
} {
if !strings.Contains(body, good) {
t.Fatalf("output lacks %q:\n%s", good, body)
}
}
if strings.Count(body, "<url>") != 2 {
t.Fatalf("urls = %d:\n%s", strings.Count(body, "<url>"), body)
}
}
func text(s string) *string { return &s }

115
classes/tiers_test.go Normal file
View File

@@ -0,0 +1,115 @@
package classes_test
import (
"os"
"path/filepath"
"strings"
"testing"
sitemap "git.golem15.com/golem15/sm-sitemap-plugin"
"git.golem15.com/golem15/sm-sitemap-plugin/classes"
"git.golem15.com/golem15/sm-sitemap-plugin/models"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/compass"
)
// TestTiers pins the priority tiers (D-13): each default rule of the PHP
// getPriorityForUrl with paths from its list, trailing slashes, the first
// match winning, the settings' values, tiers turned off, a host list that
// replaces the defaults, and a bad rule failing the plugin's Boot.
func TestTiers(t *testing.T) {
tiers := pluginTiers(t)
if len(tiers) != 5 {
t.Fatalf("default rules = %d", len(tiers))
}
s := models.DefaultSettings()
for url, want := range map[string]string{
"https://h": "1.0",
"https://h/": "1.0",
"https://h/en": "1.0",
"https://h/en/": "1.0",
"https://h/uslugi/strony-www": "0.8",
"https://h/en/services/web": "0.8",
"https://h/realizacje/projekt/": "0.8",
"https://h/en/work/x": "0.8",
"https://h/blog/kategoria/wpis": "0.6",
"https://h/en/blog/category/post/": "0.6",
"https://h/blog": "0.4",
"https://h/blog/kategoria": "0.4",
"https://h/uslugi": "0.4",
"https://h/en/work": "0.4",
"https://h/polityka-prywatnosci": "0.2",
"https://h/en/terms/": "0.2",
"https://h/laboratorium-ai/zabawa-animacja": "0.2",
"https://h/en/ai-lab/blob-playground": "0.2",
"https://h/kontakt?x=1": "0.2",
} {
if got, ok := tiers.Priority(url, s); !ok || got != want {
t.Errorf("Priority(%s) = %q %v, want %s", url, got, ok, want)
}
}
for _, url := range []string{"https://h/other", "https://h/blog/a/b/c", "https://h/uslugi/a/b", "https://h/en/blogs/x", "https://h/kontakt/more"} {
if got, ok := tiers.Priority(url, s); ok {
t.Errorf("Priority(%s) = %q, want no rule", url, got)
}
}
custom := s
custom.PriorityBlog = text("0.65")
if got, _ := tiers.Priority("https://h/blog/a/b", custom); got != "0.65" {
t.Fatalf("custom blog priority = %q", got)
}
off := s
off.EnablePriorityTiers = false
if _, ok := tiers.Priority("https://h/", off); ok {
t.Fatal("tiers apply while turned off")
}
// First match wins: an exact rule listed before a pattern that also matches.
ordered, err := classes.CompileTiers([]classes.TierConfig{
{Setting: models.PriorityLegal, Exact: []string{"/blog"}},
{Setting: models.PriorityCategories, Pattern: "^/blog$"},
})
if err != nil {
t.Fatal(err)
}
if got, _ := ordered.Priority("https://h/blog/", s); got != "0.2" {
t.Fatalf("first match = %q", got)
}
if empty, err := classes.CompileTiers(nil); err != nil || len(empty) != 0 {
t.Fatalf("no rules = %v %v", empty, err)
}
if none, err := classes.LoadTiers(nil); err != nil || len(none) != 0 {
t.Fatalf("LoadTiers(nil) = %v %v", none, err)
}
// Every refused rule names its index; a bad RE2 pattern too.
for name, rule := range map[string]classes.TierConfig{
"unknown setting": {Setting: "priority_other", Exact: []string{""}},
"neither": {Setting: models.PriorityHomepage},
"both": {Setting: models.PriorityHomepage, Exact: []string{""}, Pattern: "^/$"},
"lookahead": {Setting: models.PriorityBlog, Pattern: "^/(?=blog)"},
"unbalanced": {Setting: models.PriorityBlog, Pattern: "^/(blog"},
} {
_, err := classes.CompileTiers([]classes.TierConfig{{Setting: models.PriorityHomepage, Exact: []string{""}}, rule})
if err == nil || !strings.Contains(err.Error(), classes.TiersConfigKey+"[1]") {
t.Errorf("%s: err = %v", name, err)
}
}
// A bad host rule fails the plugin's Boot.
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "golem15.yaml"), []byte("sitemap:\n priority_tiers:\n - setting: priority_blog\n pattern: '^/(blog'\n"), 0o644); err != nil {
t.Fatal(err)
}
cfg, err := compass.Open(compass.Options{Dir: dir, Environ: []string{}})
if err != nil {
t.Fatal(err)
}
p := &sitemap.Plugin{}
if err := cfg.MergePlugin("golem15.sitemap", p.ConfigFS()); err != nil {
t.Fatal(err)
}
if err := p.Boot(backpack.New(cfg)); err == nil || !strings.Contains(err.Error(), "priority_tiers[0]") {
t.Fatalf("Boot with a bad pattern = %v", err)
}
}

57
classes/urlto_test.go Normal file
View File

@@ -0,0 +1,57 @@
package classes_test
import (
"encoding/json"
"strings"
"testing"
"git.golem15.com/golem15/sm-sitemap-plugin/classes"
)
// TestURLTo runs every case of testdata/urlto.json through URLTo and
// compares it with the line PHP's Url::to wrote to urlto.golden (SC-3), then
// pins the helpers built on it: BackendURL and URLPath.
func TestURLTo(t *testing.T) {
var in struct {
Root string `json:"root"`
URLs []*string `json:"urls"`
}
if err := json.Unmarshal(readTestdata(t, "urlto.json"), &in); err != nil {
t.Fatal(err)
}
want := strings.Split(strings.TrimSuffix(string(readTestdata(t, "urlto.golden")), "\n"), "\n")
if len(in.URLs) < 50 || len(want) != len(in.URLs) {
t.Fatalf("%d inputs, %d golden lines", len(in.URLs), len(want))
}
for i, u := range in.URLs {
path := "<null>"
input := ""
if u != nil {
path, input = *u, *u
}
if got := classes.URLTo(in.Root, input); got != want[i] {
t.Errorf("case %d URLTo(%q) = %q, PHP %q", i, path, got, want[i])
}
}
for _, tc := range []struct{ root, want string }{
{"https://api.ex.pl", "https://api.ex.pl"},
{"https://api.ex.pl/", "https://api.ex.pl"},
{"http://h:8080/base", "http://h:8080/base"},
} {
if got := classes.BackendURL(tc.root); got != tc.want {
t.Errorf("BackendURL(%q) = %q, want %q", tc.root, got, tc.want)
}
}
for raw, want := range map[string]string{
"https://h/a/b?x=1#f": "/a/b",
"https://h": "",
"https://h/": "/",
"/rel/path": "/rel/path",
"": "",
} {
if got := classes.URLPath(raw); got != want {
t.Errorf("URLPath(%q) = %q, want %q", raw, got, want)
}
}
}

192
import_test.go Normal file
View File

@@ -0,0 +1,192 @@
package sitemap_test
import (
"context"
"encoding/json"
"strings"
"testing"
"git.golem15.com/golem15/sm-sitemap-plugin/console"
"git.golem15.com/golem15/sm-sitemap-plugin/models"
"git.golem15.com/golem15/summercms/modules/signpost"
)
// TestImportWinter pins sitemap:import-winter on Postgres (D-12): type maps
// and drops with their report lines, the reference check, --theme with
// several exported definitions, --dry-run writing nothing, the refusal of a
// second copy without --force and the replacement with it, the settings
// upsert onto row 1 with PHP defaults for missing keys, and the refusals for
// missing, empty and malformed exports.
func TestImportWinter(t *testing.T) {
e := bootSitemap(t)
ctx := context.Background()
reg, ok := e.app.Lookup[*signpost.Registry]()
if !ok {
t.Fatal("signpost registry is not published")
}
dir := t.TempDir()
row := func(id int, theme string, items string) string {
quoted, _ := json.Marshal(items)
return `{"id": ` + string(rune('0'+id)) + `, "theme": "` + theme + `", "data": ` + string(quoted) + "}\n"
}
items := `[{"type":"url","url":"/"},` +
`{"type":"old-posts","reference":null,"nesting":"1"},` +
`{"type":"acme-post","reference":"7"},` +
`{"type":"acme-post","reference":"8"},` +
`{"type":"gone-type","reference":"1"},` +
`{"type":"static-page","reference":"about"},` +
`{"type":"remove-me"}]`
defs := writeFile(t, dir, "definitions.jsonl", row(1, "main", items)+"\n"+row(2, "other", `[{"type":"url","url":"/other"}]`))
stored := func(t *testing.T) []models.DefinitionItem {
t.Helper()
var rows []models.Definition
if err := e.gdb.Where("theme = ?", models.DefinitionTheme).Find(&rows).Error; err != nil {
t.Fatal(err)
}
if len(rows) == 0 {
return nil
}
if len(rows) != 1 {
t.Fatalf("%d definitions", len(rows))
}
return rows[0].Items()
}
run := func(opts console.WinterOptions) (console.WinterReport, string, error) {
out, buf := testOutput()
report, err := console.ImportWinterFiles(ctx, e.gdb, reg, opts, out)
return report, buf.String(), err
}
t.Run("refusals", func(t *testing.T) {
for name, tc := range map[string]struct {
opts console.WinterOptions
want string
}{
"no file": {console.WinterOptions{}, "give --definitions, --settings or both"},
"missing file": {console.WinterOptions{DefinitionsFile: dir + "/missing.jsonl"}, "File not found"},
"empty file": {console.WinterOptions{DefinitionsFile: writeFile(t, dir, "empty.jsonl", "\n\n")}, "holds no definition"},
"bad line": {console.WinterOptions{DefinitionsFile: writeFile(t, dir, "bad.jsonl", "{\n")}, "line 1"},
"several rows": {console.WinterOptions{DefinitionsFile: defs}, "holds 2 definitions (themes 'main', 'other'); pick one with --theme"},
"unknown theme": {console.WinterOptions{DefinitionsFile: defs, Theme: "nope"}, "no definition of theme 'nope'"},
"data not items": {console.WinterOptions{DefinitionsFile: writeFile(t, dir, "scalar.jsonl", `{"id":1,"theme":"t","data":"5"}`)}, "not a list of items"},
"bad settings": {console.WinterOptions{SettingsFile: writeFile(t, dir, "settings-bad.json", `[1]`)}, "must be a JSON object"},
} {
_, out, err := run(tc.opts)
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("%s: err = %v, want %q\n%s", name, err, tc.want, out)
}
}
if got := stored(t); got != nil {
t.Fatalf("a refused import wrote %+v", got)
}
})
opts := console.WinterOptions{DefinitionsFile: defs, Theme: "main", Maps: map[string]string{"old-posts": "acme-list", "remove-me": ""}}
wantLines := []string{
"item 1 (type 'old-posts'): type 'old-posts' mapped to 'acme-list'",
"item 3 (type 'acme-post', reference '8'): dropped (reference '8' is not among the current references of 'acme-post')",
"item 4 (type 'gone-type', reference '1'): dropped (type 'gone-type' is not registered; map it with --map gone-type=<type>)",
"item 5 (type 'static-page', reference 'about'): dropped (static and CMS page types are not supported)",
"item 6 (type 'remove-me'): dropped (mapped to nothing)",
}
t.Run("dry run writes nothing", func(t *testing.T) {
dry := opts
dry.DryRun = true
report, out, err := run(dry)
if err != nil {
t.Fatalf("dry run: %v\n%s", err, out)
}
if report.Kept != 3 || report.Dropped != 4 || report.Remapped != 2 {
t.Fatalf("report = %+v\n%s", report, out)
}
for _, line := range wantLines {
if !strings.Contains(out, line) {
t.Errorf("output lacks %q:\n%s", line, out)
}
}
if !strings.Contains(out, "Dry run: 3 items kept, 2 remapped, 4 dropped.") || !strings.Contains(out, "Dry run complete (no changes made).") {
t.Fatalf("dry run output:\n%s", out)
}
if got := stored(t); got != nil {
t.Fatalf("the dry run wrote %+v", got)
}
})
t.Run("copies, refuses a second copy, replaces with --force", func(t *testing.T) {
report, out, err := run(opts)
if err != nil {
t.Fatalf("import: %v\n%s", err, out)
}
got := stored(t)
if report.Kept != 3 || len(got) != 3 || got[0].Type != "url" || got[1].Type != "acme-list" || !got[1].Nesting || got[2].Type != "acme-post" || models.Text(got[2].Reference) != "7" {
t.Fatalf("copied = %+v (report %+v)", got, report)
}
if !strings.Contains(out, "Definition copied: 3 items kept, 2 remapped, 4 dropped.") || !strings.Contains(out, "theme 'main'") {
t.Fatalf("output:\n%s", out)
}
other := console.WinterOptions{DefinitionsFile: defs, Theme: "other"}
if _, out, err := run(other); err == nil || !strings.Contains(err.Error(), "already has 3 items; rerun with --force") {
t.Fatalf("second copy: %v\n%s", err, out)
}
// A dry run over existing items warns instead of failing.
dry := other
dry.DryRun = true
if _, out, err := run(dry); err != nil || !strings.Contains(out, "already has 3 items") {
t.Fatalf("dry run over items: %v\n%s", err, out)
}
if len(stored(t)) != 3 {
t.Fatal("a refused copy changed the definition")
}
other.Force = true
if _, out, err := run(other); err != nil {
t.Fatalf("--force: %v\n%s", err, out)
}
if got := stored(t); len(got) != 1 || models.Text(got[0].URL) != "/other" {
t.Fatalf("after --force = %+v", got)
}
})
t.Run("data held as JSON or null", func(t *testing.T) {
raw := writeFile(t, dir, "raw.jsonl", `{"id":5,"theme":"t","data":[{"type":"url","url":"/raw"}]}`)
if _, out, err := run(console.WinterOptions{DefinitionsFile: raw, Force: true}); err != nil {
t.Fatalf("raw JSON data: %v\n%s", err, out)
}
if got := stored(t); len(got) != 1 || models.Text(got[0].URL) != "/raw" {
t.Fatalf("raw data = %+v", got)
}
null := writeFile(t, dir, "null.jsonl", `{"id":6,"theme":"t","data":null}`)
if report, out, err := run(console.WinterOptions{DefinitionsFile: null, Force: true}); err != nil || report.Kept != 0 {
t.Fatalf("null data: %+v %v\n%s", report, err, out)
}
if got := stored(t); len(got) != 0 {
t.Fatalf("null data stored %+v", got)
}
})
t.Run("settings upsert", func(t *testing.T) {
first := writeFile(t, dir, "settings.json", `{"frontend_url":"https://www.example.test","enable_priority_tiers":"1","priority_blog":0.7,"unknown":"x"}`)
report, out, err := run(console.WinterOptions{SettingsFile: first})
if err != nil || !report.Settings || !strings.Contains(out, "setting 'unknown' ignored") || !strings.Contains(out, "Settings copied.") {
t.Fatalf("settings: %+v %v\n%s", report, err, out)
}
second := writeFile(t, dir, "settings2.json", `"{\"frontend_url\":\"https://other.example.test\",\"enable_priority_tiers\":\"0\"}"`)
if _, out, err := run(console.WinterOptions{SettingsFile: second}); err != nil {
t.Fatalf("second settings: %v\n%s", err, out)
}
var rows []models.Settings
if err := e.gdb.Find(&rows).Error; err != nil {
t.Fatal(err)
}
if len(rows) != 1 || rows[0].ID != 1 || models.Text(rows[0].FrontendURL) != "https://other.example.test" || rows[0].EnablePriorityTiers || rows[0].Priority(models.PriorityBlog) != "0.6" {
t.Fatalf("settings rows = %+v", rows)
}
dry := writeFile(t, dir, "settings3.json", `{"frontend_url":"https://dry.example.test"}`)
if _, out, err := run(console.WinterOptions{SettingsFile: dry, DryRun: true}); err != nil || !strings.Contains(out, "Dry run: the settings would be copied.") {
t.Fatalf("settings dry run: %v\n%s", err, out)
}
if err := e.gdb.Find(&rows).Error; err != nil || models.Text(rows[0].FrontendURL) != "https://other.example.test" {
t.Fatalf("the dry run changed the settings: %+v %v", rows, err)
}
})
}

145
models/definition_test.go Normal file
View File

@@ -0,0 +1,145 @@
package models_test
import (
"encoding/json"
"fmt"
"slices"
"strings"
"testing"
"git.golem15.com/golem15/sm-sitemap-plugin/models"
"git.golem15.com/golem15/summercms/modules/lagoon"
)
// TestFillable ports the PHP MassAssignmentTest (SITE-001) as behaviour:
// Definition.Fillable is exactly theme and data, and a fill with extra keys
// (id, timestamps, unknown keys) writes only those two.
func TestFillable(t *testing.T) {
if got := (models.Definition{}).Fillable(); !slices.Equal(got, []string{"theme", "data"}) {
t.Fatalf("Fillable = %v, want [theme data]", got)
}
def := models.Definition{ID: 4}
requested := map[string]any{
"id": 99,
"theme": "site",
"data": `[{"type":"url","url":"/a"}]`,
"created_at": "2020-01-01 00:00:00",
"updated_at": "2020-01-01 00:00:00",
"is_admin": true,
}
for _, production := range []bool{true, false} {
d := def
if err := lagoon.Fill(&d, d.Fillable(), requested, production); err != nil {
t.Fatalf("Fill: %v", err)
}
if d.ID != 4 || d.CreatedAt != nil || d.UpdatedAt != nil || models.Text(d.Theme) != "site" {
t.Fatalf("filled definition = %+v", d)
}
if items := d.Items(); len(items) != 1 || models.Text(items[0].URL) != "/a" {
t.Fatalf("filled data = %+v", items)
}
}
if (models.Definition{}).TableName() != "golem15_sitemap_definitions" || (models.Definition{}).MorphName() != `Golem15\Sitemap\Models\Definition` {
t.Fatal("table or morph name changed")
}
if rules := (models.Definition{}).Rules(); len(rules) != 0 {
t.Fatalf("Rules = %v", rules)
}
if got := (models.Settings{}).Fillable(); slices.Contains(got, "id") || len(got) != 9 {
t.Fatalf("Settings.Fillable = %v", got)
}
}
// TestDefinitionItemJSON pins the tolerant decoding of PHP-stored items
// (strings, numbers, booleans and null for text fields, PHP truthiness for
// nesting, unknown keys ignored, a missing type is url, a null item a
// default url item, an object-keyed list in key order) and the PHP toArray
// encoding (key set and order, cmsPage always null, null text kept).
func TestDefinitionItemJSON(t *testing.T) {
for _, tc := range []struct {
raw string
want string
}{
{`{"type":"url","url":"/a","priority":"0.5","changefreq":"daily","nesting":"0","reference":null,"urlPattern":null,"cmsPage":"x","code":"c","exists":false,"fillable":["a"]}`,
`type=url url=/a ref=<nil> pattern=<nil> nesting=false priority=0.5 changefreq=daily`},
{`{"url":"/b"}`, `type=url url=/b ref=<nil> pattern=<nil> nesting=false priority=<nil> changefreq=<nil>`},
{`{"type":null}`, `type= url=<nil> ref=<nil> pattern=<nil> nesting=false priority=<nil> changefreq=<nil>`},
{`{"type":"journal-post","reference":7,"priority":0.5,"changefreq":true,"nesting":1}`,
`type=journal-post url=<nil> ref=7 pattern=<nil> nesting=true priority=0.5 changefreq=1`},
{`{"type":"x","priority":1.0,"reference":12.50,"changefreq":false,"nesting":"1"}`,
`type=x url=<nil> ref=12.5 pattern=<nil> nesting=true priority=1 changefreq=`},
{`{"type":"x","nesting":"false"}`, `type=x url=<nil> ref=<nil> pattern=<nil> nesting=true priority=<nil> changefreq=<nil>`},
{`{"type":"x","nesting":""}`, `type=x url=<nil> ref=<nil> pattern=<nil> nesting=false priority=<nil> changefreq=<nil>`},
{`{"type":"x","nesting":null,"urlPattern":"/p/:slug"}`, `type=x url=<nil> ref=<nil> pattern=/p/:slug nesting=false priority=<nil> changefreq=<nil>`},
{`{"type":"x","reference":12345678901234}`, `type=x url=<nil> ref=12345678901234 pattern=<nil> nesting=false priority=<nil> changefreq=<nil>`},
{`null`, `type=url url=<nil> ref=<nil> pattern=<nil> nesting=false priority=<nil> changefreq=<nil>`},
} {
var item models.DefinitionItem
if err := json.Unmarshal([]byte(tc.raw), &item); err != nil {
t.Fatalf("%s: %v", tc.raw, err)
}
if got := describe(item); got != tc.want {
t.Errorf("%s\n got %s\nwant %s", tc.raw, got, tc.want)
}
}
for _, raw := range []string{`{"type":["x"]}`, `{"url":{"a":1}}`, `{"nesting":[1]}`, `[1]`, `"x"`} {
var item models.DefinitionItem
if err := json.Unmarshal([]byte(raw), &item); err == nil {
t.Errorf("%s decoded without an error", raw)
}
}
// A list, an object keyed like a PHP array with gaps, null and empty.
var list models.DefinitionItems
if err := json.Unmarshal([]byte(`{"3":{"url":"/c"},"1":{"url":"/a"},"x":{"url":"/x"}}`), &list); err != nil || len(list) != 3 ||
models.Text(list[0].URL) != "/c" || models.Text(list[1].URL) != "/a" || models.Text(list[2].URL) != "/x" {
t.Fatalf("object list = %+v %v", list, err)
}
for raw, n := range map[string]int{`[]`: 0, `null`: 0, `[{"url":"/a"},null]`: 2} {
var l models.DefinitionItems
if err := json.Unmarshal([]byte(raw), &l); err != nil || len(l) != n {
t.Fatalf("%s = %+v %v", raw, l, err)
}
}
var bad models.DefinitionItems
if err := json.Unmarshal([]byte(`"items"`), &bad); err == nil {
t.Fatal("a string decoded as an item list")
}
// Encoding: the PHP toArray key set and order.
pattern := "/p/:slug"
item := models.DefinitionItem{Type: "journal-post", Reference: str("7"), URLPattern: &pattern, Nesting: true, Priority: str("0.5")}
raw, err := json.Marshal(item)
if err != nil {
t.Fatal(err)
}
if want := `{"changefreq":null,"priority":"0.5","nesting":true,"type":"journal-post","url":null,"reference":"7","cmsPage":null,"urlPattern":"/p/:slug"}`; string(raw) != want {
t.Fatalf("Marshal = %s\nwant %s", raw, want)
}
if raw, _ := json.Marshal(models.DefinitionItems(nil)); string(raw) != "[]" {
t.Fatalf("nil list = %s", raw)
}
var back models.DefinitionItem
if err := json.Unmarshal(raw, &back); err != nil || describe(back) != describe(item) {
t.Fatalf("round trip = %s, %v", describe(back), err)
}
if models.Text(nil) != "" || models.Text(str("x")) != "x" {
t.Fatal("Text")
}
if s, err := models.DecodeText(json.RawMessage(`1e3`)); err != nil || *s != "1000" {
t.Fatalf("DecodeText(1e3) = %v %v", s, err)
}
}
func str(s string) *string { return &s }
func describe(d models.DefinitionItem) string {
p := func(s *string) string {
if s == nil {
return "<nil>"
}
return *s
}
return strings.TrimSpace(fmt.Sprintf("type=%s url=%s ref=%s pattern=%s nesting=%v priority=%s changefreq=%s",
d.Type, p(d.URL), p(d.Reference), p(d.URLPattern), d.Nesting, p(d.Priority), p(d.Changefreq)))
}

69
routes_test.go Normal file
View File

@@ -0,0 +1,69 @@
package sitemap
import (
"crypto/tls"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/compass"
)
// TestBackendURL pins the root URL the sitemap's absolute URLs and the
// frontend_url rewrite start from: app.url when set; else the request's
// Host with https for TLS, or for the last X-Forwarded-Proto hop of a peer
// in http.trusted_proxies, while a client that sends the header directly
// gets http.
func TestBackendURL(t *testing.T) {
app := func(t *testing.T, files map[string]string) *backpack.App {
t.Helper()
dir := t.TempDir()
for name, body := range files {
if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
cfg, err := compass.Open(compass.Options{Dir: dir, Environ: []string{}})
if err != nil {
t.Fatal(err)
}
return backpack.New(cfg)
}
trusted := app(t, map[string]string{"http.yaml": "trusted_proxies:\n - 10.0.0.0/8\n"})
for _, tc := range []struct {
name string
app *backpack.App
remote string
proto []string
tls bool
want string
}{
{"app.url wins", app(t, map[string]string{"app.yaml": "url: https://api.example.test/\n"}), "203.0.113.9:1234", []string{"http"}, false, "https://api.example.test"},
{"plain request", trusted, "203.0.113.9:1234", nil, false, "http://h.test"},
{"TLS", trusted, "203.0.113.9:1234", nil, true, "https://h.test"},
{"trusted proxy says https", trusted, "10.1.2.3:5000", []string{"https"}, false, "https://h.test"},
{"trusted proxy, last hop wins", trusted, "10.1.2.3:5000", []string{"https, http"}, false, "http://h.test"},
{"trusted proxy, last header wins", trusted, "10.1.2.3:5000", []string{"http", "HTTPS"}, false, "https://h.test"},
{"untrusted client says https", trusted, "203.0.113.9:1234", []string{"https"}, false, "http://h.test"},
{"no trusted proxies configured", app(t, nil), "10.1.2.3:5000", []string{"https"}, false, "http://h.test"},
{"nil app", nil, "10.1.2.3:5000", []string{"https"}, false, "http://h.test"},
{"IPv4-mapped trusted peer", trusted, "[::ffff:10.1.2.3]:5000", []string{"https"}, false, "https://h.test"},
{"unparsable peer", trusted, "not-an-address", []string{"https"}, false, "http://h.test"},
} {
t.Run(tc.name, func(t *testing.T) {
r := httptest.NewRequest("GET", "http://h.test/sitemap.xml", nil)
r.RemoteAddr = tc.remote
for _, v := range tc.proto {
r.Header.Add("X-Forwarded-Proto", v)
}
if tc.tls {
r.TLS = &tls.ConnectionState{}
}
if got := backendURL(tc.app, r); got != tc.want {
t.Fatalf("backendURL = %q, want %q", got, tc.want)
}
})
}
}