Files
summercms/modules/pact/capabilities_test.go
Jakub Zych 2e94cbf9f8 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
2026-10-05 14:48:34 +02:00

433 lines
14 KiB
Go

package pact
import (
"context"
"errors"
"io/fs"
"reflect"
"strings"
"testing"
"testing/fstest"
"git.golem15.com/golem15/summercms/modules/bonfire"
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
type configOnly struct{}
func (configOnly) ConfigFS() fs.FS {
return fstest.MapFS{"config/config.yaml": {Data: []byte("k: 1\n")}}
}
type commandsOnly struct{}
func (commandsOnly) Commands() []bonfire.Command {
return []bonfire.Command{{Name: "demo:run"}}
}
type bothCaps struct {
configOnly
commandsOnly
}
type neither struct{}
type migrationsOnly struct{}
func (migrationsOnly) Migrations() []*gormigrate.Migration {
return []*gormigrate.Migration{{ID: "202609170001_create_users"}}
}
func TestOptionalCapabilitiesDiscoveredByTypeAssertion(t *testing.T) {
plugins := []any{configOnly{}, commandsOnly{}, bothCaps{}, neither{}, extraMessage{}, migrationsOnly{}}
var configs, commands, messages, migrations, none int
for _, p := range plugins {
_, hasCfg := p.(HasConfig)
_, hasCmd := p.(HasCommands)
_, hasMsg := p.(OptionalMessage)
_, hasMig := p.(HasMigrations)
if hasCfg {
configs++
}
if hasCmd {
commands++
}
if hasMsg {
messages++
}
if hasMig {
migrations++
}
if !hasCfg && !hasCmd && !hasMsg && !hasMig {
none++
}
}
if configs != 2 {
t.Fatalf("HasConfig count = %d, want 2", configs)
}
if commands != 2 {
t.Fatalf("HasCommands count = %d, want 2", commands)
}
if messages != 1 {
t.Fatalf("OptionalMessage count = %d, want 1", messages)
}
if migrations != 1 {
t.Fatalf("HasMigrations count = %d, want 1", migrations)
}
if none != 1 {
t.Fatalf("neither count = %d, want 1", none)
}
}
func TestHasMigrationsReturnsOrderedSet(t *testing.T) {
var p HasMigrations = migrationsOnly{}
set := p.Migrations()
if len(set) != 1 || set[0].ID != "202609170001_create_users" {
t.Fatalf("Migrations = %+v", set)
}
}
func TestHasConfigReturnsFilesystem(t *testing.T) {
var p HasConfig = configOnly{}
fsys := p.ConfigFS()
if fsys == nil {
t.Fatal("ConfigFS returned nil")
}
raw, err := fs.ReadFile(fsys, "config/config.yaml")
if err != nil {
t.Fatal(err)
}
if string(raw) != "k: 1\n" {
t.Fatalf("ConfigFS body = %q", raw)
}
}
func TestHasCommandsReturnsNamespacedCommand(t *testing.T) {
var p HasCommands = commandsOnly{}
cmds := p.Commands()
if len(cmds) != 1 || cmds[0].Name != "demo:run" {
t.Fatalf("Commands = %+v", cmds)
}
}
func TestOptionalMessageContract(t *testing.T) {
var msg OptionalMessage = extraMessage{s: "hello-from-optional"}
if msg.Message() != "hello-from-optional" {
t.Fatalf("Message = %q", msg.Message())
}
}
type extraMessage struct{ s string }
func (e extraMessage) Message() string { return e.s }
type routesOnly struct{}
func (routesOnly) Routes(Router) error { return nil }
type middlewareOnly struct{}
func (middlewareOnly) Middlewares() map[string]Middleware {
return map[string]Middleware{"jwt.auth": nil}
}
type modelsOnly struct{}
func (modelsOnly) Models() []any { return []any{struct{}{}} }
func TestHTTPCapabilitiesDiscoveredByTypeAssertion(t *testing.T) {
var r HasRoutes = routesOnly{}
if err := r.Routes(nil); err != nil {
t.Fatal(err)
}
var m HasMiddleware = middlewareOnly{}
if _, ok := m.Middlewares()["jwt.auth"]; !ok {
t.Fatal("missing jwt.auth")
}
var models HasModels = modelsOnly{}
if len(models.Models()) != 1 {
t.Fatalf("Models = %+v", models.Models())
}
}
// relationHooks implements every relation child hook with the documented
// signature and records the calls.
type relationHooks struct{ calls *[]string }
func (h relationHooks) record(name, relation string, parent, child any) error {
*h.calls = append(*h.calls, name+":"+relation+":"+parent.(string)+":"+child.(string))
if relation == "fail" {
return errors.New(name + " refused")
}
return nil
}
func (h relationHooks) RelationBeforeCreate(_ context.Context, relation string, parent, child any) error {
return h.record("BeforeCreate", relation, parent, child)
}
func (h relationHooks) RelationAfterCreate(_ context.Context, relation string, parent, child any) error {
return h.record("AfterCreate", relation, parent, child)
}
func (h relationHooks) RelationBeforeUpdate(_ context.Context, relation string, parent, child any) error {
return h.record("BeforeUpdate", relation, parent, child)
}
func (h relationHooks) RelationAfterUpdate(_ context.Context, relation string, parent, child any) error {
return h.record("AfterUpdate", relation, parent, child)
}
func (h relationHooks) RelationBeforeDelete(_ context.Context, relation string, parent, child any) error {
return h.record("BeforeDelete", relation, parent, child)
}
func (h relationHooks) RelationAfterDelete(_ context.Context, relation string, parent, child any) error {
return h.record("AfterDelete", relation, parent, child)
}
var (
_ RelationBeforeCreate = relationHooks{}
_ RelationAfterCreate = relationHooks{}
_ RelationBeforeUpdate = relationHooks{}
_ RelationAfterUpdate = relationHooks{}
_ RelationBeforeDelete = relationHooks{}
_ RelationAfterDelete = relationHooks{}
)
// TestRelationHookInterfaces: each of the six optional relation child hooks
// is discovered by a type assertion on the controller value, receives the
// relation name, the parent and the child, and passes its error back; a
// controller without them asserts false.
func TestRelationHookInterfaces(t *testing.T) {
var calls []string
var ctl any = relationHooks{calls: &calls}
hooks := []func(context.Context, string, any, any) error{}
if h, ok := ctl.(RelationBeforeCreate); ok {
hooks = append(hooks, h.RelationBeforeCreate)
}
if h, ok := ctl.(RelationAfterCreate); ok {
hooks = append(hooks, h.RelationAfterCreate)
}
if h, ok := ctl.(RelationBeforeUpdate); ok {
hooks = append(hooks, h.RelationBeforeUpdate)
}
if h, ok := ctl.(RelationAfterUpdate); ok {
hooks = append(hooks, h.RelationAfterUpdate)
}
if h, ok := ctl.(RelationBeforeDelete); ok {
hooks = append(hooks, h.RelationBeforeDelete)
}
if h, ok := ctl.(RelationAfterDelete); ok {
hooks = append(hooks, h.RelationAfterDelete)
}
if len(hooks) != 6 {
t.Fatalf("discovered %d hooks, want 6", len(hooks))
}
for _, hook := range hooks {
if err := hook(t.Context(), "parts", "gadget", "part"); err != nil {
t.Fatal(err)
}
if err := hook(t.Context(), "fail", "gadget", "part"); err == nil {
t.Fatal("hook error was not passed back")
}
}
want := "BeforeCreate:parts:gadget:part,BeforeCreate:fail:gadget:part,AfterCreate:parts:gadget:part,AfterCreate:fail:gadget:part," +
"BeforeUpdate:parts:gadget:part,BeforeUpdate:fail:gadget:part,AfterUpdate:parts:gadget:part,AfterUpdate:fail:gadget:part," +
"BeforeDelete:parts:gadget:part,BeforeDelete:fail:gadget:part,AfterDelete:parts:gadget:part,AfterDelete:fail:gadget:part"
if got := strings.Join(calls, ","); got != want {
t.Fatalf("calls = %s", got)
}
var plain any = struct{}{}
for name, ok := range map[string]bool{
"BeforeCreate": func() bool { _, ok := plain.(RelationBeforeCreate); return ok }(),
"AfterCreate": func() bool { _, ok := plain.(RelationAfterCreate); return ok }(),
"BeforeUpdate": func() bool { _, ok := plain.(RelationBeforeUpdate); return ok }(),
"AfterUpdate": func() bool { _, ok := plain.(RelationAfterUpdate); return ok }(),
"BeforeDelete": func() bool { _, ok := plain.(RelationBeforeDelete); return ok }(),
"AfterDelete": func() bool { _, ok := plain.(RelationAfterDelete); return ok }(),
} {
if ok {
t.Fatalf("a plain value implements Relation%s", name)
}
}
}
// TestRowStateValues pins the wire values of the fixed row-state set: the
// admin SPA and plugin CSS match on these exact strings.
func TestRowStateValues(t *testing.T) {
got := []RowState{RowStateDeleted, RowStateNegative, RowStateDisabled}
want := []string{"deleted", "negative", "disabled"}
for i, state := range got {
if string(state) != want[i] {
t.Fatalf("row state %d = %q, want %q", i, state, want[i])
}
}
}
type formSeams struct{}
func (formSeams) FormVirtualFields() []string { return []string{"password", "password_confirmation"} }
func (formSeams) FormRules(_ context.Context, op string) map[string]string {
if op == "create" {
return map[string]string{"password": "required|confirmed"}
}
return map[string]string{"password": "nullable|confirmed"}
}
func TestFormSeamsDiscoveredByTypeAssertion(t *testing.T) {
var ctl any = formSeams{}
virtual, ok := ctl.(FormVirtualFields)
if !ok || len(virtual.FormVirtualFields()) != 2 {
t.Fatalf("FormVirtualFields = %v ok=%v", virtual, ok)
}
rules, ok := ctl.(FormRules)
if !ok {
t.Fatal("FormRules is not implemented")
}
if got := rules.FormRules(context.Background(), "create")["password"]; got != "required|confirmed" {
t.Fatalf("create rules = %q", got)
}
if got := rules.FormRules(context.Background(), "update")["password"]; got != "nullable|confirmed" {
t.Fatalf("update rules = %q", got)
}
if _, ok := any(neither{}).(FormVirtualFields); ok {
t.Fatal("a plugin without the method implements FormVirtualFields")
}
}
// sampleRoster is a controller value that implements every admin contract of
// Phase 12.1: declared bulk and record actions, row states and controller
// filter choices.
type sampleRoster struct{ ran *[]string }
func (s sampleRoster) AdminBulkActions() []AdminBulkAction {
return []AdminBulkAction{{
Name: "activate", Label: "acme::lang.activate", Confirm: "acme::lang.activate_confirm",
Permissions: []string{"acme.manage"},
Run: func(_ context.Context, in AdminBulkActionInput) (AdminBulkActionResult, error) {
*s.ran = append(*s.ran, "bulk")
return AdminBulkActionResult{Message: "acme::lang.activated", Affected: len(in.Records)}, nil
},
}}
}
func (s sampleRoster) AdminRecordActions() []AdminRecordAction {
return []AdminRecordAction{{
Name: "reinstate", Label: "acme::lang.reinstate",
Applies: func(_ context.Context, record any) (bool, error) {
if record == nil {
return false, errors.New("no record")
}
return record.(string) == "banned", nil
},
Run: func(_ context.Context, in AdminRecordActionInput) (AdminRecordActionResult, error) {
*s.ran = append(*s.ran, "record")
return AdminRecordActionResult{Message: "acme::lang.reinstated"}, nil
},
}}
}
func (sampleRoster) ListRowStates(_ context.Context, _ *gorm.DB, records []any) ([][]RowState, error) {
out := make([][]RowState, len(records))
for i, record := range records {
if record.(string) == "banned" {
out[i] = []RowState{RowStateNegative}
}
}
return out, nil
}
func (sampleRoster) FilterOptions(scope string) []Option {
if scope != "tagged" {
return nil
}
return []Option{{Value: "1", Label: "acme::lang.tag"}}
}
var (
_ HasAdminBulkActions = sampleRoster{}
_ HasAdminRecordActions = sampleRoster{}
_ ListRowStates = sampleRoster{}
_ FilterOptions = sampleRoster{}
)
// TestPhase121ContractsOnSampleController: the bulk action, record action,
// row state and filter choice contracts are discovered by a type assertion
// on a controller value and carry exactly the documented fields.
func TestPhase121ContractsOnSampleController(t *testing.T) {
var ran []string
var ctl any = sampleRoster{ran: &ran}
bulk, ok := ctl.(HasAdminBulkActions)
if !ok || len(bulk.AdminBulkActions()) != 1 {
t.Fatal("HasAdminBulkActions is not discovered")
}
action := bulk.AdminBulkActions()[0]
result, err := action.Run(t.Context(), AdminBulkActionInput{Records: []any{"a", "b"}})
if err != nil || result.Affected != 2 || result.Message != "acme::lang.activated" {
t.Fatalf("bulk result = %+v err=%v", result, err)
}
if action.Name != "activate" || action.Confirm == "" || len(action.Permissions) != 1 {
t.Fatalf("bulk action = %+v", action)
}
// The input of a bulk action is the loaded records and nothing else: an id
// list would let a plugin skip the list scope.
if n := reflect.TypeOf(AdminBulkActionInput{}).NumField(); n != 1 {
t.Fatalf("AdminBulkActionInput has %d fields, want only Records", n)
}
record, ok := ctl.(HasAdminRecordActions)
if !ok || len(record.AdminRecordActions()) != 1 {
t.Fatal("HasAdminRecordActions is not discovered")
}
one := record.AdminRecordActions()[0]
for input, want := range map[string]bool{"banned": true, "active": false} {
if applies, err := one.Applies(t.Context(), input); err != nil || applies != want {
t.Fatalf("Applies(%s) = %v err=%v", input, applies, err)
}
}
if _, err := one.Applies(t.Context(), nil); err == nil {
t.Fatal("Applies did not pass its error back")
}
out, err := one.Run(t.Context(), AdminRecordActionInput{RecordID: 7, Record: "banned"})
if err != nil || out.Message != "acme::lang.reinstated" || one.Confirm != "" {
t.Fatalf("record result = %+v err=%v", out, err)
}
if fields := reflect.TypeOf(AdminRecordActionInput{}).NumField(); fields != 2 {
t.Fatalf("AdminRecordActionInput has %d fields, want RecordID and Record", fields)
}
if strings.Join(ran, ",") != "bulk,record" {
t.Fatalf("ran = %v", ran)
}
states, ok := ctl.(ListRowStates)
if !ok {
t.Fatal("ListRowStates is not discovered")
}
got, err := states.ListRowStates(t.Context(), nil, []any{"active", "banned"})
if err != nil || len(got) != 2 || got[0] != nil || len(got[1]) != 1 || got[1][0] != RowStateNegative {
t.Fatalf("row states = %v err=%v", got, err)
}
// The fixed set has exactly three values, each its own string.
set := map[RowState]bool{RowStateDeleted: true, RowStateNegative: true, RowStateDisabled: true}
if len(set) != 3 || string(RowStateDeleted) != "deleted" || string(RowStateNegative) != "negative" || string(RowStateDisabled) != "disabled" {
t.Fatalf("row state set = %v", set)
}
options, ok := ctl.(FilterOptions)
if !ok || len(options.FilterOptions("tagged")) != 1 || options.FilterOptions("other") != nil {
t.Fatal("FilterOptions is not discovered or answers an unknown scope")
}
var plain any = neither{}
for name, implemented := range map[string]bool{
"HasAdminBulkActions": func() bool { _, ok := plain.(HasAdminBulkActions); return ok }(),
"HasAdminRecordActions": func() bool { _, ok := plain.(HasAdminRecordActions); return ok }(),
"ListRowStates": func() bool { _, ok := plain.(ListRowStates); return ok }(),
"FilterOptions": func() bool { _, ok := plain.(FilterOptions); return ok }(),
} {
if implemented {
t.Fatalf("a plain value implements %s", name)
}
}
}