- One shared localizer for ValidationError and ForbiddenError, tested in writeCRUDError's order; replaces the ForbiddenError-only one. - Applied on the CRUD and relation service transactions, the detached bulk branch, runAction, the record read (ShowRecord), and the reorder, singleton and relation-options routes, which localized neither type. - Only strings containing "::" are looked up, so translated text passes through unchanged and logs no missing-key warning. - Copy-on-write: the plugin's error value is never modified. - README and backend docs describe the localized ValidationError.
190 lines
7.2 KiB
Go
190 lines
7.2 KiB
Go
package cabana
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
"testing/fstest"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/phrasebook"
|
|
"git.golem15.com/golem15/summercms/modules/towel"
|
|
)
|
|
|
|
const (
|
|
menuURLScheme = "acme.menu::lang.errors.url_scheme"
|
|
menuTarget = "acme.menu::lang.errors.target"
|
|
menuMissing = "acme.menu::lang.errors.missing"
|
|
)
|
|
|
|
var menuTexts = map[string][2]string{
|
|
"en": {"The link must start with http or https.", "Choose where the link opens."},
|
|
"pl": {"Link musi zaczynać się od http lub https.", "Wybierz, gdzie otwiera się link."},
|
|
}
|
|
|
|
// menuTranslator has the acme.menu error keys in en and pl.
|
|
func menuTranslator(t *testing.T) *phrasebook.Translator {
|
|
t.Helper()
|
|
cat := phrasebook.NewCatalog()
|
|
files := fstest.MapFS{}
|
|
for locale, texts := range menuTexts {
|
|
files["lang/"+locale+"/lang.yaml"] = &fstest.MapFile{Data: []byte(fmt.Sprintf("errors:\n url_scheme: %q\n target: %q\n", texts[0], texts[1]))}
|
|
}
|
|
if err := cat.Load("acme.menu", files); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return phrasebook.NewTranslator(cat, phrasebook.Options{Locale: "en", Fallback: "en"})
|
|
}
|
|
|
|
// menuInvalid is a ValidationError with every Details shape a plugin uses.
|
|
func menuInvalid() *ValidationError {
|
|
return &ValidationError{Details: map[string]any{
|
|
"url": []string{menuURLScheme},
|
|
"target": menuTarget,
|
|
"mixed": []any{menuURLScheme, 7},
|
|
"title": []string{"The title field is required."},
|
|
"nested": map[string]any{"pl": []string{menuURLScheme}},
|
|
"absent": []string{menuMissing},
|
|
}}
|
|
}
|
|
|
|
func TestLocalizeControllerErrorValidation(t *testing.T) {
|
|
tr := menuTranslator(t)
|
|
shared := menuInvalid()
|
|
before := menuInvalid()
|
|
for _, locale := range []string{"pl", "en", "pl"} {
|
|
ctx := towel.WithLocale(context.Background(), locale)
|
|
got := localizeControllerError(ctx, tr, shared)
|
|
var ve *ValidationError
|
|
if !errors.As(got, &ve) {
|
|
t.Fatalf("%s: got %T, want *ValidationError", locale, got)
|
|
}
|
|
if ve == shared {
|
|
t.Fatalf("%s: the plugin's value was returned, not a copy", locale)
|
|
}
|
|
want := map[string]any{
|
|
"url": []string{menuTexts[locale][0]},
|
|
"target": []string{menuTexts[locale][1]},
|
|
"mixed": []any{menuTexts[locale][0], 7},
|
|
"title": []string{"The title field is required."},
|
|
"nested": map[string]any{"pl": []string{menuURLScheme}},
|
|
"absent": []string{menuMissing},
|
|
}
|
|
if !reflect.DeepEqual(ve.Details, want) {
|
|
t.Fatalf("%s: details = %#v, want %#v", locale, ve.Details, want)
|
|
}
|
|
// New slices: writing into the result does not reach the plugin's value.
|
|
ve.Details["url"].([]string)[0] = "changed"
|
|
ve.Details["title"].([]string)[0] = "changed"
|
|
ve.Details["mixed"].([]any)[0] = "changed"
|
|
if !reflect.DeepEqual(shared, before) {
|
|
t.Fatalf("%s: the plugin's value was modified: %#v", locale, shared.Details)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestLocalizeControllerErrorUnchanged(t *testing.T) {
|
|
tr := menuTranslator(t)
|
|
ctx := towel.WithLocale(context.Background(), "pl")
|
|
if got := localizeControllerError(ctx, tr, nil); got != nil {
|
|
t.Fatalf("nil error = %v", got)
|
|
}
|
|
plain := errors.New("x")
|
|
if got := localizeControllerError(ctx, tr, plain); got != plain {
|
|
t.Fatalf("plain error = %#v, want the identical value", got)
|
|
}
|
|
empty := &ValidationError{}
|
|
if got := localizeControllerError(ctx, tr, empty); got != empty {
|
|
t.Fatalf("nil details = %#v, want the identical value", got)
|
|
}
|
|
noDetails := &ValidationError{Details: map[string]any{}}
|
|
if got := localizeControllerError(ctx, tr, noDetails); got != noDetails {
|
|
t.Fatalf("empty details = %#v, want the identical value", got)
|
|
}
|
|
var nilVE *ValidationError
|
|
if got := localizeControllerError(ctx, tr, nilVE); got != error(nilVE) {
|
|
t.Fatalf("nil *ValidationError = %#v", got)
|
|
}
|
|
// A wrapped ValidationError is found and localized.
|
|
got := localizeControllerError(ctx, tr, fmt.Errorf("save: %w", &ValidationError{Details: map[string]any{"url": []string{menuURLScheme}}}))
|
|
ve, ok := got.(*ValidationError)
|
|
if !ok || !reflect.DeepEqual(ve.Details, map[string]any{"url": []string{menuTexts["pl"][0]}}) {
|
|
t.Fatalf("wrapped = %#v", got)
|
|
}
|
|
// Without a translator the keys stay.
|
|
got = localizeControllerError(nil, nil, &ValidationError{Details: map[string]any{"url": menuURLScheme}})
|
|
if ve, ok := got.(*ValidationError); !ok || !reflect.DeepEqual(ve.Details, map[string]any{"url": []string{menuURLScheme}}) {
|
|
t.Fatalf("nil translator = %#v", got)
|
|
}
|
|
}
|
|
|
|
func TestLocalizeControllerErrorForbidden(t *testing.T) {
|
|
tr := menuTranslator(t)
|
|
shared := &ForbiddenError{Message: menuTarget, Details: map[string]any{"url": []string{menuURLScheme}, "plain": "Text."}}
|
|
for _, locale := range []string{"en", "pl"} {
|
|
got := localizeControllerError(towel.WithLocale(context.Background(), locale), tr, shared)
|
|
fe, ok := got.(*ForbiddenError)
|
|
if !ok || fe == shared {
|
|
t.Fatalf("%s: got %#v", locale, got)
|
|
}
|
|
want := &ForbiddenError{Message: menuTexts[locale][1], Details: map[string]any{"url": []string{menuTexts[locale][0]}, "plain": []string{"Text."}}}
|
|
if !reflect.DeepEqual(fe, want) {
|
|
t.Fatalf("%s: got %#v, want %#v", locale, fe, want)
|
|
}
|
|
}
|
|
if shared.Message != menuTarget || !reflect.DeepEqual(shared.Details, map[string]any{"url": []string{menuURLScheme}, "plain": "Text."}) {
|
|
t.Fatalf("the plugin's value was modified: %#v", shared)
|
|
}
|
|
// An empty Message stays empty and no Details stay nil.
|
|
got := localizeControllerError(context.Background(), tr, &ForbiddenError{})
|
|
if fe, ok := got.(*ForbiddenError); !ok || fe.Message != "" || fe.Details != nil {
|
|
t.Fatalf("empty refusal = %#v", got)
|
|
}
|
|
}
|
|
|
|
func TestWriteCRUDErrorLocalized(t *testing.T) {
|
|
tr := menuTranslator(t)
|
|
err := localizeControllerError(towel.WithLocale(context.Background(), "pl"), tr, menuInvalid())
|
|
rec := httptest.NewRecorder()
|
|
writeCRUDError(rec, err)
|
|
var env ErrorEnvelope
|
|
if jerr := json.Unmarshal(rec.Body.Bytes(), &env); jerr != nil {
|
|
t.Fatal(jerr)
|
|
}
|
|
if rec.Code != http.StatusUnprocessableEntity || env.Error.Code != "validation_failed" {
|
|
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
if !reflect.DeepEqual(env.Error.Details["url"], []any{menuTexts["pl"][0]}) {
|
|
t.Fatalf("details.url = %#v", env.Error.Details["url"])
|
|
}
|
|
}
|
|
|
|
// TestLocalizeControllerErrorPlainTextNotLookedUp: text without "::" is
|
|
// never looked up, so the development log has no missing-key warning for it;
|
|
// an unknown key does warn, which proves the capture works. Not parallel: it
|
|
// swaps the default logger.
|
|
func TestLocalizeControllerErrorPlainTextNotLookedUp(t *testing.T) {
|
|
var buf bytes.Buffer
|
|
previous := slog.Default()
|
|
slog.SetDefault(slog.New(slog.NewTextHandler(&buf, nil)))
|
|
t.Cleanup(func() { slog.SetDefault(previous) })
|
|
|
|
tr := menuTranslator(t)
|
|
ctx := towel.WithLocale(context.Background(), "en")
|
|
localizeControllerError(ctx, tr, &ValidationError{Details: map[string]any{"title": []string{"The title field is required."}, "body": "Too long."}})
|
|
if strings.Contains(buf.String(), "missing translation key") {
|
|
t.Fatalf("plain text was looked up: %s", buf.String())
|
|
}
|
|
localizeControllerError(ctx, tr, &ValidationError{Details: map[string]any{"url": []string{menuMissing}}})
|
|
if !strings.Contains(buf.String(), "missing translation key") {
|
|
t.Fatalf("an unknown key logged nothing; the capture does not work: %q", buf.String())
|
|
}
|
|
}
|