package cabana import ( "bytes" "context" "encoding/json" "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") } }