feat(12.2-03): add parent-scoped child show, update, delete and pivot routes
- loadChild finds a child with one query carrying the parent predicate; a foreign child is 404
- GET/PUT .../records/{child} and POST .../delete (all or nothing) per relation kind
- hasMany link adopts NULL-key rows and unlink clears the key; pending created children are never candidates
- link accepts pivot values for one id through the pivot.form whitelist; GET/PUT .../pivot/{child}
- Link and Unlink share linkRelated/unlinkRelated for the deferred commit
This commit is contained in:
@@ -4,7 +4,9 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -78,3 +80,154 @@ func TestRelationChildSmokeCreate(t *testing.T) {
|
||||
t.Fatalf("create under a missing parent status=%d body=%s", missing.Code, missing.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// relationPath is the admin API path of a gadget relation route.
|
||||
func relationPath(gadget uint, relation, rest string) string {
|
||||
return fmt.Sprintf("/acme/conform/gadgets/%d/relations/%s%s", gadget, relation, rest)
|
||||
}
|
||||
|
||||
// expectStatus fails the test unless rec has the status.
|
||||
func expectStatus(t *testing.T, what string, rec *httptest.ResponseRecorder, status int) {
|
||||
t.Helper()
|
||||
if rec.Code != status {
|
||||
t.Fatalf("%s status=%d want %d body=%s", what, rec.Code, status, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// createPart creates a part under a gadget through the relation manager.
|
||||
func (e *conformEnv) createPart(t *testing.T, gadget uint, label string) uint {
|
||||
t.Helper()
|
||||
rec := e.send(t, http.MethodPost, relationPath(gadget, "parts", "/records"), map[string]any{"label": label}, true)
|
||||
expectStatus(t, "create part", rec, http.StatusCreated)
|
||||
return dataID(t, rec.Body.Bytes())
|
||||
}
|
||||
|
||||
// TestRelationChildSmokeScope checks D-15 on every child route: a child of
|
||||
// another parent, a member linked to another parent and a parent hidden by
|
||||
// FormExtendQuery all answer 404, and a delete naming one foreign child
|
||||
// deletes nothing. It also walks hasMany link, unlink and delete.
|
||||
func TestRelationChildSmokeScope(t *testing.T) {
|
||||
env := newConformEnv(t)
|
||||
env.loginAs(t, env.login)
|
||||
a := env.createGadget(t, "a-"+env.stamp, "")
|
||||
b := env.createGadget(t, "b-"+env.stamp, "")
|
||||
pa := env.createPart(t, a, "pa")
|
||||
pb := env.createPart(t, b, "pb")
|
||||
|
||||
expectStatus(t, "show foreign child", env.send(t, http.MethodGet, relationPath(a, "parts", fmt.Sprintf("/records/%d", pb)), nil, true), http.StatusNotFound)
|
||||
expectStatus(t, "update foreign child", env.send(t, http.MethodPut, relationPath(a, "parts", fmt.Sprintf("/records/%d", pb)), map[string]any{"label": "stolen"}, true), http.StatusNotFound)
|
||||
expectStatus(t, "delete foreign child", env.send(t, http.MethodPost, relationPath(a, "parts", "/delete"), map[string]any{"ids": []uint{pa, pb}}, true), http.StatusNotFound)
|
||||
for _, id := range []uint{pa, pb} {
|
||||
var n int64
|
||||
if err := env.db.Model(&conformPart{}).Where("id = ?", id).Count(&n).Error; err != nil || n != 1 {
|
||||
t.Fatalf("part %d count=%d err=%v after a refused delete", id, n, err)
|
||||
}
|
||||
}
|
||||
shown := env.send(t, http.MethodGet, relationPath(a, "parts", fmt.Sprintf("/records/%d", pa)), nil, true)
|
||||
expectStatus(t, "show own child", shown, http.StatusOK)
|
||||
updated := env.send(t, http.MethodPut, relationPath(a, "parts", fmt.Sprintf("/records/%d", pa)), map[string]any{"label": "pa-renamed", "gadget_id": b}, true)
|
||||
expectStatus(t, "update own child", updated, http.StatusOK)
|
||||
if owner := env.partOwner(t, pa); owner == nil || *owner != a {
|
||||
t.Fatalf("update moved the part to %v", owner)
|
||||
}
|
||||
|
||||
// A member linked to B has no pivot row under A.
|
||||
expectStatus(t, "link member to B", env.send(t, http.MethodPost, relationPath(b, "members", "/link"), map[string]any{"ids": []uint{env.memberID}}, true), http.StatusOK)
|
||||
expectStatus(t, "pivot of B's member through A", env.send(t, http.MethodGet, relationPath(a, "members", fmt.Sprintf("/pivot/%d", env.memberID)), nil, true), http.StatusNotFound)
|
||||
expectStatus(t, "pivot update of B's member through A", env.send(t, http.MethodPut, relationPath(a, "members", fmt.Sprintf("/pivot/%d", env.memberID)), map[string]any{"note": "x"}, true), http.StatusNotFound)
|
||||
|
||||
// A parent FormExtendQuery hides is 404 on every child route.
|
||||
hidden := conformGadget{Name: "hidden-" + env.stamp}
|
||||
if err := env.db.Create(&hidden).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ph := conformPart{GadgetID: &hidden.ID, Label: "ph"}
|
||||
if err := env.db.Create(&ph).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expectStatus(t, "hidden parent linked list", env.send(t, http.MethodGet, relationPath(hidden.ID, "parts", ""), nil, true), http.StatusNotFound)
|
||||
expectStatus(t, "hidden parent child", env.send(t, http.MethodGet, relationPath(hidden.ID, "parts", fmt.Sprintf("/records/%d", ph.ID)), nil, true), http.StatusNotFound)
|
||||
expectStatus(t, "hidden parent create", env.send(t, http.MethodPost, relationPath(hidden.ID, "parts", "/records"), map[string]any{"label": "x"}, true), http.StatusNotFound)
|
||||
|
||||
// hasMany link adopts a free part, unlink frees it, delete removes it.
|
||||
free := conformPart{Label: "free"}
|
||||
if err := env.db.Create(&free).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
candidates := env.send(t, http.MethodGet, relationPath(a, "parts", "/candidates"), nil, true)
|
||||
expectStatus(t, "candidates", candidates, http.StatusOK)
|
||||
if !strings.Contains(candidates.Body.String(), `"label":"free"`) || strings.Contains(candidates.Body.String(), `"label":"pb"`) {
|
||||
t.Fatalf("hasMany candidates = %s", candidates.Body.String())
|
||||
}
|
||||
expectStatus(t, "link owned part of B", env.send(t, http.MethodPost, relationPath(a, "parts", "/link"), map[string]any{"ids": []uint{pb}}, true), http.StatusUnprocessableEntity)
|
||||
expectStatus(t, "link free part", env.send(t, http.MethodPost, relationPath(a, "parts", "/link"), map[string]any{"ids": []uint{free.ID}}, true), http.StatusOK)
|
||||
if owner := env.partOwner(t, free.ID); owner == nil || *owner != a {
|
||||
t.Fatalf("linked part owner = %v, want %d", owner, a)
|
||||
}
|
||||
expectStatus(t, "unlink part", env.send(t, http.MethodPost, relationPath(a, "parts", "/unlink"), map[string]any{"ids": []uint{free.ID, pb}}, true), http.StatusOK)
|
||||
if owner := env.partOwner(t, free.ID); owner != nil {
|
||||
t.Fatalf("unlinked part owner = %d", *owner)
|
||||
}
|
||||
if owner := env.partOwner(t, pb); owner == nil || *owner != b {
|
||||
t.Fatalf("unlink through A freed B's part: %v", owner)
|
||||
}
|
||||
expectStatus(t, "delete own child", env.send(t, http.MethodPost, relationPath(a, "parts", "/delete"), map[string]any{"ids": []uint{pa}}, true), http.StatusOK)
|
||||
var n int64
|
||||
if err := env.db.Model(&conformPart{}).Where("id = ?", pa).Count(&n).Error; err != nil || n != 0 {
|
||||
t.Fatalf("deleted part count=%d err=%v", n, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRelationChildSmokePivot links a member with a pivot note (D-14): the
|
||||
// note is stored, RelationBeforeLink still stamps its hook column, and pivot
|
||||
// keys outside the pivot form are refused on link and on the pivot route.
|
||||
func TestRelationChildSmokePivot(t *testing.T) {
|
||||
env := newConformEnv(t)
|
||||
env.loginAs(t, env.login)
|
||||
g := env.createGadget(t, "g-"+env.stamp, "")
|
||||
second := conformMember{Email: "second-" + env.stamp + "@example.test"}
|
||||
if err := env.db.Create(&second).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for name, body := range map[string]map[string]any{
|
||||
"foreign key": {"ids": []uint{env.memberID}, "pivot": map[string]any{"gadget_id": 99}},
|
||||
"hook column": {"ids": []uint{env.memberID}, "pivot": map[string]any{"stamp": "forged"}},
|
||||
"two ids": {"ids": []uint{env.memberID, second.ID}, "pivot": map[string]any{"note": "x"}},
|
||||
"hasMany link": nil,
|
||||
} {
|
||||
if body == nil {
|
||||
rec := env.send(t, http.MethodPost, relationPath(g, "parts", "/link"), map[string]any{"ids": []uint{1}, "pivot": map[string]any{"note": "x"}}, true)
|
||||
expectStatus(t, name, rec, http.StatusUnprocessableEntity)
|
||||
continue
|
||||
}
|
||||
expectStatus(t, name, env.send(t, http.MethodPost, relationPath(g, "members", "/link"), body, true), http.StatusUnprocessableEntity)
|
||||
}
|
||||
var count int64
|
||||
if err := env.db.Model(&conformGadgetMember{}).Where("gadget_id = ?", g).Count(&count).Error; err != nil || count != 0 {
|
||||
t.Fatalf("refused links wrote %d pivot rows (%v)", count, err)
|
||||
}
|
||||
|
||||
expectStatus(t, "link with note", env.send(t, http.MethodPost, relationPath(g, "members", "/link"), map[string]any{"ids": []uint{env.memberID}, "pivot": map[string]any{"note": "hello"}}, true), http.StatusOK)
|
||||
var row conformGadgetMember
|
||||
if err := env.db.Where("gadget_id = ? AND member_id = ?", g, env.memberID).Take(&row).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if row.Note != "hello" || row.Stamp != "linked" {
|
||||
t.Fatalf("pivot row = %+v, want note hello and stamp linked", row)
|
||||
}
|
||||
|
||||
shown := env.send(t, http.MethodGet, relationPath(g, "members", fmt.Sprintf("/pivot/%d", env.memberID)), nil, true)
|
||||
expectStatus(t, "pivot show", shown, http.StatusOK)
|
||||
if !strings.Contains(shown.Body.String(), `"note":"hello"`) || strings.Contains(shown.Body.String(), "stamp") {
|
||||
t.Fatalf("pivot show = %s", shown.Body.String())
|
||||
}
|
||||
expectStatus(t, "pivot update with a foreign key", env.send(t, http.MethodPut, relationPath(g, "members", fmt.Sprintf("/pivot/%d", env.memberID)), map[string]any{"member_id": second.ID}, true), http.StatusUnprocessableEntity)
|
||||
expectStatus(t, "pivot update", env.send(t, http.MethodPut, relationPath(g, "members", fmt.Sprintf("/pivot/%d", env.memberID)), map[string]any{"note": "changed"}, true), http.StatusOK)
|
||||
if err := env.db.Where("gadget_id = ? AND member_id = ?", g, env.memberID).Take(&row).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if row.Note != "changed" || row.Stamp != "linked" || row.MemberID != env.memberID {
|
||||
t.Fatalf("pivot row after update = %+v", row)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user