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, ""+classes.EscapeText(text)+"") } 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, ""); n != classes.MaxURLs { t.Fatalf("%d urls written, want %d", n, classes.MaxURLs) } if !strings.Contains(xml, "https://api.example.test/u/50000") || 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") } }