Files
summercms/modules/cabana/catalog_smoke_test.go
Jakub Zych 323b6d684a test(18.1-06): taglist and flat reorder unit matrices, partitioned flat smoke and admin specs (task 2)
- TestTaglistFieldValidate: both modes, trim, dedupe, empty, null, wrong shapes,
  customTags false, required; TestTaglistFieldHydrate: stored NULL and bad text as []
- TestReorderFlatNodes: parent 422, duplicate and zero id 422, missing or extra 409,
  no depth or cycle check; TestReorderFlatCompile checks the localized list schema
- TestReorderFlatSmoke/partitioned: flat reorder within one partition on Postgres
- vitest: script markup as text, Backspace, blur, paste, datalist, customTags,
  string mode; flat ArrowUp/ArrowDown and the before/after drop zones
- export_test.go wrappers for the taglist save check, hydration and node check
- check-phase18.1.sh requires the new names in the go, admin and postgres stages
2026-10-14 20:38:52 +02:00

543 lines
21 KiB
Go

package cabana_test
import (
"encoding/json"
"fmt"
"io/fs"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"testing/fstest"
"time"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/compass"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
)
// catalogDir is the neutral acme.catalog fixture plugin tree of the Phase
// 18.1 cabana features (D-17): the taglist field and the flat reorder. No
// application names appear in framework tests.
const catalogDir = "testdata/catalog"
// catalogBadge is the fixture model of the badges controller: a name, a
// taglist in array mode over a JSON column, a taglist in string mode over a
// string column and a sort column. The table has no parent_id column.
type catalogBadge struct {
ID uint `gorm:"column:id;primaryKey"`
Name string `gorm:"column:name"`
Tags lagoon.Jsonable[[]string] `gorm:"column:tags;type:text"`
Keywords string `gorm:"column:keywords"`
SortOrder int `gorm:"column:sort_order"`
}
func (catalogBadge) TableName() string { return "acme_catalog_badges" }
func (catalogBadge) Fillable() []string {
return []string{"name", "tags", "keywords", "sort_order"}
}
func (catalogBadge) Rules() map[string]string { return map[string]string{"name": "required"} }
// catalogModels are the fixture tables, dropped and recreated per env.
func catalogModels() []any { return []any{&catalogBadge{}} }
// catalogPlugin registers the badges controller.
type catalogPlugin struct{}
func (catalogPlugin) ID() string { return "acme.catalog" }
func (catalogPlugin) Requires() []string { return nil }
func (catalogPlugin) Register(*backpack.App) error { return nil }
func (catalogPlugin) Boot(*backpack.App) error { return nil }
func (catalogPlugin) AdminFS() fs.FS { return os.DirFS(catalogDir) }
func (catalogPlugin) AdminControllers() []pact.AdminController {
return []pact.AdminController{catalogBadges{}}
}
func (catalogPlugin) Permissions() []pact.Permission {
return []pact.Permission{{Code: "acme.catalog.access", Roles: []string{"developer"}}}
}
// catalogBadges is the badges controller.
type catalogBadges struct{}
func (catalogBadges) ID() string { return "acme.catalog.badges" }
func (catalogBadges) ModelName() string { return "Badge" }
func (catalogBadges) ConfigDir() string { return "controllers/badges" }
func (catalogBadges) RequiredPermissions() []string { return []string{"acme.catalog.access"} }
func (catalogBadges) NewRecord() any { return &catalogBadge{} }
// catalogFSPlugin is the acme.catalog fixture served from another tree,
// optionally with another record model and settings pages.
type catalogFSPlugin struct {
catalogPlugin
fsys fs.FS
newRecord func() any
settings []pact.SettingsItem
}
func (p catalogFSPlugin) Settings() []pact.SettingsItem { return p.settings }
func (p catalogFSPlugin) AdminFS() fs.FS { return p.fsys }
func (p catalogFSPlugin) AdminControllers() []pact.AdminController {
if p.newRecord != nil {
return []pact.AdminController{catalogBadgesWith{newRecord: p.newRecord}}
}
return p.catalogPlugin.AdminControllers()
}
// catalogBadgesWith is the badges controller over another record model.
type catalogBadgesWith struct {
catalogBadges
newRecord func() any
}
func (c catalogBadgesWith) NewRecord() any { return c.newRecord() }
// catalogOverlay is the acme.catalog fixture tree with files replaced; an
// empty value removes the file.
func catalogOverlay(t *testing.T, files map[string]string) fstest.MapFS {
t.Helper()
out := fstest.MapFS{}
err := fs.WalkDir(os.DirFS(catalogDir), ".", func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return err
}
raw, err := os.ReadFile(filepath.Join(catalogDir, path))
if err != nil {
return err
}
out[path] = &fstest.MapFile{Data: raw}
return nil
})
if err != nil {
t.Fatal(err)
}
for path, data := range files {
if data == "" {
delete(out, path)
continue
}
out[path] = &fstest.MapFile{Data: []byte(data)}
}
return out
}
// catalogFile is one file of the fixture tree.
func catalogFile(t *testing.T, path string) string {
t.Helper()
raw, err := os.ReadFile(filepath.Join(catalogDir, path))
if err != nil {
t.Fatal(err)
}
return string(raw)
}
// activateCatalog boots the admin of p without a database.
func activateCatalog(t *testing.T, p party.Plugin) error {
t.Helper()
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: cabana-catalog-boot\n"), 0o644); err != nil {
t.Fatal(err)
}
cfg, err := compass.Open(compass.Options{Dir: dir, Environ: []string{"SUMMER_ENV=development", "SUMMER_ADMIN__JWT__SECRET=" + adminTestSecret}})
if err != nil {
t.Fatal(err)
}
_, err = cabana.Activate(backpack.New(cfg), []party.Plugin{p})
return err
}
// newCatalogEnv boots the acme.catalog fixture alone on Postgres and signs
// an administrator in.
func newCatalogEnv(t *testing.T) *conformEnv {
t.Helper()
gdb := adminGorm(t)
if err := gdb.Migrator().DropTable(catalogModels()...); err != nil {
t.Fatal(err)
}
if err := gdb.AutoMigrate(catalogModels()...); err != nil {
t.Fatal(err)
}
login := fmt.Sprintf("catalog-%d", time.Now().UnixNano())
insertAdmin(t, gdb, login, login+"@example.test", adminTestPassword, true, false)
h, err := paymentsAssemble(t, gdb, []party.Plugin{catalogPlugin{}})
if err != nil {
t.Fatal(err)
}
env := &conformEnv{h: h, db: gdb, login: login}
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())
}
return env
}
// storedTagsText is the tags column of badge id as the database holds it.
func storedTagsText(t *testing.T, e *conformEnv, id uint) *string {
t.Helper()
var text *string
if err := e.db.Raw("SELECT tags FROM acme_catalog_badges WHERE id = ?", id).Scan(&text).Error; err != nil {
t.Fatal(err)
}
return text
}
// expectCatalog422 posts body to the badges controller and wants a 422
// whose detail on field contains message.
func expectCatalog422(t *testing.T, e *conformEnv, body map[string]any, field, message string) {
t.Helper()
rec := e.send(t, http.MethodPost, "/acme/catalog/badges", body, true)
if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("want 422 on %s, status=%d body=%s", field, rec.Code, rec.Body.String())
}
var out struct {
Error struct {
Details map[string][]string `json:"details"`
} `json:"error"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatalf("422 body: %v\n%s", err, rec.Body.String())
}
msgs := out.Error.Details[field]
if len(msgs) != 1 || !strings.Contains(msgs[0], message) {
t.Fatalf("422 details on %s = %v, want %q; body=%s", field, msgs, message, rec.Body.String())
}
}
// TestTaglistFieldSmoke checks the taglist save path end to end on
// Postgres: an array-mode list is trimmed, emptied of blanks and duplicates
// and stored as exactly that JSON array, the record GET serves the list,
// null stores [], and a value that is not a list of strings is a 422 on the
// field with taglist.invalid (Polish, the request locale of the env). A
// string-mode value is split on its separator, normalised and stored joined
// by it; null stores an empty string. With customTags false a tag outside
// the options is a 422 with taglist.not_allowed naming the tag.
func TestTaglistFieldSmoke(t *testing.T) {
e := newCatalogEnv(t)
rec := e.send(t, http.MethodPost, "/acme/catalog/badges", map[string]any{"name": "First", "tags": []any{" alpha ", "beta", "alpha", ""}}, true)
if rec.Code != http.StatusCreated {
t.Fatalf("create status=%d body=%s", rec.Code, rec.Body.String())
}
id := dataID(t, rec.Body.Bytes())
if text := storedTagsText(t, e, id); text == nil || *text != `["alpha","beta"]` {
t.Fatalf("stored tags = %v, want [\"alpha\",\"beta\"]", text)
}
show := e.send(t, http.MethodGet, fmt.Sprintf("/acme/catalog/badges/%d", id), nil, true)
if show.Code != http.StatusOK {
t.Fatalf("show status=%d body=%s", show.Code, show.Body.String())
}
var record struct {
Data map[string]json.RawMessage `json:"data"`
}
if err := json.Unmarshal(show.Body.Bytes(), &record); err != nil {
t.Fatal(err)
}
if got := string(record.Data["tags"]); got != `["alpha","beta"]` {
t.Fatalf("served tags = %s, want [\"alpha\",\"beta\"]", got)
}
expectCatalog422(t, e, map[string]any{"name": "Bad", "tags": "alpha"}, "tags", "Pole tags musi być listą tagów.")
expectCatalog422(t, e, map[string]any{"name": "Bad", "tags": []any{1}}, "tags", "Pole tags musi być listą tagów.")
rec = e.send(t, http.MethodPost, "/acme/catalog/badges", map[string]any{"name": "Empty", "tags": nil}, true)
if rec.Code != http.StatusCreated {
t.Fatalf("null create status=%d body=%s", rec.Code, rec.Body.String())
}
if text := storedTagsText(t, e, dataID(t, rec.Body.Bytes())); text == nil || *text != `[]` {
t.Fatalf("null stored %v, want []", text)
}
// String mode, separator space: split on whitespace, normalised, joined
// by one space, served as stored.
rec = e.send(t, http.MethodPost, "/acme/catalog/badges", map[string]any{"name": "Words", "keywords": "x y x"}, true)
if rec.Code != http.StatusCreated {
t.Fatalf("keywords create status=%d body=%s", rec.Code, rec.Body.String())
}
id = dataID(t, rec.Body.Bytes())
var badge catalogBadge
if err := e.db.First(&badge, id).Error; err != nil {
t.Fatal(err)
}
if badge.Keywords != "x y" {
t.Fatalf("stored keywords = %q, want \"x y\"", badge.Keywords)
}
show = e.send(t, http.MethodGet, fmt.Sprintf("/acme/catalog/badges/%d", id), nil, true)
if err := json.Unmarshal(show.Body.Bytes(), &record); err != nil {
t.Fatal(err)
}
if got := string(record.Data["keywords"]); got != `"x y"` {
t.Fatalf("served keywords = %s, want \"x y\"", got)
}
rec = e.send(t, http.MethodPut, fmt.Sprintf("/acme/catalog/badges/%d", id), map[string]any{"name": "Words", "keywords": nil}, true)
if rec.Code != http.StatusOK {
t.Fatalf("keywords null status=%d body=%s", rec.Code, rec.Body.String())
}
if err := e.db.First(&badge, id).Error; err != nil {
t.Fatal(err)
}
if badge.Keywords != "" {
t.Fatalf("null keywords stored %q, want empty", badge.Keywords)
}
expectCatalog422(t, e, map[string]any{"name": "Bad", "keywords": []any{"x"}}, "keywords", "Pole keywords musi być listą tagów.")
// customTags false: only the options are accepted.
expectCatalog422(t, e, map[string]any{"name": "Bad", "tags": []any{"alpha", "delta"}}, "tags", "Tag delta jest niedozwolony.")
}
// catalogReorder is the data of the badges reorder routes.
type catalogReorder struct {
Data struct {
Flat bool `json:"flat"`
Nodes []struct {
ID uint `json:"id"`
ParentID *uint `json:"parent_id"`
SortOrder int64 `json:"sort_order"`
Name string `json:"name"`
} `json:"nodes"`
} `json:"data"`
}
// TestReorderFlatSmoke checks the flat reorder routes on Postgres: the GET
// answers flat true with parent_id null on every node, the POST stores the
// submitted sort values (only the sort column) and answers the new order, a
// node with a parent is a 422 on nodes and a missing node a 409. The
// partitioned subtest runs the same rules within one partition.
func TestReorderFlatSmoke(t *testing.T) {
e := newCatalogEnv(t)
ids := make([]uint, 0, 3)
for i, name := range []string{"One", "Two", "Three"} {
badge := catalogBadge{Name: name, SortOrder: i + 1}
if err := e.db.Create(&badge).Error; err != nil {
t.Fatal(err)
}
ids = append(ids, badge.ID)
}
rec := e.send(t, http.MethodGet, "/acme/catalog/badges/reorder", nil, true)
if rec.Code != http.StatusOK {
t.Fatalf("reorder GET status=%d body=%s", rec.Code, rec.Body.String())
}
var got catalogReorder
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatal(err)
}
if !got.Data.Flat || len(got.Data.Nodes) != 3 || got.Data.Nodes[0].Name != "One" {
t.Fatalf("reorder GET = %s", rec.Body.String())
}
if !strings.Contains(rec.Body.String(), `"flat":true`) {
t.Fatalf("reorder GET lacks flat: %s", rec.Body.String())
}
for _, node := range got.Data.Nodes {
if node.ParentID != nil {
t.Fatalf("flat node %d has parent %d", node.ID, *node.ParentID)
}
}
reversed := []map[string]any{
{"id": ids[2], "parent_id": nil, "sort_order": 1},
{"id": ids[1], "parent_id": nil, "sort_order": 2},
{"id": ids[0], "parent_id": nil, "sort_order": 3},
}
rec = e.send(t, http.MethodPost, "/acme/catalog/badges/reorder", map[string]any{"nodes": reversed}, true)
if rec.Code != http.StatusOK {
t.Fatalf("reorder POST status=%d body=%s", rec.Code, rec.Body.String())
}
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatal(err)
}
if len(got.Data.Nodes) != 3 || got.Data.Nodes[0].ID != ids[2] || got.Data.Nodes[2].ID != ids[0] || !got.Data.Flat {
t.Fatalf("reorder POST answer = %s", rec.Body.String())
}
for i, id := range []uint{ids[2], ids[1], ids[0]} {
var badge catalogBadge
if err := e.db.First(&badge, id).Error; err != nil {
t.Fatal(err)
}
if badge.SortOrder != i+1 {
t.Fatalf("badge %d sort_order = %d, want %d", id, badge.SortOrder, i+1)
}
}
nested := []map[string]any{
{"id": ids[0], "parent_id": nil, "sort_order": 1},
{"id": ids[1], "parent_id": ids[0], "sort_order": 2},
{"id": ids[2], "parent_id": nil, "sort_order": 3},
}
rec = e.send(t, http.MethodPost, "/acme/catalog/badges/reorder", map[string]any{"nodes": nested}, true)
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), `"nodes"`) || !strings.Contains(rec.Body.String(), "Kolejność płaska nie ma elementów nadrzędnych.") {
t.Fatalf("flat parent status=%d body=%s", rec.Code, rec.Body.String())
}
rec = e.send(t, http.MethodPost, "/acme/catalog/badges/reorder", map[string]any{"nodes": reversed[:2]}, true)
if rec.Code != http.StatusConflict {
t.Fatalf("missing node status=%d body=%s", rec.Code, rec.Body.String())
}
t.Run("partitioned", reorderFlatPartition)
}
// shelfPin is the record of the partitioned flat reorder fixture
// (acme.shelf): a name, the shelf it belongs to (the partition) and a sort
// column, and no parent_id column, so its reorder is flat.
type shelfPin struct {
ID uint `gorm:"column:id;primaryKey"`
Name string `gorm:"column:name"`
ShelfID *uint `gorm:"column:shelf_id"`
SortOrder int `gorm:"column:sort_order"`
}
func (shelfPin) TableName() string { return "acme_shelf_pins" }
func (shelfPin) Fillable() []string { return []string{"name", "sort_order"} }
func (shelfPin) Rules() map[string]string { return map[string]string{"name": "required"} }
// shelfShelf is the belongsTo target of the pins' shelf field.
type shelfShelf struct {
ID uint `gorm:"column:id;primaryKey"`
Title string `gorm:"column:title"`
}
func (shelfShelf) TableName() string { return "acme_shelf_shelves" }
// shelfFS is the acme.shelf fixture tree: the pins controller with a flat
// reorder partitioned by shelf.
var shelfFS = fstest.MapFS{
"controllers/pins/config_list.yaml": {Data: []byte("list: ~/plugins/acme/shelf/models/pin/columns.yaml\nmodelClass: Pin\ntitle: Pins\nrecordUrl: acme/shelf/pins/update/:id\nrecordsPerPage: 20\ntoolbar:\n buttons: [create, reorder]\n")},
"controllers/pins/config_form.yaml": {Data: []byte("name: Pin\nform: ~/plugins/acme/shelf/models/pin/fields.yaml\nmodelClass: Pin\ndefaultRedirect: acme/shelf/pins\n")},
"controllers/pins/config_reorder.yaml": {Data: []byte("title: Reorder pins\nnameFrom: name\nmodelClass: Pin\npartition:\n column: shelf_id\n relation: shelf\n label: Shelf\n")},
"models/pin/columns.yaml": {Data: []byte("columns:\n name:\n label: Name\n sort_order:\n label: Order\n")},
"models/pin/fields.yaml": {Data: []byte("fields:\n name:\n label: Name\n type: text\n shelf:\n label: Shelf\n type: relation\n nameFrom: title\n")},
}
// shelfPlugin registers the pins controller.
type shelfPlugin struct{}
func (shelfPlugin) ID() string { return "acme.shelf" }
func (shelfPlugin) Requires() []string { return nil }
func (shelfPlugin) Register(*backpack.App) error { return nil }
func (shelfPlugin) Boot(*backpack.App) error { return nil }
func (shelfPlugin) AdminFS() fs.FS { return shelfFS }
func (shelfPlugin) AdminControllers() []pact.AdminController {
return []pact.AdminController{shelfPins{}}
}
func (shelfPlugin) Permissions() []pact.Permission {
return []pact.Permission{{Code: "acme.shelf.access", Roles: []string{"developer"}}}
}
// shelfPins is the pins controller.
type shelfPins struct{}
func (shelfPins) ID() string { return "acme.shelf.pins" }
func (shelfPins) ModelName() string { return "Pin" }
func (shelfPins) ConfigDir() string { return "controllers/pins" }
func (shelfPins) RequiredPermissions() []string { return []string{"acme.shelf.access"} }
func (shelfPins) NewRecord() any { return &shelfPin{} }
// AdminFieldRelations binds the shelf relation field, the reorder partition.
func (shelfPins) AdminFieldRelations() []cabana.FieldRelationContract {
return []cabana.FieldRelationContract{{Field: "shelf", Kind: "belongsTo", NewRelated: func() any { return &shelfShelf{} }, ForeignKey: "shelf_id"}}
}
// newShelfEnv is a logged-in admin over the acme.shelf fixture with fresh
// tables.
func newShelfEnv(t *testing.T) *conformEnv {
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-%d", time.Now().UnixNano())
insertAdmin(t, gdb, login, login+"@example.test", adminTestPassword, true, false)
h, err := paymentsAssemble(t, gdb, []party.Plugin{shelfPlugin{}})
if err != nil {
t.Fatal(err)
}
env := &conformEnv{h: h, db: gdb, login: login}
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())
}
return env
}
// reorderFlatPartition checks a partitioned flat reorder on Postgres (plan
// 18.1-06, a subtest of TestReorderFlatSmoke): the GET of one shelf answers flat true with only
// that shelf's pins and the partition options, the POST writes only the sort
// column (the shelf and the other shelf's rows are untouched), a pin of the
// other shelf is the 409 stale set and a parent is a 422 on nodes.
func reorderFlatPartition(t *testing.T) {
e := newShelfEnv(t)
top, low := shelfShelf{Title: "Top"}, shelfShelf{Title: "Low"}
for _, s := range []*shelfShelf{&top, &low} {
if err := e.db.Create(s).Error; err != nil {
t.Fatal(err)
}
}
pins := []shelfPin{{Name: "A", ShelfID: &top.ID, SortOrder: 1}, {Name: "B", ShelfID: &top.ID, SortOrder: 2}, {Name: "X", ShelfID: &low.ID, SortOrder: 7}}
for i := range pins {
if err := e.db.Create(&pins[i]).Error; err != nil {
t.Fatal(err)
}
}
a, b, x := pins[0].ID, pins[1].ID, pins[2].ID
rec := e.send(t, http.MethodGet, fmt.Sprintf("/acme/shelf/pins/reorder?partition=%d", top.ID), nil, true)
if rec.Code != http.StatusOK {
t.Fatalf("GET status=%d body=%s", rec.Code, rec.Body.String())
}
var got struct {
Data struct {
Flat bool `json:"flat"`
Partition *struct {
Label string `json:"label"`
Value *uint `json:"value"`
Options []struct {
Label string `json:"label"`
} `json:"options"`
} `json:"partition"`
Nodes []struct {
ID uint `json:"id"`
ParentID *uint `json:"parent_id"`
} `json:"nodes"`
} `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatal(err)
}
d := got.Data
if !d.Flat || d.Partition == nil || d.Partition.Label != "Shelf" || d.Partition.Value == nil || *d.Partition.Value != top.ID || len(d.Partition.Options) != 2 {
t.Fatalf("GET = %s", rec.Body.String())
}
if len(d.Nodes) != 2 || d.Nodes[0].ID != a || d.Nodes[1].ID != b || d.Nodes[0].ParentID != nil || d.Nodes[1].ParentID != nil {
t.Fatalf("GET nodes = %s", rec.Body.String())
}
post := func(nodes ...map[string]any) int {
return e.send(t, http.MethodPost, "/acme/shelf/pins/reorder", map[string]any{"partition": top.ID, "nodes": nodes}, true).Code
}
n := func(id uint, parent any, sort int) map[string]any {
return map[string]any{"id": id, "parent_id": parent, "sort_order": sort}
}
if code := post(n(b, nil, 1), n(a, nil, 2)); code != http.StatusOK {
t.Fatalf("POST status=%d", code)
}
rows := map[uint]shelfPin{}
var all []shelfPin
if err := e.db.Find(&all).Error; err != nil {
t.Fatal(err)
}
for _, p := range all {
rows[p.ID] = p
}
if rows[b].SortOrder != 1 || rows[a].SortOrder != 2 || *rows[a].ShelfID != top.ID || *rows[b].ShelfID != top.ID || rows[x].SortOrder != 7 || *rows[x].ShelfID != low.ID {
t.Fatalf("rows after POST = %+v", rows)
}
if code := post(n(b, nil, 1), n(a, nil, 2), n(x, nil, 3)); code != http.StatusConflict {
t.Fatalf("pin of the other shelf status=%d, want 409", code)
}
if code := post(n(b, nil, 1), n(a, b, 2)); code != http.StatusUnprocessableEntity {
t.Fatalf("parent status=%d, want 422", code)
}
}