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
This commit is contained in:
34
classes/links_test.go
Normal file
34
classes/links_test.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package classes
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestAllowedURL pins the D-16 URL scheme rule shared by the admin save and
|
||||
// the public API: empty, a path, a fragment, or http, https, mailto and tel
|
||||
// in any case; never javascript:, data:, vbscript: or another scheme, and
|
||||
// never a URL with surrounding whitespace or control characters, which
|
||||
// browsers strip before reading the scheme.
|
||||
func TestAllowedURL(t *testing.T) {
|
||||
for _, url := range []string{
|
||||
"", "/", "/blog/post?x=1#a", "#top", "//cdn.example.test/a",
|
||||
"http://example.test", "https://example.test/a", "HTTPS://EXAMPLE.TEST", "Http://x",
|
||||
"mailto:a@example.test", "MAILTO:a@example.test", "tel:+48123456", "TEL:123",
|
||||
} {
|
||||
if !AllowedURL(url) {
|
||||
t.Errorf("AllowedURL(%q) = false", url)
|
||||
}
|
||||
}
|
||||
for _, url := range []string{
|
||||
"javascript:alert(1)", "JavaScript:alert(1)", "data:text/html,x", "vbscript:x", "ftp://x", "file:///etc/passwd",
|
||||
"blog", "example.test", "?q=1", "./a", "../a", "mailto", "https:x", "tel",
|
||||
" /a", "/a ", "\thttps://x", "https://x\n", "java\tscript:x", "/a\x00b", "/a\x7f", "\x01javascript:x",
|
||||
} {
|
||||
if AllowedURL(url) {
|
||||
t.Errorf("AllowedURL(%q) = true", url)
|
||||
}
|
||||
}
|
||||
for target, want := range map[string]bool{"": true, TargetSelf: true, TargetBlank: true, "_top": false, "_parent": false, "_BLANK": false, "frame": false} {
|
||||
if AllowedTarget(target) != want {
|
||||
t.Errorf("AllowedTarget(%q) != %v", target, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
214
classes/tree_test.go
Normal file
214
classes/tree_test.go
Normal file
@@ -0,0 +1,214 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
294
menu_api_test.go
Normal file
294
menu_api_test.go
Normal file
@@ -0,0 +1,294 @@
|
||||
package menu_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/sm-menu-plugin/console"
|
||||
"git.golem15.com/golem15/sm-menu-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"git.golem15.com/golem15/summercms/modules/party"
|
||||
"git.golem15.com/golem15/summercms/modules/signpost"
|
||||
"git.golem15.com/golem15/summercms/modules/surf"
|
||||
)
|
||||
|
||||
// extraTypesPluginID registers the types the plan 06 API cases need beyond
|
||||
// the smoke fixture: one that resolves to an unsafe URL and a list whose
|
||||
// children nest and follow the item's url_pattern.
|
||||
const extraTypesPluginID = "acme.menuextra"
|
||||
|
||||
type extraTypes struct{}
|
||||
|
||||
func (extraTypes) ID() string { return extraTypesPluginID }
|
||||
func (extraTypes) Requires() []string { return nil }
|
||||
func (extraTypes) Register(*backpack.App) error { return nil }
|
||||
func (extraTypes) Boot(*backpack.App) error { return nil }
|
||||
func (extraTypes) MenuItemTypes() []pact.MenuItemType {
|
||||
return []pact.MenuItemType{
|
||||
{Key: "acme-unsafe", Label: "Unsafe", DefaultPattern: "/u/:slug", Resolve: func(context.Context, pact.MenuItemResolve) (*pact.ResolvedMenuItem, error) {
|
||||
return &pact.ResolvedMenuItem{Title: "unsafe", URL: "javascript:alert(1)"}, nil
|
||||
}},
|
||||
{Key: "acme-tree", Label: "Tree", DefaultPattern: "/t/:slug", Nesting: true, Resolve: func(_ context.Context, in pact.MenuItemResolve) (*pact.ResolvedMenuItem, error) {
|
||||
leaf := pact.ResolvedMenuItem{Title: "leaf", URL: strings.ReplaceAll(in.Pattern, ":slug", "leaf")}
|
||||
bad := pact.ResolvedMenuItem{Title: "bad", URL: "data:text/html,x"}
|
||||
return &pact.ResolvedMenuItem{Items: []pact.ResolvedMenuItem{{
|
||||
Title: "branch", URL: strings.ReplaceAll(in.Pattern, ":slug", "branch"),
|
||||
Alternates: []pact.MenuItemAlternate{{Locale: "en", URL: "/en" + strings.ReplaceAll(in.Pattern, ":slug", "branch"), Title: ""}},
|
||||
Items: []pact.ResolvedMenuItem{leaf, bad},
|
||||
}}}, nil
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
party.Register(extraTypes{})
|
||||
}
|
||||
|
||||
// bootMenuWith boots the menu plugin with the smoke and the extra types.
|
||||
func bootMenuWith(t *testing.T) *menuEnv {
|
||||
t.Helper()
|
||||
sqlDB := freshDB(t)
|
||||
gdb, err := lagoon.Use(t.Context(), sqlDB)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app := backpack.New(testConfig(t))
|
||||
if err := lagoon.Publish(app, sqlDB, gdb); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plugins, err := party.Activate(app, []string{testTypesPluginID, extraTypesPluginID, "golem15.menu"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := lagoon.Migrate(gdb, plugins); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h, err := surf.Assemble(app, plugins)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &menuEnv{h: h, app: app, gdb: gdb}
|
||||
}
|
||||
|
||||
// TestMenuEntryBeforeSave pins the PHP beforeSave port on Postgres: unset
|
||||
// nest columns become 0, a missing title is stored empty and a missing
|
||||
// published flag true, an entry with a parent always takes the parent's
|
||||
// menu, and an entry without a sort order goes after its menu's last one
|
||||
// (1 in an empty menu); values that are set are kept.
|
||||
func TestMenuEntryBeforeSave(t *testing.T) {
|
||||
e := bootMenu(t)
|
||||
one := e.insertMenu(t, "One", "one", true)
|
||||
two := e.insertMenu(t, "Two", "two", true)
|
||||
save := func(t *testing.T, entry *models.MenuEntry) models.MenuEntry {
|
||||
t.Helper()
|
||||
if err := e.gdb.Create(entry).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return e.entry(t, entry.ID)
|
||||
}
|
||||
first := save(t, &models.MenuEntry{MenuID: one, Type: "url", URL: str("/a")})
|
||||
if intOf(first.NestLeft) != 0 || intOf(first.NestRight) != 0 || intOf(first.NestDepth) != 0 || models.Text(first.Title) != "" || first.Title == nil ||
|
||||
first.IsPublished == nil || !*first.IsPublished || intOf(first.SortOrder) != 1 {
|
||||
t.Fatalf("first = %+v", first)
|
||||
}
|
||||
sort := 7
|
||||
save(t, &models.MenuEntry{MenuID: one, Type: "url", SortOrder: &sort})
|
||||
next := save(t, &models.MenuEntry{MenuID: one, Type: "divider"})
|
||||
if intOf(next.SortOrder) != 8 {
|
||||
t.Fatalf("next sort = %d, want 8", intOf(next.SortOrder))
|
||||
}
|
||||
off := false
|
||||
left := 3
|
||||
kept := save(t, &models.MenuEntry{MenuID: two, Type: "url", Title: str("Kept"), IsPublished: &off, NestLeft: &left})
|
||||
if *kept.IsPublished || models.Text(kept.Title) != "Kept" || intOf(kept.NestLeft) != 3 || intOf(kept.SortOrder) != 1 {
|
||||
t.Fatalf("kept = %+v", kept)
|
||||
}
|
||||
// The parent's menu wins over the given one.
|
||||
child := save(t, &models.MenuEntry{MenuID: two, ParentID: &first.ID, Type: "url"})
|
||||
if child.MenuID != one || intOf(child.SortOrder) != 9 {
|
||||
t.Fatalf("child = %+v, want menu %d and sort 9", child, one)
|
||||
}
|
||||
missing := uint(99999)
|
||||
orphan := save(t, &models.MenuEntry{MenuID: two, ParentID: &missing, Type: "url"})
|
||||
if orphan.MenuID != two {
|
||||
t.Fatalf("orphan menu = %d", orphan.MenuID)
|
||||
}
|
||||
// An update runs the hook again without moving a set sort order.
|
||||
child.SortOrder = intp(2)
|
||||
if err := e.gdb.Save(&child).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if again := e.entry(t, child.ID); intOf(again.SortOrder) != 2 || again.MenuID != one {
|
||||
t.Fatalf("updated child = %+v", again)
|
||||
}
|
||||
}
|
||||
|
||||
func intp(i int) *int { return &i }
|
||||
|
||||
// TestMenuAPI pins the D-16 rules of GET /_menu/api/v1/menus/{code} beyond
|
||||
// the smoke test: other menus' entries are absent, a null sort order sorts
|
||||
// last, unsafe resolved, generated and stored list URLs are null, generated
|
||||
// children nest and follow the entry's url_pattern, an empty alternate title
|
||||
// keeps the resolved one, empty css classes are null, the answer is JSON
|
||||
// with no CORS headers of its own, and codes outside the allowed shape are
|
||||
// the JSON 404.
|
||||
func TestMenuAPI(t *testing.T) {
|
||||
e := bootMenuWith(t)
|
||||
mainID := e.insertMenu(t, "Main", "main", true)
|
||||
otherID := e.insertMenu(t, "Other", "other", true)
|
||||
if err := e.gdb.Exec(`UPDATE golem15_menu_menus SET css_class = '' WHERE id = ?`, otherID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
last := e.insertEntry(t, rawEntry{menu: mainID, sort: 1, typ: "url", title: "Null sort", url: str("/n"), published: true})
|
||||
if err := e.gdb.Exec(`UPDATE golem15_menu_menu_entries SET sort_order = NULL WHERE id = ?`, last).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first := e.insertEntry(t, rawEntry{menu: mainID, sort: 1, typ: "url", title: "First", url: str("/f"), css: str(""), published: true})
|
||||
unsafe := e.insertEntry(t, rawEntry{menu: mainID, sort: 2, typ: "acme-unsafe", title: "", reference: str("x"), published: true})
|
||||
tree := e.insertEntry(t, rawEntry{menu: mainID, sort: 3, typ: "acme-tree", title: "Tree", url: str("data:text/html,x"), pattern: str("/custom/:slug"), published: true})
|
||||
e.insertEntry(t, rawEntry{menu: otherID, sort: 1, typ: "url", title: "Elsewhere", url: str("/elsewhere"), published: true})
|
||||
|
||||
body := e.getMenu(t, "/_menu/api/v1/menus/main")
|
||||
var ids []uint
|
||||
for _, it := range body.Data.Items {
|
||||
ids = append(ids, *it.ID)
|
||||
}
|
||||
if fmt.Sprint(ids) != fmt.Sprint([]uint{first, unsafe, tree, last}) {
|
||||
t.Fatalf("order = %v", ids)
|
||||
}
|
||||
items := body.Data.Items
|
||||
if items[0].CSSClass != nil {
|
||||
t.Fatalf("empty css class = %q", deref(items[0].CSSClass))
|
||||
}
|
||||
if it := items[1]; it.URL != nil || it.Title != "unsafe" {
|
||||
t.Fatalf("unsafe resolved URL = %+v", it)
|
||||
}
|
||||
it := items[2]
|
||||
if it.Title != "Tree" || it.URL != nil || len(it.Items) != 1 {
|
||||
t.Fatalf("tree = %+v", it)
|
||||
}
|
||||
branch := it.Items[0]
|
||||
if branch.ID != nil || branch.Type != "acme-tree" || deref(branch.URL) != "/custom/branch" || len(branch.Items) != 2 {
|
||||
t.Fatalf("branch = %+v", branch)
|
||||
}
|
||||
if leaf := branch.Items[0]; deref(leaf.URL) != "/custom/leaf" || leaf.Type != "acme-tree" || leaf.ID != nil {
|
||||
t.Fatalf("leaf = %+v", leaf)
|
||||
}
|
||||
if bad := branch.Items[1]; bad.URL != nil || bad.Title != "bad" {
|
||||
t.Fatalf("bad generated URL = %+v", bad)
|
||||
}
|
||||
raw := e.publicGet(t, "/_menu/api/v1/menus/main", "").Body.String()
|
||||
if strings.Contains(raw, "Elsewhere") || strings.Contains(raw, "javascript") || strings.Contains(raw, "data:") {
|
||||
t.Fatalf("body = %s", raw)
|
||||
}
|
||||
en := e.getMenu(t, "/_menu/api/v1/menus/main?locale=en").Data.Items[2].Items[0]
|
||||
if en.Title != "branch" || deref(en.URL) != "/en/custom/branch" {
|
||||
t.Fatalf("en branch = %+v", en)
|
||||
}
|
||||
other := e.getMenu(t, "/_menu/api/v1/menus/other")
|
||||
if other.Data.CSSClass != nil || len(other.Data.Items) != 1 {
|
||||
t.Fatalf("other = %+v", other.Data)
|
||||
}
|
||||
e.insertMenu(t, "Empty", "empty_menu-1", true)
|
||||
rec := e.publicGet(t, "/_menu/api/v1/menus/empty_menu-1", "")
|
||||
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"items":[]`) {
|
||||
t.Fatalf("empty menu = %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
|
||||
t.Fatalf("Content-Type = %q", ct)
|
||||
}
|
||||
if rec.Header().Get("Access-Control-Allow-Origin") != "" {
|
||||
t.Fatal("the plugin sets CORS headers itself")
|
||||
}
|
||||
for _, path := range []string{"/_menu/api/v1/menus/m%C3%A9nu", "/_menu/api/v1/menus/a%20b", "/_menu/api/v1/menus/a.b", "/_menu/api/v1/menus/%27or%271"} {
|
||||
rec := e.publicGet(t, path, "")
|
||||
if rec.Code != http.StatusNotFound || rec.Body.String() != `{"error":"Menu not found"}` {
|
||||
t.Errorf("GET %s: %d %s", path, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &decoded); err != nil || decoded["data"] == nil {
|
||||
t.Fatalf("envelope = %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMenuImport pins the menu:import-winter edge cases beyond the smoke
|
||||
// test: a page subtree dropped to any depth, entries of a menu missing from
|
||||
// the menus export, a parent missing from the entries export, a type
|
||||
// mapped to nothing, an unregistered type, malformed rows and options.
|
||||
func TestMenuImport(t *testing.T) {
|
||||
e := bootMenu(t)
|
||||
ctx := t.Context()
|
||||
reg, ok := e.app.Lookup[*signpost.Registry]()
|
||||
if !ok {
|
||||
t.Fatal("signpost registry is not published")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
menus := writeFile(t, dir, "menus.jsonl", `{"id": 1, "name": "Main", "code": "main", "is_active": 1}`+"\n")
|
||||
row := func(id, menu int, parent, typ, title string) string {
|
||||
return fmt.Sprintf(`{"id": %d, "menu_id": %d, "parent_id": %s, "sort_order": %d, "type": %q, "title": %q, "url": "/x%d", "reference": null, "is_published": 1}`, id, menu, parent, id, typ, title, id) + "\n"
|
||||
}
|
||||
entries := writeFile(t, dir, "entries.jsonl",
|
||||
row(1, 1, "null", "link", "Keep")+
|
||||
row(2, 1, "null", "page", "Page")+
|
||||
row(3, 1, "2", "link", "Under page")+
|
||||
row(4, 1, "3", "link", "Deep under page")+
|
||||
row(5, 2, "null", "link", "Other menu")+
|
||||
row(6, 1, "50", "link", "Missing parent")+
|
||||
row(7, 1, "null", "obsolete", "Mapped away")+
|
||||
row(8, 1, "null", "unknown-type", "Unregistered")+
|
||||
row(9, 1, "1", "divider", "Kept child"))
|
||||
out, buf := testOutput()
|
||||
report, err := console.ImportWinterFiles(ctx, e.gdb, reg, console.WinterOptions{MenusFile: menus, EntriesFile: entries, Maps: map[string]string{"obsolete": ""}}, out)
|
||||
if err != nil {
|
||||
t.Fatalf("import: %v\n%s", err, buf.String())
|
||||
}
|
||||
if report.Menus != 1 || report.Entries != 2 || report.Dropped != 7 {
|
||||
t.Fatalf("report = %+v\n%s", report, buf.String())
|
||||
}
|
||||
for _, want := range []string{
|
||||
"entry 2 'Page' (menu 1, type 'page'): dropped (page entries (Winter.Pages) are not supported)",
|
||||
"entry 3 'Under page' (menu 1, type 'link'): dropped (dropped with its parent 2)",
|
||||
"entry 4 'Deep under page' (menu 1, type 'link'): dropped (dropped with its parent 3)",
|
||||
"entry 5 'Other menu' (menu 2, type 'link'): dropped (menu 2 is not in the menus export)",
|
||||
"entry 6 'Missing parent' (menu 1, type 'link'): dropped (its parent 50 is not in the entries export)",
|
||||
"entry 7 'Mapped away' (menu 1, type 'obsolete'): dropped (type 'obsolete' is mapped to nothing)",
|
||||
"entry 8 'Unregistered' (menu 1, type 'unknown-type'): dropped (type 'unknown-type' is neither built-in nor registered; map it with --map unknown-type=<type>)",
|
||||
} {
|
||||
if !strings.Contains(buf.String(), want) {
|
||||
t.Errorf("report lacks %q:\n%s", want, buf.String())
|
||||
}
|
||||
}
|
||||
var kept []models.MenuEntry
|
||||
if err := e.gdb.Order("id").Find(&kept).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(kept) != 2 || kept[0].ID != 1 || kept[0].Type != "url" || kept[1].ID != 9 || kept[1].ParentID == nil || *kept[1].ParentID != 1 {
|
||||
t.Fatalf("kept = %+v", kept)
|
||||
}
|
||||
|
||||
for name, tc := range map[string]struct {
|
||||
opts console.WinterOptions
|
||||
want string
|
||||
}{
|
||||
"no files": {console.WinterOptions{MenusFile: menus}, "give --menus and --entries"},
|
||||
"bad menu row": {console.WinterOptions{MenusFile: writeFile(t, dir, "bad-menus.jsonl", `{"id": 0, "name": "x", "code": "x"}`+"\n"), EntriesFile: entries, Force: true}, "row 1"},
|
||||
"menu sans code": {console.WinterOptions{MenusFile: writeFile(t, dir, "nocode.jsonl", `{"id": 4, "name": "x"}`+"\n"), EntriesFile: entries, Force: true}, "code is missing"},
|
||||
"entry sans type": {console.WinterOptions{MenusFile: menus, EntriesFile: writeFile(t, dir, "notype.jsonl", `{"id": 4, "menu_id": 1}`+"\n"), Force: true}, "type is missing"},
|
||||
} {
|
||||
out, buf := testOutput()
|
||||
if _, err := console.ImportWinterFiles(ctx, e.gdb, reg, tc.opts, out); err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Errorf("%s: err = %v, want %q\n%s", name, err, tc.want, buf.String())
|
||||
}
|
||||
}
|
||||
var count int64
|
||||
if err := e.gdb.Model(&models.MenuEntry{}).Count(&count).Error; err != nil || count != 2 {
|
||||
t.Fatalf("refused imports changed the entries: %d %v", count, err)
|
||||
}
|
||||
}
|
||||
64
models/menu_entry_test.go
Normal file
64
models/menu_entry_test.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package models_test
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/sm-menu-plugin/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// TestMenuEntryModel pins the Docker-free contract of the menu entry model:
|
||||
// the built-in types, the PHP class string and table, the fillable list
|
||||
// (no nest columns, no sort_order, no id), the required type rule and the
|
||||
// menu filter scope's no-op cases. BeforeSave runs on Postgres
|
||||
// (TestMenuEntryBeforeSave).
|
||||
func TestMenuEntryModel(t *testing.T) {
|
||||
for typ, want := range map[string]bool{"url": true, "divider": true, "link": false, "acme-page": false, "": false} {
|
||||
if models.Builtin(typ) != want {
|
||||
t.Errorf("Builtin(%q) != %v", typ, want)
|
||||
}
|
||||
}
|
||||
e := models.MenuEntry{}
|
||||
if e.TableName() != "golem15_menu_menu_entries" || e.MorphName() != `Golem15\Menu\Models\MenuEntry` {
|
||||
t.Fatal("table or morph name changed")
|
||||
}
|
||||
fill := e.Fillable()
|
||||
for _, key := range []string{"menu_id", "parent_id", "type", "title", "url", "url_pattern", "reference", "code", "icon", "css_class", "target", "is_published"} {
|
||||
if !slices.Contains(fill, key) {
|
||||
t.Errorf("Fillable lacks %s", key)
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"id", "nest_left", "nest_right", "nest_depth", "sort_order", "created_at", "updated_at"} {
|
||||
if slices.Contains(fill, key) {
|
||||
t.Errorf("Fillable holds %s", key)
|
||||
}
|
||||
}
|
||||
if rules := e.Rules(); len(rules) != 1 || rules["type"] != "required" {
|
||||
t.Fatalf("Rules = %v", rules)
|
||||
}
|
||||
if !slices.Equal(e.FilterScopes(), []string{models.FilterMenu}) {
|
||||
t.Fatalf("FilterScopes = %v", e.FilterScopes())
|
||||
}
|
||||
db := &gorm.DB{}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
scope string
|
||||
value any
|
||||
}{
|
||||
{"another scope", "Other", "1"},
|
||||
{"no value", models.FilterMenu, nil},
|
||||
{"empty list", models.FilterMenu, []any{}},
|
||||
{"only invalid ids", models.FilterMenu, []string{"x", "0", "-1", ""}},
|
||||
} {
|
||||
if got := e.FilterScope(tc.scope, db, tc.value); got != db {
|
||||
t.Errorf("%s: FilterScope changed the query", tc.name)
|
||||
}
|
||||
}
|
||||
if e.FilterScope(models.FilterMenu, nil, "1") != nil {
|
||||
t.Fatal("a nil query was replaced")
|
||||
}
|
||||
if models.Text(nil) != "" {
|
||||
t.Fatal("Text(nil)")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user