test(16.2-06): full unit coverage of the plugin field type framework
- pact: FormFieldError message joining, nil pointer, funcs never serialized - cabana: registry and option boot rules, compile (option kinds, general and core keys, CompileForm vs CompileFormWithTypes, settings and relation refusal), decode 422/500 split, localized and capped notices, Load, ListValue with the request ctx, field writes, field-types and upload routes - TestFieldTypeSavePostgres (Docker host): create, show, untouched and changed update, refused update, upload in and out of the record scope
This commit is contained in:
234
modules/cabana/field_type_postgres_test.go
Normal file
234
modules/cabana/field_type_postgres_test.go
Normal file
@@ -0,0 +1,234 @@
|
||||
package cabana
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// The plugin field type save, show and upload paths on a real Postgres
|
||||
// database (testcontainers, like newListService). Without Docker the test
|
||||
// fails rather than skips; it runs on a Docker host before /msd-verify-work.
|
||||
|
||||
// pgFTController scopes the notes form to rows whose title is not "hidden".
|
||||
type pgFTController struct{ ftController }
|
||||
|
||||
func (pgFTController) FormExtendQuery(_ context.Context, db *gorm.DB) *gorm.DB {
|
||||
return db.Where("title <> ?", "hidden")
|
||||
}
|
||||
|
||||
// pgFTPlugin serves pgFTController instead of ftController.
|
||||
type pgFTPlugin struct{ ftPlugin }
|
||||
|
||||
func (pgFTPlugin) AdminControllers() []pact.AdminController {
|
||||
return []pact.AdminController{pgFTController{}}
|
||||
}
|
||||
|
||||
// pgFTCall runs one admin record request against the notes controller.
|
||||
func pgFTCall(t *testing.T, svc *service, method, id string, body any) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
var raw []byte
|
||||
if body != nil {
|
||||
var err error
|
||||
if raw, err = json.Marshal(body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
req := httptest.NewRequest(method, "/", bytes.NewReader(raw))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
for name, value := range map[string]string{"vendor": "acme", "plugin": "demo", "controller": "notes"} {
|
||||
req.SetPathValue(name, value)
|
||||
}
|
||||
if id != "" {
|
||||
req.SetPathValue("id", id)
|
||||
}
|
||||
req = req.WithContext(bouncer.WithUser(req.Context(), superUser()))
|
||||
rec := httptest.NewRecorder()
|
||||
switch method {
|
||||
case http.MethodPost:
|
||||
svc.create(rec, req)
|
||||
case http.MethodGet:
|
||||
svc.show(rec, req)
|
||||
case http.MethodPut:
|
||||
svc.update(rec, req)
|
||||
default:
|
||||
t.Fatalf("method %s", method)
|
||||
}
|
||||
return rec
|
||||
}
|
||||
|
||||
// pgFTBody is one admin record response.
|
||||
type pgFTBody struct {
|
||||
Data map[string]any `json:"data"`
|
||||
Meta map[string]json.RawMessage `json:"meta"`
|
||||
}
|
||||
|
||||
func pgFTDecode(t *testing.T, rec *httptest.ResponseRecorder) pgFTBody {
|
||||
t.Helper()
|
||||
var out pgFTBody
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
||||
t.Fatalf("body %s: %v", rec.Body.String(), err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func pgFTNotices(t *testing.T, body pgFTBody) []FieldNotice {
|
||||
t.Helper()
|
||||
raw, ok := body.Meta["notices"]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
var out []FieldNotice
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestFieldTypeSavePostgres(t *testing.T) {
|
||||
_, db := newListService(t)
|
||||
if err := db.Migrator().DropTable(&ftNote{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&ftNote{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Migrator().DropTable(&ftNote{}) })
|
||||
|
||||
fsys := ftFS(ftNotesField, ftBodyField)
|
||||
fsys["controllers/notes/config_list.yaml"] = &fstest.MapFile{Data: []byte(ftListConfig + "toolbar:\n buttons: [create]\n")}
|
||||
reg, err := ftCompile(pgFTPlugin{ftPlugin{id: "acme.demo", fsys: fsys, types: []pact.FormFieldType{ftNotesType(), ftNotesMLType()}}})
|
||||
if err != nil {
|
||||
t.Fatalf("compile: %v", err)
|
||||
}
|
||||
writer := ftWriter()
|
||||
app := backpack.New(nil)
|
||||
if err := app.Publish(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := app.Publish[TranslationWriter](writer); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc := &service{app: app, reg: reg, uploadBytes: 4096}
|
||||
|
||||
row := func(id uint) ftNote {
|
||||
t.Helper()
|
||||
var out ftNote
|
||||
if err := db.First(&out, id).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Create: plain and translatable values decoded, notices per locale.
|
||||
rec := pgFTCall(t, svc, http.MethodPost, "", map[string]any{"title": "T", "notes": " n ", "body": map[string]any{"pl": " P ", "en": " E "}})
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("create status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
created := pgFTDecode(t, rec)
|
||||
idNum, _ := created.Data["id"].(float64)
|
||||
id := uint(idNum)
|
||||
if id == 0 {
|
||||
t.Fatalf("create data=%v", created.Data)
|
||||
}
|
||||
idText := strconv.FormatUint(uint64(id), 10)
|
||||
if got := row(id); got.Notes != "n" || got.Body != "P" || got.Title != "T" {
|
||||
t.Fatalf("created row=%+v", got)
|
||||
}
|
||||
if writer.attrs["en"]["body"] != "E" {
|
||||
t.Fatalf("en translation=%v", writer.attrs)
|
||||
}
|
||||
notices := pgFTNotices(t, created)
|
||||
if len(notices) != 3 {
|
||||
t.Fatalf("create notices=%+v", notices)
|
||||
}
|
||||
if created.Data["notes"] != "served:n" {
|
||||
t.Fatalf("create response serves notes=%v, want it through Load", created.Data["notes"])
|
||||
}
|
||||
|
||||
// Show serves every plugin field through Load, per locale.
|
||||
rec = pgFTCall(t, svc, http.MethodGet, idText, nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("show status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
shown := pgFTDecode(t, rec)
|
||||
body, _ := shown.Data["body"].(map[string]any)
|
||||
if shown.Data["notes"] != "served:n" || body["pl"] != "P" || body["en"] != "E" {
|
||||
t.Fatalf("show data=%v", shown.Data)
|
||||
}
|
||||
|
||||
// An untouched update writes nothing and reports nothing.
|
||||
before := row(id)
|
||||
writes := len(writer.ids)
|
||||
rec = pgFTCall(t, svc, http.MethodPut, idText, map[string]any{"title": "T", "notes": shown.Data["notes"], "body": shown.Data["body"]})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("untouched update status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if _, ok := pgFTDecode(t, rec).Meta["notices"]; ok {
|
||||
t.Fatalf("untouched update reports notices: %s", rec.Body.String())
|
||||
}
|
||||
if after := row(id); after != before {
|
||||
t.Fatalf("untouched update changed the row: %+v -> %+v", before, after)
|
||||
}
|
||||
if len(writer.ids) != writes {
|
||||
t.Fatalf("untouched update wrote translations: %v", writer.ids[writes:])
|
||||
}
|
||||
|
||||
// A changed update writes and reports only what changed.
|
||||
rec = pgFTCall(t, svc, http.MethodPut, idText, map[string]any{"title": "T", "notes": "m ", "body": map[string]any{"pl": "P", "en": " E2"}})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("changed update status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
changed := pgFTNotices(t, pgFTDecode(t, rec))
|
||||
if fmt.Sprint(changed) != fmt.Sprint([]FieldNotice{{Field: "notes", Message: "acme.demo::lang.trimmed"}, {Field: "body", Locale: "en", Message: "trimmed en"}}) {
|
||||
t.Fatalf("changed notices=%+v", changed)
|
||||
}
|
||||
if got := row(id); got.Notes != "m" || got.Body != "P" {
|
||||
t.Fatalf("changed row=%+v", got)
|
||||
}
|
||||
if writer.attrs["en"]["body"] != "E2" {
|
||||
t.Fatalf("en translation=%v", writer.attrs)
|
||||
}
|
||||
|
||||
// A FormFieldError is a 422 and the transaction writes nothing.
|
||||
before = row(id)
|
||||
rec = pgFTCall(t, svc, http.MethodPut, idText, map[string]any{"title": "Changed", "notes": "bad"})
|
||||
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), `"notes":["acme.demo::lang.bad"]`) {
|
||||
t.Fatalf("refused update status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if after := row(id); after != before {
|
||||
t.Fatalf("refused update changed the row: %+v -> %+v", before, after)
|
||||
}
|
||||
|
||||
// Upload with a real record: the type sees it; a record outside the
|
||||
// FormExtendQuery scope is 404.
|
||||
admin := &bouncer.Principal{ID: 1, Backend: true, IsSuperuser: true}
|
||||
up := upCall(t, svc, idText, "notes", []upPart{{"file", "a.txt", []byte("hello")}}, admin)
|
||||
if up.Code != http.StatusCreated || !strings.Contains(up.Body.String(), "record=set") {
|
||||
t.Fatalf("upload status=%d body=%s", up.Code, up.Body.String())
|
||||
}
|
||||
hidden := ftNote{Title: "hidden"}
|
||||
if err := db.Create(&hidden).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
up = upCall(t, svc, strconv.FormatUint(uint64(hidden.ID), 10), "notes", []upPart{{"file", "a.txt", []byte("hello")}}, admin)
|
||||
if up.Code != http.StatusNotFound {
|
||||
t.Fatalf("out-of-scope upload status=%d body=%s", up.Code, up.Body.String())
|
||||
}
|
||||
up = upCall(t, svc, "999999", "notes", []upPart{{"file", "a.txt", []byte("hello")}}, admin)
|
||||
if up.Code != http.StatusNotFound {
|
||||
t.Fatalf("missing record upload status=%d body=%s", up.Code, up.Body.String())
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
88
modules/pact/field_type_test.go
Normal file
88
modules/pact/field_type_test.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package pact
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFormFieldErrorMessage(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
messages []string
|
||||
want string
|
||||
}{
|
||||
{nil, ""},
|
||||
{[]string{}, ""},
|
||||
{[]string{"acme.demo::lang.bad"}, "acme.demo::lang.bad"},
|
||||
{[]string{"first", "second", "third"}, "first; second; third"},
|
||||
} {
|
||||
err := &FormFieldError{Messages: tc.messages}
|
||||
if got := err.Error(); got != tc.want {
|
||||
t.Fatalf("Error(%q) = %q, want %q", tc.messages, got, tc.want)
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("a nil pointer does not panic", func(t *testing.T) {
|
||||
var err *FormFieldError
|
||||
if got := err.Error(); got != "" {
|
||||
t.Fatalf("nil Error() = %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("it is found through a wrapped error", func(t *testing.T) {
|
||||
wrapped := fmt.Errorf("decode: %w", &FormFieldError{Messages: []string{"a", "b"}})
|
||||
var refused *FormFieldError
|
||||
if !errors.As(wrapped, &refused) || refused.Error() != "a; b" {
|
||||
t.Fatalf("errors.As = %v, %v", refused, wrapped)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestFormFieldTypeContract pins the plugin-facing shape: the funcs are
|
||||
// never serialized, and a plugin implements HasFormFieldTypes.
|
||||
func TestFormFieldTypeContract(t *testing.T) {
|
||||
typ := FormFieldType{
|
||||
Name: "notes",
|
||||
Element: "acme-demo-notes",
|
||||
Translatable: true,
|
||||
Scripts: []string{"assets/js/notes.js"},
|
||||
Styles: []string{"assets/css/notes.css"},
|
||||
Options: []string{"rows"},
|
||||
CheckOptions: func(map[string]any) error { return nil },
|
||||
Decode: func(context.Context, FormFieldValue) (FormFieldDecoded, error) {
|
||||
return FormFieldDecoded{}, nil
|
||||
},
|
||||
Load: func(context.Context, FormFieldValue) (any, error) { return nil, nil },
|
||||
ListValue: func(context.Context, any) string { return "" },
|
||||
Upload: func(context.Context, FormFieldUpload) (FormFieldUploadResult, error) {
|
||||
return FormFieldUploadResult{}, nil
|
||||
},
|
||||
}
|
||||
raw, err := json.Marshal(typ)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var keys map[string]any
|
||||
if err := json.Unmarshal(raw, &keys); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, fn := range []string{"CheckOptions", "Decode", "Load", "ListValue", "Upload"} {
|
||||
if _, ok := keys[fn]; ok {
|
||||
t.Fatalf("%s is serialized: %s", fn, raw)
|
||||
}
|
||||
}
|
||||
if keys["Name"] != "notes" || keys["Element"] != "acme-demo-notes" || keys["Translatable"] != true {
|
||||
t.Fatalf("json = %s", raw)
|
||||
}
|
||||
|
||||
var src HasFormFieldTypes = fieldTypePlugin{types: []FormFieldType{typ}}
|
||||
if got := src.FormFieldTypes(); len(got) != 1 || got[0].Name != "notes" {
|
||||
t.Fatalf("FormFieldTypes() = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
type fieldTypePlugin struct{ types []FormFieldType }
|
||||
|
||||
func (p fieldTypePlugin) FormFieldTypes() []FormFieldType { return p.types }
|
||||
Reference in New Issue
Block a user