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) } }