Files
summercms/modules/cabana/relation_field_test.go
Jakub Zych 5e50b166ef refactor(10.2-01): nest framework packages under modules
- Move remaining beach packages and embedded admin assets\n- Rewrite framework, example, build, and gate paths
2026-09-28 02:21:02 +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/modules/backpack"
"git.golem15.com/golem15/summercms/modules/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")
}
}