feat: relation field and picker

This commit is contained in:
Florian Sylvain committed 2026-10-23 00:13:42 +02:00
1 parent b45c99be3b
commit 37a77c9c22
92 files changed
+6826 -80

No files matched your search

@@ -35,7 +35,7 @@ const supportRecipeKey = "recipe"
var supportContentTypeKeys = []string{"created_at", "fields", "icon", "id", "key", "name", "updated_at"}
var supportFieldKeys = []string{"default", "key", "label", "max", "min", "options", "required", "type", "unique"}
var supportFieldKeys = []string{"default", "key", "label", "max", "min", "options", "relation", "required", "type", "unique"}
func newSupportEnv(t *testing.T) *contentTypeEnv {
t.Helper()
+2 -2
View File
@@ -12,8 +12,8 @@ import (
var (
contentTypeKeys = []string{"created_at", "fields", "icon", "id", "key", "name", "updated_at"}
fieldKeys = []string{"default", "key", "label", "max", "min", "options", "required", "type", "unique"}
entryKeys = []string{"content_type", "created_at", "created_by", "data", "id", "media", "updated_at", "updated_by"}
fieldKeys = []string{"default", "key", "label", "max", "min", "options", "relation", "required", "type", "unique"}
entryKeys = []string{"content_type", "created_at", "created_by", "data", "id", "media", "relations", "updated_at", "updated_by"}
)
func TestCreatingAContentType(t *testing.T) {
+1 -1
View File
@@ -628,7 +628,7 @@ func TestContentTypeStoredFormat(t *testing.T) {
keys = append(keys, key)
}
sort.Strings(keys)
expected := []string{"default", "key", "label", "max", "min", "options", "required", "type", "unique"}
expected := []string{"default", "key", "label", "max", "min", "options", "relation", "required", "type", "unique"}
if !reflect.DeepEqual(keys, expected) {
t.Errorf("stored keys %v", keys)
}
@@ -0,0 +1,66 @@
package test
import (
"RenewCMS/internal/domain/contenttype"
"RenewCMS/internal/domain/entry"
"testing"
)
func TestRelationFieldMediaIDBounds(t *testing.T) {
cases := []struct {
name string
value any
id uint32
ok bool
}{
{"uint32 one", uint32(1), 1, true},
{"uint32 zero", uint32(0), 0, false},
{"int one", 1, 1, true},
{"int zero", 0, 0, false},
{"int negative", -4, 0, false},
{"int64 max uint32", int64(4294967295), 4294967295, true},
{"int64 over uint32", int64(4294967296), 0, false},
{"int64 zero", int64(0), 0, false},
{"float whole", float64(7), 7, true},
{"float fraction", 7.5, 0, false},
{"float zero", float64(0), 0, false},
{"float max uint32", float64(4294967295), 4294967295, true},
{"float over uint32", float64(4294967296), 0, false},
{"string", "7", 0, false},
}
for _, c := range cases {
id, ok := contenttype.MediaID(c.value)
if ok != c.ok || id != c.id {
t.Errorf("%s: got (%d, %v), want (%d, %v)", c.name, id, ok, c.id, c.ok)
}
}
}
func TestRelationFieldQueryDisplayText(t *testing.T) {
text := []contenttype.Field{{Key: "name", Type: contenttype.TypeText}}
number := []contenttype.Field{{Key: "n", Type: contenttype.TypeNumber}}
if entry.MatchesQuery(nil, map[string]any{"name": "x"}, "x") {
t.Error("no fields must never match")
}
if !entry.MatchesQuery(text, map[string]any{"name": "Grand Hall"}, "grand") {
t.Error("case-insensitive text match expected")
}
if entry.MatchesQuery(text, map[string]any{}, "grand") {
t.Error("missing value must not match")
}
if !entry.MatchesQuery(text, map[string]any{}, "") {
t.Error("empty query matches an empty display text")
}
if !entry.MatchesQuery(number, map[string]any{"n": 3.5}, "3.5") {
t.Error("float must render without exponent or padding")
}
if !entry.MatchesQuery(number, map[string]any{"n": float64(42)}, "42") {
t.Error("whole float must render without decimals")
}
if entry.MatchesQuery(number, map[string]any{"n": float64(42)}, "42.0") {
t.Error("whole float must not render a decimal part")
}
if !entry.MatchesQuery(number, map[string]any{"n": true}, "true") {
t.Error("other kinds fall back to default formatting")
}
}
+539
View File
@@ -0,0 +1,539 @@
package test
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/internal/domain/entry"
"encoding/json"
"net/http"
"net/http/httptest"
"reflect"
"strconv"
"strings"
"testing"
)
type relationEnv struct {
*contentTypeEnv
}
func relationField(key string, label string, target string, kind string, required bool) map[string]any {
return map[string]any{"key": key, "label": label, "type": "relation", "required": required, "unique": false, "default": nil, "min": nil, "max": nil, "options": []string{}, "relation": map[string]any{"content_type": target, "kind": kind}}
}
func eventRelationFields(venueRequired bool, speakersRequired bool) []map[string]any {
return []map[string]any{
{"key": "title", "label": "Title", "type": "text", "required": true},
relationField("venue", "Venue", "venue", "one_to_many", venueRequired),
relationField("speakers", "Speakers", "speaker", "many_to_many", speakersRequired),
}
}
func newRelationEnv(t *testing.T, venueRequired bool, speakersRequired bool) *relationEnv {
t.Helper()
env := &relationEnv{newContentTypeEnv(t)}
env.createType(t, "venue", []map[string]any{{"key": "title", "label": "Title", "type": "text", "required": true}, {"key": "city", "label": "City", "type": "text"}})
env.createType(t, "speaker", []map[string]any{{"key": "name", "label": "Name", "type": "text", "required": true}})
env.createType(t, "event", eventRelationFields(venueRequired, speakersRequired))
env.seed(t, "venue", 3, "title", "Rue Verte hall")
env.seed(t, "venue", 4, "title", "Rue Verte yard")
env.seed(t, "speaker", 11, "name", "Ines Duarte")
env.seed(t, "speaker", 12, "name", "Jonas Pereira")
env.entries.nextID = 100
return env
}
func (e *relationEnv) seed(t *testing.T, typeKey string, id uint32, field string, value any) {
t.Helper()
e.seedData(t, typeKey, id, map[string]any{field: value})
}
func (e *relationEnv) seedData(t *testing.T, typeKey string, id uint32, data map[string]any) {
t.Helper()
owner, err := e.types.GetByKey(typeKey)
if err != nil {
t.Fatalf("seed type %s: %v", typeKey, err)
}
e.entries.entries[id] = entry.Entry{ID: id, ContentTypeID: owner.ID, Data: data, CreatedBy: ctMemberId, UpdatedBy: ctMemberId, CreatedAt: e.clock, UpdatedAt: e.clock}
}
func (e *relationEnv) member(t *testing.T, method string, path string, body string) *httptest.ResponseRecorder {
t.Helper()
return e.as(t, ctMemberId, method, path, body)
}
func (e *relationEnv) postEvent(t *testing.T, data string) *httptest.ResponseRecorder {
t.Helper()
return e.createEntry(t, "event", ctMemberId, data)
}
func (e *relationEnv) putEvent(t *testing.T, id uint32, data string) *httptest.ResponseRecorder {
t.Helper()
return e.member(t, http.MethodPut, "/v1/content-type/event/entry/"+strconv.Itoa(int(id)), `{"data": `+data+`}`)
}
func (e *relationEnv) countEntries(typeKey string) int {
owner, _ := e.types.GetByKey(typeKey)
count, _ := e.entries.CountByContentType(owner.ID)
return count
}
func hall(id float64, key string, value string, typeKey string, fieldType string) map[string]any {
return map[string]any{"id": id, "content_type": typeKey, "first_field": map[string]any{"key": key, "type": fieldType, "value": value}}
}
func TestRelationFieldTypeContentTypes(t *testing.T) {
t.Run("Creating a content type with a relation field", func(t *testing.T) {
env := newContentTypeEnv(t)
fields := []map[string]any{{"key": "title", "label": "Title", "type": "text", "required": true}, relationField("venue", "Venue", "venue", "one_to_many", false)}
rec := env.admin(t, http.MethodPost, "/v1/content-type", typeBody("venue", "Venue", fields))
if rec.Code != http.StatusCreated {
t.Fatalf("status %d %s", rec.Code, rec.Body.String())
}
stored := decodeContentObject(t, rec)["fields"].([]any)
if stored[0].(map[string]any)["relation"] != nil || !reflect.DeepEqual(objectKeys(stored[1].(map[string]any)), fieldKeys) {
t.Errorf("fields %v", stored)
}
if !reflect.DeepEqual(stored[1].(map[string]any)["relation"], map[string]any{"content_type": "venue", "kind": "one_to_many"}) {
t.Errorf("relation %v", stored[1])
}
})
t.Run("A relation field with a bad definition is refused", func(t *testing.T) {
defects := map[string]func(map[string]any){
"relation null": func(f map[string]any) { f["relation"] = nil },
"content_type nope": func(f map[string]any) { f["relation"] = map[string]any{"content_type": "nope", "kind": "one_to_many"} },
"content_type empty": func(f map[string]any) { f["relation"] = map[string]any{"content_type": "", "kind": "one_to_many"} },
"content_type missing": func(f map[string]any) { f["relation"] = map[string]any{"kind": "one_to_many"} },
"kind many_to_one": func(f map[string]any) {
f["relation"] = map[string]any{"content_type": "speaker", "kind": "many_to_one"}
},
"kind missing": func(f map[string]any) { f["relation"] = map[string]any{"content_type": "speaker"} },
"a relation object on text": func(f map[string]any) { f["type"] = "text" },
"unique true": func(f map[string]any) { f["unique"] = true },
"default 3": func(f map[string]any) { f["default"] = 3 },
"min 1": func(f map[string]any) { f["min"] = 1 },
"max 5": func(f map[string]any) { f["max"] = 5 },
"options a": func(f map[string]any) { f["options"] = []string{"a"} },
"a one_to_one self relation": func(f map[string]any) { f["relation"] = map[string]any{"content_type": "talk", "kind": "one_to_one"} },
"an invalid field key": func(f map[string]any) { f["key"] = "Bad Key" },
}
for name, defect := range defects {
t.Run(name, func(t *testing.T) {
env := newContentTypeEnv(t)
env.createType(t, "speaker", []map[string]any{{"key": "name", "label": "Name", "type": "text"}})
field := relationField("link", "Link", "speaker", "one_to_many", false)
defect(field)
rec := env.admin(t, http.MethodPost, "/v1/content-type", typeBody("talk", "Talk", []map[string]any{field}))
expectStatusAndBody(t, rec, http.StatusUnprocessableEntity, apperr.InvalidFieldErrorMessage)
if _, err := env.types.GetByKey("talk"); err == nil {
t.Error("talk should not be stored")
}
})
}
})
t.Run("A self relation is allowed for one_to_many and many_to_many", func(t *testing.T) {
env := newContentTypeEnv(t)
for key, kind := range map[string]string{"selfmany": "one_to_many", "selfpool": "many_to_many"} {
fields := []map[string]any{{"key": "title", "label": "Title", "type": "text"}, relationField("parent", "Parent", key, kind, false)}
rec := env.admin(t, http.MethodPost, "/v1/content-type", typeBody(key, "Self", fields))
if rec.Code != http.StatusCreated {
t.Errorf("%s: %d %s", kind, rec.Code, rec.Body.String())
}
}
})
t.Run("The shape of a saved relation never changes", func(t *testing.T) {
changes := map[string]func(fields []map[string]any){
"kind to many_to_many": func(f []map[string]any) {
f[1]["relation"] = map[string]any{"content_type": "venue", "kind": "many_to_many"}
},
"target to speaker": func(f []map[string]any) {
f[1]["relation"] = map[string]any{"content_type": "speaker", "kind": "one_to_many"}
},
"turned into text": func(f []map[string]any) {
f[1]["type"] = "text"
f[1]["relation"] = nil
},
}
for name, change := range changes {
t.Run(name, func(t *testing.T) {
env := newRelationEnv(t, false, false)
fields := eventRelationFields(false, false)
change(fields)
rec := env.admin(t, http.MethodPut, "/v1/content-type/event", typeBody("event", "Event", fields))
expectStatusAndBody(t, rec, http.StatusUnprocessableEntity, apperr.InvalidFieldErrorMessage)
stored, _ := env.types.GetByKey("event")
if stored.Fields[1].Relation == nil || stored.Fields[1].Relation.ContentType != "venue" {
t.Errorf("schema changed: %v", stored.Fields[1])
}
})
}
})
t.Run("Adding a relation field to a type that has entries", func(t *testing.T) {
for _, example := range []struct {
required bool
status int
message string
}{{true, http.StatusConflict, apperr.SchemaIncompatibleErrorMessage}, {false, http.StatusOK, ""}} {
t.Run(strconv.FormatBool(example.required), func(t *testing.T) {
env := newContentTypeEnv(t)
env.createType(t, "venue", []map[string]any{{"key": "title", "label": "Title", "type": "text"}})
env.createType(t, "event", []map[string]any{{"key": "title", "label": "Title", "type": "text"}})
for range 12 {
env.createEntry(t, "event", ctMemberId, `{"title":"A"}`)
}
fields := []map[string]any{{"key": "title", "label": "Title", "type": "text"}, relationField("venue", "Venue", "venue", "one_to_many", example.required)}
rec := env.admin(t, http.MethodPut, "/v1/content-type/event", typeBody("event", "Event", fields))
expectStatusAndBody(t, rec, example.status, example.message)
})
}
})
t.Run("Deleting a type that another type points at", func(t *testing.T) {
env := newRelationEnv(t, false, false)
env.entries.entries = map[uint32]entry.Entry{}
rec := env.admin(t, http.MethodDelete, "/v1/content-type/venue", "")
expectStatusAndBody(t, rec, http.StatusConflict, apperr.ContentTypeReferencedErrorMessage)
if _, err := env.types.GetByKey("venue"); err != nil {
t.Error("venue should still exist")
}
fields := []map[string]any{{"key": "title", "label": "Title", "type": "text", "required": true}, relationField("speakers", "Speakers", "speaker", "many_to_many", false)}
env.admin(t, http.MethodPut, "/v1/content-type/event", typeBody("event", "Event", fields))
expectStatusAndBody(t, env.admin(t, http.MethodDelete, "/v1/content-type/venue", ""), http.StatusNoContent, "")
})
}
func TestRelationFieldTypeEntries(t *testing.T) {
t.Run("Creating an entry with a single target", func(t *testing.T) {
env := newRelationEnv(t, false, false)
rec := env.postEvent(t, `{"title":"Lime plaster workshop","venue":3}`)
if rec.Code != http.StatusCreated {
t.Fatalf("status %d %s", rec.Code, rec.Body.String())
}
body := decodeContentObject(t, rec)
want := map[string]any{"venue": hall(3, "title", "Rue Verte hall", "venue", "text")}
if !reflect.DeepEqual(objectKeys(body), entryKeys) || body["data"].(map[string]any)["venue"] != float64(3) || !reflect.DeepEqual(body["relations"], want) {
t.Errorf("body %s", rec.Body.String())
}
})
t.Run("Creating an entry with many targets", func(t *testing.T) {
env := newRelationEnv(t, false, false)
rec := env.postEvent(t, `{"title":"A","speakers":[12,11]}`)
body := decodeContentObject(t, rec)
want := []any{hall(12, "name", "Jonas Pereira", "speaker", "text"), hall(11, "name", "Ines Duarte", "speaker", "text")}
if rec.Code != http.StatusCreated || !reflect.DeepEqual(body["relations"].(map[string]any)["speakers"], want) || !reflect.DeepEqual(body["data"].(map[string]any)["speakers"], []any{float64(12), float64(11)}) {
t.Errorf("body %s", rec.Body.String())
}
})
t.Run("Empty optional relations", func(t *testing.T) {
for _, example := range []struct{ data, key string }{{`{"title":"A","venue":null}`, "venue"}, {`{"title":"A"}`, "venue"}, {`{"title":"A","speakers":[]}`, "speakers"}} {
t.Run(example.data, func(t *testing.T) {
env := newRelationEnv(t, false, false)
rec := env.postEvent(t, example.data)
body := decodeContentObject(t, rec)
if rec.Code != http.StatusCreated || !reflect.DeepEqual(body["relations"], map[string]any{}) {
t.Errorf("body %s", rec.Body.String())
}
})
}
})
t.Run("Values that break a relation are refused and store nothing", func(t *testing.T) {
fiftyOne := make([]string, 51)
for index := range fiftyOne {
fiftyOne[index] = strconv.Itoa(index + 1)
}
for _, example := range []struct {
required bool
data string
message string
}{
{true, `{"title":"A"}`, "invalid_entry: venue: required"},
{true, `{"title":"A","venue":null}`, "invalid_entry: venue: required"},
{false, `{"title":"A","venue":999}`, "invalid_entry: venue: type"},
{false, `{"title":"A","venue":"3"}`, "invalid_entry: venue: type"},
{false, `{"title":"A","venue":0}`, "invalid_entry: venue: type"},
{false, `{"title":"A","venue":3.5}`, "invalid_entry: venue: type"},
{false, `{"title":"A","venue":[3]}`, "invalid_entry: venue: type"},
{false, `{"title":"A","speakers":11}`, "invalid_entry: speakers: type"},
{false, `{"title":"A","speakers":[11,11]}`, "invalid_entry: speakers: type"},
{false, `{"title":"A","speakers":[11,999]}`, "invalid_entry: speakers: type"},
{false, `{"title":"A","speakers":[3]}`, "invalid_entry: speakers: type"},
{false, `{"title":"A","speakers":[` + strings.Join(fiftyOne, ",") + `]}`, "invalid_entry: speakers: type"},
} {
t.Run(example.data[:min(len(example.data), 40)], func(t *testing.T) {
env := newRelationEnv(t, example.required, false)
rec := env.postEvent(t, example.data)
expectStatusAndBody(t, rec, http.StatusUnprocessableEntity, example.message)
if env.countEntries("event") != 0 {
t.Error("an entry was stored")
}
})
}
})
t.Run("A required many relation is never empty", func(t *testing.T) {
env := newRelationEnv(t, false, true)
expectStatusAndBody(t, env.postEvent(t, `{"title":"A","speakers":[]}`), http.StatusUnprocessableEntity, "invalid_entry: speakers: required")
expectStatusAndBody(t, env.postEvent(t, `{"title":"A"}`), http.StatusUnprocessableEntity, "invalid_entry: speakers: required")
})
t.Run("One to one keeps targets exclusive", func(t *testing.T) {
env := newRelationEnv(t, false, false)
env.createType(t, "talk", []map[string]any{{"key": "title", "label": "Title", "type": "text"}, relationField("room", "Room", "venue", "one_to_one", false)})
env.seedData(t, "talk", 50, map[string]any{"title": "Holder", "room": float64(3)})
post := func() *httptest.ResponseRecorder {
return env.createEntry(t, "talk", ctMemberId, `{"title":"B","room":3}`)
}
expectStatusAndBody(t, post(), http.StatusConflict, "invalid_entry: room: unique")
if env.countEntries("talk") != 1 {
t.Error("a talk was stored")
}
unchanged := env.member(t, http.MethodPut, "/v1/content-type/talk/entry/50", `{"data":{"title":"Holder","room":3}}`)
if unchanged.Code != http.StatusOK {
t.Errorf("unchanged holder: %d %s", unchanged.Code, unchanged.Body.String())
}
env.member(t, http.MethodDelete, "/v1/content-type/talk/entry/50", "")
if rec := post(); rec.Code != http.StatusCreated {
t.Errorf("after delete: %d %s", rec.Code, rec.Body.String())
}
})
t.Run("Replacing and clearing targets", func(t *testing.T) {
env := newRelationEnv(t, false, false)
env.seedData(t, "event", 5, map[string]any{"title": "E", "venue": float64(3), "speakers": []any{float64(11)}})
rec := env.putEvent(t, 5, `{"title":"E","venue":4,"speakers":[11,12]}`)
body := decodeContentObject(t, rec)
relations := body["relations"].(map[string]any)
if rec.Code != http.StatusOK || relations["venue"].(map[string]any)["id"] != float64(4) || len(relations["speakers"].([]any)) != 2 || body["data"].(map[string]any)["venue"] != float64(4) {
t.Errorf("body %s", rec.Body.String())
}
cleared := decodeContentObject(t, env.putEvent(t, 5, `{"title":"E","venue":null,"speakers":[]}`))
if !reflect.DeepEqual(cleared["relations"], map[string]any{}) {
t.Errorf("cleared %v", cleared)
}
})
t.Run("A target deleted after the entry was saved", func(t *testing.T) {
env := newRelationEnv(t, false, false)
env.seedData(t, "event", 5, map[string]any{"title": "E", "venue": float64(3), "speakers": []any{float64(11), float64(12)}})
before := env.entries.entries[5]
expectStatusAndBody(t, env.member(t, http.MethodDelete, "/v1/content-type/venue/entry/3", ""), http.StatusNoContent, "")
expectStatusAndBody(t, env.member(t, http.MethodDelete, "/v1/content-type/speaker/entry/12", ""), http.StatusNoContent, "")
rec := env.member(t, http.MethodGet, "/v1/content-type/event/entry/5", "")
body := decodeContentObject(t, rec)
relations := body["relations"].(map[string]any)
wantSpeakers := []any{hall(11, "name", "Ines Duarte", "speaker", "text"), map[string]any{"id": float64(12), "missing": true}}
if !reflect.DeepEqual(relations["venue"], map[string]any{"id": float64(3), "missing": true}) || !reflect.DeepEqual(relations["speakers"], wantSpeakers) {
t.Errorf("relations %v", relations)
}
if !reflect.DeepEqual(body["data"].(map[string]any)["speakers"], []any{float64(11), float64(12)}) || !reflect.DeepEqual(env.entries.entries[5], before) {
t.Errorf("entry modified: %v", env.entries.entries[5])
}
})
t.Run("Unchanged and changed ids when the target is gone", func(t *testing.T) {
for _, example := range []struct {
value string
status int
message string
}{{"3", 200, ""}, {"999", 422, "invalid_entry: venue: type"}, {"4", 200, ""}, {"null", 200, ""}} {
t.Run(example.value, func(t *testing.T) {
env := newRelationEnv(t, false, false)
env.seedData(t, "event", 5, map[string]any{"title": "E", "venue": float64(3)})
delete(env.entries.entries, 3)
rec := env.putEvent(t, 5, `{"title":"E","venue":`+example.value+`}`)
expectStatusAndBody(t, rec, example.status, example.message)
})
}
})
t.Run("A required relation with a missing target can't be saved as is", func(t *testing.T) {
for _, example := range []struct {
value string
status int
message string
unchanged bool
}{{"3", 422, "invalid_entry: venue: required", true}, {"null", 422, "invalid_entry: venue: required", true}, {"4", 200, "", false}} {
t.Run(example.value, func(t *testing.T) {
env := newRelationEnv(t, true, false)
env.seedData(t, "event", 5, map[string]any{"title": "E", "venue": float64(3)})
delete(env.entries.entries, 3)
before := env.entries.entries[5]
rec := env.putEvent(t, 5, `{"title":"E","venue":`+example.value+`}`)
expectStatusAndBody(t, rec, example.status, example.message)
if example.unchanged && !reflect.DeepEqual(env.entries.entries[5], before) {
t.Error("the stored entry changed")
}
})
}
})
t.Run("A create always treats the id as changed", func(t *testing.T) {
env := newRelationEnv(t, false, false)
delete(env.entries.entries, 3)
expectStatusAndBody(t, env.postEvent(t, `{"title":"A","venue":3}`), http.StatusUnprocessableEntity, "invalid_entry: venue: type")
})
t.Run("Two broken fields name only one", func(t *testing.T) {
env := newRelationEnv(t, false, false)
expectStatusAndBody(t, env.postEvent(t, `{"title":"","venue":999}`), http.StatusUnprocessableEntity, "invalid_entry: venue: type")
expectStatusAndBody(t, env.postEvent(t, `{"venue":999}`), http.StatusUnprocessableEntity, "invalid_entry: title: required")
})
t.Run("Entry list responses carry relations per item", func(t *testing.T) {
env := newRelationEnv(t, false, false)
env.seedData(t, "event", 5, map[string]any{"title": "One", "venue": float64(3), "speakers": []any{float64(11), float64(12)}})
env.seedData(t, "event", 6, map[string]any{"title": "Two", "venue": float64(99)})
env.seedData(t, "event", 7, map[string]any{"title": "Three"})
rec := env.member(t, http.MethodGet, "/v1/content-type/event/entry?sort=id:asc", "")
var page struct {
Items []map[string]any `json:"items"`
Total int `json:"total"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &page); err != nil || page.Total != 3 || len(page.Items) != 3 {
t.Fatalf("page %s", rec.Body.String())
}
for _, item := range page.Items {
if !reflect.DeepEqual(objectKeys(item), entryKeys) {
t.Errorf("keys %v", objectKeys(item))
}
}
if len(page.Items[0]["relations"].(map[string]any)) != 2 || page.Items[1]["relations"].(map[string]any)["venue"].(map[string]any)["missing"] != true || len(page.Items[2]["relations"].(map[string]any)) != 0 {
t.Errorf("relations %s", rec.Body.String())
}
})
t.Run("A type without a relation field returns empty relations and makes no target call", func(t *testing.T) {
env := newRelationEnv(t, false, false)
before := env.entries.getCalls
rec := env.member(t, http.MethodGet, "/v1/content-type/venue/entry/3", "")
body := decodeContentObject(t, rec)
if !reflect.DeepEqual(body["relations"], map[string]any{}) || env.entries.getCalls-before != 1 {
t.Errorf("body %s calls %d", rec.Body.String(), env.entries.getCalls-before)
}
})
t.Run("Many entries pointing at one target fetch it once", func(t *testing.T) {
env := newRelationEnv(t, false, false)
for id := uint32(200); id < 300; id++ {
env.seedData(t, "event", id, map[string]any{"title": "Event", "venue": float64(3)})
}
before := env.entries.getCalls
rec := env.member(t, http.MethodGet, "/v1/content-type/event/entry?size=100", "")
var page struct {
Items []map[string]any `json:"items"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &page); err != nil || len(page.Items) != 100 {
t.Fatalf("status %d items %d", rec.Code, len(page.Items))
}
if calls := env.entries.getCalls - before; calls > 1 {
t.Errorf("get calls %d", calls)
}
})
t.Run("Searching the entries of a type", func(t *testing.T) {
for _, example := range []struct {
q string
total int
}{{"rue", 3}, {"RUE%20VERTE", 2}, {"yard", 1}, {"nothing", 0}, {"", 3}, {"%20%20", 3}} {
t.Run(example.q, func(t *testing.T) {
env := newRelationEnv(t, false, false)
env.seed(t, "venue", 5, "title", "Rue du Port studio")
rec := env.member(t, http.MethodGet, "/v1/content-type/venue/entry?q="+example.q, "")
var page struct {
Total int `json:"total"`
}
json.Unmarshal(rec.Body.Bytes(), &page)
if rec.Code != http.StatusOK || page.Total != example.total {
t.Errorf("total %d: %s", page.Total, rec.Body.String())
}
})
}
})
t.Run("Search is paginated after filtering", func(t *testing.T) {
env := newRelationEnv(t, false, false)
for id := uint32(20); id < 50; id++ {
env.seed(t, "venue", id, "title", "Rue "+strconv.Itoa(int(id)))
}
rec := env.member(t, http.MethodGet, "/v1/content-type/venue/entry?q=rue&page=2&size=5&sort=id:asc", "")
var page struct {
Items []map[string]any `json:"items"`
Total int `json:"total"`
Page int `json:"page"`
Size int `json:"size"`
}
json.Unmarshal(rec.Body.Bytes(), &page)
if page.Total != 32 || page.Page != 2 || page.Size != 5 || len(page.Items) != 5 || page.Items[0]["id"] != float64(20+3) {
t.Errorf("page %+v", page)
}
})
t.Run("Deleting an entry that is referenced", func(t *testing.T) {
env := newRelationEnv(t, false, false)
env.seedData(t, "event", 5, map[string]any{"title": "E", "venue": float64(3)})
before := env.entries.entries[5]
expectStatusAndBody(t, env.member(t, http.MethodDelete, "/v1/content-type/venue/entry/3", ""), http.StatusNoContent, "")
if !reflect.DeepEqual(env.entries.entries[5], before) {
t.Error("the referencing entry changed")
}
})
t.Run("The target type, entries and API are unchanged", func(t *testing.T) {
env := newRelationEnv(t, false, false)
typeRec := env.member(t, http.MethodGet, "/v1/content-type/venue", "")
entryRec := env.member(t, http.MethodGet, "/v1/content-type/venue/entry/3", "")
if strings.Contains(typeRec.Body.String(), "Events") || len(decodeContentObject(t, typeRec)["fields"].([]any)) != 2 || strings.Contains(entryRec.Body.String(), "Events") {
t.Errorf("inverse leaked: %s %s", typeRec.Body.String(), entryRec.Body.String())
}
})
}
+253
View File
@@ -0,0 +1,253 @@
package test
import (
"RenewCMS/internal/domain/contenttype"
"RenewCMS/internal/domain/entry"
"RenewCMS/internal/infrastructure/useCases"
"errors"
"testing"
"time"
)
func relationSchema(t *testing.T, kind contenttype.RelationKind, required bool) []contenttype.Field {
t.Helper()
fields, err := contenttype.ValidateFields([]contenttype.Field{
{Key: "title", Label: "Title", Type: contenttype.TypeText, Required: true},
{Key: "link", Label: "Link", Type: contenttype.TypeRelation, Required: required, Relation: &contenttype.Relation{ContentType: "venue", Kind: kind}},
})
if err != nil {
t.Fatalf("schema: %v", err)
}
return fields
}
func TestRelationFieldDomain(t *testing.T) {
t.Run("contenttype.ValidateFields refuses what a relation field can't have", func(t *testing.T) {
valid := contenttype.Relation{ContentType: "venue", Kind: contenttype.RelationOneToMany}
refused := map[string]contenttype.Field{
"no relation": {},
"no target": {Relation: &contenttype.Relation{Kind: contenttype.RelationOneToMany}},
"bad kind": {Relation: &contenttype.Relation{ContentType: "venue", Kind: "many_to_one"}},
"unique": {Unique: true, Relation: &valid},
"default": {Default: float64(3), Relation: &valid},
"min": {Min: float64(1), Relation: &valid},
"max": {Max: float64(5), Relation: &valid},
"options": {Options: []string{"a"}, Relation: &valid},
}
for name, base := range refused {
base.Key, base.Label, base.Type = "link", "Link", contenttype.TypeRelation
if _, err := contenttype.ValidateFields([]contenttype.Field{base}); !errors.Is(err, contenttype.ErrInvalidField) {
t.Errorf("%s: %v", name, err)
}
}
onText := contenttype.Field{Key: "link", Label: "Link", Type: contenttype.TypeText, Relation: &valid}
if _, err := contenttype.ValidateFields([]contenttype.Field{onText}); !errors.Is(err, contenttype.ErrInvalidField) {
t.Errorf("relation on text: %v", err)
}
})
t.Run("entry.Validate applies required and type to a relation field", func(t *testing.T) {
single := relationSchema(t, contenttype.RelationOneToMany, true)
expectFieldError(t, entry.Validate(single, map[string]any{"title": "A"}), "link", entry.RuleRequired)
expectFieldError(t, entry.Validate(single, map[string]any{"title": "A", "link": nil}), "link", entry.RuleRequired)
expectFieldError(t, entry.Validate(single, map[string]any{"title": "A", "link": "3"}), "link", contenttype.RuleType)
expectFieldError(t, entry.Validate(single, map[string]any{"title": "A", "link": float64(0)}), "link", contenttype.RuleType)
expectFieldError(t, entry.Validate(single, map[string]any{"title": "A", "link": []any{float64(3)}}), "link", contenttype.RuleType)
if err := entry.Validate(single, map[string]any{"title": "A", "link": float64(3)}); err != nil {
t.Errorf("valid single: %v", err)
}
many := relationSchema(t, contenttype.RelationManyToMany, true)
expectFieldError(t, entry.Validate(many, map[string]any{"title": "A", "link": []any{}}), "link", entry.RuleRequired)
expectFieldError(t, entry.Validate(many, map[string]any{"title": "A", "link": float64(3)}), "link", contenttype.RuleType)
expectFieldError(t, entry.Validate(many, map[string]any{"title": "A", "link": []any{float64(3), float64(3)}}), "link", contenttype.RuleType)
if err := entry.Validate(many, map[string]any{"title": "A", "link": []any{float64(4), float64(3)}}); err != nil {
t.Errorf("valid many: %v", err)
}
if err := entry.Validate(relationSchema(t, contenttype.RelationManyToMany, false), map[string]any{"title": "A", "link": []any{}}); err != nil {
t.Errorf("empty optional many: %v", err)
}
})
}
type relationUseCaseEnv struct {
types *mockContentTypeRepo
entries *mockEntryRepo
now func() time.Time
}
func newRelationUseCaseEnv(t *testing.T, kind contenttype.RelationKind) *relationUseCaseEnv {
t.Helper()
env := &relationUseCaseEnv{types: newMockContentTypeRepo(), entries: newMockEntryRepo(), now: func() time.Time { return time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC) }}
venue, _ := env.types.Create(contenttype.ContentType{Key: "venue", Name: "Venue", Fields: []contenttype.Field{{Key: "title", Type: contenttype.TypeText}}})
env.entries.entries[3] = entry.Entry{ID: 3, ContentTypeID: venue.ID, Data: map[string]any{"title": "Hall"}}
env.types.Create(contenttype.ContentType{Key: "event", Name: "Event", Fields: relationSchema(t, kind, false)})
return env
}
func TestRelationFieldUseCases(t *testing.T) {
t.Run("CreateEntry and UpdateEntry refuse a missing target and perform no write", func(t *testing.T) {
env := newRelationUseCaseEnv(t, contenttype.RelationOneToMany)
images := newMockImageRepo()
create := useCases.NewCreateEntryUseCase(env.types, env.entries, images, env.now)
update := useCases.NewUpdateEntryUseCase(env.types, env.entries, images, env.now)
_, err := create.Create(useCases.CreateEntryCommand{ContentTypeKey: "event", Data: map[string]any{"title": "A", "link": float64(999)}, UserID: 1})
expectFieldError(t, err, "link", contenttype.RuleType)
stored, err := create.Create(useCases.CreateEntryCommand{ContentTypeKey: "event", Data: map[string]any{"title": "A", "link": float64(3)}, UserID: 1})
if err != nil || env.entries.createCalls != 1 {
t.Fatalf("create: %v", err)
}
_, err = update.Update(useCases.UpdateEntryCommand{ContentTypeKey: "event", ID: stored.ID, Data: map[string]any{"title": "A", "link": float64(999)}, UserID: 1})
expectFieldError(t, err, "link", contenttype.RuleType)
if env.entries.updateCalls != 0 || env.entries.createCalls != 1 {
t.Errorf("writes: %d %d", env.entries.updateCalls, env.entries.createCalls)
}
})
t.Run("UpdateContentType returns the sentinels for a new required relation and a changed shape", func(t *testing.T) {
env := newRelationUseCaseEnv(t, contenttype.RelationOneToMany)
env.entries.entries[9] = entry.Entry{ID: 9, ContentTypeID: 2, Data: map[string]any{"title": "A"}}
update := useCases.NewUpdateContentTypeUseCase(env.types, env.entries, env.now)
required := relationSchema(t, contenttype.RelationOneToMany, true)
changed := relationSchema(t, contenttype.RelationManyToMany, false)
_, err := update.Update(useCases.UpdateContentTypeCommand{Key: "event", Name: "Event", Fields: required})
if !errors.Is(err, contenttype.ErrSchemaIncompatible) {
t.Errorf("required: %v", err)
}
_, err = update.Update(useCases.UpdateContentTypeCommand{Key: "event", Name: "Event", Fields: changed})
if !errors.Is(err, contenttype.ErrInvalidField) {
t.Errorf("shape: %v", err)
}
})
t.Run("DeleteContentType returns ErrReferenced for a target type", func(t *testing.T) {
env := newRelationUseCaseEnv(t, contenttype.RelationOneToMany)
remove := useCases.NewDeleteContentTypeUseCase(env.types, env.entries)
if err := remove.Delete("venue"); !errors.Is(err, contenttype.ErrReferenced) {
t.Errorf("venue: %v", err)
}
if err := remove.Delete("event"); err != nil {
t.Errorf("event: %v", err)
}
})
t.Run("A relation lookup failure surfaces as an error", func(t *testing.T) {
env := newRelationUseCaseEnv(t, contenttype.RelationOneToMany)
env.entries.failGetOn = 1
get := useCases.NewGetEntryUseCase(env.types, env.entries, newMockImageRepo())
env.entries.entries[9] = entry.Entry{ID: 9, ContentTypeID: 2, Data: map[string]any{"title": "A", "link": float64(3)}}
env.entries.failGetOn = 2
if _, err := get.Get("event", 9); !errors.Is(err, errRepositoryFailure) {
t.Errorf("err %v", err)
}
})
}
type relationCountingTypeRepo struct {
*mockContentTypeRepo
getByKeyCalls int
failOnCall int
}
func (r *relationCountingTypeRepo) GetByKey(key string) (contenttype.ContentType, error) {
r.getByKeyCalls++
if r.failOnCall == r.getByKeyCalls {
return contenttype.ContentType{}, errRepositoryFailure
}
return r.mockContentTypeRepo.GetByKey(key)
}
func TestRelationFieldUseCaseEdges(t *testing.T) {
t.Run("types without relation fields never consult the target repositories", func(t *testing.T) {
types := &relationCountingTypeRepo{mockContentTypeRepo: newMockContentTypeRepo()}
entries := newMockEntryRepo()
types.Create(contenttype.ContentType{Key: "plain", Name: "Plain", Fields: []contenttype.Field{{Key: "title", Type: contenttype.TypeText}}})
now := func() time.Time { return time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC) }
images := newMockImageRepo()
created, err := useCases.NewCreateEntryUseCase(types, entries, images, now).Create(useCases.CreateEntryCommand{ContentTypeKey: "plain", Data: map[string]any{"title": "A"}, UserID: 1})
if err != nil || len(created.Relations) != 0 {
t.Fatalf("create: %v %v", err, created.Relations)
}
if types.getByKeyCalls != 1 || entries.getCalls != 0 {
t.Errorf("create calls: types %d entries %d", types.getByKeyCalls, entries.getCalls)
}
types.getByKeyCalls = 0
entries.getCalls = 0
if _, err := useCases.NewGetEntryUseCase(types, entries, images).Get("plain", created.ID); err != nil {
t.Fatalf("get: %v", err)
}
if types.getByKeyCalls != 1 || entries.getCalls != 1 {
t.Errorf("get calls: types %d entries %d", types.getByKeyCalls, entries.getCalls)
}
types.getByKeyCalls = 0
entries.getCalls = 0
updated, err := useCases.NewUpdateEntryUseCase(types, entries, images, now).Update(useCases.UpdateEntryCommand{ContentTypeKey: "plain", ID: created.ID, Data: map[string]any{"title": "B"}, UserID: 1})
if err != nil || len(updated.Relations) != 0 {
t.Fatalf("update: %v", err)
}
if types.getByKeyCalls != 1 || entries.getCalls != 1 {
t.Errorf("update calls: types %d entries %d", types.getByKeyCalls, entries.getCalls)
}
types.getByKeyCalls = 0
entries.getCalls = 0
if _, err := useCases.NewListEntriesUseCase(types, entries, images).ListPage(useCases.ListEntriesCommand{ContentTypeKey: "plain", Page: 1, Size: 10, Sort: entry.DefaultSort()}); err != nil {
t.Fatalf("list: %v", err)
}
if types.getByKeyCalls != 1 || entries.getCalls != 0 {
t.Errorf("list calls: types %d entries %d", types.getByKeyCalls, entries.getCalls)
}
})
t.Run("UpdateContentType surfaces a repository failure while checking relation targets", func(t *testing.T) {
env := newRelationUseCaseEnv(t, contenttype.RelationOneToMany)
types := &relationCountingTypeRepo{mockContentTypeRepo: env.types, failOnCall: 2}
update := useCases.NewUpdateContentTypeUseCase(types, env.entries, env.now)
_, err := update.Update(useCases.UpdateContentTypeCommand{Key: "event", Name: "Event", Fields: relationSchema(t, contenttype.RelationOneToMany, false)})
if !errors.Is(err, errRepositoryFailure) {
t.Errorf("err %v", err)
}
})
t.Run("UpdateContentType allows a field type change while no entry is stored", func(t *testing.T) {
env := newRelationUseCaseEnv(t, contenttype.RelationOneToMany)
update := useCases.NewUpdateContentTypeUseCase(env.types, env.entries, env.now)
fields := []contenttype.Field{{Key: "title", Label: "Title", Type: contenttype.TypeNumber}}
if _, err := update.Update(useCases.UpdateContentTypeCommand{Key: "event", Name: "Event", Fields: fields}); err != nil {
t.Errorf("err %v", err)
}
})
t.Run("DeleteContentType ignores a self reference", func(t *testing.T) {
env := newRelationUseCaseEnv(t, contenttype.RelationOneToMany)
env.types.Create(contenttype.ContentType{Key: "node", Name: "Node", Fields: []contenttype.Field{{Key: "parent", Type: contenttype.TypeRelation, Relation: &contenttype.Relation{ContentType: "node", Kind: contenttype.RelationOneToMany}}}})
if err := useCases.NewDeleteContentTypeUseCase(env.types, env.entries).Delete("node"); err != nil {
t.Errorf("err %v", err)
}
})
}
func TestRelationFieldIDLimit(t *testing.T) {
many := relationSchema(t, contenttype.RelationManyToMany, false)
build := func(count int) map[string]any {
ids := make([]any, 0, count)
for i := 1; i <= count; i++ {
ids = append(ids, float64(i))
}
return map[string]any{"title": "A", "link": ids}
}
if err := entry.Validate(many, build(contenttype.MaxRelationIDs)); err != nil {
t.Errorf("at limit: %v", err)
}
expectFieldError(t, entry.Validate(many, build(contenttype.MaxRelationIDs+1)), "link", contenttype.RuleType)
}
@@ -0,0 +1,48 @@
package test
import (
"RenewCMS/internal/domain/contenttype"
"RenewCMS/internal/domain/entry"
"RenewCMS/internal/infrastructure/useCases"
"testing"
"time"
)
func TestRelationFieldListQueryClampsPaging(t *testing.T) {
env := newRelationUseCaseEnv(t, contenttype.RelationOneToMany)
env.entries.entries[4] = entry.Entry{ID: 4, ContentTypeID: 1, Data: map[string]any{"title": "Arena"}}
list := useCases.NewListEntriesUseCase(env.types, env.entries, newMockImageRepo())
cases := []struct {
name string
page int
size int
items int
}{
{"page below one acts as page one", 0, 10, 2},
{"negative page acts as page one", -3, 1, 1},
{"negative size yields an empty page", 1, -1, 0},
}
for _, example := range cases {
t.Run(example.name, func(t *testing.T) {
page, err := list.ListPage(useCases.ListEntriesCommand{ContentTypeKey: "venue", Page: example.page, Size: example.size, Sort: entry.DefaultSort(), Query: "a"})
if err != nil {
t.Fatalf("err %v", err)
}
if len(page.Items) != example.items || page.Total != 2 {
t.Errorf("items %d total %d", len(page.Items), page.Total)
}
})
}
}
func TestRelationFieldTargetWithoutFieldsIsMissing(t *testing.T) {
env := newRelationUseCaseEnv(t, contenttype.RelationOneToMany)
venue, _ := env.types.GetByKey("venue")
venue.Fields = nil
env.types.types[venue.ID] = venue
create := useCases.NewCreateEntryUseCase(env.types, env.entries, newMockImageRepo(), func() time.Time { return time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC) })
_, err := create.Create(useCases.CreateEntryCommand{ContentTypeKey: "event", Data: map[string]any{"title": "A", "link": float64(3)}, UserID: 1})
expectFieldError(t, err, "link", contenttype.RuleType)
}