feat(12.1-01): declared bulk actions on admin lists
- pact.HasAdminBulkActions with AdminBulkAction, its input and result
- config_list.yaml bulkActions, compiled fail-loud, needs showCheckboxes
- POST .../{controller}/bulk/{action}: ids resolved and locked through the
list scope in one transaction; partial selection is 409
- list schema offers declared actions per principal, with confirm text
- admin SPA bulk actions menu with confirm, busy state and failure toasts
- acme.roster fixture, tracer test, OpenAPI, TS types, dist, READMEs, docs
This commit is contained in:
251
modules/cabana/phase121_actions_test.go
Normal file
251
modules/cabana/phase121_actions_test.go
Normal file
@@ -0,0 +1,251 @@
|
||||
package cabana_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||
)
|
||||
|
||||
const rosterPeople = "/acme/roster/people"
|
||||
|
||||
// rosterBulkNames returns the bulk action names the list schema offers auth.
|
||||
func rosterBulkNames(t *testing.T, env *rosterEnv, auth string) []string {
|
||||
t.Helper()
|
||||
rec := env.expect(t, http.StatusOK, http.MethodGet, rosterPeople+"/schema/list", "", auth)
|
||||
var list cabana.Envelope[cabana.ListSchema]
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &list); err != nil {
|
||||
t.Fatalf("list schema: %v\n%s", err, rec.Body.String())
|
||||
}
|
||||
names := make([]string, 0, len(list.Data.BulkActions))
|
||||
for _, action := range list.Data.BulkActions {
|
||||
names = append(names, action.Name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func rosterBulkResult(t *testing.T, rec *httptest.ResponseRecorder) cabana.BulkActionResult {
|
||||
t.Helper()
|
||||
var body cabana.Envelope[cabana.BulkActionResult]
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("bulk action body %s: %v", rec.Body.String(), err)
|
||||
}
|
||||
return body.Data
|
||||
}
|
||||
|
||||
// TestBulkActionTracer drives a declared bulk action through the assembled
|
||||
// router on PostgreSQL (D-09; T-12.1-01, T-12.1-02, T-12.1-03, T-12.1-05):
|
||||
// the per-principal list schema, the scoped and locked id resolution, the
|
||||
// loaded records the plugin receives, the action permission and the CSRF
|
||||
// header.
|
||||
func TestBulkActionTracer(t *testing.T) {
|
||||
env, gdb := newRosterEnv(t)
|
||||
ada := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Ada", Email: "ada@example.test"})
|
||||
bob := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Bob", Email: "bob@example.test"})
|
||||
cy := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Cy", Email: "cy@example.test", Active: true})
|
||||
foreign := rosterInsert(t, gdb, rosterPerson{Tenant: "other", Name: "Zed", Email: "zed@example.test"})
|
||||
const activate = rosterPeople + "/bulk/activate"
|
||||
ids := func(list ...uint) string {
|
||||
raw, _ := json.Marshal(map[string]any{"ids": list})
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
t.Run("list schema is per principal", func(t *testing.T) {
|
||||
if got := rosterBulkNames(t, env, "bearer"); !reflect.DeepEqual(got, []string{"delete", "activate", "archive"}) {
|
||||
t.Fatalf("full admin bulkActions = %v", got)
|
||||
}
|
||||
if got := rosterBulkNames(t, env, "limited"); !reflect.DeepEqual(got, []string{"delete", "archive"}) {
|
||||
t.Fatalf("limited admin bulkActions = %v", got)
|
||||
}
|
||||
rec := env.expect(t, http.StatusOK, http.MethodGet, rosterPeople+"/schema/list", "", "bearer")
|
||||
if !strings.Contains(rec.Body.String(), `"label":"Activate"`) || !strings.Contains(rec.Body.String(), `"confirm":"Activate the selected people?"`) {
|
||||
t.Fatalf("label and confirm are not localized: %s", rec.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("runs on loaded records in the list scope", func(t *testing.T) {
|
||||
rec := env.expect(t, http.StatusOK, http.MethodPost, activate, ids(bob, ada, bob), "bearer")
|
||||
if result := rosterBulkResult(t, rec); result.Affected != 2 || result.Message != "" {
|
||||
t.Fatalf("result = %+v", result)
|
||||
}
|
||||
if !rosterLoad(t, gdb, ada).Active || !rosterLoad(t, gdb, bob).Active {
|
||||
t.Fatal("the selected people were not activated")
|
||||
}
|
||||
calls := env.spy.takeBulk()
|
||||
if len(calls) != 1 || len(calls[0].Records) != 2 {
|
||||
t.Fatalf("calls = %+v", calls)
|
||||
}
|
||||
// The plugin gets loaded, locked records ordered by primary key and
|
||||
// no id list: AdminBulkActionInput has no other field.
|
||||
first, ok := calls[0].Records[0].(*rosterPerson)
|
||||
second, ok2 := calls[0].Records[1].(*rosterPerson)
|
||||
if !ok || !ok2 || first.ID != ada || second.ID != bob || first.Name != "Ada" || first.Tenant != "acme" {
|
||||
t.Fatalf("records = %+v %+v", calls[0].Records[0], calls[0].Records[1])
|
||||
}
|
||||
if n := reflect.TypeOf(calls[0]).NumField(); n != 1 {
|
||||
t.Fatalf("AdminBulkActionInput has %d fields, want only Records", n)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("affected may be lower than the selection", func(t *testing.T) {
|
||||
rec := env.expect(t, http.StatusOK, http.MethodPost, activate, ids(ada, cy), "bearer")
|
||||
if result := rosterBulkResult(t, rec); result.Affected != 0 {
|
||||
t.Fatalf("result = %+v", result)
|
||||
}
|
||||
env.spy.takeBulk()
|
||||
})
|
||||
|
||||
t.Run("server message is localized", func(t *testing.T) {
|
||||
spare := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Spare"})
|
||||
rec := env.expect(t, http.StatusOK, http.MethodPost, rosterPeople+"/bulk/archive", ids(spare), "limited")
|
||||
if result := rosterBulkResult(t, rec); result.Affected != 1 || result.Message != "The selected people were archived." {
|
||||
t.Fatalf("result = %+v", result)
|
||||
}
|
||||
if !rosterLoad(t, gdb, spare).DeletedAt.Valid {
|
||||
t.Fatal("archive did not soft-delete the person")
|
||||
}
|
||||
env.spy.takeBulk()
|
||||
})
|
||||
|
||||
t.Run("a partial selection is a 409 and changes nothing", func(t *testing.T) {
|
||||
fresh := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Fresh"})
|
||||
for _, other := range []uint{foreign, 999999} {
|
||||
rec := env.expect(t, http.StatusConflict, http.MethodPost, activate, ids(fresh, other), "bearer")
|
||||
actErrorCode(t, rec.Body.Bytes(), "conflict")
|
||||
}
|
||||
if rosterLoad(t, gdb, fresh).Active || rosterLoad(t, gdb, foreign).Active {
|
||||
t.Fatal("a refused selection changed a row")
|
||||
}
|
||||
if calls := env.spy.takeBulk(); len(calls) != 0 {
|
||||
t.Fatalf("action ran for a partial selection: %+v", calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a selection outside the scope is a no-op", func(t *testing.T) {
|
||||
rec := env.expect(t, http.StatusOK, http.MethodPost, activate, ids(foreign, 999999), "bearer")
|
||||
if result := rosterBulkResult(t, rec); result.Affected != 0 {
|
||||
t.Fatalf("result = %+v", result)
|
||||
}
|
||||
if rosterLoad(t, gdb, foreign).Active {
|
||||
t.Fatal("an out-of-scope row was activated")
|
||||
}
|
||||
if calls := env.spy.takeBulk(); len(calls) != 0 {
|
||||
t.Fatalf("action ran for out-of-scope ids: %+v", calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("body", func(t *testing.T) {
|
||||
for _, body := range []string{`{"ids":[]}`, `{}`, `{"ids":["nope"]}`, `{`} {
|
||||
rec := env.expect(t, http.StatusUnprocessableEntity, http.MethodPost, activate, body, "bearer")
|
||||
actErrorCode(t, rec.Body.Bytes(), "validation_failed")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("undeclared and reserved names are 404", func(t *testing.T) {
|
||||
for _, name := range []string{"missing", "delete", "create", "hidden"} {
|
||||
env.expect(t, http.StatusNotFound, http.MethodPost, rosterPeople+"/bulk/"+name, ids(ada), "bearer")
|
||||
}
|
||||
env.expect(t, http.StatusNotFound, http.MethodPost, "/acme/roster/nope/bulk/activate", ids(ada), "bearer")
|
||||
})
|
||||
|
||||
t.Run("action permission on top of the controller's", func(t *testing.T) {
|
||||
fresh := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Denied"})
|
||||
rec := env.expect(t, http.StatusForbidden, http.MethodPost, activate, ids(fresh), "limited")
|
||||
actErrorCode(t, rec.Body.Bytes(), "forbidden")
|
||||
if rosterLoad(t, gdb, fresh).Active {
|
||||
t.Fatal("a denied admin changed a row")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("cookie POSTs need X-Requested-With", func(t *testing.T) {
|
||||
fresh := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Cookie"})
|
||||
rec := env.expect(t, http.StatusForbidden, http.MethodPost, activate, ids(fresh), "cookie-only")
|
||||
actErrorCode(t, rec.Body.Bytes(), "forbidden")
|
||||
if rosterLoad(t, gdb, fresh).Active {
|
||||
t.Fatal("a request without the CSRF header changed a row")
|
||||
}
|
||||
env.expect(t, http.StatusOK, http.MethodPost, activate, ids(fresh), "cookie")
|
||||
if !rosterLoad(t, gdb, fresh).Active {
|
||||
t.Fatal("the cookie request with the header did not run")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("bulk delete is unchanged", func(t *testing.T) {
|
||||
spare := rosterInsert(t, gdb, rosterPerson{Tenant: "acme", Name: "Gone"})
|
||||
rec := env.expect(t, http.StatusOK, http.MethodPost, rosterPeople+"/bulk-delete", ids(spare), "bearer")
|
||||
if !strings.Contains(rec.Body.String(), `"deleted":1`) {
|
||||
t.Fatalf("bulk delete = %s", rec.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// rosterListFS is the roster fixture tree with config_list.yaml replaced.
|
||||
func rosterListFS(t *testing.T, list string) fstest.MapFS {
|
||||
t.Helper()
|
||||
columns, err := os.ReadFile(filepath.Join(rosterDir, "models/person/columns.yaml"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return fstest.MapFS{
|
||||
"controllers/people/config_list.yaml": &fstest.MapFile{Data: []byte(list)},
|
||||
"models/person/columns.yaml": &fstest.MapFile{Data: columns},
|
||||
}
|
||||
}
|
||||
|
||||
// TestListSchemaBulkActionsBoot checks the fail-loud compile of the
|
||||
// bulkActions key (D-09): every mistake names the plugin, controller and file.
|
||||
func TestListSchemaBulkActionsBoot(t *testing.T) {
|
||||
const head = "list: ~/plugins/acme/roster/models/person/columns.yaml\nmodelClass: Person\n"
|
||||
for _, tc := range []struct {
|
||||
name, yaml, want string
|
||||
}{
|
||||
{"unregistered", head + "showCheckboxes: true\nbulkActions: [activate, promote]\n", "bulkActions: unsupported action promote (want a bulk action the controller registers)"},
|
||||
{"reserved", head + "showCheckboxes: true\nbulkActions: [delete]\n", "bulkActions: unsupported action delete (want a bulk action the controller registers)"},
|
||||
{"duplicate", head + "showCheckboxes: true\nbulkActions: [activate, activate]\n", "bulkActions: duplicate action activate"},
|
||||
{"no checkboxes", head + "bulkActions: [activate]\n", "bulkActions needs showCheckboxes: true"},
|
||||
{"scalar", head + "showCheckboxes: true\nbulkActions: activate\n", "bulkActions must be a list of bulk action names the controller registers"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := cabana.CompileList("acme.roster", rosterController{}, rosterListFS(t, tc.yaml))
|
||||
if err == nil {
|
||||
t.Fatal("the list compiled")
|
||||
}
|
||||
for _, part := range []string{tc.want, "acme.roster", "acme.roster.people", "controllers/people/config_list.yaml"} {
|
||||
if !strings.Contains(err.Error(), part) {
|
||||
t.Fatalf("error %q does not name %q", err, part)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("declared order after the built-in delete", func(t *testing.T) {
|
||||
list, err := cabana.CompileList("acme.roster", rosterController{}, rosterListFS(t, head+"showCheckboxes: true\nbulkActions: [archive, activate]\n"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := fmt.Sprint(list.BulkActions)
|
||||
want := fmt.Sprint([]cabana.BulkAction{
|
||||
{Name: "delete", Label: "backend::lang.list.delete_selected"},
|
||||
{Name: "archive", Label: "acme.roster::lang.people.archive"},
|
||||
{Name: "activate", Label: "acme.roster::lang.people.activate", Confirm: "acme.roster::lang.people.activate_confirm"},
|
||||
})
|
||||
if got != want {
|
||||
t.Fatalf("bulkActions = %s, want %s", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a list without bulkActions is unchanged", func(t *testing.T) {
|
||||
list, err := cabana.CompileList("acme.roster", rosterController{}, rosterListFS(t, head+"showCheckboxes: true\n"))
|
||||
if err != nil || len(list.BulkActions) != 1 || list.BulkActions[0].Name != "delete" {
|
||||
t.Fatalf("bulkActions = %+v err=%v", list.BulkActions, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user