feat(lagoon): check min and max as character length on string and url fields (quick-261104-f4x)

- on a string or url field that is not numeric or integer, min and max
  compare utf8.RuneCountInString (Laravel's mb_strlen) and answer with
  the lagoon::validation min.string / max.string messages
- a min or max argument that is not a whole number is the
  unrecognized-rule error, whatever the value
- rule strings without string or url keep their results (pinned by a
  test); between is unchanged
This commit is contained in:
Jakub Zych
2026-11-04 11:13:09 +01:00
parent d2ce3a2c3b
commit d166f1c308
5 changed files with 109 additions and 4 deletions

View File

@@ -401,6 +401,8 @@ func TestValidateStringURLTranslated(t *testing.T) {
}{
{"title", "nullable|string|max:255", 5, "title musi być ciągiem znaków."},
{"website", "nullable|url|max:500", "not a url", "Format website jest nieprawidłowy."},
{"title", "nullable|string|max:255", strings.Repeat("a", 256), "title nie może być dłuższy niż 255 znaków."},
{"title", "string|min:3", "ab", "title musi mieć przynajmniej 3 znaków."},
}
for _, tc := range cases {
errs, err := Validate(inLocale("pl"), nil, nil, map[string]string{tc.field: tc.rule}, map[string]any{tc.field: tc.value}, tr)
@@ -412,3 +414,47 @@ func TestValidateStringURLTranslated(t *testing.T) {
}
}
}
// TestValidateStringLength: on a string or url field min and max compare
// the length in characters (runes, Laravel's mb_strlen) with the string
// size messages; rule strings without string or url keep their old results.
func TestValidateStringLength(t *testing.T) {
const longURL = "https://example.com/a-very-long-path/feed.png"
const longWords = "this is not a url and it is far too long"
if len(longWords) != 40 {
t.Fatalf("fixture length %d", len(longWords))
}
runRuleCases(t, "title", []ruleCase{
{"255 ascii", "nullable|string|max:255", strings.Repeat("a", 255), nil},
{"256 ascii", "nullable|string|max:255", strings.Repeat("a", 256), []string{"The title may not be greater than 255 characters."}},
{"255 polish letters", "nullable|string|max:255", strings.Repeat("ż", 255), nil},
{"256 polish letters", "nullable|string|max:255", strings.Repeat("ż", 256), []string{"The title may not be greater than 255 characters."}},
{"below min", "string|min:3", "ab", []string{"The title must be at least 3 characters."}},
{"at min", "string|min:3", "abc", nil},
{"min empty", "string|min:3", "", nil},
{"min nil", "string|min:3", nil, nil},
{"required min empty", "required|string|min:3", "", []string{"The title field is required."}},
{"non-string with max", "string|max:3", 12345, []string{"The title must be a string."}},
// Pinned: without string or url, min and max are not checked on a
// non-numeric field (unchanged behaviour, see the follow-up).
{"max without string", "max:5", "0123456789", nil},
{"nullable max without string", "nullable|max:5", "0123456789", nil},
{"string between keeps its check", "string|between:1,5", "0123456789", []string{"The title must be between 1 and 5 characters."}},
{"string between passes", "string|between:1,5", "abc", nil},
})
runRuleCases(t, "website", []ruleCase{
{"short url", "nullable|url|max:30", "https://example.com/feed.png", nil},
{"long url", "nullable|url|max:30", longURL, []string{"The website may not be greater than 30 characters."}},
{"long non-url", "nullable|url|max:30", longWords, []string{"The website format is invalid.", "The website may not be greater than 30 characters."}},
})
for _, v := range []any{"x", nil} {
_, err := Validate(t.Context(), nil, nil, map[string]string{"title": "string|max:abc"}, map[string]any{"title": v}, nil)
if err == nil || !strings.Contains(err.Error(), "max:abc") {
t.Fatalf("malformed max on %#v: err = %v", v, err)
}
}
_, err := Validate(t.Context(), nil, nil, map[string]string{"title": "nullable|string|min:x"}, map[string]any{"title": "abc"}, nil)
if err == nil || !strings.Contains(err.Error(), "min:x") {
t.Fatalf("malformed min: err = %v", err)
}
}