test(12.1-05): unit tests for bulk and record actions, row state, forbidden, preview and the form seams

- bulk action: empty, duplicate, unordered, absent, partial, out-of-scope, rollback, concurrent runs, permissions, CSRF, body cap
- record action: scope, Applies, strict body, offered order, rollback, Applies error
- ForbiddenError from every Form hook, the bulk delete and the relation link and child hooks
- permission editor modes, locked codes and provider errors; relation locks on create, update and belongsTo
- TestPhase121BootErrors: every boot error of plans 01 and 02 with plugin, controller and file
- pact: the action, row state and filter contracts on a sample controller
This commit is contained in:
Jakub Zych
2026-10-05 14:48:34 +02:00
parent c076b4c059
commit 2e94cbf9f8
13 changed files with 2455 additions and 12 deletions

View File

@@ -0,0 +1,218 @@
package cabana_test
import (
"context"
"strings"
"testing"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/pact"
)
// rosterActions is the roster controller with its registered bulk and record
// actions replaced (boot-error tests).
type rosterActions struct {
rosterController
bulk func([]pact.AdminBulkAction) []pact.AdminBulkAction
record func([]pact.AdminRecordAction) []pact.AdminRecordAction
}
func (a rosterActions) AdminBulkActions() []pact.AdminBulkAction {
out := a.rosterController.AdminBulkActions()
if a.bulk != nil {
out = a.bulk(out)
}
return out
}
func (a rosterActions) AdminRecordActions() []pact.AdminRecordAction {
out := a.rosterController.AdminRecordActions()
if a.record != nil {
out = a.record(out)
}
return out
}
// TestPhase121BootErrors is the table of every boot error Phase 12.1 added
// to the framework: each stops activation and names the plugin, the
// controller and, where the mistake is in a file, that file.
func TestPhase121BootErrors(t *testing.T) {
const (
listFile = "controllers/people/config_list.yaml"
formFile = "controllers/people/config_form.yaml"
listHead = "list: ~/plugins/acme/roster/models/person/columns.yaml\nmodelClass: Person\n"
)
noRun := func(context.Context, pact.AdminBulkActionInput) (pact.AdminBulkActionResult, error) {
return pact.AdminBulkActionResult{}, nil
}
bulk := func(edit func([]pact.AdminBulkAction) []pact.AdminBulkAction) func(*rosterPlugin) {
return func(p *rosterPlugin) {
p.wrap = func(inner rosterController) pact.AdminController {
return rosterActions{rosterController: inner, bulk: edit}
}
}
}
record := func(edit func([]pact.AdminRecordAction) []pact.AdminRecordAction) func(*rosterPlugin) {
return func(p *rosterPlugin) {
p.wrap = func(inner rosterController) pact.AdminController {
return rosterActions{rosterController: inner, record: edit}
}
}
}
relations := func(edit func([]cabana.FieldRelationContract)) func(*rosterPlugin) {
return func(p *rosterPlugin) {
p.relations = func(in []cabana.FieldRelationContract) []cabana.FieldRelationContract {
edit(in)
return in
}
}
}
notifyBlock := " notify:\n label: acme.roster::lang.people.notify\n type: checkbox\n default: true\n context: create\n"
for _, tc := range []struct {
name string
files map[string]string
plugin func(*rosterPlugin)
want string
file string // "" when the mistake is in Go code, not in a file
}{
// config_list.yaml bulkActions (D-09).
{name: "bulkActions without showCheckboxes", files: map[string]string{listFile: listHead + "bulkActions: [activate]\n"},
want: "bulkActions needs showCheckboxes: true", file: listFile},
{name: "bulkActions names an unregistered action", files: map[string]string{listFile: listHead + "showCheckboxes: true\nbulkActions: [activate, promote]\n"},
want: "bulkActions: unsupported action promote (want a bulk action the controller registers)", file: listFile},
{name: "bulkActions names the built-in delete", files: map[string]string{listFile: listHead + "showCheckboxes: true\nbulkActions: [delete]\n"},
want: "bulkActions: unsupported action delete (want a bulk action the controller registers)", file: listFile},
{name: "bulkActions names an action twice", files: map[string]string{listFile: listHead + "showCheckboxes: true\nbulkActions: [activate, activate]\n"},
want: "bulkActions: duplicate action activate", file: listFile},
{name: "bulkActions is a scalar", files: map[string]string{listFile: listHead + "showCheckboxes: true\nbulkActions: activate\n"},
want: "bulkActions must be a list of bulk action names the controller registers", file: listFile},
{name: "a declared bulk action has no label", plugin: bulk(func(in []pact.AdminBulkAction) []pact.AdminBulkAction {
in[0].Label = ""
return in
}), want: "bulkActions: action activate needs a label", file: listFile},
// Registered bulk actions.
{name: "a bulk action with a reserved name", plugin: bulk(func(in []pact.AdminBulkAction) []pact.AdminBulkAction {
return append(in, pact.AdminBulkAction{Name: "delete", Label: "x", Run: noRun})
}), want: "bulk action delete uses a reserved built-in name (create, delete)"},
{name: "a bulk action registered twice", plugin: bulk(func(in []pact.AdminBulkAction) []pact.AdminBulkAction {
return append(in, in[0])
}), want: "duplicate bulk action activate"},
{name: "a bulk action without Run", plugin: bulk(func(in []pact.AdminBulkAction) []pact.AdminBulkAction {
in[1].Run = nil
return in
}), want: "bulk action archive has no Run function"},
{name: "a bulk action name that is not an identifier", plugin: bulk(func(in []pact.AdminBulkAction) []pact.AdminBulkAction {
return append(in, pact.AdminBulkAction{Name: "ban users", Label: "x", Run: noRun})
}), want: `bulk action name "ban users" is not an identifier`},
// config_form.yaml recordActions and preview (D-10, D-11).
{name: "recordActions without preview", files: map[string]string{formFile: rosterFormHead + "recordActions: [activate]\n"},
want: "recordActions needs a preview block (record actions are offered on the preview screen)", file: formFile},
{name: "recordActions names an unregistered action", files: map[string]string{formFile: rosterFormHead + "preview: {}\nrecordActions: [activate, promote]\n"},
want: "recordActions: unsupported action promote (want a record action the controller registers)", file: formFile},
{name: "recordActions names an action twice", files: map[string]string{formFile: rosterFormHead + "preview: {}\nrecordActions: [activate, activate]\n"},
want: "recordActions: duplicate action activate", file: formFile},
{name: "recordActions is a scalar", files: map[string]string{formFile: rosterFormHead + "preview: {}\nrecordActions: activate\n"},
want: "recordActions must be a list of record action names the controller registers", file: formFile},
{name: "a declared record action has no label", plugin: record(func(in []pact.AdminRecordAction) []pact.AdminRecordAction {
in[0].Label = ""
return in
}), want: "recordActions: action activate needs a label", file: formFile},
{name: "a record action with a reserved name", plugin: record(func(in []pact.AdminRecordAction) []pact.AdminRecordAction {
in[1].Name = "create"
return in
}), want: "record action create uses a reserved built-in name (create, delete)"},
{name: "a record action registered twice", plugin: record(func(in []pact.AdminRecordAction) []pact.AdminRecordAction {
return append(in, in[0])
}), want: "duplicate record action activate"},
{name: "a record action without Run", plugin: record(func(in []pact.AdminRecordAction) []pact.AdminRecordAction {
in[0].Run = nil
return in
}), want: "record action activate has no Run function"},
{name: "a null preview", files: map[string]string{formFile: rosterFormHead + "preview:\n"},
want: "preview must be a mapping; write preview: {} to enable the preview screen without a header partial", file: formFile},
{name: "a scalar preview", files: map[string]string{formFile: rosterFormHead + "preview: true\n"},
want: "preview must be a mapping", file: formFile},
{name: "an unknown preview key", files: map[string]string{formFile: rosterFormHead + "preview:\n toolbar: x\n"},
want: "preview: unknown field toolbar", file: formFile},
{name: "a preview header partial that is a path", files: map[string]string{formFile: rosterFormHead + "preview:\n headerPartial: $/acme/status.htm\n"},
want: "path must be a partial name", file: formFile},
{name: "a preview header partial without a template", files: map[string]string{formFile: rosterFormHead + "preview:\n headerPartial: missing\n"},
want: "partial missing", file: "controllers/people/_missing.htm"},
// Password and form-only fields (D-27 G1, G2).
{name: "a password field outside FormVirtualFields", files: map[string]string{rosterFieldsFile: rosterFields(t, "", " secret:\n type: password\n")},
want: "field secret: type password needs the controller to list it in FormVirtualFields", file: rosterFieldsFile},
{name: "a listed virtual field that is not on the form", files: map[string]string{rosterFieldsFile: rosterFields(t, notifyBlock, "")},
want: "FormVirtualFields: field notify is not a field of this form", file: rosterFieldsFile},
{name: "a virtual field of a wrong type", files: map[string]string{rosterFieldsFile: rosterFields(t, " type: checkbox\n default: true\n", " type: partial\n path: status\n")},
want: "FormVirtualFields: field notify has type partial (want password, text, textarea, number, checkbox, switch or dropdown)", file: rosterFieldsFile},
{name: "a form-only field the controller does not list", files: map[string]string{rosterFieldsFile: rosterFields(t, "", " nickname:\n type: text\n")},
want: "field nickname is not a model column"},
// preset (D-27 G7).
{name: "an unsupported preset type", files: map[string]string{rosterFieldsFile: rosterFields(t, " preset: name\n", " preset:\n field: name\n type: camel\n")},
want: "preset type camel is not supported (want slug or exact)", file: rosterFieldsFile},
{name: "an unknown preset key", files: map[string]string{rosterFieldsFile: rosterFields(t, " preset: name\n", " preset:\n field: name\n prefix: x\n")},
want: "preset: unknown field prefix", file: rosterFieldsFile},
{name: "preset on a field that is not text", files: map[string]string{rosterFieldsFile: rosterFields(t, " type: checkbox\n default: true\n", " type: checkbox\n default: true\n preset: name\n")},
want: "preset is only valid on type: text", file: rosterFieldsFile},
{name: "preset from an unknown field", files: map[string]string{rosterFieldsFile: rosterFields(t, " preset: name\n", " preset: title\n")},
want: "field slug: preset field title is not a field of this form", file: rosterFieldsFile},
{name: "preset from a field that is not text", files: map[string]string{rosterFieldsFile: rosterFields(t, " preset: name\n", " preset: notify\n")},
want: "field slug: preset field notify must be a text field", file: rosterFieldsFile},
{name: "preset from the field itself", files: map[string]string{rosterFieldsFile: rosterFields(t, " preset: name\n", " preset: slug\n")},
want: "field slug: preset names the field itself", file: rosterFieldsFile},
// permissioneditor (D-16).
{name: "permissioneditor without mode", files: map[string]string{rosterFieldsFile: rosterFields(t, " mode: radio\n", "")},
want: "field permissions: mode must be radio or checkbox on type: permissioneditor", file: rosterFieldsFile},
{name: "permissioneditor with an unknown mode", files: map[string]string{rosterFieldsFile: rosterFields(t, " mode: radio\n", " mode: tabs\n")},
want: "mode must be radio or checkbox on type: permissioneditor", file: rosterFieldsFile},
{name: "permissioneditor with a default", files: map[string]string{rosterFieldsFile: rosterFields(t, " mode: radio\n", " mode: radio\n default: 1\n")},
want: "default is not valid on type: permissioneditor", file: rosterFieldsFile},
{name: "mode on another type", files: map[string]string{rosterFieldsFile: rosterFields(t, " type: checkbox\n default: true\n", " type: checkbox\n default: true\n mode: radio\n")},
want: "mode is only valid on type: fileupload, datepicker or permissioneditor", file: rosterFieldsFile},
// Relation fields (D-27 G3) and controller filter choices.
{name: "WritableForeignKey on belongsToMany", plugin: relations(func(in []cabana.FieldRelationContract) { in[1].WritableForeignKey = true }),
want: "field tags: WritableForeignKey is only valid on belongsTo"},
{name: "a scope filter without choices", plugin: func(p *rosterPlugin) {
p.wrap = func(inner rosterController) pact.AdminController { return rosterBare{inner: inner} }
}, want: "scope filter tagged needs FilterOptions on the controller or the model to serve its choices (D-27)", file: "controllers/people/config_filter.yaml"},
} {
t.Run(tc.name, func(t *testing.T) {
plugin := rosterPlugin{spy: &rosterSpy{}}
if tc.files != nil {
plugin.fsys = rosterTree(t, tc.files)
}
if tc.plugin != nil {
tc.plugin(&plugin)
}
err := rosterBootWith(t, plugin)
if err == nil {
t.Fatalf("the plugin booted, want an error with %q", tc.want)
}
parts := []string{tc.want, "acme.roster", "acme.roster.people"}
if tc.file != "" {
parts = append(parts, tc.file)
}
for _, part := range parts {
if !strings.Contains(err.Error(), part) {
t.Fatalf("error %q does not name %q", err, part)
}
}
})
}
// The provider-less permission editor is reported for the controller
// that lacks the provider.
t.Run("permissioneditor without the provider", func(t *testing.T) {
err := activateFields(t, datepickerFields(" rights:\n type: permissioneditor\n mode: checkbox\n"))
const want = "field rights: type permissioneditor needs the controller to implement cabana.PermissionEditorProvider"
if err == nil || !strings.Contains(err.Error(), want) || !strings.Contains(err.Error(), "fields.yaml") {
t.Fatalf("error = %v, want %q", err, want)
}
})
// The unchanged fixture boots, so every failure above is its one change.
if err := rosterBoot(t, rosterTree(t, nil)); err != nil {
t.Fatalf("the unchanged fixture does not boot: %v", err)
}
}