Files
summercms/modules/cabana/companions_postgres_test.go
Jakub Zych 91593da1f7 test(17-06): framework and admin SPA unit tests for the blog companions
- signpost: activation refusals, host overrides, D-02 pattern layers
  incl. PatternFrom, ReferenceOptions depths
- seaglass: removal report, bluemonday bypass corpus, Check
- pact Option.Depth omitempty and the menu item type contracts; party
  activates menu item types after Register and before the first Boot
- cabana: repeater compile and decode, dependsOn compile and recheck,
  richeditor keys and notices, reorder and singleton boot checks
  (Docker-free); repeater save, dependent recheck, richeditor save,
  reorder and singleton routes on Postgres; conformance cases for the
  documented 404, 409 and 422 answers of the new routes
- admin: richeditor value contract, link popover, notices and chunk
  failure; repeater bounds, remove and reorder, collapsed rows;
  dependent dropdown states and last request wins; reorder
  announcements, partition switch and tree rules; SingletonForm spec
- gate: required names in the go, admin and postgres stages
2026-10-12 19:29:37 +02:00

436 lines
18 KiB
Go

package cabana_test
import (
"encoding/json"
"fmt"
"io/fs"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"testing/fstest"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/compass"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
)
// Phase 17 plan 06 tests of the companion form features through the admin
// routes on Postgres (the acme.companions fixture of companions_smoke_test.go)
// and their boot refusals through cabana.Activate (Docker-free).
// companionsOverlay is the acme.companions fixture tree with files replaced;
// an empty value removes the file.
func companionsOverlay(t *testing.T, files map[string]string) fstest.MapFS {
t.Helper()
out := fstest.MapFS{}
err := fs.WalkDir(os.DirFS(companionsDir), ".", func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return err
}
raw, err := os.ReadFile(filepath.Join(companionsDir, path))
if err != nil {
return err
}
out[path] = &fstest.MapFile{Data: raw}
return nil
})
if err != nil {
t.Fatal(err)
}
for path, data := range files {
if data == "" {
delete(out, path)
continue
}
out[path] = &fstest.MapFile{Data: []byte(data)}
}
return out
}
// companionsFile is one file of the fixture tree.
func companionsFile(t *testing.T, path string) string {
t.Helper()
raw, err := os.ReadFile(filepath.Join(companionsDir, path))
if err != nil {
t.Fatal(err)
}
return string(raw)
}
// companionsFSPlugin is the acme.companions fixture served from another
// tree, optionally with other controllers or settings.
type companionsFSPlugin struct {
companionsPlugin
fsys fs.FS
controllers []pact.AdminController
settings []pact.SettingsItem
}
func (p companionsFSPlugin) AdminFS() fs.FS { return p.fsys }
func (p companionsFSPlugin) AdminControllers() []pact.AdminController {
if p.controllers != nil {
return p.controllers
}
return p.companionsPlugin.AdminControllers()
}
func (p companionsFSPlugin) Settings() []pact.SettingsItem {
if p.settings != nil {
return p.settings
}
return p.companionsPlugin.Settings()
}
// activateCompanions boots the admin of p without a database.
func activateCompanions(t *testing.T, p party.Plugin) error {
t.Helper()
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: cabana-companions-boot\n"), 0o644); err != nil {
t.Fatal(err)
}
cfg, err := compass.Open(compass.Options{Dir: dir, Environ: []string{"SUMMER_ENV=development", "SUMMER_ADMIN__JWT__SECRET=" + adminTestSecret}})
if err != nil {
t.Fatal(err)
}
_, err = cabana.Activate(backpack.New(cfg), []party.Plugin{p})
return err
}
// companionsLinkRows reads every link row by id.
func companionsLinkRows(t *testing.T, e *conformEnv) map[uint]companionsLink {
t.Helper()
var rows []companionsLink
if err := e.db.Order("id").Find(&rows).Error; err != nil {
t.Fatal(err)
}
out := make(map[uint]companionsLink, len(rows))
for _, row := range rows {
out[row.ID] = row
}
return out
}
// sameLinkRows fails unless the link table is exactly before.
func sameLinkRows(t *testing.T, e *conformEnv, before map[uint]companionsLink, what string) {
t.Helper()
a, err := json.Marshal(companionsLinkRows(t, e))
if err != nil {
t.Fatal(err)
}
b, err := json.Marshal(before)
if err != nil {
t.Fatal(err)
}
if string(a) != string(b) {
t.Fatalf("%s wrote rows:\nbefore %s\nafter %s", what, b, a)
}
}
// wantStatus fails unless rec answered status, and returns the body.
func wantStatus(t *testing.T, what string, rec interface {
Result() *http.Response
}, status int, body string) {
t.Helper()
if code := rec.Result().StatusCode; code != status {
t.Fatalf("%s: status=%d want %d body=%s", what, code, status, body)
}
}
// companionsError decodes the code and details of an error envelope.
func companionsError(t *testing.T, raw []byte) (string, map[string][]string) {
t.Helper()
var body struct {
Error struct {
Code string `json:"code"`
Details map[string][]string `json:"details"`
} `json:"error"`
}
if err := json.Unmarshal(raw, &body); err != nil {
t.Fatalf("error body %s: %v", raw, err)
}
return body.Error.Code, body.Error.Details
}
// TestRepeaterSave pins the D-07 save through the links routes: rows keep
// only their nested keys with scalars coerced, required applies per row
// with row-path errors, maxItems is enforced, nested richeditors are
// sanitized with row-path notices, nested dependsOn is rechecked, a refused
// save writes nothing, and the record response and show hydrate the rows.
func TestRepeaterSave(t *testing.T) {
e := newCompanionsEnv(t)
rec := e.send(t, http.MethodPost, "/acme/companions/links", map[string]any{"title": "Rows", "rows": []any{
map[string]any{"title": 5, "body": "<p>clean</p>", "type": "acme-page", "reference": 2, "extra": "dropped"},
map[string]any{"title": true, "body": `<p onclick="x()">dirty</p><!-- c -->`, "type": "acme-list", "reference": "1"},
map[string]any{"title": "third"},
}}, true)
wantStatus(t, "create", rec, http.StatusCreated, rec.Body.String())
created := decodeCompanionsRecord(t, rec.Body.Bytes())
id := dataID(t, rec.Body.Bytes())
var stored companionsLink
if err := e.db.First(&stored, id).Error; err != nil {
t.Fatal(err)
}
rows := stored.Rows.Get()
want := []map[string]any{
{"title": "5", "body": "<p>clean</p>", "type": "acme-page", "reference": "2"},
{"title": "true", "body": "<p>dirty</p>", "type": "acme-list", "reference": nil},
{"title": "third", "body": nil, "type": nil, "reference": nil},
}
if fmt.Sprint(rows) != fmt.Sprint(want) {
t.Fatalf("stored rows = %v\nwant %v", rows, want)
}
var noticed []string
for _, n := range created.Meta.Notices {
noticed = append(noticed, n.Field+": "+n.Message)
}
if len(noticed) != 2 || !strings.HasPrefix(noticed[0], "rows.1.body: Usunięto 1 atrybut: onclick") || !strings.HasPrefix(noticed[1], "rows.1.body: Usunięto 1 komentarz HTML") {
t.Fatalf("notices = %v", noticed)
}
if list, ok := created.Data["rows"].([]any); !ok || len(list) != 3 {
t.Fatalf("response rows = %#v", created.Data["rows"])
}
path := fmt.Sprintf("/acme/companions/links/%d", id)
before := companionsLinkRows(t, e)
for _, tc := range []struct {
name string
rows any
keys []string
}{
{"per-row required", []any{map[string]any{"title": "ok"}, map[string]any{"body": "<p>x</p>"}, map[string]any{"title": " "}}, []string{"rows.1.title", "rows.2.title"}},
{"nested dependent value", []any{map[string]any{"title": "a", "type": "acme-page", "reference": "99"}}, []string{"rows.0.reference"}},
{"coercion", []any{map[string]any{"title": map[string]any{"x": 1}}}, []string{"rows.0.title"}},
{"not a list", "rows", []string{"rows"}},
{"a row that is not an object", []any{"x"}, []string{"rows"}},
{"above maxItems", []any{map[string]any{"title": "1"}, map[string]any{"title": "2"}, map[string]any{"title": "3"}, map[string]any{"title": "4"}, map[string]any{"title": "5"}, map[string]any{"title": "6"}}, []string{"rows"}},
} {
t.Run(tc.name, func(t *testing.T) {
rec := e.send(t, http.MethodPut, path, map[string]any{"title": "Rows", "rows": tc.rows}, true)
wantStatus(t, tc.name, rec, http.StatusUnprocessableEntity, rec.Body.String())
_, details := companionsError(t, rec.Body.Bytes())
for _, key := range tc.keys {
if len(details[key]) != 1 {
t.Fatalf("details = %v, want %s", details, key)
}
}
if len(details) != len(tc.keys) {
t.Fatalf("details = %v, want exactly %v", details, tc.keys)
}
if tc.name == "above maxItems" && details["rows"][0] != "Usuń pozycje, aby zostało ich najwyżej 5." {
t.Fatalf("max message = %q", details["rows"][0])
}
if tc.name == "nested dependent value" && details["rows.0.reference"][0] != "Wybierz jedną z dostępnych opcji." {
t.Fatalf("dependent message = %q", details["rows.0.reference"][0])
}
sameLinkRows(t, e, before, tc.name)
})
}
t.Run("an update without rows keeps them, null stores none", func(t *testing.T) {
rec := e.send(t, http.MethodPut, path, map[string]any{"title": "Renamed"}, true)
wantStatus(t, "update", rec, http.StatusOK, rec.Body.String())
var again companionsLink
if err := e.db.First(&again, id).Error; err != nil || len(again.Rows.Get()) != 3 || again.Title != "Renamed" {
t.Fatalf("rows after an update without rows = %+v (%v)", again, err)
}
rec = e.send(t, http.MethodPut, path, map[string]any{"title": "Renamed", "rows": nil}, true)
wantStatus(t, "null rows", rec, http.StatusOK, rec.Body.String())
if err := e.db.First(&again, id).Error; err != nil || len(again.Rows.Get()) != 0 {
t.Fatalf("rows after null = %+v (%v)", again.Rows, err)
}
shown := e.send(t, http.MethodGet, path, nil, true)
if shown.Code != http.StatusOK || !strings.Contains(shown.Body.String(), `"rows":[]`) {
t.Fatalf("show = %d %s", shown.Code, shown.Body.String())
}
})
}
// TestDependentRecheck pins D-08 through the routes: the options route per
// sibling value (tree depths, Applies false, unknown and empty values), its
// 404s and 401, and the save recheck at the top level, with the stored
// sibling or value used when only the other is sent; a refused save writes
// nothing.
func TestDependentRecheck(t *testing.T) {
e := newCompanionsEnv(t)
options := func(t *testing.T, field, typ string) cabana.AdminDependentOptionsResult {
t.Helper()
rec := e.send(t, http.MethodGet, dependentOptionsPath(field, typ), nil, true)
wantStatus(t, field+" "+typ, rec, http.StatusOK, rec.Body.String())
var body cabana.Envelope[cabana.AdminDependentOptionsResult]
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
return body.Data
}
for _, field := range []string{"reference", "rows.reference"} {
page := options(t, field, "acme-page")
if !page.Applies || fmt.Sprint(page.Options) != "[{1 Home 0} {2 About 1}]" {
t.Fatalf("%s acme-page = %+v", field, page)
}
for _, typ := range []string{"acme-list", "acme-unknown", "url", ""} {
if none := options(t, field, typ); none.Applies || none.Options == nil || len(none.Options) != 0 {
t.Fatalf("%s %q = %+v", field, typ, none)
}
}
}
for _, path := range []string{dependentOptionsPath("title", "acme-page"), dependentOptionsPath("type", "acme-page"), dependentOptionsPath("rows.title", "acme-page"), dependentOptionsPath("missing", "acme-page"), dependentOptionsPath("rows.missing", "acme-page")} {
if rec := e.send(t, http.MethodGet, path, nil, true); rec.Code != http.StatusNotFound {
t.Fatalf("%s status=%d", path, rec.Code)
}
}
if rec := e.send(t, http.MethodGet, dependentOptionsPath("reference", "acme-page"), nil, false); rec.Code != http.StatusUnauthorized {
t.Fatalf("anonymous status=%d", rec.Code)
}
rec := e.send(t, http.MethodPost, "/acme/companions/links", map[string]any{"title": "Dep", "type": "acme-page", "reference": "2"}, true)
wantStatus(t, "create", rec, http.StatusCreated, rec.Body.String())
id := dataID(t, rec.Body.Bytes())
path := fmt.Sprintf("/acme/companions/links/%d", id)
load := func(t *testing.T) companionsLink {
t.Helper()
var link companionsLink
if err := e.db.First(&link, id).Error; err != nil {
t.Fatal(err)
}
return link
}
str := func(p *string) string {
if p == nil {
return "<nil>"
}
return *p
}
if link := load(t); str(link.Type) != "acme-page" || str(link.Reference) != "2" {
t.Fatalf("created = %+v", link)
}
refused := func(t *testing.T, body map[string]any) {
t.Helper()
before := companionsLinkRows(t, e)
rec := e.send(t, http.MethodPut, path, body, true)
wantStatus(t, "refused", rec, http.StatusUnprocessableEntity, rec.Body.String())
code, details := companionsError(t, rec.Body.Bytes())
if code != "validation_failed" || len(details) != 1 || len(details["reference"]) != 1 || details["reference"][0] != "Wybierz jedną z dostępnych opcji." {
t.Fatalf("details = %s %v", code, details)
}
sameLinkRows(t, e, before, "a refused dependent save")
}
refused(t, map[string]any{"title": "Dep", "type": "acme-page", "reference": "99"})
refused(t, map[string]any{"title": "Dep", "reference": "99"}) // stored type acme-page decides
refused(t, map[string]any{"title": "Dep", "reference": 7})
accepted := func(t *testing.T, body map[string]any, wantType, wantRef string) {
t.Helper()
rec := e.send(t, http.MethodPut, path, body, true)
wantStatus(t, "accepted", rec, http.StatusOK, rec.Body.String())
if link := load(t); str(link.Type) != wantType || str(link.Reference) != wantRef {
t.Fatalf("after %v: type=%s reference=%s, want %s/%s", body, str(link.Type), str(link.Reference), wantType, wantRef)
}
}
accepted(t, map[string]any{"title": "Dep", "reference": "1"}, "acme-page", "1")
accepted(t, map[string]any{"title": "Dep", "reference": 2}, "acme-page", "2")
accepted(t, map[string]any{"title": "Dep", "reference": nil}, "acme-page", "<nil>")
accepted(t, map[string]any{"title": "Dep", "reference": "1"}, "acme-page", "1")
// Only the sibling: the stored value is checked against the new type,
// and a type without references clears it.
accepted(t, map[string]any{"title": "Dep", "type": "acme-list"}, "acme-list", "<nil>")
accepted(t, map[string]any{"title": "Dep", "type": "acme-list", "reference": "1"}, "acme-list", "<nil>")
accepted(t, map[string]any{"title": "Dep", "type": "acme-page", "reference": "2"}, "acme-page", "2")
accepted(t, map[string]any{"title": "Dep", "type": "url", "reference": "2"}, "url", "<nil>")
}
// TestRichEditor pins D-10 through the routes: a save stores the sanitized
// value with notices in the request locale, clean HTML byte for byte with
// none, null stays null, a value that is not a string is a 422 that writes
// nothing; and settings and relation forms refuse the type at boot.
func TestRichEditor(t *testing.T) {
t.Run("boot refusals", func(t *testing.T) {
for name, field := range map[string]string{
"richeditor": " body:\n type: richeditor\n",
"repeater": " body:\n type: repeater\n form:\n fields:\n a:\n type: text\n",
} {
err := activateDeferred(t, dfPlugin{fsys: dfOverlay(t, map[string]string{"models/part/fields.yaml": "fields:\n label:\n type: text\n" + field})}, nil)
if err == nil || !strings.Contains(err.Error(), "field body: "+name+" is supported in controller forms only") {
t.Fatalf("relation form with %s: err = %v", name, err)
}
fsys := companionsOverlay(t, map[string]string{"models/settings/fields.yaml": "fields:\n title:\n type: text\n body:\n" + strings.TrimPrefix(field, " body:\n")})
err = activateCompanions(t, companionsFSPlugin{fsys: fsys, settings: []pact.SettingsItem{{
Code: "companions_settings", Label: "Settings", Category: "Acme", Form: "models/settings/fields.yaml", Model: "Profile",
NewModel: func() any { return &companionsProfile{} }, Permissions: []string{"acme.companions.access"},
}}})
if err == nil || !strings.Contains(err.Error(), "setting companions_settings field body: "+name+" is supported in controller forms only") {
t.Fatalf("settings form with %s: err = %v", name, err)
}
}
})
e := newCompanionsEnv(t)
type noticeView struct{ field, message string }
save := func(t *testing.T, method, path string, body map[string]any, status int) (uint, []noticeView) {
t.Helper()
rec := e.send(t, method, path, body, true)
wantStatus(t, method+" "+path, rec, status, rec.Body.String())
record := decodeCompanionsRecord(t, rec.Body.Bytes())
var out []noticeView
for _, n := range record.Meta.Notices {
out = append(out, noticeView{n.Field, n.Message})
}
return dataID(t, rec.Body.Bytes()), out
}
stored := func(t *testing.T, id uint) *string {
t.Helper()
var link companionsLink
if err := e.db.First(&link, id).Error; err != nil {
t.Fatal(err)
}
return link.Description
}
id, notices := save(t, http.MethodPost, "/acme/companions/links", map[string]any{"title": "Rich", "description": `<p onclick="a()" onmouseover="b()">x</p><script>1</script><iframe src="https://evil.example"></iframe><!-- c -->`}, http.StatusCreated)
if got := stored(t, id); got == nil || *got != "<p>x</p>" {
t.Fatalf("stored = %v", got)
}
want := []noticeView{
{"description", "Usunięto 2 elementy: script, iframe"},
{"description", "Usunięto 2 atrybuty: onclick, onmouseover"},
{"description", "Usunięto 1 komentarz HTML"},
}
if fmt.Sprint(notices) != fmt.Sprint(want) {
t.Fatalf("notices = %v\nwant %v", notices, want)
}
path := fmt.Sprintf("/acme/companions/links/%d", id)
for _, clean := range []string{
"<p>It's <strong>fine</strong> &amp; \"quoted\" &nbsp;&#8212;</p>\n<ul>\n <li>one</li>\n</ul>",
`<p class='a b'><a title="t" href="https://example.com/?a=1&amp;b=2">link</a></p>`,
"",
} {
if _, notices := save(t, http.MethodPut, path, map[string]any{"title": "Rich", "description": clean}, http.StatusOK); len(notices) != 0 {
t.Fatalf("clean %q reported %v", clean, notices)
}
if got := stored(t, id); got == nil || *got != clean {
t.Fatalf("clean value not stored byte for byte: %v, want %q", got, clean)
}
}
if _, notices := save(t, http.MethodPut, path, map[string]any{"title": "Rich", "description": nil}, http.StatusOK); len(notices) != 0 || stored(t, id) != nil {
t.Fatalf("null description: %v %v", notices, stored(t, id))
}
keep := "<p>kept</p>"
save(t, http.MethodPut, path, map[string]any{"title": "Rich", "description": keep}, http.StatusOK)
save(t, http.MethodPut, path, map[string]any{"title": "Renamed"}, http.StatusOK)
if got := stored(t, id); got == nil || *got != keep {
t.Fatalf("an update without the field changed it: %v", got)
}
before := companionsLinkRows(t, e)
for _, bad := range []any{12, true, []any{"<p>x</p>"}, map[string]any{"pl": "<p>x</p>"}} {
rec := e.send(t, http.MethodPut, path, map[string]any{"title": "Bad", "description": bad}, true)
wantStatus(t, fmt.Sprintf("description %v", bad), rec, http.StatusUnprocessableEntity, rec.Body.String())
if _, details := companionsError(t, rec.Body.Bytes()); len(details["description"]) != 1 {
t.Fatalf("details = %v", details)
}
sameLinkRows(t, e, before, "a non-string description")
}
}