Files
summercms/modules/cabana/field_request_options_smoke_test.go
Jakub Zych 4cd35c9d66 feat(cabana): per-request options for plugin field types
- pact.FormFieldType gains RequestOptions, called with the application's
  database once per form schema request for each type the form uses
- the form schema route merges the answer into a copy of each field's
  typeOptions; a fields.yaml key wins, the compiled schema never changes
- an error logs one warning naming the type and serves the fields.yaml
  options only
- TestFieldTypeRequestOptions covers every screen, the precedence, the
  call count, the error path, unchanged schemas, form extension fields
  and the authenticated handler; a runnable example backs the docs
- the typeOptions description, the regenerated OpenAPI document and
  schema.d.ts, the field types page and the pact and cabana READMEs
  document it
2026-11-04 08:40:09 +01:00

293 lines
10 KiB
Go

package cabana
import (
"bytes"
"context"
"encoding/json"
"errors"
"log/slog"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"
"testing/fstest"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/pact"
"gorm.io/gorm"
)
// roNotesFields declares the notes type four times: without a context and
// once each for the create, update and preview screens. Every one sets rows,
// which the type's request options also name.
const roNotesFields = ftNotesField +
" notes_create:\n type: notes\n label: Create\n context: create\n rows: 4\n" +
" notes_update:\n type: notes\n label: Update\n context: update\n rows: 4\n" +
" notes_preview:\n type: notes\n label: Preview\n context: preview\n rows: 4\n"
// roNote is the model of the request options controller: one column per
// notes field of roNotesFields.
type roNote struct {
ID uint `gorm:"column:id;primaryKey"`
Title string `gorm:"column:title"`
Notes string `gorm:"column:notes"`
NotesCreate string `gorm:"column:notes_create"`
NotesUpdate string `gorm:"column:notes_update"`
NotesPreview string `gorm:"column:notes_preview"`
}
func (*roNote) Fillable() []string {
return []string{"title", "notes", "notes_create", "notes_update", "notes_preview"}
}
func (*roNote) Rules() map[string]string { return map[string]string{} }
// roController is ftController editing roNote.
type roController struct{ ftController }
func (roController) NewRecord() any { return &roNote{} }
// roPlugin is ftPlugin with roController.
type roPlugin struct{ ftPlugin }
func (roPlugin) AdminControllers() []pact.AdminController {
return []pact.AdminController{roController{}}
}
// roDemo is the request options plugin with the notes type typ.
func roDemo(typ pact.FormFieldType) roPlugin {
return roPlugin{ftPlugin{id: "acme.demo", fsys: ftFS(roNotesFields), types: []pact.FormFieldType{typ}}}
}
// roCounter counts RequestOptions calls.
type roCounter struct{ calls int }
// roNotesType is the notes type whose request options add headingLevels and
// try to override rows; err makes them fail.
func roNotesType(counter *roCounter, err error) pact.FormFieldType {
typ := ftNotesType()
typ.Options = []string{"rows", "headingLevels"}
typ.RequestOptions = func(_ context.Context, _ *gorm.DB) (map[string]any, error) {
counter.calls++
if err != nil {
return nil, err
}
return map[string]any{"headingLevels": []any{2, 3, 4}, "rows": 9}, nil
}
return typ
}
// roSchema serves the form schema of acme.demo.notes as principal and
// returns the status, the fields by name and the raw body.
func roSchema(t *testing.T, svc *service, principal *bouncer.Principal) (int, map[string]map[string]any, []byte) {
t.Helper()
req := httptest.NewRequest(http.MethodGet, adminAPI("/acme/demo/notes/schema/form"), nil)
if principal != nil {
req = req.WithContext(bouncer.WithUser(req.Context(), principal))
}
req.SetPathValue("vendor", "acme")
req.SetPathValue("plugin", "demo")
req.SetPathValue("controller", "notes")
rec := httptest.NewRecorder()
svc.formSchema(rec, req)
return rec.Code, roFields(t, rec.Body.Bytes()), rec.Body.Bytes()
}
// roFields indexes a form schema response's fields by name.
func roFields(t *testing.T, raw []byte) map[string]map[string]any {
t.Helper()
var body struct {
Data struct {
Fields []map[string]any `json:"fields"`
} `json:"data"`
}
if err := json.Unmarshal(raw, &body); err != nil {
return nil
}
out := map[string]map[string]any{}
for _, field := range body.Data.Fields {
name, _ := field["name"].(string)
out[name] = field
}
return out
}
func roAdmin() *bouncer.Principal {
return &bouncer.Principal{ID: 1, Backend: true, IsSuperuser: true}
}
// roCaptureLog sends the default logger to a buffer for the test.
func roCaptureLog(t *testing.T) *bytes.Buffer {
t.Helper()
var buf bytes.Buffer
previous := slog.Default()
slog.SetDefault(slog.New(slog.NewTextHandler(&buf, nil)))
t.Cleanup(func() { slog.SetDefault(previous) })
return &buf
}
// TestFieldTypeRequestOptions: a plugin field type's RequestOptions reach
// the served form schema per request, under the fields.yaml options, for
// every screen and for a form extension's field; an error fails open; a type
// without them serves the schema as before.
func TestFieldTypeRequestOptions(t *testing.T) {
t.Run("create, update and preview fields carry the request options", func(t *testing.T) {
counter := &roCounter{}
reg, err := ftCompile(roDemo(roNotesType(counter, nil)))
if err != nil {
t.Fatalf("compile: %v", err)
}
code, fields, raw := roSchema(t, &service{reg: reg}, roAdmin())
if code != http.StatusOK {
t.Fatalf("status=%d body=%s", code, raw)
}
for _, name := range []string{"notes", "notes_create", "notes_update", "notes_preview"} {
opts, _ := fields[name]["typeOptions"].(map[string]any)
if !reflect.DeepEqual(opts["headingLevels"], []any{float64(2), float64(3), float64(4)}) {
t.Fatalf("%s typeOptions=%v", name, fields[name]["typeOptions"])
}
}
})
t.Run("an option set in fields.yaml wins over the request value", func(t *testing.T) {
counter := &roCounter{}
reg, err := ftCompile(roDemo(roNotesType(counter, nil)))
if err != nil {
t.Fatalf("compile: %v", err)
}
_, fields, _ := roSchema(t, &service{reg: reg}, roAdmin())
if opts, _ := fields["notes"]["typeOptions"].(map[string]any); opts["rows"] != float64(4) {
t.Fatalf("typeOptions=%v, want rows 4 from fields.yaml", fields["notes"]["typeOptions"])
}
})
t.Run("RequestOptions runs once per schema request for each type", func(t *testing.T) {
counter := &roCounter{}
reg, err := ftCompile(roDemo(roNotesType(counter, nil)))
if err != nil {
t.Fatalf("compile: %v", err)
}
svc := &service{reg: reg}
roSchema(t, svc, roAdmin())
if counter.calls != 1 {
t.Fatalf("calls=%d after one request with four fields of the type, want 1", counter.calls)
}
roSchema(t, svc, roAdmin())
if counter.calls != 2 {
t.Fatalf("calls=%d after two requests, want 2", counter.calls)
}
})
t.Run("the compiled schema is never changed", func(t *testing.T) {
counter := &roCounter{}
reg, err := ftCompile(roDemo(roNotesType(counter, nil)))
if err != nil {
t.Fatalf("compile: %v", err)
}
cc, _ := reg.Get("acme.demo.notes")
before, _ := cc.Form.CanonicalJSON()
roSchema(t, &service{reg: reg}, roAdmin())
after, _ := cc.Form.CanonicalJSON()
if !bytes.Equal(before, after) || strings.Contains(string(after), "headingLevels") {
t.Fatalf("compiled schema changed:\n%s\n%s", before, after)
}
})
t.Run("an error serves the fields.yaml options and logs one warning", func(t *testing.T) {
buf := roCaptureLog(t)
counter := &roCounter{}
reg, err := ftCompile(roDemo(roNotesType(counter, errors.New("settings unavailable"))))
if err != nil {
t.Fatalf("compile: %v", err)
}
code, fields, raw := roSchema(t, &service{reg: reg}, roAdmin())
if code != http.StatusOK {
t.Fatalf("status=%d body=%s", code, raw)
}
for _, name := range []string{"notes", "notes_create", "notes_update", "notes_preview"} {
if opts, _ := fields[name]["typeOptions"].(map[string]any); !reflect.DeepEqual(opts, map[string]any{"rows": float64(4)}) {
t.Fatalf("%s typeOptions=%v, want the fields.yaml options only", name, fields[name]["typeOptions"])
}
}
logged := buf.String()
if n := strings.Count(logged, "request options failed"); n != 1 || !strings.Contains(logged, "notes") || !strings.Contains(logged, "settings unavailable") {
t.Fatalf("want one warning naming the type, got %d:\n%s", n, logged)
}
if !strings.Contains(logged, "level=WARN") {
t.Fatalf("the warning is not at WARN level:\n%s", logged)
}
})
t.Run("a type without RequestOptions serves the schema unchanged", func(t *testing.T) {
reg, err := ftCompile(ftDemo(ftNotesField))
if err != nil {
t.Fatalf("compile: %v", err)
}
cc, _ := reg.Get("acme.demo.notes")
view, err := cc.Form.Localize(context.Background(), nil, dropdownProvider(cc.Controller))
if err != nil {
t.Fatal(err)
}
want, _ := view.CanonicalJSON()
code, _, raw := roSchema(t, &service{reg: reg}, roAdmin())
if code != http.StatusOK {
t.Fatalf("status=%d body=%s", code, raw)
}
var body struct {
Data FormView `json:"data"`
}
if err := json.Unmarshal(raw, &body); err != nil {
t.Fatal(err)
}
body.Data.Assets = ControllerAssets{}
view.Assets = ControllerAssets{}
got, _ := body.Data.CanonicalJSON()
want, _ = view.CanonicalJSON()
if !bytes.Equal(got, want) {
t.Fatalf("served fields differ from the compiled schema:\n%s\n%s", got, want)
}
})
t.Run("a form extension's plugin field gets its request options", func(t *testing.T) {
counter := &roCounter{}
ext := feExt("acme.extra", nil, "acme.blog.posts", "fields:\n note:\n type: extranote\n label: Note\n after: a\n mode2: on\n")
typ := feWidgetType()
typ.Options = []string{"mode2"}
typ.RequestOptions = func(context.Context, *gorm.DB) (map[string]any, error) {
counter.calls++
return map[string]any{"genericAnchors": []any{"here"}, "mode2": "request"}, nil
}
ext.fsys = fstest.MapFS{"assets/js/extra.js": &fstest.MapFile{Data: []byte("// extra\n")}}
ext.types = []pact.FormFieldType{typ}
reg, err := feCompile(feBlog(), ext)
if err != nil {
t.Fatalf("compile: %v", err)
}
rec := httptest.NewRecorder()
(&service{reg: reg}).formSchema(rec, feRequest(roAdmin(), http.MethodGet, adminAPI("/acme/blog/posts/schema/form"), nil, nil))
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
opts, _ := roFields(t, rec.Body.Bytes())["note"]["typeOptions"].(map[string]any)
if !reflect.DeepEqual(opts["genericAnchors"], []any{"here"}) || opts["mode2"] != "on" || counter.calls != 1 {
t.Fatalf("typeOptions=%v calls=%d", opts, counter.calls)
}
})
t.Run("only the authenticated schema handler calls RequestOptions", func(t *testing.T) {
counter := &roCounter{}
reg, err := ftCompile(roDemo(roNotesType(counter, nil)))
if err != nil {
t.Fatalf("compile: %v", err)
}
code, _, raw := roSchema(t, &service{reg: reg}, nil)
if code != http.StatusUnauthorized || counter.calls != 0 {
t.Fatalf("status=%d calls=%d body=%s", code, counter.calls, raw)
}
code, _, _ = roSchema(t, &service{reg: reg}, &bouncer.Principal{ID: 2, Backend: true})
if code != http.StatusForbidden || counter.calls != 0 {
t.Fatalf("an administrator without the controller permission: status=%d calls=%d", code, counter.calls)
}
})
}