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:
2026-10-12 19:40:48 +02:00
parent 33a65ad802
commit 43267de323
4 changed files with 606 additions and 0 deletions

34
classes/links_test.go Normal file
View 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
View 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
View 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
View 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)")
}
}