From 43267de3239ff42b7eb1a6f2a86d455606253e64 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Mon, 12 Oct 2026 19:40:48 +0200 Subject: [PATCH] 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 --- classes/links_test.go | 34 +++++ classes/tree_test.go | 214 +++++++++++++++++++++++++++ menu_api_test.go | 294 ++++++++++++++++++++++++++++++++++++++ models/menu_entry_test.go | 64 +++++++++ 4 files changed, 606 insertions(+) create mode 100644 classes/links_test.go create mode 100644 classes/tree_test.go create mode 100644 menu_api_test.go create mode 100644 models/menu_entry_test.go diff --git a/classes/links_test.go b/classes/links_test.go new file mode 100644 index 0000000..d270566 --- /dev/null +++ b/classes/links_test.go @@ -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) + } + } +} diff --git a/classes/tree_test.go b/classes/tree_test.go new file mode 100644 index 0000000..cb3c651 --- /dev/null +++ b/classes/tree_test.go @@ -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 += "=" + } + 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= = home=/home Start=/home Pages=/pages(Child=https://example.test/x(Grandchild=mailto:a@example.test) a=/a*(a1=*) b=/b*) Bad list=(a=/a*(a1=*) b=/b*) bad=" + 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=*) 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) + } +} diff --git a/menu_api_test.go b/menu_api_test.go new file mode 100644 index 0000000..acc488e --- /dev/null +++ b/menu_api_test.go @@ -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=)", + } { + 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) + } +} diff --git a/models/menu_entry_test.go b/models/menu_entry_test.go new file mode 100644 index 0000000..88fa76f --- /dev/null +++ b/models/menu_entry_test.go @@ -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)") + } +}