test: unit tests for the writer, News, IndexNow, HTML groups, slugs, lastmod and slots
- XML escaping and namespaces, no priority, the image cap and file names - News window and cap, sources and languages, the publication name - IndexNow batches of 10 000, answer outcomes, keys and the host filter - HTML sitemap group order, tree order and paging - default slugs, the effective type setting, lastmod config, slot edges
This commit is contained in:
129
classes/htmlgroups_test.go
Normal file
129
classes/htmlgroups_test.go
Normal file
@@ -0,0 +1,129 @@
|
||||
package classes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/sm-sitemap-plugin/models"
|
||||
)
|
||||
|
||||
// TestHTMLGroupOrder pins UI-SPEC L3's order of the named groups.
|
||||
func TestHTMLGroupOrder(t *testing.T) {
|
||||
if got := strings.Join(htmlGroupOrder, " "); got != "pages offers services categories posts tags authors" {
|
||||
t.Fatalf("htmlGroupOrder = %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestToEntries pins the links of rows: absolute on the site, the path as
|
||||
// the title when there is none, the first parent kept, and a row that
|
||||
// cannot be made absolute skipped.
|
||||
func TestToEntries(t *testing.T) {
|
||||
refs, _ := json.Marshal([]models.Ref{{Type: "cat", ID: "1"}, {Type: "cat", ID: "9"}})
|
||||
got := toEntries([]htmlRow{
|
||||
{TypeKey: "cat", RecordID: "2", URL: "/kategoria/b", Title: " Bee ", ParentRefs: string(refs)},
|
||||
{TypeKey: "cat", RecordID: "3", URL: "/kategoria/c"},
|
||||
{TypeKey: "cat", RecordID: "4", URL: "javascript:alert(1)", Title: "x"},
|
||||
{TypeKey: "cat", RecordID: "5", URL: "https://example.test/abs", Title: "Abs", ParentRefs: "[]"},
|
||||
}, "https://example.test")
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("entries = %+v, want 3 (the javascript: row skipped)", got)
|
||||
}
|
||||
if got[0].Title != "Bee" || got[0].URL != "https://example.test/kategoria/b" || got[0].parent == nil || *got[0].parent != (models.Ref{Type: "cat", ID: "1"}) || got[0].node != (models.Ref{Type: "cat", ID: "2"}) {
|
||||
t.Fatalf("entry 0 = %+v", got[0])
|
||||
}
|
||||
if got[1].Title != "/kategoria/c" || got[1].parent != nil || got[2].parent != nil || got[2].URL != "https://example.test/abs" {
|
||||
t.Fatalf("entries 1 and 2 = %+v %+v", got[1], got[2])
|
||||
}
|
||||
}
|
||||
|
||||
func treeEntry(id, title string, parent string) entry {
|
||||
e := entry{HTMLEntry: HTMLEntry{Title: title, URL: "/" + id}, node: models.Ref{Type: "cat", ID: id}}
|
||||
if parent != "" {
|
||||
e.parent = &models.Ref{Type: "cat", ID: parent}
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// TestTreeOrder pins the tree order: depth first with siblings in the
|
||||
// input order (alphabetical from the query), the parent indices into the
|
||||
// flat result, an entry whose parent is not in the group a root, and a
|
||||
// cycle broken into a root.
|
||||
func TestTreeOrder(t *testing.T) {
|
||||
in := []entry{
|
||||
treeEntry("1", "A", ""),
|
||||
treeEntry("2", "A1", "1"),
|
||||
treeEntry("3", "B", ""),
|
||||
treeEntry("4", "A2", "1"),
|
||||
treeEntry("5", "A1a", "2"),
|
||||
treeEntry("6", "Orphan", "99"),
|
||||
treeEntry("7", "Self", "7"),
|
||||
treeEntry("8", "Cy1", "9"),
|
||||
treeEntry("9", "Cy2", "8"),
|
||||
}
|
||||
flat, parents := treeOrder(in)
|
||||
var titles []string
|
||||
for _, e := range flat {
|
||||
titles = append(titles, e.Title)
|
||||
}
|
||||
if got := strings.Join(titles, " "); got != "A A1 A1a A2 B Orphan Self Cy1 Cy2" {
|
||||
t.Fatalf("order = %s", got)
|
||||
}
|
||||
if fmt.Sprint(parents) != "[-1 0 1 0 -1 -1 -1 -1 7]" {
|
||||
t.Fatalf("parents = %v", parents)
|
||||
}
|
||||
if len(flat) != len(in) {
|
||||
t.Fatalf("%d entries out of %d", len(flat), len(in))
|
||||
}
|
||||
}
|
||||
|
||||
// TestNestPaging pins the nesting of one page of the tree: a child whose
|
||||
// parent is on the same page nests under it (children in order), a child
|
||||
// whose parent is on an earlier page is a top-level link of its page, and
|
||||
// pages cover every entry once.
|
||||
func TestNestPaging(t *testing.T) {
|
||||
flat, parents := treeOrder([]entry{
|
||||
treeEntry("1", "A", ""),
|
||||
treeEntry("2", "A1", "1"),
|
||||
treeEntry("3", "A2", "1"),
|
||||
treeEntry("4", "A2x", "3"),
|
||||
treeEntry("5", "B", ""),
|
||||
})
|
||||
whole := nest(flat, parents, 0, len(flat))
|
||||
if len(whole) != 2 || whole[0].Title != "A" || len(whole[0].Children) != 2 || whole[0].Children[1].Title != "A2" ||
|
||||
len(whole[0].Children[1].Children) != 1 || whole[0].Children[1].Children[0].Title != "A2x" || whole[1].Title != "B" || whole[1].Children != nil {
|
||||
t.Fatalf("whole tree = %+v", whole)
|
||||
}
|
||||
first := nest(flat, parents, 0, 2)
|
||||
second := nest(flat, parents, 2, 4)
|
||||
third := nest(flat, parents, 4, 5)
|
||||
if len(first) != 1 || first[0].Title != "A" || len(first[0].Children) != 1 {
|
||||
t.Fatalf("page 1 = %+v", first)
|
||||
}
|
||||
if len(second) != 1 || second[0].Title != "A2" || len(second[0].Children) != 1 || second[0].Children[0].Title != "A2x" {
|
||||
t.Fatalf("page 2 = %+v, want A2 as a top-level link with its child", second)
|
||||
}
|
||||
if len(third) != 1 || third[0].Title != "B" {
|
||||
t.Fatalf("page 3 = %+v", third)
|
||||
}
|
||||
if got := nest(flat, parents, 5, 5); got == nil || len(got) != 0 {
|
||||
t.Fatalf("an empty page = %v, want []", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPickTitle pins the heading per locale: the locale's, else the
|
||||
// default locale's, else "".
|
||||
func TestPickTitle(t *testing.T) {
|
||||
titles := map[string]string{"en": "Posts", "pl": " ", "de": "Beiträge"}
|
||||
for _, tc := range []struct{ locale, def, want string }{
|
||||
{"de", "en", "Beiträge"}, {"pl", "en", "Posts"}, {"fr", "en", "Posts"}, {"fr", "it", ""}, {"en", "", "Posts"},
|
||||
} {
|
||||
if got := pickTitle(titles, tc.locale, tc.def); got != tc.want {
|
||||
t.Errorf("pickTitle(%s, %s) = %q, want %q", tc.locale, tc.def, got, tc.want)
|
||||
}
|
||||
}
|
||||
if pickTitle(nil, "en", "en") != "" {
|
||||
t.Fatal("no titles must give \"\"")
|
||||
}
|
||||
}
|
||||
112
classes/indexnow_test.go
Normal file
112
classes/indexnow_test.go
Normal file
@@ -0,0 +1,112 @@
|
||||
package classes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/sm-sitemap-plugin/models"
|
||||
)
|
||||
|
||||
// TestIndexNowBatches pins the requests of at most 10 000 addresses, in
|
||||
// order, with nothing for no addresses.
|
||||
func TestIndexNowBatches(t *testing.T) {
|
||||
if MaxIndexNowURLs != 10000 {
|
||||
t.Fatalf("MaxIndexNowURLs = %d", MaxIndexNowURLs)
|
||||
}
|
||||
urls := func(n int) []string {
|
||||
out := make([]string, n)
|
||||
for i := range out {
|
||||
out[i] = fmt.Sprintf("https://example.test/%d", i)
|
||||
}
|
||||
return out
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
n int
|
||||
want []int
|
||||
}{{0, nil}, {1, []int{1}}, {10000, []int{10000}}, {10001, []int{10000, 1}}, {25000, []int{10000, 10000, 5000}}} {
|
||||
got := indexNowBatches(urls(tc.n))
|
||||
var sizes []int
|
||||
next := 0
|
||||
for _, b := range got {
|
||||
sizes = append(sizes, len(b))
|
||||
if len(b) > 0 && b[0] != fmt.Sprintf("https://example.test/%d", next) {
|
||||
t.Fatalf("%d addresses: a batch starts at %s, want %d", tc.n, b[0], next)
|
||||
}
|
||||
next += len(b)
|
||||
}
|
||||
if fmt.Sprint(sizes) != fmt.Sprint(tc.want) {
|
||||
t.Errorf("%d addresses: batches %v, want %v", tc.n, sizes, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestIndexNowOutcome pins the answer mapping: 200 and 202 sent, 429 and
|
||||
// 5xx retried, 400, 403 and 422 (and any other status) refused.
|
||||
func TestIndexNowOutcome(t *testing.T) {
|
||||
for status, want := range map[int]int{
|
||||
http.StatusOK: indexNowSent, http.StatusAccepted: indexNowSent,
|
||||
http.StatusTooManyRequests: indexNowRetry, 500: indexNowRetry, 502: indexNowRetry, 503: indexNowRetry,
|
||||
http.StatusBadRequest: indexNowRefused, http.StatusForbidden: indexNowRefused, http.StatusUnprocessableEntity: indexNowRefused,
|
||||
http.StatusNoContent: indexNowRefused, http.StatusNotFound: indexNowRefused,
|
||||
} {
|
||||
if got := indexNowOutcome(status); got != want {
|
||||
t.Errorf("status %d: outcome %d, want %d", status, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestIndexNowKeys pins the key format (8 to 128 letters, digits and
|
||||
// hyphens), the generated key (32 lowercase hex characters, a new one each
|
||||
// time), the key file name and the active key only while IndexNow is on.
|
||||
func TestIndexNowKeys(t *testing.T) {
|
||||
for k, want := range map[string]bool{
|
||||
"abcd1234": true, "ABCD-1234-efgh": true, strings.Repeat("a", 128): true,
|
||||
"abc1234": false, strings.Repeat("a", 129): false, "abcd_1234": false, "abcd 1234": false, "../abcd1234": false, "": false,
|
||||
} {
|
||||
if ValidKey(k) != want {
|
||||
t.Errorf("ValidKey(%q) = %v, want %v", k, !want, want)
|
||||
}
|
||||
}
|
||||
a, err := GenerateKey()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, _ := GenerateKey()
|
||||
if !regexp.MustCompile(`^[0-9a-f]{32}$`).MatchString(a) || a == b || !ValidKey(a) {
|
||||
t.Fatalf("GenerateKey = %q, %q", a, b)
|
||||
}
|
||||
if KeyFileName(a) != a+".txt" || KeyFileName("short") != "" || KeyFileName("x/../y12345") != "" {
|
||||
t.Fatal("KeyFileName is wrong")
|
||||
}
|
||||
key := " " + a + " "
|
||||
bad := "bad key"
|
||||
if ActiveKey(models.Settings{IndexNowEnabled: true, IndexNowKey: &key}) != a ||
|
||||
ActiveKey(models.Settings{IndexNowEnabled: false, IndexNowKey: &key}) != "" ||
|
||||
ActiveKey(models.Settings{IndexNowEnabled: true, IndexNowKey: &bad}) != "" ||
|
||||
ActiveKey(models.Settings{IndexNowEnabled: true}) != "" {
|
||||
t.Fatal("ActiveKey is wrong")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSiteURLs pins the host filter: only http(s) addresses on the site's
|
||||
// host (case-insensitive, any port or scheme), each once, in order;
|
||||
// nothing without a site address.
|
||||
func TestSiteURLs(t *testing.T) {
|
||||
got := SiteURLs("https://Example.test", []string{
|
||||
"https://example.test/a", "https://EXAMPLE.test/b", "http://example.test/c", "https://example.test:8443/d",
|
||||
"https://example.test/a", "https://www.example.test/e", "https://other.test/f", "/g", "ftp://example.test/h", "",
|
||||
})
|
||||
want := "https://example.test/a https://EXAMPLE.test/b http://example.test/c https://example.test:8443/d"
|
||||
if strings.Join(got, " ") != want {
|
||||
t.Fatalf("SiteURLs = %v, want %s", got, want)
|
||||
}
|
||||
if SiteURLs("", []string{"https://example.test/a"}) != nil || SiteURLs("/relative", []string{"/a"}) != nil {
|
||||
t.Fatal("without a site address nothing is kept")
|
||||
}
|
||||
if got := SiteURLs("https://example.test", nil); got == nil || len(got) != 0 {
|
||||
t.Fatalf("no addresses: %v, want an empty list", got)
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
package classes
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/compass"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
)
|
||||
|
||||
@@ -36,3 +40,63 @@ func TestRealChanges(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestLastmodIgnore pins golem15.sitemap.lastmod_ignore: the default list
|
||||
// when unset, a YAML list or a comma-separated string replacing it (names
|
||||
// trimmed, blanks dropped), and an empty list ignoring nothing.
|
||||
func TestLastmodIgnore(t *testing.T) {
|
||||
keys := func(m map[string]bool) string {
|
||||
out := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
slices.Sort(out)
|
||||
return strings.Join(out, ",")
|
||||
}
|
||||
if got := keys(LastmodIgnore(nil)); got != "created_at,sort_order,updated_at,views" {
|
||||
t.Fatalf("default = %s", got)
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
value any
|
||||
want string
|
||||
}{
|
||||
{[]any{"updated_at", " hits ", ""}, "hits,updated_at"},
|
||||
{[]string{"a", "b"}, "a,b"},
|
||||
{" views , rank,,", "rank,views"},
|
||||
{[]any{}, ""},
|
||||
} {
|
||||
cfg := testConfig(t)
|
||||
if err := cfg.Set(LastmodIgnoreConfigKey, tc.value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := keys(LastmodIgnore(cfg)); got != tc.want {
|
||||
t.Errorf("LastmodIgnore(%#v) = %s, want %s", tc.value, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRebuildDelay pins golem15.sitemap.rebuild_delay_seconds: 60 s when
|
||||
// unset, the value in seconds, and 0 for 0 or a negative value.
|
||||
func TestRebuildDelay(t *testing.T) {
|
||||
if RebuildDelay(nil) != time.Minute || RebuildDelay(testConfig(t)) != time.Minute {
|
||||
t.Fatal("default delay is not 60 s")
|
||||
}
|
||||
for value, want := range map[int]time.Duration{5: 5 * time.Second, 0: 0, -3: 0} {
|
||||
cfg := testConfig(t)
|
||||
if err := cfg.Set(RebuildDelayConfigKey, value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := RebuildDelay(cfg); got != want {
|
||||
t.Errorf("RebuildDelay(%d) = %s, want %s", value, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func testConfig(t *testing.T) *compass.Config {
|
||||
t.Helper()
|
||||
cfg, err := compass.Open(compass.Options{Dir: t.TempDir()})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
126
classes/news_test.go
Normal file
126
classes/news_test.go
Normal file
@@ -0,0 +1,126 @@
|
||||
package classes
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/sm-sitemap-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/compass"
|
||||
)
|
||||
|
||||
// TestNewsLimits pins the News sitemap's window and cap (RESEARCH Pitfall
|
||||
// 9): 48 hours and 1000 articles. The 47 h and 49 h boundaries on the
|
||||
// database are TestNewsSitemap's.
|
||||
func TestNewsLimits(t *testing.T) {
|
||||
if NewsWindow != 48*time.Hour || MaxNewsEntries != 1000 || NewsFile != "sitemap-news.xml" {
|
||||
t.Fatalf("window %s, cap %d, file %s", NewsWindow, MaxNewsEntries, NewsFile)
|
||||
}
|
||||
now := time.Date(2026, 11, 4, 12, 0, 0, 0, time.UTC)
|
||||
for _, tc := range []struct {
|
||||
age time.Duration
|
||||
want bool
|
||||
}{{0, true}, {47 * time.Hour, true}, {48 * time.Hour, true}, {48*time.Hour + time.Second, false}, {49 * time.Hour, false}} {
|
||||
published := now.Add(-tc.age)
|
||||
in := !published.Before(now.Add(-NewsWindow)) && !published.After(now)
|
||||
if in != tc.want {
|
||||
t.Errorf("published %s before now: in window %v, want %v", tc.age, in, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseNewsSources pins the news_sources values: type:<key> and
|
||||
// category:<type>:<id> (the last colon splits, so a type key may hold
|
||||
// one); other forms and empty parts are ignored.
|
||||
func TestParseNewsSources(t *testing.T) {
|
||||
s := parseNewsSources([]string{"type:blog-post", "category:blog-category:7", "category:a:b:9", "type:", "category:x", "category::1", "category:x:", "other:1", ""})
|
||||
if len(s.types) != 1 || !s.types["blog-post"] {
|
||||
t.Fatalf("types = %v", s.types)
|
||||
}
|
||||
if len(s.parents) != 2 || !s.parents[models.Ref{Type: "blog-category", ID: "7"}] || !s.parents[models.Ref{Type: "a:b", ID: "9"}] {
|
||||
t.Fatalf("parents = %v", s.parents)
|
||||
}
|
||||
if s.empty() || !parseNewsSources(nil).empty() || !parseNewsSources([]string{"bogus"}).empty() {
|
||||
t.Fatal("empty() is wrong")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewsSourcesMatch pins P33-15: no source lists every article; else an
|
||||
// article is listed by its type or by one of its parents.
|
||||
func TestNewsSourcesMatch(t *testing.T) {
|
||||
cat := models.Ref{Type: "blog-category", ID: "7"}
|
||||
other := models.Ref{Type: "blog-category", ID: "8"}
|
||||
all := parseNewsSources(nil)
|
||||
byType := parseNewsSources([]string{"type:news"})
|
||||
byCat := parseNewsSources([]string{"category:blog-category:7"})
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
s newsSources
|
||||
typ string
|
||||
parents []models.Ref
|
||||
want bool
|
||||
}{
|
||||
{"none listed", all, "blog-post", nil, true},
|
||||
{"type listed", byType, "news", nil, true},
|
||||
{"other type", byType, "blog-post", []models.Ref{cat}, false},
|
||||
{"parent listed", byCat, "blog-post", []models.Ref{other, cat}, true},
|
||||
{"other parent", byCat, "blog-post", []models.Ref{other}, false},
|
||||
{"no parents", byCat, "blog-post", nil, false},
|
||||
} {
|
||||
if got := tc.s.matches(tc.typ, tc.parents); got != tc.want {
|
||||
t.Errorf("%s: matches = %v, want %v", tc.name, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewsLanguage pins the language codes Google reads.
|
||||
func TestNewsLanguage(t *testing.T) {
|
||||
for in, want := range map[string]string{
|
||||
"pl": "pl", "pl-PL": "pl", "pl_PL": "pl", " EN ": "en", "en-GB": "en",
|
||||
"zh": "zh-cn", "zh-Hans": "zh-cn", "zh-CN": "zh-cn", "zh-TW": "zh-tw", "zh_tw": "zh-tw", "zh-Hant": "zh-tw", "zh-Hant-HK": "zh-tw",
|
||||
"": "",
|
||||
} {
|
||||
if got := NewsLanguage(in); got != want {
|
||||
t.Errorf("NewsLanguage(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewsPublication pins the publication name: the setting, else
|
||||
// app.name, else nothing.
|
||||
func TestNewsPublication(t *testing.T) {
|
||||
cfg, err := compass.Open(compass.Options{Dir: t.TempDir(), Environ: []string{"SUMMER_APP__NAME=Acme Daily"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app := backpack.New(cfg)
|
||||
named := " The Acme Times "
|
||||
if got := NewsPublication(app, models.Settings{NewsPublication: &named}); got != "The Acme Times" {
|
||||
t.Fatalf("with the setting: %q", got)
|
||||
}
|
||||
blank := " "
|
||||
if got := NewsPublication(app, models.Settings{NewsPublication: &blank}); got != "Acme Daily" {
|
||||
t.Fatalf("with a blank setting: %q, want app.name", got)
|
||||
}
|
||||
if got := NewsPublication(nil, models.Settings{}); got != "" {
|
||||
t.Fatalf("without an app: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWriteNewsURLSet pins the News file: the news namespace, every
|
||||
// element in order, escaping and the UTC publication date.
|
||||
func TestWriteNewsURLSet(t *testing.T) {
|
||||
at := time.Date(2026, 11, 4, 9, 0, 0, 0, time.FixedZone("CET", 3600))
|
||||
var buf bytes.Buffer
|
||||
if err := WriteNewsURLSet(&buf, []NewsEntry{{Loc: "https://example.test/a?b&c", Publication: "A & B", Language: "pl", Published: at, Title: "<Grzyby>"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := buf.String()
|
||||
want := `<url><loc>https://example.test/a?b&c</loc><news:news><news:publication><news:name>A & B</news:name><news:language>pl</news:language></news:publication><news:publication_date>2026-11-04T08:00:00Z</news:publication_date><news:title><Grzyby></news:title></news:news></url>`
|
||||
if !strings.Contains(out, `xmlns:news="`+NSNews+`"`) || !strings.Contains(out, want) {
|
||||
t.Fatalf("news file:\n%s", out)
|
||||
}
|
||||
wellFormed(t, out)
|
||||
}
|
||||
@@ -70,3 +70,38 @@ func TestAssignSlots(t *testing.T) {
|
||||
t.Fatalf("returning row moved to %d", *rows[1].FileNo)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAssignSlotsEdges pins the edges: a size below 1 counts as 1, the
|
||||
// oldest unnumbered row is appended first (first seen, then id), an empty
|
||||
// table changes nothing, and a renumber with nothing listed clears every
|
||||
// number.
|
||||
func TestAssignSlotsEdges(t *testing.T) {
|
||||
if AssignSlots(nil, 10, true) {
|
||||
t.Fatal("an empty table changed")
|
||||
}
|
||||
t0 := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
|
||||
rows := []*Slot{
|
||||
{ID: 3, Grp: "pages", FirstSeen: t0, Listed: true},
|
||||
{ID: 1, Grp: "pages", FirstSeen: t0.Add(time.Minute), Listed: true},
|
||||
{ID: 2, Grp: "pages", FirstSeen: t0, Listed: true},
|
||||
}
|
||||
if !AssignSlots(rows, 0, false) {
|
||||
t.Fatal("numbering reported no change")
|
||||
}
|
||||
// Size 0 is 1: one row per file, oldest first (id 2 before id 3 at the
|
||||
// same time, then id 1).
|
||||
if *rows[2].FileNo != 1 || *rows[0].FileNo != 2 || *rows[1].FileNo != 3 {
|
||||
t.Fatalf("files: id3=%d id1=%d id2=%d", *rows[0].FileNo, *rows[1].FileNo, *rows[2].FileNo)
|
||||
}
|
||||
for _, r := range rows {
|
||||
r.Listed = false
|
||||
}
|
||||
if !AssignSlots(rows, 5, true) {
|
||||
t.Fatal("renumber reported no change")
|
||||
}
|
||||
for _, r := range rows {
|
||||
if r.FileNo != nil {
|
||||
t.Fatalf("row %d kept file %d after a renumber with nothing listed", r.ID, *r.FileNo)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
48
classes/types_test.go
Normal file
48
classes/types_test.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package classes
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/sm-sitemap-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
)
|
||||
|
||||
// TestDefaultSlug pins the default group slug: the last segment of the
|
||||
// key, lower-cased, letters and digits only, in plural form.
|
||||
func TestDefaultSlug(t *testing.T) {
|
||||
for key, want := range map[string]string{
|
||||
"journal-post": "posts", "journal-category": "categories", "seo-page": "pages", "journal-tag": "tags",
|
||||
"journal-author": "authors", "acme.news": "news", "acme_day": "days", "shop-boxes": "boxes", "Acme-Story": "stories",
|
||||
"x-y": "ys", "a-key": "keys", "": "pages", "acme-": "pages", "acme-żółw": "ws",
|
||||
} {
|
||||
if got := DefaultSlug(key); got != want {
|
||||
t.Errorf("DefaultSlug(%q) = %q, want %q", key, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestEffectiveType pins a content type's setting: listed under its
|
||||
// default slug without a saved row; the saved include, slug and headings
|
||||
// otherwise, with an invalid saved slug falling back to the default.
|
||||
func TestEffectiveType(t *testing.T) {
|
||||
typ := pact.MenuItemType{Key: "journal-post", Label: "Posts"}
|
||||
if got := EffectiveType(typ, nil); got.Key != "journal-post" || got.Label != "Posts" || !got.Include || got.Slug != "posts" || got.Titles != nil {
|
||||
t.Fatalf("without a row = %+v", got)
|
||||
}
|
||||
rows := map[string]models.TypeSetting{"journal-post": {TypeKey: "journal-post", Include: false, Slug: "articles", Titles: lagoon.Jsonable[map[string]string]{Data: map[string]string{"pl": "Artykuły"}, Valid: true}}}
|
||||
if got := EffectiveType(typ, rows); got.Include || got.Slug != "articles" || got.Titles["pl"] != "Artykuły" {
|
||||
t.Fatalf("with a row = %+v", got)
|
||||
}
|
||||
rows["journal-post"] = models.TypeSetting{TypeKey: "journal-post", Include: true, Slug: "Bad Slug"}
|
||||
if got := EffectiveType(typ, rows); !got.Include || got.Slug != "posts" || got.Titles != nil {
|
||||
t.Fatalf("with an invalid slug = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReservedSlugs pins the slugs the image and News files own.
|
||||
func TestReservedSlugs(t *testing.T) {
|
||||
if !ReservedSlugs["images"] || !ReservedSlugs["news"] || len(ReservedSlugs) != 2 || ReservedSlugs["pages"] {
|
||||
t.Fatalf("ReservedSlugs = %v", ReservedSlugs)
|
||||
}
|
||||
}
|
||||
141
classes/writer_test.go
Normal file
141
classes/writer_test.go
Normal file
@@ -0,0 +1,141 @@
|
||||
package classes
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestWriteURLSet pins a sitemap file: the sitemap namespace, the xhtml
|
||||
// namespace only when an entry has alternates, escaped locs and
|
||||
// attributes, lastmod in UTC RFC 3339 and never a priority or changefreq,
|
||||
// and well-formed XML.
|
||||
func TestWriteURLSet(t *testing.T) {
|
||||
at := time.Date(2026, 11, 4, 18, 30, 0, 0, time.FixedZone("CET", 3600))
|
||||
var buf bytes.Buffer
|
||||
err := WriteURLSet(&buf, []FileEntry{
|
||||
{Loc: "https://example.test/a?x=1&y=<2>", LastMod: &at},
|
||||
{Loc: "https://example.test/b", Alternates: []FileAlternate{{Hreflang: "en", Href: "https://example.test/b"}, {Hreflang: "x-default", Href: `https://example.test/b?"q"`}}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := buf.String()
|
||||
for _, want := range []string{
|
||||
xml.Header,
|
||||
`<urlset xmlns="` + NSSitemap + `" xmlns:xhtml="` + NSXHTML + `">`,
|
||||
`<loc>https://example.test/a?x=1&y=<2></loc><lastmod>2026-11-04T17:30:00Z</lastmod>`,
|
||||
`<xhtml:link rel="alternate" hreflang="x-default" href="https://example.test/b?"q""/>`,
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("file lacks %s:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
for _, never := range []string{"<priority>", "<changefreq>"} {
|
||||
if strings.Contains(out, never) {
|
||||
t.Fatalf("file has %s:\n%s", never, out)
|
||||
}
|
||||
}
|
||||
wellFormed(t, out)
|
||||
|
||||
buf.Reset()
|
||||
if err := WriteURLSet(&buf, []FileEntry{{Loc: "https://example.test/"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(buf.String(), "xmlns:xhtml") || strings.Contains(buf.String(), "<lastmod>") {
|
||||
t.Fatalf("no alternates and no lastmod, yet:\n%s", buf.String())
|
||||
}
|
||||
wellFormed(t, buf.String())
|
||||
}
|
||||
|
||||
// TestWriteIndex pins the index: one sitemap per file, lastmod only when
|
||||
// known.
|
||||
func TestWriteIndex(t *testing.T) {
|
||||
at := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
|
||||
var buf bytes.Buffer
|
||||
if err := WriteIndex(&buf, []IndexFile{{Loc: "https://example.test/sitemap-pages-1.xml", LastMod: &at}, {Loc: "https://example.test/sitemap-posts-1.xml"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, `<sitemapindex xmlns="`+NSSitemap+`">`) ||
|
||||
!strings.Contains(out, "<sitemap><loc>https://example.test/sitemap-pages-1.xml</loc><lastmod>2026-01-02T03:04:05Z</lastmod></sitemap>") ||
|
||||
!strings.Contains(out, "<sitemap><loc>https://example.test/sitemap-posts-1.xml</loc></sitemap>") {
|
||||
t.Fatalf("index:\n%s", out)
|
||||
}
|
||||
wellFormed(t, out)
|
||||
}
|
||||
|
||||
// TestWriteImageURLSet pins the image file: the image namespace, only
|
||||
// image:image and image:loc, escaping, and the MaxImagesPerURL cap.
|
||||
func TestWriteImageURLSet(t *testing.T) {
|
||||
many := make([]string, MaxImagesPerURL+5)
|
||||
for i := range many {
|
||||
many[i] = "https://cdn.example.test/i.jpg?n=" + strings.Repeat("1", i%3+1)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := WriteImageURLSet(&buf, []ImageEntry{{Loc: "https://example.test/a", Images: []string{"https://cdn.example.test/c&d.webp"}}, {Loc: "https://example.test/b", Images: many}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, `xmlns:image="`+NSImage+`"`) || !strings.Contains(out, "<image:image><image:loc>https://cdn.example.test/c&d.webp</image:loc></image:image>") {
|
||||
t.Fatalf("image file:\n%s", out)
|
||||
}
|
||||
if n := strings.Count(out, "<image:image>"); n != 1+MaxImagesPerURL {
|
||||
t.Fatalf("%d images, want 1 + the %d cap", n, MaxImagesPerURL)
|
||||
}
|
||||
for _, never := range []string{"image:caption", "image:title", "image:license"} {
|
||||
if strings.Contains(out, never) {
|
||||
t.Fatalf("image file has %s", never)
|
||||
}
|
||||
}
|
||||
wellFormed(t, out)
|
||||
}
|
||||
|
||||
// TestFileNames pins FileName and ParseFileName as inverses and the names
|
||||
// ParseFileName refuses.
|
||||
func TestFileNamesRoundTrip(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
grp string
|
||||
n int
|
||||
}{{"pages", 1}, {"posts", 12}, {"news-2", 999999999}, {"a", 3}} {
|
||||
name := FileName(tc.grp, tc.n)
|
||||
grp, n, ok := ParseFileName(name)
|
||||
if !ok || grp != tc.grp || n != tc.n {
|
||||
t.Errorf("ParseFileName(%q) = %q %d %v", name, grp, n, ok)
|
||||
}
|
||||
}
|
||||
for _, bad := range []string{"sitemap.xml", "sitemap-pages-0.xml", "sitemap-pages-01.xml", "sitemap-Pages-1.xml", "sitemap--1.xml", "sitemap-pages-1.xml.gz", "sitemap-pages-1234567890.xml", "../sitemap-pages-1.xml", "sitemap-pages-1"} {
|
||||
if _, _, ok := ParseFileName(bad); ok {
|
||||
t.Errorf("ParseFileName(%q) accepted", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidSlug pins the group slug rule.
|
||||
func TestValidSlug(t *testing.T) {
|
||||
for s, want := range map[string]bool{
|
||||
"pages": true, "news-2": true, "0abc": true, strings.Repeat("a", 64): true,
|
||||
"": false, "-pages": false, "pages-": false, "Pages": false, "pa ges": false, "pa_ges": false, "żółw": false, strings.Repeat("a", 65): false,
|
||||
} {
|
||||
if got := ValidSlug(s); got != want {
|
||||
t.Errorf("ValidSlug(%q) = %v, want %v", s, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// wellFormed parses out as XML to its end.
|
||||
func wellFormed(t *testing.T, out string) {
|
||||
t.Helper()
|
||||
d := xml.NewDecoder(strings.NewReader(out))
|
||||
for {
|
||||
_, err := d.Token()
|
||||
if err != nil {
|
||||
if err.Error() == "EOF" {
|
||||
return
|
||||
}
|
||||
t.Fatalf("not well-formed: %v\n%s", err, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user