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) } }) }