Files
summercms/modules/signpost/signpost_smoke_test.go
Jakub Zych 0cdff4c258 feat(17-01): menu item type registry and dependent dropdown binding
- pact: HasMenuItemTypes, MenuItemType, MenuItemReference, MenuItemResolve,
  ResolvedMenuItem, MenuItemAlternate, DependentOptionsProvider,
  DependentOptionSet; Option gains Depth (omitempty)
- signpost (new module): boot-time registry with fail-loud key and pattern
  checks, layered URL patterns incl. PatternFrom for list types, host
  overrides at menu_items.patterns, Resolve and ReferenceOptions
- party: signpost.Activate after postcard, before Boot
- cabana: dependsOn on dropdowns (boot checks, controller-only), GET
  fields/{field}/dependent-options with swag, inventories, conformance case
- companions fixture, TestSignpostSmoke, TestCompanionsDependentSmoke, docs
2026-10-12 15:09:14 +02:00

154 lines
6.6 KiB
Go

package signpost_test
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/compass"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/signpost"
)
type typesPlugin struct {
id string
types []pact.MenuItemType
}
func (p typesPlugin) ID() string { return p.id }
func (p typesPlugin) MenuItemTypes() []pact.MenuItemType { return p.types }
// echo resolves an item to its layered pattern, so a test sees the layer the
// registry picked.
func echo(_ context.Context, in pact.MenuItemResolve) (*pact.ResolvedMenuItem, error) {
return &pact.ResolvedMenuItem{Title: in.Type, URL: in.Pattern, Alternates: []pact.MenuItemAlternate{{Locale: "en", URL: in.Pattern}}}, nil
}
func smokeApp(t *testing.T, menuItems string) *backpack.App {
t.Helper()
dir := t.TempDir()
if menuItems != "" {
if err := os.WriteFile(filepath.Join(dir, "menu_items.yaml"), []byte(menuItems), 0o644); err != nil {
t.Fatal(err)
}
}
cfg, err := compass.Open(compass.Options{Dir: dir, Environ: []string{"SUMMER_ENV=testing"}})
if err != nil {
t.Fatal(err)
}
return backpack.New(cfg)
}
func acmeTypes() []pact.MenuItemType {
return []pact.MenuItemType{
{Key: "acme-post", Label: "Post", DefaultPattern: "/blog/:slug", Resolve: echo,
References: func(context.Context) ([]pact.MenuItemReference, error) {
return []pact.MenuItemReference{{Value: "1", Label: "First", Items: []pact.MenuItemReference{{Value: "2", Label: "Child"}}}, {Value: "3", Label: "Second"}}, nil
}},
{Key: "acme-posts", Label: "All posts", DefaultPattern: "/blog/:slug", PatternFrom: "acme-post", Resolve: echo},
{Key: "acme-page", Label: "Page", DefaultPattern: "/:slug", Resolve: echo},
}
}
// TestSignpostSmoke checks the registry's boot refusals and the D-02 pattern
// layers, including a list type that follows its record type's host override.
func TestSignpostSmoke(t *testing.T) {
t.Run("duplicate key names both plugins", func(t *testing.T) {
app := smokeApp(t, "")
err := signpost.Activate(app, []typesPlugin{
{id: "acme.blog", types: []pact.MenuItemType{{Key: "acme-post", DefaultPattern: "/a", Resolve: echo}}},
{id: "acme.news", types: []pact.MenuItemType{{Key: "acme-post", DefaultPattern: "/b", Resolve: echo}}},
})
if err == nil || !strings.HasPrefix(err.Error(), "signpost: ") || !strings.Contains(err.Error(), "acme.blog") || !strings.Contains(err.Error(), "acme.news") || !strings.Contains(err.Error(), "acme-post") {
t.Fatalf("duplicate key error = %v", err)
}
})
t.Run("boot refusals", func(t *testing.T) {
for name, types := range map[string][]pact.MenuItemType{
"empty key": {{Key: "", DefaultPattern: "/a", Resolve: echo}},
"nil resolve": {{Key: "acme-a", DefaultPattern: "/a"}},
"empty pattern": {{Key: "acme-a", Resolve: echo}},
"relative pattern": {{Key: "acme-a", DefaultPattern: "a/:slug", Resolve: echo}},
"dangling PatternFrom": {{Key: "acme-a", DefaultPattern: "/a", PatternFrom: "acme-missing", Resolve: echo}},
"self PatternFrom": {{Key: "acme-a", DefaultPattern: "/a", PatternFrom: "acme-a", Resolve: echo}},
"chained PatternFrom": {
{Key: "acme-a", DefaultPattern: "/a", Resolve: echo},
{Key: "acme-b", DefaultPattern: "/b", PatternFrom: "acme-a", Resolve: echo},
{Key: "acme-c", DefaultPattern: "/c", PatternFrom: "acme-b", Resolve: echo},
},
} {
err := signpost.Activate(smokeApp(t, ""), []typesPlugin{{id: "acme.blog", types: types}})
if err == nil || !strings.HasPrefix(err.Error(), "signpost: ") {
t.Fatalf("%s: error = %v", name, err)
}
}
err := signpost.Activate(smokeApp(t, "patterns:\n acme-unknown: /x\n"), []typesPlugin{{id: "acme.blog", types: acmeTypes()}})
if err == nil || !strings.Contains(err.Error(), "acme-unknown") {
t.Fatalf("unknown host override error = %v", err)
}
})
t.Run("pattern layers", func(t *testing.T) {
app := smokeApp(t, "patterns:\n acme-post: /news/:slug\n acme-page: /p/:slug\n")
if err := signpost.Activate(app, []typesPlugin{{id: "acme.blog", types: acmeTypes()}}); err != nil {
t.Fatal(err)
}
reg, ok := app.Lookup[*signpost.Registry]()
if !ok || len(reg.Types()) != 3 {
t.Fatalf("registry not published: %v", ok)
}
for _, tc := range []struct{ key, item, want string }{
{"acme-page", "", "/p/:slug"}, // host override
{"acme-page", "/custom/:slug", "/custom/:slug"}, // item override beats host
{"acme-post", "", "/news/:slug"}, // host override
{"acme-posts", "", "/news/:slug"}, // list type follows its record type's host override
{"acme-posts", "/mine/:slug", "/mine/:slug"}, // item override beats both
{"acme-unknown", "", ""},
} {
if got := reg.Pattern(tc.key, tc.item); got != tc.want {
t.Fatalf("Pattern(%q, %q) = %q, want %q", tc.key, tc.item, got, tc.want)
}
}
resolved, err := reg.Resolve(context.Background(), "acme-posts", "", "", false, "")
if err != nil || resolved.URL != "/news/:slug" {
t.Fatalf("Resolve = %+v, %v", resolved, err)
}
if _, err := reg.Resolve(context.Background(), "acme-unknown", "", "", false, ""); err == nil {
t.Fatal("unknown type resolved")
}
set, err := signpost.ReferenceOptions(context.Background(), reg, "acme-post")
if err != nil || !set.Applies || len(set.Options) != 3 || set.Options[1].Depth != 1 || set.Options[2].Depth != 0 {
t.Fatalf("ReferenceOptions = %+v, %v", set, err)
}
none, err := signpost.ReferenceOptions(context.Background(), reg, "acme-page")
if err != nil || none.Applies {
t.Fatalf("type without references = %+v, %v", none, err)
}
})
t.Run("host override of the list type beats its record type", func(t *testing.T) {
app := smokeApp(t, "patterns:\n acme-post: /news/:slug\n acme-posts: /all/:slug\n")
if err := signpost.Activate(app, []typesPlugin{{id: "acme.blog", types: acmeTypes()}}); err != nil {
t.Fatal(err)
}
reg, _ := app.Lookup[*signpost.Registry]()
if got := reg.Pattern("acme-posts", ""); got != "/all/:slug" {
t.Fatalf("list type host override = %q", got)
}
if got := reg.Pattern("acme-posts", "/item/:slug"); got != "/item/:slug" {
t.Fatalf("item override = %q", got)
}
})
t.Run("default pattern without host config", func(t *testing.T) {
app := smokeApp(t, "")
if err := signpost.Activate(app, []typesPlugin{{id: "acme.blog", types: acmeTypes()}}); err != nil {
t.Fatal(err)
}
reg, _ := app.Lookup[*signpost.Registry]()
if got := reg.Pattern("acme-posts", ""); got != "/blog/:slug" {
t.Fatalf("default = %q", got)
}
})
}