package cabana_test import ( "context" "errors" "fmt" "io/fs" "net/http" "strings" "sync" "testing" "time" "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/cabana" "git.golem15.com/golem15/summercms/modules/lagoon" "git.golem15.com/golem15/summercms/modules/pact" "git.golem15.com/golem15/summercms/modules/party" "gorm.io/gorm" ) // Unit tests (plan 05 of phase 19.3) for pact.ReorderAfterSave (D-11): the // hook runs inside the reorder transaction after the new order is written, // receives the partition, and its error rolls the reorder back. They reuse // the partitioned flat acme.shelf fixture of catalog_smoke_test.go. // hookShelfPlugin is acme.shelf with a pins controller that implements // pact.ReorderAfterSave through hook. type hookShelfPlugin struct { hook func(ctx context.Context, partition *uint64) error } func (hookShelfPlugin) ID() string { return "acme.shelf" } func (hookShelfPlugin) Requires() []string { return nil } func (hookShelfPlugin) Register(*backpack.App) error { return nil } func (hookShelfPlugin) Boot(*backpack.App) error { return nil } func (hookShelfPlugin) AdminFS() fs.FS { return shelfFS } func (p hookShelfPlugin) AdminControllers() []pact.AdminController { return []pact.AdminController{hookedPins{hook: p.hook}} } func (hookShelfPlugin) Permissions() []pact.Permission { return []pact.Permission{{Code: "acme.shelf.access", Roles: []string{"developer"}}} } // hookedPins is shelfPins with the reorder hook. type hookedPins struct { shelfPins hook func(ctx context.Context, partition *uint64) error } func (h hookedPins) ReorderAfterSave(ctx context.Context, partition *uint64) error { return h.hook(ctx, partition) } var _ pact.ReorderAfterSave = hookedPins{} // shelfHookEnv is a logged-in admin over plugin with fresh acme.shelf // tables, two shelves and three pins (A=1 and B=2 on top, X=7 on low). type shelfHookEnv struct { *conformEnv top, low shelfShelf a, b, x uint } func newShelfHookEnv(t *testing.T, plugin party.Plugin) *shelfHookEnv { t.Helper() gdb := adminGorm(t) models := []any{&shelfPin{}, &shelfShelf{}} if err := gdb.Migrator().DropTable(models...); err != nil { t.Fatal(err) } if err := gdb.AutoMigrate(models...); err != nil { t.Fatal(err) } login := fmt.Sprintf("shelf-hook-%d", time.Now().UnixNano()) insertAdmin(t, gdb, login, login+"@example.test", adminTestPassword, true, false) h, err := paymentsAssemble(t, gdb, []party.Plugin{plugin}) if err != nil { t.Fatal(err) } env := &shelfHookEnv{conformEnv: &conformEnv{h: h, db: gdb, login: login}, top: shelfShelf{Title: "Top"}, low: shelfShelf{Title: "Low"}} if rec := env.send(t, http.MethodPost, "/auth/login", map[string]string{"login": login, "password": adminTestPassword}, false); rec.Code != http.StatusOK { t.Fatalf("login status=%d body=%s", rec.Code, rec.Body.String()) } for _, s := range []*shelfShelf{&env.top, &env.low} { if err := gdb.Create(s).Error; err != nil { t.Fatal(err) } } pins := []shelfPin{{Name: "A", ShelfID: &env.top.ID, SortOrder: 1}, {Name: "B", ShelfID: &env.top.ID, SortOrder: 2}, {Name: "X", ShelfID: &env.low.ID, SortOrder: 7}} for i := range pins { if err := gdb.Create(&pins[i]).Error; err != nil { t.Fatal(err) } } env.a, env.b, env.x = pins[0].ID, pins[1].ID, pins[2].ID return env } // swap posts B=1, A=2 for the top shelf and returns the status and body. func (e *shelfHookEnv) swap(t *testing.T) (int, string) { t.Helper() rec := e.send(t, http.MethodPost, "/acme/shelf/pins/reorder", map[string]any{ "partition": e.top.ID, "nodes": []map[string]any{ {"id": e.b, "parent_id": nil, "sort_order": 1}, {"id": e.a, "parent_id": nil, "sort_order": 2}, }, }, true) return rec.Code, rec.Body.String() } // sorts reads the sort values of every pin through db. func sorts(t *testing.T, db *gorm.DB) map[uint]int { t.Helper() var all []shelfPin if err := db.Order("id").Find(&all).Error; err != nil { t.Fatal(err) } out := map[uint]int{} for _, p := range all { out[p.ID] = p.SortOrder } return out } func TestReorderHookRunsInTx(t *testing.T) { var ( mu sync.Mutex calls int gotPart *uint64 inTx map[uint]int outside map[uint]int hasTx bool afterCommit map[uint]int ) var env *shelfHookEnv env = newShelfHookEnv(t, hookShelfPlugin{hook: func(ctx context.Context, partition *uint64) error { mu.Lock() defer mu.Unlock() calls++ gotPart = partition tx, ok := cabana.TxFromContext(ctx) hasTx = ok if !ok { return errors.New("no transaction in the hook context") } inTx = sorts(t, tx.WithContext(ctx)) // Another connection still sees the committed order. outside = sorts(t, env.db) lagoon.AfterCommit(ctx, tx, func(ctx context.Context, db *gorm.DB) { mu.Lock() defer mu.Unlock() afterCommit = sorts(t, db.WithContext(ctx)) }) return nil }}) code, body := env.swap(t) if code != http.StatusOK { t.Fatalf("POST status=%d body=%s", code, body) } mu.Lock() defer mu.Unlock() if calls != 1 || !hasTx { t.Fatalf("hook calls=%d tx=%t", calls, hasTx) } if gotPart == nil || *gotPart != uint64(env.top.ID) { t.Fatalf("partition = %v, want %d", gotPart, env.top.ID) } if inTx[env.b] != 1 || inTx[env.a] != 2 || inTx[env.x] != 7 { t.Fatalf("hook saw %v through the transaction, want the new order", inTx) } if outside[env.a] != 1 || outside[env.b] != 2 { t.Fatalf("outside the transaction %v, want the old order before commit", outside) } if afterCommit == nil || afterCommit[env.b] != 1 || afterCommit[env.a] != 2 { t.Fatalf("AfterCommit work saw %v, want the committed new order", afterCommit) } if got := sorts(t, env.db); got[env.b] != 1 || got[env.a] != 2 || got[env.x] != 7 { t.Fatalf("stored = %v", got) } } func TestReorderHookErrorRollsBack(t *testing.T) { for _, tc := range []struct { name string err error status int }{ {"plain error", errors.New("purge registry unavailable"), http.StatusInternalServerError}, {"validation error", &cabana.ValidationError{Details: map[string]any{"nodes": []string{"Refused."}}}, http.StatusUnprocessableEntity}, {"forbidden", &cabana.ForbiddenError{Message: "Locked."}, http.StatusForbidden}, } { t.Run(tc.name, func(t *testing.T) { var afterCommitRan bool env := newShelfHookEnv(t, hookShelfPlugin{hook: func(ctx context.Context, _ *uint64) error { tx, _ := cabana.TxFromContext(ctx) lagoon.AfterCommit(ctx, tx, func(context.Context, *gorm.DB) { afterCommitRan = true }) return tc.err }}) before := sorts(t, env.db) code, body := env.swap(t) if code != tc.status { t.Fatalf("status=%d want %d body=%s", code, tc.status, body) } if after := sorts(t, env.db); fmt.Sprint(after) != fmt.Sprint(before) { t.Fatalf("sort values changed: before %v after %v", before, after) } if afterCommitRan { t.Fatal("AfterCommit work ran after a rolled-back reorder") } if tc.status == http.StatusInternalServerError && (strings.Contains(body, "purge registry") || !strings.Contains(body, `"error"`)) { t.Fatalf("500 body = %s", body) } }) } } func TestReorderWithoutHookUnchanged(t *testing.T) { plain := newShelfHookEnv(t, shelfPlugin{}) code, plainBody := plain.swap(t) if code != http.StatusOK { t.Fatalf("POST status=%d body=%s", code, plainBody) } if got := sorts(t, plain.db); got[plain.b] != 1 || got[plain.a] != 2 || got[plain.x] != 7 { t.Fatalf("stored = %v", got) } // A hook that does nothing answers the same tree. hooked := newShelfHookEnv(t, hookShelfPlugin{hook: func(context.Context, *uint64) error { return nil }}) code, hookedBody := hooked.swap(t) if code != http.StatusOK { t.Fatalf("hooked POST status=%d body=%s", code, hookedBody) } if plainBody != hookedBody { t.Fatalf("answers differ:\nwithout hook %s\nwith hook %s", plainBody, hookedBody) } }