- TestFormExtensionPlacement, TestFormExtensionBootErrors, TestFormExtensionTwoPluginsSameAnchor, TestFormExtensionOnePluginTwoControllers, TestFormExtensionUnextendedSchemaUnchanged, TestFormExtensionWidgetsAndPartials - TestFormExtensionSave (Postgres): rollback, 422 lines, notices, invisible extensions, locale maps, hidden owner fields - checkFormExtensionTabLabels declared (no-op) so the tests compile
536 lines
20 KiB
Go
536 lines
20 KiB
Go
package cabana
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io/fs"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"testing/fstest"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
|
"git.golem15.com/golem15/summercms/modules/bouncer"
|
|
"git.golem15.com/golem15/summercms/modules/pact"
|
|
"git.golem15.com/golem15/summercms/modules/party"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// The D-91 form extension save, show and schema paths on a real Postgres
|
|
// database (testcontainers, like newListService): an owner plugin acme.blog
|
|
// with the posts controller, and a contributor acme.extra that adds fields
|
|
// and stores their values in its own table.
|
|
|
|
// fePost is the owner's model: columns a and b only.
|
|
type fePost struct {
|
|
ID uint `gorm:"column:id;primaryKey"`
|
|
A string `gorm:"column:a"`
|
|
B string `gorm:"column:b"`
|
|
}
|
|
|
|
func (fePost) TableName() string { return "acme_blog_posts" }
|
|
func (*fePost) Fillable() []string { return []string{"a", "b"} }
|
|
func (*fePost) Rules() map[string]string { return map[string]string{} }
|
|
|
|
// feExtraValue is the contributor's own table: one value per post.
|
|
type feExtraValue struct {
|
|
RecordID uint `gorm:"column:record_id;primaryKey;autoIncrement:false"`
|
|
Value string `gorm:"column:value"`
|
|
}
|
|
|
|
func (feExtraValue) TableName() string { return "acme_extra_values" }
|
|
|
|
// fePosts is the owner's admin controller.
|
|
type fePosts struct{}
|
|
|
|
func (fePosts) ID() string { return "acme.blog.posts" }
|
|
func (fePosts) ModelName() string { return "Post" }
|
|
func (fePosts) ConfigDir() string { return "controllers/posts" }
|
|
func (fePosts) NewRecord() any { return &fePost{} }
|
|
func (fePosts) RequiredPermissions() []string { return []string{"acme.blog.access"} }
|
|
|
|
// feOwner is the owner plugin; fields is the body of its fields.yaml.
|
|
type feOwner struct {
|
|
fields string
|
|
}
|
|
|
|
func (feOwner) ID() string { return "acme.blog" }
|
|
func (feOwner) Requires() []string { return nil }
|
|
func (feOwner) Register(*backpack.App) error { return nil }
|
|
func (feOwner) Boot(*backpack.App) error { return nil }
|
|
func (p feOwner) AdminFS() fs.FS { return feOwnerFS(p.fields) }
|
|
func (feOwner) AdminControllers() []pact.AdminController {
|
|
return []pact.AdminController{fePosts{}}
|
|
}
|
|
|
|
// feOwnerFields is the owner form of the tracer: a and b, no tabs.
|
|
const feOwnerFields = "fields:\n a:\n type: text\n label: A\n b:\n type: text\n label: B\n"
|
|
|
|
func feOwnerFS(fields string) fstest.MapFS {
|
|
return fstest.MapFS{
|
|
"controllers/posts/config_form.yaml": &fstest.MapFile{Data: []byte("name: Post\nform: ~/plugins/acme/blog/models/post/fields.yaml\nmodelClass: Post\n")},
|
|
"controllers/posts/config_list.yaml": &fstest.MapFile{Data: []byte("list: ~/plugins/acme/blog/models/post/columns.yaml\nmodelClass: Post\nrecordsPerPage: 20\ntoolbar:\n buttons: [create]\n")},
|
|
"models/post/columns.yaml": &fstest.MapFile{Data: []byte("columns:\n a:\n label: A\n")},
|
|
"models/post/fields.yaml": &fstest.MapFile{Data: []byte(fields)},
|
|
}
|
|
}
|
|
|
|
// feContributor is a plugin that extends other plugins' forms.
|
|
type feContributor struct {
|
|
id string
|
|
requires []string
|
|
fsys fstest.MapFS
|
|
types []pact.FormFieldType
|
|
extensions []pact.AdminActionExtension
|
|
}
|
|
|
|
func (p feContributor) ID() string { return p.id }
|
|
func (p feContributor) Requires() []string { return p.requires }
|
|
func (feContributor) Register(*backpack.App) error { return nil }
|
|
func (feContributor) Boot(*backpack.App) error { return nil }
|
|
func (p feContributor) AdminFS() fs.FS { return p.fsys }
|
|
func (p feContributor) FormFieldTypes() []pact.FormFieldType { return p.types }
|
|
func (p feContributor) AdminActionExtensions() []pact.AdminActionExtension {
|
|
return p.extensions
|
|
}
|
|
|
|
var (
|
|
_ party.Plugin = feContributor{}
|
|
_ pact.HasAdminActionExtensions = feContributor{}
|
|
)
|
|
|
|
// feCompile runs the boot path: field types, the registry, then the form
|
|
// extensions, as Activate does.
|
|
func feCompile(plugins ...party.Plugin) (*Registry, error) {
|
|
reg, err := ftCompile(plugins...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := compileFormExtensions(reg, plugins); err != nil {
|
|
return nil, err
|
|
}
|
|
return reg, nil
|
|
}
|
|
|
|
// feStore is the contributor's storage spy: every Save call's input, the
|
|
// owner row as Save read it through the save transaction, and failures to
|
|
// return.
|
|
type feStore struct {
|
|
mu sync.Mutex
|
|
saves []pact.AdminFormSave
|
|
ownerA []string
|
|
fail error
|
|
notices map[string][]pact.FormFieldNotice
|
|
}
|
|
|
|
func (s *feStore) save(_ context.Context, in pact.AdminFormSave) (pact.AdminFormSaveResult, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.saves = append(s.saves, in)
|
|
var a string
|
|
if err := in.DB.Raw("SELECT a FROM acme_blog_posts WHERE id = ?", in.ID).Scan(&a).Error; err != nil {
|
|
return pact.AdminFormSaveResult{}, err
|
|
}
|
|
s.ownerA = append(s.ownerA, a)
|
|
if value, ok := in.Values["c"]; ok {
|
|
text, _ := value.(string)
|
|
id, _ := strconv.ParseUint(in.ID, 10, 64)
|
|
row := feExtraValue{RecordID: uint(id), Value: text}
|
|
if err := in.DB.Save(&row).Error; err != nil {
|
|
return pact.AdminFormSaveResult{}, err
|
|
}
|
|
}
|
|
if s.fail != nil {
|
|
return pact.AdminFormSaveResult{}, s.fail
|
|
}
|
|
return pact.AdminFormSaveResult{Notices: s.notices}, nil
|
|
}
|
|
|
|
func (s *feStore) load(_ context.Context, in pact.AdminFormLoad) (map[string]any, error) {
|
|
var row feExtraValue
|
|
err := in.DB.Where("record_id = ?", in.ID).Limit(1).Find(&row).Error
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return map[string]any{"c": row.Value, "a": "never served"}, nil
|
|
}
|
|
|
|
// feExtraFS is the contributor's tree with one fields document.
|
|
func feExtraFS(doc string) fstest.MapFS {
|
|
return fstest.MapFS{"extensions/posts.yaml": &fstest.MapFile{Data: []byte(doc)}}
|
|
}
|
|
|
|
// feCall runs one admin request against a controller of svc.
|
|
func feCall(t *testing.T, svc *service, principal *bouncer.Principal, controller, method, route, 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")
|
|
parts := strings.SplitN(controller, ".", 3)
|
|
req.SetPathValue("vendor", parts[0])
|
|
req.SetPathValue("plugin", parts[1])
|
|
req.SetPathValue("controller", parts[2])
|
|
if id != "" {
|
|
req.SetPathValue("id", id)
|
|
}
|
|
req = req.WithContext(bouncer.WithUser(req.Context(), principal))
|
|
rec := httptest.NewRecorder()
|
|
switch route {
|
|
case "schema":
|
|
svc.formSchema(rec, req)
|
|
case "record":
|
|
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)
|
|
}
|
|
default:
|
|
t.Fatalf("route %s", route)
|
|
}
|
|
return rec
|
|
}
|
|
|
|
// feSchemaNames is the field name list of a form schema response.
|
|
func feSchemaNames(t *testing.T, rec *httptest.ResponseRecorder) []string {
|
|
t.Helper()
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("schema status=%d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
var out struct {
|
|
Data struct {
|
|
Fields []struct {
|
|
Name string `json:"name"`
|
|
Tab string `json:"tab"`
|
|
} `json:"fields"`
|
|
} `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
names := make([]string, 0, len(out.Data.Fields))
|
|
for _, field := range out.Data.Fields {
|
|
names = append(names, field.Name)
|
|
}
|
|
return names
|
|
}
|
|
|
|
// feEnv is a Postgres-backed admin service with the owner and contributor.
|
|
type feEnv struct {
|
|
db *gorm.DB
|
|
svc *service
|
|
}
|
|
|
|
func newFEEnv(t *testing.T, plugins ...party.Plugin) feEnv {
|
|
t.Helper()
|
|
_, db := newListService(t)
|
|
if err := db.Migrator().DropTable(&fePost{}, &feExtraValue{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.AutoMigrate(&fePost{}, &feExtraValue{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = db.Migrator().DropTable(&fePost{}, &feExtraValue{}) })
|
|
reg, err := feCompile(plugins...)
|
|
if err != nil {
|
|
t.Fatalf("compile: %v", err)
|
|
}
|
|
app := backpack.New(nil)
|
|
if err := app.Publish(db); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return feEnv{db: db, svc: &service{app: app, reg: reg}}
|
|
}
|
|
|
|
func (e feEnv) post(t *testing.T, id uint) fePost {
|
|
t.Helper()
|
|
var out fePost
|
|
if err := e.db.First(&out, id).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (e feEnv) extra(t *testing.T, id uint) (string, bool) {
|
|
t.Helper()
|
|
var rows []feExtraValue
|
|
if err := e.db.Where("record_id = ?", id).Find(&rows).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(rows) == 0 {
|
|
return "", false
|
|
}
|
|
return rows[0].Value, true
|
|
}
|
|
|
|
// TestFormExtensionTracer (SC-1, D-91): plugin acme.extra adds field c after
|
|
// a on acme.blog's form of a and b. The schema lists a, c, b; an update
|
|
// stores a and b in the owner row and c in the contributor's table in the
|
|
// same transaction; show serves c; the owner model never gains a c column.
|
|
func TestFormExtensionTracer(t *testing.T) {
|
|
store := &feStore{}
|
|
extra := feContributor{
|
|
id: "acme.extra",
|
|
fsys: feExtraFS("fields:\n c:\n type: text\n label: C\n after: a\n"),
|
|
extensions: []pact.AdminActionExtension{{
|
|
Controller: "acme.blog.posts",
|
|
Form: &pact.AdminFormExtension{Fields: "extensions/posts.yaml", Load: store.load, Save: store.save},
|
|
}},
|
|
}
|
|
env := newFEEnv(t, feOwner{fields: feOwnerFields}, extra)
|
|
|
|
names := feSchemaNames(t, feCall(t, env.svc, superUser(), "acme.blog.posts", http.MethodGet, "schema", "", nil))
|
|
if strings.Join(names, " ") != "a c b" {
|
|
t.Fatalf("schema fields = %v, want [a c b]", names)
|
|
}
|
|
|
|
seed := fePost{A: "A0", B: "B0"}
|
|
if err := env.db.Create(&seed).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
idText := strconv.FormatUint(uint64(seed.ID), 10)
|
|
rec := feCall(t, env.svc, superUser(), "acme.blog.posts", http.MethodPut, "record", idText, map[string]any{"a": "A1", "b": "B1", "c": "C1"})
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("update status=%d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
saved := pgFTDecode(t, rec)
|
|
if saved.Data["c"] != "C1" || saved.Data["a"] != "A1" {
|
|
t.Fatalf("update data=%v, want a=A1 and c=C1 served by Load", saved.Data)
|
|
}
|
|
if got := env.post(t, seed.ID); got.A != "A1" || got.B != "B1" {
|
|
t.Fatalf("owner row=%+v", got)
|
|
}
|
|
if value, ok := env.extra(t, seed.ID); !ok || value != "C1" {
|
|
t.Fatalf("contributor row=%q,%v", value, ok)
|
|
}
|
|
if len(store.saves) != 1 || store.saves[0].Created || store.saves[0].ID != idText || store.saves[0].Controller != "acme.blog.posts" {
|
|
t.Fatalf("saves=%+v", store.saves)
|
|
}
|
|
if len(store.saves[0].Values) != 1 || store.saves[0].Values["c"] != "C1" {
|
|
t.Fatalf("Save values=%v, want only c", store.saves[0].Values)
|
|
}
|
|
if store.ownerA[0] != "A1" {
|
|
t.Fatalf("Save read owner a=%q through its DB, want the uncommitted A1 of the same transaction", store.ownerA[0])
|
|
}
|
|
|
|
rec = feCall(t, env.svc, superUser(), "acme.blog.posts", http.MethodGet, "record", idText, nil)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("show status=%d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
if shown := pgFTDecode(t, rec); shown.Data["c"] != "C1" || shown.Data["a"] != "A1" {
|
|
t.Fatalf("show data=%v", shown.Data)
|
|
}
|
|
if env.db.Migrator().HasColumn(&fePost{}, "c") {
|
|
t.Fatal("the owner table gained a c column")
|
|
}
|
|
}
|
|
|
|
// feMLType is a translatable field type of acme.extra: Decode trims and
|
|
// reports the trim per locale.
|
|
func feMLType() pact.FormFieldType {
|
|
return pact.FormFieldType{
|
|
Name: "extraml", Element: "acme-extra-ml", Translatable: true, Scripts: []string{"assets/js/extra.js"},
|
|
Decode: func(_ context.Context, in pact.FormFieldValue) (pact.FormFieldDecoded, error) {
|
|
text, _ := in.Value.(string)
|
|
if text == "bad" {
|
|
return pact.FormFieldDecoded{}, &pact.FormFieldError{Messages: []string{"bad in " + in.Locale}}
|
|
}
|
|
out := pact.FormFieldDecoded{Value: strings.TrimSpace(text)}
|
|
if out.Value != text {
|
|
out.Notices = []pact.FormFieldNotice{{Message: "trimmed " + in.Locale}}
|
|
}
|
|
return out, nil
|
|
},
|
|
Load: func(_ context.Context, in pact.FormFieldValue) (any, error) {
|
|
text, _ := in.Value.(string)
|
|
return "served:" + in.Locale + ":" + text, nil
|
|
},
|
|
}
|
|
}
|
|
|
|
// TestFormExtensionSave (D-91, T-32-01, T-32-02): Save errors roll the whole
|
|
// save back, AdminFormErrors answer 422 field lines, notices reach the save
|
|
// response, an invisible extension is inert on save, translatable values
|
|
// reach Save as locale maps without touching the owner's translations, and a
|
|
// hidden owner field leaves show and save.
|
|
func TestFormExtensionSave(t *testing.T) {
|
|
store := &feStore{}
|
|
var mlSaves []map[string]any
|
|
extra := feContributor{
|
|
id: "acme.extra",
|
|
fsys: fstest.MapFS{"extensions/posts.yaml": &fstest.MapFile{Data: []byte("fields:\n c:\n type: text\n after: a\n")}, "assets/js/extra.js": &fstest.MapFile{Data: []byte("// extra\n")}},
|
|
types: []pact.FormFieldType{feMLType()},
|
|
extensions: []pact.AdminActionExtension{
|
|
{Controller: "acme.blog.posts", Form: &pact.AdminFormExtension{Fields: "extensions/posts.yaml", Load: store.load, Save: store.save, Hide: []string{"b"}}},
|
|
{Controller: "acme.blog.posts", Form: &pact.AdminFormExtension{
|
|
FieldsYAML: []byte("tabs:\n - name: acme.extra::lang.tab\nfields:\n t:\n type: mltext\n tab: acme.extra::lang.tab\n n:\n type: extraml\n tab: acme.extra::lang.tab\n"),
|
|
Permissions: []string{"acme.extra.translate"},
|
|
Load: func(context.Context, pact.AdminFormLoad) (map[string]any, error) {
|
|
return map[string]any{"t": map[string]string{"pl": "TP", "en": "TE"}, "n": map[string]string{"pl": "NP", "en": "NE"}}, nil
|
|
},
|
|
Save: func(_ context.Context, in pact.AdminFormSave) (pact.AdminFormSaveResult, error) {
|
|
mlSaves = append(mlSaves, in.Values)
|
|
return pact.AdminFormSaveResult{}, nil
|
|
},
|
|
}},
|
|
},
|
|
}
|
|
env := newFEEnv(t, feOwner{fields: feOwnerFields}, extra)
|
|
writer := ftWriter()
|
|
if err := env.svc.app.Publish[TranslationWriter](writer); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
seed := fePost{A: "A0", B: "B0"}
|
|
if err := env.db.Create(&seed).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
id := strconv.FormatUint(uint64(seed.ID), 10)
|
|
put := func(principal *bouncer.Principal, body map[string]any) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
return feCall(t, env.svc, principal, "acme.blog.posts", http.MethodPut, "record", id, body)
|
|
}
|
|
|
|
t.Run("AdminFormErrors answer 422 and roll back", func(t *testing.T) {
|
|
store.fail = pact.AdminFormErrors{"c": {"The c field is required."}}
|
|
defer func() { store.fail = nil }()
|
|
rec := put(superUser(), map[string]any{"a": "A1", "c": "C1"})
|
|
if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "The c field is required.") {
|
|
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
if got := env.post(t, seed.ID); got.A != "A0" {
|
|
t.Fatalf("owner row changed: %+v", got)
|
|
}
|
|
if value, ok := env.extra(t, seed.ID); ok {
|
|
t.Fatalf("contributor row kept after rollback: %q", value)
|
|
}
|
|
})
|
|
|
|
t.Run("a plain Save error answers 500 and rolls back", func(t *testing.T) {
|
|
store.fail = errors.New("disk full")
|
|
defer func() { store.fail = nil }()
|
|
rec := put(superUser(), map[string]any{"a": "A1", "c": "C1"})
|
|
if rec.Code != http.StatusInternalServerError || strings.Contains(rec.Body.String(), "disk full") {
|
|
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
if got := env.post(t, seed.ID); got.A != "A0" {
|
|
t.Fatalf("owner row changed: %+v", got)
|
|
}
|
|
})
|
|
|
|
t.Run("notices reach the save response, on owner fields too", func(t *testing.T) {
|
|
store.notices = map[string][]pact.FormFieldNotice{"a": {{Message: "address changed"}}}
|
|
defer func() { store.notices = nil }()
|
|
rec := put(superUser(), map[string]any{"a": "A1", "c": "C1"})
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
notices := pgFTNotices(t, pgFTDecode(t, rec))
|
|
if len(notices) != 1 || notices[0].Field != "a" || notices[0].Message != "address changed" {
|
|
t.Fatalf("notices = %+v", notices)
|
|
}
|
|
})
|
|
|
|
t.Run("a hidden owner field leaves show and save", func(t *testing.T) {
|
|
rec := put(superUser(), map[string]any{"a": "A2", "b": "B9", "c": "C2"})
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
if got := env.post(t, seed.ID); got.A != "A2" || got.B != "B0" {
|
|
t.Fatalf("owner row=%+v, want a written and hidden b untouched", got)
|
|
}
|
|
shown := pgFTDecode(t, feCall(t, env.svc, superUser(), "acme.blog.posts", http.MethodGet, "record", id, nil))
|
|
if _, ok := shown.Data["b"]; ok {
|
|
t.Fatalf("show serves hidden b: %v", shown.Data)
|
|
}
|
|
names := feSchemaNames(t, feCall(t, env.svc, superUser(), "acme.blog.posts", http.MethodGet, "schema", "", nil))
|
|
if feJoin(names) != "a c t n" {
|
|
t.Fatalf("schema = %v", names)
|
|
}
|
|
})
|
|
|
|
t.Run("translatable values reach Save as locale maps", func(t *testing.T) {
|
|
mlSaves = nil
|
|
rec := put(superUser(), map[string]any{"a": "A3", "t": map[string]any{"pl": "P", "en": "E"}, "n": map[string]any{"pl": " X ", "en": "Y"}})
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
if len(mlSaves) != 1 {
|
|
t.Fatalf("saves = %v", mlSaves)
|
|
}
|
|
tv, _ := mlSaves[0]["t"].(map[string]string)
|
|
nv, _ := mlSaves[0]["n"].(map[string]string)
|
|
if tv["pl"] != "P" || tv["en"] != "E" || nv["pl"] != "X" || nv["en"] != "Y" {
|
|
t.Fatalf("Save values = %#v", mlSaves[0])
|
|
}
|
|
if len(writer.attrs) != 0 {
|
|
t.Fatalf("the owner's translations were written: %v", writer.attrs)
|
|
}
|
|
body := pgFTDecode(t, rec)
|
|
notices := pgFTNotices(t, body)
|
|
if len(notices) != 1 || notices[0].Field != "n" || notices[0].Locale != "pl" {
|
|
t.Fatalf("notices = %+v", notices)
|
|
}
|
|
served, _ := body.Data["n"].(map[string]any)
|
|
if served["pl"] != "served:pl:NP" || served["en"] != "served:en:NE" {
|
|
t.Fatalf("n served = %v, want the type's Load after the extension's", body.Data["n"])
|
|
}
|
|
refused := put(superUser(), map[string]any{"a": "A3", "n": map[string]any{"pl": "bad"}})
|
|
if refused.Code != http.StatusUnprocessableEntity || !strings.Contains(refused.Body.String(), "bad in pl") {
|
|
t.Fatalf("refused status=%d body=%s", refused.Code, refused.Body.String())
|
|
}
|
|
})
|
|
|
|
t.Run("an invisible extension is inert on save", func(t *testing.T) {
|
|
editor := &bouncer.Principal{ID: 2, Backend: true, PermissionGrants: map[string]bool{"acme.blog.access": true}}
|
|
mlSaves = nil
|
|
rec := put(editor, map[string]any{"a": "A4", "t": map[string]any{"pl": "Z"}, "c": "C4"})
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
if len(mlSaves) != 0 {
|
|
t.Fatalf("Save of the invisible extension ran: %v", mlSaves)
|
|
}
|
|
if value, _ := env.extra(t, seed.ID); value != "C4" {
|
|
t.Fatalf("the visible extension did not save: %q", value)
|
|
}
|
|
data := pgFTDecode(t, rec).Data
|
|
if _, ok := data["t"]; ok {
|
|
t.Fatalf("save response serves the invisible extension's field: %v", data)
|
|
}
|
|
shown := pgFTDecode(t, feCall(t, env.svc, editor, "acme.blog.posts", http.MethodGet, "record", id, nil))
|
|
if _, ok := shown.Data["n"]; ok {
|
|
t.Fatalf("show serves the invisible extension's field: %v", shown.Data)
|
|
}
|
|
if names := feSchemaNames(t, feCall(t, env.svc, editor, "acme.blog.posts", http.MethodGet, "schema", "", nil)); feJoin(names) != "a c" {
|
|
t.Fatalf("schema = %v", names)
|
|
}
|
|
})
|
|
|
|
t.Run("create hands Save the new record", func(t *testing.T) {
|
|
before := len(store.saves)
|
|
rec := feCall(t, env.svc, superUser(), "acme.blog.posts", http.MethodPost, "record", "", map[string]any{"a": "N", "c": "NC"})
|
|
if rec.Code != http.StatusCreated {
|
|
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
last := store.saves[len(store.saves)-1]
|
|
if len(store.saves) != before+1 || !last.Created || last.ID == "" || last.ID == id {
|
|
t.Fatalf("create Save = %+v", last)
|
|
}
|
|
})
|
|
}
|