test(16.2-07): conformance cases for the field-types and field upload routes

- conformCases() lists every case at package level; checkConformInventory
  reports every missing, extra or duplicate key in one sorted failure
- TestPhase10ConformanceInventory runs it without a database, and
  TestPhase10OpenAPIConformance runs it before the testcontainers setup
- GET /field-types decodes into cabana.FieldTypesResponse and serves the
  fixture type's script; POST .../fields/{field}/upload answers 201
  cabana.Envelope[cabana.FieldUploadResponse] for a multipart part file
- the fixture plugin registers conformnote and the gadget form uses it,
  so create, update and show run its Decode and Load (G-16.2-1, D-13, D-18)
This commit is contained in:
2026-10-10 18:33:03 +02:00
parent d0b7fe4571
commit 11be08d680

View File

@@ -10,6 +10,7 @@ import (
"image"
"image/color"
"image/png"
"io"
"io/fs"
"mime/multipart"
"net/http"
@@ -17,6 +18,7 @@ import (
"os"
"path/filepath"
"runtime"
"sort"
"strings"
"testing"
"testing/fstest"
@@ -126,11 +128,107 @@ func (stubMLSchemaController) ID() string { return "acme.demo.widgets" }
func (stubMLSchemaController) ModelName() string { return "Widget" }
func (stubMLSchemaController) ConfigDir() string { return "controllers/widgets" }
// TestPhase10ConformanceInventory checks without a database that every
// inventoried admin route has exactly one conformance case, so a new admin
// route without a case fails on any host (G-16.2-1).
func TestPhase10ConformanceInventory(t *testing.T) {
checkConformInventory(t, conformCases())
}
// checkConformInventory fails once, listing in sorted order every
// inventoried admin route without a conformance case, every case that names
// no inventoried route and every duplicate case.
func checkConformInventory(t *testing.T, cases []conformCase) {
t.Helper()
inventory := map[string]bool{}
for _, key := range cabana.AdminAPIRoutes() {
inventory[key] = true
}
seen := map[string]int{}
for _, tc := range cases {
seen[tc.key]++
}
var missing, extra, duplicate []string
for key := range inventory {
if seen[key] == 0 {
missing = append(missing, key)
}
}
for key, n := range seen {
if !inventory[key] {
extra = append(extra, key)
}
if n > 1 {
duplicate = append(duplicate, key)
}
}
if len(missing)+len(extra)+len(duplicate) == 0 {
return
}
sort.Strings(missing)
sort.Strings(extra)
sort.Strings(duplicate)
var b strings.Builder
for _, group := range []struct {
title string
keys []string
}{
{"admin routes without a conformance case (add one to conformCases)", missing},
{"conformance cases that name no inventoried admin route", extra},
{"duplicate conformance cases", duplicate},
} {
if len(group.keys) == 0 {
continue
}
fmt.Fprintf(&b, "\n%s:", group.title)
for _, key := range group.keys {
fmt.Fprintf(&b, "\n %s", key)
}
}
t.Fatalf("conformance inventory does not match the admin routes:%s", b.String())
}
func TestPhase10OpenAPIConformance(t *testing.T) {
cases := conformCases()
checkConformInventory(t, cases)
env := newConformEnv(t)
spec := conformSpec(t)
cases := []conformCase{
for _, tc := range cases {
ok := t.Run(tc.key, func(t *testing.T) {
rec := tc.call(t, env)
if rec.Code != tc.status {
t.Fatalf("status=%d want %d body=%s", rec.Code, tc.status, rec.Body.String())
}
method, path, _ := strings.Cut(tc.key, " ")
if tc.raw != nil {
tc.raw(t, rec)
if !spec.binary(path, strings.ToLower(method), fmt.Sprint(tc.status)) {
t.Fatalf("admin.json does not document %s %d as binary", tc.key, tc.status)
}
return
}
dec := json.NewDecoder(bytes.NewReader(rec.Body.Bytes()))
dec.DisallowUnknownFields()
if err := tc.decode(dec); err != nil {
t.Fatalf("body does not match its documented type: %v\n%s", err, rec.Body.String())
}
got := spec.ref(path, strings.ToLower(method), fmt.Sprint(tc.status))
if got != tc.ref {
t.Fatalf("admin.json documents %q for %s %d, the handler writes %s", got, tc.key, tc.status, tc.ref)
}
})
if !ok {
t.FailNow()
}
}
}
// conformCases is the conformance case of every inventoried admin route, in
// the order TestPhase10OpenAPIConformance calls them: later cases use the
// records earlier ones create.
func conformCases() []conformCase {
return []conformCase{
{"POST /auth/login", 200, "cabana.Envelope-cabana_AdminLoginData", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodPost, "/auth/login", map[string]string{"login": e.login, "password": adminTestPassword}, false)
}, into[cabana.Envelope[cabana.AdminLoginData]](), nil},
@@ -191,6 +289,45 @@ func TestPhase10OpenAPIConformance(t *testing.T) {
{"GET /{vendor}/{plugin}/{controller}/filters/{scope}/options", 200, "cabana.Envelope-array_cabana_FilterOption", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodGet, "/acme/conform/gadgets/filters/grouped/options", nil, true)
}, into[cabana.Envelope[[]cabana.FilterOption]](), nil},
{"GET /field-types", 200, "cabana.FieldTypesResponse", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
rec := e.send(t, http.MethodGet, "/field-types", nil, true)
var body cabana.FieldTypesResponse
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("field types: %v\n%s", err, rec.Body.String())
}
var found *cabana.FieldTypeAssets
for i := range body.Data {
if body.Data[i].Name == conformNoteType {
found = &body.Data[i]
}
}
if found == nil || found.Element != conformNoteElement || found.Translatable || len(found.Scripts) != 1 || len(found.Styles) != 0 {
t.Fatalf("field types = %s, want %s with its element and one script", rec.Body.String(), conformNoteType)
}
e.assertPluginAsset(t, found.Scripts[0], "text/javascript; charset=utf-8")
return rec
}, into[cabana.FieldTypesResponse](), nil},
{"POST /{vendor}/{plugin}/{controller}/{id}/fields/{field}/upload", 201, "cabana.Envelope-cabana_FieldUploadResponse", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
part, err := mw.CreateFormFile("file", "note.txt")
if err != nil {
t.Fatal(err)
}
if _, err := part.Write([]byte("conform note " + e.stamp)); err != nil {
t.Fatal(err)
}
if err := mw.Close(); err != nil {
t.Fatal(err)
}
// {id} 0 is the create form: Upload receives no record.
rec := e.sendWith(t, http.MethodPost, "/acme/conform/gadgets/0/fields/note/upload", buf.Bytes(), mw.FormDataContentType(), map[string]string{"X-Requested-With": "XMLHttpRequest"})
var body cabana.Envelope[cabana.FieldUploadResponse]
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || body.Data.URL != fmt.Sprintf("/media/conform/note.txt?bytes=%d&record=nil", len("conform note "+e.stamp)) {
t.Fatalf("field upload body = %s (%v)", rec.Body.String(), err)
}
return rec
}, into[cabana.Envelope[cabana.FieldUploadResponse]](), nil},
{"POST /{vendor}/{plugin}/{controller}/{id}/files/{field}", 201, "cabana.Envelope-cabana_FileItem", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
rec := e.upload(t, 0, "photos", "photo.png", conformPNG(t), e.sessionKey)
e.photoID = dataID(t, rec.Body.Bytes())
@@ -212,8 +349,13 @@ func TestPhase10OpenAPIConformance(t *testing.T) {
return e.sendWith(t, http.MethodDelete, fmt.Sprintf("/acme/conform/gadgets/0/files/photos/%d", e.photoID), nil, "", map[string]string{cabana.SessionKeyHeader: e.sessionKey})
}, into[cabana.Envelope[cabana.FileMutationResult]](), nil},
{"POST /{vendor}/{plugin}/{controller}", 201, "cabana.RecordEnvelope", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
rec := e.send(t, http.MethodPost, "/acme/conform/gadgets", map[string]any{"name": "gadget-" + e.stamp, "active": true, "group": e.groupID, "released_on": "2026-10-02", "starts_at": "2026-10-02T12:30:00+02:00"}, true)
rec := e.send(t, http.MethodPost, "/acme/conform/gadgets", map[string]any{"name": "gadget-" + e.stamp, "active": true, "group": e.groupID, "released_on": "2026-10-02", "starts_at": "2026-10-02T12:30:00+02:00", "note": " note-" + e.stamp + " "}, true)
e.gadgetID = dataID(t, rec.Body.Bytes())
// Decode trimmed the note before it was stored.
var stored conformGadget
if err := e.db.First(&stored, e.gadgetID).Error; err != nil || stored.Note == nil || *stored.Note != "note-"+e.stamp {
t.Fatalf("stored note = %#v (%v), want the decoded value", stored.Note, err)
}
return rec
}, into[cabana.RecordEnvelope](), nil},
{"PUT /{vendor}/{plugin}/{controller}/{id}/files/{field}/{file}", 200, "cabana.Envelope-cabana_FileItem", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
@@ -259,10 +401,14 @@ func TestPhase10OpenAPIConformance(t *testing.T) {
return e.send(t, http.MethodGet, "/acme/conform/gadgets?search="+e.stamp, nil, true)
}, into[cabana.ListEnvelope[[]cabana.AdminRecord]](), nil},
{"GET /{vendor}/{plugin}/{controller}/{id}", 200, "cabana.RecordEnvelope", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodGet, fmt.Sprintf("/acme/conform/gadgets/%d", e.gadgetID), nil, true)
rec := e.send(t, http.MethodGet, fmt.Sprintf("/acme/conform/gadgets/%d", e.gadgetID), nil, true)
if !strings.Contains(rec.Body.String(), `"note":"loaded:note-`+e.stamp+`"`) {
t.Fatalf("show does not serve the note through Load: %s", rec.Body.String())
}
return rec
}, into[cabana.RecordEnvelope](), nil},
{"PUT /{vendor}/{plugin}/{controller}/{id}", 200, "cabana.RecordEnvelope", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodPut, fmt.Sprintf("/acme/conform/gadgets/%d", e.gadgetID), map[string]any{"name": "gadget-" + e.stamp + "-renamed", "group": nil, "released_on": "2026-10-03", "starts_at": nil}, true)
return e.send(t, http.MethodPut, fmt.Sprintf("/acme/conform/gadgets/%d", e.gadgetID), map[string]any{"name": "gadget-" + e.stamp + "-renamed", "group": nil, "released_on": "2026-10-03", "starts_at": nil, "note": "note-" + e.stamp + "-renamed"}, true)
}, into[cabana.RecordEnvelope](), nil},
{"GET /{vendor}/{plugin}/{controller}/{id}/relations/{name}/candidates", 200, "cabana.ListEnvelope-array_cabana_AdminRecord", func(t *testing.T, e *conformEnv) *httptest.ResponseRecorder {
return e.send(t, http.MethodGet, fmt.Sprintf("/acme/conform/gadgets/%d/relations/members/candidates?search=%s", e.gadgetID, e.stamp), nil, true)
@@ -398,55 +544,6 @@ func TestPhase10OpenAPIConformance(t *testing.T) {
return e.send(t, http.MethodPost, "/auth/logout", nil, true)
}, into[cabana.Envelope[cabana.AdminLogoutData]](), nil},
}
inventory := map[string]bool{}
for _, key := range cabana.AdminAPIRoutes() {
inventory[key] = true
}
covered := map[string]bool{}
for _, tc := range cases {
if !inventory[tc.key] {
t.Fatalf("conformance case %s is not an inventoried admin route", tc.key)
}
if covered[tc.key] {
t.Fatalf("duplicate conformance case %s", tc.key)
}
covered[tc.key] = true
}
for key := range inventory {
if !covered[key] {
t.Fatalf("admin route %s has no conformance case", key)
}
}
for _, tc := range cases {
ok := t.Run(tc.key, func(t *testing.T) {
rec := tc.call(t, env)
if rec.Code != tc.status {
t.Fatalf("status=%d want %d body=%s", rec.Code, tc.status, rec.Body.String())
}
method, path, _ := strings.Cut(tc.key, " ")
if tc.raw != nil {
tc.raw(t, rec)
if !spec.binary(path, strings.ToLower(method), fmt.Sprint(tc.status)) {
t.Fatalf("admin.json does not document %s %d as binary", tc.key, tc.status)
}
return
}
dec := json.NewDecoder(bytes.NewReader(rec.Body.Bytes()))
dec.DisallowUnknownFields()
if err := tc.decode(dec); err != nil {
t.Fatalf("body does not match its documented type: %v\n%s", err, rec.Body.String())
}
got := spec.ref(path, strings.ToLower(method), fmt.Sprint(tc.status))
if got != tc.ref {
t.Fatalf("admin.json documents %q for %s %d, the handler writes %s", got, tc.key, tc.status, tc.ref)
}
})
if !ok {
t.FailNow()
}
}
}
type conformSpecDoc struct {
@@ -734,12 +831,14 @@ type conformGadget struct {
Active bool `gorm:"column:active"`
GroupID *uint `gorm:"column:group_id"`
// ReleasedOn and StartsAt are the date and datetime datepicker columns.
ReleasedOn lagoon.Date `gorm:"column:released_on;type:date"`
StartsAt *time.Time `gorm:"column:starts_at"`
Members []conformMember `gorm:"-"`
Parts []conformPart `gorm:"-"`
CreatedAt time.Time `gorm:"column:created_at"`
UpdatedAt time.Time `gorm:"column:updated_at"`
ReleasedOn lagoon.Date `gorm:"column:released_on;type:date"`
StartsAt *time.Time `gorm:"column:starts_at"`
// Note is the plugin-typed field (conformNoteType).
Note *string `gorm:"column:note"`
Members []conformMember `gorm:"-"`
Parts []conformPart `gorm:"-"`
CreatedAt time.Time `gorm:"column:created_at"`
UpdatedAt time.Time `gorm:"column:updated_at"`
}
func (conformGadget) TableName() string { return "cabana_conform_gadgets" }
@@ -748,7 +847,7 @@ func (conformGadget) AttachRelations() []attach.Relation {
return []attach.Relation{{Name: "photos", Many: true, Public: true}, {Name: "manual"}}
}
func (conformGadget) Fillable() []string {
return []string{"name", "active", "released_on", "starts_at"}
return []string{"name", "active", "released_on", "starts_at", "note"}
}
func (conformGadget) Rules() map[string]string { return map[string]string{"name": "required"} }
func (conformGadget) FilterScopes() []string { return []string{"filterByGroup"} }
@@ -842,6 +941,55 @@ func (conformPlugin) Settings() []pact.SettingsItem {
}
func (conformPlugin) AdminFS() fs.FS { return conformFS() }
const (
// conformNoteType is the fixture plugin's field type alias.
conformNoteType = "conformnote"
// conformNoteElement is its custom element, under the acme-conform- prefix.
conformNoteElement = "acme-conform-note"
)
// FormFieldTypes registers one plugin field type, so GET /field-types lists
// it and the gadget form's note field can upload: Decode trims the value,
// Load prefixes "loaded:", Upload answers a URL naming the bytes it read.
func (conformPlugin) FormFieldTypes() []pact.FormFieldType {
return []pact.FormFieldType{{
Name: conformNoteType,
Element: conformNoteElement,
Scripts: []string{"assets/js/lookup.js"},
Decode: func(_ context.Context, in pact.FormFieldValue) (pact.FormFieldDecoded, error) {
text, ok := in.Value.(string)
if !ok {
return pact.FormFieldDecoded{Value: in.Value}, nil
}
return pact.FormFieldDecoded{Value: strings.TrimSpace(text)}, nil
},
Load: func(_ context.Context, in pact.FormFieldValue) (any, error) {
switch v := in.Value.(type) {
case string:
return "loaded:" + v, nil
case *string:
if v != nil {
return "loaded:" + *v, nil
}
}
return in.Value, nil
},
Upload: func(_ context.Context, in pact.FormFieldUpload) (pact.FormFieldUploadResult, error) {
body, err := io.ReadAll(in.Body)
if err != nil {
return pact.FormFieldUploadResult{}, err
}
record := "nil"
if in.Record != nil {
record = "set"
}
return pact.FormFieldUploadResult{URL: fmt.Sprintf("/media/conform/%s?bytes=%d&record=%s", in.Filename, len(body), record)}, nil
},
}}
}
var _ pact.HasFormFieldTypes = conformPlugin{}
type conformController struct{ stamp string }
func (conformController) ID() string { return "acme.conform.gadgets" }
@@ -1109,6 +1257,9 @@ parts:
fileTypes: [pdf, png, svg, txt]
useCaption: true
context: update
note:
label: Note
type: conformnote
`),
"models/member/pivot_fields.yaml": file(`fields:
pivot[note]: