Files
summercms/modules/cabana/localize_error_test.go
Jakub Zych ce8d7efb67 fix(cabana): translate hook ValidationError phrase keys in the request locale
- 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.
2026-10-19 12:53:17 +02:00

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())
}
}