- Move remaining beach packages and embedded admin assets\n- Rewrite framework, example, build, and gate paths
355 lines
13 KiB
Go
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())
|
|
}
|
|
}
|