- 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
507 lines
18 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|