Files
summercms/modules/signpost/content_test.go
Jakub Zych 8c961f5fb5 test(32-06): framework unit matrices for form extensions, record events and descriptors
- form extensions: placement across three plugins, required plugins' tabs
  and fields, Hide with neighbours and whole tabs, spans, grouping only of
  extended forms; every remaining boot error with plugin, controller and
  offending name
- record events: the pure snapshot/diff (create, update, pointers, times as
  instants, timestamps and key omitted, per-locale translations) and the
  value helpers
- descriptors: MatchPattern edge cases, MatchPath ordering, types without
  Content, duplicate Model/Controller across plugins
2026-10-27 13:11:28 +01:00

427 lines
19 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)
}
}
// TestContentMatchPatternEdgeCases: trailing slashes, encoded and empty
// segments, case and segment counts. Segments are compared as given; the
// caller decodes the path and strips the query.
func TestContentMatchPatternEdgeCases(t *testing.T) {
for _, tc := range []struct {
name, pattern, path string
want map[string]string
ok bool
}{
{"a trailing slash on the path is an extra empty segment", "/blog/:slug", "/blog/hello/", nil, false},
{"a trailing slash on both matches", "/blog/:slug/", "/blog/hello/", map[string]string{"slug": "hello"}, true},
{"a trailing slash on the pattern only", "/blog/:slug/", "/blog/hello", nil, false},
{"an encoded space stays encoded", "/blog/:slug", "/blog/hello%20world", map[string]string{"slug": "hello%20world"}, true},
{"an encoded slash is one segment", "/blog/:slug", "/blog/a%2Fb", map[string]string{"slug": "a%2Fb"}, true},
{"a decoded slash is two segments", "/blog/:slug", "/blog/a/b", nil, false},
{"literal segments are case-sensitive", "/blog/:slug", "/Blog/hello", nil, false},
{"an empty segment in the middle", "/blog/:a/:b", "/blog//hello", nil, false},
{"a missing segment", "/blog/:a/:b", "/blog/x", nil, false},
{"an extra segment", "/blog/:a", "/blog/x/y", nil, false},
{"a pattern without a leading slash", "blog/:slug", "/blog/x", nil, false},
{"an empty path", "/blog/:slug", "", nil, false},
{"a lone colon is a literal segment", "/a/:", "/a/:", map[string]string{}, true},
{"a lone colon does not match a value", "/a/:", "/a/x", nil, false},
{"the root does not match a segment", "/", "/x", nil, false},
{"a placeholder matches a unicode segment", "/:slug", "/grzyby-leśne", map[string]string{"slug": "grzyby-leśne"}, true},
{"three different placeholders", "/:a/:b/:c", "/1/2/3", map[string]string{"a": "1", "b": "2", "c": "3"}, true},
} {
t.Run(tc.name, func(t *testing.T) {
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)
}
if !ok && got != nil {
t.Fatalf("a failed match returned values %v", got)
}
})
}
}
// TestContentMatchPathOrdering: an exact match of a later type beats a
// non-exact match of an earlier one, the first of two non-exact matches
// wins, types without Match are never asked, and a nil registry or context
// is safe.
func TestContentMatchPathOrdering(t *testing.T) {
first, second, third := &contentCalls{}, &contentCalls{}, &contentCalls{}
plain := contentType("acme-plain", "/plain/:slug", "acme_plain", "", &contentCalls{}, nil)
reg := contentRegistry(t, "",
contentType("acme-post", "/blog/:slug", "acme_posts", "", first, map[string]bool{"/x": false, "/y": false}),
plain,
contentType("acme-page", "/:slug", "acme_pages", "", second, map[string]bool{"/x": true, "/y": false}),
contentType("acme-tag", "/tag/:slug", "acme_tags", "", third, map[string]bool{"/x": true}),
)
ctx := context.Background()
if key, res, err := reg.MatchPath(ctx, "/x", ""); err != nil || key != "acme-page" || !res.Exact {
t.Fatalf("/x = %q %+v %v, want the later exact acme-page", key, res, err)
}
if len(third.match) != 0 {
t.Fatalf("the search went on after an exact match: %+v", third.match)
}
if key, res, err := reg.MatchPath(ctx, "/y", ""); err != nil || key != "acme-post" || res.Exact {
t.Fatalf("/y = %q %+v %v, want the first non-exact acme-post", key, res, err)
}
if len(third.match) != 1 {
t.Fatalf("without an exact match every type is asked: %+v", third.match)
}
//nolint:staticcheck // a nil context is part of the contract
if key, _, err := reg.MatchPath(nil, "/x", ""); err != nil || key != "acme-page" {
t.Fatalf("nil context: %q %v", key, err)
}
var none *signpost.Registry
if key, res, err := none.MatchPath(ctx, "/x", ""); key != "" || res != nil || err != nil {
t.Fatalf("nil registry = %q %+v %v", key, res, err)
}
if got := none.ContentTypes(); got == nil || len(got) != 0 {
t.Fatalf("nil registry ContentTypes = %#v", got)
}
if _, ok := none.ContentTypeForModel("acme_posts"); ok {
t.Fatal("a nil registry names a model")
}
if _, ok := none.ContentTypeForController("acme.blog.posts"); ok {
t.Fatal("a nil registry names a controller")
}
}
// TestContentTypesWithoutContent: types without a content descriptor keep
// resolving as before and stay out of every content lookup.
func TestContentTypesWithoutContent(t *testing.T) {
reg := contentRegistry(t, "patterns:\n acme-page: /p/:slug\n",
pact.MenuItemType{Key: "acme-page", DefaultPattern: "/:slug", Resolve: echo},
pact.MenuItemType{Key: "acme-home", DefaultPattern: "/", Resolve: echo},
)
ctx := context.Background()
if got := reg.ContentTypes(); len(got) != 0 {
t.Fatalf("ContentTypes = %v", keys(got))
}
if item, err := reg.Resolve(ctx, "acme-page", "", "", false, ""); err != nil || item.URL != "/p/:slug" {
t.Fatalf("Resolve = %+v %v, want the layered pattern", item, err)
}
if _, err := reg.Describe(ctx, "acme-page", "1", ""); err == nil || !strings.Contains(err.Error(), "no content descriptor") {
t.Fatalf("Describe = %v", err)
}
if _, err := reg.Describe(ctx, "acme-missing", "1", ""); err == nil || !strings.Contains(err.Error(), "unknown menu item type") {
t.Fatalf("Describe of an unknown key = %v", err)
}
if _, err := reg.Records(ctx, "acme-missing", "", 10, ""); err == nil {
t.Fatal("Records of an unknown key succeeded")
}
if key, res, err := reg.MatchPath(ctx, "/anything", ""); key != "" || res != nil || err != nil {
t.Fatalf("MatchPath = %q %+v %v", key, res, err)
}
mixed := contentRegistry(t, "",
pact.MenuItemType{Key: "acme-page", DefaultPattern: "/:slug", Resolve: echo},
contentType("acme-post", "/blog/:slug", "acme_posts", "acme.blog.posts", &contentCalls{}, nil),
)
got := mixed.ContentTypes()
got[0].Key = "changed"
if again := keys(mixed.ContentTypes()); !reflect.DeepEqual(again, []string{"acme-post"}) {
t.Fatalf("ContentTypes is not a copy: %v", again)
}
if typ, ok := mixed.Type("acme-page"); !ok || typ.Content != nil {
t.Fatalf("acme-page = %+v %v", typ, ok)
}
}
// TestContentBootErrorsAcrossPlugins: duplicate Model and Controller
// declarations are refused across plugins too and name both plugins; a
// blank Model is a missing one.
func TestContentBootErrorsAcrossPlugins(t *testing.T) {
calls := &contentCalls{}
for _, tc := range []struct {
name string
plugins []typesPlugin
want []string
}{
{"same Model in two plugins", []typesPlugin{
{id: "acme.blog", types: []pact.MenuItemType{contentType("acme-post", "/a/:slug", "acme_posts", "", calls, nil)}},
{id: "acme.news", types: []pact.MenuItemType{contentType("acme-article", "/b/:slug", "acme_posts", "", calls, nil)}},
}, []string{`"acme-article"`, "acme.news", `"acme-post"`, "acme.blog", `"acme_posts"`}},
{"same Controller in two plugins", []typesPlugin{
{id: "acme.blog", types: []pact.MenuItemType{contentType("acme-post", "/a/:slug", "acme_posts", "acme.blog.posts", calls, nil)}},
{id: "acme.news", types: []pact.MenuItemType{contentType("acme-article", "/b/:slug", "acme_articles", "acme.blog.posts", calls, nil)}},
}, []string{`"acme-article"`, "acme.news", `"acme-post"`, "acme.blog", `"acme.blog.posts"`}},
{"a blank Model", []typesPlugin{
{id: "acme.blog", types: []pact.MenuItemType{contentType("acme-post", "/a/:slug", " ", "", calls, nil)}},
}, []string{`"acme-post"`, "acme.blog", "without Model"}},
} {
t.Run(tc.name, func(t *testing.T) {
err := signpost.Activate(smokeApp(t, ""), tc.plugins)
if err == nil {
t.Fatal("Activate accepted the descriptors")
}
for _, part := range tc.want {
if !strings.Contains(err.Error(), part) {
t.Fatalf("error = %q, want %q", err, part)
}
}
})
}
}