diff --git a/modules/cabana/openapi_conformance_test.go b/modules/cabana/openapi_conformance_test.go index 04a8bd0..5a93854 100644 --- a/modules/cabana/openapi_conformance_test.go +++ b/modules/cabana/openapi_conformance_test.go @@ -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]: