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
This commit is contained in:
338
modules/cabana/form_extensions_test.go
Normal file
338
modules/cabana/form_extensions_test.go
Normal file
@@ -0,0 +1,338 @@
|
||||
package cabana
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"git.golem15.com/golem15/summercms/modules/party"
|
||||
)
|
||||
|
||||
// The D-91 unit matrix (no database): placement across several plugins,
|
||||
// tabs and Hide, and every form extension boot error with the plugin, the
|
||||
// controller and the offending name. The smoke tests in
|
||||
// form_extensions_smoke_test.go cover the first cases of each table.
|
||||
|
||||
// feText is an extension field document line set: a text field name with
|
||||
// optional placement keys ("after: a", "tab: T1").
|
||||
func feText(name string, keys ...string) string {
|
||||
out := " " + name + ":\n type: text\n"
|
||||
for _, key := range keys {
|
||||
out += " " + key + "\n"
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestFormExtensionPlacementMatrix(t *testing.T) {
|
||||
type layout struct{ names, tabs string }
|
||||
cases := []struct {
|
||||
name string
|
||||
plugins func() []party.Plugin
|
||||
want layout
|
||||
}{
|
||||
// party activates plugins in Requires() order; cabana places their
|
||||
// fields in that order.
|
||||
{"three plugins on one anchor keep their Requires order", func() []party.Plugin {
|
||||
return []party.Plugin{
|
||||
feBlog(),
|
||||
feExt("acme.one", nil, "acme.blog.posts", "fields:\n"+feText("c1", "after: a")),
|
||||
feExt("acme.two", []string{"acme.one"}, "acme.blog.posts", "fields:\n"+feText("c2", "after: a")),
|
||||
feExt("acme.three", []string{"acme.two"}, "acme.blog.posts", "fields:\n"+feText("c3", "after: a")),
|
||||
}
|
||||
}, layout{"a c1 c2 c3 b", "default"}},
|
||||
{"three plugins before one anchor keep their Requires order", func() []party.Plugin {
|
||||
return []party.Plugin{
|
||||
feBlog(),
|
||||
feExt("acme.one", nil, "acme.blog.posts", "fields:\n"+feText("c1", "before: b")),
|
||||
feExt("acme.two", []string{"acme.one"}, "acme.blog.posts", "fields:\n"+feText("c2", "before: b")),
|
||||
feExt("acme.three", []string{"acme.two"}, "acme.blog.posts", "fields:\n"+feText("c3", "before: b")),
|
||||
}
|
||||
}, layout{"a c1 c2 c3 b", "default"}},
|
||||
{"an anchor on a required plugin's field", func() []party.Plugin {
|
||||
return []party.Plugin{
|
||||
feBlog(),
|
||||
feExt("acme.one", nil, "acme.blog.posts", "fields:\n"+feText("c1", "after: a")),
|
||||
feExt("acme.two", []string{"acme.one"}, "acme.blog.posts", "fields:\n"+feText("c2", "before: c1")),
|
||||
}
|
||||
}, layout{"a c2 c1 b", "default"}},
|
||||
{"a field in a required plugin's tab", func() []party.Plugin {
|
||||
return []party.Plugin{
|
||||
feBlog(),
|
||||
feExt("acme.one", nil, "acme.blog.posts", "tabs:\n - name: N\nfields:\n"+feText("n1", "tab: N")),
|
||||
feExt("acme.two", []string{"acme.one"}, "acme.blog.posts", "fields:\n"+feText("n2", "tab: N")),
|
||||
}
|
||||
}, layout{"a b n1 n2", "default N"}},
|
||||
{"a tab after a required plugin's tab", func() []party.Plugin {
|
||||
return []party.Plugin{
|
||||
feBlog("a", "x:T1"),
|
||||
feExt("acme.one", nil, "acme.blog.posts", "tabs:\n - name: N\n after: default\nfields:\n"+feText("n", "tab: N")),
|
||||
feExt("acme.two", []string{"acme.one"}, "acme.blog.posts", "tabs:\n - name: M\n after: N\nfields:\n"+feText("m", "tab: M")),
|
||||
}
|
||||
}, layout{"a n m x", "default N M T1"}},
|
||||
{"a new tab right after the default tab of a tabbed form", func() []party.Plugin {
|
||||
return []party.Plugin{
|
||||
feBlog("a", "x:T1", "y:T2"),
|
||||
feExt("acme.extra", nil, "acme.blog.posts", "tabs:\n - name: N\n after: default\nfields:\n"+feText("n", "tab: N")),
|
||||
}
|
||||
}, layout{"a n x y", "default N T1 T2"}},
|
||||
{"a field before a tab's first field joins that tab", func() []party.Plugin {
|
||||
return []party.Plugin{
|
||||
feBlog("a", "x:T1", "y:T1"),
|
||||
feExt("acme.extra", nil, "acme.blog.posts", "fields:\n"+feText("c", "before: x")),
|
||||
}
|
||||
}, layout{"a c x y", "default T1"}},
|
||||
{"a field anchored in its own tab with tab: named", func() []party.Plugin {
|
||||
return []party.Plugin{
|
||||
feBlog("a", "x:T1", "y:T1"),
|
||||
feExt("acme.extra", nil, "acme.blog.posts", "fields:\n"+feText("c", "after: x", "tab: T1")),
|
||||
}
|
||||
}, layout{"a x c y", "default T1"}},
|
||||
{"fields of one extension go to several tabs", func() []party.Plugin {
|
||||
return []party.Plugin{
|
||||
feBlog("a", "x:T1", "z:T2"),
|
||||
feExt("acme.extra", nil, "acme.blog.posts", "fields:\n"+feText("c", "after: a")+feText("d", "tab: T2")+feText("e", "before: x")),
|
||||
}
|
||||
}, layout{"a c e x z d", "default T1 T2"}},
|
||||
{"hide two owner fields of the default tab", func() []party.Plugin {
|
||||
return []party.Plugin{
|
||||
feBlog("a", "b", "x:T1"),
|
||||
feExt("acme.extra", nil, "acme.blog.posts", "", func(e *pact.AdminFormExtension) { e.Hide = []string{"a", "b"} }),
|
||||
}
|
||||
}, layout{"x", "T1"}},
|
||||
{"hide a field and anchor on its neighbour", func() []party.Plugin {
|
||||
return []party.Plugin{
|
||||
feBlog("a", "b", "y"),
|
||||
feExt("acme.extra", nil, "acme.blog.posts", "fields:\n"+feText("c", "after: a"), func(e *pact.AdminFormExtension) { e.Hide = []string{"b"} }),
|
||||
}
|
||||
}, layout{"a c y", "default"}},
|
||||
{"hide every field of a tab and add a tab in its place", func() []party.Plugin {
|
||||
return []party.Plugin{
|
||||
feBlog("a", "x:T1", "z:T3"),
|
||||
feExt("acme.extra", nil, "acme.blog.posts", "tabs:\n - name: T2\n after: T1\nfields:\n"+feText("n", "tab: T2"), func(e *pact.AdminFormExtension) { e.Hide = []string{"x"} }),
|
||||
}
|
||||
}, layout{"a n z", "default T2 T3"}},
|
||||
{"an interleaved owner list is grouped when extended", func() []party.Plugin {
|
||||
return []party.Plugin{
|
||||
feBlog("x:T1", "a", "y:T2", "x2:T1", "b"),
|
||||
feExt("acme.extra", nil, "acme.blog.posts", "fields:\n"+feText("c", "after: b")),
|
||||
}
|
||||
}, layout{"a b c x x2 y", "default T1 T2"}},
|
||||
{"an interleaved owner list stays as written when not extended", func() []party.Plugin {
|
||||
return []party.Plugin{
|
||||
feBlog("x:T1", "a", "y:T2", "x2:T1", "b"),
|
||||
feExt("acme.extra", nil, "acme.news.posts", "fields:\n"+feText("c")),
|
||||
feOwnerPlugin{id: "acme.news", fields: feFields("y")},
|
||||
}
|
||||
}, layout{"x a y x2 b", "T1 default T2"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
for range 3 {
|
||||
names, tabs := feLayout(t, "acme.blog.posts", tc.plugins()...)
|
||||
got := layout{feJoin(names), feJoin(tabs)}
|
||||
if got != tc.want {
|
||||
t.Fatalf("fields = %s, tabs = %s; want fields %s, tabs %s", got.names, got.tabs, tc.want.names, tc.want.tabs)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("span left after a span left keeps both values", func(t *testing.T) {
|
||||
// cabana keeps each field's span; the SPA starts a new row when a
|
||||
// left field follows a left field, so an inserted field never
|
||||
// changes its neighbours' spans.
|
||||
owner := feOwnerPlugin{id: "acme.blog", fields: "fields:\n a:\n type: text\n span: left\n b:\n type: text\n span: right\n"}
|
||||
ext := feExt("acme.extra", nil, "acme.blog.posts", "fields:\n c:\n type: text\n span: left\n after: a\n")
|
||||
reg, err := feCompile(owner, ext)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cc, _ := reg.Get("acme.blog.posts")
|
||||
var got []string
|
||||
for _, field := range cc.Form.Fields {
|
||||
got = append(got, field.Name+"="+field.Span)
|
||||
}
|
||||
if strings.Join(got, " ") != "a=left c=left b=right" {
|
||||
t.Fatalf("spans = %v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an extension field is registered as the contributor's", func(t *testing.T) {
|
||||
reg, err := feCompile(feBlog(), feExt("acme.extra", nil, "acme.blog.posts", "fields:\n"+feText("c", "after: a")))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cc, _ := reg.Get("acme.blog.posts")
|
||||
if ext := cc.extensionOf("c"); ext == nil || ext.pluginID != "acme.extra" {
|
||||
t.Fatalf("extensionOf(c) = %+v", ext)
|
||||
}
|
||||
if cc.extensionOf("a") != nil {
|
||||
t.Fatal("an owner field is an extension's")
|
||||
}
|
||||
for _, field := range cc.Writable {
|
||||
if field.Name == "c" {
|
||||
t.Fatalf("the extension field c is in the owner's writable fields: %+v", cc.Writable)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// feNoForm owns a controller without config_form.yaml.
|
||||
type feNoForm struct{ feOwnerPlugin }
|
||||
|
||||
func (p feNoForm) AdminFS() fs.FS {
|
||||
fsys := p.feOwnerPlugin.AdminFS().(fstest.MapFS)
|
||||
out := fstest.MapFS{}
|
||||
for name, file := range fsys {
|
||||
if name != "controllers/posts/config_form.yaml" {
|
||||
out[name] = file
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestFormExtensionBootErrorsMatrix(t *testing.T) {
|
||||
const prefix = `cabana: plugin acme.extra: controller "acme.blog.posts": `
|
||||
widget := func(doc string, actions ...pact.AdminAction) feContributor {
|
||||
ext := feExtWidgetPlugin(doc)
|
||||
if actions != nil {
|
||||
ext.extensions[0].Form.Actions = actions
|
||||
}
|
||||
return ext
|
||||
}
|
||||
widgetDoc := "fields:\n title:\n type: text\n w:\n type: widget\n widget: acme-extra-preview\n action: preview\n"
|
||||
cases := []struct {
|
||||
name string
|
||||
plugins []party.Plugin
|
||||
want []string
|
||||
}{
|
||||
{"a controller without a form", []party.Plugin{feNoForm{feBlog()}, feExt("acme.extra", nil, "acme.blog.posts", "")},
|
||||
[]string{"cabana: plugin acme.extra", "acme.blog.posts", "has no form"}},
|
||||
{"an anchor hidden by an earlier plugin", []party.Plugin{feBlog(),
|
||||
feExt("acme.one", nil, "acme.blog.posts", "", func(e *pact.AdminFormExtension) { e.Hide = []string{"b"} }),
|
||||
feExt("acme.extra", []string{"acme.one"}, "acme.blog.posts", "fields:\n"+feText("c", "after: b"))},
|
||||
[]string{prefix, "field c", `anchor "b"`, "hidden by plugin acme.one"}},
|
||||
{"hide of another plugin's field", []party.Plugin{feBlog(),
|
||||
feExt("acme.one", nil, "acme.blog.posts", "fields:\n"+feText("c1")),
|
||||
feExt("acme.extra", []string{"acme.one"}, "acme.blog.posts", "", func(e *pact.AdminFormExtension) { e.Hide = []string{"c1"} })},
|
||||
[]string{prefix, `Hide "c1"`, "belongs to plugin acme.one"}},
|
||||
{"a tab named default", []party.Plugin{feBlog(), feExt("acme.extra", nil, "acme.blog.posts", "tabs:\n - name: default\n")},
|
||||
[]string{prefix, "tab default", "reserved"}},
|
||||
{"a tab after one tab and before an earlier one", []party.Plugin{feBlog("a", "x:T1", "z:T3"), feExt("acme.extra", nil, "acme.blog.posts", "tabs:\n - name: N\n after: T3\n before: T1\n")},
|
||||
[]string{prefix, "tab N", `after "T3"`, `before "T1"`}},
|
||||
{"a field after one field and before an earlier one", []party.Plugin{feBlog(), feExt("acme.extra", nil, "acme.blog.posts", "fields:\n"+feText("c", "after: b", "before: a"))},
|
||||
[]string{prefix, "field c", `after "b"`, `before "a"`}},
|
||||
{"a field whose anchor is in another tab", []party.Plugin{feBlog("a", "x:T1", "z:T2"), feExt("acme.extra", nil, "acme.blog.posts", "fields:\n"+feText("c", "after: x", "tab: T2"))},
|
||||
[]string{prefix, "field c", `its anchor is in tab "T1", not "T2"`}},
|
||||
{"a field in another plugin's tab outside Requires()", []party.Plugin{feBlog(),
|
||||
feExt("acme.one", nil, "acme.blog.posts", "tabs:\n - name: N\nfields:\n"+feText("n1", "tab: N")),
|
||||
feExt("acme.extra", nil, "acme.blog.posts", "fields:\n"+feText("n2", "tab: N"))},
|
||||
[]string{prefix, "field n2", `tab "N"`, "acme.one", "Requires()"}},
|
||||
{"a tab anchored on another plugin's tab outside Requires()", []party.Plugin{feBlog(),
|
||||
feExt("acme.one", nil, "acme.blog.posts", "tabs:\n - name: N\nfields:\n"+feText("n1", "tab: N")),
|
||||
feExt("acme.extra", nil, "acme.blog.posts", "tabs:\n - name: M\n after: N\n")},
|
||||
[]string{prefix, "tab M", `anchor "N"`, "acme.one", "Requires()"}},
|
||||
{"an unsupported field type", []party.Plugin{feBlog(), feExt("acme.extra", nil, "acme.blog.posts", "fields:\n r:\n type: repeater\n form:\n fields:\n q:\n type: text\n")},
|
||||
[]string{prefix, "field r", "type repeater is not supported in a form extension"}},
|
||||
{"a dropdown with an options method", []party.Plugin{feBlog(), feExt("acme.extra", nil, "acme.blog.posts", "fields:\n d:\n type: dropdown\n options: listThings\n")},
|
||||
[]string{prefix, "field d", "options"}},
|
||||
{"dependsOn", []party.Plugin{feBlog(), feExt("acme.extra", nil, "acme.blog.posts", "fields:\n d:\n type: dropdown\n dependsOn: a\n")},
|
||||
[]string{prefix, "field d", "dependsOn is not supported"}},
|
||||
{"a widget action that is not one of the extension's Actions", []party.Plugin{feBlog(), widget(strings.Replace(widgetDoc, "action: preview", "action: nope", 1))},
|
||||
[]string{prefix, "field w", "action nope is not one of the extension's Actions"}},
|
||||
{"a widget fill that is not a value field of the extension", []party.Plugin{feBlog(), widget(widgetDoc + " fill: [a]\n")},
|
||||
[]string{prefix, "field w", "fill a is not a value field of this extension"}},
|
||||
{"a widget without an element script", []party.Plugin{feBlog(), feExt("acme.extra", nil, "acme.blog.posts", widgetDoc, func(e *pact.AdminFormExtension) {
|
||||
e.Actions = []pact.AdminAction{{Name: "preview", Label: "Preview", Run: feActionRun}}
|
||||
})}, []string{prefix, "field w", "element script"}},
|
||||
{"a widget action whose name is not an identifier", []party.Plugin{feBlog(), widget("", pact.AdminAction{Name: "pre view", Run: feActionRun})},
|
||||
[]string{prefix, `widget action "pre view"`, "not an identifier"}},
|
||||
{"a widget action with a reserved name", []party.Plugin{feBlog(), widget("", pact.AdminAction{Name: "create", Run: feActionRun})},
|
||||
[]string{prefix, "widget action create", "reserved"}},
|
||||
{"a widget action without Run", []party.Plugin{feBlog(), widget("", pact.AdminAction{Name: "preview"})},
|
||||
[]string{prefix, "widget action preview", "no Run function"}},
|
||||
{"a widget action declared twice", []party.Plugin{feBlog(), widget("", pact.AdminAction{Name: "preview", Run: feActionRun}, pact.AdminAction{Name: "preview", Run: feActionRun})},
|
||||
[]string{prefix, "widget action preview", "declared twice"}},
|
||||
{"a partial without PartialData", []party.Plugin{feBlog(), func() feContributor {
|
||||
ext := feExtPartialPlugin("fields:\n p:\n type: partial\n path: status\n")
|
||||
ext.extensions[0].Form.PartialData = nil
|
||||
return ext
|
||||
}()}, []string{prefix, "partial status", "PartialData"}},
|
||||
{"a partial without Dir", []party.Plugin{feBlog(), func() feContributor {
|
||||
ext := feExtPartialPlugin("fields:\n p:\n type: partial\n path: status\n")
|
||||
ext.extensions[0].Form.Dir = ""
|
||||
return ext
|
||||
}()}, []string{prefix, "partial status", `Dir ""`, "must name a directory"}},
|
||||
{"a partial Dir outside the AdminFS", []party.Plugin{feBlog(), func() feContributor {
|
||||
ext := feExtPartialPlugin("fields:\n p:\n type: partial\n path: status\n")
|
||||
ext.extensions[0].Form.Dir = "../partials"
|
||||
return ext
|
||||
}()}, []string{prefix, "partial status", `Dir "../partials"`, "must name a directory"}},
|
||||
{"a partial template missing from Dir", []party.Plugin{feBlog(), func() feContributor {
|
||||
ext := feExtPartialPlugin("fields:\n p:\n type: partial\n path: status\n")
|
||||
ext.extensions[0].Form.Dir = "nowhere"
|
||||
return ext
|
||||
}()}, []string{prefix, "partial status", "template nowhere/_status.htm is not in the plugin's embedded files"}},
|
||||
{"an unknown zone", []party.Plugin{feBlog(), feExt("acme.extra", nil, "acme.blog.posts", "fields:\n"+feText("c", "zone: sidebar"))},
|
||||
[]string{prefix, "field c", `unknown zone "sidebar"`}},
|
||||
{"tabs that are not a list", []party.Plugin{feBlog(), feExt("acme.extra", nil, "acme.blog.posts", "tabs:\n N: {}\n")},
|
||||
[]string{prefix, "tabs must be a list"}},
|
||||
{"a tab without a name", []party.Plugin{feBlog(), feExt("acme.extra", nil, "acme.blog.posts", "tabs:\n - after: default\n")},
|
||||
[]string{prefix, "tabs[0]", "name is required"}},
|
||||
{"an unknown tab key", []party.Plugin{feBlog(), feExt("acme.extra", nil, "acme.blog.posts", "tabs:\n - name: N\n color: red\n")},
|
||||
[]string{prefix, "tabs[0]", "unknown key color"}},
|
||||
{"two YAML documents", []party.Plugin{feBlog(), feExt("acme.extra", nil, "acme.blog.posts", "fields: {}\n---\nfields: {}\n")},
|
||||
[]string{prefix, "one YAML document"}},
|
||||
{"a document that is not a mapping", []party.Plugin{feBlog(), feExt("acme.extra", nil, "acme.blog.posts", "- fields\n")},
|
||||
[]string{prefix, "must be a mapping"}},
|
||||
{"a Fields file that does not exist", []party.Plugin{feBlog(), feExt("acme.extra", nil, "acme.blog.posts", "", func(e *pact.AdminFormExtension) {
|
||||
e.FieldsYAML = nil
|
||||
e.Fields = "extensions/missing.yaml"
|
||||
})}, []string{prefix, "form extension fields", "missing.yaml"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := feCompile(tc.plugins...)
|
||||
if err == nil {
|
||||
t.Fatalf("boot succeeded, want an error naming %v", tc.want)
|
||||
}
|
||||
for _, want := range tc.want {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("err = %v\nwant it to contain %q", err, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("a Fields file is read from the plugin's AdminFS", func(t *testing.T) {
|
||||
ext := feExt("acme.extra", nil, "acme.blog.posts", "", func(e *pact.AdminFormExtension) {
|
||||
e.FieldsYAML = nil
|
||||
e.Fields = "extensions/posts.yaml"
|
||||
})
|
||||
ext.fsys = fstest.MapFS{"extensions/posts.yaml": &fstest.MapFile{Data: []byte("fields:\n" + feText("c", "after: a"))}}
|
||||
names, _ := feLayout(t, "acme.blog.posts", feBlog(), ext)
|
||||
if feJoin(names) != "a c b" {
|
||||
t.Fatalf("fields = %s", feJoin(names))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no error without extensions", func(t *testing.T) {
|
||||
if _, err := feCompile(feBlog()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := compileFormExtensions(nil, nil); err == nil {
|
||||
t.Fatal("a nil registry compiled")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("the Save of an extension with only a widget and a partial is optional", func(t *testing.T) {
|
||||
ext := feExtWidgetPlugin("fields:\n w:\n type: widget\n widget: acme-extra-preview\n action: preview\n")
|
||||
ext.extensions[0].Form.Save = nil
|
||||
if _, err := feCompile(feBlog(), ext); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
269
modules/cabana/record_events_test.go
Normal file
269
modules/cabana/record_events_test.go
Normal file
@@ -0,0 +1,269 @@
|
||||
package cabana
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
)
|
||||
|
||||
// The D-92 record-event diff without a database: the snapshot taken before
|
||||
// a save, the changes computed after it, and the value rules. The Postgres
|
||||
// suite (record_events_postgres_test.go) covers every admin write path.
|
||||
|
||||
// rdArticle has a column of each kind a change reports.
|
||||
type rdArticle struct {
|
||||
ID uint `gorm:"column:id;primaryKey"`
|
||||
Title string `gorm:"column:title"`
|
||||
Views int `gorm:"column:views"`
|
||||
Note *string `gorm:"column:note"`
|
||||
Published bool `gorm:"column:published"`
|
||||
PublishedAt *time.Time `gorm:"column:published_at"`
|
||||
Body []byte `gorm:"column:body"`
|
||||
Ignored string `gorm:"-"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// rdWriter is a translation writer with en as the default locale and the
|
||||
// stored non-default values in stored (field -> locale -> value).
|
||||
type rdWriter struct {
|
||||
stored map[string]map[string]string
|
||||
calls int
|
||||
}
|
||||
|
||||
func (w *rdWriter) DefaultLocale(context.Context, *gorm.DB) (string, error) { return "en", nil }
|
||||
func (w *rdWriter) EnabledLocales(context.Context, *gorm.DB) ([]string, error) {
|
||||
return []string{"en", "pl", "de"}, nil
|
||||
}
|
||||
func (w *rdWriter) WriteTranslated(context.Context, *gorm.DB, any, string, string, string) error {
|
||||
return nil
|
||||
}
|
||||
func (w *rdWriter) TranslatedExact(_ context.Context, _ *gorm.DB, _ any, field, locale string) (string, bool, error) {
|
||||
w.calls++
|
||||
v, ok := w.stored[field][locale]
|
||||
return v, ok, nil
|
||||
}
|
||||
|
||||
func rdStr(s string) *string { return &s }
|
||||
|
||||
func TestRecordEventDiff(t *testing.T) {
|
||||
db, _ := openDryRunDB(t)
|
||||
ctx := context.Background()
|
||||
at := time.Date(2026, 10, 27, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
before *rdArticle // nil: a create
|
||||
after rdArticle
|
||||
want []pact.RecordChange
|
||||
}{
|
||||
{"create lists the non-zero columns with a nil Old",
|
||||
nil,
|
||||
rdArticle{ID: 7, Title: "Hello", Note: rdStr("n"), CreatedAt: at, UpdatedAt: at},
|
||||
[]pact.RecordChange{{Attribute: "title", New: "Hello"}, {Attribute: "note", New: "n"}}},
|
||||
{"create of a zero record lists nothing",
|
||||
nil,
|
||||
rdArticle{ID: 7},
|
||||
[]pact.RecordChange{}},
|
||||
{"update reports a changed scalar column",
|
||||
&rdArticle{ID: 7, Title: "Old", Views: 3},
|
||||
rdArticle{ID: 7, Title: "New", Views: 3},
|
||||
[]pact.RecordChange{{Attribute: "title", Old: "Old", New: "New"}}},
|
||||
{"unchanged columns, the primary key and GORM timestamps are omitted",
|
||||
&rdArticle{ID: 7, Title: "Same", CreatedAt: at, UpdatedAt: at},
|
||||
rdArticle{ID: 7, Title: "Same", CreatedAt: at, UpdatedAt: at.Add(time.Hour)},
|
||||
[]pact.RecordChange{}},
|
||||
{"nil to value dereferences the pointer",
|
||||
&rdArticle{ID: 7},
|
||||
rdArticle{ID: 7, Note: rdStr("now set")},
|
||||
[]pact.RecordChange{{Attribute: "note", Old: nil, New: "now set"}}},
|
||||
{"value to nil",
|
||||
&rdArticle{ID: 7, Note: rdStr("gone")},
|
||||
rdArticle{ID: 7},
|
||||
[]pact.RecordChange{{Attribute: "note", Old: "gone", New: nil}}},
|
||||
{"times compare as instants",
|
||||
&rdArticle{ID: 7, PublishedAt: &at},
|
||||
rdArticle{ID: 7, PublishedAt: func() *time.Time { v := at.In(time.FixedZone("CET", 3600)); return &v }()},
|
||||
[]pact.RecordChange{}},
|
||||
{"a moved time is a change",
|
||||
&rdArticle{ID: 7, PublishedAt: &at},
|
||||
rdArticle{ID: 7, PublishedAt: func() *time.Time { v := at.Add(time.Minute); return &v }()},
|
||||
[]pact.RecordChange{{Attribute: "published_at", Old: at, New: at.Add(time.Minute)}}},
|
||||
{"several columns in declaration order",
|
||||
&rdArticle{ID: 7, Title: "a", Views: 1, Published: false},
|
||||
rdArticle{ID: 7, Title: "b", Views: 2, Published: true},
|
||||
[]pact.RecordChange{{Attribute: "title", Old: "a", New: "b"}, {Attribute: "views", Old: 1, New: 2}, {Attribute: "published", Old: false, New: true}}},
|
||||
{"byte columns compare by content",
|
||||
&rdArticle{ID: 7, Body: []byte("x")},
|
||||
rdArticle{ID: 7, Body: []byte("x")},
|
||||
[]pact.RecordChange{}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var snap *recordSnapshot
|
||||
if tc.before != nil {
|
||||
var err error
|
||||
snap, err = takeRecordSnapshot(ctx, db, nil, tc.before, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
after := tc.after
|
||||
got, err := recordChanges(ctx, db, nil, &after, snap, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(got, tc.want) {
|
||||
t.Fatalf("changes = %#v\nwant %#v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("translated values per locale", func(t *testing.T) {
|
||||
writer := &rdWriter{stored: map[string]map[string]string{"title": {"pl": "Stary", "de": "Gleich"}}}
|
||||
submitted := []mlValue{{field: "title", values: map[string]string{"en": "New", "pl": "Nowy", "de": "Gleich", "fr": "Nouveau"}}}
|
||||
before := &rdArticle{ID: 7, Title: "Old"}
|
||||
snap, err := takeRecordSnapshot(ctx, db, writer, before, submitted)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, asked := snap.translations["title"]["en"]; asked {
|
||||
t.Fatal("the snapshot read the default locale from the translation store")
|
||||
}
|
||||
after := &rdArticle{ID: 7, Title: "New"}
|
||||
got, err := recordChanges(ctx, db, writer, after, snap, submitted)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []pact.RecordChange{
|
||||
{Attribute: "title", Old: "Old", New: "New"},
|
||||
{Attribute: "title", Locale: "fr", Old: nil, New: "Nouveau"},
|
||||
{Attribute: "title", Locale: "pl", Old: "Stary", New: "Nowy"},
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("changes = %#v\nwant %#v", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("translated values on create", func(t *testing.T) {
|
||||
writer := &rdWriter{}
|
||||
submitted := []mlValue{{field: "title", values: map[string]string{"en": "Hello", "pl": "Cześć", "de": ""}}}
|
||||
got, err := recordChanges(ctx, db, writer, &rdArticle{ID: 7, Title: "Hello"}, nil, submitted)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []pact.RecordChange{
|
||||
{Attribute: "title", New: "Hello"},
|
||||
{Attribute: "title", Locale: "pl", New: "Cześć"},
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("changes = %#v\nwant %#v", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no translation store is asked without submitted translations", func(t *testing.T) {
|
||||
writer := &rdWriter{}
|
||||
snap, err := takeRecordSnapshot(ctx, db, writer, &rdArticle{ID: 7}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := recordChanges(ctx, db, writer, &rdArticle{ID: 7}, snap, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if writer.calls != 0 {
|
||||
t.Fatalf("TranslatedExact called %d times", writer.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a model GORM cannot parse reports nothing", func(t *testing.T) {
|
||||
snap, err := takeRecordSnapshot(ctx, db, nil, 42, nil)
|
||||
if err != nil || len(snap.columns) != 0 {
|
||||
t.Fatalf("snapshot = %+v, %v", snap, err)
|
||||
}
|
||||
got, err := recordChanges(ctx, db, nil, 42, snap, nil)
|
||||
if err != nil || len(got) != 0 {
|
||||
t.Fatalf("changes = %+v, %v", got, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRecordEventDiffValues(t *testing.T) {
|
||||
s := "x"
|
||||
var nilStr *string
|
||||
slice := []string{"a"}
|
||||
m := map[string]int{"a": 1}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
in any
|
||||
want any
|
||||
}{
|
||||
{"nil", nil, nil},
|
||||
{"a value", 3, 3},
|
||||
{"a pointer is dereferenced", &s, "x"},
|
||||
{"a nil pointer is nil", nilStr, nil},
|
||||
{"a nil slice is nil", []string(nil), nil},
|
||||
{"a nil map is nil", map[string]int(nil), nil},
|
||||
{"a slice is copied", slice, []string{"a"}},
|
||||
{"a map is copied", m, map[string]int{"a": 1}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := changeValue(tc.in); !reflect.DeepEqual(got, tc.want) {
|
||||
t.Fatalf("changeValue(%#v) = %#v, want %#v", tc.in, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("a copied slice and map do not follow later writes", func(t *testing.T) {
|
||||
gotSlice := changeValue(slice).([]string)
|
||||
gotMap := changeValue(m).(map[string]int)
|
||||
slice[0], m["a"] = "changed", 2
|
||||
if gotSlice[0] != "a" || gotMap["a"] != 1 {
|
||||
t.Fatalf("copies followed the model: %v %v", gotSlice, gotMap)
|
||||
}
|
||||
})
|
||||
|
||||
at := time.Date(2026, 10, 27, 12, 0, 0, 0, time.UTC)
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
a, b any
|
||||
same bool
|
||||
}{
|
||||
{"equal strings", "a", "a", true},
|
||||
{"different strings", "a", "b", false},
|
||||
{"the same instant in two zones", at, at.In(time.FixedZone("X", 7200)), true},
|
||||
{"two instants", at, at.Add(time.Second), false},
|
||||
{"nil and nil", nil, nil, true},
|
||||
{"nil and a value", nil, "", false},
|
||||
{"int and int64 differ", 1, int64(1), false},
|
||||
} {
|
||||
t.Run("same "+tc.name, func(t *testing.T) {
|
||||
if got := sameChangeValue(tc.a, tc.b); got != tc.same {
|
||||
t.Fatalf("sameChangeValue(%#v, %#v) = %v", tc.a, tc.b, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("columnValue converts to the column type", func(t *testing.T) {
|
||||
type tree struct {
|
||||
ParentID *uint `gorm:"column:parent_id"`
|
||||
SortOrder int `gorm:"column:sort_order"`
|
||||
}
|
||||
if got := columnValue(&tree{}, "parent_id", float64(4)); got != uint(4) {
|
||||
t.Fatalf("parent_id = %#v", got)
|
||||
}
|
||||
if got := columnValue(&tree{}, "sort_order", int64(2)); got != 2 {
|
||||
t.Fatalf("sort_order = %#v", got)
|
||||
}
|
||||
if got := columnValue(&tree{}, "missing", "v"); got != "v" {
|
||||
t.Fatalf("missing column = %#v", got)
|
||||
}
|
||||
if got := columnValue(&tree{}, "sort_order", nil); got != nil {
|
||||
t.Fatalf("nil = %#v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -263,3 +263,164 @@ func TestContentMatchPath(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user