- ContentType, ContentTypeForController, Records and MatchPath on the registry; the pure MatchPattern for whole-segment placeholder matching - two types declaring the same Content.Model or Controller stop the boot - TestContent* cover lookups, optional Records, boot errors, MatchPattern and MatchPath precedence (first exact, else first inexact) - docs: Content descriptors in menu-item-types.md, signpost README
266 lines
11 KiB
Go
266 lines
11 KiB
Go
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)
|
|
}
|
|
}
|