- 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
215 lines
8.0 KiB
Go
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)
|
|
}
|
|
}
|