Files
summercms/modules/cabana/form_extensions_postgres_test.go
Jakub Zych 0819442634 feat(32-01): cross-plugin admin form extensions (tracer)
- pact.AdminActionExtension.Form with AdminFormExtension: a fields
  document (fields.yaml format plus before:/after:/zone: and a tabs:
  list) given as an AdminFS path or YAML bytes, Load and Save hooks,
  AdminFormLoad/Save/SaveResult, AdminFormErrors and zone constants
- cabana compileFormExtensions places extension fields at owner
  anchors at boot, regrouping an extended form by tab
- save lifts extension values before any owner path and runs Save in
  the record's transaction after the owner row; show merges Load
- TestFormExtensionTracer: c after a on a, b gives a, c, b through the
  admin API, stored in the contributor's table
2026-10-26 21:46:43 +01:00

341 lines
11 KiB
Go

package cabana
import (
"bytes"
"context"
"encoding/json"
"io/fs"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"sync"
"testing"
"testing/fstest"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
"gorm.io/gorm"
)
// The D-91 form extension save, show and schema paths on a real Postgres
// database (testcontainers, like newListService): an owner plugin acme.blog
// with the posts controller, and a contributor acme.extra that adds fields
// and stores their values in its own table.
// fePost is the owner's model: columns a and b only.
type fePost struct {
ID uint `gorm:"column:id;primaryKey"`
A string `gorm:"column:a"`
B string `gorm:"column:b"`
}
func (fePost) TableName() string { return "acme_blog_posts" }
func (*fePost) Fillable() []string { return []string{"a", "b"} }
func (*fePost) Rules() map[string]string { return map[string]string{} }
// feExtraValue is the contributor's own table: one value per post.
type feExtraValue struct {
RecordID uint `gorm:"column:record_id;primaryKey;autoIncrement:false"`
Value string `gorm:"column:value"`
}
func (feExtraValue) TableName() string { return "acme_extra_values" }
// fePosts is the owner's admin controller.
type fePosts struct{}
func (fePosts) ID() string { return "acme.blog.posts" }
func (fePosts) ModelName() string { return "Post" }
func (fePosts) ConfigDir() string { return "controllers/posts" }
func (fePosts) NewRecord() any { return &fePost{} }
func (fePosts) RequiredPermissions() []string { return []string{"acme.blog.access"} }
// feOwner is the owner plugin; fields is the body of its fields.yaml.
type feOwner struct {
fields string
}
func (feOwner) ID() string { return "acme.blog" }
func (feOwner) Requires() []string { return nil }
func (feOwner) Register(*backpack.App) error { return nil }
func (feOwner) Boot(*backpack.App) error { return nil }
func (p feOwner) AdminFS() fs.FS { return feOwnerFS(p.fields) }
func (feOwner) AdminControllers() []pact.AdminController {
return []pact.AdminController{fePosts{}}
}
// feOwnerFields is the owner form of the tracer: a and b, no tabs.
const feOwnerFields = "fields:\n a:\n type: text\n label: A\n b:\n type: text\n label: B\n"
func feOwnerFS(fields string) fstest.MapFS {
return fstest.MapFS{
"controllers/posts/config_form.yaml": &fstest.MapFile{Data: []byte("name: Post\nform: ~/plugins/acme/blog/models/post/fields.yaml\nmodelClass: Post\n")},
"controllers/posts/config_list.yaml": &fstest.MapFile{Data: []byte("list: ~/plugins/acme/blog/models/post/columns.yaml\nmodelClass: Post\nrecordsPerPage: 20\ntoolbar:\n buttons: [create]\n")},
"models/post/columns.yaml": &fstest.MapFile{Data: []byte("columns:\n a:\n label: A\n")},
"models/post/fields.yaml": &fstest.MapFile{Data: []byte(fields)},
}
}
// feContributor is a plugin that extends other plugins' forms.
type feContributor struct {
id string
requires []string
fsys fstest.MapFS
types []pact.FormFieldType
extensions []pact.AdminActionExtension
}
func (p feContributor) ID() string { return p.id }
func (p feContributor) Requires() []string { return p.requires }
func (feContributor) Register(*backpack.App) error { return nil }
func (feContributor) Boot(*backpack.App) error { return nil }
func (p feContributor) AdminFS() fs.FS { return p.fsys }
func (p feContributor) FormFieldTypes() []pact.FormFieldType { return p.types }
func (p feContributor) AdminActionExtensions() []pact.AdminActionExtension {
return p.extensions
}
var (
_ party.Plugin = feContributor{}
_ pact.HasAdminActionExtensions = feContributor{}
)
// feCompile runs the boot path: field types, the registry, then the form
// extensions, as Activate does.
func feCompile(plugins ...party.Plugin) (*Registry, error) {
reg, err := ftCompile(plugins...)
if err != nil {
return nil, err
}
if err := compileFormExtensions(reg, plugins); err != nil {
return nil, err
}
return reg, nil
}
// feStore is the contributor's storage spy: every Save call's input, the
// owner row as Save read it through the save transaction, and failures to
// return.
type feStore struct {
mu sync.Mutex
saves []pact.AdminFormSave
ownerA []string
fail error
notices map[string][]pact.FormFieldNotice
}
func (s *feStore) save(_ context.Context, in pact.AdminFormSave) (pact.AdminFormSaveResult, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.saves = append(s.saves, in)
var a string
if err := in.DB.Raw("SELECT a FROM acme_blog_posts WHERE id = ?", in.ID).Scan(&a).Error; err != nil {
return pact.AdminFormSaveResult{}, err
}
s.ownerA = append(s.ownerA, a)
if value, ok := in.Values["c"]; ok {
text, _ := value.(string)
id, _ := strconv.ParseUint(in.ID, 10, 64)
row := feExtraValue{RecordID: uint(id), Value: text}
if err := in.DB.Save(&row).Error; err != nil {
return pact.AdminFormSaveResult{}, err
}
}
if s.fail != nil {
return pact.AdminFormSaveResult{}, s.fail
}
return pact.AdminFormSaveResult{Notices: s.notices}, nil
}
func (s *feStore) load(_ context.Context, in pact.AdminFormLoad) (map[string]any, error) {
var row feExtraValue
err := in.DB.Where("record_id = ?", in.ID).Limit(1).Find(&row).Error
if err != nil {
return nil, err
}
return map[string]any{"c": row.Value, "a": "never served"}, nil
}
// feExtraFS is the contributor's tree with one fields document.
func feExtraFS(doc string) fstest.MapFS {
return fstest.MapFS{"extensions/posts.yaml": &fstest.MapFile{Data: []byte(doc)}}
}
// feCall runs one admin request against a controller of svc.
func feCall(t *testing.T, svc *service, principal *bouncer.Principal, controller, method, route, id string, body any) *httptest.ResponseRecorder {
t.Helper()
var raw []byte
if body != nil {
var err error
if raw, err = json.Marshal(body); err != nil {
t.Fatal(err)
}
}
req := httptest.NewRequest(method, "/", bytes.NewReader(raw))
req.Header.Set("Content-Type", "application/json")
parts := strings.SplitN(controller, ".", 3)
req.SetPathValue("vendor", parts[0])
req.SetPathValue("plugin", parts[1])
req.SetPathValue("controller", parts[2])
if id != "" {
req.SetPathValue("id", id)
}
req = req.WithContext(bouncer.WithUser(req.Context(), principal))
rec := httptest.NewRecorder()
switch route {
case "schema":
svc.formSchema(rec, req)
case "record":
switch method {
case http.MethodPost:
svc.create(rec, req)
case http.MethodGet:
svc.show(rec, req)
case http.MethodPut:
svc.update(rec, req)
default:
t.Fatalf("method %s", method)
}
default:
t.Fatalf("route %s", route)
}
return rec
}
// feSchemaNames is the field name list of a form schema response.
func feSchemaNames(t *testing.T, rec *httptest.ResponseRecorder) []string {
t.Helper()
if rec.Code != http.StatusOK {
t.Fatalf("schema status=%d body=%s", rec.Code, rec.Body.String())
}
var out struct {
Data struct {
Fields []struct {
Name string `json:"name"`
Tab string `json:"tab"`
} `json:"fields"`
} `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
names := make([]string, 0, len(out.Data.Fields))
for _, field := range out.Data.Fields {
names = append(names, field.Name)
}
return names
}
// feEnv is a Postgres-backed admin service with the owner and contributor.
type feEnv struct {
db *gorm.DB
svc *service
}
func newFEEnv(t *testing.T, plugins ...party.Plugin) feEnv {
t.Helper()
_, db := newListService(t)
if err := db.Migrator().DropTable(&fePost{}, &feExtraValue{}); err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&fePost{}, &feExtraValue{}); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Migrator().DropTable(&fePost{}, &feExtraValue{}) })
reg, err := feCompile(plugins...)
if err != nil {
t.Fatalf("compile: %v", err)
}
app := backpack.New(nil)
if err := app.Publish(db); err != nil {
t.Fatal(err)
}
return feEnv{db: db, svc: &service{app: app, reg: reg}}
}
func (e feEnv) post(t *testing.T, id uint) fePost {
t.Helper()
var out fePost
if err := e.db.First(&out, id).Error; err != nil {
t.Fatal(err)
}
return out
}
func (e feEnv) extra(t *testing.T, id uint) (string, bool) {
t.Helper()
var rows []feExtraValue
if err := e.db.Where("record_id = ?", id).Find(&rows).Error; err != nil {
t.Fatal(err)
}
if len(rows) == 0 {
return "", false
}
return rows[0].Value, true
}
// TestFormExtensionTracer (SC-1, D-91): plugin acme.extra adds field c after
// a on acme.blog's form of a and b. The schema lists a, c, b; an update
// stores a and b in the owner row and c in the contributor's table in the
// same transaction; show serves c; the owner model never gains a c column.
func TestFormExtensionTracer(t *testing.T) {
store := &feStore{}
extra := feContributor{
id: "acme.extra",
fsys: feExtraFS("fields:\n c:\n type: text\n label: C\n after: a\n"),
extensions: []pact.AdminActionExtension{{
Controller: "acme.blog.posts",
Form: &pact.AdminFormExtension{Fields: "extensions/posts.yaml", Load: store.load, Save: store.save},
}},
}
env := newFEEnv(t, feOwner{fields: feOwnerFields}, extra)
names := feSchemaNames(t, feCall(t, env.svc, superUser(), "acme.blog.posts", http.MethodGet, "schema", "", nil))
if strings.Join(names, " ") != "a c b" {
t.Fatalf("schema fields = %v, want [a c b]", names)
}
seed := fePost{A: "A0", B: "B0"}
if err := env.db.Create(&seed).Error; err != nil {
t.Fatal(err)
}
idText := strconv.FormatUint(uint64(seed.ID), 10)
rec := feCall(t, env.svc, superUser(), "acme.blog.posts", http.MethodPut, "record", idText, map[string]any{"a": "A1", "b": "B1", "c": "C1"})
if rec.Code != http.StatusOK {
t.Fatalf("update status=%d body=%s", rec.Code, rec.Body.String())
}
saved := pgFTDecode(t, rec)
if saved.Data["c"] != "C1" || saved.Data["a"] != "A1" {
t.Fatalf("update data=%v, want a=A1 and c=C1 served by Load", saved.Data)
}
if got := env.post(t, seed.ID); got.A != "A1" || got.B != "B1" {
t.Fatalf("owner row=%+v", got)
}
if value, ok := env.extra(t, seed.ID); !ok || value != "C1" {
t.Fatalf("contributor row=%q,%v", value, ok)
}
if len(store.saves) != 1 || store.saves[0].Created || store.saves[0].ID != idText || store.saves[0].Controller != "acme.blog.posts" {
t.Fatalf("saves=%+v", store.saves)
}
if len(store.saves[0].Values) != 1 || store.saves[0].Values["c"] != "C1" {
t.Fatalf("Save values=%v, want only c", store.saves[0].Values)
}
if store.ownerA[0] != "A1" {
t.Fatalf("Save read owner a=%q through its DB, want the uncommitted A1 of the same transaction", store.ownerA[0])
}
rec = feCall(t, env.svc, superUser(), "acme.blog.posts", http.MethodGet, "record", idText, nil)
if rec.Code != http.StatusOK {
t.Fatalf("show status=%d body=%s", rec.Code, rec.Body.String())
}
if shown := pgFTDecode(t, rec); shown.Data["c"] != "C1" || shown.Data["a"] != "A1" {
t.Fatalf("show data=%v", shown.Data)
}
if env.db.Migrator().HasColumn(&fePost{}, "c") {
t.Fatal("the owner table gained a c column")
}
}