- 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
344 lines
10 KiB
Go
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")
|
|
}
|
|
}
|