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