Files
summercms/cabana/relation_field_test.go
Jakub Zych fe04dbc89e feat(10-02): relation field options and relation saves with labels
- FieldRelationContract/FieldRelationProvider bind every type: relation
  field to a belongsTo foreign key or a belongsToMany pivot; activation
  fails naming plugin, controller and field on a missing or broken contract
- GET /{vendor}/{plugin}/{controller}/fields/{field}/options serves
  {value, label} pages scoped by pact.RelationExtendOptionsQuery, behind
  the controller permission; read-only and non-relation fields are 404
- Saves apply present relation keys after the Before hook: ids are
  revalidated through the same scoped query (422 and full rollback
  otherwise), belongsTo sets the foreign key, belongsToMany replaces pivot
  rows in submitted order with the order column set to the index
- Show, create and update return relation values in data and meta.labels
- A belongsTo on a protected fill key is read-only (D-26)
- One six-segment GET pattern dispatches relation lists and field options,
  which ServeMux cannot register side by side
- Admin OpenAPI documents the options route and RecordEnvelope
2026-09-27 16:00:52 +02:00

733 lines
25 KiB
Go

package cabana
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"testing/fstest"
"time"
"git.golem15.com/golem15/summercms/backpack"
"git.golem15.com/golem15/summercms/bouncer"
"gorm.io/gorm"
)
const p10FormConfig = `name: records
form: ~/plugins/acme/demo/models/record/fields.yaml
modelClass: Record
`
const p10ListConfig = `modelClass: Record
list: ~/plugins/acme/demo/models/record/columns.yaml
recordsPerPage: 20
`
const p10Columns = `columns:
name:
label: Name
searchable: true
`
const p10Fields = `fields:
name:
label: Name
type: text
required: true
group:
label: Group
type: relation
nameFrom: title
emptyOption: None
tags:
label: Tags
type: relation
nameFrom: label
person:
label: Person
type: relation
nameFrom: username
`
type p10Record struct {
ID uint `gorm:"column:id;primaryKey"`
Name string `gorm:"column:name"`
GroupID *uint `gorm:"column:group_id"`
UserID uint `gorm:"column:user_id"`
CreatedAt time.Time `gorm:"column:created_at"`
UpdatedAt time.Time `gorm:"column:updated_at"`
}
func (p10Record) TableName() string { return "cabana_p10_records" }
func (p10Record) Fillable() []string { return []string{"name"} }
func (p10Record) Rules() map[string]string { return map[string]string{"name": "required"} }
func (p10Group) TableName() string { return "cabana_p10_groups" }
func (p10Tag) TableName() string { return "cabana_p10_tags" }
func (p10RecordTag) TableName() string { return "cabana_p10_record_tags" }
func (p10Person) TableName() string { return "cabana_p10_people" }
func (p10Controller) ID() string { return "acme.demo.records" }
func (p10Controller) ModelName() string { return "Record" }
func (p10Controller) ConfigDir() string { return "controllers/records" }
func (p10Controller) NewRecord() any { return &p10Record{} }
func (c p10Controller) RequiredPermissions() []string { return c.perms }
type p10Group struct {
ID uint `gorm:"column:id;primaryKey"`
Title string `gorm:"column:title"`
Scope string `gorm:"column:scope"`
}
type p10Tag struct {
ID uint `gorm:"column:id;primaryKey"`
Label *string `gorm:"column:label"`
Scope string `gorm:"column:scope"`
}
type p10RecordTag struct {
RecordID uint `gorm:"column:record_id;primaryKey"`
TagID uint `gorm:"column:tag_id;primaryKey"`
Position int `gorm:"column:position"`
}
type p10Person struct {
ID uint `gorm:"column:id;primaryKey"`
Email string `gorm:"column:email"`
}
// p10Controller serves the acme fixtures. Options are scoped to rows whose
// scope is "visible"; the person relation writes the protected user_id and is
// therefore read-only (D-26).
type p10Controller struct {
perms []string
mutate func([]FieldRelationContract) []FieldRelationContract
}
func (c p10Controller) AdminFieldRelations() []FieldRelationContract {
out := []FieldRelationContract{
{Field: "group", Kind: "belongsTo", NewRelated: func() any { return &p10Group{} }, ForeignKey: "group_id"},
{Field: "tags", Kind: "belongsToMany", NewRelated: func() any { return &p10Tag{} }, NewPivot: func() any { return &p10RecordTag{} },
ParentForeignKey: "record_id", RelatedForeignKey: "tag_id", OrderColumn: "position"},
{Field: "person", Kind: "belongsTo", NewRelated: func() any { return &p10Person{} }, ForeignKey: "user_id", LabelColumn: "email"},
}
if c.mutate != nil {
out = c.mutate(out)
}
return out
}
func (p10Controller) RelationExtendOptionsQuery(_ context.Context, field string, db *gorm.DB) *gorm.DB {
switch field {
case "group", "tags":
return db.Where("scope = ?", "visible")
default:
return db.Where("1 = 0")
}
}
func (p10Controller) FormBeforeCreate(ctx context.Context, model any) error {
record, ok := model.(*p10Record)
if !ok {
return fmt.Errorf("unexpected model %T", model)
}
principal, _ := bouncer.User(ctx)
if principal != nil {
record.UserID = principal.ID
}
return nil
}
func p10FS() fstest.MapFS {
return fstest.MapFS{
"controllers/records/config_list.yaml": &fstest.MapFile{Data: []byte(p10ListConfig)},
"controllers/records/config_form.yaml": &fstest.MapFile{Data: []byte(p10FormConfig)},
"models/record/columns.yaml": &fstest.MapFile{Data: []byte(p10Columns)},
"models/record/fields.yaml": &fstest.MapFile{Data: []byte(p10Fields)},
}
}
func p10Compile(ctl p10Controller) (*Registry, error) {
return compileRegistry([]controllerRef{{plugin: formPlugin{fsys: p10FS()}, ctl: ctl}})
}
type p10Seed struct {
groups map[string]uint
tags map[string]uint
person uint
}
func p10Fixture(t *testing.T) (*service, *gorm.DB, p10Seed) {
t.Helper()
_, db := newListService(t)
models := []any{&p10Record{}, &p10Group{}, &p10Tag{}, &p10RecordTag{}, &p10Person{}}
if err := db.Migrator().DropTable(models...); err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(models...); err != nil {
t.Fatal(err)
}
reg, err := p10Compile(p10Controller{perms: []string{"acme.demo.access"}})
if err != nil {
t.Fatalf("registry: %v", err)
}
app := backpack.New(nil)
if err := app.Publish(db); err != nil {
t.Fatal(err)
}
seed := p10Seed{groups: map[string]uint{}, tags: map[string]uint{}}
for _, g := range []p10Group{{Title: "Alpha", Scope: "visible"}, {Title: "Beta", Scope: "visible"}, {Title: "Hidden", Scope: "hidden"}} {
if err := db.Create(&g).Error; err != nil {
t.Fatal(err)
}
seed.groups[g.Title] = g.ID
}
for _, spec := range []struct{ label, scope string }{{"one", "visible"}, {"two", "visible"}, {"three", "visible"}, {"hidden", "hidden"}} {
label := spec.label
tag := p10Tag{Label: &label, Scope: spec.scope}
if err := db.Create(&tag).Error; err != nil {
t.Fatal(err)
}
seed.tags[spec.label] = tag.ID
}
person := p10Person{ID: 1, Email: "admin@acme.test"}
if err := db.Create(&person).Error; err != nil {
t.Fatal(err)
}
seed.person = person.ID
return &service{app: app, reg: reg}, db, seed
}
func p10Principal(granted bool) *bouncer.Principal {
p := &bouncer.Principal{ID: 1, Backend: true, PermissionGrants: map[string]bool{}}
if granted {
p.PermissionGrants["acme.demo.access"] = true
}
return p
}
func p10Request(method, id, field, query string, body any, principal *bouncer.Principal) *http.Request {
var reader *bytes.Reader
if body != nil {
raw, _ := json.Marshal(body)
reader = bytes.NewReader(raw)
} else {
reader = bytes.NewReader(nil)
}
req := httptest.NewRequest(method, "/", reader)
req.URL.RawQuery = query
req.SetPathValue("vendor", "acme")
req.SetPathValue("plugin", "demo")
req.SetPathValue("controller", "records")
if id != "" {
req.SetPathValue("id", id)
}
if field != "" {
req.SetPathValue("field", field)
}
if principal != nil {
req = req.WithContext(bouncer.WithUser(req.Context(), principal))
}
return req
}
func p10Save(svc *service, method, id string, body any) *httptest.ResponseRecorder {
rec := httptest.NewRecorder()
req := p10Request(method, id, "", "", body, p10Principal(true))
switch method {
case http.MethodPost:
svc.create(rec, req)
case http.MethodPut:
svc.update(rec, req)
case http.MethodGet:
svc.show(rec, req)
}
return rec
}
func p10Options(svc *service, field, query string, principal *bouncer.Principal) *httptest.ResponseRecorder {
rec := httptest.NewRecorder()
svc.fieldOptions(rec, p10Request(http.MethodGet, "", field, query, nil, principal))
return rec
}
type p10Envelope struct {
Data map[string]any `json:"data"`
Meta struct {
Labels map[string][]RelationOption `json:"labels"`
} `json:"meta"`
}
func p10Decode(t *testing.T, rec *httptest.ResponseRecorder, status int) p10Envelope {
t.Helper()
if rec.Code != status {
t.Fatalf("status=%d want %d body=%s", rec.Code, status, rec.Body.String())
}
var body p10Envelope
dec := json.NewDecoder(bytes.NewReader(rec.Body.Bytes()))
dec.UseNumber()
if err := dec.Decode(&body); err != nil {
t.Fatalf("decode %s: %v", rec.Body.String(), err)
}
if body.Meta.Labels == nil {
t.Fatalf("meta.labels missing: %s", rec.Body.String())
}
return body
}
func p10ID(v any) uint {
n, ok := v.(json.Number)
if !ok {
return 0
}
i, err := n.Int64()
if err != nil || i < 0 {
return 0
}
return uint(i)
}
func p10IDs(v any) []uint {
items, ok := v.([]any)
if !ok {
return nil
}
out := make([]uint, 0, len(items))
for _, item := range items {
out = append(out, p10ID(item))
}
return out
}
func p10Pivot(t *testing.T, db *gorm.DB, recordID uint) []uint {
t.Helper()
var rows []p10RecordTag
if err := db.Where("record_id = ?", recordID).Order("position, tag_id").Find(&rows).Error; err != nil {
t.Fatal(err)
}
out := make([]uint, len(rows))
for i, row := range rows {
if row.Position != i {
t.Fatalf("pivot positions=%+v", rows)
}
out[i] = row.TagID
}
return out
}
func p10Stored(t *testing.T, db *gorm.DB, id uint) p10Record {
t.Helper()
var row p10Record
if err := db.First(&row, id).Error; err != nil {
t.Fatal(err)
}
return row
}
func sameUintSeq(got, want []uint) bool {
if len(got) != len(want) {
return false
}
for i := range got {
if got[i] != want[i] {
return false
}
}
return true
}
func TestPhase10RelationOptions(t *testing.T) {
svc, db, seed := p10Fixture(t)
extra := []struct{ label, scope string }{
{"alpha first", "visible"}, {"ALPHA second", "visible"}, {"alpha hidden", "hidden"},
{"100%_off", "visible"}, {"100 off", "visible"}, {"same", "visible"}, {"same", "visible"},
}
ids := map[string][]uint{}
for _, spec := range extra {
label := spec.label
tag := p10Tag{Label: &label, Scope: spec.scope}
if err := db.Create(&tag).Error; err != nil {
t.Fatal(err)
}
ids[spec.label] = append(ids[spec.label], tag.ID)
}
for i := 0; i < 25; i++ {
label := fmt.Sprintf("bulk %02d", i)
if err := db.Create(&p10Tag{Label: &label, Scope: "visible"}).Error; err != nil {
t.Fatal(err)
}
}
if err := db.Create(&p10Tag{Scope: "visible"}).Error; err != nil {
t.Fatal(err)
}
visible := int64(3 + 6 + 25 + 1)
decode := func(rec *httptest.ResponseRecorder) ([]RelationOption, ListMeta) {
t.Helper()
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
var body struct {
Data []RelationOption `json:"data"`
Meta ListMeta `json:"meta"`
}
dec := json.NewDecoder(bytes.NewReader(rec.Body.Bytes()))
dec.DisallowUnknownFields()
if err := dec.Decode(&body); err != nil {
t.Fatalf("decode %s: %v", rec.Body.String(), err)
}
return body.Data, body.Meta
}
all := p10Options(svc, "tags", "", p10Principal(true))
rows, meta := decode(all)
if meta.Page != 1 || meta.PerPage != 20 || meta.Total != visible || meta.LastPage != 2 || len(rows) != 20 {
t.Fatalf("default page meta=%+v rows=%d", meta, len(rows))
}
if !strings.Contains(all.Body.String(), `"value":`) || strings.Contains(all.Body.String(), `"value":"`) {
t.Fatalf("option values are not numbers: %s", all.Body.String())
}
// Label order follows the database collation; the "bulk NN" labels sort
// the same under every collation.
bulk, _ := decode(p10Options(svc, "tags", "search=bulk&per_page=100", p10Principal(true)))
if len(bulk) != 25 {
t.Fatalf("bulk rows=%d", len(bulk))
}
for i, row := range bulk {
if row.Label != fmt.Sprintf("bulk %02d", i) {
t.Fatalf("options not ordered by label: %+v", bulk)
}
}
if rows[0].Label != "" {
t.Fatalf("a null label must sort first as an empty string: %+v", rows[0])
}
for _, row := range rows {
if strings.Contains(row.Label, "hidden") {
t.Fatalf("scoped options leaked a hidden row: %+v", rows)
}
}
page2, meta2 := decode(p10Options(svc, "tags", "page=2", p10Principal(true)))
if meta2.Page != 2 || len(page2) != int(visible)-20 {
t.Fatalf("page 2 meta=%+v rows=%d", meta2, len(page2))
}
big, bigMeta := decode(p10Options(svc, "tags", "per_page=100", p10Principal(true)))
if bigMeta.PerPage != 100 || len(big) != int(visible) || bigMeta.LastPage != 1 {
t.Fatalf("per_page=100 meta=%+v rows=%d", bigMeta, len(big))
}
caseless, _ := decode(p10Options(svc, "tags", "search=Alpha", p10Principal(true)))
if len(caseless) != 2 || caseless[0].Value != ids["ALPHA second"][0] && caseless[0].Value != ids["alpha first"][0] {
t.Fatalf("case-insensitive search=%+v", caseless)
}
literal, _ := decode(p10Options(svc, "tags", "search=%25_", p10Principal(true)))
if len(literal) != 1 || literal[0].Label != "100%_off" {
t.Fatalf("LIKE metacharacters were not escaped: %+v", literal)
}
same, _ := decode(p10Options(svc, "tags", "search=same", p10Principal(true)))
if len(same) != 2 || same[0].Value != ids["same"][0] || same[1].Value != ids["same"][1] {
t.Fatalf("equal labels not ordered by id: %+v want %v", same, ids["same"])
}
groups, _ := decode(p10Options(svc, "group", "", p10Principal(true)))
if len(groups) != 2 || groups[0].Value != seed.groups["Alpha"] || groups[0].Label != "Alpha" || groups[1].Label != "Beta" {
t.Fatalf("group options=%+v", groups)
}
for _, tc := range []struct{ query, field string }{
{"per_page=101", "per_page"}, {"per_page=0", "per_page"}, {"page=0", "page"}, {"page=x", "page"},
} {
rec := p10Options(svc, "tags", tc.query, p10Principal(true))
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), `"`+tc.field+`"`) {
t.Fatalf("%s status=%d body=%s", tc.query, rec.Code, rec.Body.String())
}
}
for _, field := range []string{"name", "person", "nope", "Tags"} {
rec := p10Options(svc, field, "", p10Principal(true))
if rec.Code != http.StatusNotFound {
t.Fatalf("field %s status=%d body=%s", field, rec.Code, rec.Body.String())
}
assertErrorCode(t, rec.Body.Bytes(), "not_found")
if strings.Contains(rec.Body.String(), "admin@acme.test") {
t.Fatalf("read-only options disclosed a label: %s", rec.Body.String())
}
}
// Permission is checked before any SQL: this service has no database, so
// reaching a query would be a 500, not a 403.
denied := &service{reg: svc.reg}
rec := p10Options(denied, "tags", "search=one", p10Principal(false))
if rec.Code != http.StatusForbidden {
t.Fatalf("denied status=%d body=%s", rec.Code, rec.Body.String())
}
assertErrorCode(t, rec.Body.Bytes(), "forbidden")
frontend := p10Principal(true)
frontend.Backend = false
rec = httptest.NewRecorder()
req := p10Request(http.MethodGet, "", "tags", "", nil, nil)
req = req.WithContext(bouncer.WithUser(req.Context(), frontend))
denied.fieldOptions(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("frontend principal status=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestPhase10RelationSave(t *testing.T) {
svc, db, seed := p10Fixture(t)
one, two, three := seed.tags["one"], seed.tags["two"], seed.tags["three"]
alpha, beta := seed.groups["Alpha"], seed.groups["Beta"]
created := p10Decode(t, p10Save(svc, http.MethodPost, "", map[string]any{
"name": "record", "group": alpha, "tags": []uint{three, one}, "person": 99, "user_id": 99,
}), http.StatusCreated)
id := p10ID(created.Data["id"])
if p10ID(created.Data["group"]) != alpha || !sameUintSeq(p10IDs(created.Data["tags"]), []uint{three, one}) {
t.Fatalf("created data=%v", created.Data)
}
if p10ID(created.Data["person"]) != seed.person {
t.Fatalf("read-only person value=%v want %d", created.Data["person"], seed.person)
}
if _, leaked := created.Data["user_id"]; leaked {
t.Fatalf("protected foreign key leaked: %v", created.Data)
}
labels := created.Meta.Labels
if len(labels["group"]) != 1 || labels["group"][0] != (RelationOption{Value: alpha, Label: "Alpha"}) {
t.Fatalf("group labels=%+v", labels["group"])
}
if len(labels["tags"]) != 2 || labels["tags"][0] != (RelationOption{Value: three, Label: "three"}) || labels["tags"][1].Value != one {
t.Fatalf("tag labels=%+v", labels["tags"])
}
if len(labels["person"]) != 1 || labels["person"][0] != (RelationOption{Value: seed.person, Label: "admin@acme.test"}) {
t.Fatalf("person labels=%+v", labels["person"])
}
stored := p10Stored(t, db, id)
if stored.GroupID == nil || *stored.GroupID != alpha || stored.UserID != seed.person {
t.Fatalf("stored=%+v", stored)
}
if got := p10Pivot(t, db, id); !sameUintSeq(got, []uint{three, one}) {
t.Fatalf("pivot=%v", got)
}
idText := fmt.Sprintf("%d", id)
p10Decode(t, p10Save(svc, http.MethodPut, idText, map[string]any{"name": "renamed"}), http.StatusOK)
stored = p10Stored(t, db, id)
if stored.Name != "renamed" || stored.GroupID == nil || *stored.GroupID != alpha {
t.Fatalf("absent keys changed the relation: %+v", stored)
}
if got := p10Pivot(t, db, id); !sameUintSeq(got, []uint{three, one}) {
t.Fatalf("absent key changed the pivot: %v", got)
}
cleared := p10Decode(t, p10Save(svc, http.MethodPut, idText, map[string]any{"name": "renamed", "group": nil, "tags": []uint{two, one}}), http.StatusOK)
if cleared.Data["group"] != nil || len(cleared.Meta.Labels["group"]) != 0 || cleared.Meta.Labels["group"] == nil {
t.Fatalf("cleared group data=%v labels=%v", cleared.Data["group"], cleared.Meta.Labels)
}
if stored := p10Stored(t, db, id); stored.GroupID != nil {
t.Fatalf("null did not clear group_id: %+v", stored)
}
if got := p10Pivot(t, db, id); !sameUintSeq(got, []uint{two, one}) {
t.Fatalf("replaced pivot=%v", got)
}
p10Decode(t, p10Save(svc, http.MethodPut, idText, map[string]any{"name": "renamed", "group": beta}), http.StatusOK)
shown := p10Decode(t, p10Save(svc, http.MethodGet, idText, nil), http.StatusOK)
if p10ID(shown.Data["group"]) != beta || !sameUintSeq(p10IDs(shown.Data["tags"]), []uint{two, one}) {
t.Fatalf("show data=%v", shown.Data)
}
if len(shown.Meta.Labels["tags"]) != 2 || shown.Meta.Labels["tags"][0].Label != "two" || shown.Meta.Labels["group"][0].Label != "Beta" {
t.Fatalf("show labels=%+v", shown.Meta.Labels)
}
emptied := p10Decode(t, p10Save(svc, http.MethodPut, idText, map[string]any{"name": "renamed", "tags": []uint{}}), http.StatusOK)
if tags, ok := emptied.Data["tags"].([]any); !ok || len(tags) != 0 {
t.Fatalf("emptied tags=%#v", emptied.Data["tags"])
}
if got := p10Pivot(t, db, id); len(got) != 0 {
t.Fatalf("empty list left pivot rows %v", got)
}
}
func TestPhase10RelationForgedID(t *testing.T) {
svc, db, seed := p10Fixture(t)
one := seed.tags["one"]
created := p10Decode(t, p10Save(svc, http.MethodPost, "", map[string]any{"name": "keep", "group": seed.groups["Alpha"], "tags": []uint{one}}), http.StatusCreated)
id := p10ID(created.Data["id"])
idText := fmt.Sprintf("%d", id)
for _, tc := range []struct {
name string
body map[string]any
field string
}{
{"out of scope tag", map[string]any{"tags": []uint{seed.tags["hidden"]}}, "tags"},
{"unknown tag", map[string]any{"tags": []uint{999999}}, "tags"},
{"text tag", map[string]any{"tags": []any{"x"}}, "tags"},
{"fractional tag", map[string]any{"tags": []any{1.5}}, "tags"},
{"negative tag", map[string]any{"tags": []any{-1}}, "tags"},
{"duplicate tag", map[string]any{"tags": []uint{one, one}}, "tags"},
{"scalar for many", map[string]any{"tags": one}, "tags"},
{"out of scope group", map[string]any{"group": seed.groups["Hidden"]}, "group"},
{"list for one", map[string]any{"group": []uint{seed.groups["Beta"]}}, "group"},
{"text group", map[string]any{"group": "abc"}, "group"},
} {
t.Run(tc.name, func(t *testing.T) {
body := map[string]any{"name": "changed"}
for key, value := range tc.body {
body[key] = value
}
rec := p10Save(svc, http.MethodPut, idText, body)
if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
assertErrorCode(t, rec.Body.Bytes(), "validation_failed")
var envelope struct {
Error struct {
Details map[string][]string `json:"details"`
} `json:"error"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &envelope); err != nil {
t.Fatal(err)
}
if len(envelope.Error.Details[tc.field]) == 0 {
t.Fatalf("details missing %s: %s", tc.field, rec.Body.String())
}
stored := p10Stored(t, db, id)
if stored.Name != "keep" || stored.GroupID == nil || *stored.GroupID != seed.groups["Alpha"] {
t.Fatalf("rejected save committed: %+v", stored)
}
if got := p10Pivot(t, db, id); !sameUintSeq(got, []uint{one}) {
t.Fatalf("rejected save changed the pivot: %v", got)
}
})
}
var before int64
if err := db.Model(&p10Record{}).Count(&before).Error; err != nil {
t.Fatal(err)
}
rec := p10Save(svc, http.MethodPost, "", map[string]any{"name": "forged", "tags": []uint{one, seed.tags["hidden"]}})
if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("forged create status=%d body=%s", rec.Code, rec.Body.String())
}
var after int64
if err := db.Model(&p10Record{}).Count(&after).Error; err != nil {
t.Fatal(err)
}
var pivots int64
if err := db.Model(&p10RecordTag{}).Count(&pivots).Error; err != nil {
t.Fatal(err)
}
if after != before || pivots != 1 {
t.Fatalf("forged create committed rows=%d->%d pivots=%d", before, after, pivots)
}
}
func TestPhase10RelationBoot(t *testing.T) {
reg, err := p10Compile(p10Controller{})
if err != nil {
t.Fatalf("valid contracts: %v", err)
}
cc, _ := reg.Get("acme.demo.records")
raw, err := json.Marshal(cc.Form.Fields)
if err != nil {
t.Fatal(err)
}
fields := map[string]map[string]any{}
var list []map[string]any
if err := json.Unmarshal(raw, &list); err != nil {
t.Fatal(err)
}
for _, field := range list {
fields[field["name"].(string)] = field
}
if fields["tags"]["multiple"] != true || fields["tags"]["readOnly"] != nil {
t.Fatalf("tags field=%v", fields["tags"])
}
if fields["group"]["multiple"] != nil || fields["group"]["readOnly"] != nil {
t.Fatalf("group field=%v", fields["group"])
}
if fields["person"]["readOnly"] != true || fields["person"]["multiple"] != nil {
t.Fatalf("person field=%v", fields["person"])
}
for _, tc := range []struct {
name string
mutate func([]FieldRelationContract) []FieldRelationContract
want []string
}{
{"missing contract", func(in []FieldRelationContract) []FieldRelationContract { return in[1:] }, []string{"field group", "no relation contract"}},
{"missing foreign key column", func(in []FieldRelationContract) []FieldRelationContract {
in[0].ForeignKey = "missing_id"
return in
}, []string{"field group", "missing_id"}},
{"missing pivot column", func(in []FieldRelationContract) []FieldRelationContract {
in[1].OrderColumn = "rank"
return in
}, []string{"field tags", "rank"}},
{"missing label column", func(in []FieldRelationContract) []FieldRelationContract {
in[2].LabelColumn = "username"
return in
}, []string{"field person", "username"}},
{"unknown kind", func(in []FieldRelationContract) []FieldRelationContract {
in[0].Kind = "hasMany"
return in
}, []string{"field group", "unknown relation kind hasMany"}},
{"missing related model", func(in []FieldRelationContract) []FieldRelationContract {
in[0].NewRelated = nil
return in
}, []string{"field group", "related model"}},
{"duplicate contract", func(in []FieldRelationContract) []FieldRelationContract { return append(in, in[0]) }, []string{"field group", "duplicate"}},
{"orphan contract", func(in []FieldRelationContract) []FieldRelationContract {
return append(in, FieldRelationContract{Field: "extra", Kind: "belongsTo", NewRelated: func() any { return &p10Group{} }, ForeignKey: "group_id"})
}, []string{"field extra", "not a relation field"}},
} {
t.Run(tc.name, func(t *testing.T) {
_, err := p10Compile(p10Controller{mutate: tc.mutate})
if err == nil {
t.Fatal("activation accepted a broken relation contract")
}
for _, want := range append([]string{"acme.demo", "acme.demo.records"}, tc.want...) {
if !strings.Contains(err.Error(), want) {
t.Fatalf("err=%v missing %q", err, want)
}
}
})
}
_, err = compileRegistry([]controllerRef{{plugin: formPlugin{fsys: p10FS()}, ctl: crudControllerLike{}}})
if err == nil || !strings.Contains(err.Error(), "field group") || !strings.Contains(err.Error(), "AdminFieldRelations") {
t.Fatalf("controller without contracts err=%v", err)
}
}
// crudControllerLike has the relation form but declares no contracts.
type crudControllerLike struct{}
func (crudControllerLike) ID() string { return "acme.demo.records" }
func (crudControllerLike) ModelName() string { return "Record" }
func (crudControllerLike) ConfigDir() string { return "controllers/records" }
func (crudControllerLike) NewRecord() any { return &p10Record{} }
func TestPhase10NestedGetDispatch(t *testing.T) {
svc, _, seed := p10Fixture(t)
call := func(id, segment, name string) *httptest.ResponseRecorder {
req := p10Request(http.MethodGet, id, "", "", nil, p10Principal(true))
req.SetPathValue("segment", segment)
req.SetPathValue("name", name)
rec := httptest.NewRecorder()
svc.nestedGet(rec, req)
return rec
}
options := call("fields", "group", "options")
if options.Code != http.StatusOK || !strings.Contains(options.Body.String(), fmt.Sprintf(`"value":%d`, seed.groups["Alpha"])) {
t.Fatalf("fields dispatch status=%d body=%s", options.Code, options.Body.String())
}
for _, tc := range [][3]string{{"fields", "group", "choices"}, {"5", "other", "x"}, {"filters", "group", "list"}} {
rec := call(tc[0], tc[1], tc[2])
if rec.Code != http.StatusNotFound {
t.Fatalf("%v status=%d body=%s", tc, rec.Code, rec.Body.String())
}
assertErrorCode(t, rec.Body.Bytes(), "not_found")
}
}