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": "
clean
", "type": "acme-page", "reference": 2, "extra": "dropped"}, map[string]any{"title": true, "body": `dirty
`, "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": "clean
", "type": "acme-page", "reference": "2"}, {"title": "true", "body": "dirty
", "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": "x
"}, 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 "x
`}, http.StatusCreated) if got := stored(t, id); got == nil || *got != "x
" { 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{ "It's fine & \"quoted\" —
\nkept
" 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{"x
"}, map[string]any{"pl": "x
"}} { 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") } }