feat(12.1-01): list row states from one controller call per page
- pact.ListRowStates with the fixed RowState set deleted, negative, disabled - list response meta.row_states keyed by row id; unknown values dropped - list messages rowStateDeleted, rowStateNegative, rowStateDisabled - update writes through the scope the load used, so a soft-deleted record a controller includes stays soft-deleted - DataTable row state badges and text styles - roster fixture, smoke tests, OpenAPI, TS types, dist, READMEs, docs
This commit is contained in:
@@ -11,6 +11,9 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||
@@ -454,3 +457,199 @@ func TestFormSchemaRecordActionsBoot(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// rosterList fetches the people list and returns its rows and row states.
|
||||
func rosterList(t *testing.T, env *rosterEnv, query string) (cabana.ListEnvelope[[]cabana.AdminRecord], string) {
|
||||
t.Helper()
|
||||
rec := env.expect(t, http.StatusOK, http.MethodGet, rosterPeople+query, "", "bearer")
|
||||
var body cabana.ListEnvelope[[]cabana.AdminRecord]
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("list body %s: %v", rec.Body.String(), err)
|
||||
}
|
||||
return body, rec.Body.String()
|
||||
}
|
||||
|
||||
func rosterDeleted() gorm.DeletedAt {
|
||||
return gorm.DeletedAt{Time: time.Now().UTC(), Valid: true}
|
||||
}
|
||||
|
||||
// TestRowStateSmoke checks the row-state batch hook through the assembled
|
||||
// router on PostgreSQL (D-12; T-12.1-07): one hook call per page, the fixed
|
||||
// set in the fixed order, unknown values dropped, and no key without states.
|
||||
func TestRowStateSmoke(t *testing.T) {
|
||||
env, gdb := newRosterEnv(t)
|
||||
plain := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Plain", Active: true})
|
||||
idle := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Idle"})
|
||||
gone := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Gone", Active: true, Banned: true, DeletedAt: rosterDeleted()})
|
||||
all := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "All", Banned: true, DeletedAt: rosterDeleted()})
|
||||
odd := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Odd", Active: true})
|
||||
rosterInsert(t, gdb, rosterPerson{Tenant: "other", Name: "Zed"})
|
||||
key := func(id uint) string { return fmt.Sprint(id) }
|
||||
|
||||
t.Run("one call per page, fixed set and order", func(t *testing.T) {
|
||||
env.spy.takeStates()
|
||||
body, raw := rosterList(t, env, "")
|
||||
if len(body.Data) != 5 {
|
||||
t.Fatalf("rows = %d, want the five acme people including the soft-deleted: %s", len(body.Data), raw)
|
||||
}
|
||||
if calls := env.spy.takeStates(); !reflect.DeepEqual(calls, []int{5}) {
|
||||
t.Fatalf("ListRowStates calls = %v, want one call with the page's five records", calls)
|
||||
}
|
||||
want := map[string][]string{
|
||||
key(idle): {"disabled"},
|
||||
key(gone): {"deleted", "negative"},
|
||||
key(all): {"deleted", "negative", "disabled"},
|
||||
}
|
||||
if !reflect.DeepEqual(body.Meta.RowStates, want) {
|
||||
t.Fatalf("row_states = %v, want %v", body.Meta.RowStates, want)
|
||||
}
|
||||
// A row without a state, and one whose only value is unknown, are
|
||||
// left out; the unknown value is never sent.
|
||||
if _, ok := body.Meta.RowStates[key(plain)]; ok {
|
||||
t.Fatalf("a row without states is listed: %v", body.Meta.RowStates)
|
||||
}
|
||||
if _, ok := body.Meta.RowStates[key(odd)]; ok || strings.Contains(raw, "starred") {
|
||||
t.Fatalf("an unknown state was sent: %s", raw)
|
||||
}
|
||||
// States are not row data: a column key can never collide with them.
|
||||
for _, row := range body.Data {
|
||||
if _, ok := row["row_states"]; ok {
|
||||
t.Fatalf("row carries row_states: %v", row)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("each page gets its own call", func(t *testing.T) {
|
||||
env.spy.takeStates()
|
||||
body, _ := rosterList(t, env, "?search=Idle")
|
||||
if calls := env.spy.takeStates(); !reflect.DeepEqual(calls, []int{1}) {
|
||||
t.Fatalf("ListRowStates calls = %v", calls)
|
||||
}
|
||||
if !reflect.DeepEqual(body.Meta.RowStates, map[string][]string{key(idle): {"disabled"}}) {
|
||||
t.Fatalf("row_states = %v", body.Meta.RowStates)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no key when no row has a state", func(t *testing.T) {
|
||||
_, raw := rosterList(t, env, "?search=Plain")
|
||||
if strings.Contains(raw, "row_states") {
|
||||
t.Fatalf("row_states sent for a page without states: %s", raw)
|
||||
}
|
||||
env.spy.takeStates()
|
||||
// An empty page does not call the hook.
|
||||
_, raw = rosterList(t, env, "?search=nobody-matches-this")
|
||||
if calls := env.spy.takeStates(); len(calls) != 0 || strings.Contains(raw, "row_states") {
|
||||
t.Fatalf("empty page: calls=%v body=%s", calls, raw)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a controller without the hook sends no row_states", func(t *testing.T) {
|
||||
demo, demoDB := newActEnv(t)
|
||||
actInsert(t, demoDB, "plain", "acme")
|
||||
rec := demo.expect(t, http.StatusOK, http.MethodGet, "/acme/demo/gadgets", "", "bearer")
|
||||
if strings.Contains(rec.Body.String(), "row_states") || !strings.Contains(rec.Body.String(), `"total":1`) {
|
||||
t.Fatalf("list = %s", rec.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("the list schema carries the badge labels", func(t *testing.T) {
|
||||
rec := env.expect(t, http.StatusOK, http.MethodGet, rosterPeople+"/schema/list", "", "bearer")
|
||||
for _, want := range []string{`"rowStateDeleted":{"other":"Deleted"}`, `"rowStateNegative":{"other":"Blocked"}`, `"rowStateDisabled":{"other":"Not active"}`} {
|
||||
if !strings.Contains(rec.Body.String(), want) {
|
||||
t.Fatalf("list schema lacks %s: %s", want, rec.Body.String())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestSoftDeletedRecordSmoke checks D-13's framework side: a soft-deleted
|
||||
// record that the controller's list and form scopes include can be shown,
|
||||
// updated, targeted by bulk and record actions and permanently deleted
|
||||
// through the admin API.
|
||||
func TestSoftDeletedRecordSmoke(t *testing.T) {
|
||||
env, gdb := newRosterEnv(t)
|
||||
trashed := func(name string) uint {
|
||||
return rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: name, DeletedAt: rosterDeleted()})
|
||||
}
|
||||
exists := func(id uint) bool {
|
||||
var n int64
|
||||
if err := gdb.Unscoped().Model(&rosterPerson{}).Where("id = ?", id).Count(&n).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return n == 1
|
||||
}
|
||||
path := func(id uint) string { return fmt.Sprintf("%s/%d", rosterPeople, id) }
|
||||
|
||||
t.Run("show and update keep it soft-deleted", func(t *testing.T) {
|
||||
id := trashed("Trashed")
|
||||
rec := env.expect(t, http.StatusOK, http.MethodGet, path(id), "", "bearer")
|
||||
if !strings.Contains(rec.Body.String(), `"name":"Trashed"`) {
|
||||
t.Fatalf("show = %s", rec.Body.String())
|
||||
}
|
||||
rec = env.expect(t, http.StatusOK, http.MethodPut, path(id), `{"name":"Renamed","email":"renamed@example.test"}`, "bearer")
|
||||
if !strings.Contains(rec.Body.String(), `"name":"Renamed"`) {
|
||||
t.Fatalf("update = %s", rec.Body.String())
|
||||
}
|
||||
person := rosterLoad(t, gdb, id)
|
||||
if person.Name != "Renamed" || person.Email != "renamed@example.test" || !person.DeletedAt.Valid {
|
||||
t.Fatalf("stored person = %+v", person)
|
||||
}
|
||||
var rows int64
|
||||
if err := gdb.Unscoped().Model(&rosterPerson{}).Where("name = ?", "Renamed").Count(&rows).Error; err != nil || rows != 1 {
|
||||
t.Fatalf("rows named Renamed = %d err=%v, want the one updated row", rows, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("bulk and record actions reach it", func(t *testing.T) {
|
||||
id := trashed("Dormant")
|
||||
rec := env.expect(t, http.StatusOK, http.MethodPost, rosterPeople+"/bulk/activate", fmt.Sprintf(`{"ids":[%d]}`, id), "bearer")
|
||||
if result := rosterBulkResult(t, rec); result.Affected != 1 {
|
||||
t.Fatalf("bulk result = %+v", result)
|
||||
}
|
||||
if person := rosterLoad(t, gdb, id); !person.Active || !person.DeletedAt.Valid {
|
||||
t.Fatalf("after the bulk action: %+v", person)
|
||||
}
|
||||
banned := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Barred", Active: true, Banned: true, DeletedAt: rosterDeleted()})
|
||||
if got := rosterOffered(t, env, banned, "bearer"); !reflect.DeepEqual(got, []string{"reinstate"}) {
|
||||
t.Fatalf("offered = %v", got)
|
||||
}
|
||||
env.expect(t, http.StatusOK, http.MethodPost, path(banned)+"/actions/reinstate", `{}`, "bearer")
|
||||
if person := rosterLoad(t, gdb, banned); person.Banned || !person.DeletedAt.Valid {
|
||||
t.Fatalf("after the record action: %+v", person)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("the form delete removes it for good", func(t *testing.T) {
|
||||
id := trashed("Purged")
|
||||
rec := env.expect(t, http.StatusOK, http.MethodDelete, path(id), "", "bearer")
|
||||
if !strings.Contains(rec.Body.String(), `"deleted":1`) || exists(id) {
|
||||
t.Fatalf("delete = %s, row still exists: %v", rec.Body.String(), exists(id))
|
||||
}
|
||||
// A live person is removed for good as well: the hook is the
|
||||
// controller's rule.
|
||||
live := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Live"})
|
||||
env.expect(t, http.StatusOK, http.MethodDelete, path(live), "", "bearer")
|
||||
if exists(live) {
|
||||
t.Fatal("the live person still exists")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("bulk delete removes it for good", func(t *testing.T) {
|
||||
first, second := trashed("First"), rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Second"})
|
||||
rec := env.expect(t, http.StatusOK, http.MethodPost, rosterPeople+"/bulk-delete", fmt.Sprintf(`{"ids":[%d,%d]}`, first, second), "bearer")
|
||||
if !strings.Contains(rec.Body.String(), `"deleted":2`) || exists(first) || exists(second) {
|
||||
t.Fatalf("bulk delete = %s", rec.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a controller that hides soft-deleted rows behaves as before", func(t *testing.T) {
|
||||
// Outside the acme scope nothing changes either: a soft-deleted
|
||||
// person of another tenant stays invisible.
|
||||
foreign := rosterInsert(t, gdb, rosterPerson{Tenant: "other", Name: "Zed", DeletedAt: rosterDeleted()})
|
||||
env.expect(t, http.StatusNotFound, http.MethodGet, path(foreign), "", "bearer")
|
||||
env.expect(t, http.StatusNotFound, http.MethodPut, path(foreign), `{"name":"x"}`, "bearer")
|
||||
if !exists(foreign) || rosterLoad(t, gdb, foreign).Name != "Zed" {
|
||||
t.Fatal("an out-of-scope soft-deleted person was changed")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user