- 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
295 lines
13 KiB
Go
295 lines
13 KiB
Go
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)
|
|
}
|
|
}
|