Files
sm-menu-plugin/classes/tree_test.go
Jakub Zych 43267de323 test(17-06): menu plugin unit tests
- TestAllowedURL and TestMenuTree (Docker-free): the URL scheme and
  target rules, BuildTree order, unreachable and looping entries, list
  and single registry types, generated children, locale alternates,
  left-out entries logged, the JSON shape
- TestMenuEntryModel (Docker-free): fillable, rules, filter scope
- TestMenuEntryBeforeSave, TestMenuAPI and TestMenuImport (Postgres):
  PHP beforeSave rules, D-16 API rules beyond the smoke test, import
  drops for page subtrees, foreign menus, missing parents, maps to
  nothing and unregistered types
2026-10-12 19:40:48 +02:00

215 lines
8.0 KiB
Go

package classes
import (
"bytes"
"context"
"encoding/json"
"errors"
"log/slog"
"strings"
"testing"
"git.golem15.com/golem15/sm-menu-plugin/models"
"git.golem15.com/golem15/summercms/modules/pact"
)
// fakeRegistry resolves menu item types from a table: references keyed by
// type and reference; an absent reference resolves to nothing; "boom"
// fails.
type fakeRegistry struct {
types map[string]pact.MenuItemType
resolved map[string]map[string]*pact.ResolvedMenuItem
calls []string
}
func (f *fakeRegistry) Type(key string) (pact.MenuItemType, bool) {
t, ok := f.types[key]
return t, ok
}
func (f *fakeRegistry) Resolve(_ context.Context, key, reference, pattern string, nesting bool, unprefixed string) (*pact.ResolvedMenuItem, error) {
nest := "flat"
if nesting {
nest = "nested"
}
f.calls = append(f.calls, key+"|"+reference+"|"+pattern+"|"+nest+"|"+unprefixed)
if reference == "boom" {
return nil, errors.New("resolve failed")
}
return f.resolved[key][reference], nil
}
func uintp(v uint) *uint { return &v }
func strp(s string) *string { return &s }
func boolp(b bool) *bool { return &b }
func intp(i int) *int { return &i }
func entry(id uint, parent uint, typ, title string) models.MenuEntry {
e := models.MenuEntry{ID: id, MenuID: 1, Type: typ, Title: strp(title), IsPublished: boolp(true), SortOrder: intp(int(id))}
if parent != 0 {
e.ParentID = uintp(parent)
}
return e
}
func registry() *fakeRegistry {
alt := func(locale, url, title string) pact.MenuItemAlternate {
return pact.MenuItemAlternate{Locale: locale, URL: url, Title: title}
}
return &fakeRegistry{
types: map[string]pact.MenuItemType{
"acme-page": {Key: "acme-page"},
"acme-list": {Key: "acme-list", Nesting: true},
},
resolved: map[string]map[string]*pact.ResolvedMenuItem{
"acme-page": {
"1": {Title: "home", URL: "/home", Alternates: []pact.MenuItemAlternate{alt("pl", "/home", "dom"), alt("en", "/en/home", "")}},
"bad": {Title: "bad", URL: "javascript:alert(1)"},
},
"acme-list": {
"": {Items: []pact.ResolvedMenuItem{
{Title: "a", URL: "/a", Alternates: []pact.MenuItemAlternate{alt("en", "/en/a", "a (en)")}, Items: []pact.ResolvedMenuItem{{Title: "a1", URL: "data:text/html,x"}}},
{Title: "b", URL: "/b"},
}},
},
},
}
}
func shape(items []Item) string {
var parts []string
for _, it := range items {
s := it.Title
if it.URL != nil {
s += "=" + *it.URL
} else {
s += "=<nil>"
}
if it.ID == nil {
s += "*"
}
if len(it.Items) > 0 {
s += "(" + shape(it.Items) + ")"
}
parts = append(parts, s)
}
return strings.Join(parts, " ")
}
// TestMenuTree pins classes.BuildTree without a database (D-16): nesting in
// the given order, unreachable entries and cycles left out, the URL scheme
// and target filters, built-in and registry types, list types keeping their
// stored title and URL with generated children after the stored ones, the
// locale's alternate with fallbacks, entries that do not resolve left out
// and logged, and the JSON shape (nulls, never-nil items).
func TestMenuTree(t *testing.T) {
var logs bytes.Buffer
logger := slog.New(slog.NewTextHandler(&logs, nil))
page := entry(4, 0, "acme-page", "")
page.Reference = strp("1")
titled := entry(5, 0, "acme-page", "Start")
titled.Reference = strp("1")
titled.URLPattern = strp("/p/:slug")
list := entry(6, 0, "acme-list", "Pages")
list.URL = strp("/pages")
listBad := entry(7, 0, "acme-list", "Bad list")
listBad.URL = strp("javascript:x")
link := entry(1, 0, "url", "Home")
link.URL = strp("/")
link.Target = strp("_blank")
link.CSSClass = strp("")
link.Icon = strp("icon-home")
link.Code = strp("home")
unsafe := entry(2, 0, "url", "Unsafe")
unsafe.URL = strp(" javascript:alert(1)")
unsafe.Target = strp("_top")
divider := entry(3, 0, "divider", "")
divider.URL = strp("/ignored")
child := entry(8, 6, "url", "Child")
child.URL = strp("https://example.test/x")
grandchild := entry(9, 8, "url", "Grandchild")
grandchild.URL = strp("mailto:a@example.test")
gone := entry(10, 0, "acme-page", "Gone")
gone.Reference = strp("99")
boom := entry(11, 0, "acme-page", "Boom")
boom.Reference = strp("boom")
unregistered := entry(12, 0, "gone-type", "Unregistered")
underGone := entry(13, 10, "url", "Under gone")
orphan := entry(14, 99, "url", "Orphan")
badResolved := entry(15, 0, "acme-page", "")
badResolved.Reference = strp("bad")
loopA := entry(16, 17, "url", "Loop A")
loopB := entry(17, 16, "url", "Loop B")
entries := []models.MenuEntry{link, unsafe, divider, page, titled, list, listBad, child, grandchild, gone, boom, unregistered, underGone, orphan, badResolved, loopA, loopB}
reg := registry()
items := BuildTree(context.Background(), entries, TreeOptions{Registry: reg, Logger: logger})
want := "Home=/ Unsafe=<nil> =<nil> home=/home Start=/home Pages=/pages(Child=https://example.test/x(Grandchild=mailto:a@example.test) a=/a*(a1=<nil>*) b=/b*) Bad list=<nil>(a=/a*(a1=<nil>*) b=/b*) bad=<nil>"
if got := shape(items); got != want {
t.Fatalf("tree:\n%s\nwant:\n%s", got, want)
}
if it := items[0]; *it.Target != "_blank" || it.CSSClass != nil || *it.Icon != "icon-home" || *it.Code != "home" || *it.ID != 1 || it.Type != "url" {
t.Fatalf("link = %+v", it)
}
if it := items[1]; it.Target != nil {
t.Fatalf("unknown target kept: %+v", it)
}
if it := items[2]; it.Type != "divider" || it.URL != nil || it.Items == nil {
t.Fatalf("divider = %+v", it)
}
gen := items[5].Items[1]
if gen.Type != "acme-list" || gen.ID != nil || gen.Target != nil || gen.CSSClass != nil || gen.Icon != nil || gen.Code != nil || gen.Items[0].Type != "acme-list" {
t.Fatalf("generated child = %+v", gen)
}
// Registry entries are resolved with their reference, url_pattern and
// their type's Nesting, with no unprefixed locale.
for _, call := range []string{"acme-page|1||flat|", "acme-page|1|/p/:slug|flat|", "acme-list|||nested|"} {
if !strings.Contains(strings.Join(reg.calls, "\n"), call) {
t.Fatalf("calls %v lack %s", reg.calls, call)
}
}
for _, logged := range []string{"entry=10", "entry=11", "entry=12", "reference does not resolve", "resolve failed", "not registered"} {
if !strings.Contains(logs.String(), logged) {
t.Fatalf("log lacks %q:\n%s", logged, logs.String())
}
}
// The locale picks the alternate; an empty alternate title keeps the
// resolved one, a missing locale keeps both.
en := BuildTree(context.Background(), []models.MenuEntry{page, titled, list}, TreeOptions{Registry: registry(), Locale: "en", Logger: logger})
if got := shape(en); got != "home=/en/home Start=/en/home Pages=/pages(a (en)=/en/a*(a1=<nil>*) b=/b*)" {
t.Fatalf("en tree = %s", got)
}
pl := BuildTree(context.Background(), []models.MenuEntry{page}, TreeOptions{Registry: registry(), Locale: "pl", Logger: logger})
if got := shape(pl); got != "dom=/home" {
t.Fatalf("pl tree = %s", got)
}
de := BuildTree(context.Background(), []models.MenuEntry{page}, TreeOptions{Registry: registry(), Locale: "de", Logger: logger})
if got := shape(de); got != "home=/home" {
t.Fatalf("de tree = %s", got)
}
// Without a registry every registry entry is left out.
plain := BuildTree(context.Background(), []models.MenuEntry{link, page, list, child}, TreeOptions{Logger: logger})
if got := shape(plain); got != "Home=/" {
t.Fatalf("no registry = %s", got)
}
// The JSON shape: nulls and empty lists, never a missing key.
raw, err := json.Marshal(BuildTree(context.Background(), []models.MenuEntry{divider}, TreeOptions{}))
if err != nil {
t.Fatal(err)
}
if string(raw) != `[{"id":3,"title":"","type":"divider","url":null,"target":null,"css_class":null,"icon":null,"code":null,"items":[]}]` {
t.Fatalf("json = %s", raw)
}
if empty := BuildTree(context.Background(), nil, TreeOptions{}); empty == nil || len(empty) != 0 {
t.Fatalf("no entries = %#v", empty)
}
// A nil context is replaced.
var noCtx context.Context
if got := shape(BuildTree(noCtx, []models.MenuEntry{page}, TreeOptions{Registry: registry(), Logger: logger})); got != "home=/home" {
t.Fatalf("nil context = %s", got)
}
}