test(09-03): add failing test for strict form schema compilation

- All locked field kinds, empty and single documents, and source order fail closed
- Unknown keys, types, duplicates, path escape, modelClass, partials, and missing assets must name the plugin, controller, and file
This commit is contained in:
Jakub Zych
2026-09-24 18:12:53 +02:00
parent af3312aa92
commit ea3f0705f4
2 changed files with 362 additions and 0 deletions

28
cabana/form_schema.go Normal file
View File

@@ -0,0 +1,28 @@
package cabana
import (
"fmt"
"io/fs"
"git.golem15.com/golem15/summercms/pact"
)
// FormSchema is the boot-compiled form contract. The RED stub rejects every
// asset so the ADMIN-01 tests fail on the contract, not on a missing symbol.
type FormSchema struct{}
// CompileForm will compile config_form.yaml and fields.yaml. It does not yet.
func CompileForm(pluginID string, ctl pact.AdminController, fsys fs.FS) (*FormSchema, error) {
if pluginID == "" || ctl == nil || fsys == nil {
return nil, fmt.Errorf("cabana: form schema is not implemented")
}
return nil, fmt.Errorf("cabana: form schema is not implemented")
}
// CanonicalJSON will emit the stable form contract. It does not yet.
func (s *FormSchema) CanonicalJSON() ([]byte, error) {
if s == nil {
return nil, fmt.Errorf("cabana: form schema is not implemented")
}
return nil, fmt.Errorf("cabana: form schema is not implemented")
}

334
cabana/form_schema_test.go Normal file
View File

@@ -0,0 +1,334 @@
package cabana
import (
"bytes"
"encoding/json"
"io/fs"
"strings"
"testing"
"testing/fstest"
"git.golem15.com/golem15/summercms/backpack"
"git.golem15.com/golem15/summercms/party"
)
const formConfig = `name: demo.form
form: ~/plugins/acme/demo/models/widget/fields.yaml
modelClass: Widget
defaultRedirect: acme/demo/widgets
create:
redirect: acme/demo/widgets/update/:id
redirectClose: acme/demo/widgets
update:
redirect: acme/demo/widgets
redirectClose: acme/demo/widgets
`
const allFieldsYAML = `fields:
title:
label: demo.lang.title
comment: demo.lang.title_comment
span: left
type: text
required: true
tab: demo.lang.main
context: create
default: Untitled
body:
label: demo.lang.body
span: full
type: textarea
size: small
count:
label: demo.lang.count
span: right
type: number
default: 3
attributes:
min: 0
readonly: false
step: 1
active:
label: demo.lang.active
span: left
type: checkbox
default: false
enabled:
label: demo.lang.enabled
type: switch
default: 0
format:
label: demo.lang.format
type: dropdown
required: true
emptyOption: demo.lang.none
options:
vinyl: Vinyl
2: Two
true: "Yes"
artist:
label: demo.lang.artist
span: right
type: relation
nameFrom: name
emptyOption: demo.lang.pick
editors:
label: demo.lang.editors
type: relation-manager
relation: editors
tab: demo.lang.editors_tab
span: full
context:
- update
- create
`
const allFieldsJSON = `{"name":"demo.form","modelClass":"Widget","fields":[` +
`{"name":"title","type":"text","label":"demo.lang.title","comment":"demo.lang.title_comment","span":"left","tab":"demo.lang.main","context":"create","required":true,"default":"Untitled"},` +
`{"name":"body","type":"textarea","label":"demo.lang.body","span":"full","size":"small"},` +
`{"name":"count","type":"number","label":"demo.lang.count","span":"right","default":3,"attributes":{"min":0,"readonly":false,"step":1}},` +
`{"name":"active","type":"checkbox","label":"demo.lang.active","span":"left","default":false},` +
`{"name":"enabled","type":"switch","label":"demo.lang.enabled","default":0},` +
`{"name":"format","type":"dropdown","label":"demo.lang.format","emptyOption":"demo.lang.none","required":true,"options":[{"value":"vinyl","label":"Vinyl"},{"value":2,"label":"Two"},{"value":true,"label":"Yes"}]},` +
`{"name":"artist","type":"relation","label":"demo.lang.artist","span":"right","nameFrom":"name","emptyOption":"demo.lang.pick"},` +
`{"name":"editors","type":"relation-manager","label":"demo.lang.editors","span":"full","tab":"demo.lang.editors_tab","context":["update","create"],"relation":"editors"}]}`
func TestFormSchemaCompile(t *testing.T) {
got := compileFormJSON(t, schemaController{model: "Widget"}, formConfig, allFieldsYAML)
if got != allFieldsJSON {
t.Fatalf("compiled form =\n%s\nwant\n%s", got, allFieldsJSON)
}
again := compileFormJSON(t, schemaController{model: "Widget"}, formConfig, allFieldsYAML)
if again != got {
t.Fatalf("recompilation changed JSON:\n%s\n%s", got, again)
}
for _, want := range []string{`"default":false`, `"default":0`, `"value":2`, `"value":true`, `"value":"vinyl"`} {
if !strings.Contains(got, want) {
t.Fatalf("compiled form missing scalar %s:\n%s", want, got)
}
}
if strings.Contains(got, `"value":"2"`) || strings.Contains(got, `"value":"true"`) || strings.Contains(got, `"fields":null`) {
t.Fatalf("scalar type or null fields drifted:\n%s", got)
}
t.Run("list-only controller still activates", func(t *testing.T) {
_, err := compileRegistry([]controllerRef{{
plugin: formPlugin{fsys: fstest.MapFS{
"controllers/widgets/config_list.yaml": &fstest.MapFile{Data: []byte("list: ~/plugins/acme/demo/models/widget/columns.yaml\nmodelClass: Widget\nrecordsPerPage: 20\nshowSearch: true\n")},
"models/widget/columns.yaml": &fstest.MapFile{Data: []byte("columns:\n name:\n label: Name\n searchable: true\n")},
}},
ctl: schemaController{model: "Widget"},
}})
if err != nil {
t.Fatalf("list-only registry: %v", err)
}
})
t.Run("field name case is exact", func(t *testing.T) {
raw := compileFormJSON(t, schemaController{model: "Widget"}, formConfig, "fields:\n Name:\n label: Name\n type: text\n")
if !strings.Contains(raw, `"name":"Name"`) || strings.Contains(raw, `"name":"name"`) {
t.Fatalf("field name was folded: %s", raw)
}
})
}
func TestFormSchemaEmpty(t *testing.T) {
cfg := strings.Replace(formConfig, "name: demo.form", "name: empty", 1)
got := compileFormJSON(t, schemaController{model: "Widget"}, cfg, "fields: {}\n")
if got != `{"name":"empty","modelClass":"Widget","fields":[]}` {
t.Fatalf("empty form = %s", got)
}
if strings.Contains(got, `"fields":null`) {
t.Fatal("empty fields serialized as null")
}
}
func TestFormSchemaSingle(t *testing.T) {
cfg := strings.Replace(formConfig, "name: demo.form", "name: one", 1)
got := compileFormJSON(t, schemaController{model: "Widget"}, cfg, "fields:\n title:\n label: Title\n type: text\n")
want := `{"name":"one","modelClass":"Widget","fields":[{"name":"title","type":"text","label":"Title"}]}`
if got != want {
t.Fatalf("single form =\n%s\nwant\n%s", got, want)
}
}
func TestFormSchemaOrdering(t *testing.T) {
fields := `fields:
zeta:
label: Z
type: text
alpha:
label: A
type: text
mid:
label: M
type: number
`
first := compileFormJSON(t, schemaController{model: "Widget"}, formConfig, fields)
second := compileFormJSON(t, schemaController{model: "Widget"}, formConfig, fields)
if first != second {
t.Fatalf("order changed between compiles:\n%s\n%s", first, second)
}
zeta := strings.Index(first, `"name":"zeta"`)
alpha := strings.Index(first, `"name":"alpha"`)
mid := strings.Index(first, `"name":"mid"`)
if zeta < 0 || alpha < 0 || mid < 0 || !(zeta < alpha && alpha < mid) {
t.Fatalf("source order lost: %s", first)
}
}
func TestFormSchemaRejects(t *testing.T) {
cases := []struct {
name string
model string
config string
fields string
want []string
}{
{
name: "unknown key",
config: formConfig,
fields: "fields:\n title:\n type: text\n cssClass: bad\n",
want: []string{"acme.demo", "acme.demo.widgets", "models/widget/fields.yaml", "cssClass"},
},
{
name: "unknown type",
config: formConfig,
fields: "fields:\n title:\n type: widget\n label: Title\n",
want: []string{"acme.demo", "acme.demo.widgets", "models/widget/fields.yaml", "widget"},
},
{
name: "duplicate name",
config: formConfig,
fields: "fields:\n title:\n type: text\n label: A\n title:\n type: text\n label: B\n",
want: []string{"acme.demo", "acme.demo.widgets", "models/widget/fields.yaml", "duplicate"},
},
{
name: "path escape",
config: strings.Replace(formConfig, "form: ~/plugins/acme/demo/models/widget/fields.yaml", "form: ../../etc/passwd", 1),
fields: "fields: {}\n",
want: []string{"acme.demo", "acme.demo.widgets", "config_form.yaml", "escape"},
},
{
name: "mismatched modelClass",
model: "Widget",
config: strings.Replace(formConfig, "modelClass: Widget", "modelClass: widget", 1),
fields: "fields: {}\n",
want: []string{"acme.demo", "acme.demo.widgets", "config_form.yaml", "modelClass"},
},
{
name: "partial",
config: formConfig,
fields: "fields:\n editors:\n type: partial\n tab: Editors\n span: full\n",
want: []string{"acme.demo", "acme.demo.widgets", "models/widget/fields.yaml", "partial"},
},
{
name: "partial path",
config: formConfig,
fields: "fields:\n editors:\n type: partial\n path: $/golem15/fonoteka/controllers/collections/_editors.htm\n",
want: []string{"acme.demo", "acme.demo.widgets", "models/widget/fields.yaml", "path"},
},
{
name: "missing fields",
config: strings.Replace(formConfig, "models/widget/fields.yaml", "models/widget/missing.yaml", 1),
fields: "fields: {}\n",
want: []string{"acme.demo", "acme.demo.widgets", "missing.yaml"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
model := tc.model
if model == "" {
model = "Widget"
}
_, err := CompileForm("acme.demo", schemaController{model: model}, formFS(tc.config, tc.fields))
if err == nil {
t.Fatal("expected boot error")
}
msg := err.Error()
for _, want := range tc.want {
if want == "duplicate" {
if strings.Contains(msg, "duplicate") || strings.Contains(msg, "already defined") {
continue
}
t.Fatalf("error %q missing duplicate", msg)
}
if want == "escape" {
if strings.Contains(msg, "escape") || strings.Contains(msg, "..") {
continue
}
t.Fatalf("error %q missing escape", msg)
}
if !strings.Contains(msg, want) {
t.Fatalf("error %q missing %q", msg, want)
}
}
})
}
t.Run("bad form fails activation", func(t *testing.T) {
fsys := formFS(formConfig, "fields:\n editors:\n type: partial\n tab: Editors\n")
fsys = withList(fsys)
_, err := compileRegistry([]controllerRef{{
plugin: &formPlugin{fsys: fsys},
ctl: schemaController{model: "Widget"},
}})
if err == nil || !strings.Contains(err.Error(), "partial") {
t.Fatalf("activation err = %v", err)
}
})
}
type schemaController struct{ model string }
func (c schemaController) ID() string { return "acme.demo.widgets" }
func (c schemaController) ModelName() string {
if c.model == "" {
return "Widget"
}
return c.model
}
func (c schemaController) ConfigDir() string { return "controllers/widgets" }
type formPlugin struct{ fsys fs.FS }
func (p formPlugin) ID() string { return "acme.demo" }
func (p formPlugin) Requires() []string { return nil }
func (p formPlugin) Register(*backpack.App) error { return nil }
func (p formPlugin) Boot(*backpack.App) error { return nil }
func (p formPlugin) AdminFS() fs.FS { return p.fsys }
func formFS(config, fields string) fs.FS {
return fstest.MapFS{
"controllers/widgets/config_form.yaml": &fstest.MapFile{Data: []byte(config)},
"models/widget/fields.yaml": &fstest.MapFile{Data: []byte(fields)},
}
}
func withList(fsys fs.FS) fs.FS {
base, ok := fsys.(fstest.MapFS)
if !ok {
return fsys
}
base["controllers/widgets/config_list.yaml"] = &fstest.MapFile{Data: []byte("list: ~/plugins/acme/demo/models/widget/columns.yaml\nmodelClass: Widget\nrecordsPerPage: 20\nshowSearch: true\n")}
base["models/widget/columns.yaml"] = &fstest.MapFile{Data: []byte("columns:\n name:\n label: Name\n searchable: true\n")}
return base
}
func compileFormJSON(t *testing.T, ctl schemaController, config, fields string) string {
t.Helper()
schema, err := CompileForm("acme.demo", ctl, formFS(config, fields))
if err != nil {
t.Fatalf("compile: %v", err)
}
raw, err := schema.CanonicalJSON()
if err != nil {
t.Fatalf("json: %v", err)
}
var buf bytes.Buffer
if err := json.Compact(&buf, raw); err != nil {
t.Fatalf("compact: %v\n%s", err, raw)
}
return buf.String()
}
var _ party.Plugin = formPlugin{}