package signpost_test import ( "context" "errors" "reflect" "strings" "testing" "git.golem15.com/golem15/summercms/modules/pact" "git.golem15.com/golem15/summercms/modules/signpost" ) // contentCalls records what the descriptor funcs of a test type received. type contentCalls struct { describe []pact.ContentDescribe list []pact.ContentList match []pact.ContentMatch } // contentType is a record type whose descriptor answers match for the paths // it names, with Exact as given. func contentType(key, pattern, model, controller string, calls *contentCalls, match map[string]bool) pact.MenuItemType { c := &pact.ContentDescriptor{ Controller: controller, Model: model, Describe: func(_ context.Context, in pact.ContentDescribe) (*pact.ContentRecord, error) { calls.describe = append(calls.describe, in) if in.ID == "missing" { return nil, nil } return &pact.ContentRecord{ID: in.ID, Published: true, Locales: []pact.ContentLocale{{Locale: "en", URL: strings.ReplaceAll(in.Pattern, ":slug", in.ID)}}}, nil }, Records: func(_ context.Context, in pact.ContentList) (pact.ContentPage, error) { calls.list = append(calls.list, in) return pact.ContentPage{Records: []pact.ContentRecord{{ID: "1"}}, Next: "1"}, nil }, } if match != nil { c.Match = func(_ context.Context, in pact.ContentMatch) (*pact.ContentMatchResult, error) { calls.match = append(calls.match, in) if in.Path == "/boom" { return nil, errors.New("database down") } exact, ok := match[in.Path] if !ok { return nil, nil } return &pact.ContentMatchResult{ID: key + "-record", Locale: "en", Exact: exact}, nil } } return pact.MenuItemType{Key: key, DefaultPattern: pattern, Resolve: echo, Content: c} } func contentRegistry(t *testing.T, host string, types ...pact.MenuItemType) *signpost.Registry { t.Helper() app := smokeApp(t, host) if err := signpost.Activate(app, []typesPlugin{{id: "acme.blog", types: types}}); err != nil { t.Fatalf("Activate: %v", err) } reg, ok := app.Lookup[*signpost.Registry]() if !ok { t.Fatal("registry not published") } return reg } func keys(types []pact.MenuItemType) []string { out := []string{} for _, t := range types { out = append(out, t.Key) } return out } // TestContentLookups: types with a content descriptor are listed in // registration order and found by key, controller and model; a type without // one is absent and resolves exactly as before; Describe and Records hand // the descriptor the layered pattern. func TestContentLookups(t *testing.T) { posts, tags := &contentCalls{}, &contentCalls{} reg := contentRegistry(t, "patterns:\n acme-post: /news/:slug\n", pact.MenuItemType{Key: "acme-page", DefaultPattern: "/:slug", Resolve: echo}, contentType("acme-post", "/blog/:slug", "acme_posts", "acme.blog.posts", posts, nil), contentType("acme-tag", "/blog/tag/:slug", "acme_tags", "", tags, nil), ) if got := keys(reg.ContentTypes()); !reflect.DeepEqual(got, []string{"acme-post", "acme-tag"}) { t.Fatalf("ContentTypes = %v", got) } if typ, ok := reg.ContentType("acme-post"); !ok || typ.Key != "acme-post" { t.Fatalf("ContentType(acme-post) = %v %v", typ.Key, ok) } if _, ok := reg.ContentType("acme-page"); ok { t.Fatal("a type without Content is a content type") } if _, ok := reg.ContentType("acme-missing"); ok { t.Fatal("an unknown key is a content type") } if typ, ok := reg.ContentTypeForController("acme.blog.posts"); !ok || typ.Key != "acme-post" { t.Fatalf("ContentTypeForController = %v %v", typ.Key, ok) } if _, ok := reg.ContentTypeForController(""); ok { t.Fatal("the empty controller ID names a type") } if typ, ok := reg.ContentTypeForModel("acme_tags"); !ok || typ.Key != "acme-tag" { t.Fatalf("ContentTypeForModel = %v %v", typ.Key, ok) } if _, ok := reg.ContentTypeForModel("acme_pages"); ok { t.Fatal("an unknown model names a type") } item, err := reg.Resolve(context.Background(), "acme-page", "", "", false, "") if err != nil || item.URL != "/:slug" { t.Fatalf("a type without Content resolves to %+v, %v", item, err) } rec, err := reg.Describe(context.Background(), "acme-post", "hello", "pl") if err != nil || rec == nil || rec.Locales[0].URL != "/news/hello" { t.Fatalf("Describe = %+v, %v", rec, err) } if want := (pact.ContentDescribe{ID: "hello", Pattern: "/news/:slug", UnprefixedLocale: "pl"}); !reflect.DeepEqual(posts.describe, []pact.ContentDescribe{want}) { t.Fatalf("Describe received %+v", posts.describe) } if rec, err := reg.Describe(context.Background(), "acme-post", "missing", ""); err != nil || rec != nil { t.Fatalf("Describe of an unknown record = %+v, %v", rec, err) } for _, key := range []string{"acme-page", "acme-missing"} { if _, err := reg.Describe(context.Background(), key, "1", ""); err == nil || !strings.HasPrefix(err.Error(), "signpost: ") { t.Fatalf("Describe(%s) error = %v", key, err) } } page, err := reg.Records(context.Background(), "acme-tag", "7", 50, "en") if err != nil || len(page.Records) != 1 || page.Next != "1" { t.Fatalf("Records = %+v, %v", page, err) } if want := (pact.ContentList{Pattern: "/blog/tag/:slug", After: "7", Limit: 50, UnprefixedLocale: "en"}); !reflect.DeepEqual(tags.list, []pact.ContentList{want}) { t.Fatalf("Records received %+v", tags.list) } if _, err := reg.Records(context.Background(), "acme-page", "", 10, ""); err == nil { t.Fatal("Records of a type without Content succeeded") } } // TestContentRecordsOptional: a content type without Records answers an // empty page. func TestContentRecordsOptional(t *testing.T) { typ := contentType("acme-post", "/blog/:slug", "acme_posts", "", &contentCalls{}, nil) typ.Content.Records = nil reg := contentRegistry(t, "", typ) page, err := reg.Records(context.Background(), "acme-post", "", 10, "") if err != nil || len(page.Records) != 0 || page.Next != "" { t.Fatalf("Records without a func = %+v, %v", page, err) } } // TestContentBootErrors: a descriptor without Model or Describe, and two // types declaring the same Model or Controller, stop the boot naming the // type and the plugin. func TestContentBootErrors(t *testing.T) { calls := &contentCalls{} noModel := contentType("acme-post", "/blog/:slug", "", "", calls, nil) noDescribe := contentType("acme-post", "/blog/:slug", "acme_posts", "", calls, nil) noDescribe.Content.Describe = nil for _, tc := range []struct { name string types []pact.MenuItemType want []string }{ {"no Model", []pact.MenuItemType{noModel}, []string{`"acme-post"`, "acme.blog", "Model"}}, {"no Describe", []pact.MenuItemType{noDescribe}, []string{`"acme-post"`, "acme.blog", "Describe"}}, {"same Model", []pact.MenuItemType{ contentType("acme-post", "/a/:slug", "acme_posts", "", calls, nil), contentType("acme-article", "/b/:slug", "acme_posts", "", calls, nil), }, []string{`"acme-article"`, `"acme-post"`, `"acme_posts"`}}, {"same Controller", []pact.MenuItemType{ contentType("acme-post", "/a/:slug", "acme_posts", "acme.blog.posts", calls, nil), contentType("acme-article", "/b/:slug", "acme_articles", "acme.blog.posts", calls, nil), }, []string{`"acme-article"`, `"acme-post"`, `"acme.blog.posts"`}}, } { t.Run(tc.name, func(t *testing.T) { err := signpost.Activate(smokeApp(t, ""), []typesPlugin{{id: "acme.blog", types: tc.types}}) if err == nil { t.Fatal("Activate accepted the descriptor") } if !strings.HasPrefix(err.Error(), "signpost: ") { t.Fatalf("error = %q, want the signpost prefix", err) } for _, part := range tc.want { if !strings.Contains(err.Error(), part) { t.Fatalf("error = %q, want %q", err, part) } } }) } // Two types without a controller ID do not clash. contentRegistry(t, "", contentType("acme-post", "/a/:slug", "acme_posts", "", calls, nil), contentType("acme-tag", "/b/:slug", "acme_tags", "", calls, nil), ) } // TestContentMatchPattern: whole-segment placeholders read their values; a // literal segment, the segment count and an empty value must all match. func TestContentMatchPattern(t *testing.T) { for _, tc := range []struct { pattern, path string want map[string]string ok bool }{ {"/blog/:slug", "/blog/hello", map[string]string{"slug": "hello"}, true}, {"/:category/:slug", "/news/hello", map[string]string{"category": "news", "slug": "hello"}, true}, {"/blog/:year/:month/:slug", "/blog/2026/10/hello", map[string]string{"year": "2026", "month": "10", "slug": "hello"}, true}, {"/", "/", map[string]string{}, true}, {"/blog", "/blog", map[string]string{}, true}, {"/blog/:slug", "/blog/hello/extra", nil, false}, {"/blog/:slug", "/blog", nil, false}, {"/blog/:slug", "/blog/", nil, false}, {"/blog/:slug", "/news/hello", nil, false}, {"/:category/:slug", "//hello", nil, false}, {"/blog/:slug", "blog/hello", nil, false}, {"/:slug/:slug", "/a/b", nil, false}, {"/:slug/:slug", "/a/a", map[string]string{"slug": "a"}, true}, } { got, ok := signpost.MatchPattern(tc.pattern, tc.path) if ok != tc.ok || (tc.ok && !reflect.DeepEqual(got, tc.want)) { t.Fatalf("MatchPattern(%q, %q) = %v %v, want %v %v", tc.pattern, tc.path, got, ok, tc.want, tc.ok) } } } // TestContentMatchPath: MatchPath asks the content types with Match in // registration order with their layered pattern; the first exact match // wins, else the first non-exact one, else none; a Match error is returned. func TestContentMatchPath(t *testing.T) { first, second := &contentCalls{}, &contentCalls{} reg := contentRegistry(t, "patterns:\n acme-page: /p/:slug\n", contentType("acme-post", "/blog/:slug", "acme_posts", "", first, map[string]bool{"/shared": false, "/post-only": false, "/both-exact": true}), contentType("acme-plain", "/plain/:slug", "acme_plain", "", &contentCalls{}, nil), contentType("acme-page", "/:slug", "acme_pages", "", second, map[string]bool{"/shared": true, "/both-exact": true}), ) ctx := context.Background() key, res, err := reg.MatchPath(ctx, "/shared", "pl") if err != nil || key != "acme-page" || res == nil || !res.Exact || res.ID != "acme-page-record" { t.Fatalf("exact match = %q %+v %v, want acme-page exact", key, res, err) } if len(first.match) != 1 || first.match[0] != (pact.ContentMatch{Path: "/shared", Pattern: "/blog/:slug", UnprefixedLocale: "pl"}) { t.Fatalf("first type received %+v", first.match) } if len(second.match) != 1 || second.match[0].Pattern != "/p/:slug" { t.Fatalf("second type received %+v, want the host pattern", second.match) } if key, res, err := reg.MatchPath(ctx, "/both-exact", ""); err != nil || key != "acme-post" || !res.Exact { t.Fatalf("two exact matches = %q %+v %v, want the first registered", key, res, err) } if key, res, err := reg.MatchPath(ctx, "/post-only", ""); err != nil || key != "acme-post" || res == nil || res.Exact { t.Fatalf("non-exact match = %q %+v %v", key, res, err) } if key, res, err := reg.MatchPath(ctx, "/nothing", ""); err != nil || key != "" || res != nil { t.Fatalf("no match = %q %+v %v", key, res, err) } if _, _, err := reg.MatchPath(ctx, "/boom", ""); err == nil || !strings.HasPrefix(err.Error(), "signpost: ") || !strings.Contains(err.Error(), "acme-post") { t.Fatalf("Match error = %v", err) } }