Files
sm-sitemap-plugin/classes/golden_test.go
Jakub Zych 4a82240429 feat(17-03): full sitemap generation, settings, hooks and import commands
- generator in Definition::addItemToSet order: 50000 URL cap, lastmod from
  the record or the updates manifest (regular file, 5 MiB cap, fail safe),
  BeforeAddItem (skip) and AddItem (extra XML) festival events, tier or item
  priority, per-alternate expansion of list type children
- priority tiers from golem15.sitemap.priority_tiers, compiled at boot,
  defaults equal to the PHP getPriorityForUrl rules
- frontend_url rewrite of loc, xhtml:link href and the stylesheet href;
  GET /sitemap.xsl serves the PHP stylesheet byte for byte
- golem15_sitemap_settings singleton and Settings model
- sitemap:import (PHP ImportCommand) and sitemap:import-winter with the
  read-only Winter export SQL
- urlto and full goldens generated from PHP; TestSitemapImportSmoke
2026-10-12 17:07:39 +02:00

344 lines
10 KiB
Go

package classes_test
import (
"context"
"encoding/json"
"os"
"path/filepath"
"slices"
"strconv"
"strings"
"testing"
"time"
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/compass"
"git.golem15.com/golem15/summercms/modules/festival"
"git.golem15.com/golem15/summercms/modules/pact"
)
// The goldens in testdata are written by tests/php/gen-goldens.php from the
// same JSON inputs with the PHP plugin's generation code and the vendored
// Winter URL generator; these tests feed the inputs through the Go port and
// compare bytes.
// goldenResolved is a registry item's resolve result in a golden input.
type goldenResolved struct {
URL string `json:"url"`
MTime *string `json:"mtime"`
Alternates [][2]string `json:"alternates"`
Items []goldenResolved `json:"items"`
}
func (g goldenResolved) toPact(t *testing.T) pact.ResolvedMenuItem {
t.Helper()
out := pact.ResolvedMenuItem{URL: g.URL}
if g.MTime != nil {
at, err := time.Parse(time.RFC3339, *g.MTime)
if err != nil {
t.Fatalf("mtime %q: %v", *g.MTime, err)
}
out.MTime = &at
}
for _, a := range g.Alternates {
out.Alternates = append(out.Alternates, pact.MenuItemAlternate{Locale: a[0], URL: a[1]})
}
for _, child := range g.Items {
out.Items = append(out.Items, child.toPact(t))
}
return out
}
// goldenCase is one sitemap case of a golden input: the stored items, the
// registry types' resolve results, the settings (PHP keys), the updates
// manifest, the URLs a BeforeAddItem listener skips and the text of the
// extra element an AddItem listener appends per URL.
type goldenCase struct {
Name string `json:"name"`
Root string `json:"root"`
Items models.DefinitionItems `json:"items"`
Types map[string]map[string]goldenResolved `json:"types"`
Settings map[string]json.RawMessage `json:"settings"`
Manifest map[string]any `json:"manifest"`
Skip []string `json:"skip"`
Extra map[string]string `json:"extra"`
}
// fakeResolver answers Resolve from a golden case's types.
type fakeResolver struct {
t *testing.T
types map[string]map[string]goldenResolved
}
func (f fakeResolver) Type(key string) (pact.MenuItemType, bool) {
_, ok := f.types[key]
return pact.MenuItemType{Key: key}, ok
}
func (f fakeResolver) Resolve(_ context.Context, key, reference, _ string, _ bool, _ string) (*pact.ResolvedMenuItem, error) {
r, ok := f.types[key][reference]
if !ok {
return nil, nil
}
out := r.toPact(f.t)
return &out, nil
}
// pluginTiers compiles the tier rules the plugin's own config/config.yaml
// ships.
func pluginTiers(t *testing.T) classes.Tiers {
t.Helper()
cfg, err := compass.Open(compass.Options{Dir: t.TempDir(), Environ: []string{}})
if err != nil {
t.Fatal(err)
}
if err := cfg.MergePlugin("golem15.sitemap", (&sitemap.Plugin{}).ConfigFS()); err != nil {
t.Fatal(err)
}
tiers, err := classes.LoadTiers(cfg)
if err != nil {
t.Fatal(err)
}
return tiers
}
// caseSettings builds the settings of a case from its PHP keys.
func caseSettings(t *testing.T, c goldenCase) models.Settings {
t.Helper()
s := models.DefaultSettings()
texts := map[string]**string{
"frontend_url": &s.FrontendURL,
"frontend_default_locale": &s.FrontendDefaultLocale,
"priority_homepage": &s.PriorityHomepage,
"priority_services": &s.PriorityServices,
"priority_blog": &s.PriorityBlog,
"priority_categories": &s.PriorityCategories,
"priority_legal": &s.PriorityLegal,
}
for key, raw := range c.Settings {
if key == "enable_priority_tiers" {
if err := json.Unmarshal(raw, &s.EnablePriorityTiers); err != nil {
t.Fatal(err)
}
continue
}
dest, ok := texts[key]
if !ok {
t.Fatalf("case %s: unknown setting %s", c.Name, key)
}
v, err := models.DecodeText(raw)
if err != nil {
t.Fatal(err)
}
*dest = v
}
if c.Manifest != nil {
raw, err := json.Marshal(c.Manifest)
if err != nil {
t.Fatal(err)
}
path := filepath.Join(t.TempDir(), "updates.json")
if err := os.WriteFile(path, raw, 0o644); err != nil {
t.Fatal(err)
}
s.UpdatesManifestPath = &path
}
return s
}
// renderCase runs a case through the generator and the route's rewrite.
func renderCase(t *testing.T, c goldenCase) string {
t.Helper()
bus := festival.New()
bus.Listen("test.skip", func(_ context.Context, e *classes.BeforeAddItem) error {
if slices.Contains(c.Skip, e.Info.URL) {
e.Skip = true
}
return nil
})
bus.Listen("test.extra", func(_ context.Context, e *classes.AddItem) error {
if text, ok := c.Extra[e.Info.URL]; ok {
e.Extra = append(e.Extra, "<extra>"+classes.EscapeText(text)+"</extra>")
}
return nil
})
settings := caseSettings(t, c)
gen := &classes.Generator{
Root: c.Root,
Resolver: fakeResolver{t: t, types: c.Types},
Settings: settings,
Tiers: pluginTiers(t),
Events: bus,
}
xml, err := gen.Generate(context.Background(), c.Items)
if err != nil {
t.Fatalf("generate %s: %v", c.Name, err)
}
return classes.Rewrite(xml, classes.BackendURL(c.Root), models.Text(settings.FrontendURL))
}
func readTestdata(t *testing.T, name string) []byte {
t.Helper()
raw, err := os.ReadFile(filepath.Join("testdata", name))
if err != nil {
t.Fatal(err)
}
return raw
}
// TestSitemapGolden compares the Go sitemap with the PHP goldens.
func TestSitemapGolden(t *testing.T) {
t.Run("urlto", func(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(want) != len(in.URLs) {
t.Fatalf("urlto.golden has %d lines for %d inputs", len(want), len(in.URLs))
}
var got strings.Builder
for i, u := range in.URLs {
path := ""
if u != nil {
path = *u
}
out := classes.URLTo(in.Root, path)
if out != want[i] {
t.Errorf("URLTo(%q) = %q, PHP %q", path, out, want[i])
}
got.WriteString(out + "\n")
}
if got.String() != string(readTestdata(t, "urlto.golden")) {
t.Error("urlto output differs from the golden file")
}
})
t.Run("tracer", func(t *testing.T) {
var in struct {
Case goldenCase `json:"case"`
}
if err := json.Unmarshal(readTestdata(t, "tracer.json"), &in); err != nil {
t.Fatal(err)
}
got := renderCase(t, in.Case)
if want := string(readTestdata(t, "tracer.golden")); got != want {
t.Fatalf("tracer sitemap differs from the PHP golden\n got: %q\nwant: %q", got, want)
}
})
t.Run("full", func(t *testing.T) {
var in struct {
Cases []goldenCase `json:"cases"`
}
if err := json.Unmarshal(readTestdata(t, "full.json"), &in); err != nil {
t.Fatal(err)
}
golden := string(readTestdata(t, "full.golden"))
var all strings.Builder
for i, c := range in.Cases {
got := renderCase(t, c)
all.WriteString("=== " + c.Name + "\n" + got)
header := "=== " + c.Name + "\n"
start := strings.Index(golden, header)
if start < 0 {
t.Errorf("case %s is missing from full.golden", c.Name)
continue
}
want := golden[start+len(header):]
if i+1 < len(in.Cases) {
if end := strings.Index(want, "=== "+in.Cases[i+1].Name+"\n"); end >= 0 {
want = want[:end]
}
}
if got != want {
t.Errorf("case %s differs from the PHP golden\n got: %q\nwant: %q", c.Name, got, want)
}
}
if all.String() != golden {
t.Error("full output differs from full.golden")
}
})
t.Run("cap", func(t *testing.T) {
items := make([]models.DefinitionItem, classes.MaxURLs+1)
for i := range items {
u := "/u/" + strconv.Itoa(i+1)
items[i] = models.DefinitionItem{Type: models.ItemTypeURL, URL: &u}
}
gen := &classes.Generator{Root: "https://api.example.test"}
xml, err := gen.Generate(context.Background(), items)
if err != nil {
t.Fatal(err)
}
if n := strings.Count(xml, "<url>"); n != classes.MaxURLs {
t.Fatalf("%d urls written, want %d", n, classes.MaxURLs)
}
if !strings.Contains(xml, "<loc>https://api.example.test/u/50000</loc>") || strings.Contains(xml, "/u/50001<") {
t.Fatal("the 50000th URL must be written and the 50001st must not")
}
})
}
// TestSitemapTiersConfig checks the boot-time tier rule checks, a host's
// replacement of the default list and the manifest's fail-safe reads.
func TestSitemapTiersConfig(t *testing.T) {
for name, rules := range map[string][]classes.TierConfig{
"unknown setting": {{Setting: "priority_other", Exact: []string{""}}},
"neither": {{Setting: models.PriorityHomepage}},
"both": {{Setting: models.PriorityHomepage, Exact: []string{""}, Pattern: "^/$"}},
"bad pattern": {{Setting: models.PriorityBlog, Pattern: "^/(blog"}},
} {
if _, err := classes.CompileTiers(rules); err == nil {
t.Errorf("%s: compiled, want an error", name)
}
}
dir := t.TempDir()
host := "sitemap:\n priority_tiers:\n - setting: priority_legal\n pattern: '^/legal(/.*)?$'\n"
if err := os.WriteFile(filepath.Join(dir, "golem15.yaml"), []byte(host), 0o644); err != nil {
t.Fatal(err)
}
cfg, err := compass.Open(compass.Options{Dir: dir, Environ: []string{}})
if err != nil {
t.Fatal(err)
}
if err := cfg.MergePlugin("golem15.sitemap", (&sitemap.Plugin{}).ConfigFS()); err != nil {
t.Fatal(err)
}
tiers, err := classes.LoadTiers(cfg)
if err != nil || len(tiers) != 1 {
t.Fatalf("host tiers = %+v, %v", tiers, err)
}
s := models.DefaultSettings()
if p, ok := tiers.Priority("https://h/legal/terms/", s); !ok || p != "0.2" {
t.Fatalf("host rule priority = %q %v", p, ok)
}
if _, ok := tiers.Priority("https://h/", s); ok {
t.Fatal("the host list must replace the default homepage rule")
}
if m := classes.LoadManifest(dir); m != nil {
t.Fatal("a directory is not a manifest")
}
list := filepath.Join(dir, "list.json")
if err := os.WriteFile(list, []byte(`["/a"]`), 0o644); err != nil {
t.Fatal(err)
}
if m := classes.LoadManifest(list); m != nil {
t.Fatal("a JSON list is not a manifest")
}
big := filepath.Join(dir, "big.json")
if err := os.WriteFile(big, append([]byte(`{"/a":"2026-01-01","x":"`), append(make([]byte, classes.ManifestMaxBytes), '"', '}')...), 0o644); err != nil {
t.Fatal(err)
}
if m := classes.LoadManifest(big); m != nil {
t.Fatal("a manifest over the size cap must be ignored")
}
}