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:
@@ -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]:
|
||||
|
||||
Reference in New Issue
Block a user