Files
summercms/modules/cabana/record_events_postgres_test.go
Jakub Zych ff62906301 feat(32-02): record events from every admin write path
- Delete and each bulk-delete row fire RecordDeleted after commit
- tree reorder fires RecordSaved per moved record (parent/sort in model types)
- relation-manager child create/update/delete fire events naming the
  controller that edits the child's model
- TestRecordEvents covers create, update, translated slug, no-op, rollback
  (validation, FormBeforeUpdate, extension Save), failing listener, reorder,
  child writes, delete and bulk delete; extension Save sees the changes
- docs: Record events in extending.md, admin-controllers notes, cabana README
2026-10-26 22:35:03 +01:00

507 lines
18 KiB
Go

package cabana
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io/fs"
"net/http"
"net/http/httptest"
"reflect"
"strconv"
"sync"
"testing"
"testing/fstest"
"time"
"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/towel"
"gorm.io/gorm"
)
// The D-92 record events on a real Postgres database: an acme.events plugin
// with a pages controller (translatable slug, a tree reorder page and a
// notes relation manager), a notes controller, and a contributor whose form
// extension sees the save's changes.
const rePageMorph = `Acme\Events\Page`
type rePage struct {
ID uint `gorm:"column:id;primaryKey"`
Title string `gorm:"column:title"`
Slug string `gorm:"column:slug"`
ParentID *uint `gorm:"column:parent_id"`
SortOrder int `gorm:"column:sort_order"`
UpdatedAt *time.Time `gorm:"column:updated_at"`
Notes []reNote `gorm:"-"`
}
func (rePage) TableName() string { return "cabana_re_pages" }
func (rePage) MorphName() string { return rePageMorph }
func (*rePage) Fillable() []string { return []string{"title", "slug"} }
func (*rePage) Rules() map[string]string { return map[string]string{"title": "required"} }
type reNote struct {
ID uint `gorm:"column:id;primaryKey"`
PageID *uint `gorm:"column:page_id"`
Body string `gorm:"column:body"`
}
func (reNote) TableName() string { return "cabana_re_notes" }
func (*reNote) Fillable() []string { return []string{"body"} }
func (*reNote) Rules() map[string]string { return map[string]string{} }
// rePages is the pages controller; failUpdate makes FormBeforeUpdate refuse.
type rePages struct{ failUpdate *bool }
func (rePages) ID() string { return "acme.events.pages" }
func (rePages) ModelName() string { return "Page" }
func (rePages) ConfigDir() string { return "controllers/pages" }
func (rePages) NewRecord() any { return &rePage{} }
func (rePages) FormExtendQuery(_ context.Context, q *gorm.DB) *gorm.DB { return q }
func (c rePages) FormBeforeUpdate(context.Context, any) error {
if c.failUpdate != nil && *c.failUpdate {
return errors.New("refused")
}
return nil
}
func (rePages) AdminRelationContracts() []RelationContract {
return []RelationContract{{
Name: "notes",
Kind: RelationHasMany,
NewRelated: func() any { return &reNote{} },
ForeignKey: "page_id",
Columns: map[string]string{"body": "body"},
}}
}
type reNotes struct{}
func (reNotes) ID() string { return "acme.events.notes" }
func (reNotes) ModelName() string { return "Note" }
func (reNotes) ConfigDir() string { return "controllers/notes" }
func (reNotes) NewRecord() any { return &reNote{} }
type rePlugin struct{ failUpdate *bool }
func (rePlugin) ID() string { return "acme.events" }
func (rePlugin) Requires() []string { return nil }
func (rePlugin) Register(*backpack.App) error { return nil }
func (rePlugin) Boot(*backpack.App) error { return nil }
func (rePlugin) AdminFS() fs.FS { return reFS() }
func (p rePlugin) AdminControllers() []pact.AdminController {
return []pact.AdminController{rePages{failUpdate: p.failUpdate}, reNotes{}}
}
var (
_ pact.FormBeforeUpdate = rePages{}
_ AdminRelationContractProvider = rePages{}
)
func reFS() fstest.MapFS {
file := func(s string) *fstest.MapFile { return &fstest.MapFile{Data: []byte(s)} }
return fstest.MapFS{
"controllers/pages/config_form.yaml": file("name: Page\nform: ~/plugins/acme/events/models/page/fields.yaml\nmodelClass: Page\n"),
"controllers/pages/config_list.yaml": file("list: ~/plugins/acme/events/models/page/columns.yaml\nmodelClass: Page\nrecordsPerPage: 20\ntoolbar:\n buttons: [create, reorder]\n"),
"controllers/pages/config_reorder.yaml": file("title: Reorder pages\nnameFrom: title\nparentColumn: parent_id\nsortColumn: sort_order\n"),
"controllers/pages/config_relation.yaml": file("notes:\n label: Notes\n view:\n list:\n columns:\n body:\n label: Body\n toolbarButtons: create|update|delete\n manage:\n form: $/acme/events/models/note/fields.yaml\n"),
"models/page/columns.yaml": file("columns:\n title:\n label: Title\n"),
"models/page/fields.yaml": file("fields:\n title:\n type: text\n label: Title\n slug:\n type: mltext\n label: Slug\n notes:\n type: relation-manager\n relation: notes\n"),
"controllers/notes/config_form.yaml": file("name: Note\nform: ~/plugins/acme/events/models/note/fields.yaml\nmodelClass: Note\n"),
"controllers/notes/config_list.yaml": file("list: ~/plugins/acme/events/models/note/columns.yaml\nmodelClass: Note\nrecordsPerPage: 20\n"),
"models/note/columns.yaml": file("columns:\n body:\n label: Body\n"),
"models/note/fields.yaml": file("fields:\n body:\n type: text\n label: Body\n"),
}
}
// reWriter stores translations in memory per record, field and locale.
type reWriter struct {
mu sync.Mutex
data map[string]string
}
func reKey(model any, field, locale string) string {
return fmt.Sprintf("%d/%s/%s", pkUint(model), field, locale)
}
func (*reWriter) DefaultLocale(context.Context, *gorm.DB) (string, error) { return "en", nil }
func (*reWriter) EnabledLocales(context.Context, *gorm.DB) ([]string, error) {
return []string{"en", "pl"}, nil
}
func (w *reWriter) WriteTranslated(_ context.Context, _ *gorm.DB, model any, field, locale, value string) error {
w.mu.Lock()
defer w.mu.Unlock()
if w.data == nil {
w.data = map[string]string{}
}
w.data[reKey(model, field, locale)] = value
return nil
}
func (w *reWriter) TranslatedExact(_ context.Context, _ *gorm.DB, model any, field, locale string) (string, bool, error) {
w.mu.Lock()
defer w.mu.Unlock()
text, ok := w.data[reKey(model, field, locale)]
return text, ok, nil
}
// reListener records every record event the bus delivers.
type reListener struct {
mu sync.Mutex
saved []pact.RecordSaved
deleted []pact.RecordDeleted
}
func (l *reListener) take() ([]pact.RecordSaved, []pact.RecordDeleted) {
l.mu.Lock()
defer l.mu.Unlock()
saved, deleted := l.saved, l.deleted
l.saved, l.deleted = nil, nil
return saved, deleted
}
type reEnv struct {
db *gorm.DB
svc *service
listener *reListener
extFail *error
extSaves *[]pact.AdminFormSave
}
func newREEnv(t *testing.T, failUpdate *bool) reEnv {
t.Helper()
_, db := newListService(t)
if err := db.Migrator().DropTable(&rePage{}, &reNote{}); err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&rePage{}, &reNote{}); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Migrator().DropTable(&rePage{}, &reNote{}) })
var (
extFail error
extSaves []pact.AdminFormSave
)
extra := feContributor{
id: "acme.extra",
fsys: feExtraFS("fields:\n extra:\n type: text\n label: Extra\n"),
extensions: []pact.AdminActionExtension{{
Controller: "acme.events.pages",
Form: &pact.AdminFormExtension{Fields: "extensions/posts.yaml", Save: func(_ context.Context, in pact.AdminFormSave) (pact.AdminFormSaveResult, error) {
extSaves = append(extSaves, in)
return pact.AdminFormSaveResult{}, extFail
}},
}},
}
reg, err := feCompile(rePlugin{failUpdate: failUpdate}, extra)
if err != nil {
t.Fatalf("compile: %v", err)
}
app := backpack.New(nil)
if err := app.Publish(db); err != nil {
t.Fatal(err)
}
if err := app.Publish[TranslationWriter](&reWriter{}); err != nil {
t.Fatal(err)
}
l := &reListener{}
app.Events.Listen("test.records", func(_ context.Context, ev *pact.RecordSaved) error {
l.mu.Lock()
defer l.mu.Unlock()
l.saved = append(l.saved, *ev)
return nil
})
app.Events.Listen("test.records", func(_ context.Context, ev *pact.RecordDeleted) error {
l.mu.Lock()
defer l.mu.Unlock()
l.deleted = append(l.deleted, *ev)
return nil
})
return reEnv{db: db, svc: &service{app: app, reg: reg}, listener: l, extFail: &extFail, extSaves: &extSaves}
}
func (e reEnv) pages(t *testing.T) *CompiledController {
t.Helper()
cc, ok := e.svc.reg.Get("acme.events.pages")
if !ok {
t.Fatal("pages controller missing")
}
return cc
}
func (e reEnv) crud(t *testing.T) CRUDService {
t.Helper()
svc, err := e.svc.crud()
if err != nil {
t.Fatal(err)
}
return svc
}
func (e reEnv) relations(t *testing.T) RelationService {
t.Helper()
svc, err := e.svc.relations()
if err != nil {
t.Fatal(err)
}
return svc
}
// reCtx is an administrator's request context with the content locale pl.
func reCtx() context.Context {
return towel.WithLocale(bouncer.WithUser(context.Background(), superUser()), "pl")
}
func reID(t *testing.T, data map[string]any) uint {
t.Helper()
switch v := data["id"].(type) {
case uint:
return v
case float64:
return uint(v)
case json.Number:
n, _ := v.Int64()
return uint(n)
}
n, err := strconv.ParseUint(fmt.Sprint(data["id"]), 10, 64)
if err != nil {
t.Fatalf("record id %v: %v", data["id"], err)
}
return uint(n)
}
func oneSaved(t *testing.T, l *reListener) pact.RecordSaved {
t.Helper()
saved, deleted := l.take()
if len(saved) != 1 || len(deleted) != 0 {
t.Fatalf("events: saved %+v deleted %+v, want one RecordSaved", saved, deleted)
}
return saved[0]
}
func noEvents(t *testing.T, l *reListener) {
t.Helper()
if saved, deleted := l.take(); len(saved) != 0 || len(deleted) != 0 {
t.Fatalf("events after a refused write: saved %+v deleted %+v", saved, deleted)
}
}
// TestRecordEvents (D-92): every admin write path fires RecordSaved or
// RecordDeleted after the commit, with the changes per locale, and a
// rolled-back write fires nothing.
func TestRecordEvents(t *testing.T) {
failUpdate := false
env := newREEnv(t, &failUpdate)
cc := env.pages(t)
crud := env.crud(t)
ctx := reCtx()
var homeID uint
t.Run("create", func(t *testing.T) {
res, err := crud.Create(ctx, cc, RecordInput{Body: map[string]any{"title": "Home", "slug": map[string]any{"en": "home", "pl": "dom"}}})
if err != nil {
t.Fatal(err)
}
homeID = reID(t, res)
ev := oneSaved(t, env.listener)
want := pact.RecordSaved{
Controller: "acme.events.pages", Model: rePageMorph, ID: strconv.FormatUint(uint64(homeID), 10), Locale: "pl", Created: true,
Changes: []pact.RecordChange{
{Attribute: "title", New: "Home"},
{Attribute: "slug", New: "home"},
{Attribute: "slug", Locale: "pl", New: "dom"},
},
}
if !reflect.DeepEqual(ev, want) {
t.Fatalf("event = %+v\nwant %+v", ev, want)
}
if saves := *env.extSaves; len(saves) != 1 || !reflect.DeepEqual(saves[0].Changes, want.Changes) {
t.Fatalf("extension Save changes = %+v, want the event's", saves)
}
})
update := func(body map[string]any) (map[string]any, error) {
return crud.Update(ctx, cc, homeID, RecordInput{Body: body})
}
t.Run("update", func(t *testing.T) {
*env.extSaves = nil
if _, err := update(map[string]any{"title": "Start"}); err != nil {
t.Fatal(err)
}
ev := oneSaved(t, env.listener)
want := []pact.RecordChange{{Attribute: "title", Old: "Home", New: "Start"}}
if ev.Created || !reflect.DeepEqual(ev.Changes, want) {
t.Fatalf("event = %+v, want changes %+v", ev, want)
}
if saves := *env.extSaves; len(saves) != 1 || !reflect.DeepEqual(saves[0].Changes, want) {
t.Fatalf("extension Save changes = %+v, want %+v", saves, want)
}
})
t.Run("translated slug", func(t *testing.T) {
if _, err := update(map[string]any{"slug": map[string]any{"en": "home", "pl": "strona"}}); err != nil {
t.Fatal(err)
}
ev := oneSaved(t, env.listener)
want := []pact.RecordChange{{Attribute: "slug", Locale: "pl", Old: "dom", New: "strona"}}
if !reflect.DeepEqual(ev.Changes, want) {
t.Fatalf("changes = %+v, want %+v", ev.Changes, want)
}
})
t.Run("no-op update", func(t *testing.T) {
if _, err := update(map[string]any{"title": "Start", "slug": map[string]any{"en": "home", "pl": "strona"}}); err != nil {
t.Fatal(err)
}
ev := oneSaved(t, env.listener)
if len(ev.Changes) != 0 || ev.ID != strconv.FormatUint(uint64(homeID), 10) {
t.Fatalf("no-op event = %+v, want no changes", ev)
}
})
t.Run("rollback", func(t *testing.T) {
if _, err := update(map[string]any{"title": ""}); err == nil {
t.Fatal("empty title saved")
}
noEvents(t, env.listener)
failUpdate = true
if _, err := update(map[string]any{"title": "Refused"}); err == nil {
t.Fatal("FormBeforeUpdate refusal saved")
}
failUpdate = false
noEvents(t, env.listener)
*env.extFail = pact.AdminFormErrors{"extra": {"bad"}}
if _, err := update(map[string]any{"title": "Refused"}); err == nil {
t.Fatal("extension Save refusal saved")
}
*env.extFail = nil
noEvents(t, env.listener)
var page rePage
if err := env.db.First(&page, homeID).Error; err != nil || page.Title != "Start" {
t.Fatalf("page after refusals = %+v err=%v", page, err)
}
})
t.Run("failing listener", func(t *testing.T) {
env.svc.app.Events.Listen("test.failing", func(context.Context, *pact.RecordSaved) error {
return errors.New("listener down")
})
if _, err := update(map[string]any{"title": "Again"}); err != nil {
t.Fatalf("save failed with a failing listener: %v", err)
}
ev := oneSaved(t, env.listener)
if len(ev.Changes) != 1 || ev.Changes[0].New != "Again" {
t.Fatalf("event = %+v", ev)
}
})
t.Run("reorder", func(t *testing.T) {
child := rePage{Title: "Child", Slug: "child", SortOrder: 2}
if err := env.db.Create(&child).Error; err != nil {
t.Fatal(err)
}
if err := env.db.Model(&rePage{}).Where("id = ?", homeID).Update("sort_order", 1).Error; err != nil {
t.Fatal(err)
}
body, _ := json.Marshal(map[string]any{"nodes": []map[string]any{
{"id": homeID, "parent_id": nil, "sort_order": 1},
{"id": child.ID, "parent_id": homeID, "sort_order": 1},
}})
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.SetPathValue("vendor", "acme")
req.SetPathValue("plugin", "events")
req.SetPathValue("controller", "pages")
req = req.WithContext(bouncer.WithUser(req.Context(), superUser()))
rec := httptest.NewRecorder()
env.svc.reorderPost(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("reorder status=%d body=%s", rec.Code, rec.Body.String())
}
ev := oneSaved(t, env.listener)
want := pact.RecordSaved{
Controller: "acme.events.pages", Model: rePageMorph, ID: strconv.FormatUint(uint64(child.ID), 10),
Changes: []pact.RecordChange{
{Attribute: "parent_id", Old: nil, New: homeID},
{Attribute: "sort_order", Old: 2, New: 1},
},
}
ev.Locale = ""
if !reflect.DeepEqual(ev, want) {
t.Fatalf("reorder event = %+v\nwant %+v", ev, want)
}
})
t.Run("child write", func(t *testing.T) {
rel := env.relations(t)
created, err := rel.CreateChild(ctx, cc, "notes", homeID, RecordInput{Body: map[string]any{"body": "first"}})
if err != nil {
t.Fatal(err)
}
noteID := reID(t, created.Data)
ev := oneSaved(t, env.listener)
if ev.Controller != "acme.events.notes" || ev.Model != "cabana_re_notes" || !ev.Created || ev.ID != strconv.FormatUint(uint64(noteID), 10) {
t.Fatalf("child create event = %+v", ev)
}
if _, err := rel.UpdateChild(ctx, cc, "notes", homeID, noteID, RecordInput{Body: map[string]any{"body": "second"}}); err != nil {
t.Fatal(err)
}
ev = oneSaved(t, env.listener)
want := []pact.RecordChange{{Attribute: "body", Old: "first", New: "second"}}
if ev.Created || !reflect.DeepEqual(ev.Changes, want) {
t.Fatalf("child update event = %+v, want changes %+v", ev, want)
}
if _, err := rel.DeleteChildren(ctx, cc, "notes", homeID, BulkDeleteInput{IDs: []any{float64(noteID)}}); err != nil {
t.Fatal(err)
}
saved, deleted := env.listener.take()
wantDel := []pact.RecordDeleted{{Controller: "acme.events.notes", Model: "cabana_re_notes", ID: strconv.FormatUint(uint64(noteID), 10)}}
if len(saved) != 0 || !reflect.DeepEqual(deleted, wantDel) {
t.Fatalf("child delete events: saved %+v deleted %+v", saved, deleted)
}
})
t.Run("delete", func(t *testing.T) {
victim := rePage{Title: "Victim", Slug: "victim"}
if err := env.db.Create(&victim).Error; err != nil {
t.Fatal(err)
}
if _, err := crud.Delete(ctx, cc, victim.ID); err != nil {
t.Fatal(err)
}
saved, deleted := env.listener.take()
want := []pact.RecordDeleted{{Controller: "acme.events.pages", Model: rePageMorph, ID: strconv.FormatUint(uint64(victim.ID), 10)}}
if len(saved) != 0 || !reflect.DeepEqual(deleted, want) {
t.Fatalf("delete events: saved %+v deleted %+v", saved, deleted)
}
})
t.Run("bulk delete", func(t *testing.T) {
a, b := rePage{Title: "A", Slug: "a"}, rePage{Title: "B", Slug: "b"}
for _, p := range []*rePage{&a, &b} {
if err := env.db.Create(p).Error; err != nil {
t.Fatal(err)
}
}
if _, err := crud.BulkDelete(ctx, cc, BulkDeleteInput{IDs: []any{float64(a.ID), float64(999999)}}); err == nil {
t.Fatal("partial selection deleted")
}
noEvents(t, env.listener)
if _, err := crud.BulkDelete(ctx, cc, BulkDeleteInput{IDs: []any{float64(a.ID), float64(b.ID)}}); err != nil {
t.Fatal(err)
}
saved, deleted := env.listener.take()
if len(saved) != 0 || len(deleted) != 2 {
t.Fatalf("bulk delete events: saved %+v deleted %+v", saved, deleted)
}
got := map[string]bool{deleted[0].ID: true, deleted[1].ID: true}
if !got[strconv.FormatUint(uint64(a.ID), 10)] || !got[strconv.FormatUint(uint64(b.ID), 10)] || deleted[0].Model != rePageMorph {
t.Fatalf("bulk delete events = %+v", deleted)
}
})
}