diff --git a/validation/benchmark_test.go b/validation/benchmark_test.go
new file mode 100644
index 000000000..4964f061d
--- /dev/null
+++ b/validation/benchmark_test.go
@@ -0,0 +1,221 @@
+package validation
+
+import (
+ "context"
+ "fmt"
+ "testing"
+
+ contractsvalidation "github.com/goravel/framework/contracts/validation"
+)
+
+// --- End-to-end validation benchmarks ---
+
+func BenchmarkValidation_Simple(b *testing.B) {
+ v := NewValidation()
+ ctx := context.Background()
+ data := map[string]any{
+ "name": "John",
+ "email": "john@example.com",
+ "age": "25",
+ }
+ rules := map[string]any{
+ "name": "required|string|max:255",
+ "email": "required|email",
+ "age": "required|integer|min:1|max:150",
+ }
+
+ b.ReportAllocs()
+ b.ResetTimer()
+ for b.Loop() {
+ _, _ = v.Make(ctx, data, rules)
+ }
+}
+
+func BenchmarkValidation_ManyFields(b *testing.B) {
+ v := NewValidation()
+ ctx := context.Background()
+ data := make(map[string]any, 50)
+ rules := make(map[string]any, 50)
+ for i := range 50 {
+ key := fmt.Sprintf("field_%d", i)
+ data[key] = fmt.Sprintf("value_%d", i)
+ rules[key] = "required|string|min:1|max:255"
+ }
+
+ b.ReportAllocs()
+ b.ResetTimer()
+ for b.Loop() {
+ _, _ = v.Make(ctx, data, rules)
+ }
+}
+
+func BenchmarkValidation_ComplexRules(b *testing.B) {
+ v := NewValidation()
+ ctx := context.Background()
+ data := map[string]any{
+ "email": "test@example.com",
+ "website": "https://example.com",
+ "code": "ABC-123",
+ "start_date": "2024-01-01",
+ "end_date": "2024-12-31",
+ "password": "secret123",
+ "password_confirmation": "secret123",
+ }
+ rules := map[string]any{
+ "email": "required|email",
+ "website": "required|url",
+ "code": []string{"required", "regex:^[A-Z]{3}-[0-9]{3}$"},
+ "start_date": "required|date|before:end_date",
+ "end_date": "required|date|after:start_date",
+ "password": "required|string|min:8|confirmed",
+ }
+
+ b.ReportAllocs()
+ b.ResetTimer()
+ for b.Loop() {
+ _, _ = v.Make(ctx, data, rules)
+ }
+}
+
+func BenchmarkValidation_Wildcards(b *testing.B) {
+ v := NewValidation()
+ ctx := context.Background()
+ items := make([]any, 20)
+ for i := range 20 {
+ items[i] = map[string]any{
+ "name": fmt.Sprintf("Item %d", i),
+ "price": fmt.Sprintf("%d.99", i+1),
+ "qty": fmt.Sprintf("%d", i+1),
+ }
+ }
+ data := map[string]any{"items": items}
+ rules := map[string]any{
+ "items": "required|array|min:1",
+ "items.*.name": "required|string|max:100",
+ "items.*.price": "required|numeric|min:0",
+ "items.*.qty": "required|integer|min:1",
+ }
+
+ b.ReportAllocs()
+ b.ResetTimer()
+ for b.Loop() {
+ _, _ = v.Make(ctx, data, rules)
+ }
+}
+
+func BenchmarkValidation_AllInvalid(b *testing.B) {
+ v := NewValidation()
+ ctx := context.Background()
+ data := map[string]any{
+ "name": "",
+ "email": "not-an-email",
+ "age": "not-a-number",
+ "url": "not-a-url",
+ "date": "not-a-date",
+ }
+ rules := map[string]any{
+ "name": "required|string|min:1",
+ "email": "required|email",
+ "age": "required|integer|min:1|max:150",
+ "url": "required|url",
+ "date": "required|date",
+ }
+
+ b.ReportAllocs()
+ b.ResetTimer()
+ for b.Loop() {
+ _, _ = v.Make(ctx, data, rules)
+ }
+}
+
+func BenchmarkValidation_WithFilters(b *testing.B) {
+ v := NewValidation()
+ ctx := context.Background()
+ rules := map[string]any{
+ "name": "required|string|max:255",
+ "email": "required|email",
+ "bio": "required|string",
+ }
+ opts := []contractsvalidation.Option{
+ Filters(map[string]any{
+ "name": "trim",
+ "email": "trim|lower",
+ "bio": "trim|strip_tags",
+ }),
+ }
+
+ b.ReportAllocs()
+ b.ResetTimer()
+ for b.Loop() {
+ // Data is mutated by filters, so recreate it each iteration
+ dataCopy := map[string]any{
+ "name": " John Doe ",
+ "email": " JOHN@EXAMPLE.COM ",
+ "bio": " Hello World ",
+ }
+ _, _ = v.Make(ctx, dataCopy, rules, opts...)
+ }
+}
+
+// --- Component benchmarks ---
+
+func BenchmarkRuleParser_Simple(b *testing.B) {
+ b.ReportAllocs()
+ b.ResetTimer()
+ for b.Loop() {
+ ParseRules("required|string|max:255")
+ }
+}
+
+func BenchmarkRuleParser_Complex(b *testing.B) {
+ b.ReportAllocs()
+ b.ResetTimer()
+ for b.Loop() {
+ ParseRules("required|string|min:1|max:255|in:a,b,c,d,e")
+ }
+}
+
+func BenchmarkDataBag_Get_Flat(b *testing.B) {
+ bag, _ := NewDataBag(map[string]any{"name": "John", "age": 25})
+ b.ReportAllocs()
+ b.ResetTimer()
+ for b.Loop() {
+ _, _ = bag.Get("name")
+ }
+}
+
+func BenchmarkDataBag_Get_Nested(b *testing.B) {
+ bag, _ := NewDataBag(map[string]any{
+ "user": map[string]any{
+ "profile": map[string]any{
+ "name": "John",
+ },
+ },
+ })
+ b.ReportAllocs()
+ b.ResetTimer()
+ for b.Loop() {
+ _, _ = bag.Get("user.profile.name")
+ }
+}
+
+func BenchmarkExpandWildcardFields(b *testing.B) {
+ rules := map[string][]ParsedRule{
+ "items.*.name": {{Name: "required"}, {Name: "string"}},
+ "items.*.price": {{Name: "required"}, {Name: "numeric"}},
+ "items.*.qty": {{Name: "required"}, {Name: "integer"}},
+ }
+ keys := make([]string, 0, 100)
+ for i := range 20 {
+ keys = append(keys, fmt.Sprintf("items.%d", i))
+ keys = append(keys, fmt.Sprintf("items.%d.name", i))
+ keys = append(keys, fmt.Sprintf("items.%d.price", i))
+ keys = append(keys, fmt.Sprintf("items.%d.qty", i))
+ }
+
+ b.ReportAllocs()
+ b.ResetTimer()
+ for b.Loop() {
+ _ = expandWildcardFields(rules, keys, true)
+ }
+}
diff --git a/validation/engine.go b/validation/engine.go
index 1d58e6e5c..e0bf7c42f 100644
--- a/validation/engine.go
+++ b/validation/engine.go
@@ -349,10 +349,21 @@ func (e *Engine) formatErrorMessage(field string, rule ParsedRule, attrType stri
}
replacements[":values"] = strings.Join(names, ", ")
}
+ case "exclude_if", "exclude_unless":
+ if len(rule.Parameters) > 0 {
+ replacements[":other"] = getDisplayableAttribute(rule.Parameters[0], e.attributes)
+ }
+ if len(rule.Parameters) > 1 {
+ replacements[":value"] = strings.Join(rule.Parameters[1:], ", ")
+ }
case "same", "different", "in_array", "confirmed", "prohibits":
if len(rule.Parameters) > 0 {
replacements[":other"] = getDisplayableAttribute(rule.Parameters[0], e.attributes)
}
+ case "eq", "ne":
+ if len(rule.Parameters) > 0 {
+ replacements[":value"] = rule.Parameters[0]
+ }
case "digits":
if len(rule.Parameters) > 0 {
replacements[":digits"] = rule.Parameters[0]
@@ -379,6 +390,31 @@ func (e *Engine) formatErrorMessage(field string, rule ParsedRule, attrType stri
if len(rule.Parameters) > 0 {
replacements[":format"] = rule.Parameters[0]
}
+ // Deprecated: will be removed in the next version.
+ case "len":
+ if len(rule.Parameters) > 0 {
+ replacements[":size"] = rule.Parameters[0]
+ }
+ case "min_len":
+ if len(rule.Parameters) > 0 {
+ replacements[":min"] = rule.Parameters[0]
+ }
+ case "max_len":
+ if len(rule.Parameters) > 0 {
+ replacements[":max"] = rule.Parameters[0]
+ }
+ case "eq_field", "ne_field":
+ if len(rule.Parameters) > 0 {
+ replacements[":other"] = getDisplayableAttribute(rule.Parameters[0], e.attributes)
+ }
+ case "gt_field", "gte_field", "lt_field", "lte_field":
+ if len(rule.Parameters) > 0 {
+ replacements[":value"] = getDisplayableAttribute(rule.Parameters[0], e.attributes)
+ }
+ case "gt_date", "lt_date", "gte_date", "lte_date":
+ if len(rule.Parameters) > 0 {
+ replacements[":date"] = rule.Parameters[0]
+ }
}
return formatMessage(msg, replacements)
diff --git a/validation/errors_test.go b/validation/errors_test.go
index 4175a32e9..f0a07beea 100644
--- a/validation/errors_test.go
+++ b/validation/errors_test.go
@@ -1,87 +1,230 @@
package validation
import (
+ "context"
"testing"
"github.com/stretchr/testify/assert"
-)
-func TestErrors_One(t *testing.T) {
- t.Run("empty errors", func(t *testing.T) {
- errors := NewErrors()
- assert.Equal(t, "", errors.One())
- })
+ httpvalidate "github.com/goravel/framework/contracts/validation"
+)
- t.Run("returns first error", func(t *testing.T) {
- errors := NewErrors()
- errors.Add("a", "required", "The a field is required.")
- assert.Equal(t, "The a field is required.", errors.One())
- })
+func TestOne(t *testing.T) {
+ tests := []struct {
+ describe string
+ data any
+ rules map[string]any
+ options []httpvalidate.Option
+ expectRes string
+ }{
+ {
+ describe: "errors is empty",
+ data: map[string]any{"a": "aa"},
+ rules: map[string]any{"a": "required"},
+ options: []httpvalidate.Option{
+ Filters(map[string]any{"a": "trim"}),
+ },
+ },
+ {
+ describe: "errors isn't empty",
+ data: map[string]any{"a": ""},
+ rules: map[string]any{"a": "required"},
+ options: []httpvalidate.Option{
+ Filters(map[string]any{"a": "trim"}),
+ },
+ expectRes: "The a field is required.",
+ },
+ {
+ describe: "errors isn't empty when setting messages option",
+ data: map[string]any{"a": ""},
+ rules: map[string]any{"a": "required"},
+ options: []httpvalidate.Option{
+ Filters(map[string]any{"a": "trim"}),
+ Messages(map[string]string{"a.required": "a can't be empty"}),
+ },
+ expectRes: "a can't be empty",
+ },
+ {
+ describe: "errors isn't empty when setting attributes option",
+ data: map[string]any{"a": ""},
+ rules: map[string]any{"a": "required"},
+ options: []httpvalidate.Option{
+ Filters(map[string]any{"a": "trim"}),
+ Attributes(map[string]string{"a": "aa"}),
+ },
+ expectRes: "The aa field is required.",
+ },
+ {
+ describe: "errors isn't empty when setting messages and attributes option",
+ data: map[string]any{"a": ""},
+ rules: map[string]any{"a": "required"},
+ options: []httpvalidate.Option{
+ Filters(map[string]any{"a": "trim"}),
+ Messages(map[string]string{"a.required": ":attribute can't be empty"}),
+ Attributes(map[string]string{"a": "aa"}),
+ },
+ expectRes: "aa can't be empty",
+ },
+ }
- t.Run("returns first error for specific field", func(t *testing.T) {
- errors := NewErrors()
- errors.Add("a", "required", "The a field is required.")
- errors.Add("b", "required", "The b field is required.")
- assert.Equal(t, "The a field is required.", errors.One("a"))
- assert.Equal(t, "The b field is required.", errors.One("b"))
- })
-}
+ for _, test := range tests {
+ t.Run(test.describe, func(t *testing.T) {
+ maker := NewValidation()
+ validator, err := maker.Make(
+ context.Background(),
+ test.data,
+ test.rules,
+ test.options...,
+ )
-func TestErrors_Get(t *testing.T) {
- t.Run("empty errors", func(t *testing.T) {
- errors := NewErrors()
- assert.Empty(t, errors.Get("a"))
- })
+ assert.Nil(t, err, test.describe)
+ assert.NotNil(t, validator, test.describe)
- t.Run("returns field errors", func(t *testing.T) {
- errors := NewErrors()
- errors.Add("a", "required", "The a field is required.")
- errors.Add("b", "required", "The b field is required.")
- assert.Equal(t, map[string]string{"required": "The a field is required."}, errors.Get("a"))
- assert.Equal(t, map[string]string{"required": "The b field is required."}, errors.Get("b"))
- })
+ if test.expectRes != "" {
+ errors := validator.Errors()
+ assert.NotNil(t, errors)
+ assert.Equal(t, test.expectRes, errors.One(), test.describe)
+ }
+ })
+ }
}
-func TestErrors_All(t *testing.T) {
- t.Run("empty errors", func(t *testing.T) {
- errors := NewErrors()
- assert.Empty(t, errors.All())
- })
+func TestGet(t *testing.T) {
+ tests := []struct {
+ describe string
+ data any
+ rules map[string]any
+ filters map[string]any
+ expectA map[string]string
+ expectB map[string]string
+ }{
+ {
+ describe: "errors is empty",
+ data: map[string]any{"a": "aa", "b": "bb"},
+ rules: map[string]any{"a": "required", "b": "required"},
+ filters: map[string]any{"a": "trim", "b": "trim"},
+ },
+ {
+ describe: "errors isn't empty",
+ data: map[string]any{"c": "cc"},
+ rules: map[string]any{"a": "required", "b": "required"},
+ filters: map[string]any{"a": "trim", "b": "trim"},
+ expectA: map[string]string{"required": "The a field is required."},
+ expectB: map[string]string{"required": "The b field is required."},
+ },
+ }
- t.Run("returns all errors", func(t *testing.T) {
- errors := NewErrors()
- errors.Add("a", "required", "The a field is required.")
- errors.Add("b", "required", "The b field is required.")
- assert.Equal(t, map[string]map[string]string{
- "a": {"required": "The a field is required."},
- "b": {"required": "The b field is required."},
- }, errors.All())
- })
+ for _, test := range tests {
+ t.Run(test.describe, func(t *testing.T) {
+ maker := NewValidation()
+ validator, err := maker.Make(
+ context.Background(),
+ test.data,
+ test.rules,
+ Filters(test.filters),
+ )
+ assert.Nil(t, err, test.describe)
+ if len(test.expectA) > 0 {
+ errors := validator.Errors()
+ assert.NotNil(t, errors)
+ assert.Equal(t, test.expectA, errors.Get("a"), test.describe)
+ }
+ if len(test.expectB) > 0 {
+ errors := validator.Errors()
+ assert.NotNil(t, errors)
+ assert.Equal(t, test.expectB, errors.Get("b"), test.describe)
+ }
+ })
+ }
}
-func TestErrors_Has(t *testing.T) {
- t.Run("empty errors", func(t *testing.T) {
- errors := NewErrors()
- assert.False(t, errors.Has("a"))
- })
+func TestAll(t *testing.T) {
+ tests := []struct {
+ describe string
+ data any
+ rules map[string]any
+ filters map[string]any
+ expectRes map[string]map[string]string
+ }{
+ {
+ describe: "errors is empty",
+ data: map[string]any{"a": "aa", "b": "bb"},
+ rules: map[string]any{"a": "required", "b": "required"},
+ filters: map[string]any{"a": "trim", "b": "trim"},
+ expectRes: nil,
+ },
+ {
+ describe: "errors isn't empty",
+ data: map[string]any{"c": "cc"},
+ rules: map[string]any{"a": "required", "b": "required"},
+ filters: map[string]any{"a": "trim", "b": "trim"},
+ expectRes: map[string]map[string]string{
+ "a": {"required": "The a field is required."},
+ "b": {"required": "The b field is required."},
+ },
+ },
+ }
- t.Run("has field with errors", func(t *testing.T) {
- errors := NewErrors()
- errors.Add("a", "required", "The a field is required.")
- assert.True(t, errors.Has("a"))
- assert.False(t, errors.Has("b"))
- })
+ for _, test := range tests {
+ t.Run(test.describe, func(t *testing.T) {
+ maker := NewValidation()
+ validator, err := maker.Make(
+ context.Background(),
+ test.data,
+ test.rules,
+ Filters(test.filters),
+ )
+ assert.Nil(t, err, test.describe)
+ if len(test.expectRes) > 0 {
+ errors := validator.Errors()
+ assert.NotNil(t, errors)
+ assert.Equal(t, test.expectRes, errors.All(), test.describe)
+ } else {
+ assert.Nil(t, validator.Errors(), test.describe)
+ }
+ })
+ }
}
-func TestErrors_IsEmpty(t *testing.T) {
- t.Run("empty", func(t *testing.T) {
- errors := NewErrors()
- assert.True(t, errors.IsEmpty())
- })
+func TestHas(t *testing.T) {
+ tests := []struct {
+ describe string
+ data any
+ rules map[string]any
+ filters map[string]any
+ expectRes bool
+ }{
+ {
+ describe: "errors is empty",
+ data: map[string]any{"a": "aa", "b": "bb"},
+ rules: map[string]any{"a": "required", "b": "required"},
+ filters: map[string]any{"a": "trim", "b": "trim"},
+ },
+ {
+ describe: "errors isn't empty",
+ data: map[string]any{"c": "cc"},
+ rules: map[string]any{"a": "required", "b": "required"},
+ filters: map[string]any{"a": "trim", "b": "trim"},
+ expectRes: true,
+ },
+ }
- t.Run("not empty", func(t *testing.T) {
- errors := NewErrors()
- errors.Add("a", "required", "The a field is required.")
- assert.False(t, errors.IsEmpty())
- })
+ for _, test := range tests {
+ t.Run(test.describe, func(t *testing.T) {
+ maker := NewValidation()
+ validator, err := maker.Make(
+ context.Background(),
+ test.data,
+ test.rules,
+ Filters(test.filters),
+ )
+ assert.Nil(t, err, test.describe)
+ if test.expectRes {
+ errors := validator.Errors()
+ assert.NotNil(t, errors)
+ assert.Equal(t, test.expectRes, errors.Has("a"), test.describe)
+ assert.False(t, errors.Has("nonexistent"), test.describe)
+ }
+ })
+ }
}
diff --git a/validation/filters.go b/validation/filters.go
index f5964c6ab..3fd6ed556 100644
--- a/validation/filters.go
+++ b/validation/filters.go
@@ -3,16 +3,142 @@ package validation
import (
"context"
"fmt"
+ "html"
+ "net/url"
"reflect"
+ "strings"
+ "unicode"
"github.com/spf13/cast"
+ "golang.org/x/text/cases"
+ "golang.org/x/text/language"
validatecontract "github.com/goravel/framework/contracts/validation"
"github.com/goravel/framework/errors"
)
// builtinFilters contains all built-in filter functions.
-var builtinFilters = map[string]func(val any) any{}
+var builtinFilters = map[string]func(val any) any{
+ // String cleaning
+ "trim": func(val any) any { return strings.TrimSpace(cast.ToString(val)) },
+ "ltrim": func(val any) any { return strings.TrimLeft(cast.ToString(val), " \t\n\r") },
+ "rtrim": func(val any) any { return strings.TrimRight(cast.ToString(val), " \t\n\r") },
+
+ // Case conversion
+ "lower": func(val any) any { return strings.ToLower(cast.ToString(val)) },
+ "upper": func(val any) any { return strings.ToUpper(cast.ToString(val)) },
+ "title": func(val any) any { return cases.Title(language.Und).String(cast.ToString(val)) },
+ "ucfirst": func(val any) any {
+ s := cast.ToString(val)
+ if len(s) == 0 {
+ return s
+ }
+ runes := []rune(s)
+ runes[0] = unicode.ToUpper(runes[0])
+ return string(runes)
+ },
+ "lcfirst": func(val any) any {
+ s := cast.ToString(val)
+ if len(s) == 0 {
+ return s
+ }
+ runes := []rune(s)
+ runes[0] = unicode.ToLower(runes[0])
+ return string(runes)
+ },
+
+ // Naming style
+ "camel": func(val any) any { return toCamelCase(cast.ToString(val)) },
+ "snake": func(val any) any { return toSnakeCase(cast.ToString(val)) },
+
+ // Type conversion
+ "to_int": func(val any) any { return cast.ToInt(val) },
+ "to_int64": func(val any) any { return cast.ToInt64(val) },
+ "to_uint": func(val any) any { return cast.ToUint(val) },
+ "to_float": func(val any) any { return cast.ToFloat64(val) },
+ "to_bool": func(val any) any { return cast.ToBool(val) },
+ "to_string": func(val any) any { return cast.ToString(val) },
+ "to_time": func(val any) any { return cast.ToTime(val) },
+ "int": func(val any) any { return cast.ToInt(val) },
+ "int64": func(val any) any { return cast.ToInt64(val) },
+ "uint": func(val any) any { return cast.ToUint(val) },
+ "float": func(val any) any { return cast.ToFloat64(val) },
+ "bool": func(val any) any { return cast.ToBool(val) },
+
+ // Encoding
+ "strip_tags": func(val any) any { return stripHTMLTags(cast.ToString(val)) },
+ "escape_js": func(val any) any { return escapeJS(cast.ToString(val)) },
+ "escape_html": func(val any) any { return html.EscapeString(cast.ToString(val)) },
+ "url_encode": func(val any) any { return url.QueryEscape(cast.ToString(val)) },
+ "url_decode": func(val any) any {
+ decoded, err := url.QueryUnescape(cast.ToString(val))
+ if err != nil {
+ return cast.ToString(val)
+ }
+ return decoded
+ },
+
+ // String splitting
+ "str_to_ints": func(val any) any { return strToInts(cast.ToString(val)) },
+ "str_to_array": func(val any) any { return strToArray(cast.ToString(val)) },
+ "str_to_time": func(val any) any { return cast.ToTime(val) },
+
+ // Deprecated: use snake_case names instead, will be removed in the next version.
+ "trimSpace": func(val any) any { return strings.TrimSpace(cast.ToString(val)) },
+ "trimLeft": func(val any) any { return strings.TrimLeft(cast.ToString(val), " \t\n\r") },
+ "trimRight": func(val any) any { return strings.TrimRight(cast.ToString(val), " \t\n\r") },
+ "lowercase": func(val any) any { return strings.ToLower(cast.ToString(val)) },
+ "uppercase": func(val any) any { return strings.ToUpper(cast.ToString(val)) },
+ "lowerFirst": func(val any) any {
+ s := cast.ToString(val)
+ if len(s) == 0 {
+ return s
+ }
+ runes := []rune(s)
+ runes[0] = unicode.ToLower(runes[0])
+ return string(runes)
+ },
+ "upperFirst": func(val any) any {
+ s := cast.ToString(val)
+ if len(s) == 0 {
+ return s
+ }
+ runes := []rune(s)
+ runes[0] = unicode.ToUpper(runes[0])
+ return string(runes)
+ },
+ "ucWord": func(val any) any { return cases.Title(language.Und).String(cast.ToString(val)) },
+ "upperWord": func(val any) any { return cases.Title(language.Und).String(cast.ToString(val)) },
+ "camelCase": func(val any) any { return toCamelCase(cast.ToString(val)) },
+ "snakeCase": func(val any) any { return toSnakeCase(cast.ToString(val)) },
+ "toInt": func(val any) any { return cast.ToInt(val) },
+ "toUint": func(val any) any { return cast.ToUint(val) },
+ "toInt64": func(val any) any { return cast.ToInt64(val) },
+ "toFloat": func(val any) any { return cast.ToFloat64(val) },
+ "toBool": func(val any) any { return cast.ToBool(val) },
+ "toString": func(val any) any { return cast.ToString(val) },
+ "toTime": func(val any) any { return cast.ToTime(val) },
+ "str2time": func(val any) any { return cast.ToTime(val) },
+ "strToTime": func(val any) any { return cast.ToTime(val) },
+ "escapeHtml": func(val any) any { return html.EscapeString(cast.ToString(val)) },
+ "escapeHTML": func(val any) any { return html.EscapeString(cast.ToString(val)) },
+ "escapeJs": func(val any) any { return escapeJS(cast.ToString(val)) },
+ "escapeJS": func(val any) any { return escapeJS(cast.ToString(val)) },
+ "urlEncode": func(val any) any { return url.QueryEscape(cast.ToString(val)) },
+ "urlDecode": func(val any) any {
+ decoded, err := url.QueryUnescape(cast.ToString(val))
+ if err != nil {
+ return cast.ToString(val)
+ }
+ return decoded
+ },
+ "stripTags": func(val any) any { return stripHTMLTags(cast.ToString(val)) },
+ "str2ints": func(val any) any { return strToInts(cast.ToString(val)) },
+ "strToInts": func(val any) any { return strToInts(cast.ToString(val)) },
+ "str2arr": func(val any) any { return strToArray(cast.ToString(val)) },
+ "str2array": func(val any) any { return strToArray(cast.ToString(val)) },
+ "strToArray": func(val any) any { return strToArray(cast.ToString(val)) },
+}
// applyFilters applies filter rules to the DataBag.
// Supports wildcard patterns (e.g., "users.*.name": "trim") that expand
diff --git a/validation/filters_test.go b/validation/filters_test.go
new file mode 100644
index 000000000..453f3e9e5
--- /dev/null
+++ b/validation/filters_test.go
@@ -0,0 +1,569 @@
+package validation
+
+import (
+ "context"
+ "fmt"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ contractsvalidation "github.com/goravel/framework/contracts/validation"
+)
+
+func TestBuiltinFilters(t *testing.T) {
+ tests := []struct {
+ name string
+ filter string
+ input any
+ expected any
+ }{
+ // String cleaning
+ {"trim", "trim", " hello ", "hello"},
+ {"ltrim", "ltrim", " hello ", "hello "},
+ {"rtrim", "rtrim", " hello ", " hello"},
+
+ // Case conversion
+ {"lower", "lower", "HELLO", "hello"},
+ {"upper", "upper", "hello", "HELLO"},
+ {"title", "title", "hello world", "Hello World"},
+ {"ucfirst", "ucfirst", "hello", "Hello"},
+ {"ucfirst empty", "ucfirst", "", ""},
+ {"lcfirst", "lcfirst", "Hello", "hello"},
+ {"lcfirst empty", "lcfirst", "", ""},
+
+ // Naming style
+ {"camel from snake", "camel", "hello_world", "helloWorld"},
+ {"camel from words", "camel", "hello world", "helloWorld"},
+ {"snake from camel", "snake", "helloWorld", "hello_world"},
+ {"snake from words", "snake", "hello world", "hello_world"},
+
+ // Type conversion
+ {"to_int from string", "to_int", "42", 42},
+ {"to_int from float", "to_int", 42.9, 42},
+ {"to_int64 from string", "to_int64", "9999999999", int64(9999999999)},
+ {"to_int64 from int", "to_int64", 42, int64(42)},
+ {"to_uint from string", "to_uint", "42", uint(42)},
+ {"to_float from string", "to_float", "3.14", 3.14},
+ {"to_bool true", "to_bool", "true", true},
+ {"to_bool false", "to_bool", "false", false},
+ {"to_string from int", "to_string", 42, "42"},
+ {"to_time", "to_time", "2024-01-01", time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)},
+ {"int alias", "int", "42", 42},
+ {"int64 alias", "int64", "9999999999", int64(9999999999)},
+ {"uint alias", "uint", "42", uint(42)},
+ {"float alias", "float", "3.14", 3.14},
+ {"bool alias", "bool", "true", true},
+
+ // Encoding
+ {"strip_tags", "strip_tags", "
Hello World
", "Hello World"},
+ {"strip_tags no tags", "strip_tags", "Hello World", "Hello World"},
+ {"escape_js", "escape_js", ``, `\x3cscript\x3ealert(\"xss\")\x3c\/script\x3e`},
+ {"escape_js newlines", "escape_js", "line1\nline2", `line1\nline2`},
+ {"escape_html", "escape_html", "", "<script>alert('xss')</script>"},
+ {"url_encode", "url_encode", "hello world&foo=bar", "hello+world%26foo%3Dbar"},
+ {"url_decode", "url_decode", "hello+world%26foo%3Dbar", "hello world&foo=bar"},
+ {"url_decode invalid", "url_decode", "%zz", "%zz"},
+
+ // String splitting
+ {"str_to_ints", "str_to_ints", "1,2,3", []int{1, 2, 3}},
+ {"str_to_ints with spaces", "str_to_ints", "1, 2, 3", []int{1, 2, 3}},
+ {"str_to_ints single", "str_to_ints", "42", []int{42}},
+ {"str_to_array", "str_to_array", "a,b,c", []string{"a", "b", "c"}},
+ {"str_to_array with spaces", "str_to_array", "a, b, c", []string{"a", "b", "c"}},
+ {"str_to_array single", "str_to_array", "hello", []string{"hello"}},
+ {"str_to_time", "str_to_time", "2024-01-01", time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)},
+
+ // Deprecated aliases
+ {"trimSpace", "trimSpace", " hello ", "hello"},
+ {"trimLeft", "trimLeft", " hello ", "hello "},
+ {"trimRight", "trimRight", " hello ", " hello"},
+ {"lowercase", "lowercase", "HELLO", "hello"},
+ {"uppercase", "uppercase", "hello", "HELLO"},
+ {"lowerFirst", "lowerFirst", "Hello", "hello"},
+ {"upperFirst", "upperFirst", "hello", "Hello"},
+ {"ucWord", "ucWord", "hello world", "Hello World"},
+ {"upperWord", "upperWord", "hello world", "Hello World"},
+ {"camelCase", "camelCase", "hello_world", "helloWorld"},
+ {"snakeCase", "snakeCase", "helloWorld", "hello_world"},
+ {"toInt", "toInt", "42", 42},
+ {"toUint", "toUint", "42", uint(42)},
+ {"toInt64", "toInt64", "100", int64(100)},
+ {"toFloat", "toFloat", "3.14", 3.14},
+ {"toBool", "toBool", "true", true},
+ {"toString", "toString", 42, "42"},
+ {"toTime", "toTime", "2024-01-01", time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)},
+ {"str2time", "str2time", "2024-01-01", time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)},
+ {"strToTime", "strToTime", "2024-01-01", time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)},
+ {"escapeHtml", "escapeHtml", "hi", "<b>hi</b>"},
+ {"escapeHTML", "escapeHTML", "hi", "<b>hi</b>"},
+ {"escapeJs", "escapeJs", "alert('x')", `alert(\'x\')`},
+ {"escapeJS", "escapeJS", "alert('x')", `alert(\'x\')`},
+ {"urlEncode", "urlEncode", "a b", "a+b"},
+ {"urlDecode", "urlDecode", "a+b", "a b"},
+ {"stripTags", "stripTags", "hi
", "hi"},
+ {"str2ints", "str2ints", "1,2", []int{1, 2}},
+ {"strToInts", "strToInts", "1,2", []int{1, 2}},
+ {"str2arr", "str2arr", "a,b", []string{"a", "b"}},
+ {"str2array", "str2array", "a,b", []string{"a", "b"}},
+ {"strToArray", "strToArray", "a,b", []string{"a", "b"}},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ fn, ok := builtinFilters[tt.filter]
+ require.True(t, ok, "builtin filter %s not found", tt.filter)
+ result := fn(tt.input)
+ assert.Equal(t, tt.expected, result)
+ })
+ }
+}
+
+func TestApplyFilters(t *testing.T) {
+ t.Run("apply builtin filters", func(t *testing.T) {
+ bag, err := NewDataBag(map[string]any{
+ "name": " Hello World ",
+ "email": "TEST@EXAMPLE.COM",
+ })
+ require.NoError(t, err)
+
+ err = applyFilters(context.Background(), bag, map[string]any{
+ "name": "trim|lower",
+ "email": "lower",
+ }, nil)
+ require.NoError(t, err)
+
+ val, _ := bag.Get("name")
+ assert.Equal(t, "hello world", val)
+
+ val, _ = bag.Get("email")
+ assert.Equal(t, "test@example.com", val)
+ })
+
+ t.Run("skip non-existent field", func(t *testing.T) {
+ bag, err := NewDataBag(map[string]any{
+ "name": "hello",
+ })
+ require.NoError(t, err)
+
+ err = applyFilters(context.Background(), bag, map[string]any{
+ "nonexistent": "trim",
+ }, nil)
+ require.NoError(t, err)
+
+ val, _ := bag.Get("name")
+ assert.Equal(t, "hello", val)
+ })
+
+ t.Run("empty filter rules", func(t *testing.T) {
+ bag, err := NewDataBag(map[string]any{
+ "name": "hello",
+ })
+ require.NoError(t, err)
+
+ err = applyFilters(context.Background(), bag, map[string]any{}, nil)
+ require.NoError(t, err)
+
+ val, _ := bag.Get("name")
+ assert.Equal(t, "hello", val)
+ })
+
+ t.Run("apply custom filter", func(t *testing.T) {
+ bag, err := NewDataBag(map[string]any{
+ "name": "hello",
+ })
+ require.NoError(t, err)
+
+ customFilter := &mockFilter{
+ signature: "reverse",
+ handler: func(val string) string {
+ runes := []rune(val)
+ for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 {
+ runes[i], runes[j] = runes[j], runes[i]
+ }
+ return string(runes)
+ },
+ }
+
+ err = applyFilters(context.Background(), bag, map[string]any{
+ "name": "reverse",
+ }, []contractsvalidation.Filter{customFilter})
+ require.NoError(t, err)
+
+ val, _ := bag.Get("name")
+ assert.Equal(t, "olleh", val)
+ })
+
+ t.Run("chain builtin and custom filters", func(t *testing.T) {
+ bag, err := NewDataBag(map[string]any{
+ "name": " Hello ",
+ })
+ require.NoError(t, err)
+
+ customFilter := &mockFilter{
+ signature: "append_exclaim",
+ handler: func(val string) string {
+ return val + "!"
+ },
+ }
+
+ err = applyFilters(context.Background(), bag, map[string]any{
+ "name": "trim|lower|append_exclaim",
+ }, []contractsvalidation.Filter{customFilter})
+ require.NoError(t, err)
+
+ val, _ := bag.Get("name")
+ assert.Equal(t, "hello!", val)
+ })
+
+ t.Run("invalid filter type returns error", func(t *testing.T) {
+ bag, err := NewDataBag(map[string]any{
+ "name": "hello",
+ })
+ require.NoError(t, err)
+
+ err = applyFilters(context.Background(), bag, map[string]any{
+ "name": 123,
+ }, nil)
+ assert.Error(t, err)
+ })
+}
+
+func TestCallFilterFunc(t *testing.T) {
+ t.Run("single return value", func(t *testing.T) {
+ filter := &mockFilter{
+ signature: "double",
+ handler: func(val int) int {
+ return val * 2
+ },
+ }
+
+ result, err := callFilterFunc(context.Background(), filter, 5, nil)
+ assert.NoError(t, err)
+ assert.Equal(t, 10, result)
+ })
+
+ t.Run("return value with error", func(t *testing.T) {
+ filter := &mockFilter{
+ signature: "safe_parse",
+ handler: func(val string) (int, error) {
+ if val == "bad" {
+ return 0, fmt.Errorf("bad value")
+ }
+ return 42, nil
+ },
+ }
+
+ result, err := callFilterFunc(context.Background(), filter, "good", nil)
+ assert.NoError(t, err)
+ assert.Equal(t, 42, result)
+
+ _, err = callFilterFunc(context.Background(), filter, "bad", nil)
+ assert.Error(t, err)
+ })
+
+ t.Run("variadic arguments", func(t *testing.T) {
+ filter := &mockFilter{
+ signature: "default_val",
+ handler: func(val string, def ...string) string {
+ if val == "" && len(def) > 0 {
+ return def[0]
+ }
+ return val
+ },
+ }
+
+ result, err := callFilterFunc(context.Background(), filter, "", []string{"default"})
+ assert.NoError(t, err)
+ assert.Equal(t, "default", result)
+
+ result, err = callFilterFunc(context.Background(), filter, "actual", nil)
+ assert.NoError(t, err)
+ assert.Equal(t, "actual", result)
+ })
+
+ t.Run("nil handler", func(t *testing.T) {
+ filter := &mockFilter{
+ signature: "nil_handler",
+ handler: nil,
+ }
+
+ _, err := callFilterFunc(context.Background(), filter, "test", nil)
+ assert.Error(t, err)
+ })
+
+ t.Run("non-function handler", func(t *testing.T) {
+ filter := &mockFilter{
+ signature: "not_func",
+ handler: "not a function",
+ }
+
+ _, err := callFilterFunc(context.Background(), filter, "test", nil)
+ assert.Error(t, err)
+ })
+}
+
+func TestFiltersIntegration(t *testing.T) {
+ t.Run("filters applied before validation", func(t *testing.T) {
+ validation := NewValidation()
+ validator, err := validation.Make(context.Background(), map[string]any{
+ "name": " goravel ",
+ }, map[string]any{
+ "name": "required|string",
+ }, Filters(map[string]any{
+ "name": "trim",
+ }))
+ assert.NoError(t, err)
+ assert.False(t, validator.Fails())
+
+ val := validator.Validated()
+ assert.Equal(t, "goravel", val["name"])
+ })
+
+ t.Run("filters with type conversion", func(t *testing.T) {
+ validation := NewValidation()
+ validator, err := validation.Make(context.Background(), map[string]any{
+ "age": "25",
+ }, map[string]any{
+ "age": "required",
+ }, Filters(map[string]any{
+ "age": "to_int",
+ }))
+ assert.NoError(t, err)
+ assert.False(t, validator.Fails())
+
+ val := validator.Validated()
+ assert.Equal(t, 25, val["age"])
+ })
+
+ t.Run("filters with custom filter via AddFilters", func(t *testing.T) {
+ validation := NewValidation()
+ err := validation.AddFilters([]contractsvalidation.Filter{
+ &mockFilter{
+ signature: "prefix_hello",
+ handler: func(val string) string {
+ return "hello_" + val
+ },
+ },
+ })
+ require.NoError(t, err)
+
+ validator, err := validation.Make(context.Background(), map[string]any{
+ "name": "world",
+ }, map[string]any{
+ "name": "required",
+ }, Filters(map[string]any{
+ "name": "prefix_hello",
+ }))
+ assert.NoError(t, err)
+ assert.False(t, validator.Fails())
+
+ val := validator.Validated()
+ assert.Equal(t, "hello_world", val["name"])
+ })
+
+ t.Run("filters with custom filter via option", func(t *testing.T) {
+ validation := NewValidation()
+
+ customFilter := &mockFilter{
+ signature: "suffix_test",
+ handler: func(val string) string {
+ return val + "_test"
+ },
+ }
+
+ validator, err := validation.Make(context.Background(), map[string]any{
+ "name": "hello",
+ }, map[string]any{
+ "name": "required",
+ },
+ Filters(map[string]any{
+ "name": "suffix_test",
+ }),
+ CustomFilters([]contractsvalidation.Filter{customFilter}),
+ )
+ assert.NoError(t, err)
+ assert.False(t, validator.Fails())
+
+ val := validator.Validated()
+ assert.Equal(t, "hello_test", val["name"])
+ })
+}
+
+func TestApplyFiltersWithWildcards(t *testing.T) {
+ t.Run("wildcard filter on nested slice", func(t *testing.T) {
+ bag, err := NewDataBag(map[string]any{
+ "users": []any{
+ map[string]any{"name": " Alice ", "email": "ALICE@EXAMPLE.COM"},
+ map[string]any{"name": " Bob ", "email": "BOB@EXAMPLE.COM"},
+ },
+ })
+ require.NoError(t, err)
+
+ err = applyFilters(context.Background(), bag, map[string]any{
+ "users.*.name": "trim",
+ "users.*.email": "lower",
+ }, nil)
+ require.NoError(t, err)
+
+ val, _ := bag.Get("users.0.name")
+ assert.Equal(t, "Alice", val)
+ val, _ = bag.Get("users.1.name")
+ assert.Equal(t, "Bob", val)
+ val, _ = bag.Get("users.0.email")
+ assert.Equal(t, "alice@example.com", val)
+ val, _ = bag.Get("users.1.email")
+ assert.Equal(t, "bob@example.com", val)
+ })
+
+ t.Run("wildcard filter with chained filters", func(t *testing.T) {
+ bag, err := NewDataBag(map[string]any{
+ "items": []any{
+ map[string]any{"title": " HELLO WORLD "},
+ map[string]any{"title": " FOO BAR "},
+ },
+ })
+ require.NoError(t, err)
+
+ err = applyFilters(context.Background(), bag, map[string]any{
+ "items.*.title": "trim|lower",
+ }, nil)
+ require.NoError(t, err)
+
+ val, _ := bag.Get("items.0.title")
+ assert.Equal(t, "hello world", val)
+ val, _ = bag.Get("items.1.title")
+ assert.Equal(t, "foo bar", val)
+ })
+
+ t.Run("wildcard filter with no matching data", func(t *testing.T) {
+ bag, err := NewDataBag(map[string]any{
+ "name": " hello ",
+ })
+ require.NoError(t, err)
+
+ err = applyFilters(context.Background(), bag, map[string]any{
+ "users.*.name": "trim",
+ }, nil)
+ require.NoError(t, err)
+
+ // Original data should not be affected
+ val, _ := bag.Get("name")
+ assert.Equal(t, " hello ", val)
+ })
+
+ t.Run("wildcard filter with slice syntax", func(t *testing.T) {
+ bag, err := NewDataBag(map[string]any{
+ "tags": []any{
+ map[string]any{"value": " Go "},
+ map[string]any{"value": " Rust "},
+ },
+ })
+ require.NoError(t, err)
+
+ err = applyFilters(context.Background(), bag, map[string]any{
+ "tags.*.value": []string{"trim", "upper"},
+ }, nil)
+ require.NoError(t, err)
+
+ val, _ := bag.Get("tags.0.value")
+ assert.Equal(t, "GO", val)
+ val, _ = bag.Get("tags.1.value")
+ assert.Equal(t, "RUST", val)
+ })
+
+ t.Run("mixed wildcard and direct fields", func(t *testing.T) {
+ bag, err := NewDataBag(map[string]any{
+ "title": " ADMIN ",
+ "users": []any{
+ map[string]any{"name": " Alice "},
+ },
+ })
+ require.NoError(t, err)
+
+ err = applyFilters(context.Background(), bag, map[string]any{
+ "title": "trim|lower",
+ "users.*.name": "trim",
+ }, nil)
+ require.NoError(t, err)
+
+ val, _ := bag.Get("title")
+ assert.Equal(t, "admin", val)
+ val, _ = bag.Get("users.0.name")
+ assert.Equal(t, "Alice", val)
+ })
+
+ t.Run("wildcard filter with custom filter", func(t *testing.T) {
+ bag, err := NewDataBag(map[string]any{
+ "items": []any{
+ map[string]any{"code": "abc"},
+ map[string]any{"code": "def"},
+ },
+ })
+ require.NoError(t, err)
+
+ customFilter := &mockFilter{
+ signature: "prefix_item",
+ handler: func(val string) string {
+ return "item_" + val
+ },
+ }
+
+ err = applyFilters(context.Background(), bag, map[string]any{
+ "items.*.code": "prefix_item",
+ }, []contractsvalidation.Filter{customFilter})
+ require.NoError(t, err)
+
+ val, _ := bag.Get("items.0.code")
+ assert.Equal(t, "item_abc", val)
+ val, _ = bag.Get("items.1.code")
+ assert.Equal(t, "item_def", val)
+ })
+}
+
+func TestAddFilters(t *testing.T) {
+ t.Run("success", func(t *testing.T) {
+ validation := NewValidation()
+ err := validation.AddFilters([]contractsvalidation.Filter{
+ &mockFilter{signature: "test_filter"},
+ })
+ assert.NoError(t, err)
+ assert.Len(t, validation.Filters(), 1)
+ })
+
+ t.Run("duplicate filter", func(t *testing.T) {
+ validation := NewValidation()
+ err := validation.AddFilters([]contractsvalidation.Filter{
+ &mockFilter{signature: "test_filter"},
+ })
+ assert.NoError(t, err)
+
+ err = validation.AddFilters([]contractsvalidation.Filter{
+ &mockFilter{signature: "test_filter"},
+ })
+ assert.Error(t, err)
+ })
+
+ t.Run("duplicate builtin filter", func(t *testing.T) {
+ validation := NewValidation()
+ err := validation.AddFilters([]contractsvalidation.Filter{
+ &mockFilter{signature: "trim"},
+ })
+ assert.Error(t, err)
+ })
+}
+
+// mockFilter is a test helper that implements the Filter interface.
+type mockFilter struct {
+ signature string
+ handler any
+}
+
+func (m *mockFilter) Signature() string {
+ return m.signature
+}
+
+func (m *mockFilter) Handle(_ context.Context) any {
+ return m.handler
+}
diff --git a/validation/messages.go b/validation/messages.go
index 45fdea08c..8381d23eb 100644
--- a/validation/messages.go
+++ b/validation/messages.go
@@ -48,6 +48,7 @@ var defaultMessages = map[string]string{
"string": "The :attribute field must be a string.",
"integer": "The :attribute field must be an integer.",
"int": "The :attribute field must be an integer.",
+ "uint": "The :attribute field must be a positive integer.",
"numeric": "The :attribute field must be a number.",
"boolean": "The :attribute field must be true or false.",
"bool": "The :attribute field must be true or false.",
@@ -114,6 +115,9 @@ var defaultMessages = map[string]string{
"mac": "The :attribute field must be a valid MAC address.",
"json": "The :attribute field must be a valid JSON string.",
"uuid": "The :attribute field must be a valid UUID.",
+ "uuid3": "The :attribute field must be a valid UUID v3.",
+ "uuid4": "The :attribute field must be a valid UUID v4.",
+ "uuid5": "The :attribute field must be a valid UUID v5.",
"ulid": "The :attribute field must be a valid ULID.",
"hex_color": "The :attribute field must be a valid hexadecimal color.",
"regex": "The :attribute field format is invalid.",
@@ -133,6 +137,8 @@ var defaultMessages = map[string]string{
// Comparison rules
"same": "The :attribute field must match :other.",
"different": "The :attribute field and :other must be different.",
+ "eq": "The :attribute field must be equal to :value.",
+ "ne": "The :attribute field must not be equal to :value.",
"in": "The selected :attribute is invalid.",
"not_in": "The selected :attribute is invalid.",
"in_array": "The :attribute field must exist in :other.",
@@ -171,6 +177,23 @@ var defaultMessages = map[string]string{
// Database rules
"exists": "The :attribute does not exist.",
"unique": "The :attribute has already been taken.",
+
+ // Deprecated: use the new names instead, will be removed in the next version.
+ "len": "The :attribute field must be :size characters.",
+ "min_len": "The :attribute field must be at least :min characters.",
+ "max_len": "The :attribute field must not be greater than :max characters.",
+ "eq_field": "The :attribute field must match :other.",
+ "ne_field": "The :attribute field and :other must be different.",
+ "gt_field": "The :attribute field must be greater than :value.",
+ "gte_field": "The :attribute field must be greater than or equal to :value.",
+ "lt_field": "The :attribute field must be less than :value.",
+ "lte_field": "The :attribute field must be less than or equal to :value.",
+ "gt_date": "The :attribute field must be a date after :date.",
+ "lt_date": "The :attribute field must be a date before :date.",
+ "gte_date": "The :attribute field must be a date after or equal to :date.",
+ "lte_date": "The :attribute field must be a date before or equal to :date.",
+ "number": "The :attribute field must be a number.",
+ "full_url": "The :attribute field must be a valid URL.",
}
// sizeRules are rules that have type-specific messages.
diff --git a/validation/options_test.go b/validation/options_test.go
index fd427f56a..a562bbff1 100644
--- a/validation/options_test.go
+++ b/validation/options_test.go
@@ -19,8 +19,8 @@ func TestFiltersOption(t *testing.T) {
}
func TestCustomFiltersOption(t *testing.T) {
- mockFilter := &mockFilter{signature: "test"}
- customFilters := []contractsvalidation.Filter{mockFilter}
+ f := &mockFilter{signature: "test"}
+ customFilters := []contractsvalidation.Filter{f}
opts := &contractsvalidation.Options{}
CustomFilters(customFilters)(opts)
@@ -58,19 +58,6 @@ func TestPrepareForValidationOption(t *testing.T) {
assert.NotNil(t, opts.PrepareForValidation)
}
-type mockFilter struct {
- signature string
- handler any
-}
-
-func (m *mockFilter) Signature() string {
- return m.signature
-}
-
-func (m *mockFilter) Handle(_ context.Context) any {
- return m.handler
-}
-
func TestApplyOptions(t *testing.T) {
tests := []struct {
name string
diff --git a/validation/rules.go b/validation/rules.go
index f71253a92..21fa280bd 100644
--- a/validation/rules.go
+++ b/validation/rules.go
@@ -2,6 +2,27 @@ package validation
import (
"context"
+ "encoding/json"
+ "fmt"
+ "image"
+ _ "image/gif"
+ _ "image/jpeg"
+ _ "image/png"
+ "io"
+ "math"
+ "mime/multipart"
+ "net"
+ "net/mail"
+ "net/url"
+ "reflect"
+ "regexp"
+ "strconv"
+ "strings"
+ "time"
+ "unicode"
+ "unicode/utf8"
+
+ "github.com/spf13/cast"
)
// RuleContext provides context for rule evaluation.
@@ -15,13 +36,1829 @@ type RuleContext struct {
}
// builtinRules maps rule names to their implementations.
-var builtinRules = map[string]func(ctx *RuleContext) bool{}
+var builtinRules = map[string]func(ctx *RuleContext) bool{
+ // Existence
+ "required": ruleRequired,
+ "required_if": ruleRequiredIf,
+ "required_unless": ruleRequiredUnless,
+ "required_with": ruleRequiredWith,
+ "required_with_all": ruleRequiredWithAll,
+ "required_without": ruleRequiredWithout,
+ "required_without_all": ruleRequiredWithoutAll,
+ "required_if_accepted": ruleRequiredIfAccepted,
+ "required_if_declined": ruleRequiredIfDeclined,
+ "filled": ruleFilled,
+ "present": rulePresent,
+ "present_if": rulePresentIf,
+ "present_unless": rulePresentUnless,
+ "present_with": rulePresentWith,
+ "present_with_all": rulePresentWithAll,
+ "missing": ruleMissing,
+ "missing_if": ruleMissingIf,
+ "missing_unless": ruleMissingUnless,
+ "missing_with": ruleMissingWith,
+ "missing_with_all": ruleMissingWithAll,
+
+ // Accept/Decline
+ "accepted": ruleAccepted,
+ "accepted_if": ruleAcceptedIf,
+ "declined": ruleDeclined,
+ "declined_if": ruleDeclinedIf,
+
+ // Prohibition
+ "prohibited": ruleProhibited,
+ "prohibited_if": ruleProhibitedIf,
+ "prohibited_unless": ruleProhibitedUnless,
+ "prohibited_if_accepted": ruleProhibitedIfAccepted,
+ "prohibited_if_declined": ruleProhibitedIfDeclined,
+ "prohibits": ruleProhibits,
+
+ // Type
+ "string": ruleString,
+ "integer": ruleInteger,
+ "int": ruleInteger, // Go alias
+ "uint": ruleUint, // Go-specific
+ "numeric": ruleNumeric,
+ "boolean": ruleBoolean,
+ "bool": ruleBoolean, // Go alias
+ "float": ruleFloat, // Go-specific
+ "array": ruleArray,
+ "list": ruleList,
+ "slice": ruleSlice, // Go alias for list
+ "map": ruleMap, // Go-specific
+
+ // Size
+ "size": ruleSize,
+ "min": ruleMin,
+ "max": ruleMax,
+ "between": ruleBetween,
+ "gt": ruleGt,
+ "gte": ruleGte,
+ "lt": ruleLt,
+ "lte": ruleLte,
+
+ // Numeric
+ "digits": ruleDigits,
+ "digits_between": ruleDigitsBetween,
+ "decimal": ruleDecimal,
+ "multiple_of": ruleMultipleOf,
+ "min_digits": ruleMinDigits,
+ "max_digits": ruleMaxDigits,
+
+ // String format
+ "alpha": ruleAlpha,
+ "alpha_num": ruleAlphaNum,
+ "alpha_dash": ruleAlphaDash,
+ "ascii": ruleAscii,
+ "email": ruleEmail,
+ "url": ruleUrl,
+ "active_url": ruleActiveUrl,
+ "ip": ruleIp,
+ "ipv4": ruleIpv4,
+ "ipv6": ruleIpv6,
+ "mac_address": ruleMacAddress,
+ "mac": ruleMacAddress, // alias
+ "json": ruleJson,
+ "uuid": ruleUuid,
+ "uuid3": ruleUuid3,
+ "uuid4": ruleUuid4,
+ "uuid5": ruleUuid5,
+ "ulid": ruleUlid,
+ "hex_color": ruleHexColor,
+ "regex": ruleRegex,
+ "not_regex": ruleNotRegex,
+ "lowercase": ruleLowercase,
+ "uppercase": ruleUppercase,
+
+ // String content
+ "starts_with": ruleStartsWith,
+ "doesnt_start_with": ruleDoesntStartWith,
+ "ends_with": ruleEndsWith,
+ "doesnt_end_with": ruleDoesntEndWith,
+ "contains": ruleContains,
+ "doesnt_contain": ruleDoesntContain,
+ "confirmed": ruleConfirmed,
+
+ // Comparison
+ "same": ruleSame,
+ "different": ruleDifferent,
+ "eq": ruleEq,
+ "ne": ruleNe,
+ "in": ruleIn,
+ "not_in": ruleNotIn,
+ "in_array": ruleInArray,
+ "in_array_keys": ruleInArrayKeys,
+
+ // Date
+ "date": ruleDate,
+ "date_format": ruleDateFormat,
+ "date_equals": ruleDateEquals,
+ "before": ruleBefore,
+ "before_or_equal": ruleBeforeOrEqual,
+ "after": ruleAfter,
+ "after_or_equal": ruleAfterOrEqual,
+ "timezone": ruleTimezone,
+
+ // Exclude (always return true; engine handles exclusion logic)
+ "exclude": ruleExclude,
+ "exclude_if": ruleExcludeIf,
+ "exclude_unless": ruleExcludeUnless,
+ "exclude_with": ruleExcludeWith,
+ "exclude_without": ruleExcludeWithout,
+
+ // File
+ "file": ruleFile,
+ "image": ruleImage,
+ "mimes": ruleMimes,
+ "mimetypes": ruleMimetypes,
+ "extensions": ruleExtensions,
+ "dimensions": ruleDimensions,
+ "encoding": ruleEncoding,
+
+ // Control (always pass; handled by engine)
+ "bail": ruleBail,
+ "nullable": ruleNullable,
+ "sometimes": ruleSometimes,
+
+ // Other
+ "distinct": ruleDistinct,
+ "required_array_keys": ruleRequiredArrayKeys,
+
+ // Database
+ "exists": ruleExists,
+ "unique": ruleUnique,
+
+ // Deprecated: use the new names instead, will be removed in the next version.
+ "len": ruleSize, // use "size"
+ "min_len": ruleMin, // use "min"
+ "max_len": ruleMax, // use "max"
+ "eq_field": ruleSame, // use "same"
+ "ne_field": ruleDifferent, // use "different"
+ "gt_field": ruleGt, // use "gt"
+ "gte_field": ruleGte, // use "gte"
+ "lt_field": ruleLt, // use "lt"
+ "lte_field": ruleLte, // use "lte"
+ "gt_date": ruleAfter, // use "after"
+ "lt_date": ruleBefore, // use "before"
+ "gte_date": ruleAfterOrEqual, // use "after_or_equal"
+ "lte_date": ruleBeforeOrEqual, // use "before_or_equal"
+ "number": ruleNumeric, // use "numeric"
+ "full_url": ruleUrl, // use "url"
+}
// implicitRules are rules that run even when the field is missing or empty.
-var implicitRules = map[string]bool{}
+var implicitRules = map[string]bool{
+ "required": true, "required_if": true, "required_unless": true,
+ "required_with": true, "required_with_all": true,
+ "required_without": true, "required_without_all": true,
+ "required_if_accepted": true, "required_if_declined": true,
+ "required_array_keys": true,
+ "filled": true,
+ "present": true, "present_if": true, "present_unless": true,
+ "present_with": true, "present_with_all": true,
+ "missing": true, "missing_if": true, "missing_unless": true,
+ "missing_with": true, "missing_with_all": true,
+ "accepted": true, "accepted_if": true,
+ "declined": true, "declined_if": true,
+ "prohibited": true, "prohibited_if": true, "prohibited_unless": true,
+ "prohibited_if_accepted": true, "prohibited_if_declined": true,
+ "prohibits": true,
+}
// excludeRules are rules that may cause a field to be excluded from validated data.
-var excludeRules = map[string]bool{}
+var excludeRules = map[string]bool{
+ "exclude": true, "exclude_if": true, "exclude_unless": true,
+ "exclude_with": true, "exclude_without": true,
+}
// numericRuleNames are rules that indicate a field should be treated as numeric for size rules.
-var numericRuleNames = map[string]bool{}
+var numericRuleNames = map[string]bool{
+ "numeric": true, "integer": true, "decimal": true,
+ "int": true, "uint": true, "float": true,
+}
+
+// ---- Existence Rules ----
+
+func ruleRequired(ctx *RuleContext) bool {
+ if ctx.Value == nil {
+ return false
+ }
+ return !isValueEmpty(ctx.Value)
+}
+
+func ruleRequiredIf(ctx *RuleContext) bool {
+ otherValue, comparisonValues, _ := parseDependentValues(ctx)
+ if matchesOtherValue(otherValue, comparisonValues) {
+ return ruleRequired(ctx)
+ }
+ return true
+}
+
+func ruleRequiredUnless(ctx *RuleContext) bool {
+ otherValue, comparisonValues, _ := parseDependentValues(ctx)
+ if !matchesOtherValue(otherValue, comparisonValues) {
+ return ruleRequired(ctx)
+ }
+ return true
+}
+
+func ruleRequiredWith(ctx *RuleContext) bool {
+ for _, field := range ctx.Parameters {
+ if val, ok := ctx.Data.Get(field); ok && !isValueEmpty(val) {
+ return ruleRequired(ctx)
+ }
+ }
+ return true
+}
+
+func ruleRequiredWithAll(ctx *RuleContext) bool {
+ allPresent := true
+ for _, field := range ctx.Parameters {
+ if val, ok := ctx.Data.Get(field); !ok || isValueEmpty(val) {
+ allPresent = false
+ break
+ }
+ }
+ if allPresent {
+ return ruleRequired(ctx)
+ }
+ return true
+}
+
+func ruleRequiredWithout(ctx *RuleContext) bool {
+ for _, field := range ctx.Parameters {
+ if val, ok := ctx.Data.Get(field); !ok || isValueEmpty(val) {
+ return ruleRequired(ctx)
+ }
+ }
+ return true
+}
+
+func ruleRequiredWithoutAll(ctx *RuleContext) bool {
+ nonePresent := true
+ for _, field := range ctx.Parameters {
+ if val, ok := ctx.Data.Get(field); ok && !isValueEmpty(val) {
+ nonePresent = false
+ break
+ }
+ }
+ if nonePresent {
+ return ruleRequired(ctx)
+ }
+ return true
+}
+
+func ruleRequiredIfAccepted(ctx *RuleContext) bool {
+ if len(ctx.Parameters) == 0 {
+ return true
+ }
+ otherValue, _ := ctx.Data.Get(ctx.Parameters[0])
+ if isAcceptedValue(otherValue) {
+ return ruleRequired(ctx)
+ }
+ return true
+}
+
+func ruleRequiredIfDeclined(ctx *RuleContext) bool {
+ if len(ctx.Parameters) == 0 {
+ return true
+ }
+ otherValue, _ := ctx.Data.Get(ctx.Parameters[0])
+ if isDeclinedValue(otherValue) {
+ return ruleRequired(ctx)
+ }
+ return true
+}
+
+func ruleFilled(ctx *RuleContext) bool {
+ if !ctx.Data.Has(ctx.Attribute) {
+ return true // Not present = ok for filled
+ }
+ return !isValueEmpty(ctx.Value)
+}
+
+func rulePresent(ctx *RuleContext) bool {
+ return ctx.Data.Has(ctx.Attribute)
+}
+
+func rulePresentIf(ctx *RuleContext) bool {
+ otherValue, comparisonValues, _ := parseDependentValues(ctx)
+ if matchesOtherValue(otherValue, comparisonValues) {
+ return ctx.Data.Has(ctx.Attribute)
+ }
+ return true
+}
+
+func rulePresentUnless(ctx *RuleContext) bool {
+ otherValue, comparisonValues, _ := parseDependentValues(ctx)
+ if !matchesOtherValue(otherValue, comparisonValues) {
+ return ctx.Data.Has(ctx.Attribute)
+ }
+ return true
+}
+
+func rulePresentWith(ctx *RuleContext) bool {
+ for _, field := range ctx.Parameters {
+ if ctx.Data.Has(field) {
+ return ctx.Data.Has(ctx.Attribute)
+ }
+ }
+ return true
+}
+
+func rulePresentWithAll(ctx *RuleContext) bool {
+ for _, field := range ctx.Parameters {
+ if !ctx.Data.Has(field) {
+ return true
+ }
+ }
+ return ctx.Data.Has(ctx.Attribute)
+}
+
+func ruleMissing(ctx *RuleContext) bool {
+ return !ctx.Data.Has(ctx.Attribute)
+}
+
+func ruleMissingIf(ctx *RuleContext) bool {
+ otherValue, comparisonValues, _ := parseDependentValues(ctx)
+ if matchesOtherValue(otherValue, comparisonValues) {
+ return !ctx.Data.Has(ctx.Attribute)
+ }
+ return true
+}
+
+func ruleMissingUnless(ctx *RuleContext) bool {
+ otherValue, comparisonValues, _ := parseDependentValues(ctx)
+ if !matchesOtherValue(otherValue, comparisonValues) {
+ return !ctx.Data.Has(ctx.Attribute)
+ }
+ return true
+}
+
+func ruleMissingWith(ctx *RuleContext) bool {
+ for _, field := range ctx.Parameters {
+ if ctx.Data.Has(field) {
+ return !ctx.Data.Has(ctx.Attribute)
+ }
+ }
+ return true
+}
+
+func ruleMissingWithAll(ctx *RuleContext) bool {
+ for _, field := range ctx.Parameters {
+ if !ctx.Data.Has(field) {
+ return true
+ }
+ }
+ return !ctx.Data.Has(ctx.Attribute)
+}
+
+// ---- Accept/Decline Rules ----
+
+func ruleAccepted(ctx *RuleContext) bool {
+ return !isValueEmpty(ctx.Value) && isAcceptedValue(ctx.Value)
+}
+
+func ruleAcceptedIf(ctx *RuleContext) bool {
+ otherValue, comparisonValues, _ := parseDependentValues(ctx)
+ if matchesOtherValue(otherValue, comparisonValues) {
+ return !isValueEmpty(ctx.Value) && isAcceptedValue(ctx.Value)
+ }
+ return true
+}
+
+func ruleDeclined(ctx *RuleContext) bool {
+ return !isValueEmpty(ctx.Value) && isDeclinedValue(ctx.Value)
+}
+
+func ruleDeclinedIf(ctx *RuleContext) bool {
+ otherValue, comparisonValues, _ := parseDependentValues(ctx)
+ if matchesOtherValue(otherValue, comparisonValues) {
+ return !isValueEmpty(ctx.Value) && isDeclinedValue(ctx.Value)
+ }
+ return true
+}
+
+// ---- Prohibition Rules ----
+
+func ruleProhibited(ctx *RuleContext) bool {
+ return isValueEmpty(ctx.Value)
+}
+
+func ruleProhibitedIf(ctx *RuleContext) bool {
+ otherValue, comparisonValues, _ := parseDependentValues(ctx)
+ if matchesOtherValue(otherValue, comparisonValues) {
+ return isValueEmpty(ctx.Value)
+ }
+ return true
+}
+
+func ruleProhibitedUnless(ctx *RuleContext) bool {
+ otherValue, comparisonValues, _ := parseDependentValues(ctx)
+ if !matchesOtherValue(otherValue, comparisonValues) {
+ return isValueEmpty(ctx.Value)
+ }
+ return true
+}
+
+func ruleProhibitedIfAccepted(ctx *RuleContext) bool {
+ if len(ctx.Parameters) == 0 {
+ return true
+ }
+ otherValue, _ := ctx.Data.Get(ctx.Parameters[0])
+ if isAcceptedValue(otherValue) {
+ return isValueEmpty(ctx.Value)
+ }
+ return true
+}
+
+func ruleProhibitedIfDeclined(ctx *RuleContext) bool {
+ if len(ctx.Parameters) == 0 {
+ return true
+ }
+ otherValue, _ := ctx.Data.Get(ctx.Parameters[0])
+ if isDeclinedValue(otherValue) {
+ return isValueEmpty(ctx.Value)
+ }
+ return true
+}
+
+func ruleProhibits(ctx *RuleContext) bool {
+ if isValueEmpty(ctx.Value) {
+ return true
+ }
+ for _, field := range ctx.Parameters {
+ if val, ok := ctx.Data.Get(field); ok && !isValueEmpty(val) {
+ return false
+ }
+ }
+ return true
+}
+
+// ---- Type Rules ----
+
+func ruleString(ctx *RuleContext) bool {
+ _, ok := ctx.Value.(string)
+ return ok
+}
+
+func ruleInteger(ctx *RuleContext) bool {
+ switch v := ctx.Value.(type) {
+ case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64:
+ return true
+ case float32:
+ return v == float32(int64(v))
+ case float64:
+ if v > float64(math.MaxInt64) || v < float64(math.MinInt64) {
+ return false
+ }
+ return v == float64(int64(v))
+ case json.Number:
+ _, err := v.Int64()
+ return err == nil
+ case string:
+ _, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64)
+ return err == nil
+ }
+ return false
+}
+
+func ruleNumeric(ctx *RuleContext) bool {
+ _, err := cast.ToFloat64E(ctx.Value)
+ return err == nil
+}
+
+func ruleBoolean(ctx *RuleContext) bool {
+ switch v := ctx.Value.(type) {
+ case bool:
+ return true
+ case int:
+ return v == 0 || v == 1
+ case int64:
+ return v == 0 || v == 1
+ case float64:
+ return v == 0 || v == 1
+ case string:
+ v = strings.TrimSpace(v)
+ return v == "true" || v == "false" || v == "0" || v == "1" || v == "on" || v == "off" || v == "yes" || v == "no"
+ }
+ return false
+}
+
+func ruleFloat(ctx *RuleContext) bool {
+ switch ctx.Value.(type) {
+ case float32, float64:
+ return true
+ case string:
+ _, err := strconv.ParseFloat(ctx.Value.(string), 64)
+ return err == nil
+ }
+ return false
+}
+
+func ruleArray(ctx *RuleContext) bool {
+ if ctx.Value == nil {
+ return false
+ }
+ rv := reflect.ValueOf(ctx.Value)
+ kind := rv.Kind()
+ return kind == reflect.Slice || kind == reflect.Array || kind == reflect.Map
+}
+
+func ruleList(ctx *RuleContext) bool {
+ // A list is an array with sequential integer keys (a Go slice)
+ if ctx.Value == nil {
+ return false
+ }
+ kind := reflect.ValueOf(ctx.Value).Kind()
+ return kind == reflect.Slice || kind == reflect.Array
+}
+
+func ruleSlice(ctx *RuleContext) bool {
+ // Alias for list — validates value is a Go slice
+ return ruleList(ctx)
+}
+
+func ruleMap(ctx *RuleContext) bool {
+ // Validates value is a Go map
+ if ctx.Value == nil {
+ return false
+ }
+ return reflect.ValueOf(ctx.Value).Kind() == reflect.Map
+}
+
+// ---- Size Rules ----
+
+func ruleSize(ctx *RuleContext) bool {
+ if len(ctx.Parameters) == 0 {
+ return false
+ }
+ expected, err := strconv.ParseFloat(ctx.Parameters[0], 64)
+ if err != nil {
+ return false
+ }
+ attrType := getAttributeType(ctx.Attribute, ctx.Value, ctx.Rules)
+ size, ok := getSize(ctx.Value, attrType)
+ if !ok {
+ return false
+ }
+ return size == expected
+}
+
+func ruleMin(ctx *RuleContext) bool {
+ if len(ctx.Parameters) == 0 {
+ return false
+ }
+ minV, err := strconv.ParseFloat(ctx.Parameters[0], 64)
+ if err != nil {
+ return false
+ }
+ attrType := getAttributeType(ctx.Attribute, ctx.Value, ctx.Rules)
+ size, ok := getSize(ctx.Value, attrType)
+ if !ok {
+ return false
+ }
+ return size >= minV
+}
+
+func ruleMax(ctx *RuleContext) bool {
+ if len(ctx.Parameters) == 0 {
+ return false
+ }
+ maxV, err := strconv.ParseFloat(ctx.Parameters[0], 64)
+ if err != nil {
+ return false
+ }
+ attrType := getAttributeType(ctx.Attribute, ctx.Value, ctx.Rules)
+ size, ok := getSize(ctx.Value, attrType)
+ if !ok {
+ return false
+ }
+ return size <= maxV
+}
+
+func ruleBetween(ctx *RuleContext) bool {
+ if len(ctx.Parameters) < 2 {
+ return false
+ }
+ minV, err := strconv.ParseFloat(ctx.Parameters[0], 64)
+ if err != nil {
+ return false
+ }
+ maxV, err := strconv.ParseFloat(ctx.Parameters[1], 64)
+ if err != nil {
+ return false
+ }
+ attrType := getAttributeType(ctx.Attribute, ctx.Value, ctx.Rules)
+ size, ok := getSize(ctx.Value, attrType)
+ if !ok {
+ return false
+ }
+ return size >= minV && size <= maxV
+}
+
+func ruleGt(ctx *RuleContext) bool {
+ if len(ctx.Parameters) == 0 {
+ return false
+ }
+ attrType := getAttributeType(ctx.Attribute, ctx.Value, ctx.Rules)
+ size, ok := getSize(ctx.Value, attrType)
+ if !ok {
+ return false
+ }
+ // Try as field reference
+ if otherVal, exists := ctx.Data.Get(ctx.Parameters[0]); exists {
+ otherType := getAttributeType(ctx.Parameters[0], otherVal, ctx.Rules)
+ otherSize, ok := getSize(otherVal, otherType)
+ if ok {
+ return size > otherSize
+ }
+ }
+ // Try as numeric value
+ threshold, err := strconv.ParseFloat(ctx.Parameters[0], 64)
+ if err != nil {
+ return false
+ }
+ return size > threshold
+}
+
+func ruleGte(ctx *RuleContext) bool {
+ if len(ctx.Parameters) == 0 {
+ return false
+ }
+ attrType := getAttributeType(ctx.Attribute, ctx.Value, ctx.Rules)
+ size, ok := getSize(ctx.Value, attrType)
+ if !ok {
+ return false
+ }
+ if otherVal, exists := ctx.Data.Get(ctx.Parameters[0]); exists {
+ otherType := getAttributeType(ctx.Parameters[0], otherVal, ctx.Rules)
+ otherSize, ok := getSize(otherVal, otherType)
+ if ok {
+ return size >= otherSize
+ }
+ }
+ threshold, err := strconv.ParseFloat(ctx.Parameters[0], 64)
+ if err != nil {
+ return false
+ }
+ return size >= threshold
+}
+
+func ruleLt(ctx *RuleContext) bool {
+ if len(ctx.Parameters) == 0 {
+ return false
+ }
+ attrType := getAttributeType(ctx.Attribute, ctx.Value, ctx.Rules)
+ size, ok := getSize(ctx.Value, attrType)
+ if !ok {
+ return false
+ }
+ if otherVal, exists := ctx.Data.Get(ctx.Parameters[0]); exists {
+ otherType := getAttributeType(ctx.Parameters[0], otherVal, ctx.Rules)
+ otherSize, ok := getSize(otherVal, otherType)
+ if ok {
+ return size < otherSize
+ }
+ }
+ threshold, err := strconv.ParseFloat(ctx.Parameters[0], 64)
+ if err != nil {
+ return false
+ }
+ return size < threshold
+}
+
+func ruleLte(ctx *RuleContext) bool {
+ if len(ctx.Parameters) == 0 {
+ return false
+ }
+ attrType := getAttributeType(ctx.Attribute, ctx.Value, ctx.Rules)
+ size, ok := getSize(ctx.Value, attrType)
+ if !ok {
+ return false
+ }
+ if otherVal, exists := ctx.Data.Get(ctx.Parameters[0]); exists {
+ otherType := getAttributeType(ctx.Parameters[0], otherVal, ctx.Rules)
+ otherSize, ok := getSize(otherVal, otherType)
+ if ok {
+ return size <= otherSize
+ }
+ }
+ threshold, err := strconv.ParseFloat(ctx.Parameters[0], 64)
+ if err != nil {
+ return false
+ }
+ return size <= threshold
+}
+
+// ---- Numeric Rules ----
+
+func ruleDigits(ctx *RuleContext) bool {
+ if len(ctx.Parameters) == 0 {
+ return false
+ }
+ s := fmt.Sprintf("%v", ctx.Value)
+ s = strings.TrimSpace(s)
+ expected, err := strconv.Atoi(ctx.Parameters[0])
+ if err != nil {
+ return false
+ }
+ // Must be all digits
+ for _, r := range s {
+ if r < '0' || r > '9' {
+ return false
+ }
+ }
+ return len(s) == expected
+}
+
+func ruleDigitsBetween(ctx *RuleContext) bool {
+ if len(ctx.Parameters) < 2 {
+ return false
+ }
+ s := fmt.Sprintf("%v", ctx.Value)
+ s = strings.TrimSpace(s)
+ minV, err := strconv.Atoi(ctx.Parameters[0])
+ if err != nil {
+ return false
+ }
+ maxV, err := strconv.Atoi(ctx.Parameters[1])
+ if err != nil {
+ return false
+ }
+ for _, r := range s {
+ if r < '0' || r > '9' {
+ return false
+ }
+ }
+ l := len(s)
+ return l >= minV && l <= maxV
+}
+
+func ruleDecimal(ctx *RuleContext) bool {
+ if len(ctx.Parameters) == 0 {
+ return false
+ }
+ s := fmt.Sprintf("%v", ctx.Value)
+ s = strings.TrimSpace(s)
+
+ // Parse expected decimal places
+ minPlaces, err := strconv.Atoi(ctx.Parameters[0])
+ if err != nil {
+ return false
+ }
+ maxPlaces := minPlaces
+ if len(ctx.Parameters) > 1 {
+ maxPlaces, err = strconv.Atoi(ctx.Parameters[1])
+ if err != nil {
+ return false
+ }
+ }
+
+ // Must be numeric
+ if _, err := strconv.ParseFloat(s, 64); err != nil {
+ return false
+ }
+
+ parts := strings.Split(s, ".")
+ if len(parts) == 1 {
+ return minPlaces == 0
+ }
+ decimalLen := len(parts[1])
+ return decimalLen >= minPlaces && decimalLen <= maxPlaces
+}
+
+func ruleMultipleOf(ctx *RuleContext) bool {
+ if len(ctx.Parameters) == 0 {
+ return false
+ }
+ val, err := cast.ToFloat64E(ctx.Value)
+ if err != nil {
+ return false
+ }
+ divisor, err := strconv.ParseFloat(ctx.Parameters[0], 64)
+ if err != nil || divisor == 0 {
+ return false
+ }
+ remainder := math.Mod(val, divisor)
+ epsilon := 1e-9
+ return math.Abs(remainder) < epsilon || math.Abs(remainder-divisor) < epsilon
+}
+
+func ruleMinDigits(ctx *RuleContext) bool {
+ if len(ctx.Parameters) == 0 {
+ return false
+ }
+ s := fmt.Sprintf("%v", ctx.Value)
+ s = strings.TrimSpace(s)
+ // Remove non-digit characters for counting
+ digitCount := 0
+ for _, r := range s {
+ if r >= '0' && r <= '9' {
+ digitCount++
+ }
+ }
+ minV, err := strconv.Atoi(ctx.Parameters[0])
+ if err != nil {
+ return false
+ }
+ // Must be numeric
+ if _, err = strconv.ParseFloat(s, 64); err != nil {
+ return false
+ }
+ return digitCount >= minV
+}
+
+func ruleMaxDigits(ctx *RuleContext) bool {
+ if len(ctx.Parameters) == 0 {
+ return false
+ }
+ s := fmt.Sprintf("%v", ctx.Value)
+ s = strings.TrimSpace(s)
+ digitCount := 0
+ for _, r := range s {
+ if r >= '0' && r <= '9' {
+ digitCount++
+ }
+ }
+ maxV, err := strconv.Atoi(ctx.Parameters[0])
+ if err != nil {
+ return false
+ }
+ if _, err = strconv.ParseFloat(s, 64); err != nil {
+ return false
+ }
+ return digitCount <= maxV
+}
+
+// ---- String Format Rules ----
+
+func ruleAlpha(ctx *RuleContext) bool {
+ s, ok := ctx.Value.(string)
+ if !ok {
+ return false
+ }
+ for _, r := range s {
+ if !unicode.IsLetter(r) {
+ return false
+ }
+ }
+ return len(s) > 0
+}
+
+func ruleAlphaNum(ctx *RuleContext) bool {
+ s, ok := ctx.Value.(string)
+ if !ok {
+ return false
+ }
+ for _, r := range s {
+ if !unicode.IsLetter(r) && !unicode.IsDigit(r) {
+ return false
+ }
+ }
+ return len(s) > 0
+}
+
+func ruleAlphaDash(ctx *RuleContext) bool {
+ s, ok := ctx.Value.(string)
+ if !ok {
+ return false
+ }
+ for _, r := range s {
+ if !unicode.IsLetter(r) && !unicode.IsDigit(r) && r != '-' && r != '_' {
+ return false
+ }
+ }
+ return len(s) > 0
+}
+
+func ruleAscii(ctx *RuleContext) bool {
+ s, ok := ctx.Value.(string)
+ if !ok {
+ return false
+ }
+ for _, r := range s {
+ if r > unicode.MaxASCII {
+ return false
+ }
+ }
+ return true
+}
+
+func ruleEmail(ctx *RuleContext) bool {
+ s, ok := ctx.Value.(string)
+ if !ok {
+ return false
+ }
+ addr, err := mail.ParseAddress(s)
+ if err != nil {
+ return false
+ }
+ // mail.ParseAddress accepts "Name " format, but validation
+ // should only accept bare email addresses.
+ return addr.Address == s
+}
+
+func ruleUrl(ctx *RuleContext) bool {
+ s, ok := ctx.Value.(string)
+ if !ok {
+ return false
+ }
+ u, err := url.Parse(s)
+ if err != nil {
+ return false
+ }
+ return u.Scheme != "" && u.Host != ""
+}
+
+func ruleActiveUrl(ctx *RuleContext) bool {
+ s, ok := ctx.Value.(string)
+ if !ok {
+ return false
+ }
+ u, err := url.Parse(s)
+ if err != nil || u.Host == "" {
+ return false
+ }
+ resolver := net.Resolver{}
+ _, err = resolver.LookupHost(ctx.Ctx, u.Hostname())
+ return err == nil
+}
+
+func ruleIp(ctx *RuleContext) bool {
+ s, ok := ctx.Value.(string)
+ if !ok {
+ return false
+ }
+ return net.ParseIP(s) != nil
+}
+
+func ruleIpv4(ctx *RuleContext) bool {
+ s, ok := ctx.Value.(string)
+ if !ok {
+ return false
+ }
+ ip := net.ParseIP(s)
+ return ip != nil && ip.To4() != nil
+}
+
+func ruleIpv6(ctx *RuleContext) bool {
+ s, ok := ctx.Value.(string)
+ if !ok {
+ return false
+ }
+ ip := net.ParseIP(s)
+ return ip != nil && ip.To4() == nil
+}
+
+var macRegex = regexp.MustCompile(`^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$`)
+
+func ruleMacAddress(ctx *RuleContext) bool {
+ s, ok := ctx.Value.(string)
+ if !ok {
+ return false
+ }
+ return macRegex.MatchString(s)
+}
+
+func ruleJson(ctx *RuleContext) bool {
+ s, ok := ctx.Value.(string)
+ if !ok {
+ return false
+ }
+ var js json.RawMessage
+ return json.Unmarshal([]byte(s), &js) == nil
+}
+
+var uuidRegex = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`)
+
+func ruleUuid(ctx *RuleContext) bool {
+ s, ok := ctx.Value.(string)
+ if !ok {
+ return false
+ }
+ return uuidRegex.MatchString(s)
+}
+
+var uuid3Regex = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-3[0-9a-fA-F]{3}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`)
+
+func ruleUuid3(ctx *RuleContext) bool {
+ s, ok := ctx.Value.(string)
+ if !ok {
+ return false
+ }
+ return uuid3Regex.MatchString(s)
+}
+
+var uuid4Regex = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$`)
+
+func ruleUuid4(ctx *RuleContext) bool {
+ s, ok := ctx.Value.(string)
+ if !ok {
+ return false
+ }
+ return uuid4Regex.MatchString(s)
+}
+
+var uuid5Regex = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-5[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$`)
+
+func ruleUuid5(ctx *RuleContext) bool {
+ s, ok := ctx.Value.(string)
+ if !ok {
+ return false
+ }
+ return uuid5Regex.MatchString(s)
+}
+
+var ulidRegex = regexp.MustCompile(`^[0-9A-HJ-KM-NP-TV-Za-hj-km-np-tv-z]{26}$`)
+
+func ruleUlid(ctx *RuleContext) bool {
+ s, ok := ctx.Value.(string)
+ if !ok {
+ return false
+ }
+ return ulidRegex.MatchString(s)
+}
+
+var hexColorRegex = regexp.MustCompile(`^#([0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$`)
+
+func ruleHexColor(ctx *RuleContext) bool {
+ s, ok := ctx.Value.(string)
+ if !ok {
+ return false
+ }
+ return hexColorRegex.MatchString(s)
+}
+
+func ruleRegex(ctx *RuleContext) bool {
+ s := fmt.Sprintf("%v", ctx.Value)
+ if len(ctx.Parameters) == 0 || ctx.Parameters[0] == "" {
+ return false
+ }
+ pattern := ctx.Parameters[0]
+ re, err := regexp.Compile(pattern)
+ if err != nil {
+ return false
+ }
+ return re.MatchString(s)
+}
+
+func ruleNotRegex(ctx *RuleContext) bool {
+ s := fmt.Sprintf("%v", ctx.Value)
+ if len(ctx.Parameters) == 0 {
+ return false
+ }
+ pattern := ctx.Parameters[0]
+ re, err := regexp.Compile(pattern)
+ if err != nil {
+ return false
+ }
+ return !re.MatchString(s)
+}
+
+func ruleLowercase(ctx *RuleContext) bool {
+ s, ok := ctx.Value.(string)
+ if !ok {
+ return false
+ }
+ return s == strings.ToLower(s)
+}
+
+func ruleUppercase(ctx *RuleContext) bool {
+ s, ok := ctx.Value.(string)
+ if !ok {
+ return false
+ }
+ return s == strings.ToUpper(s)
+}
+
+// ---- String Content Rules ----
+
+func ruleStartsWith(ctx *RuleContext) bool {
+ s := fmt.Sprintf("%v", ctx.Value)
+ for _, prefix := range ctx.Parameters {
+ if strings.HasPrefix(s, prefix) {
+ return true
+ }
+ }
+ return false
+}
+
+func ruleDoesntStartWith(ctx *RuleContext) bool {
+ s := fmt.Sprintf("%v", ctx.Value)
+ for _, prefix := range ctx.Parameters {
+ if strings.HasPrefix(s, prefix) {
+ return false
+ }
+ }
+ return true
+}
+
+func ruleEndsWith(ctx *RuleContext) bool {
+ s := fmt.Sprintf("%v", ctx.Value)
+ for _, suffix := range ctx.Parameters {
+ if strings.HasSuffix(s, suffix) {
+ return true
+ }
+ }
+ return false
+}
+
+func ruleDoesntEndWith(ctx *RuleContext) bool {
+ s := fmt.Sprintf("%v", ctx.Value)
+ for _, suffix := range ctx.Parameters {
+ if strings.HasSuffix(s, suffix) {
+ return false
+ }
+ }
+ return true
+}
+
+func ruleContains(ctx *RuleContext) bool {
+ s := fmt.Sprintf("%v", ctx.Value)
+ for _, substr := range ctx.Parameters {
+ if !strings.Contains(s, substr) {
+ return false
+ }
+ }
+ return true
+}
+
+func ruleDoesntContain(ctx *RuleContext) bool {
+ s := fmt.Sprintf("%v", ctx.Value)
+ for _, substr := range ctx.Parameters {
+ if strings.Contains(s, substr) {
+ return false
+ }
+ }
+ return true
+}
+
+func ruleConfirmed(ctx *RuleContext) bool {
+ confirmField := ctx.Attribute + "_confirmation"
+ confirmVal, ok := ctx.Data.Get(confirmField)
+ if !ok {
+ return false
+ }
+ return fmt.Sprintf("%v", ctx.Value) == fmt.Sprintf("%v", confirmVal)
+}
+
+// ---- Comparison Rules ----
+
+func ruleSame(ctx *RuleContext) bool {
+ if len(ctx.Parameters) == 0 {
+ return false
+ }
+ otherVal, ok := ctx.Data.Get(ctx.Parameters[0])
+ if !ok {
+ return false
+ }
+ return fmt.Sprintf("%v", ctx.Value) == fmt.Sprintf("%v", otherVal)
+}
+
+func ruleDifferent(ctx *RuleContext) bool {
+ if len(ctx.Parameters) == 0 {
+ return false
+ }
+ otherVal, ok := ctx.Data.Get(ctx.Parameters[0])
+ if !ok {
+ return true
+ }
+ return fmt.Sprintf("%v", ctx.Value) != fmt.Sprintf("%v", otherVal)
+}
+
+func ruleEq(ctx *RuleContext) bool {
+ if len(ctx.Parameters) == 0 {
+ return false
+ }
+ return fmt.Sprintf("%v", ctx.Value) == ctx.Parameters[0]
+}
+
+func ruleNe(ctx *RuleContext) bool {
+ if len(ctx.Parameters) == 0 {
+ return false
+ }
+ return fmt.Sprintf("%v", ctx.Value) != ctx.Parameters[0]
+}
+
+func ruleUint(ctx *RuleContext) bool {
+ if !ruleInteger(ctx) {
+ return false
+ }
+ return cast.ToInt64(ctx.Value) >= 0
+}
+
+func ruleIn(ctx *RuleContext) bool {
+ s := fmt.Sprintf("%v", ctx.Value)
+ for _, allowed := range ctx.Parameters {
+ if s == allowed {
+ return true
+ }
+ }
+ return false
+}
+
+func ruleNotIn(ctx *RuleContext) bool {
+ s := fmt.Sprintf("%v", ctx.Value)
+ for _, disallowed := range ctx.Parameters {
+ if s == disallowed {
+ return false
+ }
+ }
+ return true
+}
+
+func ruleInArray(ctx *RuleContext) bool {
+ if len(ctx.Parameters) == 0 {
+ return false
+ }
+ otherField := ctx.Parameters[0]
+ otherVal, ok := ctx.Data.Get(otherField)
+ if !ok {
+ return false
+ }
+ s := fmt.Sprintf("%v", ctx.Value)
+ switch arr := otherVal.(type) {
+ case []any:
+ for _, item := range arr {
+ if fmt.Sprintf("%v", item) == s {
+ return true
+ }
+ }
+ case []string:
+ for _, item := range arr {
+ if item == s {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+// ---- Date Rules ----
+
+func ruleDate(ctx *RuleContext) bool {
+ _, ok := parseDate(ctx.Value)
+ return ok
+}
+
+func ruleDateFormat(ctx *RuleContext) bool {
+ if len(ctx.Parameters) == 0 {
+ return false
+ }
+ s, ok := ctx.Value.(string)
+ if !ok {
+ return false
+ }
+ // Parameters[0] is a Go time layout (e.g., "2006-01-02 15:04:05")
+ _, err := time.Parse(ctx.Parameters[0], s)
+ return err == nil
+}
+
+func ruleDateEquals(ctx *RuleContext) bool {
+ if len(ctx.Parameters) == 0 {
+ return false
+ }
+ valDate, ok := parseDate(ctx.Value)
+ if !ok {
+ return false
+ }
+ otherDate, ok := parseDateValue(ctx.Parameters[0], ctx.Data)
+ if !ok {
+ return false
+ }
+ return valDate.Equal(otherDate)
+}
+
+func ruleBefore(ctx *RuleContext) bool {
+ if len(ctx.Parameters) == 0 {
+ return false
+ }
+ valDate, ok := parseDate(ctx.Value)
+ if !ok {
+ return false
+ }
+ otherDate, ok := parseDateValue(ctx.Parameters[0], ctx.Data)
+ if !ok {
+ return false
+ }
+ return valDate.Before(otherDate)
+}
+
+func ruleBeforeOrEqual(ctx *RuleContext) bool {
+ if len(ctx.Parameters) == 0 {
+ return false
+ }
+ valDate, ok := parseDate(ctx.Value)
+ if !ok {
+ return false
+ }
+ otherDate, ok := parseDateValue(ctx.Parameters[0], ctx.Data)
+ if !ok {
+ return false
+ }
+ return valDate.Before(otherDate) || valDate.Equal(otherDate)
+}
+
+func ruleAfter(ctx *RuleContext) bool {
+ if len(ctx.Parameters) == 0 {
+ return false
+ }
+ valDate, ok := parseDate(ctx.Value)
+ if !ok {
+ return false
+ }
+ otherDate, ok := parseDateValue(ctx.Parameters[0], ctx.Data)
+ if !ok {
+ return false
+ }
+ return valDate.After(otherDate)
+}
+
+func ruleAfterOrEqual(ctx *RuleContext) bool {
+ if len(ctx.Parameters) == 0 {
+ return false
+ }
+ valDate, ok := parseDate(ctx.Value)
+ if !ok {
+ return false
+ }
+ otherDate, ok := parseDateValue(ctx.Parameters[0], ctx.Data)
+ if !ok {
+ return false
+ }
+ return valDate.After(otherDate) || valDate.Equal(otherDate)
+}
+
+// ---- Exclude Rules ----
+// These always return true; the engine handles the actual exclusion logic.
+
+func ruleExclude(ctx *RuleContext) bool {
+ return true
+}
+
+func ruleExcludeIf(ctx *RuleContext) bool {
+ return true
+}
+
+func ruleExcludeUnless(ctx *RuleContext) bool {
+ return true
+}
+
+func ruleExcludeWith(ctx *RuleContext) bool {
+ return true
+}
+
+func ruleExcludeWithout(ctx *RuleContext) bool {
+ return true
+}
+
+// ---- File Rules ----
+
+func ruleFile(ctx *RuleContext) bool {
+ if _, ok := ctx.Value.(*multipart.FileHeader); ok {
+ return true
+ }
+ if fhs, ok := ctx.Value.([]*multipart.FileHeader); ok {
+ return len(fhs) > 0
+ }
+ return false
+}
+
+func ruleImage(ctx *RuleContext) bool {
+ check := func(fh *multipart.FileHeader) bool {
+ mtype, err := detectMIME(fh)
+ if err != nil {
+ return false
+ }
+ imageTypes := []string{"image/jpeg", "image/png", "image/gif", "image/bmp", "image/svg+xml", "image/webp"}
+ for _, t := range imageTypes {
+ if mtype.Is(t) {
+ return true
+ }
+ }
+ return false
+ }
+
+ if fh, ok := ctx.Value.(*multipart.FileHeader); ok {
+ return check(fh)
+ }
+ if fhs, ok := ctx.Value.([]*multipart.FileHeader); ok {
+ for _, fh := range fhs {
+ if !check(fh) {
+ return false
+ }
+ }
+ return len(fhs) > 0
+ }
+ return false
+}
+
+func ruleMimes(ctx *RuleContext) bool {
+ check := func(fh *multipart.FileHeader) bool {
+ mtype, err := detectMIME(fh)
+ if err != nil {
+ return false
+ }
+ ext := strings.TrimPrefix(mtype.Extension(), ".")
+ for _, allowed := range ctx.Parameters {
+ if strings.EqualFold(ext, allowed) {
+ return true
+ }
+ }
+ return false
+ }
+
+ if fh, ok := ctx.Value.(*multipart.FileHeader); ok {
+ return check(fh)
+ }
+ if fhs, ok := ctx.Value.([]*multipart.FileHeader); ok {
+ for _, fh := range fhs {
+ if !check(fh) {
+ return false
+ }
+ }
+ return len(fhs) > 0
+ }
+ return false
+}
+
+func ruleMimetypes(ctx *RuleContext) bool {
+ check := func(fh *multipart.FileHeader) bool {
+ mtype, err := detectMIME(fh)
+ if err != nil {
+ return false
+ }
+ for _, allowed := range ctx.Parameters {
+ if mtype.Is(allowed) {
+ return true
+ }
+ }
+ return false
+ }
+
+ if fh, ok := ctx.Value.(*multipart.FileHeader); ok {
+ return check(fh)
+ }
+ if fhs, ok := ctx.Value.([]*multipart.FileHeader); ok {
+ for _, fh := range fhs {
+ if !check(fh) {
+ return false
+ }
+ }
+ return len(fhs) > 0
+ }
+ return false
+}
+
+func ruleExtensions(ctx *RuleContext) bool {
+ check := func(fh *multipart.FileHeader) bool {
+ ext := strings.ToLower(getFileExtension(fh.Filename))
+ for _, allowed := range ctx.Parameters {
+ if ext == strings.ToLower(allowed) {
+ return true
+ }
+ }
+ return false
+ }
+
+ if fh, ok := ctx.Value.(*multipart.FileHeader); ok {
+ return check(fh)
+ }
+ if fhs, ok := ctx.Value.([]*multipart.FileHeader); ok {
+ for _, fh := range fhs {
+ if !check(fh) {
+ return false
+ }
+ }
+ return len(fhs) > 0
+ }
+ return false
+}
+
+func ruleDimensions(ctx *RuleContext) bool {
+ // Parse named parameters: "min_width=100,max_width=500,width=200,height=200,ratio=3/2"
+ constraints := make(map[string]string, len(ctx.Parameters))
+ for _, p := range ctx.Parameters {
+ if k, v, found := strings.Cut(p, "="); found {
+ constraints[k] = v
+ }
+ }
+
+ check := func(fh *multipart.FileHeader) bool {
+ f, err := fh.Open()
+ if err != nil {
+ return false
+ }
+ defer func(f multipart.File) { _ = f.Close() }(f)
+
+ cfg, _, err := image.DecodeConfig(f)
+ if err != nil {
+ return false
+ }
+
+ width, height := cfg.Width, cfg.Height
+
+ if v, ok := constraints["width"]; ok {
+ if w, err := strconv.Atoi(v); err == nil && width != w {
+ return false
+ }
+ }
+ if v, ok := constraints["height"]; ok {
+ if h, err := strconv.Atoi(v); err == nil && height != h {
+ return false
+ }
+ }
+ if v, ok := constraints["min_width"]; ok {
+ if mw, err := strconv.Atoi(v); err == nil && width < mw {
+ return false
+ }
+ }
+ if v, ok := constraints["max_width"]; ok {
+ if mw, err := strconv.Atoi(v); err == nil && width > mw {
+ return false
+ }
+ }
+ if v, ok := constraints["min_height"]; ok {
+ if mh, err := strconv.Atoi(v); err == nil && height < mh {
+ return false
+ }
+ }
+ if v, ok := constraints["max_height"]; ok {
+ if mh, err := strconv.Atoi(v); err == nil && height > mh {
+ return false
+ }
+ }
+
+ if v, ok := constraints["ratio"]; ok {
+ var targetRatio float64
+ if num, den, found := strings.Cut(v, "/"); found {
+ n, err1 := strconv.ParseFloat(num, 64)
+ d, err2 := strconv.ParseFloat(den, 64)
+ if err1 != nil || err2 != nil || d == 0 {
+ return false
+ }
+ targetRatio = n / d
+ } else {
+ r, err := strconv.ParseFloat(v, 64)
+ if err != nil {
+ return false
+ }
+ targetRatio = r
+ }
+ actualRatio := float64(width) / float64(height)
+ if math.Abs(actualRatio-targetRatio) > 0.01 {
+ return false
+ }
+ }
+
+ return true
+ }
+
+ if fh, ok := ctx.Value.(*multipart.FileHeader); ok {
+ return check(fh)
+ }
+ if fhs, ok := ctx.Value.([]*multipart.FileHeader); ok {
+ for _, fh := range fhs {
+ if !check(fh) {
+ return false
+ }
+ }
+ return len(fhs) > 0
+ }
+ return false
+}
+
+// ---- Control Rules ----
+
+func ruleBail(_ *RuleContext) bool {
+ return true
+}
+
+func ruleNullable(_ *RuleContext) bool {
+ return true
+}
+
+func ruleSometimes(_ *RuleContext) bool {
+ return true
+}
+
+// ---- Other Rules ----
+
+func ruleDistinct(ctx *RuleContext) bool {
+ // Distinct checks for duplicate values in array fields.
+ // The engine handles tracking unique values across wildcard-expanded fields.
+ return true
+}
+
+func ruleRequiredArrayKeys(ctx *RuleContext) bool {
+ if len(ctx.Parameters) == 0 {
+ return false
+ }
+ if ctx.Value == nil {
+ return false
+ }
+ rv := reflect.ValueOf(ctx.Value)
+ if rv.Kind() != reflect.Map {
+ return false
+ }
+ keys := rv.MapKeys()
+ for _, param := range ctx.Parameters {
+ found := false
+ for _, k := range keys {
+ if fmt.Sprintf("%v", k.Interface()) == param {
+ found = true
+ break
+ }
+ }
+ if !found {
+ return false
+ }
+ }
+ return true
+}
+
+func ruleInArrayKeys(ctx *RuleContext) bool {
+ if len(ctx.Parameters) == 0 {
+ return false
+ }
+ if ctx.Value == nil {
+ return false
+ }
+ rv := reflect.ValueOf(ctx.Value)
+ if rv.Kind() != reflect.Map {
+ return false
+ }
+ keys := rv.MapKeys()
+ for _, param := range ctx.Parameters {
+ for _, k := range keys {
+ if fmt.Sprintf("%v", k.Interface()) == param {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+func ruleTimezone(ctx *RuleContext) bool {
+ s, ok := ctx.Value.(string)
+ if !ok {
+ return false
+ }
+ _, err := time.LoadLocation(s)
+ return err == nil
+}
+
+func ruleEncoding(ctx *RuleContext) bool {
+ if len(ctx.Parameters) == 0 {
+ return false
+ }
+
+ var data []byte
+ switch v := ctx.Value.(type) {
+ case string:
+ data = []byte(v)
+ case *multipart.FileHeader:
+ f, err := v.Open()
+ if err != nil {
+ return false
+ }
+ defer func(f multipart.File) { _ = f.Close() }(f)
+ data, err = io.ReadAll(f)
+ if err != nil {
+ return false
+ }
+ default:
+ return false
+ }
+
+ enc := strings.ToLower(ctx.Parameters[0])
+ switch enc {
+ case "utf-8", "utf8":
+ return utf8.Valid(data)
+ case "ascii", "us-ascii":
+ for _, b := range data {
+ if b > 127 {
+ return false
+ }
+ }
+ return true
+ default:
+ return false
+ }
+}
+
+// ---- Database Rules ----
+
+// ruleExists validates that the given value exists in the specified database table.
+// Syntax: exists:table,column1,column2,...
+// - table: required, supports "connection.table" format
+// - columns: optional, defaults to the current field name. Multiple columns are joined with OR.
+func ruleExists(ctx *RuleContext) bool {
+ if ormFacade == nil || len(ctx.Parameters) == 0 {
+ return false
+ }
+
+ table := ctx.Parameters[0]
+ var connection string
+ if dotIdx := strings.Index(table, "."); dotIdx > 0 {
+ connection = table[:dotIdx]
+ table = table[dotIdx+1:]
+ }
+ if table == "" {
+ return false
+ }
+
+ var columns []string
+ for i := 1; i < len(ctx.Parameters); i++ {
+ if ctx.Parameters[i] != "" {
+ columns = append(columns, ctx.Parameters[i])
+ }
+ }
+ if len(columns) == 0 {
+ columns = []string{ctx.Attribute}
+ }
+
+ o := ormFacade.WithContext(ctx.Ctx)
+ if connection != "" {
+ o = o.Connection(connection)
+ }
+ query := o.Query().Table(table)
+
+ if len(columns) == 1 {
+ query = query.Where(columns[0], ctx.Value)
+ } else {
+ for i, col := range columns {
+ if i == 0 {
+ query = query.Where(col, ctx.Value)
+ } else {
+ query = query.OrWhere(col, ctx.Value)
+ }
+ }
+ }
+
+ exists, err := query.Exists()
+ if err != nil {
+ return false
+ }
+ return exists
+}
+
+// ruleUnique validates that the given value is unique in the specified database table.
+// Syntax: unique:table,column,idColumn,except1,except2,...
+// - table: required, supports "connection.table" format
+// - column: optional, defaults to the current field name
+// - idColumn: optional, defaults to "id", the column to use for the except clause
+// - except values: optional, values to exclude from the unique check (for update scenarios)
+func ruleUnique(ctx *RuleContext) bool {
+ if ormFacade == nil || len(ctx.Parameters) == 0 {
+ return false
+ }
+
+ table := ctx.Parameters[0]
+ var connection string
+ if dotIdx := strings.Index(table, "."); dotIdx > 0 {
+ connection = table[:dotIdx]
+ table = table[dotIdx+1:]
+ }
+ if table == "" {
+ return false
+ }
+
+ column := ctx.Attribute
+ if len(ctx.Parameters) >= 2 && ctx.Parameters[1] != "" {
+ column = ctx.Parameters[1]
+ }
+
+ o := ormFacade.WithContext(ctx.Ctx)
+ if connection != "" {
+ o = o.Connection(connection)
+ }
+ query := o.Query().Table(table).Where(column, ctx.Value)
+
+ // Handle except (ignore specific records for updates)
+ // Parameters: table, column, idColumn, except1, except2, ...
+ if len(ctx.Parameters) >= 4 {
+ idColumn := "id"
+ if ctx.Parameters[2] != "" {
+ idColumn = ctx.Parameters[2]
+ }
+
+ var exceptValues []any
+ for i := 3; i < len(ctx.Parameters); i++ {
+ if ctx.Parameters[i] != "" {
+ exceptValues = append(exceptValues, ctx.Parameters[i])
+ }
+ }
+
+ if len(exceptValues) > 0 {
+ query = query.WhereNotIn(idColumn, exceptValues)
+ }
+ }
+
+ count, err := query.Count()
+ if err != nil {
+ return false
+ }
+ return count == 0
+}
diff --git a/validation/rules_test.go b/validation/rules_test.go
new file mode 100644
index 000000000..5b04e89d8
--- /dev/null
+++ b/validation/rules_test.go
@@ -0,0 +1,4194 @@
+package validation
+
+import (
+ "bytes"
+ "context"
+ "mime/multipart"
+ "testing"
+
+ "github.com/stretchr/testify/mock"
+ "github.com/stretchr/testify/suite"
+
+ contractsvalidation "github.com/goravel/framework/contracts/validation"
+ mocksorm "github.com/goravel/framework/mocks/database/orm"
+)
+
+type RulesTestSuite struct {
+ suite.Suite
+ validation *Validation
+}
+
+func (s *RulesTestSuite) SetupTest() {
+ s.validation = NewValidation()
+}
+
+func (s *RulesTestSuite) makeValidator(data map[string]any, rules map[string]any, options ...contractsvalidation.Option) contractsvalidation.Validator {
+ validator, err := s.validation.Make(context.Background(), data, rules, options...)
+ s.Require().NoError(err)
+ return validator
+}
+
+func TestRulesTestSuite(t *testing.T) {
+ suite.Run(t, new(RulesTestSuite))
+}
+
+// ===== 1. Existence Rules =====
+
+func (s *RulesTestSuite) TestRequired() {
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ {"pass_with_value", map[string]any{"name": "goravel"}, map[string]any{"name": "required"}, false},
+ {"pass_with_int", map[string]any{"age": 18}, map[string]any{"age": "required"}, false},
+ {"pass_with_int_zero", map[string]any{"age": 0}, map[string]any{"age": "required"}, false},
+ {"pass_with_bool_true", map[string]any{"ok": true}, map[string]any{"ok": "required"}, false},
+ {"pass_with_bool_false", map[string]any{"ok": false}, map[string]any{"ok": "required"}, false},
+ {"pass_with_float", map[string]any{"score": 3.14}, map[string]any{"score": "required"}, false},
+ {"pass_with_slice", map[string]any{"items": []any{1, 2}}, map[string]any{"items": "required"}, false},
+ {"pass_with_map", map[string]any{"meta": map[string]any{"k": "v"}}, map[string]any{"meta": "required"}, false},
+ {"pass_zero_float", map[string]any{"val": 0.0}, map[string]any{"val": "required"}, false},
+ {"fail_empty_string", map[string]any{"name": ""}, map[string]any{"name": "required"}, true},
+ {"fail_whitespace_only", map[string]any{"name": " "}, map[string]any{"name": "required"}, true},
+ {"fail_nil", map[string]any{"name": nil}, map[string]any{"name": "required"}, true},
+ {"fail_missing_key", map[string]any{"other": "x"}, map[string]any{"name": "required"}, true},
+ {"pass_multiple_required", map[string]any{"a": "1", "b": "2"}, map[string]any{"a": "required", "b": "required"}, false},
+ {"fail_one_of_multiple_missing", map[string]any{"a": "1"}, map[string]any{"a": "required", "b": "required"}, true},
+ {"fail_empty_slice", map[string]any{"items": []any{}}, map[string]any{"items": "required"}, true},
+ {"fail_empty_map", map[string]any{"data": map[string]any{}}, map[string]any{"data": "required"}, true},
+ {"fail_nested_empty_string", map[string]any{"user": map[string]any{"name": ""}}, map[string]any{"user.name": "required"}, true},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+func (s *RulesTestSuite) TestRequiredIf() {
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ {"pass_when_condition_met_and_present", map[string]any{"type": "admin", "role": "manager"}, map[string]any{"role": "required_if:type,admin"}, false},
+ {"pass_when_condition_not_met", map[string]any{"type": "user"}, map[string]any{"role": "required_if:type,admin"}, false},
+ {"fail_when_condition_met_and_missing", map[string]any{"type": "admin"}, map[string]any{"role": "required_if:type,admin"}, true},
+ {"fail_when_condition_met_and_empty", map[string]any{"type": "admin", "role": ""}, map[string]any{"role": "required_if:type,admin"}, true},
+ {"pass_multiple_values_no_match", map[string]any{"type": "guest"}, map[string]any{"role": "required_if:type,admin,manager"}, false},
+ {"fail_multiple_values_match_second", map[string]any{"type": "manager", "role": ""}, map[string]any{"role": "required_if:type,admin,manager"}, true},
+ {"pass_bool_condition_true", map[string]any{"active": true, "name": "go"}, map[string]any{"name": "required_if:active,true"}, false},
+ {"fail_bool_condition_true_missing", map[string]any{"active": true}, map[string]any{"name": "required_if:active,true"}, true},
+ {"pass_bool_condition_false_no_trigger", map[string]any{"active": false}, map[string]any{"name": "required_if:active,true"}, false},
+ {"pass_other_field_missing", map[string]any{}, map[string]any{"role": "required_if:type,admin"}, false},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+func (s *RulesTestSuite) TestRequiredUnless() {
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ {"pass_when_other_matches", map[string]any{"role": "admin"}, map[string]any{"name": "required_unless:role,admin"}, false},
+ {"pass_when_other_no_match_and_present", map[string]any{"role": "user", "name": "John"}, map[string]any{"name": "required_unless:role,admin"}, false},
+ {"fail_when_other_no_match_and_missing", map[string]any{"role": "user"}, map[string]any{"name": "required_unless:role,admin"}, true},
+ {"fail_when_other_no_match_and_empty", map[string]any{"role": "user", "name": ""}, map[string]any{"name": "required_unless:role,admin"}, true},
+ {"pass_multiple_unless_match", map[string]any{"role": "mod"}, map[string]any{"name": "required_unless:role,admin,mod"}, false},
+ {"fail_multiple_unless_no_match", map[string]any{"role": "user"}, map[string]any{"name": "required_unless:role,admin,mod"}, true},
+ {"pass_other_field_missing_and_filled", map[string]any{"name": "go"}, map[string]any{"name": "required_unless:role,admin"}, false},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+func (s *RulesTestSuite) TestRequiredWith() {
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ {"pass_when_other_present_and_filled", map[string]any{"first": "A", "last": "B"}, map[string]any{"last": "required_with:first"}, false},
+ {"pass_when_other_absent", map[string]any{"other": "x"}, map[string]any{"last": "required_with:first"}, false},
+ {"fail_when_other_present_and_empty", map[string]any{"first": "A", "last": ""}, map[string]any{"last": "required_with:first"}, true},
+ {"fail_when_other_present_and_missing", map[string]any{"first": "A"}, map[string]any{"last": "required_with:first"}, true},
+ {"pass_multiple_one_present", map[string]any{"a": "1", "c": "3"}, map[string]any{"c": "required_with:a,b"}, false},
+ {"fail_multiple_one_present_target_missing", map[string]any{"a": "1"}, map[string]any{"c": "required_with:a,b"}, true},
+ {"pass_other_present_but_empty_value", map[string]any{"first": "", "last": "B"}, map[string]any{"last": "required_with:first"}, false},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+func (s *RulesTestSuite) TestRequiredWithAll() {
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ {"pass_all_present_and_filled", map[string]any{"a": "1", "b": "2", "c": "3"}, map[string]any{"c": "required_with_all:a,b"}, false},
+ {"pass_not_all_present", map[string]any{"a": "1"}, map[string]any{"c": "required_with_all:a,b"}, false},
+ {"fail_all_present_and_empty", map[string]any{"a": "1", "b": "2", "c": ""}, map[string]any{"c": "required_with_all:a,b"}, true},
+ {"fail_all_present_and_missing", map[string]any{"a": "1", "b": "2"}, map[string]any{"c": "required_with_all:a,b"}, true},
+ {"pass_one_other_empty_str", map[string]any{"a": "1", "b": "", "c": "3"}, map[string]any{"c": "required_with_all:a,b"}, false},
+ {"pass_three_others_not_all", map[string]any{"a": "1", "b": "2"}, map[string]any{"d": "required_with_all:a,b,c"}, false},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+func (s *RulesTestSuite) TestRequiredWithout() {
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ {"pass_when_other_absent_and_filled", map[string]any{"email": "a@b.com"}, map[string]any{"email": "required_without:phone"}, false},
+ {"pass_when_other_present", map[string]any{"phone": "123"}, map[string]any{"email": "required_without:phone"}, false},
+ {"fail_when_other_absent_and_empty", map[string]any{"other": "x"}, map[string]any{"email": "required_without:phone"}, true},
+ {"fail_when_other_absent_and_missing", map[string]any{}, map[string]any{"email": "required_without:phone"}, true},
+ {"pass_multiple_one_absent", map[string]any{"a": "1", "c": "3"}, map[string]any{"c": "required_without:a,b"}, false},
+ {"pass_both_present", map[string]any{"a": "1", "b": "2", "c": "3"}, map[string]any{"c": "required_without:a,b"}, false},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+func (s *RulesTestSuite) TestRequiredWithoutAll() {
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ {"pass_none_present_and_filled", map[string]any{"c": "val"}, map[string]any{"c": "required_without_all:a,b"}, false},
+ {"pass_some_present", map[string]any{"a": "1"}, map[string]any{"c": "required_without_all:a,b"}, false},
+ {"pass_all_present", map[string]any{"a": "1", "b": "2"}, map[string]any{"c": "required_without_all:a,b"}, false},
+ {"pass_one_of_three_present", map[string]any{"b": "2"}, map[string]any{"d": "required_without_all:a,b,c"}, false},
+ {"fail_none_present_and_empty", map[string]any{"other": "x"}, map[string]any{"c": "required_without_all:a,b"}, true},
+ {"fail_none_present_and_missing", map[string]any{}, map[string]any{"c": "required_without_all:a,b"}, true},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+func (s *RulesTestSuite) TestRequiredIfAccepted() {
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ {"pass_accepted_and_filled", map[string]any{"terms": true, "sig": "yes"}, map[string]any{"sig": "required_if_accepted:terms"}, false},
+ {"pass_not_accepted_bool", map[string]any{"terms": false}, map[string]any{"sig": "required_if_accepted:terms"}, false},
+ {"pass_not_accepted_string", map[string]any{"terms": "no"}, map[string]any{"sig": "required_if_accepted:terms"}, false},
+ {"pass_other_field_missing", map[string]any{}, map[string]any{"sig": "required_if_accepted:terms"}, false},
+ {"fail_accepted_bool_and_missing", map[string]any{"terms": true}, map[string]any{"sig": "required_if_accepted:terms"}, true},
+ {"fail_accepted_string_yes", map[string]any{"terms": "yes"}, map[string]any{"sig": "required_if_accepted:terms"}, true},
+ {"fail_accepted_string_on", map[string]any{"terms": "on"}, map[string]any{"sig": "required_if_accepted:terms"}, true},
+ {"fail_accepted_string_1", map[string]any{"terms": "1"}, map[string]any{"sig": "required_if_accepted:terms"}, true},
+ {"fail_accepted_int_1", map[string]any{"terms": 1}, map[string]any{"sig": "required_if_accepted:terms"}, true},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+func (s *RulesTestSuite) TestRequiredIfDeclined() {
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ {"pass_declined_and_filled", map[string]any{"auto": false, "reason": "manual"}, map[string]any{"reason": "required_if_declined:auto"}, false},
+ {"pass_not_declined_bool", map[string]any{"auto": true}, map[string]any{"reason": "required_if_declined:auto"}, false},
+ {"pass_not_declined_string", map[string]any{"auto": "yes"}, map[string]any{"reason": "required_if_declined:auto"}, false},
+ {"pass_other_field_missing", map[string]any{}, map[string]any{"reason": "required_if_declined:auto"}, false},
+ {"fail_declined_bool_and_missing", map[string]any{"auto": false}, map[string]any{"reason": "required_if_declined:auto"}, true},
+ {"fail_declined_string_no", map[string]any{"auto": "no"}, map[string]any{"reason": "required_if_declined:auto"}, true},
+ {"fail_declined_string_off", map[string]any{"auto": "off"}, map[string]any{"reason": "required_if_declined:auto"}, true},
+ {"fail_declined_string_0", map[string]any{"auto": "0"}, map[string]any{"reason": "required_if_declined:auto"}, true},
+ {"fail_declined_int_0", map[string]any{"auto": 0}, map[string]any{"reason": "required_if_declined:auto"}, true},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+func (s *RulesTestSuite) TestFilled() {
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ {"pass_present_with_value", map[string]any{"name": "go"}, map[string]any{"name": "filled"}, false},
+ {"pass_present_with_int", map[string]any{"age": 0}, map[string]any{"age": "filled"}, false},
+ {"pass_present_with_bool", map[string]any{"ok": false}, map[string]any{"ok": "filled"}, false},
+ {"pass_not_present", map[string]any{"other": "x"}, map[string]any{"name": "filled"}, false},
+ {"fail_present_empty", map[string]any{"name": ""}, map[string]any{"name": "filled"}, true},
+ {"fail_present_whitespace", map[string]any{"name": " "}, map[string]any{"name": "filled"}, true},
+ {"fail_present_nil", map[string]any{"name": nil}, map[string]any{"name": "filled"}, true},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+func (s *RulesTestSuite) TestPresent() {
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ {"pass_present_with_value", map[string]any{"name": "go"}, map[string]any{"name": "present"}, false},
+ {"pass_present_empty", map[string]any{"name": ""}, map[string]any{"name": "present"}, false},
+ {"pass_present_nil", map[string]any{"name": nil}, map[string]any{"name": "present"}, false},
+ {"fail_missing", map[string]any{"other": "x"}, map[string]any{"name": "present"}, true},
+ {"fail_empty_data", map[string]any{}, map[string]any{"name": "present"}, true},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+func (s *RulesTestSuite) TestPresentIf() {
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ {"pass_condition_met_and_present", map[string]any{"type": "a", "name": ""}, map[string]any{"name": "present_if:type,a"}, false},
+ {"pass_condition_not_met", map[string]any{"type": "b"}, map[string]any{"name": "present_if:type,a"}, false},
+ {"fail_condition_met_and_missing", map[string]any{"type": "a"}, map[string]any{"name": "present_if:type,a"}, true},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+func (s *RulesTestSuite) TestPresentUnless() {
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ {"pass_condition_met", map[string]any{"role": "admin"}, map[string]any{"name": "present_unless:role,admin"}, false},
+ {"pass_condition_not_met_and_present", map[string]any{"role": "user", "name": ""}, map[string]any{"name": "present_unless:role,admin"}, false},
+ {"fail_condition_not_met_and_missing", map[string]any{"role": "user"}, map[string]any{"name": "present_unless:role,admin"}, true},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+func (s *RulesTestSuite) TestPresentWith() {
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ {"pass_other_present_and_self_present", map[string]any{"a": "1", "b": ""}, map[string]any{"b": "present_with:a"}, false},
+ {"pass_other_absent", map[string]any{"c": "1"}, map[string]any{"b": "present_with:a"}, false},
+ {"fail_other_present_and_self_missing", map[string]any{"a": "1"}, map[string]any{"b": "present_with:a"}, true},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+func (s *RulesTestSuite) TestPresentWithAll() {
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ {"pass_all_present_and_self_present", map[string]any{"a": "1", "b": "2", "c": ""}, map[string]any{"c": "present_with_all:a,b"}, false},
+ {"pass_not_all_present", map[string]any{"a": "1"}, map[string]any{"c": "present_with_all:a,b"}, false},
+ {"fail_all_present_and_self_missing", map[string]any{"a": "1", "b": "2"}, map[string]any{"c": "present_with_all:a,b"}, true},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+func (s *RulesTestSuite) TestMissing() {
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ {"pass_when_missing", map[string]any{"other": "x"}, map[string]any{"name": "missing"}, false},
+ {"pass_empty_data", map[string]any{}, map[string]any{"name": "missing"}, false},
+ {"fail_when_present", map[string]any{"name": "go"}, map[string]any{"name": "missing"}, true},
+ {"fail_when_present_empty", map[string]any{"name": ""}, map[string]any{"name": "missing"}, true},
+ {"fail_when_present_nil", map[string]any{"name": nil}, map[string]any{"name": "missing"}, true},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+func (s *RulesTestSuite) TestMissingIf() {
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ {"pass_condition_met_and_missing", map[string]any{"type": "a"}, map[string]any{"name": "missing_if:type,a"}, false},
+ {"pass_condition_not_met", map[string]any{"type": "b", "name": "go"}, map[string]any{"name": "missing_if:type,a"}, false},
+ {"fail_condition_met_and_present", map[string]any{"type": "a", "name": "go"}, map[string]any{"name": "missing_if:type,a"}, true},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+func (s *RulesTestSuite) TestMissingUnless() {
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ {"pass_condition_met", map[string]any{"role": "admin", "name": "go"}, map[string]any{"name": "missing_unless:role,admin"}, false},
+ {"pass_condition_not_met_and_missing", map[string]any{"role": "user"}, map[string]any{"name": "missing_unless:role,admin"}, false},
+ {"fail_condition_not_met_and_present", map[string]any{"role": "user", "name": "go"}, map[string]any{"name": "missing_unless:role,admin"}, true},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+func (s *RulesTestSuite) TestMissingWith() {
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ {"pass_other_present_and_self_missing", map[string]any{"a": "1"}, map[string]any{"b": "missing_with:a"}, false},
+ {"pass_other_absent", map[string]any{"b": "1"}, map[string]any{"b": "missing_with:a"}, false},
+ {"fail_other_present_and_self_present", map[string]any{"a": "1", "b": "2"}, map[string]any{"b": "missing_with:a"}, true},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+func (s *RulesTestSuite) TestMissingWithAll() {
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ {"pass_all_present_and_self_missing", map[string]any{"a": "1", "b": "2"}, map[string]any{"c": "missing_with_all:a,b"}, false},
+ {"pass_not_all_present", map[string]any{"a": "1", "c": "3"}, map[string]any{"c": "missing_with_all:a,b"}, false},
+ {"fail_all_present_and_self_present", map[string]any{"a": "1", "b": "2", "c": "3"}, map[string]any{"c": "missing_with_all:a,b"}, true},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+// ===== 2. Accept/Decline Rules =====
+
+func (s *RulesTestSuite) TestAccepted() {
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ {"pass_bool_true", map[string]any{"terms": true}, map[string]any{"terms": "accepted"}, false},
+ {"pass_string_yes", map[string]any{"terms": "yes"}, map[string]any{"terms": "accepted"}, false},
+ {"pass_string_on", map[string]any{"terms": "on"}, map[string]any{"terms": "accepted"}, false},
+ {"pass_string_1", map[string]any{"terms": "1"}, map[string]any{"terms": "accepted"}, false},
+ {"pass_int_1", map[string]any{"terms": 1}, map[string]any{"terms": "accepted"}, false},
+ {"pass_string_true", map[string]any{"terms": "true"}, map[string]any{"terms": "accepted"}, false},
+ {"pass_float_1", map[string]any{"terms": float64(1)}, map[string]any{"terms": "accepted"}, false},
+ {"pass_string_YES_case", map[string]any{"terms": "YES"}, map[string]any{"terms": "accepted"}, false},
+ {"pass_string_True_case", map[string]any{"terms": "True"}, map[string]any{"terms": "accepted"}, false},
+ {"fail_bool_false", map[string]any{"terms": false}, map[string]any{"terms": "accepted"}, true},
+ {"fail_string_no", map[string]any{"terms": "no"}, map[string]any{"terms": "accepted"}, true},
+ {"fail_int_0", map[string]any{"terms": 0}, map[string]any{"terms": "accepted"}, true},
+ {"fail_nil", map[string]any{"terms": nil}, map[string]any{"terms": "accepted"}, true},
+ {"fail_string_random", map[string]any{"terms": "maybe"}, map[string]any{"terms": "accepted"}, true},
+ {"fail_int_2", map[string]any{"terms": 2}, map[string]any{"terms": "accepted"}, true},
+ {"fail_empty_string", map[string]any{"terms": ""}, map[string]any{"terms": "accepted"}, true},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+func (s *RulesTestSuite) TestAcceptedIf() {
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ {"pass_condition_met_and_accepted", map[string]any{"type": "a", "terms": true}, map[string]any{"terms": "accepted_if:type,a"}, false},
+ {"pass_condition_not_met", map[string]any{"type": "b", "terms": false}, map[string]any{"terms": "accepted_if:type,a"}, false},
+ {"fail_condition_met_and_not_accepted", map[string]any{"type": "a", "terms": false}, map[string]any{"terms": "accepted_if:type,a"}, true},
+ {"fail_condition_met_and_missing", map[string]any{"type": "a"}, map[string]any{"terms": "accepted_if:type,a"}, true},
+ {"pass_condition_met_string_yes", map[string]any{"type": "a", "terms": "yes"}, map[string]any{"terms": "accepted_if:type,a"}, false},
+ {"pass_multiple_condition_values", map[string]any{"type": "b", "terms": "on"}, map[string]any{"terms": "accepted_if:type,a,b"}, false},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+func (s *RulesTestSuite) TestDeclined() {
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ {"pass_bool_false", map[string]any{"opt": false}, map[string]any{"opt": "declined"}, false},
+ {"pass_string_no", map[string]any{"opt": "no"}, map[string]any{"opt": "declined"}, false},
+ {"pass_string_off", map[string]any{"opt": "off"}, map[string]any{"opt": "declined"}, false},
+ {"pass_string_0", map[string]any{"opt": "0"}, map[string]any{"opt": "declined"}, false},
+ {"pass_int_0", map[string]any{"opt": 0}, map[string]any{"opt": "declined"}, false},
+ {"pass_string_false", map[string]any{"opt": "false"}, map[string]any{"opt": "declined"}, false},
+ {"pass_float_0", map[string]any{"opt": float64(0)}, map[string]any{"opt": "declined"}, false},
+ {"pass_string_NO_case", map[string]any{"opt": "NO"}, map[string]any{"opt": "declined"}, false},
+ {"pass_string_False_case", map[string]any{"opt": "False"}, map[string]any{"opt": "declined"}, false},
+ {"fail_bool_true", map[string]any{"opt": true}, map[string]any{"opt": "declined"}, true},
+ {"fail_string_yes", map[string]any{"opt": "yes"}, map[string]any{"opt": "declined"}, true},
+ {"fail_nil", map[string]any{"opt": nil}, map[string]any{"opt": "declined"}, true},
+ {"fail_string_random", map[string]any{"opt": "maybe"}, map[string]any{"opt": "declined"}, true},
+ {"fail_int_1", map[string]any{"opt": 1}, map[string]any{"opt": "declined"}, true},
+ {"fail_empty_string", map[string]any{"opt": ""}, map[string]any{"opt": "declined"}, true},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+func (s *RulesTestSuite) TestDeclinedIf() {
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ {"pass_condition_met_and_declined", map[string]any{"type": "a", "opt": false}, map[string]any{"opt": "declined_if:type,a"}, false},
+ {"pass_condition_not_met", map[string]any{"type": "b", "opt": true}, map[string]any{"opt": "declined_if:type,a"}, false},
+ {"fail_condition_met_and_not_declined", map[string]any{"type": "a", "opt": true}, map[string]any{"opt": "declined_if:type,a"}, true},
+ {"fail_condition_met_and_missing", map[string]any{"type": "a"}, map[string]any{"opt": "declined_if:type,a"}, true},
+ {"pass_condition_met_string_no", map[string]any{"type": "a", "opt": "no"}, map[string]any{"opt": "declined_if:type,a"}, false},
+ {"pass_multiple_values", map[string]any{"type": "b", "opt": "off"}, map[string]any{"opt": "declined_if:type,a,b"}, false},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+// ===== 3. Prohibition Rules =====
+
+func (s *RulesTestSuite) TestProhibited() {
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ {"pass_empty", map[string]any{"x": ""}, map[string]any{"x": "prohibited"}, false},
+ {"pass_nil", map[string]any{"x": nil}, map[string]any{"x": "prohibited"}, false},
+ {"pass_empty_slice", map[string]any{"x": []any{}}, map[string]any{"x": "prohibited"}, false},
+ {"pass_empty_map", map[string]any{"x": map[string]any{}}, map[string]any{"x": "prohibited"}, false},
+ {"fail_with_value", map[string]any{"x": "hello"}, map[string]any{"x": "prohibited"}, true},
+ {"fail_with_int", map[string]any{"x": 1}, map[string]any{"x": "prohibited"}, true},
+ {"fail_with_bool", map[string]any{"x": true}, map[string]any{"x": "prohibited"}, true},
+ {"fail_with_slice", map[string]any{"x": []any{1}}, map[string]any{"x": "prohibited"}, true},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+func (s *RulesTestSuite) TestProhibitedIf() {
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ {"pass_condition_met_and_empty", map[string]any{"type": "a", "x": ""}, map[string]any{"x": "prohibited_if:type,a"}, false},
+ {"pass_condition_not_met", map[string]any{"type": "b", "x": "val"}, map[string]any{"x": "prohibited_if:type,a"}, false},
+ {"fail_condition_met_and_has_value", map[string]any{"type": "a", "x": "val"}, map[string]any{"x": "prohibited_if:type,a"}, true},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+func (s *RulesTestSuite) TestProhibitedUnless() {
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ {"pass_condition_met", map[string]any{"role": "admin", "x": "val"}, map[string]any{"x": "prohibited_unless:role,admin"}, false},
+ {"pass_condition_not_met_and_empty", map[string]any{"role": "user", "x": ""}, map[string]any{"x": "prohibited_unless:role,admin"}, false},
+ {"fail_condition_not_met_and_has_value", map[string]any{"role": "user", "x": "val"}, map[string]any{"x": "prohibited_unless:role,admin"}, true},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+func (s *RulesTestSuite) TestProhibitedIfAccepted() {
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ {"pass_accepted_and_empty", map[string]any{"terms": true, "x": ""}, map[string]any{"x": "prohibited_if_accepted:terms"}, false},
+ {"pass_not_accepted", map[string]any{"terms": false, "x": "val"}, map[string]any{"x": "prohibited_if_accepted:terms"}, false},
+ {"fail_accepted_and_has_value", map[string]any{"terms": true, "x": "val"}, map[string]any{"x": "prohibited_if_accepted:terms"}, true},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+func (s *RulesTestSuite) TestProhibitedIfDeclined() {
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ {"pass_declined_and_empty", map[string]any{"auto": false, "x": ""}, map[string]any{"x": "prohibited_if_declined:auto"}, false},
+ {"pass_not_declined", map[string]any{"auto": true, "x": "val"}, map[string]any{"x": "prohibited_if_declined:auto"}, false},
+ {"fail_declined_and_has_value", map[string]any{"auto": false, "x": "val"}, map[string]any{"x": "prohibited_if_declined:auto"}, true},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+func (s *RulesTestSuite) TestProhibits() {
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ {"pass_self_empty", map[string]any{"x": "", "y": "val"}, map[string]any{"x": "prohibits:y"}, false},
+ {"pass_self_has_value_other_empty", map[string]any{"x": "val", "y": ""}, map[string]any{"x": "prohibits:y"}, false},
+ {"pass_self_has_value_other_missing", map[string]any{"x": "val"}, map[string]any{"x": "prohibits:y"}, false},
+ {"pass_self_nil", map[string]any{"x": nil, "y": "val"}, map[string]any{"x": "prohibits:y"}, false},
+ {"fail_both_have_values", map[string]any{"x": "val", "y": "val2"}, map[string]any{"x": "prohibits:y"}, true},
+ {"fail_multiple_one_present", map[string]any{"x": "val", "y": "", "z": "val2"}, map[string]any{"x": "prohibits:y,z"}, true},
+ {"pass_multiple_all_empty", map[string]any{"x": "val", "y": "", "z": ""}, map[string]any{"x": "prohibits:y,z"}, false},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+// ===== 4. Type Rules =====
+
+func (s *RulesTestSuite) TestStringRule() {
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ {"pass_string", map[string]any{"x": "hello"}, map[string]any{"x": "string"}, false},
+ {"pass_string_with_spaces", map[string]any{"x": "hello world"}, map[string]any{"x": "string"}, false},
+ {"pass_string_unicode", map[string]any{"x": "你好世界"}, map[string]any{"x": "string"}, false},
+ {"pass_string_numeric_str", map[string]any{"x": "12345"}, map[string]any{"x": "string"}, false},
+ {"fail_int", map[string]any{"x": 123}, map[string]any{"x": "string"}, true},
+ {"fail_bool", map[string]any{"x": true}, map[string]any{"x": "string"}, true},
+ {"fail_float", map[string]any{"x": 3.14}, map[string]any{"x": "string"}, true},
+ {"fail_slice", map[string]any{"x": []any{1}}, map[string]any{"x": "string"}, true},
+ {"fail_map", map[string]any{"x": map[string]any{"a": 1}}, map[string]any{"x": "string"}, true},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+func (s *RulesTestSuite) TestInteger() {
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ {"pass_int", map[string]any{"x": 42}, map[string]any{"x": "integer"}, false},
+ {"pass_int64", map[string]any{"x": int64(42)}, map[string]any{"x": "integer"}, false},
+ {"pass_int32", map[string]any{"x": int32(42)}, map[string]any{"x": "integer"}, false},
+ {"pass_int8", map[string]any{"x": int8(42)}, map[string]any{"x": "integer"}, false},
+ {"pass_uint", map[string]any{"x": uint(42)}, map[string]any{"x": "integer"}, false},
+ {"pass_string_int", map[string]any{"x": "42"}, map[string]any{"x": "integer"}, false},
+ {"pass_string_negative", map[string]any{"x": "-10"}, map[string]any{"x": "integer"}, false},
+ {"pass_float_whole", map[string]any{"x": 42.0}, map[string]any{"x": "integer"}, false},
+ {"pass_zero", map[string]any{"x": 0}, map[string]any{"x": "integer"}, false},
+ {"fail_float_decimal", map[string]any{"x": 42.5}, map[string]any{"x": "integer"}, true},
+ {"fail_string_alpha", map[string]any{"x": "abc"}, map[string]any{"x": "integer"}, true},
+ {"fail_string_float", map[string]any{"x": "42.5"}, map[string]any{"x": "integer"}, true},
+ {"fail_bool", map[string]any{"x": true}, map[string]any{"x": "integer"}, true},
+ {"pass_int_alias", map[string]any{"x": 1}, map[string]any{"x": "int"}, false},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+func (s *RulesTestSuite) TestUintRule() {
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ {"pass_uint", map[string]any{"v": uint(42)}, map[string]any{"v": "uint"}, false},
+ {"pass_uint8", map[string]any{"v": uint8(255)}, map[string]any{"v": "uint"}, false},
+ {"pass_uint16", map[string]any{"v": uint16(65535)}, map[string]any{"v": "uint"}, false},
+ {"pass_uint32", map[string]any{"v": uint32(100)}, map[string]any{"v": "uint"}, false},
+ {"pass_uint64", map[string]any{"v": uint64(100)}, map[string]any{"v": "uint"}, false},
+ {"pass_int_positive", map[string]any{"v": 42}, map[string]any{"v": "uint"}, false},
+ {"pass_int8_positive", map[string]any{"v": int8(1)}, map[string]any{"v": "uint"}, false},
+ {"pass_int16_positive", map[string]any{"v": int16(1)}, map[string]any{"v": "uint"}, false},
+ {"pass_int32_positive", map[string]any{"v": int32(1)}, map[string]any{"v": "uint"}, false},
+ {"pass_int64_positive", map[string]any{"v": int64(1)}, map[string]any{"v": "uint"}, false},
+ {"pass_zero", map[string]any{"v": 0}, map[string]any{"v": "uint"}, false},
+ {"pass_string_zero", map[string]any{"v": "0"}, map[string]any{"v": "uint"}, false},
+ {"pass_string", map[string]any{"v": "42"}, map[string]any{"v": "uint"}, false},
+ {"pass_float64_whole", map[string]any{"v": float64(42)}, map[string]any{"v": "uint"}, false},
+ {"fail_negative", map[string]any{"v": -1}, map[string]any{"v": "uint"}, true},
+ {"fail_negative_int64", map[string]any{"v": int64(-1)}, map[string]any{"v": "uint"}, true},
+ {"fail_float", map[string]any{"v": 3.14}, map[string]any{"v": "uint"}, true},
+ {"fail_string_alpha", map[string]any{"v": "abc"}, map[string]any{"v": "uint"}, true},
+ {"fail_negative_string", map[string]any{"v": "-5"}, map[string]any{"v": "uint"}, true},
+ {"fail_float_string", map[string]any{"v": "3.14"}, map[string]any{"v": "uint"}, true},
+ {"fail_bool", map[string]any{"v": true}, map[string]any{"v": "uint"}, true},
+ {"fail_slice", map[string]any{"v": []any{1}}, map[string]any{"v": "uint"}, true},
+ {"fail_map", map[string]any{"v": map[string]any{}}, map[string]any{"v": "uint"}, true},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+func (s *RulesTestSuite) TestNumeric() {
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ {"pass_int", map[string]any{"x": 42}, map[string]any{"x": "numeric"}, false},
+ {"pass_float", map[string]any{"x": 3.14}, map[string]any{"x": "numeric"}, false},
+ {"pass_numeric_string", map[string]any{"x": "3.14"}, map[string]any{"x": "numeric"}, false},
+ {"pass_negative_string", map[string]any{"x": "-5"}, map[string]any{"x": "numeric"}, false},
+ {"pass_zero", map[string]any{"x": 0}, map[string]any{"x": "numeric"}, false},
+ {"pass_int64", map[string]any{"x": int64(100)}, map[string]any{"x": "numeric"}, false},
+ {"pass_uint", map[string]any{"x": uint(50)}, map[string]any{"x": "numeric"}, false},
+ {"pass_string_int", map[string]any{"x": "42"}, map[string]any{"x": "numeric"}, false},
+ {"pass_bool_converts", map[string]any{"x": true}, map[string]any{"x": "numeric"}, false},
+ {"fail_alpha_string", map[string]any{"x": "abc"}, map[string]any{"x": "numeric"}, true},
+ {"fail_mixed_string", map[string]any{"x": "12abc"}, map[string]any{"x": "numeric"}, true},
+ {"fail_slice", map[string]any{"x": []any{1}}, map[string]any{"x": "numeric"}, true},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+func (s *RulesTestSuite) TestBooleanRule() {
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ {"pass_true", map[string]any{"x": true}, map[string]any{"x": "boolean"}, false},
+ {"pass_false", map[string]any{"x": false}, map[string]any{"x": "boolean"}, false},
+ {"pass_int_0", map[string]any{"x": 0}, map[string]any{"x": "boolean"}, false},
+ {"pass_int_1", map[string]any{"x": 1}, map[string]any{"x": "boolean"}, false},
+ {"pass_int64_0", map[string]any{"x": int64(0)}, map[string]any{"x": "boolean"}, false},
+ {"pass_int64_1", map[string]any{"x": int64(1)}, map[string]any{"x": "boolean"}, false},
+ {"pass_float64_0", map[string]any{"x": float64(0)}, map[string]any{"x": "boolean"}, false},
+ {"pass_float64_1", map[string]any{"x": float64(1)}, map[string]any{"x": "boolean"}, false},
+ {"pass_string_true", map[string]any{"x": "true"}, map[string]any{"x": "boolean"}, false},
+ {"pass_string_false", map[string]any{"x": "false"}, map[string]any{"x": "boolean"}, false},
+ {"pass_string_0", map[string]any{"x": "0"}, map[string]any{"x": "boolean"}, false},
+ {"pass_string_1", map[string]any{"x": "1"}, map[string]any{"x": "boolean"}, false},
+ {"pass_string_yes", map[string]any{"x": "yes"}, map[string]any{"x": "boolean"}, false},
+ {"pass_string_on", map[string]any{"x": "on"}, map[string]any{"x": "boolean"}, false},
+ {"fail_int_2", map[string]any{"x": 2}, map[string]any{"x": "boolean"}, true},
+ {"fail_int_neg1", map[string]any{"x": -1}, map[string]any{"x": "boolean"}, true},
+ {"fail_string_2", map[string]any{"x": "2"}, map[string]any{"x": "boolean"}, true},
+ {"fail_slice", map[string]any{"x": []any{true}}, map[string]any{"x": "boolean"}, true},
+ {"pass_bool_alias", map[string]any{"x": true}, map[string]any{"x": "bool"}, false},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+func (s *RulesTestSuite) TestFloat() {
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ {"pass_float64", map[string]any{"x": 3.14}, map[string]any{"x": "float"}, false},
+ {"pass_float32", map[string]any{"x": float32(3.14)}, map[string]any{"x": "float"}, false},
+ {"pass_string_float", map[string]any{"x": "3.14"}, map[string]any{"x": "float"}, false},
+ {"pass_string_negative_float", map[string]any{"x": "-3.14"}, map[string]any{"x": "float"}, false},
+ {"pass_string_whole_number", map[string]any{"x": "42"}, map[string]any{"x": "float"}, false},
+ {"pass_float64_zero", map[string]any{"x": 0.0}, map[string]any{"x": "float"}, false},
+ {"fail_int", map[string]any{"x": 42}, map[string]any{"x": "float"}, true},
+ {"fail_string", map[string]any{"x": "abc"}, map[string]any{"x": "float"}, true},
+ {"fail_bool", map[string]any{"x": true}, map[string]any{"x": "float"}, true},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+func (s *RulesTestSuite) TestArray() {
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ {"pass_slice", map[string]any{"x": []any{1, 2}}, map[string]any{"x": "array"}, false},
+ {"pass_map", map[string]any{"x": map[string]any{"a": 1}}, map[string]any{"x": "array"}, false},
+ {"pass_string_slice", map[string]any{"x": []string{"a"}}, map[string]any{"x": "array"}, false},
+ {"pass_empty_slice", map[string]any{"x": []any{}}, map[string]any{"x": "array"}, false},
+ {"pass_empty_map", map[string]any{"x": map[string]any{}}, map[string]any{"x": "array"}, false},
+ {"pass_int_slice", map[string]any{"x": []int{1, 2}}, map[string]any{"x": "array"}, false},
+ {"pass_float64_slice", map[string]any{"x": []float64{1.1, 2.2}}, map[string]any{"x": "array"}, false},
+ {"pass_bool_slice", map[string]any{"x": []bool{true, false}}, map[string]any{"x": "array"}, false},
+ {"pass_fixed_array", map[string]any{"x": [3]int{1, 2, 3}}, map[string]any{"x": "array"}, false},
+ {"fail_string", map[string]any{"x": "hello"}, map[string]any{"x": "array"}, true},
+ {"fail_int", map[string]any{"x": 42}, map[string]any{"x": "array"}, true},
+ {"fail_nil", map[string]any{"x": nil}, map[string]any{"x": "array"}, true},
+ {"fail_bool", map[string]any{"x": true}, map[string]any{"x": "array"}, true},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+func (s *RulesTestSuite) TestList() {
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ {"pass_slice", map[string]any{"x": []any{1, 2}}, map[string]any{"x": "list"}, false},
+ {"pass_empty_slice", map[string]any{"x": []any{}}, map[string]any{"x": "list"}, false},
+ {"pass_string_slice", map[string]any{"x": []string{"a"}}, map[string]any{"x": "list"}, false},
+ {"fail_map", map[string]any{"x": map[string]any{"a": 1}}, map[string]any{"x": "list"}, true},
+ {"fail_string", map[string]any{"x": "hello"}, map[string]any{"x": "list"}, true},
+ {"fail_nil", map[string]any{"x": nil}, map[string]any{"x": "list"}, true},
+ {"pass_slice_alias", map[string]any{"x": []any{1}}, map[string]any{"x": "slice"}, false},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+func (s *RulesTestSuite) TestMap() {
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ {"pass_map", map[string]any{"x": map[string]any{"a": 1}}, map[string]any{"x": "map"}, false},
+ {"pass_empty_map", map[string]any{"x": map[string]any{}}, map[string]any{"x": "map"}, false},
+ {"fail_slice", map[string]any{"x": []any{1, 2}}, map[string]any{"x": "map"}, true},
+ {"fail_string", map[string]any{"x": "hello"}, map[string]any{"x": "map"}, true},
+ {"fail_nil", map[string]any{"x": nil}, map[string]any{"x": "map"}, true},
+ {"fail_int", map[string]any{"x": 42}, map[string]any{"x": "map"}, true},
+ {"fail_bool", map[string]any{"x": true}, map[string]any{"x": "map"}, true},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+// ===== 5. Size Rules =====
+
+func (s *RulesTestSuite) TestSize() {
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ // String type: size = character count
+ {"pass_string_exact", map[string]any{"x": "abc"}, map[string]any{"x": "string|size:3"}, false},
+ {"fail_string_too_short", map[string]any{"x": "ab"}, map[string]any{"x": "string|size:3"}, true},
+ {"fail_string_too_long", map[string]any{"x": "abcd"}, map[string]any{"x": "string|size:3"}, true},
+ {"pass_string_empty_size_zero", map[string]any{"x": ""}, map[string]any{"x": "string|size:0"}, false},
+ {"pass_string_unicode", map[string]any{"x": "你好世"}, map[string]any{"x": "string|size:3"}, false},
+ {"pass_string_single_char", map[string]any{"x": "a"}, map[string]any{"x": "string|size:1"}, false},
+
+ // Numeric type: size = numeric value
+ {"pass_numeric_exact_int", map[string]any{"x": 10}, map[string]any{"x": "numeric|size:10"}, false},
+ {"fail_numeric_wrong", map[string]any{"x": 5}, map[string]any{"x": "numeric|size:10"}, true},
+ {"pass_numeric_exact_float", map[string]any{"x": 3.14}, map[string]any{"x": "numeric|size:3.14"}, false},
+ {"pass_numeric_zero", map[string]any{"x": 0}, map[string]any{"x": "numeric|size:0"}, false},
+ {"pass_numeric_negative", map[string]any{"x": -5}, map[string]any{"x": "numeric|size:-5"}, false},
+ {"fail_numeric_negative_mismatch", map[string]any{"x": -3}, map[string]any{"x": "numeric|size:-5"}, true},
+ {"pass_numeric_string_value", map[string]any{"x": "42"}, map[string]any{"x": "numeric|size:42"}, false},
+ {"pass_numeric_float64", map[string]any{"x": float64(100)}, map[string]any{"x": "numeric|size:100"}, false},
+
+ // Array type: size = element count
+ {"pass_array_exact", map[string]any{"x": []any{1, 2, 3}}, map[string]any{"x": "array|size:3"}, false},
+ {"fail_array_too_few", map[string]any{"x": []any{1, 2}}, map[string]any{"x": "array|size:3"}, true},
+ {"fail_array_too_many", map[string]any{"x": []any{1, 2, 3, 4}}, map[string]any{"x": "array|size:3"}, true},
+ {"pass_array_empty_size_zero", map[string]any{"x": []any{}}, map[string]any{"x": "array|size:0"}, false},
+ {"pass_array_single", map[string]any{"x": []any{"a"}}, map[string]any{"x": "array|size:1"}, false},
+
+ // Map type with size
+ {"pass_map_exact", map[string]any{"x": map[string]any{"a": 1, "b": 2}}, map[string]any{"x": "map|size:2"}, false},
+ {"fail_map_wrong", map[string]any{"x": map[string]any{"a": 1}}, map[string]any{"x": "map|size:2"}, true},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+func (s *RulesTestSuite) TestMin() {
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ // String: min character count
+ {"pass_string_above", map[string]any{"x": "abcde"}, map[string]any{"x": "string|min:3"}, false},
+ {"pass_string_exact", map[string]any{"x": "abc"}, map[string]any{"x": "string|min:3"}, false},
+ {"fail_string_below", map[string]any{"x": "ab"}, map[string]any{"x": "string|min:3"}, true},
+ {"pass_string_unicode_above", map[string]any{"x": "你好世界"}, map[string]any{"x": "string|min:3"}, false},
+ {"fail_string_unicode_below", map[string]any{"x": "你好"}, map[string]any{"x": "string|min:3"}, true},
+ {"pass_string_min_zero", map[string]any{"x": ""}, map[string]any{"x": "string|min:0"}, false},
+ {"pass_string_min_one", map[string]any{"x": "a"}, map[string]any{"x": "string|min:1"}, false},
+
+ // Numeric: min numeric value
+ {"pass_numeric_above", map[string]any{"x": 15}, map[string]any{"x": "numeric|min:10"}, false},
+ {"pass_numeric_exact", map[string]any{"x": 10}, map[string]any{"x": "numeric|min:10"}, false},
+ {"fail_numeric_below", map[string]any{"x": 5}, map[string]any{"x": "numeric|min:10"}, true},
+ {"pass_numeric_float", map[string]any{"x": 3.5}, map[string]any{"x": "numeric|min:3.5"}, false},
+ {"fail_numeric_float_below", map[string]any{"x": 3.4}, map[string]any{"x": "numeric|min:3.5"}, true},
+ {"pass_numeric_negative", map[string]any{"x": -1}, map[string]any{"x": "numeric|min:-5"}, false},
+ {"fail_numeric_negative_below", map[string]any{"x": -10}, map[string]any{"x": "numeric|min:-5"}, true},
+ {"pass_numeric_zero", map[string]any{"x": 0}, map[string]any{"x": "numeric|min:0"}, false},
+ {"pass_numeric_string", map[string]any{"x": "100"}, map[string]any{"x": "numeric|min:50"}, false},
+
+ // Array: min element count
+ {"pass_array_above", map[string]any{"x": []any{1, 2, 3}}, map[string]any{"x": "array|min:2"}, false},
+ {"pass_array_exact", map[string]any{"x": []any{1, 2}}, map[string]any{"x": "array|min:2"}, false},
+ {"fail_array_below", map[string]any{"x": []any{1}}, map[string]any{"x": "array|min:2"}, true},
+ {"pass_array_min_zero", map[string]any{"x": []any{}}, map[string]any{"x": "array|min:0"}, false},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+func (s *RulesTestSuite) TestMax() {
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ // String
+ {"pass_string_below", map[string]any{"x": "ab"}, map[string]any{"x": "string|max:3"}, false},
+ {"pass_string_exact", map[string]any{"x": "abc"}, map[string]any{"x": "string|max:3"}, false},
+ {"fail_string_above", map[string]any{"x": "abcd"}, map[string]any{"x": "string|max:3"}, true},
+ {"pass_string_empty", map[string]any{"x": ""}, map[string]any{"x": "string|max:3"}, false},
+ {"pass_string_unicode_within", map[string]any{"x": "你好"}, map[string]any{"x": "string|max:3"}, false},
+ {"fail_string_unicode_over", map[string]any{"x": "你好世界"}, map[string]any{"x": "string|max:3"}, true},
+
+ // Numeric
+ {"pass_numeric_below", map[string]any{"x": 5}, map[string]any{"x": "numeric|max:10"}, false},
+ {"pass_numeric_exact", map[string]any{"x": 10}, map[string]any{"x": "numeric|max:10"}, false},
+ {"fail_numeric_above", map[string]any{"x": 15}, map[string]any{"x": "numeric|max:10"}, true},
+ {"pass_numeric_zero", map[string]any{"x": 0}, map[string]any{"x": "numeric|max:10"}, false},
+ {"pass_numeric_negative", map[string]any{"x": -5}, map[string]any{"x": "numeric|max:0"}, false},
+ {"fail_numeric_negative_threshold", map[string]any{"x": -3}, map[string]any{"x": "numeric|max:-5"}, true},
+ {"pass_numeric_float", map[string]any{"x": 3.14}, map[string]any{"x": "numeric|max:3.14"}, false},
+ {"fail_numeric_float_over", map[string]any{"x": 3.15}, map[string]any{"x": "numeric|max:3.14"}, true},
+
+ // Array
+ {"pass_array_below", map[string]any{"x": []any{1}}, map[string]any{"x": "array|max:2"}, false},
+ {"pass_array_exact", map[string]any{"x": []any{1, 2}}, map[string]any{"x": "array|max:2"}, false},
+ {"fail_array_above", map[string]any{"x": []any{1, 2, 3}}, map[string]any{"x": "array|max:2"}, true},
+ {"pass_array_empty", map[string]any{"x": []any{}}, map[string]any{"x": "array|max:2"}, false},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+func (s *RulesTestSuite) TestNumericAliasesWithSizeRules() {
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ // int alias should resolve size as numeric value, not string length
+ {"int_max_pass", map[string]any{"x": 3}, map[string]any{"x": "int|max:3"}, false},
+ {"int_max_fail", map[string]any{"x": 42}, map[string]any{"x": "int|max:3"}, true},
+ {"int_max_string_input_fail", map[string]any{"x": "42"}, map[string]any{"x": "int|max:3"}, true},
+ {"int_min_pass", map[string]any{"x": 10}, map[string]any{"x": "int|min:5"}, false},
+ {"int_min_fail", map[string]any{"x": 2}, map[string]any{"x": "int|min:5"}, true},
+ {"int_between_pass", map[string]any{"x": 5}, map[string]any{"x": "int|between:1,10"}, false},
+ {"int_between_fail", map[string]any{"x": 20}, map[string]any{"x": "int|between:1,10"}, true},
+
+ // uint alias
+ {"uint_max_pass", map[string]any{"x": 3}, map[string]any{"x": "uint|max:5"}, false},
+ {"uint_max_fail", map[string]any{"x": 10}, map[string]any{"x": "uint|max:5"}, true},
+ {"uint_min_pass", map[string]any{"x": 5}, map[string]any{"x": "uint|min:0"}, false},
+
+ // float alias
+ {"float_max_pass", map[string]any{"x": 3.14}, map[string]any{"x": "float|max:5"}, false},
+ {"float_max_fail", map[string]any{"x": 10.5}, map[string]any{"x": "float|max:5"}, true},
+ {"float_min_pass", map[string]any{"x": 3.14}, map[string]any{"x": "float|min:3.14"}, false},
+ {"float_min_fail", map[string]any{"x": 1.5}, map[string]any{"x": "float|min:3.14"}, true},
+ {"float_size_pass", map[string]any{"x": 3.14}, map[string]any{"x": "float|size:3.14"}, false},
+ {"float_size_fail", map[string]any{"x": 2.0}, map[string]any{"x": "float|size:3.14"}, true},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails(), tt.name)
+ })
+ }
+}
+
+func (s *RulesTestSuite) TestBetween() {
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ // String
+ {"pass_string_in_range", map[string]any{"x": "abcd"}, map[string]any{"x": "string|between:3,5"}, false},
+ {"pass_string_at_min", map[string]any{"x": "abc"}, map[string]any{"x": "string|between:3,5"}, false},
+ {"pass_string_at_max", map[string]any{"x": "abcde"}, map[string]any{"x": "string|between:3,5"}, false},
+ {"fail_string_below", map[string]any{"x": "ab"}, map[string]any{"x": "string|between:3,5"}, true},
+ {"fail_string_above", map[string]any{"x": "abcdef"}, map[string]any{"x": "string|between:3,5"}, true},
+
+ // Numeric
+ {"pass_numeric_in_range", map[string]any{"x": 25}, map[string]any{"x": "numeric|between:18,65"}, false},
+ {"pass_numeric_at_min", map[string]any{"x": 18}, map[string]any{"x": "numeric|between:18,65"}, false},
+ {"pass_numeric_at_max", map[string]any{"x": 65}, map[string]any{"x": "numeric|between:18,65"}, false},
+ {"fail_numeric_below", map[string]any{"x": 10}, map[string]any{"x": "numeric|between:18,65"}, true},
+ {"fail_numeric_above", map[string]any{"x": 100}, map[string]any{"x": "numeric|between:18,65"}, true},
+ {"pass_numeric_float_range", map[string]any{"x": 1.5}, map[string]any{"x": "numeric|between:1.0,2.0"}, false},
+ {"pass_numeric_negative_range", map[string]any{"x": -3}, map[string]any{"x": "numeric|between:-5,-1"}, false},
+
+ // Array
+ {"pass_array_in_range", map[string]any{"x": []any{1, 2, 3}}, map[string]any{"x": "array|between:2,4"}, false},
+ {"pass_array_at_min", map[string]any{"x": []any{1, 2}}, map[string]any{"x": "array|between:2,4"}, false},
+ {"pass_array_at_max", map[string]any{"x": []any{1, 2, 3, 4}}, map[string]any{"x": "array|between:2,4"}, false},
+ {"fail_array_below", map[string]any{"x": []any{1}}, map[string]any{"x": "array|between:2,4"}, true},
+ {"fail_array_above", map[string]any{"x": []any{1, 2, 3, 4, 5}}, map[string]any{"x": "array|between:2,4"}, true},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+func (s *RulesTestSuite) TestGt() {
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ // String: character count > threshold
+ {"pass_string_greater", map[string]any{"x": "abcde"}, map[string]any{"x": "string|gt:3"}, false},
+ {"fail_string_equal", map[string]any{"x": "abc"}, map[string]any{"x": "string|gt:3"}, true},
+ {"fail_string_less", map[string]any{"x": "ab"}, map[string]any{"x": "string|gt:3"}, true},
+
+ // Numeric: value > threshold
+ {"pass_numeric_greater", map[string]any{"x": 15}, map[string]any{"x": "numeric|gt:10"}, false},
+ {"fail_numeric_equal", map[string]any{"x": 10}, map[string]any{"x": "numeric|gt:10"}, true},
+ {"fail_numeric_less", map[string]any{"x": 5}, map[string]any{"x": "numeric|gt:10"}, true},
+ {"pass_numeric_float_greater", map[string]any{"x": 3.15}, map[string]any{"x": "numeric|gt:3.14"}, false},
+ {"fail_numeric_float_equal", map[string]any{"x": 3.14}, map[string]any{"x": "numeric|gt:3.14"}, true},
+
+ // Array: count > threshold
+ {"pass_array_greater", map[string]any{"x": []any{1, 2, 3}}, map[string]any{"x": "array|gt:2"}, false},
+ {"fail_array_equal", map[string]any{"x": []any{1, 2}}, map[string]any{"x": "array|gt:2"}, true},
+
+ // Field reference: compare against another field
+ {"pass_gt_field_ref_string", map[string]any{"x": "abcd", "y": "ab"}, map[string]any{"x": "string|gt:y", "y": "string"}, false},
+ {"fail_gt_field_ref_string", map[string]any{"x": "ab", "y": "abcd"}, map[string]any{"x": "string|gt:y", "y": "string"}, true},
+ {"pass_gt_field_ref_numeric", map[string]any{"x": 20, "y": 10}, map[string]any{"x": "numeric|gt:y", "y": "numeric"}, false},
+ {"fail_gt_field_ref_numeric", map[string]any{"x": 10, "y": 10}, map[string]any{"x": "numeric|gt:y", "y": "numeric"}, true},
+ {"pass_gt_field_ref_array", map[string]any{"x": []any{1, 2, 3}, "y": []any{1}}, map[string]any{"x": "array|gt:y", "y": "array"}, false},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+func (s *RulesTestSuite) TestGte() {
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ // String
+ {"pass_string_greater", map[string]any{"x": "abcde"}, map[string]any{"x": "string|gte:3"}, false},
+ {"pass_string_equal", map[string]any{"x": "abc"}, map[string]any{"x": "string|gte:3"}, false},
+ {"fail_string_less", map[string]any{"x": "ab"}, map[string]any{"x": "string|gte:3"}, true},
+
+ // Numeric
+ {"pass_numeric_greater", map[string]any{"x": 15}, map[string]any{"x": "numeric|gte:10"}, false},
+ {"pass_numeric_equal", map[string]any{"x": 10}, map[string]any{"x": "numeric|gte:10"}, false},
+ {"fail_numeric_less", map[string]any{"x": 5}, map[string]any{"x": "numeric|gte:10"}, true},
+ {"pass_numeric_float_equal", map[string]any{"x": 3.14}, map[string]any{"x": "numeric|gte:3.14"}, false},
+ {"pass_numeric_negative", map[string]any{"x": -3}, map[string]any{"x": "numeric|gte:-5"}, false},
+
+ // Array
+ {"pass_array_greater", map[string]any{"x": []any{1, 2, 3}}, map[string]any{"x": "array|gte:2"}, false},
+ {"pass_array_equal", map[string]any{"x": []any{1, 2}}, map[string]any{"x": "array|gte:2"}, false},
+ {"fail_array_less", map[string]any{"x": []any{1}}, map[string]any{"x": "array|gte:2"}, true},
+
+ // Field reference
+ {"pass_gte_field_ref_numeric", map[string]any{"x": 10, "y": 10}, map[string]any{"x": "numeric|gte:y", "y": "numeric"}, false},
+ {"pass_gte_field_ref_numeric_greater", map[string]any{"x": 15, "y": 10}, map[string]any{"x": "numeric|gte:y", "y": "numeric"}, false},
+ {"fail_gte_field_ref_numeric", map[string]any{"x": 5, "y": 10}, map[string]any{"x": "numeric|gte:y", "y": "numeric"}, true},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+func (s *RulesTestSuite) TestLt() {
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ // String
+ {"pass_string_less", map[string]any{"x": "ab"}, map[string]any{"x": "string|lt:3"}, false},
+ {"fail_string_equal", map[string]any{"x": "abc"}, map[string]any{"x": "string|lt:3"}, true},
+ {"fail_string_greater", map[string]any{"x": "abcd"}, map[string]any{"x": "string|lt:3"}, true},
+
+ // Numeric
+ {"pass_numeric_less", map[string]any{"x": 5}, map[string]any{"x": "numeric|lt:10"}, false},
+ {"fail_numeric_equal", map[string]any{"x": 10}, map[string]any{"x": "numeric|lt:10"}, true},
+ {"fail_numeric_greater", map[string]any{"x": 15}, map[string]any{"x": "numeric|lt:10"}, true},
+ {"pass_numeric_negative", map[string]any{"x": -10}, map[string]any{"x": "numeric|lt:-5"}, false},
+ {"fail_numeric_negative_equal", map[string]any{"x": -5}, map[string]any{"x": "numeric|lt:-5"}, true},
+
+ // Array
+ {"pass_array_less", map[string]any{"x": []any{1}}, map[string]any{"x": "array|lt:2"}, false},
+ {"fail_array_equal", map[string]any{"x": []any{1, 2}}, map[string]any{"x": "array|lt:2"}, true},
+
+ // Field reference
+ {"pass_lt_field_ref_numeric", map[string]any{"x": 5, "y": 10}, map[string]any{"x": "numeric|lt:y", "y": "numeric"}, false},
+ {"fail_lt_field_ref_numeric", map[string]any{"x": 10, "y": 10}, map[string]any{"x": "numeric|lt:y", "y": "numeric"}, true},
+ {"pass_lt_field_ref_string", map[string]any{"x": "ab", "y": "abcde"}, map[string]any{"x": "string|lt:y", "y": "string"}, false},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+func (s *RulesTestSuite) TestLte() {
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ // String
+ {"pass_string_less", map[string]any{"x": "ab"}, map[string]any{"x": "string|lte:3"}, false},
+ {"pass_string_equal", map[string]any{"x": "abc"}, map[string]any{"x": "string|lte:3"}, false},
+ {"fail_string_greater", map[string]any{"x": "abcd"}, map[string]any{"x": "string|lte:3"}, true},
+
+ // Numeric
+ {"pass_numeric_less", map[string]any{"x": 5}, map[string]any{"x": "numeric|lte:10"}, false},
+ {"pass_numeric_equal", map[string]any{"x": 10}, map[string]any{"x": "numeric|lte:10"}, false},
+ {"fail_numeric_greater", map[string]any{"x": 15}, map[string]any{"x": "numeric|lte:10"}, true},
+ {"pass_numeric_zero", map[string]any{"x": 0}, map[string]any{"x": "numeric|lte:0"}, false},
+ {"pass_numeric_negative", map[string]any{"x": -10}, map[string]any{"x": "numeric|lte:-5"}, false},
+ {"pass_numeric_negative_equal", map[string]any{"x": -5}, map[string]any{"x": "numeric|lte:-5"}, false},
+
+ // Array
+ {"pass_array_less", map[string]any{"x": []any{1}}, map[string]any{"x": "array|lte:2"}, false},
+ {"pass_array_equal", map[string]any{"x": []any{1, 2}}, map[string]any{"x": "array|lte:2"}, false},
+ {"fail_array_greater", map[string]any{"x": []any{1, 2, 3}}, map[string]any{"x": "array|lte:2"}, true},
+
+ // Field reference
+ {"pass_lte_field_ref_numeric_equal", map[string]any{"x": 10, "y": 10}, map[string]any{"x": "numeric|lte:y", "y": "numeric"}, false},
+ {"pass_lte_field_ref_numeric_less", map[string]any{"x": 5, "y": 10}, map[string]any{"x": "numeric|lte:y", "y": "numeric"}, false},
+ {"fail_lte_field_ref_numeric", map[string]any{"x": 15, "y": 10}, map[string]any{"x": "numeric|lte:y", "y": "numeric"}, true},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+// ===== 6. Numeric Rules =====
+
+func (s *RulesTestSuite) TestDigits() {
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ {"pass_exact_digits_3", map[string]any{"x": "123"}, map[string]any{"x": "digits:3"}, false},
+ {"pass_exact_digits_1", map[string]any{"x": "5"}, map[string]any{"x": "digits:1"}, false},
+ {"pass_exact_digits_6", map[string]any{"x": "000000"}, map[string]any{"x": "digits:6"}, false},
+ {"fail_wrong_count_less", map[string]any{"x": "12"}, map[string]any{"x": "digits:3"}, true},
+ {"fail_wrong_count_more", map[string]any{"x": "1234"}, map[string]any{"x": "digits:3"}, true},
+ {"fail_non_digit_letter", map[string]any{"x": "12a"}, map[string]any{"x": "digits:3"}, true},
+ {"fail_non_digit_symbol", map[string]any{"x": "12."}, map[string]any{"x": "digits:3"}, true},
+ {"fail_negative_sign", map[string]any{"x": "-12"}, map[string]any{"x": "digits:3"}, true},
+ {"pass_int_value", map[string]any{"x": 1234}, map[string]any{"x": "digits:4"}, false},
+ {"pass_int_single_digit", map[string]any{"x": 0}, map[string]any{"x": "digits:1"}, false},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+func (s *RulesTestSuite) TestDigitsBetween() {
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ {"pass_in_range", map[string]any{"x": "123"}, map[string]any{"x": "digits_between:2,4"}, false},
+ {"pass_at_min", map[string]any{"x": "12"}, map[string]any{"x": "digits_between:2,4"}, false},
+ {"pass_at_max", map[string]any{"x": "1234"}, map[string]any{"x": "digits_between:2,4"}, false},
+ {"fail_below_min", map[string]any{"x": "1"}, map[string]any{"x": "digits_between:2,4"}, true},
+ {"fail_above_max", map[string]any{"x": "12345"}, map[string]any{"x": "digits_between:2,4"}, true},
+ {"fail_non_digit", map[string]any{"x": "12a"}, map[string]any{"x": "digits_between:2,4"}, true},
+ {"pass_int_value", map[string]any{"x": 123}, map[string]any{"x": "digits_between:2,4"}, false},
+ {"pass_all_zeros", map[string]any{"x": "000"}, map[string]any{"x": "digits_between:2,4"}, false},
+ {"pass_range_1_10", map[string]any{"x": "12345"}, map[string]any{"x": "digits_between:1,10"}, false},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+func (s *RulesTestSuite) TestDecimal() {
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ {"pass_exact_2_places", map[string]any{"x": "3.14"}, map[string]any{"x": "decimal:2"}, false},
+ {"fail_1_place_for_2", map[string]any{"x": "3.1"}, map[string]any{"x": "decimal:2"}, true},
+ {"fail_3_places_for_2", map[string]any{"x": "3.142"}, map[string]any{"x": "decimal:2"}, true},
+ {"pass_range_1_3", map[string]any{"x": "3.14"}, map[string]any{"x": "decimal:1,3"}, false},
+ {"pass_range_min", map[string]any{"x": "3.1"}, map[string]any{"x": "decimal:1,3"}, false},
+ {"pass_range_max", map[string]any{"x": "3.142"}, map[string]any{"x": "decimal:1,3"}, false},
+ {"fail_range_below", map[string]any{"x": "3"}, map[string]any{"x": "decimal:1,3"}, true},
+ {"fail_range_above", map[string]any{"x": "3.1415"}, map[string]any{"x": "decimal:1,3"}, true},
+ {"pass_zero_decimal_places", map[string]any{"x": "3"}, map[string]any{"x": "decimal:0"}, false},
+ {"fail_has_decimal_for_0", map[string]any{"x": "3.1"}, map[string]any{"x": "decimal:0"}, true},
+ {"fail_not_numeric", map[string]any{"x": "abc"}, map[string]any{"x": "decimal:2"}, true},
+ {"pass_negative_decimal", map[string]any{"x": "-3.14"}, map[string]any{"x": "decimal:2"}, false},
+ {"pass_zero_value", map[string]any{"x": "0.00"}, map[string]any{"x": "decimal:2"}, false},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+func (s *RulesTestSuite) TestMultipleOf() {
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ {"pass_15_of_5", map[string]any{"x": 15}, map[string]any{"x": "multiple_of:5"}, false},
+ {"pass_10_of_5", map[string]any{"x": 10}, map[string]any{"x": "multiple_of:5"}, false},
+ {"fail_13_of_5", map[string]any{"x": 13}, map[string]any{"x": "multiple_of:5"}, true},
+ {"pass_zero_of_5", map[string]any{"x": 0}, map[string]any{"x": "multiple_of:5"}, false},
+ {"pass_100_of_25", map[string]any{"x": 100}, map[string]any{"x": "multiple_of:25"}, false},
+ {"fail_99_of_25", map[string]any{"x": 99}, map[string]any{"x": "multiple_of:25"}, true},
+ {"pass_negative_of_3", map[string]any{"x": -9}, map[string]any{"x": "multiple_of:3"}, false},
+ {"fail_negative_of_4", map[string]any{"x": -9}, map[string]any{"x": "multiple_of:4"}, true},
+ {"pass_float_of_half", map[string]any{"x": 1.5}, map[string]any{"x": "multiple_of:0.5"}, false},
+ {"pass_string_number", map[string]any{"x": "12"}, map[string]any{"x": "multiple_of:4"}, false},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+func (s *RulesTestSuite) TestMinDigits() {
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ {"pass_exact_digits", map[string]any{"x": "123"}, map[string]any{"x": "min_digits:3"}, false},
+ {"pass_more_digits", map[string]any{"x": "12345"}, map[string]any{"x": "min_digits:3"}, false},
+ {"fail_too_few", map[string]any{"x": "12"}, map[string]any{"x": "min_digits:3"}, true},
+ {"pass_int_value", map[string]any{"x": 12345}, map[string]any{"x": "min_digits:3"}, false},
+ {"fail_single_digit", map[string]any{"x": "5"}, map[string]any{"x": "min_digits:3"}, true},
+ {"pass_min_1", map[string]any{"x": "0"}, map[string]any{"x": "min_digits:1"}, false},
+ {"pass_with_leading_zeros", map[string]any{"x": "001"}, map[string]any{"x": "min_digits:3"}, false},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+func (s *RulesTestSuite) TestMaxDigits() {
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ {"pass_within_limit", map[string]any{"x": "12"}, map[string]any{"x": "max_digits:3"}, false},
+ {"pass_at_limit", map[string]any{"x": "123"}, map[string]any{"x": "max_digits:3"}, false},
+ {"fail_over_limit", map[string]any{"x": "1234"}, map[string]any{"x": "max_digits:3"}, true},
+ {"pass_single_digit", map[string]any{"x": "5"}, map[string]any{"x": "max_digits:3"}, false},
+ {"pass_int_value", map[string]any{"x": 99}, map[string]any{"x": "max_digits:3"}, false},
+ {"fail_int_over", map[string]any{"x": 10000}, map[string]any{"x": "max_digits:3"}, true},
+ {"pass_max_1", map[string]any{"x": "0"}, map[string]any{"x": "max_digits:1"}, false},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+// ===== 7. String Format Rules =====
+
+func (s *RulesTestSuite) TestAlpha() {
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ {"pass_letters", map[string]any{"x": "Hello"}, map[string]any{"x": "alpha"}, false},
+ {"pass_unicode_letters", map[string]any{"x": "Héllo"}, map[string]any{"x": "alpha"}, false},
+ {"pass_chinese", map[string]any{"x": "你好"}, map[string]any{"x": "alpha"}, false},
+ {"pass_single_letter", map[string]any{"x": "a"}, map[string]any{"x": "alpha"}, false},
+ {"fail_with_numbers", map[string]any{"x": "abc123"}, map[string]any{"x": "alpha"}, true},
+ {"fail_with_spaces", map[string]any{"x": "abc def"}, map[string]any{"x": "alpha"}, true},
+ {"fail_with_special", map[string]any{"x": "abc!"}, map[string]any{"x": "alpha"}, true},
+ {"fail_with_dash", map[string]any{"x": "abc-def"}, map[string]any{"x": "alpha"}, true},
+ {"fail_with_underscore", map[string]any{"x": "abc_def"}, map[string]any{"x": "alpha"}, true},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+func (s *RulesTestSuite) TestAlphaNum() {
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ {"pass_letters_and_numbers", map[string]any{"x": "abc123"}, map[string]any{"x": "alpha_num"}, false},
+ {"pass_letters_only", map[string]any{"x": "abc"}, map[string]any{"x": "alpha_num"}, false},
+ {"pass_numbers_only", map[string]any{"x": "123"}, map[string]any{"x": "alpha_num"}, false},
+ {"pass_unicode_letters_nums", map[string]any{"x": "Héllo123"}, map[string]any{"x": "alpha_num"}, false},
+ {"fail_with_spaces", map[string]any{"x": "abc 123"}, map[string]any{"x": "alpha_num"}, true},
+ {"fail_with_special", map[string]any{"x": "abc@123"}, map[string]any{"x": "alpha_num"}, true},
+ {"fail_with_dash", map[string]any{"x": "abc-123"}, map[string]any{"x": "alpha_num"}, true},
+ {"fail_with_underscore", map[string]any{"x": "abc_123"}, map[string]any{"x": "alpha_num"}, true},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+func (s *RulesTestSuite) TestAlphaDash() {
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ {"pass_with_dash_underscore", map[string]any{"x": "abc-123_def"}, map[string]any{"x": "alpha_dash"}, false},
+ {"pass_letters_only", map[string]any{"x": "abc"}, map[string]any{"x": "alpha_dash"}, false},
+ {"pass_numbers_only", map[string]any{"x": "123"}, map[string]any{"x": "alpha_dash"}, false},
+ {"pass_underscore_only", map[string]any{"x": "___"}, map[string]any{"x": "alpha_dash"}, false},
+ {"pass_dash_only", map[string]any{"x": "---"}, map[string]any{"x": "alpha_dash"}, false},
+ {"pass_slug", map[string]any{"x": "my-blog-post"}, map[string]any{"x": "alpha_dash"}, false},
+ {"fail_with_spaces", map[string]any{"x": "abc def"}, map[string]any{"x": "alpha_dash"}, true},
+ {"fail_with_special", map[string]any{"x": "abc@def"}, map[string]any{"x": "alpha_dash"}, true},
+ {"fail_with_dot", map[string]any{"x": "abc.def"}, map[string]any{"x": "alpha_dash"}, true},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+func (s *RulesTestSuite) TestAscii() {
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ {"pass_ascii_text", map[string]any{"x": "hello world"}, map[string]any{"x": "ascii"}, false},
+ {"pass_ascii_with_symbols", map[string]any{"x": "hello@world.com!#$%"}, map[string]any{"x": "ascii"}, false},
+ {"pass_ascii_numbers", map[string]any{"x": "12345"}, map[string]any{"x": "ascii"}, false},
+ {"pass_ascii_newline_tab", map[string]any{"x": "hello\nworld\t!"}, map[string]any{"x": "ascii"}, false},
+ {"fail_unicode_accent", map[string]any{"x": "héllo"}, map[string]any{"x": "ascii"}, true},
+ {"fail_emoji", map[string]any{"x": "hello 🎉"}, map[string]any{"x": "ascii"}, true},
+ {"fail_chinese", map[string]any{"x": "你好"}, map[string]any{"x": "ascii"}, true},
+ {"fail_japanese", map[string]any{"x": "こんにちは"}, map[string]any{"x": "ascii"}, true},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+func (s *RulesTestSuite) TestEmail() {
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ {"pass_simple", map[string]any{"x": "user@example.com"}, map[string]any{"x": "email"}, false},
+ {"pass_with_plus", map[string]any{"x": "user+tag@example.com"}, map[string]any{"x": "email"}, false},
+ {"pass_with_dot", map[string]any{"x": "first.last@example.com"}, map[string]any{"x": "email"}, false},
+ {"pass_subdomain", map[string]any{"x": "user@mail.example.com"}, map[string]any{"x": "email"}, false},
+ {"pass_numbers", map[string]any{"x": "user123@example.com"}, map[string]any{"x": "email"}, false},
+ {"pass_dash_domain", map[string]any{"x": "user@my-domain.com"}, map[string]any{"x": "email"}, false},
+ {"fail_no_at", map[string]any{"x": "userexample.com"}, map[string]any{"x": "email"}, true},
+ {"fail_no_domain", map[string]any{"x": "user@"}, map[string]any{"x": "email"}, true},
+ {"fail_no_user", map[string]any{"x": "@example.com"}, map[string]any{"x": "email"}, true},
+ {"fail_plain_string", map[string]any{"x": "notanemail"}, map[string]any{"x": "email"}, true},
+ {"fail_double_at", map[string]any{"x": "user@@example.com"}, map[string]any{"x": "email"}, true},
+ {"fail_spaces", map[string]any{"x": "user @example.com"}, map[string]any{"x": "email"}, true},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+func (s *RulesTestSuite) TestUrl() {
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ {"pass_https", map[string]any{"x": "https://goravel.dev"}, map[string]any{"x": "url"}, false},
+ {"pass_http", map[string]any{"x": "https://example.com/path"}, map[string]any{"x": "url"}, false},
+ {"pass_with_port", map[string]any{"x": "https://localhost:8080"}, map[string]any{"x": "url"}, false},
+ {"pass_with_query", map[string]any{"x": "https://example.com/path?q=test&a=1"}, map[string]any{"x": "url"}, false},
+ {"pass_with_fragment", map[string]any{"x": "https://example.com/page#section"}, map[string]any{"x": "url"}, false},
+ {"pass_ftp", map[string]any{"x": "ftp://files.example.com"}, map[string]any{"x": "url"}, false},
+ {"fail_no_scheme", map[string]any{"x": "goravel.dev"}, map[string]any{"x": "url"}, true},
+ {"fail_plain_string", map[string]any{"x": "not a url"}, map[string]any{"x": "url"}, true},
+ {"fail_just_path", map[string]any{"x": "/path/to/file"}, map[string]any{"x": "url"}, true},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+func (s *RulesTestSuite) TestIp() {
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ {"pass_ipv4", map[string]any{"x": "192.168.1.1"}, map[string]any{"x": "ip"}, false},
+ {"pass_ipv4_loopback", map[string]any{"x": "127.0.0.1"}, map[string]any{"x": "ip"}, false},
+ {"pass_ipv4_all_zeros", map[string]any{"x": "0.0.0.0"}, map[string]any{"x": "ip"}, false},
+ {"pass_ipv6_full", map[string]any{"x": "2001:0db8:85a3:0000:0000:8a2e:0370:7334"}, map[string]any{"x": "ip"}, false},
+ {"pass_ipv6_loopback", map[string]any{"x": "::1"}, map[string]any{"x": "ip"}, false},
+ {"fail_invalid", map[string]any{"x": "not-an-ip"}, map[string]any{"x": "ip"}, true},
+ {"fail_out_of_range", map[string]any{"x": "999.999.999.999"}, map[string]any{"x": "ip"}, true},
+ {"fail_incomplete", map[string]any{"x": "192.168.1"}, map[string]any{"x": "ip"}, true},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+func (s *RulesTestSuite) TestIpv4() {
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ {"pass_valid", map[string]any{"x": "192.168.1.1"}, map[string]any{"x": "ipv4"}, false},
+ {"pass_loopback", map[string]any{"x": "127.0.0.1"}, map[string]any{"x": "ipv4"}, false},
+ {"pass_broadcast", map[string]any{"x": "255.255.255.255"}, map[string]any{"x": "ipv4"}, false},
+ {"pass_all_zeros", map[string]any{"x": "0.0.0.0"}, map[string]any{"x": "ipv4"}, false},
+ {"fail_ipv6", map[string]any{"x": "::1"}, map[string]any{"x": "ipv4"}, true},
+ {"fail_invalid", map[string]any{"x": "abc"}, map[string]any{"x": "ipv4"}, true},
+ {"fail_three_octets", map[string]any{"x": "192.168.1"}, map[string]any{"x": "ipv4"}, true},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+func (s *RulesTestSuite) TestIpv6() {
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ {"pass_full", map[string]any{"x": "2001:0db8:85a3:0000:0000:8a2e:0370:7334"}, map[string]any{"x": "ipv6"}, false},
+ {"pass_loopback", map[string]any{"x": "::1"}, map[string]any{"x": "ipv6"}, false},
+ {"pass_abbreviated", map[string]any{"x": "fe80::1"}, map[string]any{"x": "ipv6"}, false},
+ {"pass_all_zeros", map[string]any{"x": "::"}, map[string]any{"x": "ipv6"}, false},
+ {"fail_ipv4", map[string]any{"x": "192.168.1.1"}, map[string]any{"x": "ipv6"}, true},
+ {"fail_invalid", map[string]any{"x": "not-ipv6"}, map[string]any{"x": "ipv6"}, true},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+func (s *RulesTestSuite) TestMacAddress() {
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ {"pass_colon", map[string]any{"x": "00:1B:44:11:3A:B7"}, map[string]any{"x": "mac_address"}, false},
+ {"pass_dash", map[string]any{"x": "00-1B-44-11-3A-B7"}, map[string]any{"x": "mac_address"}, false},
+ {"pass_lowercase_mac", map[string]any{"x": "00:1b:44:11:3a:b7"}, map[string]any{"x": "mac_address"}, false},
+ {"pass_alias_mac", map[string]any{"x": "00:1B:44:11:3A:B7"}, map[string]any{"x": "mac"}, false},
+ {"fail_invalid", map[string]any{"x": "not-a-mac"}, map[string]any{"x": "mac_address"}, true},
+ {"fail_too_short", map[string]any{"x": "00:1B:44"}, map[string]any{"x": "mac_address"}, true},
+ {"fail_invalid_hex", map[string]any{"x": "GG:HH:II:JJ:KK:LL"}, map[string]any{"x": "mac_address"}, true},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+func (s *RulesTestSuite) TestJson() {
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ {"pass_object", map[string]any{"x": `{"key":"val"}`}, map[string]any{"x": "json"}, false},
+ {"pass_array", map[string]any{"x": `[1,2,3]`}, map[string]any{"x": "json"}, false},
+ {"pass_string", map[string]any{"x": `"hello"`}, map[string]any{"x": "json"}, false},
+ {"pass_number", map[string]any{"x": `42`}, map[string]any{"x": "json"}, false},
+ {"pass_boolean", map[string]any{"x": `true`}, map[string]any{"x": "json"}, false},
+ {"pass_null", map[string]any{"x": `null`}, map[string]any{"x": "json"}, false},
+ {"pass_nested", map[string]any{"x": `{"a":{"b":[1,2]}}`}, map[string]any{"x": "json"}, false},
+ {"pass_empty_object", map[string]any{"x": `{}`}, map[string]any{"x": "json"}, false},
+ {"pass_empty_array", map[string]any{"x": `[]`}, map[string]any{"x": "json"}, false},
+ {"fail_invalid_braces", map[string]any{"x": `{invalid}`}, map[string]any{"x": "json"}, true},
+ {"fail_plain", map[string]any{"x": "hello"}, map[string]any{"x": "json"}, true},
+ {"fail_trailing_comma", map[string]any{"x": `{"a":1,}`}, map[string]any{"x": "json"}, true},
+ {"fail_single_quotes", map[string]any{"x": `{'a': 1}`}, map[string]any{"x": "json"}, true},
+ {"fail_non_string_type", map[string]any{"x": 123}, map[string]any{"x": "json"}, true},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+func (s *RulesTestSuite) TestUuid() {
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ {"pass_v4", map[string]any{"x": "550e8400-e29b-41d4-a716-446655440000"}, map[string]any{"x": "uuid"}, false},
+ {"pass_v3", map[string]any{"x": "a3bb189e-8bf9-3888-9912-ace4e6543002"}, map[string]any{"x": "uuid"}, false},
+ {"pass_v5", map[string]any{"x": "886313e1-3b8a-5372-9b90-0c9aee199e5d"}, map[string]any{"x": "uuid"}, false},
+ {"pass_v1", map[string]any{"x": "550e8400-e29b-11d4-a716-446655440000"}, map[string]any{"x": "uuid"}, false},
+ {"pass_lowercase", map[string]any{"x": "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"}, map[string]any{"x": "uuid"}, false},
+ {"pass_uppercase", map[string]any{"x": "A0EEBC99-9C0B-4EF8-BB6D-6BB9BD380A11"}, map[string]any{"x": "uuid"}, false},
+ {"pass_nil_uuid", map[string]any{"x": "00000000-0000-0000-0000-000000000000"}, map[string]any{"x": "uuid"}, false},
+ {"fail_invalid", map[string]any{"x": "not-a-uuid"}, map[string]any{"x": "uuid"}, true},
+ {"fail_missing_dashes", map[string]any{"x": "550e8400e29b41d4a716446655440000"}, map[string]any{"x": "uuid"}, true},
+ {"fail_too_short", map[string]any{"x": "550e8400-e29b"}, map[string]any{"x": "uuid"}, true},
+ {"fail_too_long", map[string]any{"x": "550e8400-e29b-41d4-a716-446655440000a"}, map[string]any{"x": "uuid"}, true},
+ {"fail_invalid_chars", map[string]any{"x": "gggggggg-gggg-gggg-gggg-gggggggggggg"}, map[string]any{"x": "uuid"}, true},
+ {"fail_int", map[string]any{"x": 123}, map[string]any{"x": "uuid"}, true},
+ {"fail_nil", map[string]any{"x": nil}, map[string]any{"x": "uuid"}, true},
+ {"fail_extra_dash", map[string]any{"x": "550e8400-e29b-41d4-a716-4466554400-00"}, map[string]any{"x": "uuid"}, true},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+func (s *RulesTestSuite) TestUuid3() {
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ {"pass", map[string]any{"v": "a3bb189e-8bf9-3888-9912-ace4e6543002"}, map[string]any{"v": "uuid3"}, false},
+ {"pass_uppercase", map[string]any{"v": "A3BB189E-8BF9-3888-9912-ACE4E6543002"}, map[string]any{"v": "uuid3"}, false},
+ {"fail_uuid4", map[string]any{"v": "550e8400-e29b-41d4-a716-446655440000"}, map[string]any{"v": "uuid3"}, true},
+ {"fail_uuid5", map[string]any{"v": "886313e1-3b8a-5372-9b90-0c9aee199e5d"}, map[string]any{"v": "uuid3"}, true},
+ {"fail_uuid1", map[string]any{"v": "550e8400-e29b-11d4-a716-446655440000"}, map[string]any{"v": "uuid3"}, true},
+ {"fail_not_uuid", map[string]any{"v": "not-a-uuid"}, map[string]any{"v": "uuid3"}, true},
+ {"fail_int", map[string]any{"v": 123}, map[string]any{"v": "uuid3"}, true},
+ {"fail_nil", map[string]any{"v": nil}, map[string]any{"v": "uuid3"}, true},
+ {"fail_too_short", map[string]any{"v": "a3bb189e-8bf9-3888"}, map[string]any{"v": "uuid3"}, true},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+func (s *RulesTestSuite) TestUuid4() {
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ {"pass", map[string]any{"v": "550e8400-e29b-41d4-a716-446655440000"}, map[string]any{"v": "uuid4"}, false},
+ {"pass_variant_8", map[string]any{"v": "550e8400-e29b-41d4-8716-446655440000"}, map[string]any{"v": "uuid4"}, false},
+ {"pass_variant_9", map[string]any{"v": "550e8400-e29b-41d4-9716-446655440000"}, map[string]any{"v": "uuid4"}, false},
+ {"pass_variant_b", map[string]any{"v": "550e8400-e29b-41d4-b716-446655440000"}, map[string]any{"v": "uuid4"}, false},
+ {"pass_uppercase", map[string]any{"v": "550E8400-E29B-41D4-A716-446655440000"}, map[string]any{"v": "uuid4"}, false},
+ {"fail_uuid3", map[string]any{"v": "a3bb189e-8bf9-3888-9912-ace4e6543002"}, map[string]any{"v": "uuid4"}, true},
+ {"fail_uuid5", map[string]any{"v": "886313e1-3b8a-5372-9b90-0c9aee199e5d"}, map[string]any{"v": "uuid4"}, true},
+ {"fail_uuid1", map[string]any{"v": "550e8400-e29b-11d4-a716-446655440000"}, map[string]any{"v": "uuid4"}, true},
+ {"fail_bad_variant", map[string]any{"v": "550e8400-e29b-41d4-0716-446655440000"}, map[string]any{"v": "uuid4"}, true},
+ {"fail_not_uuid", map[string]any{"v": "not-a-uuid"}, map[string]any{"v": "uuid4"}, true},
+ {"fail_int", map[string]any{"v": 123}, map[string]any{"v": "uuid4"}, true},
+ {"fail_nil", map[string]any{"v": nil}, map[string]any{"v": "uuid4"}, true},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+func (s *RulesTestSuite) TestUuid5() {
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ {"pass", map[string]any{"v": "886313e1-3b8a-5372-9b90-0c9aee199e5d"}, map[string]any{"v": "uuid5"}, false},
+ {"pass_variant_8", map[string]any{"v": "886313e1-3b8a-5372-8b90-0c9aee199e5d"}, map[string]any{"v": "uuid5"}, false},
+ {"pass_variant_b", map[string]any{"v": "886313e1-3b8a-5372-bb90-0c9aee199e5d"}, map[string]any{"v": "uuid5"}, false},
+ {"pass_uppercase", map[string]any{"v": "886313E1-3B8A-5372-9B90-0C9AEE199E5D"}, map[string]any{"v": "uuid5"}, false},
+ {"fail_uuid3", map[string]any{"v": "a3bb189e-8bf9-3888-9912-ace4e6543002"}, map[string]any{"v": "uuid5"}, true},
+ {"fail_uuid4", map[string]any{"v": "550e8400-e29b-41d4-a716-446655440000"}, map[string]any{"v": "uuid5"}, true},
+ {"fail_uuid1", map[string]any{"v": "550e8400-e29b-11d4-a716-446655440000"}, map[string]any{"v": "uuid5"}, true},
+ {"fail_bad_variant", map[string]any{"v": "886313e1-3b8a-5372-0b90-0c9aee199e5d"}, map[string]any{"v": "uuid5"}, true},
+ {"fail_not_uuid", map[string]any{"v": "not-a-uuid"}, map[string]any{"v": "uuid5"}, true},
+ {"fail_int", map[string]any{"v": 123}, map[string]any{"v": "uuid5"}, true},
+ {"fail_nil", map[string]any{"v": nil}, map[string]any{"v": "uuid5"}, true},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+func (s *RulesTestSuite) TestUlid() {
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ {"pass_valid", map[string]any{"x": "01ARZ3NDEKTSV4RRFFQ69G5FAV"}, map[string]any{"x": "ulid"}, false},
+ {"pass_lowercase_ulid", map[string]any{"x": "01arz3ndektsv4rrffq69g5fav"}, map[string]any{"x": "ulid"}, false},
+ {"fail_invalid", map[string]any{"x": "not-a-ulid"}, map[string]any{"x": "ulid"}, true},
+ {"fail_too_short", map[string]any{"x": "01ARZ3NDEK"}, map[string]any{"x": "ulid"}, true},
+ {"fail_too_long", map[string]any{"x": "01ARZ3NDEKTSV4RRFFQ69G5FAVX"}, map[string]any{"x": "ulid"}, true},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+func (s *RulesTestSuite) TestHexColor() {
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ {"pass_6_digit", map[string]any{"x": "#FF5733"}, map[string]any{"x": "hex_color"}, false},
+ {"pass_3_digit", map[string]any{"x": "#FFF"}, map[string]any{"x": "hex_color"}, false},
+ {"pass_8_digit_alpha", map[string]any{"x": "#FF5733AA"}, map[string]any{"x": "hex_color"}, false},
+ {"pass_lowercase_hex", map[string]any{"x": "#ff5733"}, map[string]any{"x": "hex_color"}, false},
+ {"fail_no_hash", map[string]any{"x": "FF5733"}, map[string]any{"x": "hex_color"}, true},
+ {"fail_invalid_chars", map[string]any{"x": "#GGGGGG"}, map[string]any{"x": "hex_color"}, true},
+ {"fail_empty_hash", map[string]any{"x": "#"}, map[string]any{"x": "hex_color"}, true},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+func (s *RulesTestSuite) TestRegex() {
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ {"pass_match", map[string]any{"x": "abc123"}, map[string]any{"x": `regex:^[a-z]+\d+$`}, false},
+ {"pass_simple_digits", map[string]any{"x": "12345"}, map[string]any{"x": `regex:^\d+$`}, false},
+ {"pass_email_pattern", map[string]any{"x": "a@b.c"}, map[string]any{"x": `regex:^.+@.+\..+$`}, false},
+ {"fail_no_match", map[string]any{"x": "ABC"}, map[string]any{"x": `regex:^[a-z]+\d+$`}, true},
+ {"fail_partial_match", map[string]any{"x": "abc"}, map[string]any{"x": `regex:^[a-z]+\d+$`}, true},
+ // Array syntax for regex with pipe
+ {"pass_regex_with_pipe_array", map[string]any{"x": "foo"}, map[string]any{"x": []string{"required", `regex:^(foo|bar)$`}}, false},
+ {"fail_regex_with_pipe_array", map[string]any{"x": "baz"}, map[string]any{"x": []string{"required", `regex:^(foo|bar)$`}}, true},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+func (s *RulesTestSuite) TestNotRegex() {
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ {"pass_no_match", map[string]any{"x": "ABC"}, map[string]any{"x": `not_regex:^[a-z]+$`}, false},
+ {"pass_numbers", map[string]any{"x": "123"}, map[string]any{"x": `not_regex:^[a-z]+$`}, false},
+ {"fail_match", map[string]any{"x": "abc"}, map[string]any{"x": `not_regex:^[a-z]+$`}, true},
+ {"fail_full_match", map[string]any{"x": "hello"}, map[string]any{"x": `not_regex:^hello$`}, true},
+ // Array syntax for not_regex with pipe
+ {"pass_not_regex_pipe_array", map[string]any{"x": "baz"}, map[string]any{"x": []string{"required", `not_regex:^(foo|bar)$`}}, false},
+ {"fail_not_regex_pipe_array", map[string]any{"x": "foo"}, map[string]any{"x": []string{"required", `not_regex:^(foo|bar)$`}}, true},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+func (s *RulesTestSuite) TestLowercase() {
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ {"pass_lowercase", map[string]any{"x": "hello"}, map[string]any{"x": "lowercase"}, false},
+ {"pass_lowercase_with_numbers", map[string]any{"x": "hello123"}, map[string]any{"x": "lowercase"}, false},
+ {"pass_lowercase_with_spaces", map[string]any{"x": "hello world"}, map[string]any{"x": "lowercase"}, false},
+ {"pass_lowercase_with_symbols", map[string]any{"x": "hello@world!"}, map[string]any{"x": "lowercase"}, false},
+ {"fail_uppercase_first", map[string]any{"x": "Hello"}, map[string]any{"x": "lowercase"}, true},
+ {"fail_all_uppercase", map[string]any{"x": "HELLO"}, map[string]any{"x": "lowercase"}, true},
+ {"fail_mixed", map[string]any{"x": "hELLO"}, map[string]any{"x": "lowercase"}, true},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+func (s *RulesTestSuite) TestUppercase() {
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ {"pass_uppercase", map[string]any{"x": "HELLO"}, map[string]any{"x": "uppercase"}, false},
+ {"pass_uppercase_with_numbers", map[string]any{"x": "HELLO123"}, map[string]any{"x": "uppercase"}, false},
+ {"pass_uppercase_with_spaces", map[string]any{"x": "HELLO WORLD"}, map[string]any{"x": "uppercase"}, false},
+ {"pass_uppercase_with_symbols", map[string]any{"x": "HELLO@WORLD!"}, map[string]any{"x": "uppercase"}, false},
+ {"fail_lowercase_first", map[string]any{"x": "hELLO"}, map[string]any{"x": "uppercase"}, true},
+ {"fail_all_lowercase", map[string]any{"x": "hello"}, map[string]any{"x": "uppercase"}, true},
+ {"fail_mixed", map[string]any{"x": "Hello"}, map[string]any{"x": "uppercase"}, true},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+// ===== 8. String Content Rules =====
+
+func (s *RulesTestSuite) TestStartsWith() {
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ {"pass_single", map[string]any{"x": "hello world"}, map[string]any{"x": "starts_with:hello"}, false},
+ {"pass_one_of", map[string]any{"x": "world hello"}, map[string]any{"x": "starts_with:hello,world"}, false},
+ {"pass_exact", map[string]any{"x": "hello"}, map[string]any{"x": "starts_with:hello"}, false},
+ {"fail_empty_prefix_no_params", map[string]any{"x": "anything"}, map[string]any{"x": "starts_with:"}, true},
+ {"fail_none", map[string]any{"x": "goodbye"}, map[string]any{"x": "starts_with:hello,world"}, true},
+ {"fail_case_sensitive", map[string]any{"x": "Hello"}, map[string]any{"x": "starts_with:hello"}, true},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+func (s *RulesTestSuite) TestDoesntStartWith() {
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ {"pass_no_match", map[string]any{"x": "goodbye"}, map[string]any{"x": "doesnt_start_with:hello,world"}, false},
+ {"pass_different_case", map[string]any{"x": "Hello"}, map[string]any{"x": "doesnt_start_with:hello"}, false},
+ {"fail_match_first", map[string]any{"x": "hello world"}, map[string]any{"x": "doesnt_start_with:hello,world"}, true},
+ {"fail_match_second", map[string]any{"x": "world hello"}, map[string]any{"x": "doesnt_start_with:hello,world"}, true},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+func (s *RulesTestSuite) TestEndsWith() {
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ {"pass_single", map[string]any{"x": "hello world"}, map[string]any{"x": "ends_with:world"}, false},
+ {"pass_one_of", map[string]any{"x": "test.jpg"}, map[string]any{"x": "ends_with:.jpg,.png"}, false},
+ {"pass_exact", map[string]any{"x": "world"}, map[string]any{"x": "ends_with:world"}, false},
+ {"fail_none", map[string]any{"x": "test.gif"}, map[string]any{"x": "ends_with:.jpg,.png"}, true},
+ {"fail_case_sensitive", map[string]any{"x": "test.JPG"}, map[string]any{"x": "ends_with:.jpg"}, true},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+func (s *RulesTestSuite) TestDoesntEndWith() {
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ {"pass_no_match", map[string]any{"x": "test.gif"}, map[string]any{"x": "doesnt_end_with:.jpg,.png"}, false},
+ {"pass_different_case", map[string]any{"x": "test.JPG"}, map[string]any{"x": "doesnt_end_with:.jpg"}, false},
+ {"fail_match_first", map[string]any{"x": "test.jpg"}, map[string]any{"x": "doesnt_end_with:.jpg,.png"}, true},
+ {"fail_match_second", map[string]any{"x": "test.png"}, map[string]any{"x": "doesnt_end_with:.jpg,.png"}, true},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+func (s *RulesTestSuite) TestContains() {
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ {"pass_contains", map[string]any{"x": "hello world"}, map[string]any{"x": "contains:world"}, false},
+ {"pass_contains_all", map[string]any{"x": "hello world foo"}, map[string]any{"x": "contains:hello,world"}, false},
+ {"pass_contains_single", map[string]any{"x": "abcdef"}, map[string]any{"x": "contains:cd"}, false},
+ {"fail_missing_one", map[string]any{"x": "hello foo"}, map[string]any{"x": "contains:hello,world"}, true},
+ {"fail_missing_all", map[string]any{"x": "goodbye"}, map[string]any{"x": "contains:hello,world"}, true},
+ {"fail_case_sensitive", map[string]any{"x": "Hello"}, map[string]any{"x": "contains:hello"}, true},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+func (s *RulesTestSuite) TestDoesntContain() {
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ {"pass_no_match", map[string]any{"x": "hello world"}, map[string]any{"x": "doesnt_contain:foo,bar"}, false},
+ {"pass_case_different", map[string]any{"x": "Hello World"}, map[string]any{"x": "doesnt_contain:hello"}, false},
+ {"fail_match_one", map[string]any{"x": "hello world"}, map[string]any{"x": "doesnt_contain:hello,bar"}, true},
+ {"fail_match_multiple", map[string]any{"x": "hello world"}, map[string]any{"x": "doesnt_contain:hello,world"}, true},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+func (s *RulesTestSuite) TestConfirmed() {
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ {"pass_matching", map[string]any{"pw": "secret", "pw_confirmation": "secret"}, map[string]any{"pw": "confirmed"}, false},
+ {"pass_matching_number", map[string]any{"code": 123, "code_confirmation": 123}, map[string]any{"code": "confirmed"}, false},
+ {"fail_mismatch", map[string]any{"pw": "secret", "pw_confirmation": "diff"}, map[string]any{"pw": "confirmed"}, true},
+ {"fail_missing_confirmation", map[string]any{"pw": "secret"}, map[string]any{"pw": "confirmed"}, true},
+ {"fail_empty_confirmation", map[string]any{"pw": "secret", "pw_confirmation": ""}, map[string]any{"pw": "confirmed"}, true},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+// ===== 9. Comparison Rules =====
+
+func (s *RulesTestSuite) TestSame() {
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ {"pass_same_string", map[string]any{"a": "x", "b": "x"}, map[string]any{"a": "same:b"}, false},
+ {"pass_same_int", map[string]any{"a": 42, "b": 42}, map[string]any{"a": "same:b"}, false},
+ {"pass_same_bool", map[string]any{"a": true, "b": true}, map[string]any{"a": "same:b"}, false},
+ {"pass_string_int_same_via_sprintf", map[string]any{"a": "1", "b": 1}, map[string]any{"a": "same:b"}, false},
+ {"fail_different_value", map[string]any{"a": "x", "b": "y"}, map[string]any{"a": "same:b"}, true},
+ {"fail_missing_other", map[string]any{"a": "x"}, map[string]any{"a": "same:b"}, true},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+func (s *RulesTestSuite) TestDifferent() {
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ {"pass_different_value", map[string]any{"a": "x", "b": "y"}, map[string]any{"a": "different:b"}, false},
+ {"pass_missing_other", map[string]any{"a": "x"}, map[string]any{"a": "different:b"}, false},
+ {"fail_string_int_same_via_sprintf", map[string]any{"a": "1", "b": 1}, map[string]any{"a": "different:b"}, true},
+ {"fail_same_string", map[string]any{"a": "x", "b": "x"}, map[string]any{"a": "different:b"}, true},
+ {"fail_same_int", map[string]any{"a": 42, "b": 42}, map[string]any{"a": "different:b"}, true},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+func (s *RulesTestSuite) TestEq() {
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ {"pass_string", map[string]any{"v": "hello"}, map[string]any{"v": "eq:hello"}, false},
+ {"pass_int", map[string]any{"v": 42}, map[string]any{"v": "eq:42"}, false},
+ {"pass_float", map[string]any{"v": 3.14}, map[string]any{"v": "eq:3.14"}, false},
+ {"pass_bool_true", map[string]any{"v": true}, map[string]any{"v": "eq:true"}, false},
+ {"pass_bool_false", map[string]any{"v": false}, map[string]any{"v": "eq:false"}, false},
+ {"pass_zero", map[string]any{"v": 0}, map[string]any{"v": "eq:0"}, false},
+ {"pass_empty_string", map[string]any{"v": ""}, map[string]any{"v": "eq:"}, false},
+ {"fail_different_string", map[string]any{"v": "hello"}, map[string]any{"v": "eq:world"}, true},
+ {"fail_different_int", map[string]any{"v": 42}, map[string]any{"v": "eq:43"}, true},
+ {"fail_type_mismatch", map[string]any{"v": 42}, map[string]any{"v": "eq:hello"}, true},
+ {"fail_case_sensitive", map[string]any{"v": "Hello"}, map[string]any{"v": "eq:hello"}, true},
+ {"fail_no_params", map[string]any{"v": "hello"}, map[string]any{"v": "eq"}, true},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+func (s *RulesTestSuite) TestNe() {
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ {"pass_different_string", map[string]any{"v": "hello"}, map[string]any{"v": "ne:world"}, false},
+ {"pass_different_int", map[string]any{"v": 42}, map[string]any{"v": "ne:43"}, false},
+ {"pass_case_sensitive", map[string]any{"v": "Hello"}, map[string]any{"v": "ne:hello"}, false},
+ {"pass_type_mismatch", map[string]any{"v": 42}, map[string]any{"v": "ne:hello"}, false},
+ {"fail_same_string", map[string]any{"v": "hello"}, map[string]any{"v": "ne:hello"}, true},
+ {"fail_same_int", map[string]any{"v": 42}, map[string]any{"v": "ne:42"}, true},
+ {"fail_same_zero", map[string]any{"v": 0}, map[string]any{"v": "ne:0"}, true},
+ {"fail_same_bool", map[string]any{"v": true}, map[string]any{"v": "ne:true"}, true},
+ {"fail_no_params", map[string]any{"v": "hello"}, map[string]any{"v": "ne"}, true},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+func (s *RulesTestSuite) TestIn() {
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ {"pass_in_list", map[string]any{"x": "a"}, map[string]any{"x": "in:a,b,c"}, false},
+ {"pass_last_in_list", map[string]any{"x": "c"}, map[string]any{"x": "in:a,b,c"}, false},
+ {"pass_int_as_string", map[string]any{"x": 1}, map[string]any{"x": "in:1,2,3"}, false},
+ {"pass_single_value", map[string]any{"x": "yes"}, map[string]any{"x": "in:yes"}, false},
+ {"pass_empty_skipped", map[string]any{"x": ""}, map[string]any{"x": "in:a,b,c"}, false},
+ {"pass_numeric_string_in_list", map[string]any{"status": "1"}, map[string]any{"status": "in:1,2,3"}, false},
+ {"pass_integer_in_string_list", map[string]any{"status": 1}, map[string]any{"status": "in:1,2,3"}, false},
+ {"pass_single_only", map[string]any{"x": "only"}, map[string]any{"x": "in:only"}, false},
+ {"fail_not_in_list", map[string]any{"x": "d"}, map[string]any{"x": "in:a,b,c"}, true},
+ {"fail_case_sensitive", map[string]any{"x": "A"}, map[string]any{"x": "in:a,b,c"}, true},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+func (s *RulesTestSuite) TestNotIn() {
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ {"pass_not_in_list", map[string]any{"x": "d"}, map[string]any{"x": "not_in:a,b,c"}, false},
+ {"pass_case_different", map[string]any{"x": "A"}, map[string]any{"x": "not_in:a,b,c"}, false},
+ {"pass_empty", map[string]any{"x": ""}, map[string]any{"x": "not_in:a,b,c"}, false},
+ {"fail_in_list", map[string]any{"x": "a"}, map[string]any{"x": "not_in:a,b,c"}, true},
+ {"fail_last_in_list", map[string]any{"x": "c"}, map[string]any{"x": "not_in:a,b,c"}, true},
+ {"pass_pending_not_banned", map[string]any{"status": "pending"}, map[string]any{"status": "not_in:banned,deleted"}, false},
+ {"fail_integer_match", map[string]any{"id": 0}, map[string]any{"id": "not_in:0,999"}, true},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+func (s *RulesTestSuite) TestInArray() {
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ {"pass_in_array", map[string]any{"x": "a", "arr": []any{"a", "b", "c"}}, map[string]any{"x": "in_array:arr"}, false},
+ {"fail_not_in_array", map[string]any{"x": "d", "arr": []any{"a", "b", "c"}}, map[string]any{"x": "in_array:arr"}, true},
+ {"fail_array_missing", map[string]any{"x": "a"}, map[string]any{"x": "in_array:arr"}, true},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+func (s *RulesTestSuite) TestInArrayKeys() {
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ {"pass_has_key", map[string]any{"x": map[string]any{"a": 1, "b": 2}}, map[string]any{"x": "in_array_keys:a,c"}, false},
+ {"fail_no_key", map[string]any{"x": map[string]any{"d": 1}}, map[string]any{"x": "in_array_keys:a,b"}, true},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+// ===== 10. Date Rules =====
+
+func (s *RulesTestSuite) TestDate() {
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ {"pass_date_only", map[string]any{"x": "2024-01-15"}, map[string]any{"x": "date"}, false},
+ {"pass_datetime_space", map[string]any{"x": "2024-01-15 10:30:00"}, map[string]any{"x": "date"}, false},
+ {"pass_rfc3339", map[string]any{"x": "2024-01-15T10:30:00Z"}, map[string]any{"x": "date"}, false},
+ {"pass_rfc3339_offset", map[string]any{"x": "2024-01-15T10:30:00+08:00"}, map[string]any{"x": "date"}, false},
+ {"pass_datetime_t", map[string]any{"x": "2024-01-15T10:30:00"}, map[string]any{"x": "date"}, false},
+ {"pass_empty_skipped", map[string]any{"x": ""}, map[string]any{"x": "date"}, false},
+ {"fail_invalid", map[string]any{"x": "not-a-date"}, map[string]any{"x": "date"}, true},
+ {"fail_numbers_only", map[string]any{"x": "20240115"}, map[string]any{"x": "date"}, true},
+ {"fail_partial_date", map[string]any{"x": "2024-01"}, map[string]any{"x": "date"}, true},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+func (s *RulesTestSuite) TestDateFormat() {
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ {"pass_date_format", map[string]any{"x": "2024-01-15"}, map[string]any{"x": "date_format:2006-01-02"}, false},
+ {"pass_datetime_format", map[string]any{"x": "2024-01-15 10:30:00"}, map[string]any{"x": "date_format:2006-01-02 15:04:05"}, false},
+ {"pass_custom_format", map[string]any{"x": "15/01/2024"}, map[string]any{"x": "date_format:02/01/2006"}, false},
+ {"pass_time_only", map[string]any{"x": "10:30:00"}, map[string]any{"x": "date_format:15:04:05"}, false},
+ {"fail_wrong_format", map[string]any{"x": "15/01/2024"}, map[string]any{"x": "date_format:2006-01-02"}, true},
+ {"fail_invalid_date", map[string]any{"x": "not-a-date"}, map[string]any{"x": "date_format:2006-01-02"}, true},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+func (s *RulesTestSuite) TestDateEquals() {
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ {"pass_equal", map[string]any{"x": "2024-01-15"}, map[string]any{"x": "date_equals:2024-01-15"}, false},
+ {"pass_equal_datetime", map[string]any{"x": "2024-01-15T10:30:00Z"}, map[string]any{"x": "date_equals:2024-01-15T10:30:00Z"}, false},
+ {"fail_not_equal", map[string]any{"x": "2024-01-16"}, map[string]any{"x": "date_equals:2024-01-15"}, true},
+ {"fail_invalid_date", map[string]any{"x": "not-a-date"}, map[string]any{"x": "date_equals:2024-01-15"}, true},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+func (s *RulesTestSuite) TestBefore() {
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ {"pass_before", map[string]any{"x": "2024-01-14"}, map[string]any{"x": "before:2024-01-15"}, false},
+ {"fail_after", map[string]any{"x": "2024-01-16"}, map[string]any{"x": "before:2024-01-15"}, true},
+ {"fail_equal", map[string]any{"x": "2024-01-15"}, map[string]any{"x": "before:2024-01-15"}, true},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+func (s *RulesTestSuite) TestBeforeOrEqual() {
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ {"pass_before", map[string]any{"x": "2024-01-14"}, map[string]any{"x": "before_or_equal:2024-01-15"}, false},
+ {"pass_equal", map[string]any{"x": "2024-01-15"}, map[string]any{"x": "before_or_equal:2024-01-15"}, false},
+ {"fail_after", map[string]any{"x": "2024-01-16"}, map[string]any{"x": "before_or_equal:2024-01-15"}, true},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+func (s *RulesTestSuite) TestAfter() {
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ {"pass_after", map[string]any{"x": "2024-01-16"}, map[string]any{"x": "after:2024-01-15"}, false},
+ {"fail_before", map[string]any{"x": "2024-01-14"}, map[string]any{"x": "after:2024-01-15"}, true},
+ {"fail_equal", map[string]any{"x": "2024-01-15"}, map[string]any{"x": "after:2024-01-15"}, true},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+func (s *RulesTestSuite) TestAfterOrEqual() {
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ {"pass_after", map[string]any{"x": "2024-01-16"}, map[string]any{"x": "after_or_equal:2024-01-15"}, false},
+ {"pass_equal", map[string]any{"x": "2024-01-15"}, map[string]any{"x": "after_or_equal:2024-01-15"}, false},
+ {"fail_before", map[string]any{"x": "2024-01-14"}, map[string]any{"x": "after_or_equal:2024-01-15"}, true},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+func (s *RulesTestSuite) TestTimezone() {
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ {"pass_utc", map[string]any{"x": "UTC"}, map[string]any{"x": "timezone"}, false},
+ {"pass_named", map[string]any{"x": "America/New_York"}, map[string]any{"x": "timezone"}, false},
+ {"pass_asia", map[string]any{"x": "Asia/Shanghai"}, map[string]any{"x": "timezone"}, false},
+ {"pass_europe", map[string]any{"x": "Europe/London"}, map[string]any{"x": "timezone"}, false},
+ {"pass_local", map[string]any{"x": "Local"}, map[string]any{"x": "timezone"}, false},
+ {"pass_empty_skipped", map[string]any{"x": ""}, map[string]any{"x": "timezone"}, false},
+ {"pass_abbreviation_est", map[string]any{"x": "EST"}, map[string]any{"x": "timezone"}, false},
+ {"fail_invalid", map[string]any{"x": "Not/A/Timezone"}, map[string]any{"x": "timezone"}, true},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+func (s *RulesTestSuite) TestDateWithFieldReference() {
+ s.Run("before_field_reference", func() {
+ v := s.makeValidator(
+ map[string]any{"start": "2024-01-10", "end": "2024-01-15"},
+ map[string]any{"start": "before:end"},
+ )
+ s.False(v.Fails())
+ })
+
+ s.Run("after_field_reference", func() {
+ v := s.makeValidator(
+ map[string]any{"start": "2024-01-10", "end": "2024-01-15"},
+ map[string]any{"end": "after:start"},
+ )
+ s.False(v.Fails())
+ })
+}
+
+// ===== 11. Exclude Rules =====
+
+func (s *RulesTestSuite) TestExclude() {
+ s.Run("excluded_from_validated", func() {
+ v := s.makeValidator(
+ map[string]any{"name": "go", "secret": "hidden"},
+ map[string]any{"name": "required", "secret": "exclude"},
+ )
+ s.False(v.Fails())
+ _, exists := v.Validated()["secret"]
+ s.False(exists)
+ s.Equal("go", v.Validated()["name"])
+ })
+}
+
+func (s *RulesTestSuite) TestExcludeIf() {
+ s.Run("excluded_when_condition_met", func() {
+ v := s.makeValidator(
+ map[string]any{"type": "free", "cc": "1234"},
+ map[string]any{"type": "required", "cc": "exclude_if:type,free"},
+ )
+ s.False(v.Fails())
+ _, exists := v.Validated()["cc"]
+ s.False(exists)
+ })
+
+ s.Run("not_excluded_when_condition_not_met", func() {
+ v := s.makeValidator(
+ map[string]any{"type": "paid", "cc": "1234"},
+ map[string]any{"type": "required", "cc": "exclude_if:type,free"},
+ )
+ s.False(v.Fails())
+ s.Equal("1234", v.Validated()["cc"])
+ })
+}
+
+func (s *RulesTestSuite) TestExcludeUnless() {
+ s.Run("excluded_when_condition_not_met", func() {
+ v := s.makeValidator(
+ map[string]any{"role": "user", "admin_note": "note"},
+ map[string]any{"role": "required", "admin_note": "exclude_unless:role,admin"},
+ )
+ s.False(v.Fails())
+ _, exists := v.Validated()["admin_note"]
+ s.False(exists)
+ })
+
+ s.Run("not_excluded_when_condition_met", func() {
+ v := s.makeValidator(
+ map[string]any{"role": "admin", "admin_note": "note"},
+ map[string]any{"role": "required", "admin_note": "exclude_unless:role,admin"},
+ )
+ s.False(v.Fails())
+ s.Equal("note", v.Validated()["admin_note"])
+ })
+}
+
+func (s *RulesTestSuite) TestExcludeWith() {
+ s.Run("excluded_when_other_present", func() {
+ v := s.makeValidator(
+ map[string]any{"email": "a@b.com", "phone": "123"},
+ map[string]any{"email": "required", "phone": "exclude_with:email"},
+ )
+ s.False(v.Fails())
+ _, exists := v.Validated()["phone"]
+ s.False(exists)
+ })
+}
+
+func (s *RulesTestSuite) TestExcludeWithout() {
+ s.Run("excluded_when_other_absent", func() {
+ v := s.makeValidator(
+ map[string]any{"phone": "123"},
+ map[string]any{"phone": "required", "note": "exclude_without:email"},
+ )
+ s.False(v.Fails())
+ })
+}
+
+// ===== 12. File Rules =====
+
+func (s *RulesTestSuite) TestFile() {
+ fh := &multipart.FileHeader{Filename: "test.txt", Size: 100}
+ fh2 := &multipart.FileHeader{Filename: "test2.txt", Size: 200}
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ {"pass_file", map[string]any{"x": fh}, map[string]any{"x": "file"}, false},
+ {"pass_multiple_files", map[string]any{"x": []*multipart.FileHeader{fh, fh2}}, map[string]any{"x": "file"}, false},
+ {"fail_string", map[string]any{"x": "not-a-file"}, map[string]any{"x": "file"}, true},
+ {"fail_empty_slice", map[string]any{"x": []*multipart.FileHeader{}}, map[string]any{"x": "file"}, true},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+func (s *RulesTestSuite) TestImage() {
+ // JPEG: minimal valid JFIF
+ jpegData := []byte{0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00}
+ // PNG: minimal valid PNG header
+ pngData := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}
+ // PDF: minimal PDF header
+ pdfData := []byte{0x25, 0x50, 0x44, 0x46, 0x2D}
+
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ {"pass_jpeg", map[string]any{"x": makeFileHeader(s.T(), "img.jpg", jpegData)}, map[string]any{"x": "image"}, false},
+ {"pass_png", map[string]any{"x": makeFileHeader(s.T(), "img.png", pngData)}, map[string]any{"x": "image"}, false},
+ {"pass_multiple_images", map[string]any{"x": []*multipart.FileHeader{
+ makeFileHeader(s.T(), "a.jpg", jpegData),
+ makeFileHeader(s.T(), "b.png", pngData),
+ }}, map[string]any{"x": "image"}, false},
+ {"fail_pdf", map[string]any{"x": makeFileHeader(s.T(), "doc.pdf", pdfData)}, map[string]any{"x": "image"}, true},
+ {"fail_multiple_one_not_image", map[string]any{"x": []*multipart.FileHeader{
+ makeFileHeader(s.T(), "a.jpg", jpegData),
+ makeFileHeader(s.T(), "doc.pdf", pdfData),
+ }}, map[string]any{"x": "image"}, true},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+func (s *RulesTestSuite) TestMimes() {
+ pngData := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}
+ gifData := []byte{0x47, 0x49, 0x46, 0x38, 0x39, 0x61}
+ jpegData := []byte{0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00}
+
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ {"pass_png", map[string]any{"x": makeFileHeader(s.T(), "photo.png", pngData)}, map[string]any{"x": "mimes:png,jpg"}, false},
+ {"pass_multiple_pngs", map[string]any{"x": []*multipart.FileHeader{
+ makeFileHeader(s.T(), "a.png", pngData),
+ makeFileHeader(s.T(), "b.png", pngData),
+ }}, map[string]any{"x": "mimes:png"}, false},
+ {"fail_gif", map[string]any{"x": makeFileHeader(s.T(), "photo.gif", gifData)}, map[string]any{"x": "mimes:jpg,png"}, true},
+ {"fail_multiple_one_mismatch", map[string]any{"x": []*multipart.FileHeader{
+ makeFileHeader(s.T(), "a.png", pngData),
+ makeFileHeader(s.T(), "b.jpg", jpegData),
+ }}, map[string]any{"x": "mimes:png"}, true},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+func (s *RulesTestSuite) TestMimetypes() {
+ pngData := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}
+ pdfData := []byte{0x25, 0x50, 0x44, 0x46, 0x2D}
+ jpegData := []byte{0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00}
+
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ {"pass_match", map[string]any{"x": makeFileHeader(s.T(), "img.png", pngData)}, map[string]any{"x": "mimetypes:image/png,image/jpeg"}, false},
+ {"pass_multiple_match", map[string]any{"x": []*multipart.FileHeader{
+ makeFileHeader(s.T(), "a.png", pngData),
+ makeFileHeader(s.T(), "b.jpg", jpegData),
+ }}, map[string]any{"x": "mimetypes:image/png,image/jpeg"}, false},
+ {"fail_no_match", map[string]any{"x": makeFileHeader(s.T(), "doc.pdf", pdfData)}, map[string]any{"x": "mimetypes:image/png,image/jpeg"}, true},
+ {"fail_multiple_one_mismatch", map[string]any{"x": []*multipart.FileHeader{
+ makeFileHeader(s.T(), "a.png", pngData),
+ makeFileHeader(s.T(), "doc.pdf", pdfData),
+ }}, map[string]any{"x": "mimetypes:image/png,image/jpeg"}, true},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+func (s *RulesTestSuite) TestExtensions() {
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ {"pass_match", map[string]any{"x": &multipart.FileHeader{Filename: "doc.pdf"}}, map[string]any{"x": "extensions:pdf,docx"}, false},
+ {"pass_multiple_match", map[string]any{"x": []*multipart.FileHeader{
+ {Filename: "a.pdf"},
+ {Filename: "b.docx"},
+ }}, map[string]any{"x": "extensions:pdf,docx"}, false},
+ {"fail_no_match", map[string]any{"x": &multipart.FileHeader{Filename: "doc.txt"}}, map[string]any{"x": "extensions:pdf,docx"}, true},
+ {"fail_multiple_one_mismatch", map[string]any{"x": []*multipart.FileHeader{
+ {Filename: "a.pdf"},
+ {Filename: "b.txt"},
+ }}, map[string]any{"x": "extensions:pdf,docx"}, true},
+ {"fail_empty_slice", map[string]any{"x": []*multipart.FileHeader{}}, map[string]any{"x": "extensions:pdf"}, true},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+// ===== 13. Control Rules =====
+
+func (s *RulesTestSuite) TestBail() {
+ s.Run("stops_on_first_error", func() {
+ v := s.makeValidator(
+ map[string]any{"email": ""},
+ map[string]any{"email": "bail|required|email"},
+ )
+ s.True(v.Fails())
+ errs := v.Errors().Get("email")
+ s.Len(errs, 1)
+ })
+
+ s.Run("reports_all_errors_without_bail", func() {
+ v := s.makeValidator(
+ map[string]any{"x": ""},
+ map[string]any{"x": "required|email"},
+ )
+ s.True(v.Fails())
+ // Without bail, required is an implicit rule failure that triggers
+ errs := v.Errors().Get("x")
+ s.GreaterOrEqual(len(errs), 1)
+ })
+
+ s.Run("passes_when_valid_with_bail", func() {
+ v := s.makeValidator(
+ map[string]any{"email": "user@example.com"},
+ map[string]any{"email": "bail|required|email"},
+ )
+ s.False(v.Fails())
+ })
+}
+
+func (s *RulesTestSuite) TestNullable() {
+ s.Run("allows_nil", func() {
+ v := s.makeValidator(
+ map[string]any{"name": "go"},
+ map[string]any{"name": "required", "email": "nullable|email"},
+ )
+ s.False(v.Fails())
+ })
+
+ s.Run("validates_when_present", func() {
+ v := s.makeValidator(
+ map[string]any{"email": "bad"},
+ map[string]any{"email": "nullable|email"},
+ )
+ s.True(v.Fails())
+ })
+
+ s.Run("allows_nil_value", func() {
+ v := s.makeValidator(
+ map[string]any{"name": nil},
+ map[string]any{"name": "nullable|string"},
+ )
+ s.False(v.Fails())
+ })
+
+ s.Run("passes_with_valid_value", func() {
+ v := s.makeValidator(
+ map[string]any{"name": "hello"},
+ map[string]any{"name": "nullable|string|min:3"},
+ )
+ s.False(v.Fails())
+ })
+}
+
+func (s *RulesTestSuite) TestSometimes() {
+ s.Run("skips_when_absent", func() {
+ v := s.makeValidator(
+ map[string]any{"name": "go"},
+ map[string]any{"name": "required", "email": "sometimes|required|email"},
+ )
+ s.False(v.Fails())
+ })
+
+ s.Run("validates_when_present", func() {
+ v := s.makeValidator(
+ map[string]any{"name": "go", "email": "bad"},
+ map[string]any{"name": "required", "email": "sometimes|required|email"},
+ )
+ s.True(v.Fails())
+ })
+
+ s.Run("passes_when_present_and_valid", func() {
+ v := s.makeValidator(
+ map[string]any{"name": "go", "email": "user@example.com"},
+ map[string]any{"name": "required", "email": "sometimes|required|email"},
+ )
+ s.False(v.Fails())
+ })
+}
+
+// ===== 14. Other Rules =====
+
+func (s *RulesTestSuite) TestRequiredArrayKeys() {
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ {"pass_all_keys", map[string]any{"x": map[string]any{"a": 1, "b": 2}}, map[string]any{"x": "required_array_keys:a,b"}, false},
+ {"fail_missing_key", map[string]any{"x": map[string]any{"a": 1}}, map[string]any{"x": "required_array_keys:a,b"}, true},
+ {"fail_not_map", map[string]any{"x": "hello"}, map[string]any{"x": "required_array_keys:a"}, true},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+func (s *RulesTestSuite) TestEncoding() {
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ {"pass_utf8", map[string]any{"x": "hello"}, map[string]any{"x": "encoding:utf-8"}, false},
+ {"pass_ascii_encoding", map[string]any{"x": "hello"}, map[string]any{"x": "encoding:ascii"}, false},
+ {"fail_non_ascii", map[string]any{"x": "héllo"}, map[string]any{"x": "encoding:ascii"}, true},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+// ===== 15. Engine Feature Tests =====
+
+func (s *RulesTestSuite) TestWildcardExpansion() {
+ s.Run("validates_all_items", func() {
+ v := s.makeValidator(
+ map[string]any{"items": []any{
+ map[string]any{"name": "a"},
+ map[string]any{"name": ""},
+ }},
+ map[string]any{"items.*.name": "required"},
+ )
+ s.True(v.Fails())
+ })
+
+ s.Run("all_valid", func() {
+ v := s.makeValidator(
+ map[string]any{"items": []any{
+ map[string]any{"name": "a"},
+ map[string]any{"name": "b"},
+ }},
+ map[string]any{"items.*.name": "required|string"},
+ )
+ s.False(v.Fails())
+ })
+}
+
+func (s *RulesTestSuite) TestNestedDotNotation() {
+ s.Run("nested_access", func() {
+ v := s.makeValidator(
+ map[string]any{"user": map[string]any{"profile": map[string]any{"name": "Go"}}},
+ map[string]any{"user.profile.name": "required|string|min:2"},
+ )
+ s.False(v.Fails())
+ })
+
+ s.Run("nested_missing", func() {
+ v := s.makeValidator(
+ map[string]any{"user": map[string]any{"profile": map[string]any{}}},
+ map[string]any{"user.profile.name": "required"},
+ )
+ s.True(v.Fails())
+ })
+}
+
+func (s *RulesTestSuite) TestCustomMessages() {
+ s.Run("field_rule_message", func() {
+ v := s.makeValidator(
+ map[string]any{"name": ""},
+ map[string]any{"name": "required"},
+ Messages(map[string]string{"name.required": "Name is mandatory."}),
+ )
+ s.True(v.Fails())
+ s.Equal("Name is mandatory.", v.Errors().One("name"))
+ })
+}
+
+func (s *RulesTestSuite) TestCustomAttributes() {
+ s.Run("attribute_replacement", func() {
+ v := s.makeValidator(
+ map[string]any{"email_address": ""},
+ map[string]any{"email_address": "required"},
+ Attributes(map[string]string{"email_address": "Email"}),
+ )
+ s.True(v.Fails())
+ s.Equal("The Email field is required.", v.Errors().One("email_address"))
+ })
+}
+
+func (s *RulesTestSuite) TestValidatedOnlyReturnsRuledFields() {
+ s.Run("excludes_extra_fields", func() {
+ v := s.makeValidator(
+ map[string]any{"name": "go", "email": "a@b.com", "extra": "x"},
+ map[string]any{"name": "required", "email": "required|email"},
+ )
+ s.False(v.Fails())
+ validated := v.Validated()
+ s.Equal("go", validated["name"])
+ s.Equal("a@b.com", validated["email"])
+ _, exists := validated["extra"]
+ s.False(exists)
+ })
+}
+
+func (s *RulesTestSuite) TestMultiRuleCombination() {
+ s.Run("all_pass", func() {
+ v := s.makeValidator(
+ map[string]any{"name": "goravel"},
+ map[string]any{"name": "required|string|min:3|max:50"},
+ )
+ s.False(v.Fails())
+ })
+
+ s.Run("min_fails", func() {
+ v := s.makeValidator(
+ map[string]any{"name": "go"},
+ map[string]any{"name": "required|string|min:3|max:50"},
+ )
+ s.True(v.Fails())
+ errs := v.Errors().Get("name")
+ s.Contains(errs, "min")
+ })
+
+ s.Run("required_string_email_min", func() {
+ v := s.makeValidator(
+ map[string]any{"email": "a@b.com"},
+ map[string]any{"email": "required|string|email|min:5"},
+ )
+ s.False(v.Fails())
+ })
+
+ s.Run("required_string_email_min_fail_too_short", func() {
+ v := s.makeValidator(
+ map[string]any{"email": "a@b"},
+ map[string]any{"email": "required|string|email|min:5"},
+ )
+ s.True(v.Fails())
+ })
+
+ s.Run("required_integer_between", func() {
+ v := s.makeValidator(
+ map[string]any{"age": 25},
+ map[string]any{"age": "required|integer|between:18,100"},
+ )
+ s.False(v.Fails())
+ })
+
+ s.Run("required_integer_between_fail_too_young", func() {
+ v := s.makeValidator(
+ map[string]any{"age": 10},
+ map[string]any{"age": "required|integer|between:18,100"},
+ )
+ s.True(v.Fails())
+ })
+
+ s.Run("nullable_string_email", func() {
+ v := s.makeValidator(
+ map[string]any{"email": nil},
+ map[string]any{"email": "nullable|string|email"},
+ )
+ s.False(v.Fails())
+ })
+
+ s.Run("sometimes_required_string", func() {
+ v := s.makeValidator(
+ map[string]any{},
+ map[string]any{"nickname": "sometimes|required|string"},
+ )
+ s.False(v.Fails())
+ })
+
+ s.Run("sometimes_required_string_present_empty", func() {
+ v := s.makeValidator(
+ map[string]any{"nickname": ""},
+ map[string]any{"nickname": "sometimes|required|string"},
+ )
+ s.True(v.Fails())
+ })
+
+ s.Run("array_min_max_combination", func() {
+ v := s.makeValidator(
+ map[string]any{"items": []any{1, 2, 3}},
+ map[string]any{"items": "required|array|min:1|max:5"},
+ )
+ s.False(v.Fails())
+ })
+
+ s.Run("string_alpha_dash_max", func() {
+ v := s.makeValidator(
+ map[string]any{"slug": "my-post_123"},
+ map[string]any{"slug": "required|string|alpha_dash|max:50"},
+ )
+ s.False(v.Fails())
+ })
+
+ s.Run("numeric_multiple_of_between", func() {
+ v := s.makeValidator(
+ map[string]any{"qty": 15},
+ map[string]any{"qty": "required|numeric|multiple_of:5|between:5,50"},
+ )
+ s.False(v.Fails())
+ })
+
+ s.Run("multiple_fields_all_pass", func() {
+ v := s.makeValidator(
+ map[string]any{
+ "name": "goravel",
+ "email": "go@example.com",
+ "age": 25,
+ "role": "admin",
+ },
+ map[string]any{
+ "name": "required|string|min:3|max:50",
+ "email": "required|email",
+ "age": "required|integer|between:18,100",
+ "role": "required|in:admin,user,guest",
+ },
+ )
+ s.False(v.Fails())
+ })
+
+ s.Run("multiple_fields_some_fail", func() {
+ v := s.makeValidator(
+ map[string]any{
+ "name": "go",
+ "email": "bad",
+ "age": 200,
+ "role": "superadmin",
+ },
+ map[string]any{
+ "name": "required|string|min:3|max:50",
+ "email": "required|email",
+ "age": "required|integer|between:18,100",
+ "role": "required|in:admin,user,guest",
+ },
+ )
+ s.True(v.Fails())
+ errs := v.Errors().All()
+ s.Contains(errs, "name")
+ s.Contains(errs, "email")
+ s.Contains(errs, "age")
+ s.Contains(errs, "role")
+ })
+}
+
+func (s *RulesTestSuite) TestDistinct() {
+ s.Run("pass_distinct_values", func() {
+ v := s.makeValidator(
+ map[string]any{"items": []any{"a", "b", "c"}},
+ map[string]any{"items.*": "distinct"},
+ )
+ s.False(v.Fails())
+ })
+
+ s.Run("fail_duplicate_values", func() {
+ v := s.makeValidator(
+ map[string]any{"items": []any{"a", "b", "a"}},
+ map[string]any{"items.*": "distinct"},
+ )
+ s.True(v.Fails())
+ })
+}
+
+// ===== Error Message Tests =====
+
+func (s *RulesTestSuite) TestErrorMessages() {
+ s.Run("required_message", func() {
+ v := s.makeValidator(
+ map[string]any{"name": ""},
+ map[string]any{"name": "required"},
+ )
+ s.True(v.Fails())
+ s.Equal("The name field is required.", v.Errors().One("name"))
+ })
+
+ s.Run("min_string_message", func() {
+ v := s.makeValidator(
+ map[string]any{"name": "ab"},
+ map[string]any{"name": "string|min:3"},
+ )
+ s.True(v.Fails())
+ s.Equal("The name field must be at least 3 characters.", v.Errors().One("name"))
+ })
+
+ s.Run("max_numeric_message", func() {
+ v := s.makeValidator(
+ map[string]any{"age": 200},
+ map[string]any{"age": "numeric|max:150"},
+ )
+ s.True(v.Fails())
+ s.Equal("The age field must not be greater than 150.", v.Errors().One("age"))
+ })
+
+ s.Run("between_string_message", func() {
+ v := s.makeValidator(
+ map[string]any{"x": "ab"},
+ map[string]any{"x": "string|between:3,5"},
+ )
+ s.True(v.Fails())
+ s.Equal("The x field must be between 3 and 5 characters.", v.Errors().One("x"))
+ })
+
+ s.Run("in_message", func() {
+ v := s.makeValidator(
+ map[string]any{"status": "bad"},
+ map[string]any{"status": "in:active,inactive"},
+ )
+ s.True(v.Fails())
+ s.Equal("The selected status is invalid.", v.Errors().One("status"))
+ })
+
+ s.Run("email_message", func() {
+ v := s.makeValidator(
+ map[string]any{"email": "bad"},
+ map[string]any{"email": "email"},
+ )
+ s.True(v.Fails())
+ s.Equal("The email field must be a valid email address.", v.Errors().One("email"))
+ })
+
+ s.Run("confirmed_message", func() {
+ v := s.makeValidator(
+ map[string]any{"pw": "a", "pw_confirmation": "b"},
+ map[string]any{"pw": "confirmed"},
+ )
+ s.True(v.Fails())
+ s.Equal("The pw field confirmation does not match.", v.Errors().One("pw"))
+ })
+
+ s.Run("same_message", func() {
+ v := s.makeValidator(
+ map[string]any{"a": "x", "b": "y"},
+ map[string]any{"a": "same:b"},
+ )
+ s.True(v.Fails())
+ s.Equal("The a field must match b.", v.Errors().One("a"))
+ })
+
+ s.Run("size_array_message", func() {
+ v := s.makeValidator(
+ map[string]any{"items": []any{1, 2}},
+ map[string]any{"items": "array|size:3"},
+ )
+ s.True(v.Fails())
+ s.Equal("The items field must contain 3 items.", v.Errors().One("items"))
+ })
+
+ s.Run("uuid_message", func() {
+ v := s.makeValidator(
+ map[string]any{"id": "bad"},
+ map[string]any{"id": "uuid"},
+ )
+ s.True(v.Fails())
+ s.Equal("The id field must be a valid UUID.", v.Errors().One("id"))
+ })
+
+ s.Run("underscore_to_space_in_attribute", func() {
+ v := s.makeValidator(
+ map[string]any{"first_name": ""},
+ map[string]any{"first_name": "required"},
+ )
+ s.True(v.Fails())
+ s.Equal("The first name field is required.", v.Errors().One("first_name"))
+ })
+
+ s.Run("custom_message_for_specific_field_rule", func() {
+ v := s.makeValidator(
+ map[string]any{"email": "bad"},
+ map[string]any{"email": "email"},
+ Messages(map[string]string{"email.email": "Please enter a valid email"}),
+ )
+ s.True(v.Fails())
+ s.Equal("Please enter a valid email", v.Errors().One("email"))
+ })
+
+ s.Run("custom_message_for_rule_only", func() {
+ v := s.makeValidator(
+ map[string]any{"email": "bad"},
+ map[string]any{"email": "email"},
+ Messages(map[string]string{"email": "Invalid email format"}),
+ )
+ s.True(v.Fails())
+ s.Equal("Invalid email format", v.Errors().One("email"))
+ })
+
+ s.Run("custom_attribute_name", func() {
+ v := s.makeValidator(
+ map[string]any{"email_addr": ""},
+ map[string]any{"email_addr": "required"},
+ Attributes(map[string]string{"email_addr": "email address"}),
+ )
+ s.True(v.Fails())
+ s.Equal("The email address field is required.", v.Errors().One("email_addr"))
+ })
+
+ s.Run("custom_message_with_attribute_placeholder", func() {
+ v := s.makeValidator(
+ map[string]any{"user_name": ""},
+ map[string]any{"user_name": "required"},
+ Messages(map[string]string{"user_name.required": ":attribute is mandatory"}),
+ Attributes(map[string]string{"user_name": "Username"}),
+ )
+ s.True(v.Fails())
+ s.Equal("Username is mandatory", v.Errors().One("user_name"))
+ })
+
+ s.Run("min_numeric_message", func() {
+ v := s.makeValidator(
+ map[string]any{"price": 5},
+ map[string]any{"price": "numeric|min:10"},
+ )
+ s.True(v.Fails())
+ s.Equal("The price field must be at least 10.", v.Errors().One("price"))
+ })
+
+ s.Run("max_array_message", func() {
+ v := s.makeValidator(
+ map[string]any{"items": []any{1, 2, 3, 4, 5}},
+ map[string]any{"items": "array|max:3"},
+ )
+ s.True(v.Fails())
+ s.Equal("The items field must not have more than 3 items.", v.Errors().One("items"))
+ })
+
+ s.Run("between_numeric_message", func() {
+ v := s.makeValidator(
+ map[string]any{"score": 100},
+ map[string]any{"score": "numeric|between:1,10"},
+ )
+ s.True(v.Fails())
+ s.Equal("The score field must be between 1 and 10.", v.Errors().One("score"))
+ })
+
+ s.Run("digits_message", func() {
+ v := s.makeValidator(
+ map[string]any{"pin": "12"},
+ map[string]any{"pin": "digits:4"},
+ )
+ s.True(v.Fails())
+ s.Equal("The pin field must be 4 digits.", v.Errors().One("pin"))
+ })
+
+ s.Run("required_if_message", func() {
+ v := s.makeValidator(
+ map[string]any{"type": "admin"},
+ map[string]any{"role": "required_if:type,admin"},
+ )
+ s.True(v.Fails())
+ s.Equal("The role field is required when type is admin.", v.Errors().One("role"))
+ })
+
+ s.Run("prohibited_if_message", func() {
+ v := s.makeValidator(
+ map[string]any{"role": "guest", "token": "abc"},
+ map[string]any{"token": "prohibited_if:role,guest"},
+ )
+ s.True(v.Fails())
+ s.Equal("The token field is prohibited when role is guest.", v.Errors().One("token"))
+ })
+
+ s.Run("starts_with_message", func() {
+ v := s.makeValidator(
+ map[string]any{"code": "xyz_bad"},
+ map[string]any{"code": "starts_with:abc_,def_"},
+ )
+ s.True(v.Fails())
+ s.Contains(v.Errors().One("code"), "must start with one of the following")
+ })
+
+ s.Run("date_before_message", func() {
+ v := s.makeValidator(
+ map[string]any{"d": "2030-01-01"},
+ map[string]any{"d": "before:2025-01-01"},
+ )
+ s.True(v.Fails())
+ s.Equal("The d field must be a date before 2025-01-01.", v.Errors().One("d"))
+ })
+
+ s.Run("min_array_message", func() {
+ v := s.makeValidator(
+ map[string]any{"items": []any{1}},
+ map[string]any{"items": "array|min:3"},
+ )
+ s.True(v.Fails())
+ s.Equal("The items field must have at least 3 items.", v.Errors().One("items"))
+ })
+
+ s.Run("size_numeric_message", func() {
+ v := s.makeValidator(
+ map[string]any{"qty": 5},
+ map[string]any{"qty": "numeric|size:10"},
+ )
+ s.True(v.Fails())
+ s.Equal("The qty field must be 10.", v.Errors().One("qty"))
+ })
+
+ s.Run("multiple_of_message", func() {
+ v := s.makeValidator(
+ map[string]any{"n": 7},
+ map[string]any{"n": "multiple_of:3"},
+ )
+ s.True(v.Fails())
+ s.Equal("The n field must be a multiple of 3.", v.Errors().One("n"))
+ })
+}
+
+// ===== Rule Alias Tests =====
+
+func (s *RulesTestSuite) TestIntAlias() {
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ {"pass_int_alias", map[string]any{"n": 42}, map[string]any{"n": "int"}, false},
+ {"pass_string_int_alias", map[string]any{"n": "42"}, map[string]any{"n": "int"}, false},
+ {"fail_float_int_alias", map[string]any{"n": 3.14}, map[string]any{"n": "int"}, true},
+ {"fail_string_int_alias", map[string]any{"n": "abc"}, map[string]any{"n": "int"}, true},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+func (s *RulesTestSuite) TestBoolAlias() {
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ {"pass_bool_alias_true", map[string]any{"ok": true}, map[string]any{"ok": "bool"}, false},
+ {"pass_bool_alias_false", map[string]any{"ok": false}, map[string]any{"ok": "bool"}, false},
+ {"pass_bool_alias_1", map[string]any{"ok": 1}, map[string]any{"ok": "bool"}, false},
+ {"pass_bool_alias_0", map[string]any{"ok": 0}, map[string]any{"ok": "bool"}, false},
+ {"pass_bool_alias_string_true", map[string]any{"ok": "true"}, map[string]any{"ok": "bool"}, false},
+ {"pass_bool_alias_string_false", map[string]any{"ok": "false"}, map[string]any{"ok": "bool"}, false},
+ {"pass_bool_alias_string_on", map[string]any{"ok": "on"}, map[string]any{"ok": "bool"}, false},
+ {"pass_bool_alias_string_off", map[string]any{"ok": "off"}, map[string]any{"ok": "bool"}, false},
+ {"pass_bool_alias_string_yes", map[string]any{"ok": "yes"}, map[string]any{"ok": "bool"}, false},
+ {"pass_bool_alias_string_no", map[string]any{"ok": "no"}, map[string]any{"ok": "bool"}, false},
+ {"fail_bool_alias_string", map[string]any{"ok": "abc"}, map[string]any{"ok": "bool"}, true},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+func (s *RulesTestSuite) TestSliceAlias() {
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ {"pass_slice_alias", map[string]any{"items": []any{1, 2}}, map[string]any{"items": "slice"}, false},
+ {"fail_slice_alias_map", map[string]any{"items": map[string]any{"a": 1}}, map[string]any{"items": "slice"}, true},
+ {"fail_slice_alias_string", map[string]any{"items": "abc"}, map[string]any{"items": "slice"}, true},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+func (s *RulesTestSuite) TestMacAlias() {
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ {"pass_mac_alias", map[string]any{"mac": "00:1A:2B:3C:4D:5E"}, map[string]any{"mac": "mac"}, false},
+ {"fail_mac_alias", map[string]any{"mac": "invalid"}, map[string]any{"mac": "mac"}, true},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+// ===== Active URL Tests =====
+
+func (s *RulesTestSuite) TestActiveUrl() {
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ {"pass_goravel", map[string]any{"u": "https://goravel.dev"}, map[string]any{"u": "active_url"}, false},
+ {"fail_non_string", map[string]any{"u": 123}, map[string]any{"u": "active_url"}, true},
+ {"fail_no_host", map[string]any{"u": "not-a-url"}, map[string]any{"u": "active_url"}, true},
+ }
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
+
+// ===== Array Syntax ([]string) Tests =====
+
+func (s *RulesTestSuite) TestArraySyntaxBasic() {
+ s.Run("string_and_array_syntax_equivalent", func() {
+ data := map[string]any{"name": "goravel", "age": 18}
+ v1 := s.makeValidator(data, map[string]any{
+ "name": "required|string|min:3",
+ "age": "required|integer",
+ })
+ v2 := s.makeValidator(data, map[string]any{
+ "name": []string{"required", "string", "min:3"},
+ "age": []string{"required", "integer"},
+ })
+ s.Equal(v1.Fails(), v2.Fails())
+ })
+
+ s.Run("array_syntax_regex_with_pipe", func() {
+ v := s.makeValidator(
+ map[string]any{"code": "foo"},
+ map[string]any{"code": []string{"required", "regex:^(foo|bar)$"}},
+ )
+ s.False(v.Fails())
+ })
+
+ s.Run("array_syntax_regex_with_pipe_fail", func() {
+ v := s.makeValidator(
+ map[string]any{"code": "baz"},
+ map[string]any{"code": []string{"required", "regex:^(foo|bar)$"}},
+ )
+ s.True(v.Fails())
+ })
+
+ s.Run("array_syntax_regex_followed_by_more_rules", func() {
+ v := s.makeValidator(
+ map[string]any{"code": "foo"},
+ map[string]any{"code": []string{"required", "regex:^(foo|bar)$", "string", "min:2"}},
+ )
+ s.False(v.Fails())
+ })
+
+ s.Run("array_syntax_not_regex_with_pipe", func() {
+ v := s.makeValidator(
+ map[string]any{"code": "baz"},
+ map[string]any{"code": []string{"required", "not_regex:^(foo|bar)$"}},
+ )
+ s.False(v.Fails())
+ })
+
+ s.Run("array_syntax_mixed_string_and_array", func() {
+ v := s.makeValidator(
+ map[string]any{"name": "goravel", "code": "foo"},
+ map[string]any{
+ "name": "required|string",
+ "code": []string{"required", "regex:^(foo|bar)$"},
+ },
+ )
+ s.False(v.Fails())
+ })
+
+ s.Run("array_syntax_multiple_rules_fail", func() {
+ v := s.makeValidator(
+ map[string]any{"name": "ab"},
+ map[string]any{"name": []string{"required", "string", "min:5", "max:10"}},
+ )
+ s.True(v.Fails())
+ })
+
+ s.Run("array_syntax_with_bail", func() {
+ v := s.makeValidator(
+ map[string]any{"email": ""},
+ map[string]any{"email": []string{"bail", "required", "email"}},
+ )
+ s.True(v.Fails())
+ // With bail, should only have the required error
+ errs := v.Errors().Get("email")
+ s.Equal(1, len(errs))
+ })
+
+ s.Run("array_syntax_empty_strings_ignored", func() {
+ v := s.makeValidator(
+ map[string]any{"x": "hello"},
+ map[string]any{"x": []string{"", "required", "", "string"}},
+ )
+ s.False(v.Fails())
+ })
+}
+
+// ===== Validated Data Tests =====
+
+func (s *RulesTestSuite) TestValidatedDataExcludesUnruledFields() {
+ v := s.makeValidator(
+ map[string]any{"name": "go", "age": 25, "extra": "ignored"},
+ map[string]any{"name": "required", "age": "required|integer"},
+ )
+ s.False(v.Fails())
+ data := v.Validated()
+ s.Equal(map[string]any{"name": "go", "age": 25}, data)
+ _, hasExtra := data["extra"]
+ s.False(hasExtra)
+}
+
+func (s *RulesTestSuite) TestValidatedDataWithExclusion() {
+ s.Run("exclude_removes_field", func() {
+ v := s.makeValidator(
+ map[string]any{"name": "go", "secret": "hidden"},
+ map[string]any{"name": "required", "secret": "exclude"},
+ )
+ s.False(v.Fails())
+ data := v.Validated()
+ _, hasSecret := data["secret"]
+ s.False(hasSecret)
+ s.Equal("go", data["name"])
+ })
+
+ s.Run("exclude_if_conditional", func() {
+ v := s.makeValidator(
+ map[string]any{"role": "admin", "token": "abc"},
+ map[string]any{"role": "required", "token": "exclude_if:role,admin"},
+ )
+ s.False(v.Fails())
+ data := v.Validated()
+ _, hasToken := data["token"]
+ s.False(hasToken)
+ })
+
+ s.Run("exclude_if_not_triggered", func() {
+ v := s.makeValidator(
+ map[string]any{"role": "user", "token": "abc"},
+ map[string]any{"role": "required", "token": "exclude_if:role,admin"},
+ )
+ s.False(v.Fails())
+ data := v.Validated()
+ s.Equal("abc", data["token"])
+ })
+}
+
+func (s *RulesTestSuite) TestValidatedDataNestedDot() {
+ v := s.makeValidator(
+ map[string]any{"user": map[string]any{"name": "go", "email": "a@b.com"}},
+ map[string]any{"user.name": "required|string", "user.email": "required|email"},
+ )
+ s.False(v.Fails())
+ data := v.Validated()
+ user, ok := data["user"].(map[string]any)
+ s.True(ok)
+ s.Equal("go", user["name"])
+ s.Equal("a@b.com", user["email"])
+}
+
+func (s *RulesTestSuite) TestValidatedDataWithWildcard() {
+ v := s.makeValidator(
+ map[string]any{"tags": []any{"go", "rust", "zig"}},
+ map[string]any{"tags.*": "required|string"},
+ )
+ s.False(v.Fails())
+ data := v.Validated()
+ tags, ok := data["tags"].(map[string]any)
+ s.True(ok)
+ s.Equal("go", tags["0"])
+}
+
+// ===== Error Bag Methods Tests =====
+
+func (s *RulesTestSuite) TestErrorBagMethods() {
+ s.Run("has_returns_false_when_no_errors", func() {
+ v := s.makeValidator(
+ map[string]any{"name": "go"},
+ map[string]any{"name": "required"},
+ )
+ s.False(v.Fails())
+ s.Nil(v.Errors())
+ })
+
+ s.Run("get_returns_all_errors_for_field", func() {
+ v := s.makeValidator(
+ map[string]any{"name": ""},
+ map[string]any{"name": "required"},
+ )
+ s.True(v.Fails())
+ errs := v.Errors().Get("name")
+ s.NotNil(errs)
+ s.Contains(errs, "required")
+ })
+
+ s.Run("all_returns_errors_for_all_fields", func() {
+ v := s.makeValidator(
+ map[string]any{},
+ map[string]any{"a": "required", "b": "required"},
+ )
+ s.True(v.Fails())
+ all := v.Errors().All()
+ s.Contains(all, "a")
+ s.Contains(all, "b")
+ })
+
+ s.Run("one_returns_first_error", func() {
+ v := s.makeValidator(
+ map[string]any{"name": ""},
+ map[string]any{"name": "required"},
+ )
+ s.True(v.Fails())
+ s.NotEmpty(v.Errors().One())
+ })
+
+ s.Run("one_with_field_returns_specific", func() {
+ v := s.makeValidator(
+ map[string]any{},
+ map[string]any{"a": "required", "b": "required"},
+ )
+ s.True(v.Fails())
+ s.NotEmpty(v.Errors().One("a"))
+ s.NotEmpty(v.Errors().One("b"))
+ })
+
+ s.Run("has_returns_true_for_failed_field", func() {
+ v := s.makeValidator(
+ map[string]any{},
+ map[string]any{"x": "required", "y": "required"},
+ )
+ s.True(v.Fails())
+ s.True(v.Errors().Has("x"))
+ s.True(v.Errors().Has("y"))
+ s.False(v.Errors().Has("z"))
+ })
+}
+
+func (s *RulesTestSuite) TestWildcardWithMultipleRules() {
+ s.Run("wildcard_required_and_string", func() {
+ v := s.makeValidator(
+ map[string]any{"tags": []any{"go", "rust"}},
+ map[string]any{"tags.*": "required|string"},
+ )
+ s.False(v.Fails())
+ })
+
+ s.Run("wildcard_fail_one_element", func() {
+ v := s.makeValidator(
+ map[string]any{"tags": []any{"go", ""}},
+ map[string]any{"tags.*": "required"},
+ )
+ s.True(v.Fails())
+ })
+
+ s.Run("wildcard_with_min", func() {
+ v := s.makeValidator(
+ map[string]any{"names": []any{"ab", "cdef"}},
+ map[string]any{"names.*": "string|min:3"},
+ )
+ s.True(v.Fails())
+ })
+
+ s.Run("wildcard_nested_objects", func() {
+ v := s.makeValidator(
+ map[string]any{
+ "users": []any{
+ map[string]any{"name": "alice"},
+ map[string]any{"name": "bob"},
+ },
+ },
+ map[string]any{"users.*.name": "required|string"},
+ )
+ s.False(v.Fails())
+ })
+
+ s.Run("wildcard_nested_fail", func() {
+ v := s.makeValidator(
+ map[string]any{
+ "users": []any{
+ map[string]any{"name": "alice"},
+ map[string]any{"name": ""},
+ },
+ },
+ map[string]any{"users.*.name": "required"},
+ )
+ s.True(v.Fails())
+ })
+}
+
+func (s *RulesTestSuite) TestDeepNestedDotNotation() {
+ s.Run("three_level_deep", func() {
+ v := s.makeValidator(
+ map[string]any{
+ "config": map[string]any{
+ "db": map[string]any{
+ "host": "localhost",
+ },
+ },
+ },
+ map[string]any{"config.db.host": "required|string"},
+ )
+ s.False(v.Fails())
+ })
+
+ s.Run("three_level_deep_fail", func() {
+ v := s.makeValidator(
+ map[string]any{
+ "config": map[string]any{
+ "db": map[string]any{
+ "host": "",
+ },
+ },
+ },
+ map[string]any{"config.db.host": "required"},
+ )
+ s.True(v.Fails())
+ })
+
+ s.Run("nested_with_multiple_fields", func() {
+ v := s.makeValidator(
+ map[string]any{
+ "server": map[string]any{
+ "host": "localhost",
+ "port": 8080,
+ },
+ },
+ map[string]any{
+ "server.host": "required|string",
+ "server.port": "required|integer",
+ },
+ )
+ s.False(v.Fails())
+ })
+}
+
+// makeFileHeader creates a real multipart.FileHeader from file content so that
+// detectMIME (which calls fh.Open()) works correctly in tests.
+func makeFileHeader(t *testing.T, filename string, content []byte) *multipart.FileHeader {
+ t.Helper()
+ var buf bytes.Buffer
+ w := multipart.NewWriter(&buf)
+ part, err := w.CreateFormFile("file", filename)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err = part.Write(content); err != nil {
+ t.Fatal(err)
+ }
+ if err = w.Close(); err != nil {
+ t.Fatal(err)
+ }
+ r := multipart.NewReader(&buf, w.Boundary())
+ form, err := r.ReadForm(32 << 20)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return form.File["file"][0]
+}
+
+// ---- Database Rules Tests ----
+
+type DBRulesTestSuite struct {
+ suite.Suite
+ mockOrm *mocksorm.Orm
+ mockQuery *mocksorm.Query
+}
+
+func TestDBRulesTestSuite(t *testing.T) {
+ suite.Run(t, new(DBRulesTestSuite))
+}
+
+func (s *DBRulesTestSuite) SetupTest() {
+ s.mockOrm = mocksorm.NewOrm(s.T())
+ s.mockQuery = mocksorm.NewQuery(s.T())
+ ormFacade = s.mockOrm
+}
+
+func (s *DBRulesTestSuite) TearDownTest() {
+ ormFacade = nil
+}
+
+// --- exists rule tests ---
+
+func (s *DBRulesTestSuite) TestRuleExists_SingleColumn_Found() {
+ s.mockOrm.EXPECT().WithContext(mock.Anything).Return(s.mockOrm).Once()
+ s.mockOrm.EXPECT().Query().Return(s.mockQuery).Once()
+ s.mockQuery.EXPECT().Table("users").Return(s.mockQuery).Once()
+ s.mockQuery.EXPECT().Where("email", "test@example.com").Return(s.mockQuery).Once()
+ s.mockQuery.EXPECT().Exists().Return(true, nil).Once()
+
+ ctx := &RuleContext{
+ Ctx: context.Background(),
+ Attribute: "email",
+ Value: "test@example.com",
+ Parameters: []string{"users", "email"},
+ }
+ s.True(ruleExists(ctx))
+}
+
+func (s *DBRulesTestSuite) TestRuleExists_SingleColumn_NotFound() {
+ s.mockOrm.EXPECT().WithContext(mock.Anything).Return(s.mockOrm).Once()
+ s.mockOrm.EXPECT().Query().Return(s.mockQuery).Once()
+ s.mockQuery.EXPECT().Table("users").Return(s.mockQuery).Once()
+ s.mockQuery.EXPECT().Where("email", "notfound@example.com").Return(s.mockQuery).Once()
+ s.mockQuery.EXPECT().Exists().Return(false, nil).Once()
+
+ ctx := &RuleContext{
+ Ctx: context.Background(),
+ Attribute: "email",
+ Value: "notfound@example.com",
+ Parameters: []string{"users", "email"},
+ }
+ s.False(ruleExists(ctx))
+}
+
+func (s *DBRulesTestSuite) TestRuleExists_DefaultColumn() {
+ s.mockOrm.EXPECT().WithContext(mock.Anything).Return(s.mockOrm).Once()
+ s.mockOrm.EXPECT().Query().Return(s.mockQuery).Once()
+ s.mockQuery.EXPECT().Table("users").Return(s.mockQuery).Once()
+ s.mockQuery.EXPECT().Where("email", "test@example.com").Return(s.mockQuery).Once()
+ s.mockQuery.EXPECT().Exists().Return(true, nil).Once()
+
+ ctx := &RuleContext{
+ Ctx: context.Background(),
+ Attribute: "email",
+ Value: "test@example.com",
+ Parameters: []string{"users"},
+ }
+ s.True(ruleExists(ctx))
+}
+
+func (s *DBRulesTestSuite) TestRuleExists_MultipleColumns_OR() {
+ s.mockOrm.EXPECT().WithContext(mock.Anything).Return(s.mockOrm).Once()
+ s.mockOrm.EXPECT().Query().Return(s.mockQuery).Once()
+ s.mockQuery.EXPECT().Table("users").Return(s.mockQuery).Once()
+ s.mockQuery.EXPECT().Where("email", "test@example.com").Return(s.mockQuery).Once()
+ s.mockQuery.EXPECT().OrWhere("username", "test@example.com").Return(s.mockQuery).Once()
+ s.mockQuery.EXPECT().Exists().Return(true, nil).Once()
+
+ ctx := &RuleContext{
+ Ctx: context.Background(),
+ Attribute: "email",
+ Value: "test@example.com",
+ Parameters: []string{"users", "email", "username"},
+ }
+ s.True(ruleExists(ctx))
+}
+
+func (s *DBRulesTestSuite) TestRuleExists_MultipleColumns_ThreeFields() {
+ s.mockOrm.EXPECT().WithContext(mock.Anything).Return(s.mockOrm).Once()
+ s.mockOrm.EXPECT().Query().Return(s.mockQuery).Once()
+ s.mockQuery.EXPECT().Table("users").Return(s.mockQuery).Once()
+ s.mockQuery.EXPECT().Where("email", "value").Return(s.mockQuery).Once()
+ s.mockQuery.EXPECT().OrWhere("username", "value").Return(s.mockQuery).Once()
+ s.mockQuery.EXPECT().OrWhere("phone", "value").Return(s.mockQuery).Once()
+ s.mockQuery.EXPECT().Exists().Return(false, nil).Once()
+
+ ctx := &RuleContext{
+ Ctx: context.Background(),
+ Attribute: "email",
+ Value: "value",
+ Parameters: []string{"users", "email", "username", "phone"},
+ }
+ s.False(ruleExists(ctx))
+}
+
+func (s *DBRulesTestSuite) TestRuleExists_ConnectionTable() {
+ s.mockOrm.EXPECT().WithContext(mock.Anything).Return(s.mockOrm).Once()
+ s.mockOrm.EXPECT().Connection("mysql").Return(s.mockOrm).Once()
+ s.mockOrm.EXPECT().Query().Return(s.mockQuery).Once()
+ s.mockQuery.EXPECT().Table("users").Return(s.mockQuery).Once()
+ s.mockQuery.EXPECT().Where("email", "test@example.com").Return(s.mockQuery).Once()
+ s.mockQuery.EXPECT().Exists().Return(true, nil).Once()
+
+ ctx := &RuleContext{
+ Ctx: context.Background(),
+ Attribute: "email",
+ Value: "test@example.com",
+ Parameters: []string{"mysql.users", "email"},
+ }
+ s.True(ruleExists(ctx))
+}
+
+func (s *DBRulesTestSuite) TestRuleExists_OrmNil() {
+ ormFacade = nil
+
+ ctx := &RuleContext{
+ Ctx: context.Background(),
+ Attribute: "email",
+ Value: "test@example.com",
+ Parameters: []string{"users", "email"},
+ }
+ s.False(ruleExists(ctx))
+}
+
+func (s *DBRulesTestSuite) TestRuleExists_NoParameters() {
+ ctx := &RuleContext{
+ Ctx: context.Background(),
+ Attribute: "email",
+ Value: "test@example.com",
+ Parameters: []string{},
+ }
+ s.False(ruleExists(ctx))
+}
+
+// --- unique rule tests ---
+
+func (s *DBRulesTestSuite) TestRuleUnique_IsUnique() {
+ s.mockOrm.EXPECT().WithContext(mock.Anything).Return(s.mockOrm).Once()
+ s.mockOrm.EXPECT().Query().Return(s.mockQuery).Once()
+ s.mockQuery.EXPECT().Table("users").Return(s.mockQuery).Once()
+ s.mockQuery.EXPECT().Where("email", "test@example.com").Return(s.mockQuery).Once()
+ s.mockQuery.EXPECT().Count().Return(int64(0), nil).Once()
+
+ ctx := &RuleContext{
+ Ctx: context.Background(),
+ Attribute: "email",
+ Value: "test@example.com",
+ Parameters: []string{"users", "email"},
+ }
+ s.True(ruleUnique(ctx))
+}
+
+func (s *DBRulesTestSuite) TestRuleUnique_NotUnique() {
+ s.mockOrm.EXPECT().WithContext(mock.Anything).Return(s.mockOrm).Once()
+ s.mockOrm.EXPECT().Query().Return(s.mockQuery).Once()
+ s.mockQuery.EXPECT().Table("users").Return(s.mockQuery).Once()
+ s.mockQuery.EXPECT().Where("email", "taken@example.com").Return(s.mockQuery).Once()
+ s.mockQuery.EXPECT().Count().Return(int64(1), nil).Once()
+
+ ctx := &RuleContext{
+ Ctx: context.Background(),
+ Attribute: "email",
+ Value: "taken@example.com",
+ Parameters: []string{"users", "email"},
+ }
+ s.False(ruleUnique(ctx))
+}
+
+func (s *DBRulesTestSuite) TestRuleUnique_DefaultColumn() {
+ s.mockOrm.EXPECT().WithContext(mock.Anything).Return(s.mockOrm).Once()
+ s.mockOrm.EXPECT().Query().Return(s.mockQuery).Once()
+ s.mockQuery.EXPECT().Table("users").Return(s.mockQuery).Once()
+ s.mockQuery.EXPECT().Where("email", "test@example.com").Return(s.mockQuery).Once()
+ s.mockQuery.EXPECT().Count().Return(int64(0), nil).Once()
+
+ ctx := &RuleContext{
+ Ctx: context.Background(),
+ Attribute: "email",
+ Value: "test@example.com",
+ Parameters: []string{"users"},
+ }
+ s.True(ruleUnique(ctx))
+}
+
+func (s *DBRulesTestSuite) TestRuleUnique_WithExcept() {
+ s.mockOrm.EXPECT().WithContext(mock.Anything).Return(s.mockOrm).Once()
+ s.mockOrm.EXPECT().Query().Return(s.mockQuery).Once()
+ s.mockQuery.EXPECT().Table("users").Return(s.mockQuery).Once()
+ s.mockQuery.EXPECT().Where("email", "test@example.com").Return(s.mockQuery).Once()
+ s.mockQuery.EXPECT().WhereNotIn("id", []any{"5"}).Return(s.mockQuery).Once()
+ s.mockQuery.EXPECT().Count().Return(int64(0), nil).Once()
+
+ ctx := &RuleContext{
+ Ctx: context.Background(),
+ Attribute: "email",
+ Value: "test@example.com",
+ Parameters: []string{"users", "email", "id", "5"},
+ }
+ s.True(ruleUnique(ctx))
+}
+
+func (s *DBRulesTestSuite) TestRuleUnique_WithCustomIdColumnAndExcept() {
+ s.mockOrm.EXPECT().WithContext(mock.Anything).Return(s.mockOrm).Once()
+ s.mockOrm.EXPECT().Query().Return(s.mockQuery).Once()
+ s.mockQuery.EXPECT().Table("users").Return(s.mockQuery).Once()
+ s.mockQuery.EXPECT().Where("email", "test@example.com").Return(s.mockQuery).Once()
+ s.mockQuery.EXPECT().WhereNotIn("user_id", []any{"5"}).Return(s.mockQuery).Once()
+ s.mockQuery.EXPECT().Count().Return(int64(0), nil).Once()
+
+ ctx := &RuleContext{
+ Ctx: context.Background(),
+ Attribute: "email",
+ Value: "test@example.com",
+ Parameters: []string{"users", "email", "user_id", "5"},
+ }
+ s.True(ruleUnique(ctx))
+}
+
+func (s *DBRulesTestSuite) TestRuleUnique_WithMultipleExcepts() {
+ s.mockOrm.EXPECT().WithContext(mock.Anything).Return(s.mockOrm).Once()
+ s.mockOrm.EXPECT().Query().Return(s.mockQuery).Once()
+ s.mockQuery.EXPECT().Table("users").Return(s.mockQuery).Once()
+ s.mockQuery.EXPECT().Where("email", "test@example.com").Return(s.mockQuery).Once()
+ s.mockQuery.EXPECT().WhereNotIn("id", []any{"1", "2", "3"}).Return(s.mockQuery).Once()
+ s.mockQuery.EXPECT().Count().Return(int64(0), nil).Once()
+
+ ctx := &RuleContext{
+ Ctx: context.Background(),
+ Attribute: "email",
+ Value: "test@example.com",
+ Parameters: []string{"users", "email", "id", "1", "2", "3"},
+ }
+ s.True(ruleUnique(ctx))
+}
+
+func (s *DBRulesTestSuite) TestRuleUnique_WithDefaultIdColumn() {
+ s.mockOrm.EXPECT().WithContext(mock.Anything).Return(s.mockOrm).Once()
+ s.mockOrm.EXPECT().Query().Return(s.mockQuery).Once()
+ s.mockQuery.EXPECT().Table("users").Return(s.mockQuery).Once()
+ s.mockQuery.EXPECT().Where("email", "test@example.com").Return(s.mockQuery).Once()
+ s.mockQuery.EXPECT().WhereNotIn("id", []any{"5"}).Return(s.mockQuery).Once()
+ s.mockQuery.EXPECT().Count().Return(int64(0), nil).Once()
+
+ ctx := &RuleContext{
+ Ctx: context.Background(),
+ Attribute: "email",
+ Value: "test@example.com",
+ Parameters: []string{"users", "email", "", "5"},
+ }
+ s.True(ruleUnique(ctx))
+}
+
+func (s *DBRulesTestSuite) TestRuleUnique_ConnectionTable() {
+ s.mockOrm.EXPECT().WithContext(mock.Anything).Return(s.mockOrm).Once()
+ s.mockOrm.EXPECT().Connection("pgsql").Return(s.mockOrm).Once()
+ s.mockOrm.EXPECT().Query().Return(s.mockQuery).Once()
+ s.mockQuery.EXPECT().Table("users").Return(s.mockQuery).Once()
+ s.mockQuery.EXPECT().Where("email", "test@example.com").Return(s.mockQuery).Once()
+ s.mockQuery.EXPECT().Count().Return(int64(0), nil).Once()
+
+ ctx := &RuleContext{
+ Ctx: context.Background(),
+ Attribute: "email",
+ Value: "test@example.com",
+ Parameters: []string{"pgsql.users", "email"},
+ }
+ s.True(ruleUnique(ctx))
+}
+
+func (s *DBRulesTestSuite) TestRuleUnique_OrmNil() {
+ ormFacade = nil
+
+ ctx := &RuleContext{
+ Ctx: context.Background(),
+ Attribute: "email",
+ Value: "test@example.com",
+ Parameters: []string{"users", "email"},
+ }
+ s.False(ruleUnique(ctx))
+}
+
+func (s *DBRulesTestSuite) TestRuleUnique_NoParameters() {
+ ctx := &RuleContext{
+ Ctx: context.Background(),
+ Attribute: "email",
+ Value: "test@example.com",
+ Parameters: []string{},
+ }
+ s.False(ruleUnique(ctx))
+}
+
+// ===== Deprecated Rule Aliases =====
+
+func (s *RulesTestSuite) TestDeprecatedAliases() {
+ tests := []struct {
+ name string
+ data map[string]any
+ rules map[string]any
+ fails bool
+ }{
+ // len → size
+ {"len_string_pass", map[string]any{"name": "hello"}, map[string]any{"name": "string|len:5"}, false},
+ {"len_string_fail", map[string]any{"name": "hi"}, map[string]any{"name": "string|len:5"}, true},
+
+ // min_len → min
+ {"min_len_pass", map[string]any{"name": "hello"}, map[string]any{"name": "string|min_len:3"}, false},
+ {"min_len_fail", map[string]any{"name": "hi"}, map[string]any{"name": "string|min_len:3"}, true},
+
+ // max_len → max
+ {"max_len_pass", map[string]any{"name": "hi"}, map[string]any{"name": "string|max_len:5"}, false},
+ {"max_len_fail", map[string]any{"name": "hello world"}, map[string]any{"name": "string|max_len:5"}, true},
+
+ // eq_field → same
+ {"eq_field_pass", map[string]any{"a": "x", "b": "x"}, map[string]any{"a": "eq_field:b"}, false},
+ {"eq_field_fail", map[string]any{"a": "x", "b": "y"}, map[string]any{"a": "eq_field:b"}, true},
+
+ // ne_field → different
+ {"ne_field_pass", map[string]any{"a": "x", "b": "y"}, map[string]any{"a": "ne_field:b"}, false},
+ {"ne_field_fail", map[string]any{"a": "x", "b": "x"}, map[string]any{"a": "ne_field:b"}, true},
+
+ // gt_field → gt
+ {"gt_field_pass", map[string]any{"a": 10, "b": 5}, map[string]any{"a": "numeric|gt_field:b"}, false},
+ {"gt_field_fail", map[string]any{"a": 3, "b": 5}, map[string]any{"a": "numeric|gt_field:b"}, true},
+
+ // gte_field → gte
+ {"gte_field_pass", map[string]any{"a": 5, "b": 5}, map[string]any{"a": "numeric|gte_field:b"}, false},
+ {"gte_field_fail", map[string]any{"a": 3, "b": 5}, map[string]any{"a": "numeric|gte_field:b"}, true},
+
+ // lt_field → lt
+ {"lt_field_pass", map[string]any{"a": 3, "b": 5}, map[string]any{"a": "numeric|lt_field:b"}, false},
+ {"lt_field_fail", map[string]any{"a": 10, "b": 5}, map[string]any{"a": "numeric|lt_field:b"}, true},
+
+ // lte_field → lte
+ {"lte_field_pass", map[string]any{"a": 5, "b": 5}, map[string]any{"a": "numeric|lte_field:b"}, false},
+ {"lte_field_fail", map[string]any{"a": 10, "b": 5}, map[string]any{"a": "numeric|lte_field:b"}, true},
+
+ // gt_date → after
+ {"gt_date_pass", map[string]any{"d": "2025-01-02"}, map[string]any{"d": "gt_date:2025-01-01"}, false},
+ {"gt_date_fail", map[string]any{"d": "2025-01-01"}, map[string]any{"d": "gt_date:2025-01-02"}, true},
+
+ // lt_date → before
+ {"lt_date_pass", map[string]any{"d": "2025-01-01"}, map[string]any{"d": "lt_date:2025-01-02"}, false},
+ {"lt_date_fail", map[string]any{"d": "2025-01-02"}, map[string]any{"d": "lt_date:2025-01-01"}, true},
+
+ // gte_date → after_or_equal
+ {"gte_date_pass_equal", map[string]any{"d": "2025-01-01"}, map[string]any{"d": "gte_date:2025-01-01"}, false},
+ {"gte_date_pass_after", map[string]any{"d": "2025-01-02"}, map[string]any{"d": "gte_date:2025-01-01"}, false},
+ {"gte_date_fail", map[string]any{"d": "2024-12-31"}, map[string]any{"d": "gte_date:2025-01-01"}, true},
+
+ // lte_date → before_or_equal
+ {"lte_date_pass_equal", map[string]any{"d": "2025-01-01"}, map[string]any{"d": "lte_date:2025-01-01"}, false},
+ {"lte_date_pass_before", map[string]any{"d": "2024-12-31"}, map[string]any{"d": "lte_date:2025-01-01"}, false},
+ {"lte_date_fail", map[string]any{"d": "2025-01-02"}, map[string]any{"d": "lte_date:2025-01-01"}, true},
+
+ // number → numeric
+ {"number_pass_int", map[string]any{"v": 42}, map[string]any{"v": "number"}, false},
+ {"number_pass_string", map[string]any{"v": "3.14"}, map[string]any{"v": "number"}, false},
+ {"number_fail", map[string]any{"v": "abc"}, map[string]any{"v": "number"}, true},
+
+ // full_url → url
+ {"full_url_pass", map[string]any{"v": "https://goravel.dev"}, map[string]any{"v": "full_url"}, false},
+ {"full_url_fail", map[string]any{"v": "not-a-url"}, map[string]any{"v": "full_url"}, true},
+ }
+
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ v := s.makeValidator(tt.data, tt.rules)
+ s.Equal(tt.fails, v.Fails())
+ })
+ }
+}
diff --git a/validation/service_provider.go b/validation/service_provider.go
index 2d19e50d0..4f438505e 100644
--- a/validation/service_provider.go
+++ b/validation/service_provider.go
@@ -3,10 +3,13 @@ package validation
import (
"github.com/goravel/framework/contracts/binding"
consolecontract "github.com/goravel/framework/contracts/console"
+ "github.com/goravel/framework/contracts/database/orm"
"github.com/goravel/framework/contracts/foundation"
"github.com/goravel/framework/validation/console"
)
+var ormFacade orm.Orm
+
type ServiceProvider struct {
}
@@ -27,6 +30,8 @@ func (r *ServiceProvider) Register(app foundation.Application) {
}
func (r *ServiceProvider) Boot(app foundation.Application) {
+ ormFacade = app.MakeOrm()
+
app.Commands([]consolecontract.Command{
&console.RuleMakeCommand{},
&console.FilterMakeCommand{},
diff --git a/validation/utils.go b/validation/utils.go
index 0abaaffd8..8af8951e2 100644
--- a/validation/utils.go
+++ b/validation/utils.go
@@ -8,6 +8,12 @@ import (
"regexp"
"strconv"
"strings"
+ "time"
+ "unicode"
+ "unicode/utf8"
+
+ "github.com/gabriel-vasile/mimetype"
+ "github.com/spf13/cast"
)
// isValueEmpty checks if a value is considered "empty" for validation purposes.
@@ -85,11 +91,6 @@ func matchesOtherValue(otherValue any, comparisonValues []string) bool {
return false
}
-// isControlRule returns true for rule names that are control directives (not actual validation rules).
-func isControlRule(name string) bool {
- return name == "bail" || name == "nullable" || name == "sometimes"
-}
-
// dotGet navigates nested maps/slices using path segments.
func dotGet(data any, segments []string) (any, bool) {
if len(segments) == 0 {
@@ -338,3 +339,242 @@ func normalizeValue(rv reflect.Value) any {
return rv.Interface()
}
}
+
+// getSize returns the "size" of a value based on its attribute type.
+func getSize(val any, attrType string) (float64, bool) {
+ switch attrType {
+ case "numeric":
+ num, err := cast.ToFloat64E(val)
+ return num, err == nil
+ case "array":
+ if val == nil {
+ return 0, false
+ }
+ rv := reflect.ValueOf(val)
+ kind := rv.Kind()
+ if kind == reflect.Slice || kind == reflect.Array || kind == reflect.Map {
+ return float64(rv.Len()), true
+ }
+ return 0, false
+ case "file":
+ if fh, ok := val.(*multipart.FileHeader); ok {
+ return float64(fh.Size) / 1024, true // kilobytes
+ }
+ if fhs, ok := val.([]*multipart.FileHeader); ok {
+ var total int64
+ for _, fh := range fhs {
+ total += fh.Size
+ }
+ return float64(total) / 1024, true
+ }
+ return 0, false
+ default: // string
+ s := fmt.Sprintf("%v", val)
+ return float64(utf8.RuneCountInString(s)), true
+ }
+}
+
+// parseDateValue attempts to parse a date from a value or field reference.
+func parseDateValue(val string, data *DataBag) (time.Time, bool) {
+ // Try as a field reference first
+ if fieldVal, ok := data.Get(val); ok {
+ return parseDate(fieldVal)
+ }
+ return parseDate(val)
+}
+
+// parseDate attempts to parse a value as a date.
+func parseDate(val any) (time.Time, bool) {
+ switch v := val.(type) {
+ case time.Time:
+ return v, true
+ case string:
+ formats := []string{
+ time.RFC3339,
+ "2006-01-02T15:04:05",
+ "2006-01-02 15:04:05",
+ "2006-01-02",
+ time.RFC1123,
+ time.RFC822,
+ }
+ for _, f := range formats {
+ if t, err := time.Parse(f, v); err == nil {
+ return t, true
+ }
+ }
+ }
+ return time.Time{}, false
+}
+
+// isAcceptedValue checks if a value is one of the "accepted" values.
+func isAcceptedValue(val any) bool {
+ if val == nil {
+ return false
+ }
+ switch v := val.(type) {
+ case string:
+ v = strings.ToLower(strings.TrimSpace(v))
+ return v == "yes" || v == "on" || v == "1" || v == "true"
+ }
+ v, err := cast.ToFloat64E(val)
+ return v == 1 && err == nil
+}
+
+// isDeclinedValue checks if a value is one of the "declined" values.
+func isDeclinedValue(val any) bool {
+ if val == nil {
+ return false
+ }
+ switch v := val.(type) {
+ case string:
+ v = strings.ToLower(strings.TrimSpace(v))
+ return v == "no" || v == "off" || v == "0" || v == "false"
+ }
+ v, err := cast.ToFloat64E(val)
+ return v == 0 && err == nil
+}
+
+// parseDependentValues extracts the other field's value and comparison values from parameters.
+// params[0] is the other field name, params[1:] are comparison values.
+func parseDependentValues(ctx *RuleContext) (otherValue any, comparisonValues []string, otherField string) {
+ if len(ctx.Parameters) == 0 {
+ return nil, nil, ""
+ }
+ otherField = ctx.Parameters[0]
+ otherValue, _ = ctx.Data.Get(otherField)
+ comparisonValues = ctx.Parameters[1:]
+ return
+}
+
+// toCamelCase converts a string to camelCase.
+func toCamelCase(s string) string {
+ words := splitWords(s)
+ if len(words) == 0 {
+ return ""
+ }
+
+ result := strings.ToLower(words[0])
+ for _, w := range words[1:] {
+ if len(w) > 0 {
+ runes := []rune(strings.ToLower(w))
+ runes[0] = unicode.ToUpper(runes[0])
+ result += string(runes)
+ }
+ }
+
+ return result
+}
+
+// toSnakeCase converts a string to snake_case.
+func toSnakeCase(s string) string {
+ words := splitWords(s)
+ for i, w := range words {
+ words[i] = strings.ToLower(w)
+ }
+ return strings.Join(words, "_")
+}
+
+// splitWords splits a string into words based on separators and case changes.
+func splitWords(s string) []string {
+ // Replace common separators with spaces
+ s = strings.NewReplacer("-", " ", "_", " ").Replace(s)
+
+ // Split on camelCase boundaries
+ var words []string
+ current := strings.Builder{}
+
+ runes := []rune(s)
+ for i, r := range runes {
+ if r == ' ' {
+ if current.Len() > 0 {
+ words = append(words, current.String())
+ current.Reset()
+ }
+ continue
+ }
+
+ if i > 0 && unicode.IsUpper(r) && !unicode.IsUpper(runes[i-1]) {
+ if current.Len() > 0 {
+ words = append(words, current.String())
+ current.Reset()
+ }
+ }
+
+ current.WriteRune(r)
+ }
+
+ if current.Len() > 0 {
+ words = append(words, current.String())
+ }
+
+ return words
+}
+
+var htmlTagRegex = regexp.MustCompile(`<[^>]*>`)
+
+// stripHTMLTags removes HTML tags from a string.
+func stripHTMLTags(s string) string {
+ return htmlTagRegex.ReplaceAllString(s, "")
+}
+
+// escapeJS escapes a string for safe embedding in JavaScript.
+func escapeJS(s string) string {
+ replacer := strings.NewReplacer(
+ `\`, `\\`,
+ `"`, `\"`,
+ `'`, `\'`,
+ "\n", `\n`,
+ "\r", `\r`,
+ "<", `\x3c`,
+ ">", `\x3e`,
+ "/", `\/`,
+ )
+ return replacer.Replace(s)
+}
+
+// strToInts splits a comma-separated string into []int.
+func strToInts(s string) []int {
+ parts := strings.Split(s, ",")
+ result := make([]int, 0, len(parts))
+ for _, p := range parts {
+ p = strings.TrimSpace(p)
+ if p == "" {
+ continue
+ }
+ result = append(result, cast.ToInt(p))
+ }
+ return result
+}
+
+// strToArray splits a comma-separated string into []string.
+func strToArray(s string) []string {
+ parts := strings.Split(s, ",")
+ result := make([]string, 0, len(parts))
+ for _, p := range parts {
+ p = strings.TrimSpace(p)
+ if p == "" {
+ continue
+ }
+ result = append(result, p)
+ }
+ return result
+}
+
+// detectMIME detects the real MIME type of a multipart file by reading its content.
+func detectMIME(fh *multipart.FileHeader) (*mimetype.MIME, error) {
+ f, err := fh.Open()
+ if err != nil {
+ return nil, err
+ }
+ defer func(f multipart.File) { _ = f.Close() }(f)
+
+ return mimetype.DetectReader(f)
+}
+
+func getFileExtension(filename string) string {
+ idx := strings.LastIndex(filename, ".")
+ if idx == -1 {
+ return ""
+ }
+ return filename[idx+1:]
+}
diff --git a/validation/utils_test.go b/validation/utils_test.go
index cb5d9c07a..9a84c6cab 100644
--- a/validation/utils_test.go
+++ b/validation/utils_test.go
@@ -1,9 +1,11 @@
package validation
import (
+ "context"
"net/url"
"reflect"
"testing"
+ "time"
"github.com/stretchr/testify/assert"
)
@@ -78,14 +80,6 @@ func TestMatchesOtherValue(t *testing.T) {
})
}
-func TestIsControlRule(t *testing.T) {
- assert.True(t, isControlRule("bail"))
- assert.True(t, isControlRule("nullable"))
- assert.True(t, isControlRule("sometimes"))
- assert.False(t, isControlRule("required"))
- assert.False(t, isControlRule("string"))
-}
-
func TestDotGet(t *testing.T) {
data := map[string]any{
"user": map[string]any{
@@ -436,3 +430,330 @@ func TestNormalizeValue(t *testing.T) {
assert.Equal(t, "Alice", result["name"])
})
}
+
+func TestGetSize(t *testing.T) {
+ t.Run("numeric", func(t *testing.T) {
+ size, ok := getSize(42, "numeric")
+ assert.True(t, ok)
+ assert.Equal(t, float64(42), size)
+
+ size, ok = getSize(3.14, "numeric")
+ assert.True(t, ok)
+ assert.Equal(t, 3.14, size)
+
+ size, ok = getSize("100", "numeric")
+ assert.True(t, ok)
+ assert.Equal(t, float64(100), size)
+ })
+
+ t.Run("string", func(t *testing.T) {
+ size, ok := getSize("hello", "string")
+ assert.True(t, ok)
+ assert.Equal(t, float64(5), size)
+
+ size, ok = getSize("你好", "string")
+ assert.True(t, ok)
+ assert.Equal(t, float64(2), size)
+ })
+
+ t.Run("array", func(t *testing.T) {
+ size, ok := getSize([]any{1, 2, 3}, "array")
+ assert.True(t, ok)
+ assert.Equal(t, float64(3), size)
+
+ size, ok = getSize(map[string]any{"a": 1, "b": 2}, "array")
+ assert.True(t, ok)
+ assert.Equal(t, float64(2), size)
+
+ _, ok = getSize(nil, "array")
+ assert.False(t, ok)
+
+ _, ok = getSize("not-array", "array")
+ assert.False(t, ok)
+ })
+}
+
+func TestParseDateValue(t *testing.T) {
+ bag, _ := NewDataBag(map[string]any{
+ "start": "2024-01-15",
+ })
+
+ t.Run("field reference", func(t *testing.T) {
+ dt, ok := parseDateValue("start", bag)
+ assert.True(t, ok)
+ assert.Equal(t, 2024, dt.Year())
+ assert.Equal(t, time.January, dt.Month())
+ assert.Equal(t, 15, dt.Day())
+ })
+
+ t.Run("literal date string", func(t *testing.T) {
+ dt, ok := parseDateValue("2023-06-01", bag)
+ assert.True(t, ok)
+ assert.Equal(t, 2023, dt.Year())
+ })
+
+ t.Run("invalid date", func(t *testing.T) {
+ _, ok := parseDateValue("not-a-date", bag)
+ assert.False(t, ok)
+ })
+
+ t.Run("missing field falls back to literal", func(t *testing.T) {
+ _, ok := parseDateValue("missing_field", bag)
+ assert.False(t, ok)
+ })
+}
+
+func TestParseDate(t *testing.T) {
+ t.Run("time.Time value", func(t *testing.T) {
+ now := time.Now()
+ dt, ok := parseDate(now)
+ assert.True(t, ok)
+ assert.Equal(t, now, dt)
+ })
+
+ t.Run("RFC3339 string", func(t *testing.T) {
+ dt, ok := parseDate("2024-01-15T10:30:00Z")
+ assert.True(t, ok)
+ assert.Equal(t, 2024, dt.Year())
+ })
+
+ t.Run("date only string", func(t *testing.T) {
+ dt, ok := parseDate("2024-01-15")
+ assert.True(t, ok)
+ assert.Equal(t, 15, dt.Day())
+ })
+
+ t.Run("datetime string", func(t *testing.T) {
+ dt, ok := parseDate("2024-01-15 10:30:00")
+ assert.True(t, ok)
+ assert.Equal(t, 10, dt.Hour())
+ })
+
+ t.Run("invalid string", func(t *testing.T) {
+ _, ok := parseDate("not-a-date")
+ assert.False(t, ok)
+ })
+
+ t.Run("non-string non-time", func(t *testing.T) {
+ _, ok := parseDate(12345)
+ assert.False(t, ok)
+ })
+}
+
+func TestIsAcceptedValue(t *testing.T) {
+ tests := []struct {
+ name string
+ val any
+ expected bool
+ }{
+ {"nil", nil, false},
+ {"string yes", "yes", true},
+ {"string on", "on", true},
+ {"string 1", "1", true},
+ {"string true", "true", true},
+ {"string YES", "YES", true},
+ {"string True", "True", true},
+ {"string yes ", " yes ", true},
+ {"string no", "no", false},
+ {"string false", "false", false},
+ {"string empty", "", false},
+ {"string random", "hello", false},
+ {"string 2", "2", false},
+ {"bool true", true, true},
+ {"bool false", false, false},
+ {"int 1", 1, true},
+ {"int 0", 0, false},
+ {"int 2", 2, false},
+ {"int -1", -1, false},
+ {"int64 1", int64(1), true},
+ {"int64 0", int64(0), false},
+ {"float64 1", float64(1), true},
+ {"float64 0", float64(0), false},
+ {"float64 0.5", float64(0.5), false},
+ {"slice", []any{1, 2}, false},
+ {"map", map[string]any{"a": 1}, false},
+ {"struct", struct{ Name string }{"test"}, false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ assert.Equal(t, tt.expected, isAcceptedValue(tt.val))
+ })
+ }
+}
+
+func TestIsDeclinedValue(t *testing.T) {
+ tests := []struct {
+ name string
+ val any
+ expected bool
+ }{
+ {"nil", nil, false},
+ {"string no", "no", true},
+ {"string off", "off", true},
+ {"string 0", "0", true},
+ {"string false", "false", true},
+ {"string NO", "NO", true},
+ {"string False", "False", true},
+ {"string no ", " no ", true},
+ {"string yes", "yes", false},
+ {"string true", "true", false},
+ {"string empty", "", false},
+ {"string random", "hello", false},
+ {"string 2", "2", false},
+ {"bool false", false, true},
+ {"bool true", true, false},
+ {"int 0", 0, true},
+ {"int 1", 1, false},
+ {"int 2", 2, false},
+ {"int -1", -1, false},
+ {"int64 0", int64(0), true},
+ {"int64 1", int64(1), false},
+ {"float64 0", float64(0), true},
+ {"float64 1", float64(1), false},
+ {"float64 0.5", 0.5, false},
+ {"slice", []any{1, 2}, false},
+ {"map", map[string]any{"a": 1}, false},
+ {"struct", struct{ Name string }{"test"}, false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ assert.Equal(t, tt.expected, isDeclinedValue(tt.val))
+ })
+ }
+}
+
+func TestParseDependentValues(t *testing.T) {
+ bag, _ := NewDataBag(map[string]any{"status": "active"})
+
+ t.Run("no parameters", func(t *testing.T) {
+ ctx := &RuleContext{Ctx: context.Background(), Data: bag, Parameters: []string{}}
+ otherValue, comparisonValues, otherField := parseDependentValues(ctx)
+ assert.Nil(t, otherValue)
+ assert.Nil(t, comparisonValues)
+ assert.Empty(t, otherField)
+ })
+
+ t.Run("with field and values", func(t *testing.T) {
+ ctx := &RuleContext{Ctx: context.Background(), Data: bag, Parameters: []string{"status", "active", "pending"}}
+ otherValue, comparisonValues, otherField := parseDependentValues(ctx)
+ assert.Equal(t, "active", otherValue)
+ assert.Equal(t, []string{"active", "pending"}, comparisonValues)
+ assert.Equal(t, "status", otherField)
+ })
+
+ t.Run("field only no comparison values", func(t *testing.T) {
+ ctx := &RuleContext{Ctx: context.Background(), Data: bag, Parameters: []string{"status"}}
+ otherValue, comparisonValues, otherField := parseDependentValues(ctx)
+ assert.Equal(t, "active", otherValue)
+ assert.Empty(t, comparisonValues)
+ assert.Equal(t, "status", otherField)
+ })
+
+ t.Run("missing field", func(t *testing.T) {
+ ctx := &RuleContext{Ctx: context.Background(), Data: bag, Parameters: []string{"missing", "val"}}
+ otherValue, comparisonValues, _ := parseDependentValues(ctx)
+ assert.Nil(t, otherValue)
+ assert.Equal(t, []string{"val"}, comparisonValues)
+ })
+}
+
+func TestToCamelCase(t *testing.T) {
+ tests := []struct {
+ input string
+ expected string
+ }{
+ {"hello_world", "helloWorld"},
+ {"hello-world", "helloWorld"},
+ {"hello world", "helloWorld"},
+ {"HelloWorld", "helloWorld"},
+ {"", ""},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.input, func(t *testing.T) {
+ assert.Equal(t, tt.expected, toCamelCase(tt.input))
+ })
+ }
+}
+
+func TestToSnakeCase(t *testing.T) {
+ tests := []struct {
+ input string
+ expected string
+ }{
+ {"helloWorld", "hello_world"},
+ {"hello-world", "hello_world"},
+ {"hello world", "hello_world"},
+ {"HelloWorld", "hello_world"},
+ {"", ""},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.input, func(t *testing.T) {
+ assert.Equal(t, tt.expected, toSnakeCase(tt.input))
+ })
+ }
+}
+
+func TestSplitWords(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ expected []string
+ }{
+ {"underscore", "hello_world", []string{"hello", "world"}},
+ {"dash", "hello-world", []string{"hello", "world"}},
+ {"space", "hello world", []string{"hello", "world"}},
+ {"camelCase", "helloWorld", []string{"hello", "World"}},
+ {"PascalCase", "HelloWorld", []string{"Hello", "World"}},
+ {"empty", "", nil},
+ {"single word", "hello", []string{"hello"}},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ assert.Equal(t, tt.expected, splitWords(tt.input))
+ })
+ }
+}
+
+func TestStripHTMLTags(t *testing.T) {
+ tests := []struct {
+ input string
+ expected string
+ }{
+ {"Hello
", "Hello"},
+ {"Bold and italic", "Bold and italic"},
+ {"No tags here", "No tags here"},
+ {"", "alert('xss')"},
+ {"", ""},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.input, func(t *testing.T) {
+ assert.Equal(t, tt.expected, stripHTMLTags(tt.input))
+ })
+ }
+}
+
+func TestGetFileExtension(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ expected string
+ }{
+ {"normal", "photo.jpg", "jpg"},
+ {"multiple dots", "archive.tar.gz", "gz"},
+ {"no extension", "README", ""},
+ {"dot only", ".", ""},
+ {"hidden file", ".gitignore", "gitignore"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ assert.Equal(t, tt.expected, getFileExtension(tt.input))
+ })
+ }
+}
diff --git a/validation/validation.go b/validation/validation.go
index 13e3c6b7c..95d01e0c3 100644
--- a/validation/validation.go
+++ b/validation/validation.go
@@ -95,7 +95,8 @@ func (r *Validation) Make(ctx context.Context, data any, rules map[string]any, o
// Validate that all rule names are known (builtin, custom, or control)
for field, fieldRules := range parsedRules {
for _, pr := range fieldRules {
- if isControlRule(pr.Name) {
+ // skip control rules
+ if slices.Contains([]string{"bail", "sometimes", "nullable"}, pr.Name) {
continue
}
if _, ok := builtinRules[pr.Name]; ok {
diff --git a/validation/validation_test.go b/validation/validation_test.go
index acb4bdcc2..6423a8488 100644
--- a/validation/validation_test.go
+++ b/validation/validation_test.go
@@ -1,8 +1,22 @@
package validation
-/*func TestMake(t *testing.T) {
+import (
+ "context"
+ "strings"
+ "testing"
+
+ "github.com/spf13/cast"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ httpvalidate "github.com/goravel/framework/contracts/validation"
+ "github.com/goravel/framework/errors"
+ "github.com/goravel/framework/http"
+)
+
+func TestMake(t *testing.T) {
type Data struct {
- A string
+ A string `form:"a"`
}
ctx := http.NewContext()
@@ -12,7 +26,7 @@ package validation
tests := []struct {
description string
data any
- rules map[string]string
+ rules map[string]any
options []httpvalidate.Option
expectValidator bool
expectErr error
@@ -23,9 +37,9 @@ package validation
{
description: "success when data is map[string]any",
data: map[string]any{"a": " b "},
- rules: map[string]string{"a": "required"},
+ rules: map[string]any{"a": "required"},
options: []httpvalidate.Option{
- Filters(map[string]string{"a": "trim"}),
+ Filters(map[string]any{"a": "trim"}),
},
expectValidator: true,
expectData: Data{A: "b"},
@@ -33,43 +47,42 @@ package validation
{
description: "success when data is struct",
data: &Data{A: " b"},
- rules: map[string]string{"A": "required"},
+ rules: map[string]any{"a": "required"},
options: []httpvalidate.Option{
- Filters(map[string]string{"A": "trim"}),
+ Filters(map[string]any{"a": "trim"}),
},
expectValidator: true,
expectData: Data{A: "b"},
},
+ {
+ description: "error when data is empty map",
+ data: map[string]any{},
+ rules: map[string]any{"a": "required"},
+ expectValidator: true,
+ expectErrors: true,
+ expectErrorMessage: "The a field is required.",
+ },
{
description: "error when data isn't map[string]any or map[string][]string or struct",
data: "1 ",
- rules: map[string]string{"a": "required"},
+ rules: map[string]any{"a": "required"},
options: []httpvalidate.Option{
- Filters(map[string]string{"a": "trim"}),
+ Filters(map[string]any{"a": "trim"}),
},
expectErr: errors.ValidationDataInvalidType,
},
- {
- description: "error when data is empty map",
- data: map[string]any{},
- rules: map[string]string{"a": "required"},
- options: []httpvalidate.Option{
- Filters(map[string]string{"a": "trim"}),
- },
- expectErr: errors.ValidationEmptyData,
- },
{
description: "error when rule is empty map",
data: map[string]any{"a": "b"},
- rules: map[string]string{},
+ rules: map[string]any{},
expectErr: errors.ValidationEmptyRules,
},
{
description: "error when PrepareForValidation returns error",
data: map[string]any{"a": " b "},
- rules: map[string]string{"a": "required"},
+ rules: map[string]any{"a": "required"},
options: []httpvalidate.Option{
- Filters(map[string]string{"a": "trim"}),
+ Filters(map[string]any{"a": "trim"}),
PrepareForValidation(func(ctx context.Context, data httpvalidate.Data) error {
return assert.AnError
}),
@@ -79,14 +92,13 @@ package validation
{
description: "success when data is map[string]any and with PrepareForValidation",
data: map[string]any{"a": " b "},
- rules: map[string]string{"a": "required"},
+ rules: map[string]any{"a": "required"},
options: []httpvalidate.Option{
- Filters(map[string]string{"a": "trim"}),
+ Filters(map[string]any{"a": "trim"}),
PrepareForValidation(func(ctx context.Context, data httpvalidate.Data) error {
if _, exist := data.Get("a"); exist {
return data.Set("a", "c")
}
-
return nil
}),
},
@@ -96,9 +108,9 @@ package validation
{
description: "success when calling PrepareForValidation with ctx",
data: map[string]any{"a": " b "},
- rules: map[string]string{"a": "required"},
+ rules: map[string]any{"a": "required"},
options: []httpvalidate.Option{
- Filters(map[string]string{"a": "trim"}),
+ Filters(map[string]any{"a": "trim"}),
PrepareForValidation(func(ctx context.Context, data httpvalidate.Data) error {
if _, exist := data.Get("a"); exist {
return data.Set("a", ctx.Value("test"))
@@ -113,9 +125,9 @@ package validation
{
description: "contain errors when data is map[string]any and with Messages, Attributes, PrepareForValidation",
data: map[string]any{"a": "aa "},
- rules: map[string]string{"a": "required", "b": "required"},
+ rules: map[string]any{"a": "required", "b": "required"},
options: []httpvalidate.Option{
- Filters(map[string]string{"a": "trim", "b": "trim"}),
+ Filters(map[string]any{"a": "trim", "b": "trim"}),
Messages(map[string]string{
"b.required": ":attribute can't be empty",
}),
@@ -126,7 +138,6 @@ package validation
if _, exist := data.Get("a"); exist {
return data.Set("a", "c")
}
-
return nil
}),
},
@@ -138,14 +149,13 @@ package validation
{
description: "success when data is struct and with PrepareForValidation",
data: &Data{A: "b"},
- rules: map[string]string{"A": "required"},
+ rules: map[string]any{"a": "required"},
options: []httpvalidate.Option{
- Filters(map[string]string{"A": "trim"}),
+ Filters(map[string]any{"a": "trim"}),
PrepareForValidation(func(ctx context.Context, data httpvalidate.Data) error {
- if _, exist := data.Get("A"); exist {
- return data.Set("A", "c")
+ if _, exist := data.Get("a"); exist {
+ return data.Set("a", "c")
}
-
return nil
}),
},
@@ -155,20 +165,19 @@ package validation
{
description: "contain errors when data is struct and with Messages, Attributes, PrepareForValidation",
data: &Data{A: "b"},
- rules: map[string]string{"A": "required", "B": "required"},
+ rules: map[string]any{"a": "required", "b": "required"},
options: []httpvalidate.Option{
- Filters(map[string]string{"A": "trim", "B": "trim"}),
+ Filters(map[string]any{"a": "trim", "b": "trim"}),
Messages(map[string]string{
- "B.required": ":attribute can't be empty",
+ "b.required": ":attribute can't be empty",
}),
Attributes(map[string]string{
- "B": "b",
+ "b": "b",
}),
PrepareForValidation(func(ctx context.Context, data httpvalidate.Data) error {
if _, exist := data.Get("a"); exist {
return data.Set("a", "c")
}
-
return nil
}),
},
@@ -216,7 +225,7 @@ func TestBindWithNestedStruct(t *testing.T) {
"b": map[string][]string{
"b": {"c", "d"},
},
- }, map[string]string{"a": "required|map", "b": "required|map"})
+ }, map[string]any{"a": "required|map", "b": "required|map"})
require.NoError(t, err)
require.NotNil(t, validator)
@@ -247,8 +256,8 @@ func TestRule_Regex(t *testing.T) {
setup: func(c Case) {
validator, err := validation.Make(context.Background(), map[string]any{
"email": "test@example.com",
- }, map[string]string{
- "email": "regex:^\\S+@\\S+\\.\\S+$",
+ }, map[string]any{
+ "email": `regex:^\S+@\S+\.\S+$`,
})
assert.Nil(t, err, c.description)
assert.NotNil(t, validator, c.description)
@@ -260,13 +269,14 @@ func TestRule_Regex(t *testing.T) {
setup: func(c Case) {
validator, err := validation.Make(context.Background(), map[string]any{
"email": "testexample.com",
- }, map[string]string{
- "email": "regex:^\\S+@\\S+\\.\\S+$",
+ }, map[string]any{
+ "email": `regex:^\S+@\S+\.\S+$`,
})
assert.Nil(t, err, c.description)
assert.NotNil(t, validator, c.description)
+ assert.True(t, validator.Fails(), c.description)
assert.Equal(t, map[string]string{
- "regex": "email value does not pass the regex check",
+ "regex": "The email field format is invalid.",
}, validator.Errors().Get("email"))
},
},
@@ -274,11 +284,11 @@ func TestRule_Regex(t *testing.T) {
description: "success with regex and nested structure",
setup: func(c Case) {
validator, err := validation.Make(context.Background(), map[string]any{
- "user": map[string]string{
+ "user": map[string]any{
"email": "test@example.com",
},
- }, map[string]string{
- "user.email": "regex:^\\S+@\\S+\\.\\S+$",
+ }, map[string]any{
+ "user.email": `regex:^\S+@\S+\.\S+$`,
})
assert.Nil(t, err, c.description)
assert.NotNil(t, validator, c.description)
@@ -289,58 +299,43 @@ func TestRule_Regex(t *testing.T) {
description: "error with regex and nested structure",
setup: func(c Case) {
validator, err := validation.Make(context.Background(), map[string]any{
- "user": map[string]string{
+ "user": map[string]any{
"email": "testexample.com",
},
- }, map[string]string{
- "user.email": "regex:^\\S+@\\S+\\.\\S+$",
+ }, map[string]any{
+ "user.email": `regex:^\S+@\S+\.\S+$`,
})
assert.Nil(t, err, c.description)
assert.NotNil(t, validator, c.description)
- assert.Equal(t, map[string]string{
- "regex": "user.email value does not pass the regex check",
- }, validator.Errors().Get("user.email"))
- },
- },
- {
- description: "panic when regex pattern is missing",
- setup: func(c Case) {
- assert.Panics(t, func() {
- _, err := validation.Make(context.Background(), map[string]any{
- "email": "test@example.com",
- }, map[string]string{
- "email": "regex:",
- })
- assert.NotNil(t, err, c.description)
- }, c.description)
+ assert.True(t, validator.Fails(), c.description)
+ assert.NotEmpty(t, validator.Errors().Get("user.email"))
},
},
{
- description: "success with valid regexp match",
+ description: "error when regex pattern is empty",
setup: func(c Case) {
validator, err := validation.Make(context.Background(), map[string]any{
- "phone": "+1-800-555-5555",
- }, map[string]string{
- "phone": "regexp:^\\+\\d{1,3}-\\d{3}-\\d{3}-\\d{4}$",
+ "email": "test@example.com",
+ }, map[string]any{
+ "email": "regex:",
})
assert.Nil(t, err, c.description)
assert.NotNil(t, validator, c.description)
- assert.False(t, validator.Fails(), c.description)
+ assert.True(t, validator.Fails(), c.description)
},
},
{
- description: "error with invalid regexp match",
+ description: "error with invalid regex match",
setup: func(c Case) {
validator, err := validation.Make(context.Background(), map[string]any{
"phone": "18005555555",
- }, map[string]string{
- "phone": "regexp:^\\+\\d{1,3}-\\d{3}-\\d{3}-\\d{4}$",
+ }, map[string]any{
+ "phone": "regex:^\\+\\d{1,3}-\\d{3}-\\d{3}-\\d{4}$",
})
assert.Nil(t, err, c.description)
assert.NotNil(t, validator, c.description)
- assert.Equal(t, map[string]string{
- "regexp": "phone must match pattern ^\\+\\d{1,3}-\\d{3}-\\d{3}-\\d{4}$",
- }, validator.Errors().Get("phone"))
+ assert.True(t, validator.Fails(), c.description)
+ assert.NotEmpty(t, validator.Errors().Get("phone"))
},
},
}
@@ -360,7 +355,7 @@ func TestRule_Required(t *testing.T) {
setup: func(c Case) {
validator, err := validation.Make(context.Background(), map[string]any{
"name": "goravel",
- }, map[string]string{
+ }, map[string]any{
"name": "required",
})
assert.Nil(t, err, c.description)
@@ -372,10 +367,10 @@ func TestRule_Required(t *testing.T) {
description: "success with nested",
setup: func(c Case) {
validator, err := validation.Make(context.Background(), map[string]any{
- "name": map[string]string{
+ "name": map[string]any{
"first": "Goravel",
},
- }, map[string]string{
+ }, map[string]any{
"name.first": "required",
})
assert.Nil(t, err, c.description)
@@ -388,14 +383,13 @@ func TestRule_Required(t *testing.T) {
setup: func(c Case) {
validator, err := validation.Make(context.Background(), map[string]any{
"name": "",
- }, map[string]string{
+ }, map[string]any{
"name": "required",
})
assert.Nil(t, err, c.description)
assert.NotNil(t, validator, c.description)
- assert.Equal(t, map[string]string{
- "required": "name is required to not be empty",
- }, validator.Errors().Get("name"))
+ assert.True(t, validator.Fails(), c.description)
+ assert.Equal(t, "The name field is required.", validator.Errors().One("name"))
},
},
{
@@ -403,15 +397,14 @@ func TestRule_Required(t *testing.T) {
setup: func(c Case) {
validator, err := validation.Make(context.Background(), map[string]any{
"name": "Goravel",
- }, map[string]string{
+ }, map[string]any{
"name": "required",
"name1": "required",
})
assert.Nil(t, err, c.description)
assert.NotNil(t, validator, c.description)
- assert.Equal(t, map[string]string{
- "required": "name1 is required to not be empty",
- }, validator.Errors().Get("name1"))
+ assert.True(t, validator.Fails(), c.description)
+ assert.Equal(t, "The name1 field is required.", validator.Errors().One("name1"))
},
},
{
@@ -421,13 +414,14 @@ func TestRule_Required(t *testing.T) {
"name": map[string]string{
"first": "",
},
- }, map[string]string{
+ }, map[string]any{
"name.first": "required",
})
assert.Nil(t, err, c.description)
assert.NotNil(t, validator, c.description)
+ assert.True(t, validator.Fails(), c.description)
assert.Equal(t, map[string]string{
- "required": "name.first is required to not be empty",
+ "required": "The name.first field is required.",
}, validator.Errors().Get("name.first"))
},
},
@@ -449,7 +443,7 @@ func TestRule_RequiredIf(t *testing.T) {
validator, err := validation.Make(context.Background(), map[string]any{
"name": "goravel",
"name1": "goravel1",
- }, map[string]string{
+ }, map[string]any{
"name": "required",
"name1": "required_if:name,goravel,goravel1",
})
@@ -463,7 +457,7 @@ func TestRule_RequiredIf(t *testing.T) {
setup: func(c Case) {
validator, err := validation.Make(context.Background(), map[string]any{
"name": "goravel2",
- }, map[string]string{
+ }, map[string]any{
"name": "required",
"name1": "required_if:name,goravel,goravel1",
})
@@ -478,15 +472,13 @@ func TestRule_RequiredIf(t *testing.T) {
validator, err := validation.Make(context.Background(), map[string]any{
"name": "goravel",
"name1": "",
- }, map[string]string{
+ }, map[string]any{
"name": "required",
"name1": "required_if:name,goravel,goravel1",
})
assert.Nil(t, err, c.description)
assert.NotNil(t, validator, c.description)
- assert.Equal(t, map[string]string{
- "required_if": "name1 is required when name is in [goravel,goravel1]",
- }, validator.Errors().Get("name1"))
+ assert.True(t, validator.Fails(), c.description)
},
},
{
@@ -494,14 +486,15 @@ func TestRule_RequiredIf(t *testing.T) {
setup: func(c Case) {
validator, err := validation.Make(context.Background(), map[string]any{
"name": "goravel",
- }, map[string]string{
+ }, map[string]any{
"name": "required",
"name1": "required_if:name,goravel,goravel1",
})
assert.Nil(t, err, c.description)
assert.NotNil(t, validator, c.description)
+ assert.True(t, validator.Fails(), c.description)
assert.Equal(t, map[string]string{
- "required_if": "name1 is required when name is in [goravel,goravel1]",
+ "required_if": "The name1 field is required when name is goravel, goravel1.",
}, validator.Errors().Get("name1"))
},
},
@@ -523,7 +516,7 @@ func TestRule_RequiredUnless(t *testing.T) {
validator, err := validation.Make(context.Background(), map[string]any{
"name": "goravel",
"name1": "goravel1",
- }, map[string]string{
+ }, map[string]any{
"name": "required",
"name1": "required_unless:name,hello,hello1",
})
@@ -537,7 +530,7 @@ func TestRule_RequiredUnless(t *testing.T) {
setup: func(c Case) {
validator, err := validation.Make(context.Background(), map[string]any{
"name": "goravel",
- }, map[string]string{
+ }, map[string]any{
"name": "required",
"name1": "required_unless:name,goravel,goravel1",
})
@@ -552,14 +545,14 @@ func TestRule_RequiredUnless(t *testing.T) {
validator, err := validation.Make(context.Background(), map[string]any{
"name": "goravel",
"name1": "",
- }, map[string]string{
+ }, map[string]any{
"name": "required",
"name1": "required_unless:name,hello,hello1",
})
assert.Nil(t, err, c.description)
assert.NotNil(t, validator, c.description)
assert.Equal(t, map[string]string{
- "required_unless": "name1 field is required unless name is in [hello,hello1]",
+ "required_unless": "The name1 field is required unless name is in hello, hello1.",
}, validator.Errors().Get("name1"))
},
},
@@ -568,14 +561,14 @@ func TestRule_RequiredUnless(t *testing.T) {
setup: func(c Case) {
validator, err := validation.Make(context.Background(), map[string]any{
"name": "goravel",
- }, map[string]string{
+ }, map[string]any{
"name": "required",
"name1": "required_unless:name,hello,hello1",
})
assert.Nil(t, err, c.description)
assert.NotNil(t, validator, c.description)
assert.Equal(t, map[string]string{
- "required_unless": "name1 field is required unless name is in [hello,hello1]",
+ "required_unless": "The name1 field is required unless name is in hello, hello1.",
}, validator.Errors().Get("name1"))
},
},
@@ -597,7 +590,7 @@ func TestRule_RequiredWith(t *testing.T) {
validator, err := validation.Make(context.Background(), map[string]any{
"name": "goravel",
"name2": "goravel2",
- }, map[string]string{
+ }, map[string]any{
"name": "required",
"name2": "required_with:name,name1",
})
@@ -611,7 +604,7 @@ func TestRule_RequiredWith(t *testing.T) {
setup: func(c Case) {
validator, err := validation.Make(context.Background(), map[string]any{
"name": "",
- }, map[string]string{
+ }, map[string]any{
"name": "required_with:name1,name2",
})
assert.Nil(t, err, c.description)
@@ -626,7 +619,7 @@ func TestRule_RequiredWith(t *testing.T) {
"name": "goravel",
"name1": "goravel1",
"name2": "",
- }, map[string]string{
+ }, map[string]any{
"name": "required",
"name1": "required",
"name2": "required_with:name,name1",
@@ -634,7 +627,7 @@ func TestRule_RequiredWith(t *testing.T) {
assert.Nil(t, err, c.description)
assert.NotNil(t, validator, c.description)
assert.Equal(t, map[string]string{
- "required_with": "name2 field is required when [name,name1] is present",
+ "required_with": "The name2 field is required when name, name1 is present.",
}, validator.Errors().Get("name2"))
},
},
@@ -644,7 +637,7 @@ func TestRule_RequiredWith(t *testing.T) {
validator, err := validation.Make(context.Background(), map[string]any{
"name": "goravel",
"name1": "goravel1",
- }, map[string]string{
+ }, map[string]any{
"name": "required",
"name1": "required",
"name2": "required_with:name,name1",
@@ -652,7 +645,7 @@ func TestRule_RequiredWith(t *testing.T) {
assert.Nil(t, err, c.description)
assert.NotNil(t, validator, c.description)
assert.Equal(t, map[string]string{
- "required_with": "name2 field is required when [name,name1] is present",
+ "required_with": "The name2 field is required when name, name1 is present.",
}, validator.Errors().Get("name2"))
},
},
@@ -675,7 +668,7 @@ func TestRule_RequiredWithAll(t *testing.T) {
"name": "goravel",
"name1": "goravel1",
"name2": "goravel2",
- }, map[string]string{
+ }, map[string]any{
"name": "required",
"name1": "required",
"name2": "required_with_all:name,name1",
@@ -686,13 +679,13 @@ func TestRule_RequiredWithAll(t *testing.T) {
},
},
{
- description: "success when required_with_all is true",
+ description: "success when not all fields present",
setup: func(c Case) {
validator, err := validation.Make(context.Background(), map[string]any{
"name": "goravel",
"name1": "",
"name2": "goravel2",
- }, map[string]string{
+ }, map[string]any{
"name": "required",
"name2": "required_with_all:name,name1",
})
@@ -706,7 +699,7 @@ func TestRule_RequiredWithAll(t *testing.T) {
setup: func(c Case) {
validator, err := validation.Make(context.Background(), map[string]any{
"name": "",
- }, map[string]string{
+ }, map[string]any{
"name": "required_with_all:name1,name2",
})
assert.Nil(t, err, c.description)
@@ -721,7 +714,7 @@ func TestRule_RequiredWithAll(t *testing.T) {
"name": "goravel",
"name1": "goravel1",
"name2": "",
- }, map[string]string{
+ }, map[string]any{
"name": "required",
"name1": "required",
"name2": "required_with_all:name,name1",
@@ -729,17 +722,17 @@ func TestRule_RequiredWithAll(t *testing.T) {
assert.Nil(t, err, c.description)
assert.NotNil(t, validator, c.description)
assert.Equal(t, map[string]string{
- "required_with_all": "name2 field is required when [name,name1] is present",
+ "required_with_all": "The name2 field is required when name, name1 are present.",
}, validator.Errors().Get("name2"))
},
},
{
- description: "error when required_with is true and key isn't exist",
+ description: "error when required_with_all is true and key isn't exist",
setup: func(c Case) {
validator, err := validation.Make(context.Background(), map[string]any{
"name": "goravel",
"name1": "goravel1",
- }, map[string]string{
+ }, map[string]any{
"name": "required",
"name1": "required",
"name2": "required_with_all:name,name1",
@@ -747,7 +740,7 @@ func TestRule_RequiredWithAll(t *testing.T) {
assert.Nil(t, err, c.description)
assert.NotNil(t, validator, c.description)
assert.Equal(t, map[string]string{
- "required_with_all": "name2 field is required when [name,name1] is present",
+ "required_with_all": "The name2 field is required when name, name1 are present.",
}, validator.Errors().Get("name2"))
},
},
@@ -769,7 +762,7 @@ func TestRule_RequiredWithout(t *testing.T) {
validator, err := validation.Make(context.Background(), map[string]any{
"name": "goravel",
"name2": "goravel2",
- }, map[string]string{
+ }, map[string]any{
"name": "required",
"name2": "required_without:name,name1",
})
@@ -782,10 +775,10 @@ func TestRule_RequiredWithout(t *testing.T) {
description: "success when required_without is false",
setup: func(c Case) {
validator, err := validation.Make(context.Background(), map[string]any{
- "name": "",
- "name1": "",
- "name2": "",
- }, map[string]string{
+ "name": "goravel",
+ "name1": "goravel1",
+ "name2": "goravel2",
+ }, map[string]any{
"name": "required_without:name1,name2",
})
assert.Nil(t, err, c.description)
@@ -799,14 +792,14 @@ func TestRule_RequiredWithout(t *testing.T) {
validator, err := validation.Make(context.Background(), map[string]any{
"name": "goravel",
"name2": "",
- }, map[string]string{
+ }, map[string]any{
"name": "required",
"name2": "required_without:name,name1",
})
assert.Nil(t, err, c.description)
assert.NotNil(t, validator, c.description)
assert.Equal(t, map[string]string{
- "required_without": "name2 field is required when [name,name1] is not present",
+ "required_without": "The name2 field is required when name, name1 is not present.",
}, validator.Errors().Get("name2"))
},
},
@@ -815,14 +808,14 @@ func TestRule_RequiredWithout(t *testing.T) {
setup: func(c Case) {
validator, err := validation.Make(context.Background(), map[string]any{
"name": "goravel",
- }, map[string]string{
+ }, map[string]any{
"name": "required",
"name2": "required_without:name,name1",
})
assert.Nil(t, err, c.description)
assert.NotNil(t, validator, c.description)
assert.Equal(t, map[string]string{
- "required_without": "name2 field is required when [name,name1] is not present",
+ "required_without": "The name2 field is required when name, name1 is not present.",
}, validator.Errors().Get("name2"))
},
},
@@ -843,7 +836,7 @@ func TestRule_RequiredWithoutAll(t *testing.T) {
setup: func(c Case) {
validator, err := validation.Make(context.Background(), map[string]any{
"name": "goravel",
- }, map[string]string{
+ }, map[string]any{
"name": "required_without_all:name1,name2",
})
assert.Nil(t, err, c.description)
@@ -856,9 +849,8 @@ func TestRule_RequiredWithoutAll(t *testing.T) {
setup: func(c Case) {
validator, err := validation.Make(context.Background(), map[string]any{
"name": "",
- "name1": "",
- "name2": "",
- }, map[string]string{
+ "name1": "goravel1",
+ }, map[string]any{
"name": "required_without_all:name1,name2",
})
assert.Nil(t, err, c.description)
@@ -871,13 +863,13 @@ func TestRule_RequiredWithoutAll(t *testing.T) {
setup: func(c Case) {
validator, err := validation.Make(context.Background(), map[string]any{
"name": "",
- }, map[string]string{
+ }, map[string]any{
"name": "required_without_all:name1,name2",
})
assert.Nil(t, err, c.description)
assert.NotNil(t, validator, c.description)
assert.Equal(t, map[string]string{
- "required_without_all": "name field is required when none of [name1,name2] are present",
+ "required_without_all": "The name field is required when none of name1, name2 are present.",
}, validator.Errors().Get("name"))
},
},
@@ -886,13 +878,13 @@ func TestRule_RequiredWithoutAll(t *testing.T) {
setup: func(c Case) {
validator, err := validation.Make(context.Background(), map[string]any{
"name3": "goravel3",
- }, map[string]string{
+ }, map[string]any{
"name": "required_without_all:name1,name2",
})
assert.Nil(t, err, c.description)
assert.NotNil(t, validator, c.description)
assert.Equal(t, map[string]string{
- "required_without_all": "name field is required when none of [name1,name2] are present",
+ "required_without_all": "The name field is required when none of name1, name2 are present.",
}, validator.Errors().Get("name"))
},
},
@@ -905,142 +897,96 @@ func TestRule_RequiredWithoutAll(t *testing.T) {
}
}
-func TestRule_Int(t *testing.T) {
+func TestAddRules(t *testing.T) {
validation := NewValidation()
- tests := []Case{
- {
- description: "success",
- setup: func(c Case) {
- validator, err := validation.Make(context.Background(), map[string]any{
- "name": 1,
- }, map[string]string{
- "name": "required|int",
- })
- assert.Nil(t, err, c.description)
- assert.NotNil(t, validator, c.description)
- assert.False(t, validator.Fails(), c.description)
- },
- },
- {
- description: "success with range",
- setup: func(c Case) {
- validator, err := validation.Make(context.Background(), map[string]any{
- "name": 3,
- }, map[string]string{
- "name": "required|int:2,4",
- })
- assert.Nil(t, err, c.description)
- assert.NotNil(t, validator, c.description)
- assert.False(t, validator.Fails(), c.description)
- },
- },
- {
- description: "error when type error",
- setup: func(c Case) {
- validator, err := validation.Make(context.Background(), map[string]any{
- "name": "1",
- }, map[string]string{
- "name": "required|int",
- })
- assert.Nil(t, err, c.description)
- assert.NotNil(t, validator, c.description)
- assert.Equal(t, map[string]string{
- "int": "name value must be an integer",
- }, validator.Errors().Get("name"))
- },
- },
- {
- description: "error when value doesn't in the right range",
- setup: func(c Case) {
- validator, err := validation.Make(context.Background(), map[string]any{
- "name": 1,
- }, map[string]string{
- "name": "required|int:2,4",
- })
- assert.Nil(t, err, c.description)
- assert.NotNil(t, validator, c.description)
- assert.Equal(t, map[string]string{
- "int": "name value must be an integer and in the range 2 - 4",
- }, validator.Errors().Get("name"))
- },
- },
- }
- for _, test := range tests {
- t.Run(test.description, func(t *testing.T) {
- test.setup(test)
- })
- }
+ t.Run("success", func(t *testing.T) {
+ err := validation.AddRules([]httpvalidate.Rule{&CustomUppercase{}})
+ assert.Nil(t, err)
+ })
+
+ t.Run("duplicate rule", func(t *testing.T) {
+ err := validation.AddRules([]httpvalidate.Rule{&Duplicate{}})
+ assert.EqualError(t, err, "duplicate rule name: required")
+ })
}
-func TestRule_Uint(t *testing.T) {
+func TestCustomFilters(t *testing.T) {
validation := NewValidation()
- tests := []Case{
- {
- description: "success",
- setup: func(c Case) {
- validator, err := validation.Make(context.Background(), map[string]any{
- "name": 1,
- }, map[string]string{
- "name": "required|uint",
- })
- assert.Nil(t, err, c.description)
- assert.NotNil(t, validator, c.description)
- assert.False(t, validator.Fails(), c.description)
- },
- },
- {
- description: "error when type error",
- setup: func(c Case) {
- validator, err := validation.Make(context.Background(), map[string]any{
- "name": "s",
- }, map[string]string{
- "name": "required|uint",
- })
- assert.Nil(t, err, c.description)
- assert.NotNil(t, validator, c.description)
- assert.Equal(t, map[string]string{
- "uint": "name value must be an unsigned integer(>= 0)",
- }, validator.Errors().Get("name"))
- },
- },
- }
+ err := validation.AddFilters([]httpvalidate.Filter{&DefaultFilter{}})
+ assert.Nil(t, err)
- for _, test := range tests {
- t.Run(test.description, func(t *testing.T) {
- test.setup(test)
- })
+ filters := validation.Filters()
+ defaultFilterFunc := filters[0].Handle(context.Background()).(func(string, ...string) string)
+ assert.Equal(t, "default", defaultFilterFunc("", "default"))
+ assert.Equal(t, "a", defaultFilterFunc("a"))
+}
+
+func TestCustomFiltersIntegration(t *testing.T) {
+ mp := map[string]any{
+ "name": "krishan ",
+ "age": " 22 ",
+ "empty": "",
}
+
+ validation := NewValidation()
+ err := validation.AddFilters([]httpvalidate.Filter{&DefaultFilter{}})
+ assert.Nil(t, err)
+
+ validator, err := validation.Make(context.Background(), mp, map[string]any{
+ "name": "required",
+ "age": "required",
+ "empty": "required",
+ }, Filters(map[string]any{
+ "empty": "default:emptyDefault",
+ "name": "trim|upper",
+ "age": "trim|to_int",
+ }))
+
+ assert.Nil(t, err)
+ var newMp map[string]any
+ assert.Nil(t, validator.Bind(&newMp))
+
+ assert.Equal(t, "KRISHAN", newMp["name"])
+ assert.Equal(t, 22, newMp["age"])
+ assert.Equal(t, "emptyDefault", newMp["empty"])
}
-func TestRule_Bool(t *testing.T) {
+func TestCustomRule(t *testing.T) {
validation := NewValidation()
+ err := validation.AddRules([]httpvalidate.Rule{&CustomUppercase{}, &CustomLowercase{}})
+ assert.Nil(t, err)
+
tests := []Case{
{
description: "success",
setup: func(c Case) {
validator, err := validation.Make(context.Background(), map[string]any{
- "name1": "on",
- "name2": "off",
- "name3": "yes",
- "name4": "no",
- "name5": true,
- "name6": false,
- "name7": "true",
- "name8": "false",
- "name9": "1",
- "name10": "0",
- }, map[string]string{
- "name1": "bool",
- "name2": "bool",
- "name3": "bool",
- "name4": "bool",
- "name5": "bool",
- "name6": "bool",
- "name7": "bool",
- "name8": "bool",
- "name9": "bool",
- "name10": "bool",
+ "name1": "on",
+ "name2": "off",
+ "name3": "yes",
+ "name4": "no",
+ "name5": true,
+ "name6": false,
+ "name7": "true",
+ "name8": "false",
+ "name9": "1",
+ "name10": "0",
+ "name": "ABC",
+ "address": "de",
+ }, map[string]any{
+ "name1": "bool",
+ "name2": "bool",
+ "name3": "bool",
+ "name4": "bool",
+ "name5": "bool",
+ "name6": "bool",
+ "name7": "bool",
+ "name8": "bool",
+ "name9": "bool",
+ "name10": "bool",
+ "name": "required|custom_uppercase:3",
+ "address": "required|custom_lowercase:2",
})
assert.Nil(t, err, c.description)
assert.NotNil(t, validator, c.description)
@@ -1048,22 +994,25 @@ func TestRule_Bool(t *testing.T) {
},
},
{
- description: "error when type error",
+ description: "error",
setup: func(c Case) {
validator, err := validation.Make(context.Background(), map[string]any{
- "name1": 1,
- "name2": 0,
- "name3": "a",
- }, map[string]string{
- "name1": "bool",
- "name2": "bool",
- "name3": "bool",
+ "name1": 1,
+ "name2": 0,
+ "name3": "a",
+ "name": "abc",
+ "address": "DE",
+ }, map[string]any{
+ "name1": "bool",
+ "name2": "bool",
+ "name3": "bool",
+ "name": "required|custom_uppercase:3",
+ "address": "required|custom_lowercase:2",
})
assert.Nil(t, err, c.description)
assert.NotNil(t, validator, c.description)
- assert.Equal(t, map[string]string{"bool": "name1 value must be a bool"}, validator.Errors().Get("name1"))
- assert.Nil(t, validator.Errors().Get("name2"))
- assert.Equal(t, map[string]string{"bool": "name3 value must be a bool"}, validator.Errors().Get("name3"))
+ assert.Equal(t, map[string]string{"custom_uppercase": "name must be upper"}, validator.Errors().Get("name"))
+ assert.Equal(t, map[string]string{"custom_lowercase": "address must be lower"}, validator.Errors().Get("address"))
},
},
}
@@ -1075,1867 +1024,129 @@ func TestRule_Bool(t *testing.T) {
}
}
-func TestRule_String(t *testing.T) {
+func TestValidated(t *testing.T) {
validation := NewValidation()
- tests := []Case{
- {
- description: "success",
- setup: func(c Case) {
- validator, err := validation.Make(context.Background(), map[string]any{
- "name": "1",
- }, map[string]string{
- "name": "required|string",
- })
- assert.Nil(t, err, c.description)
- assert.NotNil(t, validator, c.description)
- assert.False(t, validator.Fails(), c.description)
- },
- },
- {
- description: "success with range",
- setup: func(c Case) {
- validator, err := validation.Make(context.Background(), map[string]any{
- "name": "abc",
- }, map[string]string{
- "name": "required|string:2,4",
- })
- assert.Nil(t, err, c.description)
- assert.NotNil(t, validator, c.description)
- assert.False(t, validator.Fails(), c.description)
- },
- },
- {
- description: "error when type error",
- setup: func(c Case) {
- validator, err := validation.Make(context.Background(), map[string]any{
- "name": 1,
- }, map[string]string{
- "name": "required|string",
- })
- assert.Nil(t, err, c.description)
- assert.NotNil(t, validator, c.description)
- assert.Equal(t, map[string]string{
- "string": "name value must be a string",
- }, validator.Errors().Get("name"))
- },
- },
- {
- description: "error when value doesn't in the right range",
- setup: func(c Case) {
- validator, err := validation.Make(context.Background(), map[string]any{
- "name": "a",
- }, map[string]string{
- "name": "required|string:2,4",
- })
- assert.Nil(t, err, c.description)
- assert.NotNil(t, validator, c.description)
- assert.Equal(t, map[string]string{
- "string": "name value must be a string",
- }, validator.Errors().Get("name"))
- },
- },
- }
+ validator, err := validation.Make(context.Background(), map[string]any{
+ "name": "goravel",
+ "email": "test@example.com",
+ "extra": "not in rules",
+ }, map[string]any{
+ "name": "required",
+ "email": "required|email",
+ })
+ assert.Nil(t, err)
+ assert.False(t, validator.Fails())
- for _, test := range tests {
- t.Run(test.description, func(t *testing.T) {
- test.setup(test)
- })
- }
+ validated := validator.Validated()
+ assert.Equal(t, "goravel", validated["name"])
+ assert.Equal(t, "test@example.com", validated["email"])
+ // "extra" should not be in validated data
+ _, exists := validated["extra"]
+ assert.False(t, exists)
}
-func TestRule_Float(t *testing.T) {
+func TestWildcardRules(t *testing.T) {
validation := NewValidation()
- tests := []Case{
- {
- description: "success",
- setup: func(c Case) {
- validator, err := validation.Make(context.Background(), map[string]any{
- "name": 1.1,
- }, map[string]string{
- "name": "required|float",
- })
- assert.Nil(t, err, c.description)
- assert.NotNil(t, validator, c.description)
- assert.False(t, validator.Fails(), c.description)
- },
- },
- {
- description: "error when type error",
- setup: func(c Case) {
- validator, err := validation.Make(context.Background(), map[string]any{
- "name": "a",
- }, map[string]string{
- "name": "required|float",
- })
- assert.Nil(t, err, c.description)
- assert.NotNil(t, validator, c.description)
- assert.Equal(t, map[string]string{
- "float": "name value must be a float",
- }, validator.Errors().Get("name"))
- },
- },
- }
- for _, test := range tests {
- t.Run(test.description, func(t *testing.T) {
- test.setup(test)
+ t.Run("validates wildcard fields", func(t *testing.T) {
+ validator, err := validation.Make(context.Background(), map[string]any{
+ "users": []any{
+ map[string]any{"name": "Alice"},
+ map[string]any{"name": ""},
+ },
+ }, map[string]any{
+ "users.*.name": "required",
})
- }
+ assert.Nil(t, err)
+ assert.True(t, validator.Fails())
+ })
+
+ t.Run("success with all valid", func(t *testing.T) {
+ validator, err := validation.Make(context.Background(), map[string]any{
+ "users": []any{
+ map[string]any{"name": "Alice"},
+ map[string]any{"name": "Bob"},
+ },
+ }, map[string]any{
+ "users.*.name": "required|string",
+ })
+ assert.Nil(t, err)
+ assert.False(t, validator.Fails())
+ })
}
-func TestRule_Slice(t *testing.T) {
+func TestSliceRuleSyntax(t *testing.T) {
validation := NewValidation()
- tests := []Case{
- {
- description: "success",
- setup: func(c Case) {
- validator, err := validation.Make(context.Background(), map[string]any{
- "name1": []int{1, 2},
- "name2": []uint{1, 2},
- "name3": []string{"a", "b"},
- }, map[string]string{
- "name1": "required|slice",
- "name2": "required|slice",
- "name3": "required|slice",
- })
- assert.Nil(t, err, c.description)
- assert.NotNil(t, validator, c.description)
- assert.False(t, validator.Fails(), c.description)
- },
- },
- {
- description: "error when type error",
- setup: func(c Case) {
- validator, err := validation.Make(context.Background(), map[string]any{
- "name1": 1,
- "name2": "a",
- "name3": true,
- }, map[string]string{
- "name1": "required|slice",
- "name2": "required|slice",
- "name3": "required|slice",
- })
- assert.Nil(t, err, c.description)
- assert.NotNil(t, validator, c.description)
- assert.Equal(t, map[string]string{"slice": "name1 value must be a slice"}, validator.Errors().Get("name1"))
- assert.Equal(t, map[string]string{"slice": "name2 value must be a slice"}, validator.Errors().Get("name2"))
- assert.Equal(t, map[string]string{"slice": "name3 value must be a slice"}, validator.Errors().Get("name3"))
- },
- },
- }
- for _, test := range tests {
- t.Run(test.description, func(t *testing.T) {
- test.setup(test)
+ t.Run("regex with pipe in pattern using slice syntax", func(t *testing.T) {
+ validator, err := validation.Make(context.Background(), map[string]any{
+ "code": "foo",
+ }, map[string]any{
+ "code": []string{"required", "regex:^(foo|bar)$", "string"},
})
- }
+ assert.Nil(t, err)
+ assert.NotNil(t, validator)
+ assert.False(t, validator.Fails())
+ })
+
+ t.Run("regex with pipe fails validation using slice syntax", func(t *testing.T) {
+ validator, err := validation.Make(context.Background(), map[string]any{
+ "code": "baz",
+ }, map[string]any{
+ "code": []string{"required", "regex:^(foo|bar)$"},
+ })
+ assert.Nil(t, err)
+ assert.NotNil(t, validator)
+ assert.True(t, validator.Fails())
+ })
+
+ t.Run("mixed string and slice rules", func(t *testing.T) {
+ validator, err := validation.Make(context.Background(), map[string]any{
+ "name": "goravel",
+ "code": "foo",
+ }, map[string]any{
+ "name": "required|string",
+ "code": []string{"required", "regex:^(foo|bar)$"},
+ })
+ assert.Nil(t, err)
+ assert.NotNil(t, validator)
+ assert.False(t, validator.Fails())
+ })
+
+ t.Run("invalid rule type returns error", func(t *testing.T) {
+ _, err := validation.Make(context.Background(), map[string]any{
+ "name": "goravel",
+ }, map[string]any{
+ "name": 123,
+ })
+ assert.ErrorIs(t, err, errors.ValidationInvalidRuleType)
+ })
+
+ t.Run("slice syntax with filters", func(t *testing.T) {
+ validator, err := validation.Make(context.Background(), map[string]any{
+ "name": " Goravel ",
+ }, map[string]any{
+ "name": []string{"required", "string"},
+ }, Filters(map[string]any{
+ "name": []string{"trim", "lower"},
+ }))
+ assert.Nil(t, err)
+ assert.NotNil(t, validator)
+ assert.False(t, validator.Fails())
+
+ val := validator.Validated()
+ assert.Equal(t, "goravel", val["name"])
+ })
}
-func TestRule_In(t *testing.T) {
- validation := NewValidation()
- tests := []Case{
- {
- description: "success",
- setup: func(c Case) {
- validator, err := validation.Make(context.Background(), map[string]any{
- "name1": 1,
- "name2": "a",
- }, map[string]string{
- "name1": "required|in:1,2",
- "name2": "required|in:a,b",
- })
- assert.Nil(t, err, c.description)
- assert.NotNil(t, validator, c.description)
- assert.False(t, validator.Fails(), c.description)
- },
- },
- {
- description: "error",
- setup: func(c Case) {
- validator, err := validation.Make(context.Background(), map[string]any{
- "name1": 3,
- "name2": "c",
- }, map[string]string{
- "name1": "required|in:1,2",
- "name2": "required|in:a,b",
- })
- assert.Nil(t, err, c.description)
- assert.NotNil(t, validator, c.description)
- assert.Equal(t, map[string]string{"in": "name1 value must be in the enum [1 2]"}, validator.Errors().Get("name1"))
- assert.Equal(t, map[string]string{"in": "name2 value must be in the enum [a b]"}, validator.Errors().Get("name2"))
- },
- },
- }
-
- for _, test := range tests {
- t.Run(test.description, func(t *testing.T) {
- test.setup(test)
- })
- }
-}
-
-func TestRule_NotIn(t *testing.T) {
- validation := NewValidation()
- tests := []Case{
- {
- description: "success",
- setup: func(c Case) {
- validator, err := validation.Make(context.Background(), map[string]any{
- "name1": 3,
- "name2": "c",
- }, map[string]string{
- "name1": "required|not_in:1,2",
- "name2": "required|not_in:a,b",
- })
- assert.Nil(t, err, c.description)
- assert.NotNil(t, validator, c.description)
- assert.False(t, validator.Fails(), c.description)
- },
- },
- {
- description: "error",
- setup: func(c Case) {
- validator, err := validation.Make(context.Background(), map[string]any{
- "name1": 1,
- "name2": "a",
- }, map[string]string{
- "name1": "required|not_in:1,2",
- "name2": "required|not_in:a,b",
- })
- assert.Nil(t, err, c.description)
- assert.NotNil(t, validator, c.description)
- assert.Equal(t, map[string]string{"not_in": "name1 value must not be in the given enum list [%!d(string=1) %!d(string=2)]"}, validator.Errors().Get("name1"))
- assert.Equal(t, map[string]string{"not_in": "name2 value must not be in the given enum list [%!d(string=a) %!d(string=b)]"}, validator.Errors().Get("name2"))
- },
- },
- }
-
- for _, test := range tests {
- t.Run(test.description, func(t *testing.T) {
- test.setup(test)
- })
- }
-}
-
-func TestRule_StartsWith(t *testing.T) {
- validation := NewValidation()
- tests := []Case{
- {
- description: "success",
- setup: func(c Case) {
- validator, err := validation.Make(context.Background(), map[string]any{
- "name": "abc",
- }, map[string]string{
- "name": "required|starts_with:ab",
- })
- assert.Nil(t, err, c.description)
- assert.NotNil(t, validator, c.description)
- assert.False(t, validator.Fails(), c.description)
- },
- },
- {
- description: "error",
- setup: func(c Case) {
- validator, err := validation.Make(context.Background(), map[string]any{
- "name": "a",
- }, map[string]string{
- "name": "required|starts_with:ab",
- })
- assert.Nil(t, err, c.description)
- assert.NotNil(t, validator, c.description)
- assert.Equal(t, map[string]string{"starts_with": "name value does not start with ab"}, validator.Errors().Get("name"))
- },
- },
- }
-
- for _, test := range tests {
- t.Run(test.description, func(t *testing.T) {
- test.setup(test)
- })
- }
-}
-
-func TestRule_EndsWith(t *testing.T) {
- validation := NewValidation()
- tests := []Case{
- {
- description: "success",
- setup: func(c Case) {
- validator, err := validation.Make(context.Background(), map[string]any{
- "name": "cab",
- }, map[string]string{
- "name": "required|ends_with:ab",
- })
- assert.Nil(t, err, c.description)
- assert.NotNil(t, validator, c.description)
- assert.False(t, validator.Fails(), c.description)
- },
- },
- {
- description: "error",
- setup: func(c Case) {
- validator, err := validation.Make(context.Background(), map[string]any{
- "name": "a",
- }, map[string]string{
- "name": "required|ends_with:ab",
- })
- assert.Nil(t, err, c.description)
- assert.NotNil(t, validator, c.description)
- assert.Equal(t, map[string]string{"ends_with": "name value does not end with ab"}, validator.Errors().Get("name"))
- },
- },
- }
-
- for _, test := range tests {
- t.Run(test.description, func(t *testing.T) {
- test.setup(test)
- })
- }
-}
-
-func TestRule_Between(t *testing.T) {
- validation := NewValidation()
- tests := []Case{
- {
- description: "success",
- setup: func(c Case) {
- validator, err := validation.Make(context.Background(), map[string]any{
- "name": 2,
- }, map[string]string{
- "name": "required|between:1,3",
- })
- assert.Nil(t, err, c.description)
- assert.NotNil(t, validator, c.description)
- assert.False(t, validator.Fails(), c.description)
- },
- },
- {
- description: "error",
- setup: func(c Case) {
- validator, err := validation.Make(context.Background(), map[string]any{
- "name": 1,
- }, map[string]string{
- "name": "required|between:2,4",
- })
- assert.Nil(t, err, c.description)
- assert.NotNil(t, validator, c.description)
- assert.Equal(t, map[string]string{"between": "name field did not pass validation"}, validator.Errors().Get("name"))
- },
- },
- }
-
- for _, test := range tests {
- t.Run(test.description, func(t *testing.T) {
- test.setup(test)
- })
- }
-}
-
-func TestRule_Max(t *testing.T) {
- validation := NewValidation()
- tests := []Case{
- {
- description: "success",
- setup: func(c Case) {
- validator, err := validation.Make(context.Background(), map[string]any{
- "name": 2,
- }, map[string]string{
- "name": "required|max:3",
- })
- assert.Nil(t, err, c.description)
- assert.NotNil(t, validator, c.description)
- assert.False(t, validator.Fails(), c.description)
- },
- },
- {
- description: "error",
- setup: func(c Case) {
- validator, err := validation.Make(context.Background(), map[string]any{
- "name": 4,
- }, map[string]string{
- "name": "required|max:3",
- })
- assert.Nil(t, err, c.description)
- assert.NotNil(t, validator, c.description)
- assert.Equal(t, map[string]string{"max": "name max value is 3"}, validator.Errors().Get("name"))
- },
- },
- }
-
- for _, test := range tests {
- t.Run(test.description, func(t *testing.T) {
- test.setup(test)
- })
- }
-}
-
-func TestRule_Min(t *testing.T) {
- validation := NewValidation()
- tests := []Case{
- {
- description: "success",
- setup: func(c Case) {
- validator, err := validation.Make(context.Background(), map[string]any{
- "name": 3,
- }, map[string]string{
- "name": "required|min:3",
- })
- assert.Nil(t, err, c.description)
- assert.NotNil(t, validator, c.description)
- assert.False(t, validator.Fails(), c.description)
- },
- },
- {
- description: "error",
- setup: func(c Case) {
- validator, err := validation.Make(context.Background(), map[string]any{
- "name": 2,
- }, map[string]string{
- "name": "required|min:3",
- })
- assert.Nil(t, err, c.description)
- assert.NotNil(t, validator, c.description)
- assert.Equal(t, map[string]string{"min": "name min value is 3"}, validator.Errors().Get("name"))
- },
- },
- }
-
- for _, test := range tests {
- t.Run(test.description, func(t *testing.T) {
- test.setup(test)
- })
- }
-}
-
-func TestRule_Eq(t *testing.T) {
- validation := NewValidation()
- tests := []Case{
- {
- description: "success",
- setup: func(c Case) {
- validator, err := validation.Make(context.Background(), map[string]any{
- "name": "a",
- }, map[string]string{
- "name": "required|eq:a",
- })
- assert.Nil(t, err, c.description)
- assert.NotNil(t, validator, c.description)
- assert.False(t, validator.Fails(), c.description)
- },
- },
- {
- description: "error",
- setup: func(c Case) {
- validator, err := validation.Make(context.Background(), map[string]any{
- "name": "b",
- }, map[string]string{
- "name": "required|eq:a",
- })
- assert.Nil(t, err, c.description)
- assert.NotNil(t, validator, c.description)
- assert.Equal(t, map[string]string{"eq": "name field did not pass validation"}, validator.Errors().Get("name"))
- },
- },
- }
-
- for _, test := range tests {
- t.Run(test.description, func(t *testing.T) {
- test.setup(test)
- })
- }
-}
-
-func TestRule_Ne(t *testing.T) {
- validation := NewValidation()
- tests := []Case{
- {
- description: "success",
- setup: func(c Case) {
- validator, err := validation.Make(context.Background(), map[string]any{
- "name": "b",
- }, map[string]string{
- "name": "required|ne:a",
- })
- assert.Nil(t, err, c.description)
- assert.NotNil(t, validator, c.description)
- assert.False(t, validator.Fails(), c.description)
- },
- },
- {
- description: "error",
- setup: func(c Case) {
- validator, err := validation.Make(context.Background(), map[string]any{
- "name": "a",
- }, map[string]string{
- "name": "required|ne:a",
- })
- assert.Nil(t, err, c.description)
- assert.NotNil(t, validator, c.description)
- assert.Equal(t, map[string]string{"ne": "name field did not pass validation"}, validator.Errors().Get("name"))
- },
- },
- }
-
- for _, test := range tests {
- t.Run(test.description, func(t *testing.T) {
- test.setup(test)
- })
- }
-}
-
-func TestRule_Lt(t *testing.T) {
- validation := NewValidation()
- tests := []Case{
- {
- description: "success",
- setup: func(c Case) {
- validator, err := validation.Make(context.Background(), map[string]any{
- "name": 1,
- }, map[string]string{
- "name": "required|lt:2",
- })
- assert.Nil(t, err, c.description)
- assert.NotNil(t, validator, c.description)
- assert.False(t, validator.Fails(), c.description)
- },
- },
- {
- description: "error",
- setup: func(c Case) {
- validator, err := validation.Make(context.Background(), map[string]any{
- "name": 2,
- }, map[string]string{
- "name": "required|lt:1",
- })
- assert.Nil(t, err, c.description)
- assert.NotNil(t, validator, c.description)
- assert.Equal(t, map[string]string{"lt": "name value should be less than 1"}, validator.Errors().Get("name"))
- },
- },
- }
-
- for _, test := range tests {
- t.Run(test.description, func(t *testing.T) {
- test.setup(test)
- })
- }
-}
-
-func TestRule_Gt(t *testing.T) {
- validation := NewValidation()
- tests := []Case{
- {
- description: "success",
- setup: func(c Case) {
- validator, err := validation.Make(context.Background(), map[string]any{
- "name": 2,
- }, map[string]string{
- "name": "required|gt:1",
- })
- assert.Nil(t, err, c.description)
- assert.NotNil(t, validator, c.description)
- assert.False(t, validator.Fails(), c.description)
- },
- },
- {
- description: "error",
- setup: func(c Case) {
- validator, err := validation.Make(context.Background(), map[string]any{
- "name": 1,
- }, map[string]string{
- "name": "required|gt:2",
- })
- assert.Nil(t, err, c.description)
- assert.NotNil(t, validator, c.description)
- assert.Equal(t, map[string]string{"gt": "name value should be greater than 2"}, validator.Errors().Get("name"))
- },
- },
- }
-
- for _, test := range tests {
- t.Run(test.description, func(t *testing.T) {
- test.setup(test)
- })
- }
-}
-
-func TestRule_Len(t *testing.T) {
- validation := NewValidation()
- tests := []Case{
- {
- description: "success",
- setup: func(c Case) {
- validator, err := validation.Make(context.Background(), map[string]any{
- "name": "abc",
- "name1": [3]string{"a", "b", "c"},
- "name2": []string{"a", "b", "c"},
- "name3": map[string]string{
- "a": "a1",
- "b": "b1",
- "c": "c1",
- },
- }, map[string]string{
- "name": "required|len:3",
- "name1": "required|len:3",
- "name2": "required|len:3",
- "name3": "required|len:3",
- })
- assert.Nil(t, err, c.description)
- assert.NotNil(t, validator, c.description)
- assert.False(t, validator.Fails(), c.description)
- },
- },
- {
- description: "error",
- setup: func(c Case) {
- validator, err := validation.Make(context.Background(), map[string]any{
- "name": "abc",
- "name1": [3]string{"a", "b", "c"},
- "name2": []string{"a", "b", "c"},
- "name3": map[string]string{
- "a": "a1",
- "b": "b1",
- "c": "c1",
- },
- }, map[string]string{
- "name": "required|len:2",
- "name1": "required|len:2",
- "name2": "required|len:2",
- "name3": "required|len:2",
- })
- assert.Nil(t, err, c.description)
- assert.NotNil(t, validator, c.description)
- assert.Equal(t, map[string]string{"len": "name field did not pass validation"}, validator.Errors().Get("name"))
- assert.Equal(t, map[string]string{"len": "name1 field did not pass validation"}, validator.Errors().Get("name1"))
- assert.Equal(t, map[string]string{"len": "name2 field did not pass validation"}, validator.Errors().Get("name2"))
- assert.Equal(t, map[string]string{"len": "name3 field did not pass validation"}, validator.Errors().Get("name3"))
- },
- },
- }
-
- for _, test := range tests {
- t.Run(test.description, func(t *testing.T) {
- test.setup(test)
- })
- }
-}
-
-func TestRule_MinLen(t *testing.T) {
- validation := NewValidation()
- tests := []Case{
- {
- description: "success",
- setup: func(c Case) {
- validator, err := validation.Make(context.Background(), map[string]any{
- "name": "abc",
- "name1": [3]string{"a", "b", "c"},
- "name2": []string{"a", "b", "c"},
- "name3": map[string]string{
- "a": "a1",
- "b": "b1",
- "c": "c1",
- },
- }, map[string]string{
- "name": "required|min_len:2",
- "name1": "required|min_len:2",
- "name2": "required|min_len:2",
- "name3": "required|min_len:2",
- })
- assert.Nil(t, err, c.description)
- assert.NotNil(t, validator, c.description)
- assert.False(t, validator.Fails(), c.description)
- },
- },
- {
- description: "error",
- setup: func(c Case) {
- validator, err := validation.Make(context.Background(), map[string]any{
- "name": "abc",
- "name1": [3]string{"a", "b", "c"},
- "name2": []string{"a", "b", "c"},
- "name3": map[string]string{
- "a": "a1",
- "b": "b1",
- "c": "c1",
- },
- }, map[string]string{
- "name": "required|min_len:4",
- "name1": "required|min_len:4",
- "name2": "required|min_len:4",
- "name3": "required|min_len:4",
- })
- assert.Nil(t, err, c.description)
- assert.NotNil(t, validator, c.description)
- assert.Equal(t, map[string]string{"min_len": "name min length is 4"}, validator.Errors().Get("name"))
- assert.Equal(t, map[string]string{"min_len": "name1 min length is 4"}, validator.Errors().Get("name1"))
- assert.Equal(t, map[string]string{"min_len": "name2 min length is 4"}, validator.Errors().Get("name2"))
- assert.Equal(t, map[string]string{"min_len": "name3 min length is 4"}, validator.Errors().Get("name3"))
- },
- },
- }
-
- for _, test := range tests {
- t.Run(test.description, func(t *testing.T) {
- test.setup(test)
- })
- }
-}
-
-func TestRule_MaxLen(t *testing.T) {
- validation := NewValidation()
- tests := []Case{
- {
- description: "success",
- setup: func(c Case) {
- validator, err := validation.Make(context.Background(), map[string]any{
- "name": "abc",
- "name1": [3]string{"a", "b", "c"},
- "name2": []string{"a", "b", "c"},
- "name3": map[string]string{
- "a": "a1",
- "b": "b1",
- "c": "c1",
- },
- }, map[string]string{
- "name": "required|max_len:4",
- "name1": "required|max_len:4",
- "name2": "required|max_len:4",
- "name3": "required|max_len:4",
- })
- assert.Nil(t, err, c.description)
- assert.NotNil(t, validator, c.description)
- assert.False(t, validator.Fails(), c.description)
- },
- },
- {
- description: "error",
- setup: func(c Case) {
- validator, err := validation.Make(context.Background(), map[string]any{
- "name": "abc",
- "name1": [3]string{"a", "b", "c"},
- "name2": []string{"a", "b", "c"},
- "name3": map[string]string{
- "a": "a1",
- "b": "b1",
- "c": "c1",
- },
- }, map[string]string{
- "name": "required|max_len:2",
- "name1": "required|max_len:2",
- "name2": "required|max_len:2",
- "name3": "required|max_len:2",
- })
- assert.Nil(t, err, c.description)
- assert.NotNil(t, validator, c.description)
- assert.Equal(t, map[string]string{"max_len": "name max length is 2"}, validator.Errors().Get("name"))
- assert.Equal(t, map[string]string{"max_len": "name1 max length is 2"}, validator.Errors().Get("name1"))
- assert.Equal(t, map[string]string{"max_len": "name2 max length is 2"}, validator.Errors().Get("name2"))
- assert.Equal(t, map[string]string{"max_len": "name3 max length is 2"}, validator.Errors().Get("name3"))
- },
- },
- }
-
- for _, test := range tests {
- t.Run(test.description, func(t *testing.T) {
- test.setup(test)
- })
- }
-}
-
-func TestRule_Email(t *testing.T) {
- validation := NewValidation()
- tests := []Case{
- {
- description: "success",
- setup: func(c Case) {
- validator, err := validation.Make(context.Background(), map[string]any{
- "name": "hello@goravel.com",
- }, map[string]string{
- "name": "required|email",
- })
- assert.Nil(t, err, c.description)
- assert.NotNil(t, validator, c.description)
- assert.False(t, validator.Fails(), c.description)
- },
- },
- {
- description: "error",
- setup: func(c Case) {
- validator, err := validation.Make(context.Background(), map[string]any{
- "name": "abc",
- }, map[string]string{
- "name": "required|email",
- })
- assert.Nil(t, err, c.description)
- assert.NotNil(t, validator, c.description)
- assert.Equal(t, map[string]string{"email": "name value is an invalid email address"}, validator.Errors().Get("name"))
- },
- },
- }
-
- for _, test := range tests {
- t.Run(test.description, func(t *testing.T) {
- test.setup(test)
- })
- }
-}
-
-func TestRule_Array(t *testing.T) {
- validation := NewValidation()
- tests := []Case{
- {
- description: "success",
- setup: func(c Case) {
- validator, err := validation.Make(context.Background(), map[string]any{
- "name": [2]string{"a", "b"},
- "name1": []string{"a", "b"},
- }, map[string]string{
- "name": "required|array",
- "name1": "required|array",
- })
- assert.Nil(t, err, c.description)
- assert.NotNil(t, validator, c.description)
- assert.False(t, validator.Fails(), c.description)
- },
- },
- {
- description: "error",
- setup: func(c Case) {
- validator, err := validation.Make(context.Background(), map[string]any{
- "name": "a",
- "name1": 1,
- "name2": true,
- }, map[string]string{
- "name": "required|array",
- "name1": "required|array",
- "name2": "required|array",
- })
- assert.Nil(t, err, c.description)
- assert.NotNil(t, validator, c.description)
- assert.Equal(t, map[string]string{"array": "name value must be an array"}, validator.Errors().Get("name"))
- assert.Equal(t, map[string]string{"array": "name1 value must be an array"}, validator.Errors().Get("name1"))
- assert.Equal(t, map[string]string{"array": "name2 value must be an array"}, validator.Errors().Get("name2"))
- },
- },
- }
-
- for _, test := range tests {
- t.Run(test.description, func(t *testing.T) {
- test.setup(test)
- })
- }
-}
-
-func TestRule_Map(t *testing.T) {
- validation := NewValidation()
- tests := []Case{
- {
- description: "success",
- setup: func(c Case) {
- validator, err := validation.Make(context.Background(), map[string]any{
- "name": map[string]string{"a": "a1"},
- }, map[string]string{
- "name": "required|map",
- })
- assert.Nil(t, err, c.description)
- assert.NotNil(t, validator, c.description)
- assert.False(t, validator.Fails(), c.description)
- },
- },
- {
- description: "error",
- setup: func(c Case) {
- validator, err := validation.Make(context.Background(), map[string]any{
- "name": "a",
- "name1": 1,
- "name2": true,
- "name3": []string{"a"},
- }, map[string]string{
- "name": "required|map",
- "name1": "required|map",
- "name2": "required|map",
- "name3": "required|map",
- })
- assert.Nil(t, err, c.description)
- assert.NotNil(t, validator, c.description)
- assert.Equal(t, map[string]string{"map": "name value must be a map"}, validator.Errors().Get("name"))
- assert.Equal(t, map[string]string{"map": "name1 value must be a map"}, validator.Errors().Get("name1"))
- assert.Equal(t, map[string]string{"map": "name2 value must be a map"}, validator.Errors().Get("name2"))
- assert.Equal(t, map[string]string{"map": "name3 value must be a map"}, validator.Errors().Get("name3"))
- },
- },
- }
-
- for _, test := range tests {
- t.Run(test.description, func(t *testing.T) {
- test.setup(test)
- })
- }
-}
-
-func TestRule_EqField(t *testing.T) {
- validation := NewValidation()
- tests := []Case{
- {
- description: "success",
- setup: func(c Case) {
- validator, err := validation.Make(context.Background(), map[string]any{
- "name": "a",
- "name1": "a",
- }, map[string]string{
- "name": "required",
- "name1": "required|eq_field:name",
- })
- assert.Nil(t, err, c.description)
- assert.NotNil(t, validator, c.description)
- assert.False(t, validator.Fails(), c.description)
- },
- },
- {
- description: "error",
- setup: func(c Case) {
- validator, err := validation.Make(context.Background(), map[string]any{
- "name": "a",
- "name1": "b",
- }, map[string]string{
- "name": "required",
- "name1": "required|eq_field:name",
- })
- assert.Nil(t, err, c.description)
- assert.NotNil(t, validator, c.description)
- assert.Equal(t, map[string]string{"eq_field": "name1 value must be equal the field name"}, validator.Errors().Get("name1"))
- },
- },
- }
-
- for _, test := range tests {
- t.Run(test.description, func(t *testing.T) {
- test.setup(test)
- })
- }
-}
-
-func TestRule_NeField(t *testing.T) {
- validation := NewValidation()
- tests := []Case{
- {
- description: "success",
- setup: func(c Case) {
- validator, err := validation.Make(context.Background(), map[string]any{
- "name": "a",
- "name1": "b",
- }, map[string]string{
- "name": "required",
- "name1": "required|ne_field:name",
- })
- assert.Nil(t, err, c.description)
- assert.NotNil(t, validator, c.description)
- assert.False(t, validator.Fails(), c.description)
- },
- },
- {
- description: "error",
- setup: func(c Case) {
- validator, err := validation.Make(context.Background(), map[string]any{
- "name": "a",
- "name1": "a",
- }, map[string]string{
- "name": "required",
- "name1": "required|ne_field:name",
- })
- assert.Nil(t, err, c.description)
- assert.NotNil(t, validator, c.description)
- assert.Equal(t, map[string]string{"ne_field": "name1 value cannot be equal to the field name"}, validator.Errors().Get("name1"))
- },
- },
- }
-
- for _, test := range tests {
- t.Run(test.description, func(t *testing.T) {
- test.setup(test)
- })
- }
-}
-
-func TestRule_GtField(t *testing.T) {
- validation := NewValidation()
- tests := []Case{
- {
- description: "success",
- setup: func(c Case) {
- validator, err := validation.Make(context.Background(), map[string]any{
- "name": 1,
- "name1": 2,
- }, map[string]string{
- "name": "required",
- "name1": "required|gt_field:name",
- })
- assert.Nil(t, err, c.description)
- assert.NotNil(t, validator, c.description)
- assert.False(t, validator.Fails(), c.description)
- },
- },
- {
- description: "error",
- setup: func(c Case) {
- validator, err := validation.Make(context.Background(), map[string]any{
- "name": 2,
- "name1": 1,
- }, map[string]string{
- "name": "required",
- "name1": "required|gt_field:name",
- })
- assert.Nil(t, err, c.description)
- assert.NotNil(t, validator, c.description)
- assert.Equal(t, map[string]string{"gt_field": "name1 value must be greater than the field name"}, validator.Errors().Get("name1"))
- },
- },
- }
-
- for _, test := range tests {
- t.Run(test.description, func(t *testing.T) {
- test.setup(test)
- })
- }
-}
-
-func TestRule_GteField(t *testing.T) {
- validation := NewValidation()
- tests := []Case{
- {
- description: "success",
- setup: func(c Case) {
- validator, err := validation.Make(context.Background(), map[string]any{
- "name": 1,
- "name1": 2,
- "name2": 1,
- }, map[string]string{
- "name": "required",
- "name1": "required|gte_field:name",
- "name2": "required|gte_field:name",
- })
- assert.Nil(t, err, c.description)
- assert.NotNil(t, validator, c.description)
- assert.False(t, validator.Fails(), c.description)
- },
- },
- {
- description: "error",
- setup: func(c Case) {
- validator, err := validation.Make(context.Background(), map[string]any{
- "name": 2,
- "name1": 1,
- }, map[string]string{
- "name": "required",
- "name1": "required|gte_field:name",
- })
- assert.Nil(t, err, c.description)
- assert.NotNil(t, validator, c.description)
- assert.Equal(t, map[string]string{"gte_field": "name1 value should be greater or equal to the field name"}, validator.Errors().Get("name1"))
- },
- },
- }
-
- for _, test := range tests {
- t.Run(test.description, func(t *testing.T) {
- test.setup(test)
- })
- }
-}
-
-func TestRule_LtField(t *testing.T) {
- validation := NewValidation()
- tests := []Case{
- {
- description: "success",
- setup: func(c Case) {
- validator, err := validation.Make(context.Background(), map[string]any{
- "name": 2,
- "name1": 1,
- }, map[string]string{
- "name": "required",
- "name1": "required|lt_field:name",
- })
- assert.Nil(t, err, c.description)
- assert.NotNil(t, validator, c.description)
- assert.False(t, validator.Fails(), c.description)
- },
- },
- {
- description: "error",
- setup: func(c Case) {
- validator, err := validation.Make(context.Background(), map[string]any{
- "name": 1,
- "name1": 2,
- }, map[string]string{
- "name": "required",
- "name1": "required|lt_field:name",
- })
- assert.Nil(t, err, c.description)
- assert.NotNil(t, validator, c.description)
- assert.Equal(t, map[string]string{"lt_field": "name1 value should be less than the field name"}, validator.Errors().Get("name1"))
- },
- },
- }
-
- for _, test := range tests {
- t.Run(test.description, func(t *testing.T) {
- test.setup(test)
- })
- }
-}
-
-func TestRule_LteField(t *testing.T) {
- validation := NewValidation()
- tests := []Case{
- {
- description: "success",
- setup: func(c Case) {
- validator, err := validation.Make(context.Background(), map[string]any{
- "name": 2,
- "name1": 2,
- "name2": 1,
- }, map[string]string{
- "name": "required",
- "name1": "required|lte_field:name",
- "name2": "required|lte_field:name",
- })
- assert.Nil(t, err, c.description)
- assert.NotNil(t, validator, c.description)
- assert.False(t, validator.Fails(), c.description)
- },
- },
- {
- description: "error",
- setup: func(c Case) {
- validator, err := validation.Make(context.Background(), map[string]any{
- "name": 1,
- "name1": 2,
- }, map[string]string{
- "name": "required",
- "name1": "required|lte_field:name",
- })
- assert.Nil(t, err, c.description)
- assert.NotNil(t, validator, c.description)
- assert.Equal(t, map[string]string{"lte_field": "name1 value should be less than or equal to the field name"}, validator.Errors().Get("name1"))
- },
- },
- }
-
- for _, test := range tests {
- t.Run(test.description, func(t *testing.T) {
- test.setup(test)
- })
- }
-}
-
-func TestRule_Date(t *testing.T) {
- validation := NewValidation()
- tests := []Case{
- {
- description: "success",
- setup: func(c Case) {
- validator, err := validation.Make(context.Background(), map[string]any{
- "name": "2022-12-25",
- "name1": "2022/12/25",
- "name2": "",
- }, map[string]string{
- "name": "required|date",
- "name1": "required|date",
- "name2": "date",
- "name3": "date",
- })
- assert.Nil(t, err, c.description)
- assert.NotNil(t, validator, c.description)
- assert.False(t, validator.Fails(), c.description)
- },
- },
- {
- description: "error",
- setup: func(c Case) {
- validator, err := validation.Make(context.Background(), map[string]any{
- "name": "2022.12.25",
- "name1": "a",
- }, map[string]string{
- "name": "required|date",
- "name1": "required|date",
- })
- assert.Nil(t, err, c.description)
- assert.NotNil(t, validator, c.description)
- assert.Equal(t, map[string]string{"date": "name value should be a date string"}, validator.Errors().Get("name"))
- assert.Equal(t, map[string]string{"date": "name1 value should be a date string"}, validator.Errors().Get("name1"))
- },
- },
- }
-
- for _, test := range tests {
- t.Run(test.description, func(t *testing.T) {
- test.setup(test)
- })
- }
-}
-
-func TestRule_GtDate(t *testing.T) {
- validation := NewValidation()
- tests := []Case{
- {
- description: "success",
- setup: func(c Case) {
- validator, err := validation.Make(context.Background(), map[string]any{
- "name": "2022-12-25",
- }, map[string]string{
- "name": "required|gt_date:2022-12-24",
- })
- assert.Nil(t, err, c.description)
- assert.NotNil(t, validator, c.description)
- assert.False(t, validator.Fails(), c.description)
- },
- },
- {
- description: "error",
- setup: func(c Case) {
- validator, err := validation.Make(context.Background(), map[string]any{
- "name": "2022-12-25",
- }, map[string]string{
- "name": "required|gt_date:2022-12-26",
- })
- assert.Nil(t, err, c.description)
- assert.NotNil(t, validator, c.description)
- assert.Equal(t, map[string]string{"gt_date": "name field did not pass validation"}, validator.Errors().Get("name"))
- },
- },
- }
-
- for _, test := range tests {
- t.Run(test.description, func(t *testing.T) {
- test.setup(test)
- })
- }
-}
-
-func TestRule_LtDate(t *testing.T) {
- validation := NewValidation()
- tests := []Case{
- {
- description: "success",
- setup: func(c Case) {
- validator, err := validation.Make(context.Background(), map[string]any{
- "name": "2022-12-25",
- }, map[string]string{
- "name": "required|lt_date:2022-12-26",
- })
- assert.Nil(t, err, c.description)
- assert.NotNil(t, validator, c.description)
- assert.False(t, validator.Fails(), c.description)
- },
- },
- {
- description: "error",
- setup: func(c Case) {
- validator, err := validation.Make(context.Background(), map[string]any{
- "name": "2022-12-25",
- }, map[string]string{
- "name": "required|lt_date:2022-12-24",
- })
- assert.Nil(t, err, c.description)
- assert.NotNil(t, validator, c.description)
- assert.Equal(t, map[string]string{"lt_date": "name field did not pass validation"}, validator.Errors().Get("name"))
- },
- },
- }
-
- for _, test := range tests {
- t.Run(test.description, func(t *testing.T) {
- test.setup(test)
- })
- }
-}
-
-func TestRule_GteDate(t *testing.T) {
- validation := NewValidation()
- tests := []Case{
- {
- description: "success",
- setup: func(c Case) {
- validator, err := validation.Make(context.Background(), map[string]any{
- "name": "2022-12-25",
- "name1": "2022-12-25",
- }, map[string]string{
- "name": "required|gte_date:2022-12-25",
- "name1": "required|gte_date:2022-12-24",
- })
- assert.Nil(t, err, c.description)
- assert.NotNil(t, validator, c.description)
- assert.False(t, validator.Fails(), c.description)
- },
- },
- {
- description: "error",
- setup: func(c Case) {
- validator, err := validation.Make(context.Background(), map[string]any{
- "name": "2022-12-25",
- }, map[string]string{
- "name": "required|gte_date:2022-12-26",
- })
- assert.Nil(t, err, c.description)
- assert.NotNil(t, validator, c.description)
- assert.Equal(t, map[string]string{"gte_date": "name field did not pass validation"}, validator.Errors().Get("name"))
- },
- },
- }
-
- for _, test := range tests {
- t.Run(test.description, func(t *testing.T) {
- test.setup(test)
- })
- }
-}
-
-func TestRule_lteDate(t *testing.T) {
- validation := NewValidation()
- tests := []Case{
- {
- description: "success",
- setup: func(c Case) {
- validator, err := validation.Make(context.Background(), map[string]any{
- "name": "2022-12-25",
- "name1": "2022-12-25",
- }, map[string]string{
- "name": "required|lte_date:2022-12-25",
- "name1": "required|lte_date:2022-12-26",
- })
- assert.Nil(t, err, c.description)
- assert.NotNil(t, validator, c.description)
- assert.False(t, validator.Fails(), c.description)
- },
- },
- {
- description: "error",
- setup: func(c Case) {
- validator, err := validation.Make(context.Background(), map[string]any{
- "name": "2022-12-25",
- }, map[string]string{
- "name": "required|lte_date:2022-12-24",
- })
- assert.Nil(t, err, c.description)
- assert.NotNil(t, validator, c.description)
- assert.Equal(t, map[string]string{"lte_date": "name field did not pass validation"}, validator.Errors().Get("name"))
- },
- },
- }
-
- for _, test := range tests {
- t.Run(test.description, func(t *testing.T) {
- test.setup(test)
- })
- }
-}
-
-func TestRule_Alpha(t *testing.T) {
- validation := NewValidation()
- tests := []Case{
- {
- description: "success",
- setup: func(c Case) {
- validator, err := validation.Make(context.Background(), map[string]any{
- "name": "abcABC",
- }, map[string]string{
- "name": "required|alpha",
- })
- assert.Nil(t, err, c.description)
- assert.NotNil(t, validator, c.description)
- assert.False(t, validator.Fails(), c.description)
- },
- },
- {
- description: "error",
- setup: func(c Case) {
- validator, err := validation.Make(context.Background(), map[string]any{
- "name": "abcABC123",
- "name1": "abc.",
- }, map[string]string{
- "name": "required|alpha",
- "name1": "required|alpha",
- })
- assert.Nil(t, err, c.description)
- assert.NotNil(t, validator, c.description)
- assert.Equal(t, map[string]string{"alpha": "name value contains only alpha char"}, validator.Errors().Get("name"))
- assert.Equal(t, map[string]string{"alpha": "name1 value contains only alpha char"}, validator.Errors().Get("name1"))
- },
- },
- }
-
- for _, test := range tests {
- t.Run(test.description, func(t *testing.T) {
- test.setup(test)
- })
- }
-}
-
-func TestRule_AlphaNum(t *testing.T) {
- validation := NewValidation()
- tests := []Case{
- {
- description: "success",
- setup: func(c Case) {
- validator, err := validation.Make(context.Background(), map[string]any{
- "name": "abcABC123",
- }, map[string]string{
- "name": "required|alpha_num",
- })
- assert.Nil(t, err, c.description)
- assert.NotNil(t, validator, c.description)
- assert.False(t, validator.Fails(), c.description)
- },
- },
- {
- description: "error",
- setup: func(c Case) {
- validator, err := validation.Make(context.Background(), map[string]any{
- "name": "abcABC123.",
- }, map[string]string{
- "name": "required|alpha_num",
- })
- assert.Nil(t, err, c.description)
- assert.NotNil(t, validator, c.description)
- assert.Equal(t, map[string]string{"alpha_num": "name field did not pass validation"}, validator.Errors().Get("name"))
- },
- },
- }
-
- for _, test := range tests {
- t.Run(test.description, func(t *testing.T) {
- test.setup(test)
- })
- }
-}
-
-func TestRule_AlphaDash(t *testing.T) {
- validation := NewValidation()
- tests := []Case{
- {
- description: "success",
- setup: func(c Case) {
- validator, err := validation.Make(context.Background(), map[string]any{
- "name": "abcABC123-_",
- }, map[string]string{
- "name": "required|alpha_dash",
- })
- assert.Nil(t, err, c.description)
- assert.NotNil(t, validator, c.description)
- assert.False(t, validator.Fails(), c.description)
- },
- },
- {
- description: "error",
- setup: func(c Case) {
- validator, err := validation.Make(context.Background(), map[string]any{
- "name": "abcABC123-_.",
- }, map[string]string{
- "name": "required|alpha_dash",
- })
- assert.Nil(t, err, c.description)
- assert.NotNil(t, validator, c.description)
- assert.Equal(t, map[string]string{"alpha_dash": "name field did not pass validation"}, validator.Errors().Get("name"))
- },
- },
- }
-
- for _, test := range tests {
- t.Run(test.description, func(t *testing.T) {
- test.setup(test)
- })
- }
-}
-
-func TestRule_Json(t *testing.T) {
- validation := NewValidation()
- tests := []Case{
- {
- description: "success",
- setup: func(c Case) {
- validator, err := validation.Make(context.Background(), map[string]any{
- "name": "{\"a\":1}",
- }, map[string]string{
- "name": "required|json",
- })
- assert.Nil(t, err, c.description)
- assert.NotNil(t, validator, c.description)
- assert.False(t, validator.Fails(), c.description)
- },
- },
- {
- description: "error",
- setup: func(c Case) {
- validator, err := validation.Make(context.Background(), map[string]any{
- "name": "a",
- }, map[string]string{
- "name": "required|json",
- })
- assert.Nil(t, err, c.description)
- assert.NotNil(t, validator, c.description)
- assert.Equal(t, map[string]string{"json": "name value should be a json string"}, validator.Errors().Get("name"))
- },
- },
- }
-
- for _, test := range tests {
- t.Run(test.description, func(t *testing.T) {
- test.setup(test)
- })
- }
-}
-
-func TestRule_Number(t *testing.T) {
- validation := NewValidation()
- tests := []Case{
- {
- description: "success",
- setup: func(c Case) {
- validator, err := validation.Make(context.Background(), map[string]any{
- "name": 1,
- }, map[string]string{
- "name": "required|number",
- })
- assert.Nil(t, err, c.description)
- assert.NotNil(t, validator, c.description)
- assert.False(t, validator.Fails(), c.description)
- },
- },
- {
- description: "error",
- setup: func(c Case) {
- validator, err := validation.Make(context.Background(), map[string]any{
- "name": "a",
- }, map[string]string{
- "name": "required|number",
- })
- assert.Nil(t, err, c.description)
- assert.NotNil(t, validator, c.description)
- assert.Equal(t, map[string]string{"number": "name field did not pass validation"}, validator.Errors().Get("name"))
- },
- },
- }
-
- for _, test := range tests {
- t.Run(test.description, func(t *testing.T) {
- test.setup(test)
- })
- }
-}
-
-func TestRule_FullUrl(t *testing.T) {
- validation := NewValidation()
- tests := []Case{
- {
- description: "success",
- setup: func(c Case) {
- validator, err := validation.Make(context.Background(), map[string]any{
- "name": "https://www.goravel.dev",
- "name1": "http://www.goravel.dev",
- }, map[string]string{
- "name": "required|full_url",
- "name1": "required|full_url",
- })
- assert.Nil(t, err, c.description)
- assert.NotNil(t, validator, c.description)
- assert.False(t, validator.Fails(), c.description)
- },
- },
- {
- description: "error",
- setup: func(c Case) {
- validator, err := validation.Make(context.Background(), map[string]any{
- "name": "a",
- }, map[string]string{
- "name": "required|full_url",
- })
- assert.Nil(t, err, c.description)
- assert.NotNil(t, validator, c.description)
- assert.Equal(t, map[string]string{"full_url": "name must be a valid full URL address"}, validator.Errors().Get("name"))
- },
- },
- }
-
- for _, test := range tests {
- t.Run(test.description, func(t *testing.T) {
- test.setup(test)
- })
- }
-}
-
-func TestRule_Ip(t *testing.T) {
- validation := NewValidation()
- tests := []Case{
- {
- description: "success",
- setup: func(c Case) {
- validator, err := validation.Make(context.Background(), map[string]any{
- "name": "192.168.1.1",
- "name1": "FE80:CD00:0000:0CDE:1257:0000:211E:729C",
- }, map[string]string{
- "name": "required|ip",
- "name1": "required|ip",
- })
- assert.Nil(t, err, c.description)
- assert.NotNil(t, validator, c.description)
- assert.False(t, validator.Fails(), c.description)
- },
- },
- {
- description: "error",
- setup: func(c Case) {
- validator, err := validation.Make(context.Background(), map[string]any{
- "name": "a",
- "name1": "192.168.1.300",
- }, map[string]string{
- "name": "required|ip",
- "name1": "required|ip",
- })
- assert.Nil(t, err, c.description)
- assert.NotNil(t, validator, c.description)
- assert.Equal(t, map[string]string{"ip": "name value should be an IP (v4 or v6) string"}, validator.Errors().Get("name"))
- assert.Equal(t, map[string]string{"ip": "name1 value should be an IP (v4 or v6) string"}, validator.Errors().Get("name1"))
- },
- },
- }
-
- for _, test := range tests {
- t.Run(test.description, func(t *testing.T) {
- test.setup(test)
- })
- }
-}
-
-func TestRule_Ipv4(t *testing.T) {
- validation := NewValidation()
- tests := []Case{
- {
- description: "success",
- setup: func(c Case) {
- validator, err := validation.Make(context.Background(), map[string]any{
- "name": "192.168.1.1",
- }, map[string]string{
- "name": "required|ipv4",
- })
- assert.Nil(t, err, c.description)
- assert.NotNil(t, validator, c.description)
- assert.False(t, validator.Fails(), c.description)
- },
- },
- {
- description: "error",
- setup: func(c Case) {
- validator, err := validation.Make(context.Background(), map[string]any{
- "name": "a",
- "name1": "FE80:CD00:0000:0CDE:1257:0000:211E:729C",
- "name2": "192.168.1.300",
- }, map[string]string{
- "name": "required|ipv4",
- "name1": "required|ipv4",
- "name2": "required|ipv4",
- })
- assert.Nil(t, err, c.description)
- assert.NotNil(t, validator, c.description)
- assert.Equal(t, map[string]string{"ipv4": "name value should be an IPv4 string"}, validator.Errors().Get("name"))
- assert.Equal(t, map[string]string{"ipv4": "name1 value should be an IPv4 string"}, validator.Errors().Get("name1"))
- assert.Equal(t, map[string]string{"ipv4": "name2 value should be an IPv4 string"}, validator.Errors().Get("name2"))
- },
- },
- }
-
- for _, test := range tests {
- t.Run(test.description, func(t *testing.T) {
- test.setup(test)
- })
- }
-}
-
-func TestRule_Ipv6(t *testing.T) {
- validation := NewValidation()
- tests := []Case{
- {
- description: "success",
- setup: func(c Case) {
- validator, err := validation.Make(context.Background(), map[string]any{
- "name": "FE80:CD00:0000:0CDE:1257:0000:211E:729C",
- }, map[string]string{
- "name": "required|ipv6",
- })
- assert.Nil(t, err, c.description)
- assert.NotNil(t, validator, c.description)
- assert.False(t, validator.Fails(), c.description)
- },
- },
- {
- description: "error",
- setup: func(c Case) {
- validator, err := validation.Make(context.Background(), map[string]any{
- "name": "a",
- "name1": "192.168.1.300",
- }, map[string]string{
- "name": "required|ipv6",
- "name1": "required|ipv6",
- })
- assert.Nil(t, err, c.description)
- assert.NotNil(t, validator, c.description)
- assert.Equal(t, map[string]string{"ipv6": "name value should be an IPv6 string"}, validator.Errors().Get("name"))
- assert.Equal(t, map[string]string{"ipv6": "name1 value should be an IPv6 string"}, validator.Errors().Get("name1"))
- },
- },
- }
-
- for _, test := range tests {
- t.Run(test.description, func(t *testing.T) {
- test.setup(test)
- })
- }
-}
-
-func TestAddRule(t *testing.T) {
- validation := NewValidation()
- err := validation.AddRules([]httpvalidate.Rule{&Uppercase{}})
- assert.Nil(t, err)
-
- err = validation.AddRules([]httpvalidate.Rule{&Duplicate{}})
- assert.EqualError(t, err, "duplicate rule name: required")
-}
-
-func TestAddFilters(t *testing.T) {
- validation := NewValidation()
- err := validation.AddFilters([]httpvalidate.Filter{&DefaultFilter{}, &Arr2Str{}})
- assert.Nil(t, err)
-
- filters := validation.Filters()
- defaultFilterFunc := filters[0].Handle(context.Background()).(func(string, ...string) string)
- arr2StrFilterFunc := filters[1].Handle(context.Background()).(func(any, string) string)
- assert.Equal(t, "default", defaultFilterFunc("", "default"))
- assert.Equal(t, "a", defaultFilterFunc("a"))
- assert.Equal(t, "a,b", arr2StrFilterFunc([]string{"a", "b"}, ","))
-}
-
-func TestFilters(t *testing.T) {
- mp := map[string]any{
- "name": "krishan ",
- "age": " 22 ",
- "empty": "",
- "languages": "cpp, go",
- "numbers": []int{1, 2, 3},
- }
-
- validation := NewValidation()
- err := validation.AddFilters([]httpvalidate.Filter{&DefaultFilter{}, &Arr2Str{}})
- assert.Nil(t, err)
-
- validator, err := validation.Make(context.Background(), mp, map[string]string{
- "name, age, empty, languages, numbers": "required",
- }, Filters(map[string]string{
- "empty": "default:emptyDefault",
- "name": "trim|upper",
- "age, not-exist": "trim|int",
- "languages": "str2arr:,",
- "numbers": "arr2str:,",
- }))
-
- assert.Nil(t, err)
- var newMp map[string]any
- assert.Nil(t, validator.Bind(&newMp))
-
- assert.Equal(t, "KRISHAN", newMp["name"])
- assert.Equal(t, 22, newMp["age"])
- assert.Equal(t, "emptyDefault", newMp["empty"])
- assert.Equal(t, []string{"cpp", "go"}, newMp["languages"])
- assert.Equal(t, "1,2,3", newMp["numbers"])
-}
-
-type DefaultFilter struct {
-}
-
-func (receiver *DefaultFilter) Signature() string {
- return "default"
-}
-
-func (receiver *DefaultFilter) Handle(ctx context.Context) any {
- return func(val string, def ...string) string {
- if val == "" {
- if len(def) > 0 {
- return def[0]
- }
- }
-
- return val
- }
-}
-
-type Arr2Str struct {
+type CustomUppercase struct {
}
-func (receiver *Arr2Str) Signature() string {
- return "arr2str"
+func (receiver *CustomUppercase) Signature() string {
+ return "custom_uppercase"
}
-func (receiver *Arr2Str) Handle(ctx context.Context) any {
- return func(val any, sep string) string {
- return strings.Join(cast.ToStringSlice(val), sep)
- }
-}
-
-func TestCustomRule(t *testing.T) {
- validation := NewValidation()
- err := validation.AddRules([]httpvalidate.Rule{&Uppercase{}, &Lowercase{}})
- assert.Nil(t, err)
-
- tests := []Case{
- {
- description: "success",
- setup: func(c Case) {
- validator, err := validation.Make(context.Background(), map[string]any{
- "name": "ABC",
- "address": "de",
- }, map[string]string{
- "name": "required|uppercase:3",
- "address": "required|lowercase:2",
- })
- assert.Nil(t, err, c.description)
- assert.NotNil(t, validator, c.description)
- assert.False(t, validator.Fails(), c.description)
- },
- },
- {
- description: "error",
- setup: func(c Case) {
- validator, err := validation.Make(context.Background(), map[string]any{
- "name": "abc",
- "address": "DE",
- }, map[string]string{
- "name": "required|uppercase:3",
- "address": "required|lowercase:2",
- })
- assert.Nil(t, err, c.description)
- assert.NotNil(t, validator, c.description)
- assert.Equal(t, map[string]string{"uppercase": "name must be upper"}, validator.Errors().Get("name"))
- assert.Equal(t, map[string]string{"lowercase": "address must be lower"}, validator.Errors().Get("address"))
- },
- },
- }
-
- for _, test := range tests {
- t.Run(test.description, func(t *testing.T) {
- test.setup(test)
- })
- }
-}
-
-type Uppercase struct {
-}
-
-// Signature The name of the rule.
-func (receiver *Uppercase) Signature() string {
- return "uppercase"
-}
-
-// Passes Determine if the validation rule passes.
-func (receiver *Uppercase) Passes(ctx context.Context, data httpvalidate.Data, val any, options ...any) bool {
+func (receiver *CustomUppercase) Passes(ctx context.Context, data httpvalidate.Data, val any, options ...any) bool {
name, exist := data.Get("name")
if len(options) > 0 {
@@ -2945,21 +1156,18 @@ func (receiver *Uppercase) Passes(ctx context.Context, data httpvalidate.Data, v
return false
}
-// Message Get the validation error message.
-func (receiver *Uppercase) Message(ctx context.Context) string {
+func (receiver *CustomUppercase) Message(ctx context.Context) string {
return ":attribute must be upper"
}
-type Lowercase struct {
+type CustomLowercase struct {
}
-// Signature The name of the rule.
-func (receiver *Lowercase) Signature() string {
- return "lowercase"
+func (receiver *CustomLowercase) Signature() string {
+ return "custom_lowercase"
}
-// Passes Determine if the validation rule passes.
-func (receiver *Lowercase) Passes(ctx context.Context, data httpvalidate.Data, val any, options ...any) bool {
+func (receiver *CustomLowercase) Passes(ctx context.Context, data httpvalidate.Data, val any, options ...any) bool {
address, exist := data.Get("address")
if len(options) > 0 {
@@ -2969,25 +1177,40 @@ func (receiver *Lowercase) Passes(ctx context.Context, data httpvalidate.Data, v
return false
}
-// Message Get the validation error message.
-func (receiver *Lowercase) Message(ctx context.Context) string {
+func (receiver *CustomLowercase) Message(ctx context.Context) string {
return ":attribute must be lower"
}
type Duplicate struct {
}
-// Signature The name of the rule.
func (receiver *Duplicate) Signature() string {
return "required"
}
-// Passes Determine if the validation rule passes.
func (receiver *Duplicate) Passes(ctx context.Context, data httpvalidate.Data, val any, options ...any) bool {
return true
}
-// Message Get the validation error message.
func (receiver *Duplicate) Message(ctx context.Context) string {
return ""
-}*/
+}
+
+type DefaultFilter struct {
+}
+
+func (receiver *DefaultFilter) Signature() string {
+ return "default"
+}
+
+func (receiver *DefaultFilter) Handle(ctx context.Context) any {
+ return func(val string, def ...string) string {
+ if val == "" {
+ if len(def) > 0 {
+ return def[0]
+ }
+ }
+
+ return val
+ }
+}
diff --git a/validation/validator_test.go b/validation/validator_test.go
index 8ff104195..da16c399f 100644
--- a/validation/validator_test.go
+++ b/validation/validator_test.go
@@ -1,6 +1,27 @@
package validation
-/*func TestBind_Rule(t *testing.T) {
+import (
+ "bytes"
+ "context"
+ "io"
+ "mime/multipart"
+ "net/http"
+ "os"
+ "path/filepath"
+ "reflect"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/goravel/framework/errors"
+ "github.com/goravel/framework/foundation/json"
+ "github.com/goravel/framework/support/carbon"
+ "github.com/goravel/framework/support/convert"
+)
+
+func TestBind_Rule(t *testing.T) {
type Embed struct {
C string `form:"c" json:"c"`
}
@@ -30,14 +51,14 @@ package validation
tests := []struct {
name string
- data validate.DataFace
- rules map[string]string
+ data any
+ rules map[string]any
assert func(data Data)
}{
{
name: "data is map and key is lowercase",
- data: validate.FromMap(map[string]any{"a": "aa", "b": "1"}),
- rules: map[string]string{"a": "required"},
+ data: map[string]any{"a": "aa", "b": "1"},
+ rules: map[string]any{"a": "required"},
assert: func(data Data) {
assert.Equal(t, "aa", data.A)
assert.Equal(t, 1, data.B)
@@ -45,35 +66,30 @@ package validation
},
{
name: "data is map and cast key",
- data: validate.FromMap(map[string]any{"b": "1"}),
- rules: map[string]string{"b": "required"},
+ data: map[string]any{"b": "1"},
+ rules: map[string]any{"b": "required"},
assert: func(data Data) {
assert.Equal(t, 1, data.B)
},
},
{
name: "data is map and key is uppercase",
- data: validate.FromMap(map[string]any{"A": "aa"}),
- rules: map[string]string{"A": "required"},
+ data: map[string]any{"A": "aa"},
+ rules: map[string]any{"A": "required"},
assert: func(data Data) {
assert.Equal(t, "aa", data.A)
},
},
{
name: "data is struct",
- data: func() validate.DataFace {
- data, err := validate.FromStruct(&struct {
- A string
- B int
- }{
- A: "a",
- B: 1,
- })
- assert.Nil(t, err)
-
- return data
- }(),
- rules: map[string]string{"A": "required"},
+ data: &struct {
+ A string
+ B int
+ }{
+ A: "a",
+ B: 1,
+ },
+ rules: map[string]any{"A": "required"},
assert: func(data Data) {
assert.Equal(t, "a", data.A)
assert.Equal(t, 1, data.B)
@@ -81,15 +97,12 @@ package validation
},
{
name: "data is get request",
- data: func() validate.DataFace {
+ data: func() *http.Request {
request, err := http.NewRequest(http.MethodGet, "/?a=aa&&b=1", nil)
assert.Nil(t, err)
- data, err := validate.FromRequest(request)
- assert.Nil(t, err)
-
- return data
+ return request
}(),
- rules: map[string]string{"a": "required"},
+ rules: map[string]any{"a": "required"},
assert: func(data Data) {
assert.Equal(t, "aa", data.A)
assert.Equal(t, 1, data.B)
@@ -97,37 +110,25 @@ package validation
},
{
name: "data is get request with names",
- data: func() validate.DataFace {
+ data: func() *http.Request {
request, err := http.NewRequest(http.MethodGet, "/?names=a&names=b", nil)
assert.Nil(t, err)
- data, err := validate.FromRequest(request)
- assert.Nil(t, err)
-
- return data
+ return request
}(),
- rules: map[string]string{"names": "required|array|len:2"},
+ rules: map[string]any{"names": "required"},
assert: func(data Data) {
assert.Equal(t, []string{"a", "b"}, data.Names)
},
},
{
name: "data is post request",
- data: func() validate.DataFace {
+ data: func() *http.Request {
request, err := http.NewRequest(http.MethodPost, "/", bytes.NewBufferString(`{"a":"Goravel", "b": 1, "ages": [1, 2], "names": ["a", "b"]}`))
request.Header.Set("Content-Type", "application/json")
assert.Nil(t, err)
- data, err := validate.FromRequest(request)
- assert.Nil(t, err)
-
- age, exist := data.Get("ages")
- assert.True(t, exist)
-
- _, err = data.Set("ages", cast.ToIntSlice(age))
- assert.Nil(t, err)
-
- return data
+ return request
}(),
- rules: map[string]string{"a": "required", "ages.*": "int", "names.*": "string"},
+ rules: map[string]any{"a": "required"},
assert: func(data Data) {
assert.Equal(t, "Goravel", data.A)
assert.Equal(t, 1, data.B)
@@ -137,456 +138,348 @@ package validation
},
{
name: "data is post request with Carbon",
- data: func() validate.DataFace {
+ data: func() *http.Request {
request, err := http.NewRequest(http.MethodPost, "/", bytes.NewBufferString(`{"carbon": "2024-07-04 10:00:52"}`))
request.Header.Set("Content-Type", "application/json")
assert.Nil(t, err)
-
- data, err := validate.FromRequest(request)
- assert.Nil(t, err)
-
- return data
+ return request
}(),
- rules: map[string]string{"carbon": "string"},
+ rules: map[string]any{"carbon": "required"},
assert: func(data Data) {
assert.Equal(t, "2024-07-04 10:00:52", data.Carbon.ToDateTimeString())
},
},
{
name: "data is post request with DateTime",
- data: func() validate.DataFace {
+ data: func() *http.Request {
request, err := http.NewRequest(http.MethodPost, "/", bytes.NewBufferString(`{"date_time": "2024-07-04 10:00:52"}`))
request.Header.Set("Content-Type", "application/json")
assert.Nil(t, err)
-
- data, err := validate.FromRequest(request)
- assert.Nil(t, err)
-
- return data
+ return request
}(),
- rules: map[string]string{"date_time": "string"},
+ rules: map[string]any{"date_time": "required"},
assert: func(data Data) {
assert.Equal(t, "2024-07-04 10:00:52", data.DateTime.ToDateTimeString())
},
},
{
name: "data is post request with DateTime(string)",
- data: func() validate.DataFace {
+ data: func() *http.Request {
request, err := http.NewRequest(http.MethodPost, "/", bytes.NewBufferString(`{"date_time": "1720087252"}`))
request.Header.Set("Content-Type", "application/json")
assert.Nil(t, err)
-
- data, err := validate.FromRequest(request)
- assert.Nil(t, err)
-
- return data
+ return request
}(),
- rules: map[string]string{"date_time": "required"},
+ rules: map[string]any{"date_time": "required"},
assert: func(data Data) {
assert.Equal(t, "2024-07-04 10:00:52", data.DateTime.ToDateTimeString())
},
},
{
name: "data is post request with DateTime(int)",
- data: func() validate.DataFace {
+ data: func() *http.Request {
request, err := http.NewRequest(http.MethodPost, "/", bytes.NewBufferString(`{"date_time": 1720087252}`))
request.Header.Set("Content-Type", "application/json")
assert.Nil(t, err)
-
- data, err := validate.FromRequest(request)
- assert.Nil(t, err)
-
- return data
+ return request
}(),
- rules: map[string]string{"date_time": "required"},
+ rules: map[string]any{"date_time": "required"},
assert: func(data Data) {
assert.Equal(t, "2024-07-04 10:00:52", data.DateTime.ToDateTimeString())
},
},
{
name: "data is post request with DateTime(milli)",
- data: func() validate.DataFace {
+ data: func() *http.Request {
request, err := http.NewRequest(http.MethodPost, "/", bytes.NewBufferString(`{"date_time": 1720087252000}`))
request.Header.Set("Content-Type", "application/json")
assert.Nil(t, err)
-
- data, err := validate.FromRequest(request)
- assert.Nil(t, err)
-
- return data
+ return request
}(),
- rules: map[string]string{"date_time": "required"},
+ rules: map[string]any{"date_time": "required"},
assert: func(data Data) {
assert.Equal(t, "2024-07-04 10:00:52", data.DateTime.ToDateTimeString())
},
},
{
name: "data is post request with DateTime(micro)",
- data: func() validate.DataFace {
+ data: func() *http.Request {
request, err := http.NewRequest(http.MethodPost, "/", bytes.NewBufferString(`{"date_time": 1720087252000000}`))
request.Header.Set("Content-Type", "application/json")
assert.Nil(t, err)
-
- data, err := validate.FromRequest(request)
- assert.Nil(t, err)
-
- return data
+ return request
}(),
- rules: map[string]string{"date_time": "required"},
+ rules: map[string]any{"date_time": "required"},
assert: func(data Data) {
assert.Equal(t, "2024-07-04 10:00:52", data.DateTime.ToDateTimeString())
},
},
{
name: "data is post request with DateTime(nano)",
- data: func() validate.DataFace {
+ data: func() *http.Request {
request, err := http.NewRequest(http.MethodPost, "/", bytes.NewBufferString(`{"date_time": 1720087252000000000}`))
request.Header.Set("Content-Type", "application/json")
assert.Nil(t, err)
-
- data, err := validate.FromRequest(request)
- assert.Nil(t, err)
-
- return data
+ return request
}(),
- rules: map[string]string{"date_time": "required"},
+ rules: map[string]any{"date_time": "required"},
assert: func(data Data) {
assert.Equal(t, "2024-07-04 10:00:52", data.DateTime.ToDateTimeString())
},
},
{
name: "data is post request with DateTimeMilli",
- data: func() validate.DataFace {
+ data: func() *http.Request {
request, err := http.NewRequest(http.MethodPost, "/", bytes.NewBufferString(`{"date_time_milli": "2024-07-04 10:00:52.123"}`))
request.Header.Set("Content-Type", "application/json")
assert.Nil(t, err)
-
- data, err := validate.FromRequest(request)
- assert.Nil(t, err)
-
- return data
+ return request
}(),
- rules: map[string]string{"date_time_milli": "string"},
+ rules: map[string]any{"date_time_milli": "required"},
assert: func(data Data) {
assert.Equal(t, "2024-07-04 10:00:52.123", data.DateTimeMilli.ToDateTimeMilliString())
},
},
{
name: "data is post request with DateTimeMilli(int)",
- data: func() validate.DataFace {
+ data: func() *http.Request {
request, err := http.NewRequest(http.MethodPost, "/", bytes.NewBufferString(`{"date_time_milli": 1720087252123}`))
request.Header.Set("Content-Type", "application/json")
assert.Nil(t, err)
-
- data, err := validate.FromRequest(request)
- assert.Nil(t, err)
-
- return data
+ return request
}(),
- rules: map[string]string{"date_time_milli": "required"},
+ rules: map[string]any{"date_time_milli": "required"},
assert: func(data Data) {
assert.Equal(t, "2024-07-04 10:00:52.123", data.DateTimeMilli.ToDateTimeMilliString())
},
},
{
name: "data is post request with DateTimeMicro",
- data: func() validate.DataFace {
+ data: func() *http.Request {
request, err := http.NewRequest(http.MethodPost, "/", bytes.NewBufferString(`{"date_time_micro": "2024-07-04 10:00:52.123456"}`))
request.Header.Set("Content-Type", "application/json")
assert.Nil(t, err)
-
- data, err := validate.FromRequest(request)
- assert.Nil(t, err)
-
- return data
+ return request
}(),
- rules: map[string]string{"date_time_micro": "string"},
+ rules: map[string]any{"date_time_micro": "required"},
assert: func(data Data) {
assert.Equal(t, "2024-07-04 10:00:52.123456", data.DateTimeMicro.ToDateTimeMicroString())
},
},
{
name: "data is post request with DateTimeNano",
- data: func() validate.DataFace {
+ data: func() *http.Request {
request, err := http.NewRequest(http.MethodPost, "/", bytes.NewBufferString(`{"date_time_nano": "2024-07-04 10:00:52.123456789"}`))
request.Header.Set("Content-Type", "application/json")
assert.Nil(t, err)
-
- data, err := validate.FromRequest(request)
- assert.Nil(t, err)
-
- return data
+ return request
}(),
- rules: map[string]string{"date_time_nano": "string"},
+ rules: map[string]any{"date_time_nano": "required"},
assert: func(data Data) {
assert.Equal(t, "2024-07-04 10:00:52.123456789", data.DateTimeNano.ToDateTimeNanoString())
},
},
{
name: "data is post request with DateTimeNano(int)",
- data: func() validate.DataFace {
+ data: func() *http.Request {
request, err := http.NewRequest(http.MethodPost, "/", bytes.NewBufferString(`{"date_time_nano": "1720087252123456789"}`))
request.Header.Set("Content-Type", "application/json")
assert.Nil(t, err)
-
- data, err := validate.FromRequest(request)
- assert.Nil(t, err)
-
- return data
+ return request
}(),
- rules: map[string]string{"date_time_nano": "required"},
+ rules: map[string]any{"date_time_nano": "required"},
assert: func(data Data) {
assert.Equal(t, "2024-07-04 10:00:52.123456789", data.DateTimeNano.ToDateTimeNanoString())
},
},
{
name: "data is post request with Date",
- data: func() validate.DataFace {
+ data: func() *http.Request {
request, err := http.NewRequest(http.MethodPost, "/", bytes.NewBufferString(`{"date": "2024-07-04"}`))
request.Header.Set("Content-Type", "application/json")
assert.Nil(t, err)
-
- data, err := validate.FromRequest(request)
- assert.Nil(t, err)
-
- return data
+ return request
}(),
- rules: map[string]string{"date": "string"},
+ rules: map[string]any{"date": "required"},
assert: func(data Data) {
assert.Equal(t, "2024-07-04", data.Date.ToDateString())
},
},
{
name: "data is post request with Date(int)",
- data: func() validate.DataFace {
+ data: func() *http.Request {
request, err := http.NewRequest(http.MethodPost, "/", bytes.NewBufferString(`{"date": 1720087252}`))
request.Header.Set("Content-Type", "application/json")
assert.Nil(t, err)
-
- data, err := validate.FromRequest(request)
- assert.Nil(t, err)
-
- return data
+ return request
}(),
- rules: map[string]string{"date": "required"},
+ rules: map[string]any{"date": "required"},
assert: func(data Data) {
assert.Equal(t, "2024-07-04", data.Date.ToDateString())
},
},
{
name: "data is post request with DateMilli",
- data: func() validate.DataFace {
+ data: func() *http.Request {
request, err := http.NewRequest(http.MethodPost, "/", bytes.NewBufferString(`{"date_milli": "2024-07-04.123"}`))
request.Header.Set("Content-Type", "application/json")
assert.Nil(t, err)
-
- data, err := validate.FromRequest(request)
- assert.Nil(t, err)
-
- return data
+ return request
}(),
- rules: map[string]string{"date_milli": "string"},
+ rules: map[string]any{"date_milli": "required"},
assert: func(data Data) {
assert.Equal(t, "2024-07-04.123", data.DateMilli.ToDateMilliString())
},
},
{
name: "data is post request with DateMilli(int)",
- data: func() validate.DataFace {
+ data: func() *http.Request {
request, err := http.NewRequest(http.MethodPost, "/", bytes.NewBufferString(`{"date_milli": 1720087252123}`))
request.Header.Set("Content-Type", "application/json")
assert.Nil(t, err)
-
- data, err := validate.FromRequest(request)
- assert.Nil(t, err)
-
- return data
+ return request
}(),
- rules: map[string]string{"date_milli": "required"},
+ rules: map[string]any{"date_milli": "required"},
assert: func(data Data) {
assert.Equal(t, "2024-07-04.123", data.DateMilli.ToDateMilliString())
},
},
{
name: "data is post request with DateMicro",
- data: func() validate.DataFace {
+ data: func() *http.Request {
request, err := http.NewRequest(http.MethodPost, "/", bytes.NewBufferString(`{"date_micro": "2024-07-04.123456"}`))
request.Header.Set("Content-Type", "application/json")
assert.Nil(t, err)
-
- data, err := validate.FromRequest(request)
- assert.Nil(t, err)
-
- return data
+ return request
}(),
- rules: map[string]string{"date_micro": "string"},
+ rules: map[string]any{"date_micro": "required"},
assert: func(data Data) {
assert.Equal(t, "2024-07-04.123456", data.DateMicro.ToDateMicroString())
},
},
{
name: "data is post request with DateMicro(int)",
- data: func() validate.DataFace {
+ data: func() *http.Request {
request, err := http.NewRequest(http.MethodPost, "/", bytes.NewBufferString(`{"date_micro": 1720087252123456}`))
request.Header.Set("Content-Type", "application/json")
assert.Nil(t, err)
-
- data, err := validate.FromRequest(request)
- assert.Nil(t, err)
-
- return data
+ return request
}(),
- rules: map[string]string{"date_micro": "required"},
+ rules: map[string]any{"date_micro": "required"},
assert: func(data Data) {
assert.Equal(t, "2024-07-04.123456", data.DateMicro.ToDateMicroString())
},
},
{
name: "data is post request with DateNano",
- data: func() validate.DataFace {
+ data: func() *http.Request {
request, err := http.NewRequest(http.MethodPost, "/", bytes.NewBufferString(`{"date_nano": "2024-07-04.123456789"}`))
request.Header.Set("Content-Type", "application/json")
assert.Nil(t, err)
-
- data, err := validate.FromRequest(request)
- assert.Nil(t, err)
-
- return data
+ return request
}(),
- rules: map[string]string{"date_nano": "string"},
+ rules: map[string]any{"date_nano": "required"},
assert: func(data Data) {
assert.Equal(t, "2024-07-04.123456789", data.DateNano.ToDateNanoString())
},
},
{
name: "data is post request with DateNano(int)",
- data: func() validate.DataFace {
+ data: func() *http.Request {
request, err := http.NewRequest(http.MethodPost, "/", bytes.NewBufferString(`{"date_nano": "1720087252123456789"}`))
request.Header.Set("Content-Type", "application/json")
assert.Nil(t, err)
-
- data, err := validate.FromRequest(request)
- assert.Nil(t, err)
-
- return data
+ return request
}(),
- rules: map[string]string{"date_nano": "required"},
+ rules: map[string]any{"date_nano": "required"},
assert: func(data Data) {
assert.Equal(t, "2024-07-04.123456789", data.DateNano.ToDateNanoString())
},
},
{
name: "data is post request with Timestamp",
- data: func() validate.DataFace {
+ data: func() *http.Request {
request, err := http.NewRequest(http.MethodPost, "/", bytes.NewBufferString(`{"timestamp": 1720087252}`))
request.Header.Set("Content-Type", "application/json")
assert.Nil(t, err)
-
- data, err := validate.FromRequest(request)
- assert.Nil(t, err)
-
- return data
+ return request
}(),
- rules: map[string]string{"timestamp": "required"},
+ rules: map[string]any{"timestamp": "required"},
assert: func(data Data) {
assert.Equal(t, "2024-07-04 10:00:52", data.Timestamp.ToDateTimeString())
},
},
{
name: "data is post request with TimestampMilli",
- data: func() validate.DataFace {
+ data: func() *http.Request {
request, err := http.NewRequest(http.MethodPost, "/", bytes.NewBufferString(`{"timestamp_milli": 1720087252123}`))
request.Header.Set("Content-Type", "application/json")
assert.Nil(t, err)
-
- data, err := validate.FromRequest(request)
- assert.Nil(t, err)
-
- return data
+ return request
}(),
- rules: map[string]string{"timestamp_milli": "required"},
+ rules: map[string]any{"timestamp_milli": "required"},
assert: func(data Data) {
assert.Equal(t, "2024-07-04 10:00:52.123", data.TimestampMilli.ToDateTimeMilliString())
},
},
{
name: "data is post request with TimestampMicro",
- data: func() validate.DataFace {
+ data: func() *http.Request {
request, err := http.NewRequest(http.MethodPost, "/", bytes.NewBufferString(`{"timestamp_micro": 1720087252123456}`))
request.Header.Set("Content-Type", "application/json")
assert.Nil(t, err)
-
- data, err := validate.FromRequest(request)
- assert.Nil(t, err)
-
- return data
+ return request
}(),
- rules: map[string]string{"timestamp_micro": "required"},
+ rules: map[string]any{"timestamp_micro": "required"},
assert: func(data Data) {
assert.Equal(t, "2024-07-04 10:00:52.123456", data.TimestampMicro.ToDateTimeMicroString())
},
},
{
name: "data is post request with TimestampNano",
- data: func() validate.DataFace {
+ data: func() *http.Request {
request, err := http.NewRequest(http.MethodPost, "/", bytes.NewBufferString(`{"timestamp_nano": "1720087252123456789"}`))
request.Header.Set("Content-Type", "application/json")
assert.Nil(t, err)
-
- data, err := validate.FromRequest(request)
- assert.Nil(t, err)
-
- return data
+ return request
}(),
- rules: map[string]string{"timestamp_nano": "required"},
+ rules: map[string]any{"timestamp_nano": "required"},
assert: func(data Data) {
assert.Equal(t, "2024-07-04 10:00:52.123456789", data.TimestampNano.ToDateTimeNanoString())
},
},
{
name: "data is post request with Time",
- data: func() validate.DataFace {
+ data: func() *http.Request {
request, err := http.NewRequest(http.MethodPost, "/", bytes.NewBufferString(`{"time": "2025-05-23 22:16:39"}`))
request.Header.Set("Content-Type", "application/json")
assert.Nil(t, err)
-
- data, err := validate.FromRequest(request)
- assert.Nil(t, err)
-
- return data
+ return request
}(),
- rules: map[string]string{"time": "required"},
+ rules: map[string]any{"time": "required"},
assert: func(data Data) {
assert.Equal(t, "2025-05-23 22:16:39", data.Time.Format("2006-01-02 15:04:05"))
},
},
{
name: "data is post request with Time(date)",
- data: func() validate.DataFace {
+ data: func() *http.Request {
request, err := http.NewRequest(http.MethodPost, "/", bytes.NewBufferString(`{"time": "2025-05-23"}`))
request.Header.Set("Content-Type", "application/json")
assert.Nil(t, err)
-
- data, err := validate.FromRequest(request)
- assert.Nil(t, err)
-
- return data
+ return request
}(),
- rules: map[string]string{"time": "required"},
+ rules: map[string]any{"time": "required"},
assert: func(data Data) {
assert.Equal(t, "2025-05-23", data.Time.Format("2006-01-02"))
},
},
{
name: "data is post request with body",
- data: func() validate.DataFace {
- request := buildRequest(t)
- data, err := validate.FromRequest(request, 1)
- assert.Nil(t, err)
-
- return data
+ data: func() *http.Request {
+ return buildRequest(t)
}(),
- rules: map[string]string{"a": "required", "file": "file"},
+ rules: map[string]any{"a": "required", "file": "file"},
assert: func(data Data) {
request := buildRequest(t)
_, file, err := request.FormFile("file")
@@ -599,14 +492,10 @@ package validation
},
{
name: "data is post request with multiple files",
- data: func() validate.DataFace {
- request := buildRequestWithMultipleFiles(t)
- data, err := validate.FromRequest(request, 1)
- assert.Nil(t, err)
-
- return data
+ data: func() *http.Request {
+ return buildRequestWithMultipleFiles(t)
}(),
- rules: map[string]string{"a": "required", "files": "file"},
+ rules: map[string]any{"a": "required", "files": "file"},
assert: func(data Data) {
request := buildRequestWithMultipleFiles(t)
_, file, err := request.FormFile("files")
@@ -620,8 +509,8 @@ package validation
},
{
name: "data has embed struct field",
- data: validate.FromMap(map[string]any{"c": "cc"}),
- rules: map[string]string{"c": "required"},
+ data: map[string]any{"c": "cc"},
+ rules: map[string]any{"c": "required"},
assert: func(data Data) {
assert.Equal(t, "cc", data.C)
},
@@ -652,61 +541,53 @@ func TestBind_Filter(t *testing.T) {
tests := []struct {
name string
- data validate.DataFace
- rules map[string]string
- filters map[string]string
+ data any
+ rules map[string]any
+ filters map[string]any
assert func(data Data)
}{
{
name: "data is map and key is lowercase",
- data: validate.FromMap(map[string]any{"a": " a ", "b": "1"}),
- rules: map[string]string{"a": "required", "b": "required"},
- filters: map[string]string{"a": "trim", "b": "int"},
+ data: map[string]any{"a": " a ", "b": "1"},
+ rules: map[string]any{"a": "required", "b": "required"},
+ filters: map[string]any{"a": "trim", "b": "to_int"},
assert: func(data Data) {
assert.Equal(t, "a", data.A)
assert.Equal(t, 1, data.B)
},
},
{
- name: "data is map and key is lowercase, a no rule but has filter, a should keep the original value.",
- data: validate.FromMap(map[string]any{"a": "a", "b": " 1"}),
- rules: map[string]string{"b": "required"},
- filters: map[string]string{"a": "upper", "b": "trim|int"},
+ name: "data is map and key is lowercase, has filters",
+ data: map[string]any{"a": "a", "b": " 1"},
+ rules: map[string]any{"b": "required"},
+ filters: map[string]any{"a": "upper", "b": "trim|to_int"},
assert: func(data Data) {
- assert.Equal(t, "a", data.A)
+ assert.Equal(t, "A", data.A)
assert.Equal(t, 1, data.B)
},
},
{
name: "data is struct",
- data: func() validate.DataFace {
- data, err := validate.FromStruct(&struct {
- A string
- }{
- A: " a ",
- })
- assert.Nil(t, err)
-
- return data
- }(),
- rules: map[string]string{"A": "required"},
- filters: map[string]string{"A": "trim"},
+ data: &struct {
+ A string
+ }{
+ A: " a ",
+ },
+ rules: map[string]any{"A": "required"},
+ filters: map[string]any{"A": "trim"},
assert: func(data Data) {
assert.Equal(t, "a", data.A)
},
},
{
name: "data is get request",
- data: func() validate.DataFace {
+ data: func() *http.Request {
request, err := http.NewRequest(http.MethodGet, "/?a= a &&b=1", nil)
assert.Nil(t, err)
- data, err := validate.FromRequest(request)
- assert.Nil(t, err)
-
- return data
+ return request
}(),
- rules: map[string]string{"a": "required", "b": "required"},
- filters: map[string]string{"a": "trim"},
+ rules: map[string]any{"a": "required", "b": "required"},
+ filters: map[string]any{"a": "trim"},
assert: func(data Data) {
assert.Equal(t, "a", data.A)
assert.Equal(t, 1, data.B)
@@ -714,7 +595,7 @@ func TestBind_Filter(t *testing.T) {
},
{
name: "data is post request with body",
- data: func() validate.DataFace {
+ data: func() *http.Request {
payload := &bytes.Buffer{}
writer := multipart.NewWriter(payload)
@@ -726,13 +607,10 @@ func TestBind_Filter(t *testing.T) {
assert.Nil(t, err)
request.Header.Set("Content-Type", writer.FormDataContentType())
- data, err := validate.FromRequest(request, 1)
- assert.Nil(t, err)
-
- return data
+ return request
}(),
- rules: map[string]string{"a": "required", "file": "file"},
- filters: map[string]string{"a": "trim"},
+ rules: map[string]any{"a": "required", "file": "file"},
+ filters: map[string]any{"a": "trim"},
assert: func(data Data) {
assert.Equal(t, "a", data.A)
},
@@ -756,36 +634,31 @@ func TestBind_Filter(t *testing.T) {
}
func TestFails(t *testing.T) {
- var maker *Validation
tests := []struct {
describe string
data any
- rules map[string]string
- filters map[string]string
+ rules map[string]any
expectRes bool
}{
{
describe: "false",
data: map[string]any{"a": "aa"},
- rules: map[string]string{"a": "required"},
- filters: map[string]string{},
+ rules: map[string]any{"a": "required"},
},
{
describe: "true",
data: map[string]any{"b": "bb"},
- rules: map[string]string{"a": "required"},
- filters: map[string]string{},
+ rules: map[string]any{"a": "required"},
expectRes: true,
},
}
for _, test := range tests {
- maker = NewValidation()
+ maker := NewValidation()
validator, err := maker.Make(
context.Background(),
test.data,
test.rules,
- Filters(test.filters),
)
assert.Nil(t, err)
assert.Equal(t, test.expectRes, validator.Fails(), test.describe)
@@ -902,12 +775,12 @@ func TestCastValue(t *testing.T) {
tests := []struct {
name string
- data validate.DataFace
+ data any
wantErr error
}{
{
name: "success with struct",
- data: func() validate.DataFace {
+ data: func() *http.Request {
body := &Data{
String: "1",
Int: 1,
@@ -962,15 +835,12 @@ func TestCastValue(t *testing.T) {
request, err := http.NewRequest(http.MethodPost, "/", bytes.NewBuffer(jsonBytes))
request.Header.Set("Content-Type", "application/json")
assert.Nil(t, err)
- data, err := validate.FromRequest(request)
- assert.Nil(t, err)
-
- return data
+ return request
}(),
},
{
name: "success with map",
- data: func() validate.DataFace {
+ data: func() *http.Request {
body := map[string]any{
"String": "1",
"Int": "1",
@@ -1025,10 +895,7 @@ func TestCastValue(t *testing.T) {
request, err := http.NewRequest(http.MethodPost, "/", bytes.NewBuffer(jsonBytes))
request.Header.Set("Content-Type", "application/json")
assert.Nil(t, err)
- data, err := validate.FromRequest(request)
- assert.Nil(t, err)
-
- return data
+ return request
}(),
},
}
@@ -1036,7 +903,7 @@ func TestCastValue(t *testing.T) {
validation := NewValidation()
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
- validator, err := validation.Make(context.Background(), test.data, map[string]string{
+ validator, err := validation.Make(context.Background(), test.data, map[string]any{
"String": "required",
})
assert.Nil(t, err)
@@ -1157,7 +1024,7 @@ func TestCastCarbon(t *testing.T) {
},
},
{
- name: "Happy path - length 19 int",
+ name: "Happy path - length 19 string",
from: reflect.ValueOf("1720087252123456789"),
transform: func(c *carbon.Carbon) any {
return carbon.NewTimestampNano(c)
@@ -1257,4 +1124,4 @@ func buildRequestWithMultipleFiles(t *testing.T) *http.Request {
request.Header.Set("Content-Type", writer.FormDataContentType())
return request
-}*/
+}