Files
summercms/modules/cabana/messages_test.go
Jakub Zych 5e50b166ef refactor(10.2-01): nest framework packages under modules
- Move remaining beach packages and embedded admin assets\n- Rewrite framework, example, build, and gate paths
2026-09-28 02:21:02 +02:00

355 lines
13 KiB
Go

package cabana
import (
"context"
"encoding/json"
"io/fs"
"net/http"
"net/http/httptest"
"strings"
"testing"
"testing/fstest"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/phrasebook"
"git.golem15.com/golem15/summercms/modules/towel"
)
type langPlugin struct {
id string
lang fs.FS
}
func (p langPlugin) ID() string { return p.id }
func (p langPlugin) LangFS() fs.FS { return p.lang }
// messagesTranslator activates phrasebook with the framework backend strings
// and an acme.demo catalog.
func messagesTranslator(t *testing.T) *phrasebook.Translator {
t.Helper()
app := backpack.New(nil)
demo := fstest.MapFS{
"lang/pl/lang.yaml": {Data: []byte(`widgets:
count:
one: ":count widżet"
few: ":count widżety"
many: ":count widżetów"
other: ":count widżetu"
create: Nowy widżet
saved: Widżet „:name” zapisany
link: Dodaj edytora
`)},
"lang/en/lang.yaml": {Data: []byte(`widgets:
count:
one: ":count widget"
other: ":count widgets"
create: New widget
saved: Widget “:name” saved
link: Add editor
`)},
}
if err := phrasebook.Activate(app, []langPlugin{{id: "acme.demo", lang: demo}}); err != nil {
t.Fatal(err)
}
tr, ok := app.Lookup[*phrasebook.Translator]()
if !ok || tr == nil {
t.Fatal("translator missing")
}
return tr
}
const messagesListConfig = `modelClass: Widget
list: ~/plugins/acme/demo/models/widget/columns.yaml
recordsPerPage: 20
showCheckboxes: true
toolbar:
buttons: [create, delete]
search:
prompt: backend::lang.list.search_prompt
messages:
recordCount: acme.demo::lang.widgets.count
create: acme.demo::lang.widgets.create
`
func localeCtx(locale string) context.Context {
return towel.WithLocale(context.Background(), locale)
}
func TestPhase10Messages(t *testing.T) {
tr := messagesTranslator(t)
schema, err := CompileList("acme.demo", schemaController{}, listFS(messagesListConfig, "columns: {}\n"))
if err != nil {
t.Fatal(err)
}
pl, err := schema.Localize(localeCtx("pl"), tr)
if err != nil {
t.Fatal(err)
}
m := pl.Messages
if m == nil {
t.Fatal("localized list has no messages")
}
if len(m.RecordCount) != 4 || m.RecordCount["few"] != ":count widżety" || m.RecordCount["many"] != ":count widżetów" {
t.Fatalf("plural recordCount=%v", m.RecordCount)
}
if m.Create["other"] != "Nowy widżet" {
t.Fatalf("plugin create=%v", m.Create)
}
for name, got := range map[string]MessageForms{
"deleteConfirm": m.DeleteConfirm,
"searchPrompt": m.SearchPrompt,
"empty": m.Empty,
"deleteSelected": m.DeleteSelected,
"selected": m.Selected,
} {
if got["other"] == "" || strings.Contains(got["other"], "::") {
t.Fatalf("default %s did not resolve: %v", name, got)
}
}
if m.DeleteConfirm["other"] != "Usunąć zaznaczone (:count)?" || m.SearchPrompt["other"] != "Szukaj…" {
t.Fatalf("framework defaults: deleteConfirm=%v searchPrompt=%v", m.DeleteConfirm, m.SearchPrompt)
}
if len(m.Deleted) != 4 || m.Deleted["one"] != "Usunięto :count rekord" {
t.Fatalf("default plural deleted=%v", m.Deleted)
}
en, err := schema.Localize(localeCtx("en"), tr)
if err != nil {
t.Fatal(err)
}
if en.Messages.RecordCount["one"] != ":count widget" || len(en.Messages.RecordCount) != 2 || en.Messages.Create["other"] != "New widget" {
t.Fatalf("en messages=%+v", en.Messages)
}
raw, err := json.Marshal(pl)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(raw), `"messages":{"recordCount":{`) {
t.Fatalf("served list JSON has no messages: %s", raw)
}
if schema.Messages.RecordCount["other"] != "acme.demo::lang.widgets.count" {
t.Fatalf("cached schema lost its keys: %v", schema.Messages.RecordCount)
}
t.Run("form", func(t *testing.T) {
config := `name: acme.demo::lang.widgets.create
form: ~/plugins/acme/demo/models/widget/fields.yaml
modelClass: Widget
defaultRedirect: acme/demo/widgets
create:
redirect: acme/demo/widgets/update/:id
redirectClose: acme/demo/widgets
update:
redirect: acme/demo/widgets
redirectClose: acme/demo/widgets
messages:
saved: acme.demo::lang.widgets.saved
`
form, err := CompileForm("acme.demo", schemaController{}, formFS(config, "fields:\n name:\n type: text\n"))
if err != nil {
t.Fatal(err)
}
view, err := form.Localize(localeCtx("pl"), tr, nil)
if err != nil {
t.Fatal(err)
}
if view.Messages.Saved["other"] != "Widżet „:name” zapisany" || view.Messages.Create["other"] != "Nowy widżet" || view.Messages.Update["other"] != "Edycja rekordu" {
t.Fatalf("form messages=%+v", view.Messages)
}
want := FormRedirects{Default: "acme/demo/widgets",
Create: FormRedirect{Redirect: "acme/demo/widgets/update/:id", RedirectClose: "acme/demo/widgets"},
Update: FormRedirect{Redirect: "acme/demo/widgets", RedirectClose: "acme/demo/widgets"}}
if view.Redirects != want {
t.Fatalf("redirects=%+v", view.Redirects)
}
raw, err := view.CanonicalJSON()
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(raw), `"redirects":{"default":"acme/demo/widgets"`) || !strings.Contains(string(raw), `"saved":{"other":`) {
t.Fatalf("form view JSON=%s", raw)
}
})
t.Run("relation", func(t *testing.T) {
yaml := strings.Replace(validRelationYAML, "editors:\n label: demo.editors\n", "editors:\n label: demo.editors\n messages:\n link: acme.demo::lang.widgets.link\n", 1)
ctl := relationTestController{}
fsys := relationTestFS(yaml)
form, err := CompileForm("acme.demo", ctl, fsys)
if err != nil {
t.Fatal(err)
}
relations, err := compileRelations("acme.demo", ctl, fsys, form)
if err != nil {
t.Fatal(err)
}
view := relations["editors"].Schema.Localize(localeCtx("pl"), tr)
if view.Messages.Link["other"] != "Dodaj edytora" || len(view.Messages.Linked) != 4 || view.Messages.Linked["few"] != "Dołączono :count rekordy" {
t.Fatalf("relation messages=%+v", view.Messages)
}
})
t.Run("unknown key fails at boot", func(t *testing.T) {
list := strings.Replace(messagesListConfig, " create: acme.demo::lang.widgets.create\n", " create: acme.demo::lang.widgets.create\n bogusKey: x\n", 1)
if _, err := CompileList("acme.demo", schemaController{}, listFS(list, "columns: {}\n")); err == nil || !strings.Contains(err.Error(), "bogusKey") || !strings.Contains(err.Error(), "config_list.yaml") {
t.Fatalf("list err=%v", err)
}
form := "form: ~/plugins/acme/demo/models/widget/fields.yaml\nmodelClass: Widget\nmessages:\n toast: x\n"
if _, err := CompileForm("acme.demo", schemaController{}, formFS(form, "fields: {}\n")); err == nil || !strings.Contains(err.Error(), "toast") {
t.Fatalf("form err=%v", err)
}
yaml := strings.Replace(validRelationYAML, " label: demo.editors\n", " label: demo.editors\n messages:\n bogusKey: x\n", 1)
ctl := relationTestController{}
fsys := relationTestFS(yaml)
compiled, _ := CompileForm("acme.demo", ctl, fsys)
if _, err := compileRelations("acme.demo", ctl, fsys, compiled); err == nil || !strings.Contains(err.Error(), "bogusKey") {
t.Fatalf("relation err=%v", err)
}
})
t.Run("missing phrase key fails at boot", func(t *testing.T) {
list := strings.Replace(messagesListConfig, "acme.demo::lang.widgets.count", "acme.demo::lang.widgets.missing", 1)
reg, err := compileRegistry([]controllerRef{{plugin: formPlugin{fsys: listFS(list, "columns: {}\n")}, ctl: schemaController{}}})
if err != nil {
t.Fatal(err)
}
err = validateMessageKeys(reg, tr)
if err == nil || !strings.Contains(err.Error(), "messages.recordCount") || !strings.Contains(err.Error(), "acme.demo::lang.widgets.missing") || !strings.Contains(err.Error(), "acme.demo.widgets") {
t.Fatalf("err=%v", err)
}
ok, err := compileRegistry([]controllerRef{{plugin: formPlugin{fsys: listFS(messagesListConfig, "columns: {}\n")}, ctl: schemaController{}}})
if err != nil {
t.Fatal(err)
}
if err := validateMessageKeys(ok, tr); err != nil {
t.Fatalf("declared keys failed: %v", err)
}
if err := validateMessageKeys(reg, nil); err != nil {
t.Fatalf("no translator must skip the check: %v", err)
}
})
}
func TestPhase10Toolbar(t *testing.T) {
base := "modelClass: Widget\nlist: ~/plugins/acme/demo/models/widget/columns.yaml\nshowCheckboxes: true\ntoolbar:\n buttons: BUTTONS\n"
compile := func(buttons string, checkboxes bool) (*ListSchema, error) {
config := strings.Replace(base, "BUTTONS", buttons, 1)
if !checkboxes {
config = strings.Replace(config, "showCheckboxes: true\n", "", 1)
}
return CompileList("acme.demo", schemaController{}, listFS(config, "columns: {}\n"))
}
for _, tc := range []struct {
buttons string
want []string
}{
{"[create, delete]", []string{"create", "delete"}},
{"[delete, create]", []string{"delete", "create"}},
{"[create]", []string{"create"}},
{"[]", []string{}},
{"\n - delete\n - create", []string{"delete", "create"}},
} {
schema, err := compile(tc.buttons, true)
if err != nil {
t.Fatalf("%s: %v", tc.buttons, err)
}
if strings.Join(schema.ToolbarButtons, ",") != strings.Join(tc.want, ",") {
t.Fatalf("%s compiled %v want %v", tc.buttons, schema.ToolbarButtons, tc.want)
}
}
for _, tc := range []struct {
buttons string
checkboxes bool
want string
}{
{"list_toolbar", true, "toolbar.buttons must be a list"},
{"list_toolbar", true, `"list_toolbar" is not supported`},
{"[create, delete]", false, "showCheckboxes"},
{"[create, create]", true, "duplicate action create"},
{"[create, export]", true, "unsupported action export"},
{"[{create: true}]", true, "toolbar.buttons"},
} {
_, err := compile(tc.buttons, tc.checkboxes)
if err == nil || !strings.Contains(err.Error(), tc.want) || !strings.Contains(err.Error(), "acme.demo.widgets") {
t.Fatalf("%s err=%v want %q", tc.buttons, err, tc.want)
}
}
// A controller without a compiled form cannot create: create is dropped.
reg, err := compileRegistry([]controllerRef{{plugin: formPlugin{fsys: listFS(strings.Replace(base, "BUTTONS", "[create, delete]", 1), "columns: {}\n")}, ctl: schemaController{}}})
if err != nil {
t.Fatal(err)
}
cc, _ := reg.Get("acme.demo.widgets")
if strings.Join(cc.List.ToolbarButtons, ",") != "delete" {
t.Fatalf("form-less toolbar=%v", cc.List.ToolbarButtons)
}
}
func TestPhase10Bundle(t *testing.T) {
tr := messagesTranslator(t)
app := backpack.New(nil)
if err := app.Publish(tr); err != nil {
t.Fatal(err)
}
svc := &service{app: app}
call := func(locale string) (map[string]map[string]string, string, *httptest.ResponseRecorder) {
t.Helper()
req := httptest.NewRequest(http.MethodGet, adminAPI("/lang"), nil)
if locale != "" {
req = req.WithContext(towel.WithLocale(req.Context(), locale))
}
rec := httptest.NewRecorder()
svc.langBundle(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
var body struct {
Data map[string]map[string]string `json:"data"`
Meta struct {
Locale string `json:"locale"`
} `json:"meta"`
}
dec := json.NewDecoder(strings.NewReader(rec.Body.String()))
dec.DisallowUnknownFields()
if err := dec.Decode(&body); err != nil {
t.Fatal(err)
}
return body.Data, body.Meta.Locale, rec
}
pl, locale, rec := call("pl")
if locale != "pl" || rec.Header().Get("Cache-Control") != "no-cache" {
t.Fatalf("locale=%q cache=%q", locale, rec.Header().Get("Cache-Control"))
}
if pl["backend::lang.form.save"]["other"] != "Zapisz" || len(pl["backend::lang.list.results"]) != 4 {
t.Fatalf("pl bundle save=%v results=%v", pl["backend::lang.form.save"], pl["backend::lang.list.results"])
}
if len(pl) < 50 {
t.Fatalf("pl bundle has only %d keys", len(pl))
}
for key := range pl {
if !strings.HasPrefix(key, "backend::lang.") {
t.Fatalf("bundle leaked a key outside backend::lang: %s", key)
}
}
if strings.Contains(rec.Body.String(), "acme.demo::") || strings.Contains(rec.Body.String(), "lagoon::") {
t.Fatalf("bundle leaked another namespace: %s", rec.Body.String())
}
en, locale, _ := call("en-GB")
if locale != "en" || en["backend::lang.form.save"]["other"] != "Save" {
t.Fatalf("en-GB locale=%q save=%v", locale, en["backend::lang.form.save"])
}
fallback, locale, _ := call("de")
if locale != "en" || fallback["backend::lang.form.save"]["other"] != "Save" {
t.Fatalf("de falls back to en: locale=%q save=%v", locale, fallback["backend::lang.form.save"])
}
// No translator: an empty bundle, still an envelope.
empty := &service{app: backpack.New(nil)}
req := httptest.NewRequest(http.MethodGet, adminAPI("/lang"), nil)
out := httptest.NewRecorder()
empty.langBundle(out, req)
if out.Code != http.StatusOK || !strings.Contains(out.Body.String(), `"data":{}`) {
t.Fatalf("no translator status=%d body=%s", out.Code, out.Body.String())
}
}