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&bd\"e'f\rg\th\ni" if got := classes.EscapeText(raw); got != "a&b<c>d\"e'f g\th\ni" { t.Fatalf("EscapeText = %q", got) } if got := classes.EscapeAttr(raw); got != "a&b<c>d"e'f g h i" { t.Fatalf("EscapeAttr = %q", got) } if got := classes.EscapeText("zażółć — &"); got != "zażółć — &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 := "\n" + "\n" + `` + "https://h/a?x=1&y=<2>2026-01-02T03:04:05+00:00weekly&<>0.5 " + `` + `` + "\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, "") { 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=&c=\"y\"" changefreq := "daily&" 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, ""):] for _, bad := range []string{"", "&b", "&y", `"y"`, "", "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{ "daily&<weekly>", "0.9\"'", `hreflang="pl&<"`, `hreflang="en""`, "y=2", } { if !strings.Contains(body, good) { t.Fatalf("output lacks %q:\n%s", good, body) } } if strings.Count(body, "") != 2 { t.Fatalf("urls = %d:\n%s", strings.Count(body, ""), body) } } func text(s string) *string { return &s }