committed by
Sacha Al Himdani
parent
288c59a5f2
commit
f9216d30b2
201
pkg/validator/checkeach_slice_test.go
Normal file
201
pkg/validator/checkeach_slice_test.go
Normal file
@@ -0,0 +1,201 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package validator
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// CustomType simulates types like gid.GID
|
||||
type CustomType string
|
||||
|
||||
func TestCheckEach_EmptyTypedSlice(t *testing.T) {
|
||||
v := New()
|
||||
|
||||
// Simulate what happens with []gid.GID{} (empty slice of custom type)
|
||||
emptySlice := []CustomType{}
|
||||
|
||||
v.CheckEach(emptySlice, "items", func(index int, item any) {
|
||||
// This callback should never be called for an empty slice
|
||||
t.Error("callback should not be called for empty slice")
|
||||
})
|
||||
|
||||
if v.HasErrors() {
|
||||
t.Errorf("unexpected error for empty slice: %v", v.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckEach_NonEmptyTypedSlice(t *testing.T) {
|
||||
v := New()
|
||||
|
||||
// Simulate what happens with []gid.GID{"abc", "def"}
|
||||
slice := []CustomType{"abc", "def"}
|
||||
|
||||
callCount := 0
|
||||
v.CheckEach(slice, "items", func(index int, item any) {
|
||||
callCount++
|
||||
// Verify the item is the correct type
|
||||
str, ok := item.(CustomType)
|
||||
if !ok {
|
||||
t.Errorf("expected CustomType, got %T", item)
|
||||
}
|
||||
if index == 0 && str != "abc" {
|
||||
t.Errorf("expected 'abc', got %s", str)
|
||||
}
|
||||
if index == 1 && str != "def" {
|
||||
t.Errorf("expected 'def', got %s", str)
|
||||
}
|
||||
})
|
||||
|
||||
if callCount != 2 {
|
||||
t.Errorf("expected callback to be called 2 times, got %d", callCount)
|
||||
}
|
||||
|
||||
if v.HasErrors() {
|
||||
t.Errorf("unexpected error: %v", v.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckEach_NilTypedSlice(t *testing.T) {
|
||||
v := New()
|
||||
|
||||
// Simulate what happens with var x []gid.GID (nil slice)
|
||||
var nilSlice []CustomType
|
||||
|
||||
v.CheckEach(nilSlice, "items", func(index int, item any) {
|
||||
// This callback should never be called for a nil slice
|
||||
t.Error("callback should not be called for nil slice")
|
||||
})
|
||||
|
||||
if v.HasErrors() {
|
||||
t.Errorf("unexpected error for nil slice: %v", v.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckEach_PointerToNonEmptySlice(t *testing.T) {
|
||||
v := New()
|
||||
|
||||
// Simulate what happens with *[]gid.GID (pointer to slice)
|
||||
slice := []CustomType{"abc", "def", "ghi"}
|
||||
ptrToSlice := &slice
|
||||
|
||||
callCount := 0
|
||||
v.CheckEach(ptrToSlice, "items", func(index int, item any) {
|
||||
callCount++
|
||||
str, ok := item.(CustomType)
|
||||
if !ok {
|
||||
t.Errorf("expected CustomType, got %T", item)
|
||||
}
|
||||
expectedValues := []CustomType{"abc", "def", "ghi"}
|
||||
if str != expectedValues[index] {
|
||||
t.Errorf("at index %d: expected %s, got %s", index, expectedValues[index], str)
|
||||
}
|
||||
})
|
||||
|
||||
if callCount != 3 {
|
||||
t.Errorf("expected callback to be called 3 times, got %d", callCount)
|
||||
}
|
||||
|
||||
if v.HasErrors() {
|
||||
t.Errorf("unexpected error for pointer to slice: %v", v.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckEach_PointerToEmptySlice(t *testing.T) {
|
||||
v := New()
|
||||
|
||||
// Simulate what happens with *[]gid.GID{} (pointer to empty slice)
|
||||
slice := []CustomType{}
|
||||
ptrToSlice := &slice
|
||||
|
||||
v.CheckEach(ptrToSlice, "items", func(index int, item any) {
|
||||
t.Error("callback should not be called for empty slice")
|
||||
})
|
||||
|
||||
if v.HasErrors() {
|
||||
t.Errorf("unexpected error for pointer to empty slice: %v", v.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckEach_NilPointerToSlice(t *testing.T) {
|
||||
v := New()
|
||||
|
||||
// Simulate what happens with var x *[]gid.GID (nil pointer to slice)
|
||||
var nilPtrToSlice *[]CustomType
|
||||
|
||||
v.CheckEach(nilPtrToSlice, "items", func(index int, item any) {
|
||||
t.Error("callback should not be called for nil pointer to slice")
|
||||
})
|
||||
|
||||
if v.HasErrors() {
|
||||
t.Errorf("unexpected error for nil pointer to slice: %v", v.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckEach_DoublePointerToSlice(t *testing.T) {
|
||||
v := New()
|
||||
|
||||
// Simulate what happens with **[]gid.GID (double pointer to slice)
|
||||
slice := []CustomType{"x", "y"}
|
||||
ptrToSlice := &slice
|
||||
doublePtrToSlice := &ptrToSlice
|
||||
|
||||
callCount := 0
|
||||
v.CheckEach(doublePtrToSlice, "items", func(index int, item any) {
|
||||
callCount++
|
||||
str, ok := item.(CustomType)
|
||||
if !ok {
|
||||
t.Errorf("expected CustomType, got %T", item)
|
||||
}
|
||||
expectedValues := []CustomType{"x", "y"}
|
||||
if str != expectedValues[index] {
|
||||
t.Errorf("at index %d: expected %s, got %s", index, expectedValues[index], str)
|
||||
}
|
||||
})
|
||||
|
||||
if callCount != 2 {
|
||||
t.Errorf("expected callback to be called 2 times, got %d", callCount)
|
||||
}
|
||||
|
||||
if v.HasErrors() {
|
||||
t.Errorf("unexpected error for double pointer to slice: %v", v.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckEach_NonSliceValue(t *testing.T) {
|
||||
v := New()
|
||||
|
||||
// Pass a non-slice value
|
||||
notASlice := "this is a string"
|
||||
|
||||
v.CheckEach(notASlice, "items", func(index int, item any) {
|
||||
t.Error("callback should not be called for non-slice value")
|
||||
})
|
||||
|
||||
if !v.HasErrors() {
|
||||
t.Error("expected error for non-slice value")
|
||||
}
|
||||
|
||||
errors := v.Errors()
|
||||
if len(errors) != 1 {
|
||||
t.Errorf("expected 1 error, got %d", len(errors))
|
||||
}
|
||||
if errors[0].Code != ErrorCodeInvalidFormat {
|
||||
t.Errorf("expected error code %s, got %s", ErrorCodeInvalidFormat, errors[0].Code)
|
||||
}
|
||||
if errors[0].Message != "expected a slice" {
|
||||
t.Errorf("expected message 'expected a slice', got '%s'", errors[0].Message)
|
||||
}
|
||||
}
|
||||
98
pkg/validator/double_pointer_test.go
Normal file
98
pkg/validator/double_pointer_test.go
Normal file
@@ -0,0 +1,98 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package validator_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"go.probo.inc/probo/pkg/validator"
|
||||
)
|
||||
|
||||
func TestDoublePointerValidation(t *testing.T) {
|
||||
t.Run("valid double pointer string", func(t *testing.T) {
|
||||
v := validator.New()
|
||||
str := "hello"
|
||||
ptr := &str
|
||||
doublePtr := &ptr
|
||||
|
||||
v.Check(doublePtr, "name", validator.Required(), validator.NotEmpty(), validator.MaxLen(1000))
|
||||
|
||||
if v.HasErrors() {
|
||||
t.Errorf("expected no errors, got: %v", v.Error())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid double pointer string - empty", func(t *testing.T) {
|
||||
v := validator.New()
|
||||
str := ""
|
||||
ptr := &str
|
||||
doublePtr := &ptr
|
||||
|
||||
v.Check(doublePtr, "name", validator.Required(), validator.NotEmpty())
|
||||
|
||||
if !v.HasErrors() {
|
||||
t.Error("expected errors for empty string")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid double pointer string - too long", func(t *testing.T) {
|
||||
v := validator.New()
|
||||
str := "this is a very long string that exceeds the maximum length"
|
||||
ptr := &str
|
||||
doublePtr := &ptr
|
||||
|
||||
v.Check(doublePtr, "name", validator.Required(), validator.MaxLen(10))
|
||||
|
||||
if !v.HasErrors() {
|
||||
t.Error("expected errors for string exceeding max length")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("optional double pointer - nil outer pointer", func(t *testing.T) {
|
||||
v := validator.New()
|
||||
var doublePtr **string = nil
|
||||
|
||||
v.Check(doublePtr, "name", validator.NotEmpty(), validator.MaxLen(1000))
|
||||
|
||||
if v.HasErrors() {
|
||||
t.Errorf("expected no errors for nil optional field, got: %v", v.Error())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("optional double pointer - nil inner pointer", func(t *testing.T) {
|
||||
v := validator.New()
|
||||
var ptr *string = nil
|
||||
doublePtr := &ptr
|
||||
|
||||
v.Check(doublePtr, "name", validator.NotEmpty(), validator.MaxLen(1000))
|
||||
|
||||
if v.HasErrors() {
|
||||
t.Errorf("expected no errors for nil optional field, got: %v", v.Error())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("optional double pointer - valid value", func(t *testing.T) {
|
||||
v := validator.New()
|
||||
str := "hello"
|
||||
ptr := &str
|
||||
doublePtr := &ptr
|
||||
|
||||
v.Check(doublePtr, "name", validator.NotEmpty(), validator.MaxLen(1000))
|
||||
|
||||
if v.HasErrors() {
|
||||
t.Errorf("expected no errors, got: %v", v.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
107
pkg/validator/errors.go
Normal file
107
pkg/validator/errors.go
Normal file
@@ -0,0 +1,107 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package validator
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type ErrorCode string
|
||||
|
||||
const (
|
||||
ErrorCodeRequired ErrorCode = "REQUIRED"
|
||||
ErrorCodeInvalidFormat ErrorCode = "INVALID_FORMAT"
|
||||
ErrorCodeOutOfRange ErrorCode = "OUT_OF_RANGE"
|
||||
ErrorCodeTooShort ErrorCode = "TOO_SHORT"
|
||||
ErrorCodeTooLong ErrorCode = "TOO_LONG"
|
||||
ErrorCodeInvalidEmail ErrorCode = "INVALID_EMAIL"
|
||||
ErrorCodeInvalidURL ErrorCode = "INVALID_URL"
|
||||
ErrorCodeInvalidEnum ErrorCode = "INVALID_ENUM"
|
||||
ErrorCodeInvalidGID ErrorCode = "INVALID_GID"
|
||||
ErrorCodeUnsafeContent ErrorCode = "UNSAFE_CONTENT"
|
||||
ErrorCodeCustom ErrorCode = "CUSTOM"
|
||||
)
|
||||
|
||||
type ValidationError struct {
|
||||
Field string
|
||||
Code ErrorCode
|
||||
Message string
|
||||
Value any
|
||||
}
|
||||
|
||||
func (e *ValidationError) Error() string {
|
||||
return fmt.Sprintf("%s %s", e.Field, e.Message)
|
||||
}
|
||||
|
||||
type ValidationErrors []*ValidationError
|
||||
|
||||
func (ve ValidationErrors) Error() string {
|
||||
if len(ve) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var messages []string
|
||||
for _, err := range ve {
|
||||
messages = append(messages, err.Error())
|
||||
}
|
||||
return strings.Join(messages, "; ")
|
||||
}
|
||||
|
||||
func (ve ValidationErrors) HasErrors() bool {
|
||||
return len(ve) > 0
|
||||
}
|
||||
|
||||
func (ve ValidationErrors) Fields() []string {
|
||||
fields := make([]string, 0, len(ve))
|
||||
for _, err := range ve {
|
||||
fields = append(fields, err.Field)
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
func (ve ValidationErrors) ByField(field string) ValidationErrors {
|
||||
var errors ValidationErrors
|
||||
for _, err := range ve {
|
||||
if err.Field == field {
|
||||
errors = append(errors, err)
|
||||
}
|
||||
}
|
||||
return errors
|
||||
}
|
||||
|
||||
func (ve ValidationErrors) ByCode(code ErrorCode) ValidationErrors {
|
||||
var errors ValidationErrors
|
||||
for _, err := range ve {
|
||||
if err.Code == code {
|
||||
errors = append(errors, err)
|
||||
}
|
||||
}
|
||||
return errors
|
||||
}
|
||||
|
||||
func (ve ValidationErrors) First() *ValidationError {
|
||||
if len(ve) == 0 {
|
||||
return nil
|
||||
}
|
||||
return ve[0]
|
||||
}
|
||||
|
||||
func newValidationError(code ErrorCode, message string) *ValidationError {
|
||||
return &ValidationError{
|
||||
Code: code,
|
||||
Message: message,
|
||||
}
|
||||
}
|
||||
82
pkg/validator/oneof_custom_type_test.go
Normal file
82
pkg/validator/oneof_custom_type_test.go
Normal file
@@ -0,0 +1,82 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package validator
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// AssetType simulates coredata.AssetType
|
||||
type AssetType string
|
||||
|
||||
const (
|
||||
AssetTypePhysical AssetType = "PHYSICAL"
|
||||
AssetTypeVirtual AssetType = "VIRTUAL"
|
||||
)
|
||||
|
||||
func (at AssetType) String() string {
|
||||
return string(at)
|
||||
}
|
||||
|
||||
func TestOneOf_CustomStringType(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value any
|
||||
allowed []string
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "valid custom type - physical",
|
||||
value: AssetTypePhysical,
|
||||
allowed: []string{"PHYSICAL", "VIRTUAL"},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "valid custom type - virtual",
|
||||
value: AssetTypeVirtual,
|
||||
allowed: []string{"PHYSICAL", "VIRTUAL"},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "invalid custom type",
|
||||
value: AssetType("INVALID"),
|
||||
allowed: []string{"PHYSICAL", "VIRTUAL"},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "custom type not in allowed list",
|
||||
value: AssetTypePhysical,
|
||||
allowed: []string{"VIRTUAL"},
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
v := New()
|
||||
v.Check(tt.value, "asset_type", OneOfSlice(tt.allowed))
|
||||
|
||||
if tt.expectError {
|
||||
if !v.HasErrors() {
|
||||
t.Error("expected error but got none")
|
||||
}
|
||||
} else {
|
||||
if v.HasErrors() {
|
||||
t.Errorf("unexpected error: %v", v.Error())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
116
pkg/validator/optional_pointer_test.go
Normal file
116
pkg/validator/optional_pointer_test.go
Normal file
@@ -0,0 +1,116 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package validator
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
)
|
||||
|
||||
// CustomStringType simulates coredata.AssetType
|
||||
type CustomStringType string
|
||||
|
||||
func (c CustomStringType) String() string {
|
||||
return string(c)
|
||||
}
|
||||
|
||||
func TestOptional_WithGIDPointer(t *testing.T) {
|
||||
tenantID := gid.NewTenantID()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
value *gid.GID
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "nil pointer - should skip validation",
|
||||
value: nil,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "valid GID pointer",
|
||||
value: func() *gid.GID {
|
||||
g := gid.New(tenantID, 100)
|
||||
return &g
|
||||
}(),
|
||||
expectError: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
v := New()
|
||||
v.Check(tt.value, "owner_id", GID(100))
|
||||
|
||||
if tt.expectError {
|
||||
if !v.HasErrors() {
|
||||
t.Error("expected error but got none")
|
||||
}
|
||||
} else {
|
||||
if v.HasErrors() {
|
||||
t.Errorf("unexpected error: %v", v.Error())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOptional_WithCustomTypePointer(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value *CustomStringType
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "nil pointer - should skip validation",
|
||||
value: nil,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "valid custom type pointer",
|
||||
value: func() *CustomStringType {
|
||||
v := CustomStringType("VALID")
|
||||
return &v
|
||||
}(),
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "invalid custom type pointer",
|
||||
value: func() *CustomStringType {
|
||||
v := CustomStringType("INVALID")
|
||||
return &v
|
||||
}(),
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
v := New()
|
||||
v.Check(tt.value, "asset_type", OneOf("VALID", "ANOTHER"))
|
||||
|
||||
if tt.expectError {
|
||||
if !v.HasErrors() {
|
||||
t.Error("expected error but got none")
|
||||
}
|
||||
} else {
|
||||
if v.HasErrors() {
|
||||
t.Errorf("unexpected error: %v", v.Error())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
149
pkg/validator/validation.go
Normal file
149
pkg/validator/validation.go
Normal file
@@ -0,0 +1,149 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package validator
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
)
|
||||
|
||||
type Validator struct {
|
||||
errors ValidationErrors
|
||||
}
|
||||
|
||||
func New() *Validator {
|
||||
return &Validator{
|
||||
errors: ValidationErrors{},
|
||||
}
|
||||
}
|
||||
|
||||
func (v *Validator) Check(value any, field string, validators ...ValidatorFunc) {
|
||||
if len(validators) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Dereference pointer values to get the actual value for validation
|
||||
actualValue := value
|
||||
if value != nil {
|
||||
val := reflect.ValueOf(value)
|
||||
// Dereference all pointer levels
|
||||
for val.Kind() == reflect.Ptr && !val.IsNil() {
|
||||
val = val.Elem()
|
||||
actualValue = val.Interface()
|
||||
}
|
||||
// If we ended up with a nil pointer at any level, set actualValue to nil
|
||||
if val.Kind() == reflect.Ptr && val.IsNil() {
|
||||
actualValue = nil
|
||||
}
|
||||
}
|
||||
|
||||
for _, validator := range validators {
|
||||
if err := validator(actualValue); err != nil {
|
||||
v.errors = append(v.errors, &ValidationError{
|
||||
Field: field,
|
||||
Code: err.Code,
|
||||
Message: err.Message,
|
||||
Value: value,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (v *Validator) CheckEach(items any, field string, fn func(index int, item any)) {
|
||||
if items == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if slice, ok := items.([]any); ok {
|
||||
for i, item := range slice {
|
||||
fn(i, item)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val := reflect.ValueOf(items)
|
||||
// Dereference pointer levels to get to the actual slice
|
||||
for val.Kind() == reflect.Ptr {
|
||||
if val.IsNil() {
|
||||
return
|
||||
}
|
||||
val = val.Elem()
|
||||
}
|
||||
|
||||
if val.Kind() != reflect.Slice {
|
||||
v.errors = append(v.errors, &ValidationError{
|
||||
Field: field,
|
||||
Code: ErrorCodeInvalidFormat,
|
||||
Message: "expected a slice",
|
||||
Value: items,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
for i := 0; i < val.Len(); i++ {
|
||||
fn(i, val.Index(i).Interface())
|
||||
}
|
||||
}
|
||||
|
||||
func (v *Validator) CheckNested(field string, fn func(v *Validator)) {
|
||||
nestedValidator := New()
|
||||
fn(nestedValidator)
|
||||
|
||||
for _, err := range nestedValidator.errors {
|
||||
prefixedErr := &ValidationError{
|
||||
Field: fmt.Sprintf("%s.%s", field, err.Field),
|
||||
Code: err.Code,
|
||||
Message: err.Message,
|
||||
Value: err.Value,
|
||||
}
|
||||
v.errors = append(v.errors, prefixedErr)
|
||||
}
|
||||
}
|
||||
|
||||
func (v *Validator) HasErrors() bool {
|
||||
return len(v.errors) > 0
|
||||
}
|
||||
|
||||
func (v *Validator) Errors() ValidationErrors {
|
||||
return v.errors
|
||||
}
|
||||
|
||||
func (v *Validator) Error() error {
|
||||
if len(v.errors) == 0 {
|
||||
return nil
|
||||
}
|
||||
return v.errors
|
||||
}
|
||||
|
||||
type ValidatorFunc func(value any) *ValidationError
|
||||
|
||||
// dereferenceValue recursively dereferences all pointer levels.
|
||||
// Returns the final dereferenced value and a boolean indicating if any pointer in the chain was nil.
|
||||
func dereferenceValue(value any) (any, bool) {
|
||||
if value == nil {
|
||||
return nil, true
|
||||
}
|
||||
|
||||
val := reflect.ValueOf(value)
|
||||
// Dereference all pointer levels
|
||||
for val.Kind() == reflect.Ptr {
|
||||
if val.IsNil() {
|
||||
return nil, true
|
||||
}
|
||||
val = val.Elem()
|
||||
}
|
||||
|
||||
return val.Interface(), false
|
||||
}
|
||||
425
pkg/validator/validation_bench_test.go
Normal file
425
pkg/validator/validation_bench_test.go
Normal file
@@ -0,0 +1,425 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package validator
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func BenchmarkValidate_SingleField(b *testing.B) {
|
||||
email := "test@example.com"
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
v := New()
|
||||
v.Check(&email, "email", Required(), Email())
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkValidate_MultipleFields(b *testing.B) {
|
||||
email := "test@example.com"
|
||||
password := "password123"
|
||||
age := 25
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
v := New()
|
||||
v.Check(&email, "email", Required(), Email())
|
||||
v.Check(&password, "password", Required(), MinLen(8))
|
||||
v.Check(&age, "age", Min(18), Max(120))
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkValidate_OptionalField(b *testing.B) {
|
||||
var website *string
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
v := New()
|
||||
v.Check(website, "website", URL())
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkValidate_NestedStruct(b *testing.B) {
|
||||
type Address struct {
|
||||
City string
|
||||
ZipCode string
|
||||
}
|
||||
|
||||
type User struct {
|
||||
Name string
|
||||
Address Address
|
||||
}
|
||||
|
||||
user := User{
|
||||
Name: "John Doe",
|
||||
Address: Address{
|
||||
City: "New York",
|
||||
ZipCode: "10001",
|
||||
},
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
v := New()
|
||||
v.Check(&user.Name, "name", Required())
|
||||
v.CheckNested("address", func(av *Validator) {
|
||||
av.Check(&user.Address.City, "city", Required())
|
||||
av.Check(&user.Address.ZipCode, "zipCode", Pattern(`^\d{5}$`, ""))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkValidate_ArrayValidation(b *testing.B) {
|
||||
type Item struct {
|
||||
Name string
|
||||
Price int
|
||||
}
|
||||
|
||||
items := []Item{
|
||||
{Name: "Item 1", Price: 100},
|
||||
{Name: "Item 2", Price: 200},
|
||||
{Name: "Item 3", Price: 300},
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
v := New()
|
||||
for j, item := range items {
|
||||
v.CheckNested("items[0]", func(iv *Validator) {
|
||||
iv.Check(&item.Name, "name", Required())
|
||||
iv.Check(&item.Price, "price", Min(0))
|
||||
_ = j
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkEmail(b *testing.B) {
|
||||
email := "test@example.com"
|
||||
validator := Email()
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = validator(&email)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkURL(b *testing.B) {
|
||||
urlStr := "https://example.com"
|
||||
validator := URL()
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = validator(&urlStr)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkUUID(b *testing.B) {
|
||||
uuid := "550e8400-e29b-41d4-a716-446655440000"
|
||||
validator := UUID()
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = validator(&uuid)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkMinLen(b *testing.B) {
|
||||
str := "hello world"
|
||||
validator := MinLen(5)
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = validator(&str)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkMin(b *testing.B) {
|
||||
num := 42
|
||||
validator := Min(18)
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = validator(&num)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkMinFloat(b *testing.B) {
|
||||
num := 99.99
|
||||
validator := MinFloat(0.01)
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = validator(&num)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkMaxFloat(b *testing.B) {
|
||||
num := 50.50
|
||||
validator := MaxFloat(99.99)
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = validator(&num)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkRangeFloat(b *testing.B) {
|
||||
num := 50.50
|
||||
validator := RangeFloat(0.01, 99.99)
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = validator(&num)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkNotEmpty(b *testing.B) {
|
||||
str := "hello world"
|
||||
validator := NotEmpty()
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = validator(&str)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkPattern(b *testing.B) {
|
||||
zipCode := "12345"
|
||||
validator := Pattern(`^\d{5}$`, "")
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = validator(&zipCode)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkValidate_WithErrors(b *testing.B) {
|
||||
email := "invalid-email"
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
v := New()
|
||||
v.Check(&email, "email", Required(), Email())
|
||||
if !v.HasErrors() {
|
||||
b.Fatal("expected validation error")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkValidate_ComplexForm(b *testing.B) {
|
||||
type Address struct {
|
||||
Street string
|
||||
City string
|
||||
ZipCode string
|
||||
}
|
||||
|
||||
type User struct {
|
||||
Email string
|
||||
Name string
|
||||
Age int
|
||||
Website *string
|
||||
PhoneNumber *string
|
||||
Price float64
|
||||
Address Address
|
||||
}
|
||||
|
||||
website := "https://example.com"
|
||||
user := User{
|
||||
Email: "user@example.com",
|
||||
Name: "John Doe",
|
||||
Age: 30,
|
||||
Website: &website,
|
||||
PhoneNumber: nil,
|
||||
Price: 99.99,
|
||||
Address: Address{
|
||||
Street: "123 Main St",
|
||||
City: "New York",
|
||||
ZipCode: "10001",
|
||||
},
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
v := New()
|
||||
v.Check(&user.Email, "email", Required(), Email())
|
||||
v.Check(&user.Name, "name", Required(), MinLen(2))
|
||||
v.Check(&user.Age, "age", Min(18), Max(120))
|
||||
v.Check(user.Website, "website", URL())
|
||||
v.Check(user.PhoneNumber, "phoneNumber", MinLen(10))
|
||||
v.Check(&user.Price, "price", MinFloat(0.01))
|
||||
|
||||
v.CheckNested("address", func(av *Validator) {
|
||||
av.Check(&user.Address.Street, "street", Required())
|
||||
av.Check(&user.Address.City, "city", Required())
|
||||
av.Check(&user.Address.ZipCode, "zipCode", Pattern(`^\d{5}$`, ""))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkMinItems(b *testing.B) {
|
||||
items := []string{"a", "b", "c"}
|
||||
validator := MinItems(2)
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = validator(&items)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkMaxItems(b *testing.B) {
|
||||
items := []string{"a", "b", "c"}
|
||||
validator := MaxItems(5)
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = validator(&items)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkUniqueItems(b *testing.B) {
|
||||
items := []string{"a", "b", "c"}
|
||||
validator := UniqueItems()
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = validator(&items)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkAlphaNumeric(b *testing.B) {
|
||||
str := "abc123DEF456"
|
||||
validator := AlphaNumeric()
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = validator(&str)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkNoSpaces(b *testing.B) {
|
||||
str := "hello-world-test"
|
||||
validator := NoSpaces()
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = validator(&str)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkSlug(b *testing.B) {
|
||||
str := "hello-world-123"
|
||||
validator := Slug()
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = validator(&str)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkAfter(b *testing.B) {
|
||||
now := time.Now()
|
||||
future := now.Add(24 * time.Hour)
|
||||
validator := After(now)
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = validator(&future)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkBefore(b *testing.B) {
|
||||
now := time.Now()
|
||||
past := now.Add(-24 * time.Hour)
|
||||
validator := Before(now)
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = validator(&past)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkFutureDate(b *testing.B) {
|
||||
future := time.Now().Add(24 * time.Hour)
|
||||
validator := FutureDate()
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = validator(&future)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkPastDate(b *testing.B) {
|
||||
past := time.Now().Add(-24 * time.Hour)
|
||||
validator := PastDate()
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = validator(&past)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkEqualTo(b *testing.B) {
|
||||
str1 := "password"
|
||||
str2 := "password"
|
||||
validator := EqualTo(&str2)
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = validator(&str1)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkNotEqualTo(b *testing.B) {
|
||||
str1 := "password"
|
||||
str2 := "different"
|
||||
validator := NotEqualTo(&str2)
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = validator(&str1)
|
||||
}
|
||||
}
|
||||
func BenchmarkDomain(b *testing.B) {
|
||||
str := "api.example.com"
|
||||
validator := Domain()
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = validator(&str)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkHTTPUrl(b *testing.B) {
|
||||
str := "http://api.example.com/v1/users"
|
||||
validator := HTTPUrl()
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = validator(&str)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkHTTPSUrl(b *testing.B) {
|
||||
str := "https://api.example.com/v1/users"
|
||||
validator := HTTPSUrl()
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = validator(&str)
|
||||
}
|
||||
}
|
||||
451
pkg/validator/validation_test.go
Normal file
451
pkg/validator/validation_test.go
Normal file
@@ -0,0 +1,451 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package validator
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"go.gearno.de/x/ref"
|
||||
)
|
||||
|
||||
func TestValidator_Validate(t *testing.T) {
|
||||
t.Run("single field validation", func(t *testing.T) {
|
||||
v := New()
|
||||
email := "test@example.com"
|
||||
v.Check(&email, "email", Required(), Email())
|
||||
|
||||
if v.HasErrors() {
|
||||
t.Errorf("expected no errors, got: %v", v.Errors())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("multiple field validations", func(t *testing.T) {
|
||||
v := New()
|
||||
email := ""
|
||||
password := "123"
|
||||
|
||||
v.Check(&email, "email", Required(), Email())
|
||||
v.Check(&password, "password", Required(), MinLen(8))
|
||||
|
||||
if !v.HasErrors() {
|
||||
t.Error("expected validation errors")
|
||||
}
|
||||
|
||||
errors := v.Errors()
|
||||
// email: 1 error (Required), password: 1 error (MinLen - Required passes because it's not empty)
|
||||
if len(errors) != 2 {
|
||||
t.Errorf("expected 2 errors, got %d: %v", len(errors), errors)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("collect multiple errors for same field", func(t *testing.T) {
|
||||
v := New()
|
||||
value := "abc"
|
||||
|
||||
v.Check(&value, "password", MinLen(8), MaxLen(5))
|
||||
|
||||
errors := v.Errors()
|
||||
// Both MinLen and MaxLen will fail (too short and somehow conflicts, but logically MinLen will fail)
|
||||
if len(errors) < 1 {
|
||||
t.Errorf("expected at least 1 error, got %d", len(errors))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidator_CheckNested(t *testing.T) {
|
||||
v := New()
|
||||
|
||||
v.CheckNested("user", func(nv *Validator) {
|
||||
email := "invalid"
|
||||
nv.Check(&email, "email", Email())
|
||||
|
||||
nv.CheckNested("address", func(av *Validator) {
|
||||
city := ""
|
||||
av.Check(&city, "city", Required())
|
||||
})
|
||||
})
|
||||
|
||||
if !v.HasErrors() {
|
||||
t.Error("expected validation errors")
|
||||
}
|
||||
|
||||
errors := v.Errors()
|
||||
if len(errors) != 2 {
|
||||
t.Errorf("expected 2 errors, got %d", len(errors))
|
||||
}
|
||||
|
||||
// Check field paths
|
||||
expectedFields := map[string]bool{
|
||||
"user.email": true,
|
||||
"user.address.city": true,
|
||||
}
|
||||
|
||||
for _, err := range errors {
|
||||
if !expectedFields[err.Field] {
|
||||
t.Errorf("unexpected field path: %s", err.Field)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidator_Error(t *testing.T) {
|
||||
t.Run("no errors", func(t *testing.T) {
|
||||
v := New()
|
||||
if v.Error() != nil {
|
||||
t.Errorf("expected nil error, got: %v", v.Error())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("with errors", func(t *testing.T) {
|
||||
v := New()
|
||||
email := ""
|
||||
v.Check(&email, "email", Required())
|
||||
|
||||
err := v.Error()
|
||||
if err == nil {
|
||||
t.Error("expected error, got nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidationErrors_Methods(t *testing.T) {
|
||||
errors := ValidationErrors{
|
||||
{Field: "email", Code: ErrorCodeInvalidEmail, Message: "invalid email"},
|
||||
{Field: "password", Code: ErrorCodeTooShort, Message: "too short"},
|
||||
{Field: "email", Code: ErrorCodeRequired, Message: "required"},
|
||||
}
|
||||
|
||||
t.Run("Fields", func(t *testing.T) {
|
||||
fields := errors.Fields()
|
||||
if len(fields) != 3 {
|
||||
t.Errorf("expected 3 fields, got %d", len(fields))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ByField", func(t *testing.T) {
|
||||
emailErrors := errors.ByField("email")
|
||||
if len(emailErrors) != 2 {
|
||||
t.Errorf("expected 2 email errors, got %d", len(emailErrors))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ByCode", func(t *testing.T) {
|
||||
requiredErrors := errors.ByCode(ErrorCodeRequired)
|
||||
if len(requiredErrors) != 1 {
|
||||
t.Errorf("expected 1 required error, got %d", len(requiredErrors))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("First", func(t *testing.T) {
|
||||
first := errors.First()
|
||||
if first == nil {
|
||||
t.Error("expected first error")
|
||||
}
|
||||
if first.Field != "email" {
|
||||
t.Errorf("expected first field to be 'email', got '%s'", first.Field)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Error", func(t *testing.T) {
|
||||
errorStr := errors.Error()
|
||||
if errorStr == "" {
|
||||
t.Error("expected non-empty error string")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestOptionalFieldExample(t *testing.T) {
|
||||
type CreateUserRequest struct {
|
||||
Email string
|
||||
Name string
|
||||
Website *string
|
||||
PhoneNumber *string
|
||||
Age *int
|
||||
}
|
||||
|
||||
website := "not-a-url"
|
||||
req := CreateUserRequest{
|
||||
Email: "user@example.com",
|
||||
Name: "John Doe",
|
||||
Website: &website,
|
||||
PhoneNumber: nil,
|
||||
Age: nil,
|
||||
}
|
||||
|
||||
v := New()
|
||||
|
||||
v.Check(&req.Email, "email", Required(), Email())
|
||||
v.Check(&req.Name, "name", Required(), MinLen(2))
|
||||
v.Check(req.Website, "website", URL())
|
||||
v.Check(req.PhoneNumber, "phoneNumber", MinLen(10))
|
||||
v.Check(req.Age, "age", Min(18), Max(120))
|
||||
|
||||
if !v.HasErrors() {
|
||||
t.Fatal("expected validation errors")
|
||||
}
|
||||
|
||||
errors := v.Errors()
|
||||
|
||||
websiteErr := errors.ByField("website")
|
||||
if len(websiteErr) != 1 {
|
||||
t.Errorf("expected 1 website error, got %d", len(websiteErr))
|
||||
}
|
||||
|
||||
phoneErr := errors.ByField("phoneNumber")
|
||||
if len(phoneErr) != 0 {
|
||||
t.Errorf("expected 0 phoneNumber errors (nil should be skipped), got %d", len(phoneErr))
|
||||
}
|
||||
|
||||
ageErr := errors.ByField("age")
|
||||
if len(ageErr) != 0 {
|
||||
t.Errorf("expected 0 age errors (nil should be skipped), got %d", len(ageErr))
|
||||
}
|
||||
|
||||
t.Logf("Optional field validation errors: %s", errors.Error())
|
||||
}
|
||||
|
||||
func TestRealWorldExample(t *testing.T) {
|
||||
// Simulate a user registration form
|
||||
type Address struct {
|
||||
City string
|
||||
ZipCode string
|
||||
}
|
||||
|
||||
type User struct {
|
||||
Email string
|
||||
Password string
|
||||
Age int
|
||||
Website *string
|
||||
Address Address
|
||||
}
|
||||
|
||||
user := User{
|
||||
Email: "invalid-email",
|
||||
Password: "123",
|
||||
Age: 15,
|
||||
Website: ref.Ref("not-a-url"),
|
||||
Address: Address{
|
||||
City: "",
|
||||
ZipCode: "12345",
|
||||
},
|
||||
}
|
||||
|
||||
v := New()
|
||||
|
||||
// Validate user fields
|
||||
v.Check(&user.Email, "email", Required(), Email())
|
||||
v.Check(&user.Password, "password", Required(), MinLen(8))
|
||||
v.Check(&user.Age, "age", Min(18), Max(120))
|
||||
v.Check(user.Website, "website", URL())
|
||||
|
||||
// Validate nested address
|
||||
v.CheckNested("address", func(av *Validator) {
|
||||
av.Check(&user.Address.City, "city", Required())
|
||||
av.Check(&user.Address.ZipCode, "zipCode", Pattern(`^\d{5}$`, "must be 5 digits"))
|
||||
})
|
||||
|
||||
if !v.HasErrors() {
|
||||
t.Fatal("expected validation errors")
|
||||
}
|
||||
|
||||
errors := v.Errors()
|
||||
expectedErrors := map[string]ErrorCode{
|
||||
"email": ErrorCodeInvalidEmail,
|
||||
"password": ErrorCodeTooShort,
|
||||
"age": ErrorCodeOutOfRange,
|
||||
"website": ErrorCodeInvalidURL,
|
||||
"address.city": ErrorCodeRequired,
|
||||
}
|
||||
|
||||
// Check that we have the expected errors
|
||||
for field, expectedCode := range expectedErrors {
|
||||
found := false
|
||||
for _, err := range errors {
|
||||
if err.Field == field && err.Code == expectedCode {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("expected error for field '%s' with code '%s'", field, expectedCode)
|
||||
}
|
||||
}
|
||||
|
||||
// Print errors for debugging
|
||||
t.Logf("Validation errors: %s", errors.Error())
|
||||
}
|
||||
|
||||
func TestArrayValidation(t *testing.T) {
|
||||
type Item struct {
|
||||
Name string
|
||||
Price int
|
||||
}
|
||||
|
||||
items := []Item{
|
||||
{Name: "", Price: -10},
|
||||
{Name: "Valid", Price: 100},
|
||||
{Name: "X", Price: 10},
|
||||
}
|
||||
|
||||
v := New()
|
||||
|
||||
// Validate each item
|
||||
for i, item := range items {
|
||||
field := fmt.Sprintf("items[%d]", i)
|
||||
v.CheckNested(field, func(iv *Validator) {
|
||||
iv.Check(&item.Name, "name", Required(), MinLen(2))
|
||||
iv.Check(&item.Price, "price", Min(0))
|
||||
})
|
||||
}
|
||||
|
||||
if !v.HasErrors() {
|
||||
t.Fatal("expected validation errors")
|
||||
}
|
||||
|
||||
errors := v.Errors()
|
||||
|
||||
// Check for specific field paths
|
||||
expectedFields := []string{
|
||||
"items[0].name",
|
||||
"items[0].price",
|
||||
"items[2].name",
|
||||
}
|
||||
|
||||
for _, expectedField := range expectedFields {
|
||||
found := false
|
||||
for _, err := range errors {
|
||||
if err.Field == expectedField {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("expected error for field '%s'", expectedField)
|
||||
}
|
||||
}
|
||||
|
||||
t.Logf("Array validation errors: %s", errors.Error())
|
||||
}
|
||||
|
||||
func TestDuplicateValidators(t *testing.T) {
|
||||
t.Run("duplicate MinLen creates two errors", func(t *testing.T) {
|
||||
v := New()
|
||||
name := "abc"
|
||||
v.Check(&name, "name", MinLen(5), MinLen(5))
|
||||
|
||||
if !v.HasErrors() {
|
||||
t.Error("expected validation errors")
|
||||
}
|
||||
|
||||
errors := v.Errors()
|
||||
if len(errors) != 2 {
|
||||
t.Errorf("expected 2 errors (one per MinLen), got %d", len(errors))
|
||||
}
|
||||
|
||||
if errors[0].Message != "must be at least 5 characters" {
|
||||
t.Errorf("unexpected first error: %s", errors[0].Message)
|
||||
}
|
||||
if errors[1].Message != "must be at least 5 characters" {
|
||||
t.Errorf("unexpected second error: %s", errors[1].Message)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("duplicate Required creates two errors", func(t *testing.T) {
|
||||
v := New()
|
||||
name := ""
|
||||
v.Check(&name, "name", Required(), Required())
|
||||
|
||||
errors := v.Errors()
|
||||
if len(errors) != 2 {
|
||||
t.Errorf("expected 2 errors, got %d", len(errors))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("duplicate Email creates two errors", func(t *testing.T) {
|
||||
v := New()
|
||||
email := "invalid"
|
||||
v.Check(&email, "email", Email(), Email())
|
||||
|
||||
errors := v.Errors()
|
||||
if len(errors) != 2 {
|
||||
t.Errorf("expected 2 errors, got %d", len(errors))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("same validator with different parameters", func(t *testing.T) {
|
||||
v := New()
|
||||
name := "test"
|
||||
v.Check(&name, "name", MinLen(5), MinLen(10))
|
||||
|
||||
errors := v.Errors()
|
||||
if len(errors) != 2 {
|
||||
t.Errorf("expected 2 errors, got %d", len(errors))
|
||||
}
|
||||
|
||||
if errors[0].Message != "must be at least 5 characters" {
|
||||
t.Errorf("unexpected first error: %s", errors[0].Message)
|
||||
}
|
||||
if errors[1].Message != "must be at least 10 characters" {
|
||||
t.Errorf("unexpected second error: %s", errors[1].Message)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestStandardErrorPattern(t *testing.T) {
|
||||
// Simulates a typical validation function
|
||||
validateUser := func(email, password string) error {
|
||||
v := New()
|
||||
v.Check(&email, "email", Required(), Email())
|
||||
v.Check(&password, "password", Required(), MinLen(8))
|
||||
return v.Error()
|
||||
}
|
||||
|
||||
t.Run("valid data returns nil", func(t *testing.T) {
|
||||
err := validateUser("user@example.com", "password123")
|
||||
if err != nil {
|
||||
t.Errorf("expected nil, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid data returns ValidationErrors as error", func(t *testing.T) {
|
||||
err := validateUser("", "123")
|
||||
if err == nil {
|
||||
t.Fatal("expected validation errors")
|
||||
}
|
||||
|
||||
// Standard error handling
|
||||
t.Logf("validation failed: %v", err)
|
||||
|
||||
// Can get detailed errors if needed
|
||||
if validationErrs, ok := err.(ValidationErrors); ok {
|
||||
for _, e := range validationErrs {
|
||||
t.Logf(" - %s: %s (code: %s)", e.Field, e.Message, e.Code)
|
||||
}
|
||||
|
||||
// Can use helper methods
|
||||
emailErrs := validationErrs.ByField("email")
|
||||
if len(emailErrs) != 1 {
|
||||
t.Errorf("expected 1 email error, got %d", len(emailErrs))
|
||||
}
|
||||
|
||||
passwordErrs := validationErrs.ByField("password")
|
||||
if len(passwordErrs) != 1 {
|
||||
t.Errorf("expected 1 password error, got %d", len(passwordErrs))
|
||||
}
|
||||
} else {
|
||||
t.Error("expected ValidationErrors type")
|
||||
}
|
||||
})
|
||||
}
|
||||
109
pkg/validator/validator_collection.go
Normal file
109
pkg/validator/validator_collection.go
Normal file
@@ -0,0 +1,109 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package validator
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
)
|
||||
|
||||
// MinItems validates that a slice or array has at least the specified minimum number of items.
|
||||
func MinItems(min int) ValidatorFunc {
|
||||
return func(value any) *ValidationError {
|
||||
v := reflect.ValueOf(value)
|
||||
if v.Kind() == reflect.Ptr {
|
||||
if v.IsNil() {
|
||||
return nil
|
||||
}
|
||||
v = v.Elem()
|
||||
}
|
||||
|
||||
if v.Kind() != reflect.Slice && v.Kind() != reflect.Array {
|
||||
return newValidationError(ErrorCodeInvalidFormat, "value must be a slice or array")
|
||||
}
|
||||
|
||||
if v.Len() < min {
|
||||
return newValidationError(
|
||||
ErrorCodeOutOfRange,
|
||||
fmt.Sprintf("must contain at least %d items", min),
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// MaxItems validates that a slice or array does not exceed the specified maximum number of items.
|
||||
func MaxItems(max int) ValidatorFunc {
|
||||
return func(value any) *ValidationError {
|
||||
v := reflect.ValueOf(value)
|
||||
if v.Kind() == reflect.Ptr {
|
||||
if v.IsNil() {
|
||||
return nil
|
||||
}
|
||||
v = v.Elem()
|
||||
}
|
||||
|
||||
if v.Kind() != reflect.Slice && v.Kind() != reflect.Array {
|
||||
return newValidationError(ErrorCodeInvalidFormat, "value must be a slice or array")
|
||||
}
|
||||
|
||||
if v.Len() > max {
|
||||
return newValidationError(
|
||||
ErrorCodeOutOfRange,
|
||||
fmt.Sprintf("must contain at most %d items", max),
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// UniqueItems validates that all items in a slice or array are unique.
|
||||
func UniqueItems() ValidatorFunc {
|
||||
return func(value any) *ValidationError {
|
||||
v := reflect.ValueOf(value)
|
||||
if v.Kind() == reflect.Ptr {
|
||||
if v.IsNil() {
|
||||
return nil
|
||||
}
|
||||
v = v.Elem()
|
||||
}
|
||||
|
||||
if v.Kind() != reflect.Slice && v.Kind() != reflect.Array {
|
||||
return newValidationError(ErrorCodeInvalidFormat, "value must be a slice or array")
|
||||
}
|
||||
|
||||
if v.Len() == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
elemType := v.Type().Elem()
|
||||
if !elemType.Comparable() {
|
||||
return newValidationError(ErrorCodeInvalidFormat, "cannot validate uniqueness for non-comparable types")
|
||||
}
|
||||
|
||||
seen := make(map[any]bool)
|
||||
for i := 0; i < v.Len(); i++ {
|
||||
item := v.Index(i).Interface()
|
||||
if seen[item] {
|
||||
return newValidationError(ErrorCodeInvalidFormat, "items must be unique")
|
||||
}
|
||||
seen[item] = true
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
220
pkg/validator/validator_collection_test.go
Normal file
220
pkg/validator/validator_collection_test.go
Normal file
@@ -0,0 +1,220 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package validator
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMinItems(t *testing.T) {
|
||||
t.Run("valid slice", func(t *testing.T) {
|
||||
items := []string{"a", "b", "c"}
|
||||
err := MinItems(2)(&items)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("exact minimum", func(t *testing.T) {
|
||||
items := []int{1, 2}
|
||||
err := MinItems(2)(&items)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("too few items", func(t *testing.T) {
|
||||
items := []string{"a"}
|
||||
err := MinItems(2)(&items)
|
||||
if err == nil {
|
||||
t.Error("expected validation error")
|
||||
}
|
||||
if err.Code != ErrorCodeOutOfRange {
|
||||
t.Errorf("expected error code %s, got %s", ErrorCodeOutOfRange, err.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nil slice", func(t *testing.T) {
|
||||
var items *[]string
|
||||
err := MinItems(2)(items)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error for nil, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non-slice value", func(t *testing.T) {
|
||||
value := "not a slice"
|
||||
err := MinItems(2)(&value)
|
||||
if err == nil || err.Code != ErrorCodeInvalidFormat {
|
||||
t.Error("expected invalid format error")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestMaxItems(t *testing.T) {
|
||||
t.Run("valid slice", func(t *testing.T) {
|
||||
items := []string{"a", "b"}
|
||||
err := MaxItems(5)(&items)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("exact maximum", func(t *testing.T) {
|
||||
items := []int{1, 2, 3}
|
||||
err := MaxItems(3)(&items)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("too many items", func(t *testing.T) {
|
||||
items := []string{"a", "b", "c", "d"}
|
||||
err := MaxItems(2)(&items)
|
||||
if err == nil {
|
||||
t.Error("expected validation error")
|
||||
}
|
||||
if err.Code != ErrorCodeOutOfRange {
|
||||
t.Errorf("expected error code %s, got %s", ErrorCodeOutOfRange, err.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nil slice", func(t *testing.T) {
|
||||
var items *[]string
|
||||
err := MaxItems(2)(items)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error for nil, got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestUniqueItems(t *testing.T) {
|
||||
t.Run("unique items", func(t *testing.T) {
|
||||
items := []string{"a", "b", "c"}
|
||||
err := UniqueItems()(&items)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("duplicate items", func(t *testing.T) {
|
||||
items := []string{"a", "b", "a"}
|
||||
err := UniqueItems()(&items)
|
||||
if err == nil {
|
||||
t.Error("expected validation error")
|
||||
}
|
||||
if err.Code != ErrorCodeInvalidFormat {
|
||||
t.Errorf("expected error code %s, got %s", ErrorCodeInvalidFormat, err.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unique integers", func(t *testing.T) {
|
||||
items := []int{1, 2, 3}
|
||||
err := UniqueItems()(&items)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("duplicate integers", func(t *testing.T) {
|
||||
items := []int{1, 2, 1}
|
||||
err := UniqueItems()(&items)
|
||||
if err == nil {
|
||||
t.Error("expected validation error")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nil slice", func(t *testing.T) {
|
||||
var items *[]string
|
||||
err := UniqueItems()(items)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error for nil, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty slice", func(t *testing.T) {
|
||||
items := []string{}
|
||||
err := UniqueItems()(&items)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error for empty slice, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non-comparable type - slice of slices", func(t *testing.T) {
|
||||
items := [][]int{{1, 2}, {3, 4}}
|
||||
err := UniqueItems()(&items)
|
||||
if err == nil {
|
||||
t.Error("expected validation error for non-comparable type")
|
||||
}
|
||||
if err.Code != ErrorCodeInvalidFormat {
|
||||
t.Errorf("expected error code %s, got %s", ErrorCodeInvalidFormat, err.Code)
|
||||
}
|
||||
if err.Message != "cannot validate uniqueness for non-comparable types" {
|
||||
t.Errorf("unexpected error message: %s", err.Message)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non-comparable type - slice of maps", func(t *testing.T) {
|
||||
items := []map[string]int{{"a": 1}, {"b": 2}}
|
||||
err := UniqueItems()(&items)
|
||||
if err == nil {
|
||||
t.Error("expected validation error for non-comparable type")
|
||||
}
|
||||
if err.Code != ErrorCodeInvalidFormat {
|
||||
t.Errorf("expected error code %s, got %s", ErrorCodeInvalidFormat, err.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non-comparable type - struct with slice field", func(t *testing.T) {
|
||||
type NonComparable struct {
|
||||
Items []int
|
||||
}
|
||||
items := []NonComparable{{Items: []int{1, 2}}, {Items: []int{3, 4}}}
|
||||
err := UniqueItems()(&items)
|
||||
if err == nil {
|
||||
t.Error("expected validation error for non-comparable type")
|
||||
}
|
||||
if err.Code != ErrorCodeInvalidFormat {
|
||||
t.Errorf("expected error code %s, got %s", ErrorCodeInvalidFormat, err.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("comparable struct with unique values", func(t *testing.T) {
|
||||
type ComparableStruct struct {
|
||||
ID int
|
||||
Name string
|
||||
}
|
||||
items := []ComparableStruct{{ID: 1, Name: "a"}, {ID: 2, Name: "b"}}
|
||||
err := UniqueItems()(&items)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error for comparable structs, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("comparable struct with duplicate values", func(t *testing.T) {
|
||||
type ComparableStruct struct {
|
||||
ID int
|
||||
Name string
|
||||
}
|
||||
items := []ComparableStruct{{ID: 1, Name: "a"}, {ID: 2, Name: "b"}, {ID: 1, Name: "a"}}
|
||||
err := UniqueItems()(&items)
|
||||
if err == nil {
|
||||
t.Error("expected validation error for duplicate comparable structs")
|
||||
}
|
||||
if err.Code != ErrorCodeInvalidFormat {
|
||||
t.Errorf("expected error code %s, got %s", ErrorCodeInvalidFormat, err.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
71
pkg/validator/validator_common.go
Normal file
71
pkg/validator/validator_common.go
Normal file
@@ -0,0 +1,71 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package validator
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Required validates that a field has a value.
|
||||
// For strings, it also checks that the value is not empty or just whitespace.
|
||||
// For slices, it checks that the slice is not empty.
|
||||
func Required() ValidatorFunc {
|
||||
return func(value any) *ValidationError {
|
||||
actualValue, isNil := dereferenceValue(value)
|
||||
if isNil {
|
||||
return newValidationError(ErrorCodeRequired, "field is required")
|
||||
}
|
||||
|
||||
switch v := actualValue.(type) {
|
||||
case string:
|
||||
if strings.TrimSpace(v) == "" {
|
||||
return newValidationError(ErrorCodeRequired, "field is required")
|
||||
}
|
||||
default:
|
||||
rv := reflect.ValueOf(actualValue)
|
||||
if rv.Kind() == reflect.Slice && rv.Len() == 0 {
|
||||
return newValidationError(ErrorCodeRequired, "field is required")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// NotEmpty validates that a field is not empty.
|
||||
// Similar to Required, but can be used independently.
|
||||
func NotEmpty() ValidatorFunc {
|
||||
return func(value any) *ValidationError {
|
||||
actualValue, isNil := dereferenceValue(value)
|
||||
if isNil {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch v := actualValue.(type) {
|
||||
case string:
|
||||
if strings.TrimSpace(v) == "" {
|
||||
return newValidationError(ErrorCodeRequired, "field cannot be empty")
|
||||
}
|
||||
default:
|
||||
rv := reflect.ValueOf(actualValue)
|
||||
if rv.Kind() == reflect.Slice && rv.Len() == 0 {
|
||||
return newValidationError(ErrorCodeRequired, "field cannot be empty")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
270
pkg/validator/validator_common_test.go
Normal file
270
pkg/validator/validator_common_test.go
Normal file
@@ -0,0 +1,270 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package validator
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestOptionalByDefault(t *testing.T) {
|
||||
t.Run("nil value skips validation by default", func(t *testing.T) {
|
||||
v := New()
|
||||
v.Check(nil, "field", MinLen(5))
|
||||
|
||||
if v.HasErrors() {
|
||||
t.Errorf("expected no errors for nil (optional by default), got: %v", v.Errors())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nil pointer skips validation by default", func(t *testing.T) {
|
||||
v := New()
|
||||
var str *string
|
||||
v.Check(str, "field", MinLen(5))
|
||||
|
||||
if v.HasErrors() {
|
||||
t.Errorf("expected no errors for nil pointer (optional by default), got: %v", v.Errors())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("valid value passes validation", func(t *testing.T) {
|
||||
v := New()
|
||||
str := "hello world"
|
||||
v.Check(&str, "field", MinLen(5))
|
||||
|
||||
if v.HasErrors() {
|
||||
t.Errorf("expected no errors, got: %v", v.Errors())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid value fails validation", func(t *testing.T) {
|
||||
v := New()
|
||||
str := "hi"
|
||||
v.Check(&str, "field", MinLen(5))
|
||||
|
||||
if !v.HasErrors() {
|
||||
t.Error("expected validation error")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("multiple validators", func(t *testing.T) {
|
||||
v := New()
|
||||
str := "hello"
|
||||
v.Check(&str, "field", MinLen(3), MaxLen(10))
|
||||
|
||||
if v.HasErrors() {
|
||||
t.Errorf("expected no errors, got: %v", v.Errors())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty string is not nil and gets validated", func(t *testing.T) {
|
||||
v := New()
|
||||
str := ""
|
||||
v.Check(&str, "field", MinLen(5))
|
||||
|
||||
if !v.HasErrors() {
|
||||
t.Error("expected validation error for empty string")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Required() validates nil values", func(t *testing.T) {
|
||||
v := New()
|
||||
var str *string
|
||||
v.Check(str, "field", Required())
|
||||
|
||||
if !v.HasErrors() {
|
||||
t.Error("expected validation error for nil with Required()")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRequired(t *testing.T) {
|
||||
t.Run("valid string", func(t *testing.T) {
|
||||
str := "hello"
|
||||
err := Required()(&str)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty string", func(t *testing.T) {
|
||||
str := ""
|
||||
err := Required()(&str)
|
||||
if err == nil {
|
||||
t.Error("expected validation error")
|
||||
}
|
||||
if err.Code != ErrorCodeRequired {
|
||||
t.Errorf("expected error code %s, got %s", ErrorCodeRequired, err.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("whitespace string", func(t *testing.T) {
|
||||
str := " "
|
||||
err := Required()(&str)
|
||||
if err == nil {
|
||||
t.Error("expected validation error for whitespace")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nil string pointer", func(t *testing.T) {
|
||||
var str *string
|
||||
err := Required()(str)
|
||||
if err == nil {
|
||||
t.Error("expected validation error for nil pointer")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("valid string pointer", func(t *testing.T) {
|
||||
str := "hello"
|
||||
err := Required()(&str)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nil interface", func(t *testing.T) {
|
||||
err := Required()(nil)
|
||||
if err == nil {
|
||||
t.Error("expected validation error for nil")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("zero int", func(t *testing.T) {
|
||||
num := 0
|
||||
err := Required()(&num)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error for zero int, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("positive int", func(t *testing.T) {
|
||||
num := 42
|
||||
err := Required()(&num)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nil int pointer", func(t *testing.T) {
|
||||
var num *int
|
||||
err := Required()(num)
|
||||
if err == nil {
|
||||
t.Error("expected validation error for nil int pointer")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("valid int pointer", func(t *testing.T) {
|
||||
num := 42
|
||||
err := Required()(&num)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty slice", func(t *testing.T) {
|
||||
slice := []any{}
|
||||
err := Required()(slice)
|
||||
if err == nil {
|
||||
t.Error("expected validation error for empty slice")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non-empty slice", func(t *testing.T) {
|
||||
slice := []any{1, 2, 3}
|
||||
err := Required()(slice)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty string slice", func(t *testing.T) {
|
||||
slice := []string{}
|
||||
err := Required()(slice)
|
||||
if err == nil {
|
||||
t.Error("expected validation error for empty []string slice")
|
||||
}
|
||||
if err.Code != ErrorCodeRequired {
|
||||
t.Errorf("expected error code %s, got %s", ErrorCodeRequired, err.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non-empty string slice", func(t *testing.T) {
|
||||
slice := []string{"a", "b", "c"}
|
||||
err := Required()(slice)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error for non-empty []string, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty int slice", func(t *testing.T) {
|
||||
slice := []int{}
|
||||
err := Required()(slice)
|
||||
if err == nil {
|
||||
t.Error("expected validation error for empty []int slice")
|
||||
}
|
||||
if err.Code != ErrorCodeRequired {
|
||||
t.Errorf("expected error code %s, got %s", ErrorCodeRequired, err.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non-empty int slice", func(t *testing.T) {
|
||||
slice := []int{1, 2, 3}
|
||||
err := Required()(slice)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error for non-empty []int, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty custom type slice", func(t *testing.T) {
|
||||
type CustomType struct {
|
||||
ID int
|
||||
}
|
||||
slice := []CustomType{}
|
||||
err := Required()(slice)
|
||||
if err == nil {
|
||||
t.Error("expected validation error for empty custom type slice")
|
||||
}
|
||||
if err.Code != ErrorCodeRequired {
|
||||
t.Errorf("expected error code %s, got %s", ErrorCodeRequired, err.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non-empty custom type slice", func(t *testing.T) {
|
||||
type CustomType struct {
|
||||
ID int
|
||||
}
|
||||
slice := []CustomType{{ID: 1}, {ID: 2}}
|
||||
err := Required()(slice)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error for non-empty custom type slice, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty pointer slice", func(t *testing.T) {
|
||||
slice := []*string{}
|
||||
err := Required()(slice)
|
||||
if err == nil {
|
||||
t.Error("expected validation error for empty []*string slice")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non-empty pointer slice", func(t *testing.T) {
|
||||
str1, str2 := "a", "b"
|
||||
slice := []*string{&str1, &str2}
|
||||
err := Required()(slice)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error for non-empty []*string, got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
71
pkg/validator/validator_conditional.go
Normal file
71
pkg/validator/validator_conditional.go
Normal file
@@ -0,0 +1,71 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package validator
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"time"
|
||||
)
|
||||
|
||||
// EqualTo validates that a value equals another value using deep equality.
|
||||
// Special handling for time.Time to compare instants rather than internal structure.
|
||||
func EqualTo(other any) ValidatorFunc {
|
||||
return func(value any) *ValidationError {
|
||||
if !areEqual(value, other) {
|
||||
return newValidationError(ErrorCodeInvalidFormat, "values must match")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// NotEqualTo validates that a value does not equal another value using deep equality.
|
||||
// Special handling for time.Time to compare instants rather than internal structure.
|
||||
func NotEqualTo(other any) ValidatorFunc {
|
||||
return func(value any) *ValidationError {
|
||||
if areEqual(value, other) {
|
||||
return newValidationError(ErrorCodeInvalidFormat, "values must not match")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// areEqual compares two values for equality with special handling for time.Time.
|
||||
func areEqual(a, b any) bool {
|
||||
// Dereference both values
|
||||
aVal, aIsNil := dereferenceValue(a)
|
||||
bVal, bIsNil := dereferenceValue(b)
|
||||
|
||||
// If both are nil, they're equal
|
||||
if aIsNil && bIsNil {
|
||||
return true
|
||||
}
|
||||
|
||||
// If only one is nil, they're not equal
|
||||
if aIsNil || bIsNil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Special handling for time.Time
|
||||
aTime, aIsTime := aVal.(time.Time)
|
||||
bTime, bIsTime := bVal.(time.Time)
|
||||
|
||||
if aIsTime && bIsTime {
|
||||
// Use time.Time.Equal() which compares the instant, ignoring location and monotonic clock
|
||||
return aTime.Equal(bTime)
|
||||
}
|
||||
|
||||
// Fall back to reflect.DeepEqual for all other types
|
||||
return reflect.DeepEqual(aVal, bVal)
|
||||
}
|
||||
182
pkg/validator/validator_conditional_test.go
Normal file
182
pkg/validator/validator_conditional_test.go
Normal file
@@ -0,0 +1,182 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package validator
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestEqualTo(t *testing.T) {
|
||||
t.Run("equal strings", func(t *testing.T) {
|
||||
str1 := "password"
|
||||
str2 := "password"
|
||||
err := EqualTo(&str2)(&str1)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("different strings", func(t *testing.T) {
|
||||
str1 := "password"
|
||||
str2 := "different"
|
||||
err := EqualTo(&str2)(&str1)
|
||||
if err == nil {
|
||||
t.Error("expected validation error")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("equal integers", func(t *testing.T) {
|
||||
num1 := 42
|
||||
num2 := 42
|
||||
err := EqualTo(&num2)(&num1)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("different integers", func(t *testing.T) {
|
||||
num1 := 42
|
||||
num2 := 43
|
||||
err := EqualTo(&num2)(&num1)
|
||||
if err == nil {
|
||||
t.Error("expected validation error")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestNotEqualTo(t *testing.T) {
|
||||
t.Run("different strings", func(t *testing.T) {
|
||||
str1 := "password"
|
||||
str2 := "different"
|
||||
err := NotEqualTo(&str2)(&str1)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("equal strings", func(t *testing.T) {
|
||||
str1 := "password"
|
||||
str2 := "password"
|
||||
err := NotEqualTo(&str2)(&str1)
|
||||
if err == nil {
|
||||
t.Error("expected validation error")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestEqualTo_TimeComparison(t *testing.T) {
|
||||
t.Run("same instant same location", func(t *testing.T) {
|
||||
time1 := time.Date(2025, 11, 5, 12, 0, 0, 0, time.UTC)
|
||||
time2 := time.Date(2025, 11, 5, 12, 0, 0, 0, time.UTC)
|
||||
err := EqualTo(time2)(time1)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error for same instant, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("same instant different location", func(t *testing.T) {
|
||||
// Create the same instant in different time zones
|
||||
utcTime := time.Date(2025, 11, 5, 12, 0, 0, 0, time.UTC)
|
||||
est, _ := time.LoadLocation("America/New_York")
|
||||
estTime := time.Date(2025, 11, 5, 7, 0, 0, 0, est) // 7am EST = 12pm UTC
|
||||
|
||||
err := EqualTo(utcTime)(estTime)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error for same instant in different locations, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("different instants same location", func(t *testing.T) {
|
||||
time1 := time.Date(2025, 11, 5, 12, 0, 0, 0, time.UTC)
|
||||
time2 := time.Date(2025, 11, 5, 13, 0, 0, 0, time.UTC)
|
||||
err := EqualTo(time2)(time1)
|
||||
if err == nil {
|
||||
t.Error("expected validation error for different instants")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("pointer to time same instant", func(t *testing.T) {
|
||||
time1 := time.Date(2025, 11, 5, 12, 0, 0, 0, time.UTC)
|
||||
time2 := time.Date(2025, 11, 5, 12, 0, 0, 0, time.UTC)
|
||||
err := EqualTo(&time2)(&time1)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error for pointer to same instant, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nil time pointers", func(t *testing.T) {
|
||||
var time1 *time.Time
|
||||
var time2 *time.Time
|
||||
err := EqualTo(time2)(time1)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error for nil time pointers, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("one nil one non-nil time pointer", func(t *testing.T) {
|
||||
var time1 *time.Time
|
||||
time2 := time.Date(2025, 11, 5, 12, 0, 0, 0, time.UTC)
|
||||
err := EqualTo(&time2)(time1)
|
||||
if err == nil {
|
||||
t.Error("expected validation error for nil vs non-nil time")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("same instant with monotonic clock difference", func(t *testing.T) {
|
||||
// Simulate times with different monotonic clock data
|
||||
baseTime := time.Date(2025, 11, 5, 12, 0, 0, 0, time.UTC)
|
||||
time1 := baseTime
|
||||
time.Sleep(1 * time.Millisecond) // Advances monotonic clock
|
||||
time2 := baseTime
|
||||
|
||||
// Even though monotonic clocks differ, the instants are the same
|
||||
err := EqualTo(time2)(time1)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error despite monotonic clock difference, got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestNotEqualTo_TimeComparison(t *testing.T) {
|
||||
t.Run("different instants", func(t *testing.T) {
|
||||
time1 := time.Date(2025, 11, 5, 12, 0, 0, 0, time.UTC)
|
||||
time2 := time.Date(2025, 11, 5, 13, 0, 0, 0, time.UTC)
|
||||
err := NotEqualTo(time2)(time1)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error for different instants, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("same instant same location", func(t *testing.T) {
|
||||
time1 := time.Date(2025, 11, 5, 12, 0, 0, 0, time.UTC)
|
||||
time2 := time.Date(2025, 11, 5, 12, 0, 0, 0, time.UTC)
|
||||
err := NotEqualTo(time2)(time1)
|
||||
if err == nil {
|
||||
t.Error("expected validation error for same instant")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("same instant different location", func(t *testing.T) {
|
||||
utcTime := time.Date(2025, 11, 5, 12, 0, 0, 0, time.UTC)
|
||||
est, _ := time.LoadLocation("America/New_York")
|
||||
estTime := time.Date(2025, 11, 5, 7, 0, 0, 0, est)
|
||||
|
||||
err := NotEqualTo(utcTime)(estTime)
|
||||
if err == nil {
|
||||
t.Error("expected validation error for same instant in different locations")
|
||||
}
|
||||
})
|
||||
}
|
||||
26
pkg/validator/validator_custom.go
Normal file
26
pkg/validator/validator_custom.go
Normal file
@@ -0,0 +1,26 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package validator
|
||||
|
||||
// Custom creates a custom validator with a specified error code, message, and validation function.
|
||||
// The validation function should return true if the value is valid, false otherwise.
|
||||
func Custom(code ErrorCode, message string, fn func(value any) bool) ValidatorFunc {
|
||||
return func(value any) *ValidationError {
|
||||
if !fn(value) {
|
||||
return newValidationError(code, message)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
47
pkg/validator/validator_custom_test.go
Normal file
47
pkg/validator/validator_custom_test.go
Normal file
@@ -0,0 +1,47 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package validator
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCustom(t *testing.T) {
|
||||
validator := Custom(ErrorCodeCustom, "value must be positive", func(value any) bool {
|
||||
if num, ok := value.(int); ok {
|
||||
return num > 0
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
value any
|
||||
wantError bool
|
||||
}{
|
||||
{"valid positive", 5, false},
|
||||
{"invalid zero", 0, true},
|
||||
{"invalid negative", -5, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validator(tt.value)
|
||||
if (err != nil) != tt.wantError {
|
||||
t.Errorf("Custom() error = %v, wantError %v", err, tt.wantError)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
255
pkg/validator/validator_format.go
Normal file
255
pkg/validator/validator_format.go
Normal file
@@ -0,0 +1,255 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package validator
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"regexp"
|
||||
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
)
|
||||
|
||||
var (
|
||||
emailRegex = regexp.MustCompile(`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`)
|
||||
gidRegex = regexp.MustCompile(`^gid://[a-zA-Z0-9\-_]+/[a-zA-Z0-9\-_]+/[a-zA-Z0-9\-_]+$`)
|
||||
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}$`)
|
||||
domainRegex = regexp.MustCompile(`^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$`)
|
||||
)
|
||||
|
||||
// Email validates that a string is a valid email address.
|
||||
func Email() ValidatorFunc {
|
||||
return func(value any) *ValidationError {
|
||||
actualValue, isNil := dereferenceValue(value)
|
||||
if isNil {
|
||||
return nil
|
||||
}
|
||||
|
||||
str, ok := actualValue.(string)
|
||||
if !ok {
|
||||
return newValidationError(ErrorCodeInvalidEmail, "value must be a string")
|
||||
}
|
||||
|
||||
if str == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if !emailRegex.MatchString(str) {
|
||||
return newValidationError(ErrorCodeInvalidEmail, "invalid email address")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// URL validates that a string is a valid URL with http or https scheme.
|
||||
func URL() ValidatorFunc {
|
||||
return func(value any) *ValidationError {
|
||||
actualValue, isNil := dereferenceValue(value)
|
||||
if isNil {
|
||||
return nil
|
||||
}
|
||||
|
||||
str, ok := actualValue.(string)
|
||||
if !ok {
|
||||
return newValidationError(ErrorCodeInvalidURL, "value must be a string")
|
||||
}
|
||||
|
||||
if str == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
parsedURL, err := url.Parse(str)
|
||||
if err != nil {
|
||||
return newValidationError(ErrorCodeInvalidURL, "invalid URL format")
|
||||
}
|
||||
|
||||
if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
|
||||
return newValidationError(ErrorCodeInvalidURL, "URL must use http or https scheme")
|
||||
}
|
||||
|
||||
if parsedURL.Host == "" {
|
||||
return newValidationError(ErrorCodeInvalidURL, "URL must have a host")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// HTTPUrl validates that a string is a valid HTTP URL (not HTTPS).
|
||||
func HTTPUrl() ValidatorFunc {
|
||||
return func(value any) *ValidationError {
|
||||
actualValue, isNil := dereferenceValue(value)
|
||||
if isNil {
|
||||
return nil
|
||||
}
|
||||
|
||||
str, ok := actualValue.(string)
|
||||
if !ok {
|
||||
return newValidationError(ErrorCodeInvalidURL, "value must be a string")
|
||||
}
|
||||
|
||||
if str == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
parsedURL, err := url.Parse(str)
|
||||
if err != nil {
|
||||
return newValidationError(ErrorCodeInvalidURL, "invalid URL format")
|
||||
}
|
||||
|
||||
if parsedURL.Scheme != "http" {
|
||||
return newValidationError(ErrorCodeInvalidURL, "URL must use http scheme")
|
||||
}
|
||||
|
||||
if parsedURL.Host == "" {
|
||||
return newValidationError(ErrorCodeInvalidURL, "URL must have a host")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// HTTPSUrl validates that a string is a valid HTTPS URL (not HTTP).
|
||||
func HTTPSUrl() ValidatorFunc {
|
||||
return func(value any) *ValidationError {
|
||||
actualValue, isNil := dereferenceValue(value)
|
||||
if isNil {
|
||||
return nil
|
||||
}
|
||||
|
||||
str, ok := actualValue.(string)
|
||||
if !ok {
|
||||
return newValidationError(ErrorCodeInvalidURL, "value must be a string")
|
||||
}
|
||||
|
||||
if str == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
parsedURL, err := url.Parse(str)
|
||||
if err != nil {
|
||||
return newValidationError(ErrorCodeInvalidURL, "invalid URL format")
|
||||
}
|
||||
|
||||
if parsedURL.Scheme != "https" {
|
||||
return newValidationError(ErrorCodeInvalidURL, "URL must use https scheme")
|
||||
}
|
||||
|
||||
if parsedURL.Host == "" {
|
||||
return newValidationError(ErrorCodeInvalidURL, "URL must have a host")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// UUID validates that a string is a valid UUID.
|
||||
func UUID() ValidatorFunc {
|
||||
return func(value any) *ValidationError {
|
||||
actualValue, isNil := dereferenceValue(value)
|
||||
if isNil {
|
||||
return nil
|
||||
}
|
||||
|
||||
str, ok := actualValue.(string)
|
||||
if !ok {
|
||||
return newValidationError(ErrorCodeInvalidFormat, "value must be a string")
|
||||
}
|
||||
|
||||
if str == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if !uuidRegex.MatchString(str) {
|
||||
return newValidationError(ErrorCodeInvalidFormat, "invalid UUID format")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// GID validates that a string is a valid GID using gid.ParseGID.
|
||||
// Optionally validates the entity type if provided.
|
||||
//
|
||||
// Example usage:
|
||||
// - GID() validates any GID format
|
||||
// - GID(100) validates GID with entity type 100
|
||||
// - GID(100, 200) validates GID with entity type 100 or 200
|
||||
func GID(entityTypes ...uint16) ValidatorFunc {
|
||||
return func(value any) *ValidationError {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var gidValue gid.GID
|
||||
|
||||
switch v := value.(type) {
|
||||
case gid.GID:
|
||||
gidValue = v
|
||||
case *gid.GID:
|
||||
if v == nil {
|
||||
return nil
|
||||
}
|
||||
gidValue = *v
|
||||
default:
|
||||
return newValidationError(ErrorCodeInvalidGID, "value must be a GID")
|
||||
}
|
||||
|
||||
if len(entityTypes) > 0 {
|
||||
parsedEntityType := gidValue.EntityType()
|
||||
valid := false
|
||||
for _, expected := range entityTypes {
|
||||
if parsedEntityType == expected {
|
||||
valid = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !valid {
|
||||
return newValidationError(ErrorCodeInvalidGID, "GID has invalid entity type")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Domain validates that a string is a valid domain name.
|
||||
func Domain() ValidatorFunc {
|
||||
return func(value any) *ValidationError {
|
||||
actualValue, isNil := dereferenceValue(value)
|
||||
if isNil {
|
||||
return nil
|
||||
}
|
||||
|
||||
str, ok := actualValue.(string)
|
||||
if !ok {
|
||||
return newValidationError(ErrorCodeInvalidFormat, "value must be a string")
|
||||
}
|
||||
|
||||
if str == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if len(str) > 253 {
|
||||
return newValidationError(ErrorCodeInvalidFormat, "domain name too long (max 253 characters)")
|
||||
}
|
||||
|
||||
if !domainRegex.MatchString(str) {
|
||||
return newValidationError(ErrorCodeInvalidFormat, "invalid domain name format")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
432
pkg/validator/validator_format_test.go
Normal file
432
pkg/validator/validator_format_test.go
Normal file
@@ -0,0 +1,432 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package validator
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
)
|
||||
|
||||
func TestEmail(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value any
|
||||
wantError bool
|
||||
}{
|
||||
{"valid email", "test@example.com", false},
|
||||
{"valid email with plus", "test+tag@example.com", false},
|
||||
{"invalid email no @", "testexample.com", true},
|
||||
{"invalid email no domain", "test@", true},
|
||||
{"invalid email no TLD", "test@example", true},
|
||||
{"empty string", "", false}, // Empty is allowed, use Required() to enforce
|
||||
{"nil pointer", (*string)(nil), false}, // Skip validation
|
||||
{"non-string", 123, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := Email()(tt.value)
|
||||
if (err != nil) != tt.wantError {
|
||||
t.Errorf("Email() error = %v, wantError %v", err, tt.wantError)
|
||||
}
|
||||
if err != nil && err.Code != ErrorCodeInvalidEmail {
|
||||
t.Errorf("Expected error code %s, got %s", ErrorCodeInvalidEmail, err.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value any
|
||||
wantError bool
|
||||
}{
|
||||
{"valid http URL", "http://example.com", false},
|
||||
{"valid https URL", "https://example.com", false},
|
||||
{"valid URL with path", "https://example.com/path", false},
|
||||
{"invalid scheme", "ftp://example.com", true},
|
||||
{"no scheme", "example.com", true},
|
||||
{"no host", "https://", true},
|
||||
{"empty string", "", false}, // Empty is allowed
|
||||
{"nil pointer", (*string)(nil), false},
|
||||
{"non-string", 123, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := URL()(tt.value)
|
||||
if (err != nil) != tt.wantError {
|
||||
t.Errorf("URL() error = %v, wantError %v", err, tt.wantError)
|
||||
}
|
||||
if err != nil && err.Code != ErrorCodeInvalidURL {
|
||||
t.Errorf("Expected error code %s, got %s", ErrorCodeInvalidURL, err.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPUrl(t *testing.T) {
|
||||
t.Run("valid http URL", func(t *testing.T) {
|
||||
str := "http://example.com"
|
||||
err := HTTPUrl()(&str)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("valid http URL with path", func(t *testing.T) {
|
||||
str := "http://example.com/path/to/resource"
|
||||
err := HTTPUrl()(&str)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("valid http URL with query", func(t *testing.T) {
|
||||
str := "http://example.com?foo=bar"
|
||||
err := HTTPUrl()(&str)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid - https scheme", func(t *testing.T) {
|
||||
str := "https://example.com"
|
||||
err := HTTPUrl()(&str)
|
||||
if err == nil {
|
||||
t.Error("expected validation error for https")
|
||||
}
|
||||
if err.Message != "URL must use http scheme" {
|
||||
t.Errorf("unexpected error message: %s", err.Message)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid - no scheme", func(t *testing.T) {
|
||||
str := "example.com"
|
||||
err := HTTPUrl()(&str)
|
||||
if err == nil {
|
||||
t.Error("expected validation error for missing scheme")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid - no host", func(t *testing.T) {
|
||||
str := "http://"
|
||||
err := HTTPUrl()(&str)
|
||||
if err == nil {
|
||||
t.Error("expected validation error for missing host")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty string", func(t *testing.T) {
|
||||
str := ""
|
||||
err := HTTPUrl()(&str)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error for empty string, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nil pointer", func(t *testing.T) {
|
||||
var str *string
|
||||
err := HTTPUrl()(str)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error for nil, got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestHTTPSUrl(t *testing.T) {
|
||||
t.Run("valid https URL", func(t *testing.T) {
|
||||
str := "https://example.com"
|
||||
err := HTTPSUrl()(&str)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("valid https URL with path", func(t *testing.T) {
|
||||
str := "https://example.com/path/to/resource"
|
||||
err := HTTPSUrl()(&str)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("valid https URL with query", func(t *testing.T) {
|
||||
str := "https://api.example.com/v1/users?page=1"
|
||||
err := HTTPSUrl()(&str)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid - http scheme", func(t *testing.T) {
|
||||
str := "http://example.com"
|
||||
err := HTTPSUrl()(&str)
|
||||
if err == nil {
|
||||
t.Error("expected validation error for http")
|
||||
}
|
||||
if err.Message != "URL must use https scheme" {
|
||||
t.Errorf("unexpected error message: %s", err.Message)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid - ftp scheme", func(t *testing.T) {
|
||||
str := "ftp://example.com"
|
||||
err := HTTPSUrl()(&str)
|
||||
if err == nil {
|
||||
t.Error("expected validation error for ftp")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid - no scheme", func(t *testing.T) {
|
||||
str := "example.com"
|
||||
err := HTTPSUrl()(&str)
|
||||
if err == nil {
|
||||
t.Error("expected validation error for missing scheme")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid - no host", func(t *testing.T) {
|
||||
str := "https://"
|
||||
err := HTTPSUrl()(&str)
|
||||
if err == nil {
|
||||
t.Error("expected validation error for missing host")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty string", func(t *testing.T) {
|
||||
str := ""
|
||||
err := HTTPSUrl()(&str)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error for empty string, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nil pointer", func(t *testing.T) {
|
||||
var str *string
|
||||
err := HTTPSUrl()(str)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error for nil, got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestDomain(t *testing.T) {
|
||||
t.Run("valid domain", func(t *testing.T) {
|
||||
str := "example.com"
|
||||
err := Domain()(&str)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("valid subdomain", func(t *testing.T) {
|
||||
str := "api.example.com"
|
||||
err := Domain()(&str)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("valid nested subdomain", func(t *testing.T) {
|
||||
str := "api.v1.example.com"
|
||||
err := Domain()(&str)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("valid domain with hyphens", func(t *testing.T) {
|
||||
str := "my-api.example-site.com"
|
||||
err := Domain()(&str)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("single word domain", func(t *testing.T) {
|
||||
str := "localhost"
|
||||
err := Domain()(&str)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid - starts with hyphen", func(t *testing.T) {
|
||||
str := "-example.com"
|
||||
err := Domain()(&str)
|
||||
if err == nil {
|
||||
t.Error("expected validation error for domain starting with hyphen")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid - ends with hyphen", func(t *testing.T) {
|
||||
str := "example-.com"
|
||||
err := Domain()(&str)
|
||||
if err == nil {
|
||||
t.Error("expected validation error for domain ending with hyphen")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid - contains underscore", func(t *testing.T) {
|
||||
str := "example_site.com"
|
||||
err := Domain()(&str)
|
||||
if err == nil {
|
||||
t.Error("expected validation error for underscore")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid - contains spaces", func(t *testing.T) {
|
||||
str := "example site.com"
|
||||
err := Domain()(&str)
|
||||
if err == nil {
|
||||
t.Error("expected validation error for spaces")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid - empty label", func(t *testing.T) {
|
||||
str := "example..com"
|
||||
err := Domain()(&str)
|
||||
if err == nil {
|
||||
t.Error("expected validation error for empty label")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid - too long", func(t *testing.T) {
|
||||
str := strings.Repeat("a", 254)
|
||||
err := Domain()(&str)
|
||||
if err == nil {
|
||||
t.Error("expected validation error for domain too long")
|
||||
}
|
||||
if err.Message != "domain name too long (max 253 characters)" {
|
||||
t.Errorf("unexpected error message: %s", err.Message)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty string", func(t *testing.T) {
|
||||
str := ""
|
||||
err := Domain()(&str)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error for empty string, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nil pointer", func(t *testing.T) {
|
||||
var str *string
|
||||
err := Domain()(str)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error for nil, got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestGID(t *testing.T) {
|
||||
// Create a valid GID for testing
|
||||
tenantID := gid.TenantID([8]byte{1, 2, 3, 4, 5, 6, 7, 8})
|
||||
validGID := gid.New(tenantID, 100)
|
||||
|
||||
t.Run("valid GID type - no entity type validation", func(t *testing.T) {
|
||||
err := GID()(validGID)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("valid GID type - with matching entity type", func(t *testing.T) {
|
||||
err := GID(100)(validGID)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("valid GID type - with multiple entity types", func(t *testing.T) {
|
||||
err := GID(100, 200, 300)(validGID)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid - wrong entity type", func(t *testing.T) {
|
||||
err := GID(200)(validGID)
|
||||
if err == nil {
|
||||
t.Error("expected validation error for wrong entity type")
|
||||
}
|
||||
if err.Code != ErrorCodeInvalidGID {
|
||||
t.Errorf("expected error code %s, got %s", ErrorCodeInvalidGID, err.Code)
|
||||
}
|
||||
if err.Message != "GID has invalid entity type" {
|
||||
t.Errorf("unexpected error message: %s", err.Message)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid - wrong entity type with multiple options", func(t *testing.T) {
|
||||
err := GID(200, 300)(validGID)
|
||||
if err == nil {
|
||||
t.Error("expected validation error for wrong entity type")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("valid - entity type matches one of multiple options", func(t *testing.T) {
|
||||
err := GID(99, 100, 101)(validGID)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nil GID pointer", func(t *testing.T) {
|
||||
var gidPtr *gid.GID
|
||||
err := GID()(gidPtr)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error for nil GID pointer, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("valid GID pointer", func(t *testing.T) {
|
||||
err := GID()(&validGID)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("valid GID pointer with entity type validation", func(t *testing.T) {
|
||||
err := GID(100)(&validGID)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non-GID type", func(t *testing.T) {
|
||||
err := GID()(123)
|
||||
if err == nil {
|
||||
t.Error("expected validation error for non-GID type")
|
||||
}
|
||||
if err.Message != "value must be a GID" {
|
||||
t.Errorf("unexpected error message: %s", err.Message)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("string type not supported", func(t *testing.T) {
|
||||
err := GID()("some-string")
|
||||
if err == nil {
|
||||
t.Error("expected validation error for string type")
|
||||
}
|
||||
if err.Message != "value must be a GID" {
|
||||
t.Errorf("unexpected error message: %s", err.Message)
|
||||
}
|
||||
})
|
||||
}
|
||||
203
pkg/validator/validator_numeric.go
Normal file
203
pkg/validator/validator_numeric.go
Normal file
@@ -0,0 +1,203 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package validator
|
||||
|
||||
import "fmt"
|
||||
|
||||
// Min validates that a number is at least the specified minimum value.
|
||||
func Min(min int) ValidatorFunc {
|
||||
return func(value any) *ValidationError {
|
||||
actualValue, isNil := dereferenceValue(value)
|
||||
if isNil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var num int
|
||||
switch v := actualValue.(type) {
|
||||
case int:
|
||||
num = v
|
||||
case int32:
|
||||
num = int(v)
|
||||
case int64:
|
||||
num = int(v)
|
||||
default:
|
||||
return newValidationError(ErrorCodeInvalidFormat, "value must be a number")
|
||||
}
|
||||
|
||||
if num < min {
|
||||
return newValidationError(
|
||||
ErrorCodeOutOfRange,
|
||||
fmt.Sprintf("must be at least %d", min),
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Max validates that a number does not exceed the specified maximum value.
|
||||
func Max(max int) ValidatorFunc {
|
||||
return func(value any) *ValidationError {
|
||||
actualValue, isNil := dereferenceValue(value)
|
||||
if isNil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var num int
|
||||
switch v := actualValue.(type) {
|
||||
case int:
|
||||
num = v
|
||||
case int32:
|
||||
num = int(v)
|
||||
case int64:
|
||||
num = int(v)
|
||||
default:
|
||||
return newValidationError(ErrorCodeInvalidFormat, "value must be a number")
|
||||
}
|
||||
|
||||
if num > max {
|
||||
return newValidationError(
|
||||
ErrorCodeOutOfRange,
|
||||
fmt.Sprintf("must be at most %d", max),
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Range validates that a number is within the specified range (inclusive).
|
||||
func Range(min, max int) ValidatorFunc {
|
||||
return func(value any) *ValidationError {
|
||||
actualValue, isNil := dereferenceValue(value)
|
||||
if isNil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var num int
|
||||
switch v := actualValue.(type) {
|
||||
case int:
|
||||
num = v
|
||||
case int32:
|
||||
num = int(v)
|
||||
case int64:
|
||||
num = int(v)
|
||||
default:
|
||||
return newValidationError(ErrorCodeInvalidFormat, "value must be a number")
|
||||
}
|
||||
|
||||
if num < min || num > max {
|
||||
return newValidationError(
|
||||
ErrorCodeOutOfRange,
|
||||
fmt.Sprintf("must be between %d and %d", min, max),
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// MinFloat validates that a floating-point number is at least the specified minimum value.
|
||||
func MinFloat(min float64) ValidatorFunc {
|
||||
return func(value any) *ValidationError {
|
||||
actualValue, isNil := dereferenceValue(value)
|
||||
if isNil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var num float64
|
||||
switch v := actualValue.(type) {
|
||||
case float32:
|
||||
num = float64(v)
|
||||
case float64:
|
||||
num = v
|
||||
case int:
|
||||
num = float64(v)
|
||||
default:
|
||||
return newValidationError(ErrorCodeInvalidFormat, "value must be a number")
|
||||
}
|
||||
|
||||
if num < min {
|
||||
return newValidationError(
|
||||
ErrorCodeOutOfRange,
|
||||
fmt.Sprintf("must be at least %g", min),
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// MaxFloat validates that a floating-point number does not exceed the specified maximum value.
|
||||
func MaxFloat(max float64) ValidatorFunc {
|
||||
return func(value any) *ValidationError {
|
||||
actualValue, isNil := dereferenceValue(value)
|
||||
if isNil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var num float64
|
||||
switch v := actualValue.(type) {
|
||||
case float32:
|
||||
num = float64(v)
|
||||
case float64:
|
||||
num = v
|
||||
case int:
|
||||
num = float64(v)
|
||||
default:
|
||||
return newValidationError(ErrorCodeInvalidFormat, "value must be a number")
|
||||
}
|
||||
|
||||
if num > max {
|
||||
return newValidationError(
|
||||
ErrorCodeOutOfRange,
|
||||
fmt.Sprintf("must be at most %g", max),
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// RangeFloat validates that a floating-point number is within the specified range (inclusive).
|
||||
func RangeFloat(min, max float64) ValidatorFunc {
|
||||
return func(value any) *ValidationError {
|
||||
actualValue, isNil := dereferenceValue(value)
|
||||
if isNil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var num float64
|
||||
switch v := actualValue.(type) {
|
||||
case float32:
|
||||
num = float64(v)
|
||||
case float64:
|
||||
num = v
|
||||
case int:
|
||||
num = float64(v)
|
||||
default:
|
||||
return newValidationError(ErrorCodeInvalidFormat, "value must be a number")
|
||||
}
|
||||
|
||||
if num < min || num > max {
|
||||
return newValidationError(
|
||||
ErrorCodeOutOfRange,
|
||||
fmt.Sprintf("must be between %g and %g", min, max),
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
95
pkg/validator/validator_numeric_test.go
Normal file
95
pkg/validator/validator_numeric_test.go
Normal file
@@ -0,0 +1,95 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package validator
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"go.gearno.de/x/ref"
|
||||
)
|
||||
|
||||
func TestMin(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value any
|
||||
min int
|
||||
wantError bool
|
||||
}{
|
||||
{"valid int", 10, 5, false},
|
||||
{"exact min", 5, 5, false},
|
||||
{"below min", 3, 5, true},
|
||||
{"valid int pointer", ref.Ref(10), 5, false},
|
||||
{"nil pointer", (*int)(nil), 5, false}, // Skip validation
|
||||
{"non-numeric", "test", 5, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := Min(tt.min)(tt.value)
|
||||
if (err != nil) != tt.wantError {
|
||||
t.Errorf("Min() error = %v, wantError %v", err, tt.wantError)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMax(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value any
|
||||
max int
|
||||
wantError bool
|
||||
}{
|
||||
{"valid int", 5, 10, false},
|
||||
{"exact max", 10, 10, false},
|
||||
{"above max", 15, 10, true},
|
||||
{"valid int pointer", ref.Ref(5), 10, false},
|
||||
{"nil pointer", (*int)(nil), 10, false}, // Skip validation
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := Max(tt.max)(tt.value)
|
||||
if (err != nil) != tt.wantError {
|
||||
t.Errorf("Max() error = %v, wantError %v", err, tt.wantError)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRange(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value any
|
||||
min int
|
||||
max int
|
||||
wantError bool
|
||||
}{
|
||||
{"in range", 5, 1, 10, false},
|
||||
{"at min", 1, 1, 10, false},
|
||||
{"at max", 10, 1, 10, false},
|
||||
{"below range", 0, 1, 10, true},
|
||||
{"above range", 11, 1, 10, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := Range(tt.min, tt.max)(tt.value)
|
||||
if (err != nil) != tt.wantError {
|
||||
t.Errorf("Range() error = %v, wantError %v", err, tt.wantError)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
173
pkg/validator/validator_security.go
Normal file
173
pkg/validator/validator_security.go
Normal file
@@ -0,0 +1,173 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package validator
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
htmlTagRegex = regexp.MustCompile(`<[^>]*>`)
|
||||
)
|
||||
|
||||
// NoHTML validates that a string does not contain HTML tags or angle brackets.
|
||||
// It rejects:
|
||||
// - HTML tags (e.g., <script>, <b>, <div>, etc.)
|
||||
// - Angle brackets (< and >) even when not part of complete tags
|
||||
//
|
||||
// This helps prevent XSS attacks and ensures user input doesn't contain HTML markup.
|
||||
// Combine with PrintableText() for comprehensive text field validation.
|
||||
func NoHTML() ValidatorFunc {
|
||||
return func(value any) *ValidationError {
|
||||
actualValue, isNil := dereferenceValue(value)
|
||||
if isNil {
|
||||
return nil
|
||||
}
|
||||
|
||||
str, ok := actualValue.(string)
|
||||
if !ok {
|
||||
return newValidationError(ErrorCodeInvalidFormat, "value must be a string")
|
||||
}
|
||||
|
||||
if str == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check for HTML tags first (more specific error message)
|
||||
if htmlTagRegex.MatchString(str) {
|
||||
return newValidationError(ErrorCodeInvalidFormat, "must not contain HTML tags")
|
||||
}
|
||||
|
||||
// Check for angle brackets (even without complete tags)
|
||||
if strings.ContainsAny(str, "<>") {
|
||||
return newValidationError(ErrorCodeInvalidFormat, "must not contain angle brackets")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// PrintableText validates that a string contains only printable UTF-8 characters.
|
||||
// It rejects:
|
||||
// - Control characters (including null bytes, tabs, line breaks except space)
|
||||
// - Unicode direction override characters (RLO, LRO, PDF, etc.)
|
||||
// - Zero-width characters (ZWSP, ZWNJ, ZWJ, etc.)
|
||||
// - Other invisible or formatting characters
|
||||
// - Private use area characters
|
||||
// - Replacement characters
|
||||
//
|
||||
// This validator does NOT check for HTML tags - use NoHTML() for that.
|
||||
// This is ideal for validating titles, full names, display names, and similar text fields
|
||||
// where only printable characters should be allowed.
|
||||
func PrintableText() ValidatorFunc {
|
||||
return func(value any) *ValidationError {
|
||||
actualValue, isNil := dereferenceValue(value)
|
||||
if isNil {
|
||||
return nil
|
||||
}
|
||||
|
||||
str, ok := actualValue.(string)
|
||||
if !ok {
|
||||
return newValidationError(ErrorCodeInvalidFormat, "value must be a string")
|
||||
}
|
||||
|
||||
if str == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check each rune for invisible or problematic characters
|
||||
for i, r := range str {
|
||||
// Allow normal space
|
||||
if r == ' ' {
|
||||
continue
|
||||
}
|
||||
|
||||
// Reject control characters (0x00-0x1F and 0x7F-0x9F)
|
||||
if r < 0x20 || (r >= 0x7F && r < 0xA0) {
|
||||
return newValidationError(ErrorCodeInvalidFormat, fmt.Sprintf("contains invalid control character at position %d", i))
|
||||
}
|
||||
|
||||
// Reject Unicode direction override and formatting characters
|
||||
// U+200E LEFT-TO-RIGHT MARK (LRM)
|
||||
// U+200F RIGHT-TO-LEFT MARK (RLM)
|
||||
// U+202A LEFT-TO-RIGHT EMBEDDING (LRE)
|
||||
// U+202B RIGHT-TO-LEFT EMBEDDING (RLE)
|
||||
// U+202C POP DIRECTIONAL FORMATTING (PDF)
|
||||
// U+202D LEFT-TO-RIGHT OVERRIDE (LRO)
|
||||
// U+202E RIGHT-TO-LEFT OVERRIDE (RLO)
|
||||
// U+2066 LEFT-TO-RIGHT ISOLATE (LRI)
|
||||
// U+2067 RIGHT-TO-LEFT ISOLATE (RLI)
|
||||
// U+2068 FIRST STRONG ISOLATE (FSI)
|
||||
// U+2069 POP DIRECTIONAL ISOLATE (PDI)
|
||||
if r >= 0x200E && r <= 0x200F || r >= 0x202A && r <= 0x202E || r >= 0x2066 && r <= 0x2069 {
|
||||
return newValidationError(ErrorCodeInvalidFormat, fmt.Sprintf("contains bidirectional override character at position %d", i))
|
||||
}
|
||||
|
||||
// Reject zero-width characters
|
||||
// U+200B ZERO WIDTH SPACE (ZWSP)
|
||||
// U+200C ZERO WIDTH NON-JOINER (ZWNJ)
|
||||
// U+200D ZERO WIDTH JOINER (ZWJ)
|
||||
// U+FEFF ZERO WIDTH NO-BREAK SPACE (BOM)
|
||||
if r == 0x200B || r == 0x200C || r == 0x200D || r == 0xFEFF {
|
||||
return newValidationError(ErrorCodeInvalidFormat, fmt.Sprintf("contains zero-width character at position %d", i))
|
||||
}
|
||||
|
||||
// Reject other format characters (Cf category)
|
||||
// U+00AD SOFT HYPHEN
|
||||
// U+2060 WORD JOINER
|
||||
// U+180E MONGOLIAN VOWEL SEPARATOR (deprecated but still problematic)
|
||||
if r == 0x00AD || r == 0x2060 || r == 0x180E {
|
||||
return newValidationError(ErrorCodeInvalidFormat, fmt.Sprintf("contains invisible formatting character at position %d", i))
|
||||
}
|
||||
|
||||
// Reject private use area characters (often used for exploits)
|
||||
// U+E000-U+F8FF Private Use Area
|
||||
// U+F0000-U+FFFFD Supplementary Private Use Area-A
|
||||
// U+100000-U+10FFFD Supplementary Private Use Area-B
|
||||
if (r >= 0xE000 && r <= 0xF8FF) || (r >= 0xF0000 && r <= 0xFFFFD) || (r >= 0x100000 && r <= 0x10FFFD) {
|
||||
return newValidationError(ErrorCodeInvalidFormat, fmt.Sprintf("contains private use character at position %d", i))
|
||||
}
|
||||
|
||||
// Reject replacement character (often indicates encoding issues)
|
||||
if r == 0xFFFD {
|
||||
return newValidationError(ErrorCodeInvalidFormat, fmt.Sprintf("contains replacement character at position %d", i))
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// SafeText validates that a string is non-empty, bounded, and contains only safe content.
|
||||
// It combines NotEmpty, MaxLen, NoHTML, and PrintableText validators.
|
||||
func SafeText(maxLen int) ValidatorFunc {
|
||||
validators := []ValidatorFunc{
|
||||
NotEmpty(),
|
||||
MaxLen(maxLen),
|
||||
NoHTML(),
|
||||
PrintableText(),
|
||||
}
|
||||
|
||||
return func(value any) *ValidationError {
|
||||
for _, validator := range validators {
|
||||
if err := validator(value); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
755
pkg/validator/validator_security_test.go
Normal file
755
pkg/validator/validator_security_test.go
Normal file
@@ -0,0 +1,755 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package validator
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNoHTML(t *testing.T) {
|
||||
t.Run("valid text without HTML", func(t *testing.T) {
|
||||
str := "This is a normal text"
|
||||
err := NoHTML()(&str)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("valid text with special characters", func(t *testing.T) {
|
||||
str := "Price: $10.99 - 20% off!"
|
||||
err := NoHTML()(&str)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("valid UTF-8 text", func(t *testing.T) {
|
||||
str := "José García 张伟"
|
||||
err := NoHTML()(&str)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("valid text with emojis", func(t *testing.T) {
|
||||
str := "Hello World 🌍"
|
||||
err := NoHTML()(&str)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid - script tag XSS", func(t *testing.T) {
|
||||
str := "<script>alert('xss')</script>"
|
||||
err := NoHTML()(&str)
|
||||
if err == nil {
|
||||
t.Error("expected validation error for script tag")
|
||||
}
|
||||
if !strings.Contains(err.Message, "HTML tags") {
|
||||
t.Errorf("unexpected error message: %s", err.Message)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid - simple bold tag", func(t *testing.T) {
|
||||
str := "Hello <b>World</b>"
|
||||
err := NoHTML()(&str)
|
||||
if err == nil {
|
||||
t.Error("expected validation error for bold tag")
|
||||
}
|
||||
if !strings.Contains(err.Message, "HTML tags") {
|
||||
t.Errorf("unexpected error message: %s", err.Message)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid - div tag", func(t *testing.T) {
|
||||
str := "<div>Content</div>"
|
||||
err := NoHTML()(&str)
|
||||
if err == nil {
|
||||
t.Error("expected validation error for div tag")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid - self-closing tag", func(t *testing.T) {
|
||||
str := "Line break<br/>here"
|
||||
err := NoHTML()(&str)
|
||||
if err == nil {
|
||||
t.Error("expected validation error for self-closing tag")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid - img tag", func(t *testing.T) {
|
||||
str := `<img src="x" onerror="alert(1)">`
|
||||
err := NoHTML()(&str)
|
||||
if err == nil {
|
||||
t.Error("expected validation error for img tag")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid - anchor tag", func(t *testing.T) {
|
||||
str := `<a href="javascript:alert(1)">Click</a>`
|
||||
err := NoHTML()(&str)
|
||||
if err == nil {
|
||||
t.Error("expected validation error for anchor tag")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid - less than symbol", func(t *testing.T) {
|
||||
str := "5 < 10"
|
||||
err := NoHTML()(&str)
|
||||
if err == nil {
|
||||
t.Error("expected validation error for angle bracket")
|
||||
}
|
||||
if !strings.Contains(err.Message, "angle brackets") {
|
||||
t.Errorf("unexpected error message: %s", err.Message)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid - greater than symbol", func(t *testing.T) {
|
||||
str := "10 > 5"
|
||||
err := NoHTML()(&str)
|
||||
if err == nil {
|
||||
t.Error("expected validation error for angle bracket")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid - both angle brackets", func(t *testing.T) {
|
||||
str := "5 < x > 10"
|
||||
err := NoHTML()(&str)
|
||||
if err == nil {
|
||||
t.Error("expected validation error for angle brackets")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid - malformed tag", func(t *testing.T) {
|
||||
str := "text <incomplete"
|
||||
err := NoHTML()(&str)
|
||||
if err == nil {
|
||||
t.Error("expected validation error for incomplete tag")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid - encoded attempt", func(t *testing.T) {
|
||||
str := "<ScRiPt>alert(1)</ScRiPt>"
|
||||
err := NoHTML()(&str)
|
||||
if err == nil {
|
||||
t.Error("expected validation error for mixed case script tag")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty string", func(t *testing.T) {
|
||||
str := ""
|
||||
err := NoHTML()(&str)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error for empty string, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nil pointer", func(t *testing.T) {
|
||||
var str *string
|
||||
err := NoHTML()(str)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error for nil, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("not a string", func(t *testing.T) {
|
||||
num := 123
|
||||
err := NoHTML()(&num)
|
||||
if err == nil {
|
||||
t.Error("expected validation error for non-string")
|
||||
}
|
||||
if !strings.Contains(err.Message, "must be a string") {
|
||||
t.Errorf("unexpected error message: %s", err.Message)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("combined with other validators", func(t *testing.T) {
|
||||
v := New()
|
||||
title := "Product Title 2024"
|
||||
v.Check(&title, "title", Required(), NoHTML(), MinLen(3), MaxLen(100))
|
||||
|
||||
if v.HasErrors() {
|
||||
t.Errorf("expected no errors, got: %v", v.Errors())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("combined with PrintableText", func(t *testing.T) {
|
||||
v := New()
|
||||
title := "José García-O'Brien"
|
||||
v.Check(&title, "title", Required(), NoHTML(), PrintableText(), MinLen(3), MaxLen(100))
|
||||
|
||||
if v.HasErrors() {
|
||||
t.Errorf("expected no errors, got: %v", v.Errors())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("combined validators catch XSS", func(t *testing.T) {
|
||||
v := New()
|
||||
malicious := "<script>alert('xss')</script>"
|
||||
v.Check(&malicious, "content", Required(), NoHTML(), PrintableText())
|
||||
|
||||
if !v.HasErrors() {
|
||||
t.Error("expected validation errors")
|
||||
}
|
||||
|
||||
// Should have error from NoHTML
|
||||
errors := v.Errors()
|
||||
found := false
|
||||
for _, err := range errors {
|
||||
if strings.Contains(err.Message, "HTML tags") || strings.Contains(err.Message, "angle brackets") {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("expected error about HTML tags or angle brackets")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("combined validators catch invisible chars and HTML", func(t *testing.T) {
|
||||
v := New()
|
||||
malicious := "<b>test\x00text</b>"
|
||||
v.Check(&malicious, "content", NoHTML(), PrintableText())
|
||||
|
||||
if !v.HasErrors() {
|
||||
t.Error("expected validation errors")
|
||||
}
|
||||
|
||||
// Should have at least one error (NoHTML will catch it first)
|
||||
if len(v.Errors()) < 1 {
|
||||
t.Error("expected at least one validation error")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPrintableText(t *testing.T) {
|
||||
t.Run("valid UTF-8 text with accents", func(t *testing.T) {
|
||||
str := "José García"
|
||||
err := PrintableText()(&str)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error for valid UTF-8 name, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("valid text with emojis", func(t *testing.T) {
|
||||
str := "Hello World 🌍"
|
||||
err := PrintableText()(&str)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error for emojis, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("valid Chinese characters", func(t *testing.T) {
|
||||
str := "张伟"
|
||||
err := PrintableText()(&str)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error for Chinese characters, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("valid Arabic text", func(t *testing.T) {
|
||||
str := "محمد"
|
||||
err := PrintableText()(&str)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error for Arabic text, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("valid Cyrillic text", func(t *testing.T) {
|
||||
str := "Александр"
|
||||
err := PrintableText()(&str)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error for Cyrillic text, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("valid text with apostrophe and hyphen", func(t *testing.T) {
|
||||
str := "O'Brien-Smith"
|
||||
err := PrintableText()(&str)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error for apostrophe and hyphen, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("valid text with numbers", func(t *testing.T) {
|
||||
str := "Product 2024"
|
||||
err := PrintableText()(&str)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error for text with numbers, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("valid text with punctuation", func(t *testing.T) {
|
||||
str := "Hello, World! How are you?"
|
||||
err := PrintableText()(&str)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error for punctuation, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("valid text with angle brackets", func(t *testing.T) {
|
||||
str := "5 < 10 > 3"
|
||||
err := PrintableText()(&str)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error for angle brackets (HTML checking is separate), got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid - RLO character", func(t *testing.T) {
|
||||
str := "test\u202Eexe.txt"
|
||||
err := PrintableText()(&str)
|
||||
if err == nil {
|
||||
t.Error("expected validation error for RLO character")
|
||||
}
|
||||
if !strings.Contains(err.Message, "bidirectional override") {
|
||||
t.Errorf("unexpected error message: %s", err.Message)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid - LRO character", func(t *testing.T) {
|
||||
str := "test\u202Dtext"
|
||||
err := PrintableText()(&str)
|
||||
if err == nil {
|
||||
t.Error("expected validation error for LRO character")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid - zero-width space", func(t *testing.T) {
|
||||
str := "test\u200Btext"
|
||||
err := PrintableText()(&str)
|
||||
if err == nil {
|
||||
t.Error("expected validation error for zero-width space")
|
||||
}
|
||||
if !strings.Contains(err.Message, "zero-width") {
|
||||
t.Errorf("unexpected error message: %s", err.Message)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid - zero-width non-joiner", func(t *testing.T) {
|
||||
str := "test\u200Ctext"
|
||||
err := PrintableText()(&str)
|
||||
if err == nil {
|
||||
t.Error("expected validation error for zero-width non-joiner")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid - zero-width joiner", func(t *testing.T) {
|
||||
str := "test\u200Dtext"
|
||||
err := PrintableText()(&str)
|
||||
if err == nil {
|
||||
t.Error("expected validation error for zero-width joiner")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid - BOM character", func(t *testing.T) {
|
||||
str := "\uFEFFtest"
|
||||
err := PrintableText()(&str)
|
||||
if err == nil {
|
||||
t.Error("expected validation error for BOM character")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid - null byte", func(t *testing.T) {
|
||||
str := "test\x00text"
|
||||
err := PrintableText()(&str)
|
||||
if err == nil {
|
||||
t.Error("expected validation error for null byte")
|
||||
}
|
||||
if !strings.Contains(err.Message, "control character") {
|
||||
t.Errorf("unexpected error message: %s", err.Message)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid - tab character", func(t *testing.T) {
|
||||
str := "test\ttext"
|
||||
err := PrintableText()(&str)
|
||||
if err == nil {
|
||||
t.Error("expected validation error for tab character")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid - newline character", func(t *testing.T) {
|
||||
str := "test\ntext"
|
||||
err := PrintableText()(&str)
|
||||
if err == nil {
|
||||
t.Error("expected validation error for newline character")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid - carriage return", func(t *testing.T) {
|
||||
str := "test\rtext"
|
||||
err := PrintableText()(&str)
|
||||
if err == nil {
|
||||
t.Error("expected validation error for carriage return")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid - soft hyphen", func(t *testing.T) {
|
||||
str := "test\u00ADtext"
|
||||
err := PrintableText()(&str)
|
||||
if err == nil {
|
||||
t.Error("expected validation error for soft hyphen")
|
||||
}
|
||||
if !strings.Contains(err.Message, "invisible formatting") {
|
||||
t.Errorf("unexpected error message: %s", err.Message)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid - word joiner", func(t *testing.T) {
|
||||
str := "test\u2060text"
|
||||
err := PrintableText()(&str)
|
||||
if err == nil {
|
||||
t.Error("expected validation error for word joiner")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid - private use area character", func(t *testing.T) {
|
||||
str := "test\uE000text"
|
||||
err := PrintableText()(&str)
|
||||
if err == nil {
|
||||
t.Error("expected validation error for private use area")
|
||||
}
|
||||
if !strings.Contains(err.Message, "private use") {
|
||||
t.Errorf("unexpected error message: %s", err.Message)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid - replacement character", func(t *testing.T) {
|
||||
str := "test\uFFFDtext"
|
||||
err := PrintableText()(&str)
|
||||
if err == nil {
|
||||
t.Error("expected validation error for replacement character")
|
||||
}
|
||||
if !strings.Contains(err.Message, "replacement character") {
|
||||
t.Errorf("unexpected error message: %s", err.Message)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid - DEL control character", func(t *testing.T) {
|
||||
str := "test\x7Ftext"
|
||||
err := PrintableText()(&str)
|
||||
if err == nil {
|
||||
t.Error("expected validation error for DEL control character")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid - C1 control character", func(t *testing.T) {
|
||||
str := "test\u0080text"
|
||||
err := PrintableText()(&str)
|
||||
if err == nil {
|
||||
t.Error("expected validation error for C1 control character")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid - LTR mark", func(t *testing.T) {
|
||||
str := "test\u200Etext"
|
||||
err := PrintableText()(&str)
|
||||
if err == nil {
|
||||
t.Error("expected validation error for LTR mark")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid - RTL mark", func(t *testing.T) {
|
||||
str := "test\u200Ftext"
|
||||
err := PrintableText()(&str)
|
||||
if err == nil {
|
||||
t.Error("expected validation error for RTL mark")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("valid with pointer", func(t *testing.T) {
|
||||
str := "Valid Name"
|
||||
err := PrintableText()(&str)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty string", func(t *testing.T) {
|
||||
str := ""
|
||||
err := PrintableText()(&str)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error for empty string, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nil pointer", func(t *testing.T) {
|
||||
var str *string
|
||||
err := PrintableText()(str)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error for nil, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("not a string", func(t *testing.T) {
|
||||
num := 123
|
||||
err := PrintableText()(&num)
|
||||
if err == nil {
|
||||
t.Error("expected validation error for non-string")
|
||||
}
|
||||
if !strings.Contains(err.Message, "must be a string") {
|
||||
t.Errorf("unexpected error message: %s", err.Message)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("combined with other validators", func(t *testing.T) {
|
||||
v := New()
|
||||
title := "Product Title 2024"
|
||||
v.Check(&title, "title", Required(), PrintableText(), MinLen(3), MaxLen(100))
|
||||
|
||||
if v.HasErrors() {
|
||||
t.Errorf("expected no errors, got: %v", v.Errors())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("position reported correctly", func(t *testing.T) {
|
||||
str := "abc\x00def"
|
||||
err := PrintableText()(&str)
|
||||
if err == nil {
|
||||
t.Error("expected validation error")
|
||||
}
|
||||
if !strings.Contains(err.Message, "position 3") {
|
||||
t.Errorf("expected position 3 in error message, got: %s", err.Message)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("UTF-8 position counting", func(t *testing.T) {
|
||||
// Test that position is counted correctly with UTF-8 characters
|
||||
// The range loop in Go iterates by runes, so position will be rune index
|
||||
str := "abc\x00"
|
||||
err := PrintableText()(&str)
|
||||
if err == nil {
|
||||
t.Error("expected validation error")
|
||||
}
|
||||
// The null byte is at rune position 3 (after 'a', 'b', 'c')
|
||||
if !strings.Contains(err.Message, "position 3") {
|
||||
t.Errorf("expected position 3 in error message, got: %s", err.Message)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSafeText(t *testing.T) {
|
||||
t.Run("valid text", func(t *testing.T) {
|
||||
str := "Product Name 2024"
|
||||
err := SafeText(100)(&str)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("valid UTF-8 text", func(t *testing.T) {
|
||||
str := "José García"
|
||||
err := SafeText(50)(&str)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("valid text with emoji", func(t *testing.T) {
|
||||
str := "Hello World 🌍"
|
||||
err := SafeText(50)(&str)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("valid text with apostrophe and hyphen", func(t *testing.T) {
|
||||
str := "O'Brien-Smith"
|
||||
err := SafeText(50)(&str)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid - empty string", func(t *testing.T) {
|
||||
str := ""
|
||||
err := SafeText(100)(&str)
|
||||
if err == nil {
|
||||
t.Error("expected validation error for empty string")
|
||||
}
|
||||
if !strings.Contains(err.Message, "empty") && !strings.Contains(err.Message, "required") {
|
||||
t.Errorf("unexpected error message: %s", err.Message)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid - exceeds max length", func(t *testing.T) {
|
||||
str := "This is a very long string that exceeds the maximum length"
|
||||
err := SafeText(10)(&str)
|
||||
if err == nil {
|
||||
t.Error("expected validation error for exceeding max length")
|
||||
}
|
||||
if !strings.Contains(err.Message, "at most") {
|
||||
t.Errorf("unexpected error message: %s", err.Message)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid - contains HTML tags", func(t *testing.T) {
|
||||
str := "Hello <b>World</b>"
|
||||
err := SafeText(100)(&str)
|
||||
if err == nil {
|
||||
t.Error("expected validation error for HTML tags")
|
||||
}
|
||||
if !strings.Contains(err.Message, "HTML tags") {
|
||||
t.Errorf("unexpected error message: %s", err.Message)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid - contains script tag", func(t *testing.T) {
|
||||
str := "<script>alert('xss')</script>"
|
||||
err := SafeText(100)(&str)
|
||||
if err == nil {
|
||||
t.Error("expected validation error for script tag")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid - contains angle brackets", func(t *testing.T) {
|
||||
str := "5 < 10"
|
||||
err := SafeText(100)(&str)
|
||||
if err == nil {
|
||||
t.Error("expected validation error for angle brackets")
|
||||
}
|
||||
if !strings.Contains(err.Message, "angle brackets") {
|
||||
t.Errorf("unexpected error message: %s", err.Message)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid - contains null byte", func(t *testing.T) {
|
||||
str := "test\x00text"
|
||||
err := SafeText(100)(&str)
|
||||
if err == nil {
|
||||
t.Error("expected validation error for null byte")
|
||||
}
|
||||
if !strings.Contains(err.Message, "control character") {
|
||||
t.Errorf("unexpected error message: %s", err.Message)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid - contains tab character", func(t *testing.T) {
|
||||
str := "test\ttext"
|
||||
err := SafeText(100)(&str)
|
||||
if err == nil {
|
||||
t.Error("expected validation error for tab character")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid - contains newline", func(t *testing.T) {
|
||||
str := "test\ntext"
|
||||
err := SafeText(100)(&str)
|
||||
if err == nil {
|
||||
t.Error("expected validation error for newline")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid - contains zero-width space", func(t *testing.T) {
|
||||
str := "test\u200Btext"
|
||||
err := SafeText(100)(&str)
|
||||
if err == nil {
|
||||
t.Error("expected validation error for zero-width space")
|
||||
}
|
||||
if !strings.Contains(err.Message, "zero-width") {
|
||||
t.Errorf("unexpected error message: %s", err.Message)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid - contains RLO character", func(t *testing.T) {
|
||||
str := "test\u202Eexe.txt"
|
||||
err := SafeText(100)(&str)
|
||||
if err == nil {
|
||||
t.Error("expected validation error for RLO character")
|
||||
}
|
||||
if !strings.Contains(err.Message, "bidirectional override") {
|
||||
t.Errorf("unexpected error message: %s", err.Message)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid - contains private use area character", func(t *testing.T) {
|
||||
str := "test\uE000text"
|
||||
err := SafeText(100)(&str)
|
||||
if err == nil {
|
||||
t.Error("expected validation error for private use area")
|
||||
}
|
||||
if !strings.Contains(err.Message, "private use") {
|
||||
t.Errorf("unexpected error message: %s", err.Message)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nil pointer", func(t *testing.T) {
|
||||
var str *string
|
||||
err := SafeText(100)(str)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error for nil pointer, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("not a string", func(t *testing.T) {
|
||||
num := 123
|
||||
err := SafeText(100)(&num)
|
||||
if err == nil {
|
||||
t.Error("expected validation error for non-string")
|
||||
}
|
||||
if !strings.Contains(err.Message, "must be a string") {
|
||||
t.Errorf("unexpected error message: %s", err.Message)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("combined with validator struct", func(t *testing.T) {
|
||||
v := New()
|
||||
title := "Product Title 2024"
|
||||
v.Check(&title, "title", SafeText(100))
|
||||
|
||||
if v.HasErrors() {
|
||||
t.Errorf("expected no errors, got: %v", v.Errors())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("combined with validator struct - invalid", func(t *testing.T) {
|
||||
v := New()
|
||||
malicious := "<script>alert('xss')</script>"
|
||||
v.Check(&malicious, "content", SafeText(100))
|
||||
|
||||
if !v.HasErrors() {
|
||||
t.Error("expected validation errors")
|
||||
}
|
||||
|
||||
errors := v.Errors()
|
||||
found := false
|
||||
for _, err := range errors {
|
||||
if strings.Contains(err.Message, "HTML tags") || strings.Contains(err.Message, "angle brackets") {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("expected error about HTML tags or angle brackets")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("edge case - exactly at max length", func(t *testing.T) {
|
||||
str := "12345"
|
||||
err := SafeText(5)(&str)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error for string at max length, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("edge case - one character over max length", func(t *testing.T) {
|
||||
str := "123456"
|
||||
err := SafeText(5)(&str)
|
||||
if err == nil {
|
||||
t.Error("expected validation error for string over max length")
|
||||
}
|
||||
})
|
||||
}
|
||||
275
pkg/validator/validator_string.go
Normal file
275
pkg/validator/validator_string.go
Normal file
@@ -0,0 +1,275 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package validator
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
alphaNumericRegex = regexp.MustCompile(`^[a-zA-Z0-9]+$`)
|
||||
slugRegex = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)
|
||||
)
|
||||
|
||||
// MinLen validates that a string has at least the specified minimum length.
|
||||
func MinLen(minLength int) ValidatorFunc {
|
||||
return func(value any) *ValidationError {
|
||||
actualValue, isNil := dereferenceValue(value)
|
||||
if isNil {
|
||||
return nil
|
||||
}
|
||||
|
||||
str, ok := actualValue.(string)
|
||||
if !ok {
|
||||
return newValidationError(ErrorCodeInvalidFormat, "value must be a string")
|
||||
}
|
||||
|
||||
if len(str) < minLength {
|
||||
return newValidationError(
|
||||
ErrorCodeTooShort,
|
||||
fmt.Sprintf("must be at least %d characters", minLength),
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// MaxLen validates that a string does not exceed the specified maximum length.
|
||||
func MaxLen(maxLength int) ValidatorFunc {
|
||||
return func(value any) *ValidationError {
|
||||
actualValue, isNil := dereferenceValue(value)
|
||||
if isNil {
|
||||
return nil
|
||||
}
|
||||
|
||||
str, ok := actualValue.(string)
|
||||
if !ok {
|
||||
return newValidationError(ErrorCodeInvalidFormat, "value must be a string")
|
||||
}
|
||||
|
||||
if len(str) > maxLength {
|
||||
return newValidationError(
|
||||
ErrorCodeTooLong,
|
||||
fmt.Sprintf("must be at most %d characters", maxLength),
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Pattern validates that a string matches the specified regular expression pattern.
|
||||
func Pattern(pattern string, message string) ValidatorFunc {
|
||||
regex := regexp.MustCompile(pattern)
|
||||
|
||||
return func(value any) *ValidationError {
|
||||
actualValue, isNil := dereferenceValue(value)
|
||||
if isNil {
|
||||
return nil
|
||||
}
|
||||
|
||||
str, ok := actualValue.(string)
|
||||
if !ok {
|
||||
return newValidationError(ErrorCodeInvalidFormat, "value must be a string")
|
||||
}
|
||||
|
||||
if !regex.MatchString(str) {
|
||||
if message == "" {
|
||||
message = fmt.Sprintf("must match pattern: %s", pattern)
|
||||
}
|
||||
return newValidationError(ErrorCodeInvalidFormat, message)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// AlphaNumeric validates that a string contains only letters and numbers.
|
||||
func AlphaNumeric() ValidatorFunc {
|
||||
return func(value any) *ValidationError {
|
||||
actualValue, isNil := dereferenceValue(value)
|
||||
if isNil {
|
||||
return nil
|
||||
}
|
||||
|
||||
str, ok := actualValue.(string)
|
||||
if !ok {
|
||||
return newValidationError(ErrorCodeInvalidFormat, "value must be a string")
|
||||
}
|
||||
|
||||
if str == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if !alphaNumericRegex.MatchString(str) {
|
||||
return newValidationError(ErrorCodeInvalidFormat, "must contain only letters and numbers")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// NoSpaces validates that a string does not contain any spaces.
|
||||
func NoSpaces() ValidatorFunc {
|
||||
return func(value any) *ValidationError {
|
||||
actualValue, isNil := dereferenceValue(value)
|
||||
if isNil {
|
||||
return nil
|
||||
}
|
||||
|
||||
str, ok := actualValue.(string)
|
||||
if !ok {
|
||||
return newValidationError(ErrorCodeInvalidFormat, "value must be a string")
|
||||
}
|
||||
|
||||
if str == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if strings.Contains(str, " ") {
|
||||
return newValidationError(ErrorCodeInvalidFormat, "must not contain spaces")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Slug validates that a string is a valid URL slug (lowercase letters, numbers, and hyphens).
|
||||
func Slug() ValidatorFunc {
|
||||
return func(value any) *ValidationError {
|
||||
actualValue, isNil := dereferenceValue(value)
|
||||
if isNil {
|
||||
return nil
|
||||
}
|
||||
|
||||
str, ok := actualValue.(string)
|
||||
if !ok {
|
||||
return newValidationError(ErrorCodeInvalidFormat, "value must be a string")
|
||||
}
|
||||
|
||||
if str == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if !slugRegex.MatchString(str) {
|
||||
return newValidationError(ErrorCodeInvalidFormat, "must be a valid slug (lowercase letters, numbers, and hyphens)")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// OneOfSlice validates that a value is one of the allowed values in the slice.
|
||||
// Accepts a slice of any type. Compares by value first, then by string representation.
|
||||
func OneOfSlice[T any](allowed []T) ValidatorFunc {
|
||||
// Build allowed map with string keys for flexible comparison
|
||||
allowedMap := make(map[string]bool)
|
||||
allowedStrings := make([]string, 0, len(allowed))
|
||||
|
||||
for _, v := range allowed {
|
||||
str := fmt.Sprint(v)
|
||||
allowedMap[str] = true
|
||||
allowedStrings = append(allowedStrings, str)
|
||||
}
|
||||
|
||||
return func(value any) *ValidationError {
|
||||
// Handle nil values first
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Dereference all pointer levels
|
||||
actualValue := value
|
||||
val := reflect.ValueOf(value)
|
||||
for val.Kind() == reflect.Ptr {
|
||||
if val.IsNil() {
|
||||
return nil
|
||||
}
|
||||
val = val.Elem()
|
||||
actualValue = val.Interface()
|
||||
}
|
||||
|
||||
// First try exact match with DeepEqual
|
||||
for _, allowedVal := range allowed {
|
||||
if reflect.DeepEqual(actualValue, allowedVal) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Then try string comparison (for custom string types)
|
||||
valueStr := fmt.Sprint(actualValue)
|
||||
if allowedMap[valueStr] {
|
||||
return nil
|
||||
}
|
||||
|
||||
return newValidationError(
|
||||
ErrorCodeInvalidEnum,
|
||||
fmt.Sprintf("must be one of: %s", strings.Join(allowedStrings, ", ")),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// OneOf validates that a value is one of the allowed values.
|
||||
// Accepts strings or types that implement fmt.Stringer as variadic arguments.
|
||||
func OneOf(allowed ...any) ValidatorFunc {
|
||||
allowedMap := make(map[string]bool)
|
||||
allowedStrings := make([]string, 0, len(allowed))
|
||||
|
||||
for _, v := range allowed {
|
||||
var str string
|
||||
switch val := v.(type) {
|
||||
case string:
|
||||
str = val
|
||||
case fmt.Stringer:
|
||||
str = val.String()
|
||||
default:
|
||||
str = fmt.Sprint(val)
|
||||
}
|
||||
allowedMap[str] = true
|
||||
allowedStrings = append(allowedStrings, str)
|
||||
}
|
||||
|
||||
return func(value any) *ValidationError {
|
||||
actualValue, isNil := dereferenceValue(value)
|
||||
if isNil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var str string
|
||||
switch v := actualValue.(type) {
|
||||
case string:
|
||||
str = v
|
||||
default:
|
||||
if stringer, ok := actualValue.(fmt.Stringer); ok {
|
||||
str = stringer.String()
|
||||
} else {
|
||||
return newValidationError(ErrorCodeInvalidEnum, "value must be a string or implement fmt.Stringer")
|
||||
}
|
||||
}
|
||||
|
||||
if !allowedMap[str] {
|
||||
return newValidationError(
|
||||
ErrorCodeInvalidEnum,
|
||||
fmt.Sprintf("must be one of: %s", strings.Join(allowedStrings, ", ")),
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
303
pkg/validator/validator_string_test.go
Normal file
303
pkg/validator/validator_string_test.go
Normal file
@@ -0,0 +1,303 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package validator
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"go.gearno.de/x/ref"
|
||||
)
|
||||
|
||||
func TestMinLen(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value any
|
||||
minLen int
|
||||
wantError bool
|
||||
}{
|
||||
{"valid string", "hello", 3, false},
|
||||
{"exact length", "hello", 5, false},
|
||||
{"too short", "hi", 5, true},
|
||||
{"nil pointer", (*string)(nil), 5, false}, // Skip validation
|
||||
{"valid pointer", ref.Ref("hello"), 3, false},
|
||||
{"non-string", 123, 5, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := MinLen(tt.minLen)(tt.value)
|
||||
if (err != nil) != tt.wantError {
|
||||
t.Errorf("MinLen() error = %v, wantError %v", err, tt.wantError)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaxLen(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value any
|
||||
maxLen int
|
||||
wantError bool
|
||||
}{
|
||||
{"valid string", "hello", 10, false},
|
||||
{"exact length", "hello", 5, false},
|
||||
{"too long", "hello world", 5, true},
|
||||
{"nil pointer", (*string)(nil), 5, false}, // Skip validation
|
||||
{"valid pointer", ref.Ref("hi"), 5, false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := MaxLen(tt.maxLen)(tt.value)
|
||||
if (err != nil) != tt.wantError {
|
||||
t.Errorf("MaxLen() error = %v, wantError %v", err, tt.wantError)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPattern(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value any
|
||||
pattern string
|
||||
message string
|
||||
wantError bool
|
||||
}{
|
||||
{"valid pattern", "abc123", `^[a-z0-9]+$`, "", false},
|
||||
{"invalid pattern", "ABC123", `^[a-z0-9]+$`, "", true},
|
||||
{"custom message", "invalid", `^valid$`, "must be 'valid'", true},
|
||||
{"nil pointer", (*string)(nil), `^test$`, "", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := Pattern(tt.pattern, tt.message)(tt.value)
|
||||
if (err != nil) != tt.wantError {
|
||||
t.Errorf("Pattern() error = %v, wantError %v", err, tt.wantError)
|
||||
}
|
||||
if err != nil && tt.message != "" && err.Message != tt.message {
|
||||
t.Errorf("Expected message '%s', got '%s'", tt.message, err.Message)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlphaNumeric(t *testing.T) {
|
||||
t.Run("valid alphanumeric", func(t *testing.T) {
|
||||
str := "abc123"
|
||||
err := AlphaNumeric()(&str)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("only letters", func(t *testing.T) {
|
||||
str := "abcDEF"
|
||||
err := AlphaNumeric()(&str)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("only numbers", func(t *testing.T) {
|
||||
str := "123456"
|
||||
err := AlphaNumeric()(&str)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("contains spaces", func(t *testing.T) {
|
||||
str := "abc 123"
|
||||
err := AlphaNumeric()(&str)
|
||||
if err == nil {
|
||||
t.Error("expected validation error")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("contains special characters", func(t *testing.T) {
|
||||
str := "abc-123"
|
||||
err := AlphaNumeric()(&str)
|
||||
if err == nil {
|
||||
t.Error("expected validation error")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty string", func(t *testing.T) {
|
||||
str := ""
|
||||
err := AlphaNumeric()(&str)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error for empty string, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nil pointer", func(t *testing.T) {
|
||||
var str *string
|
||||
err := AlphaNumeric()(str)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error for nil, got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestNoSpaces(t *testing.T) {
|
||||
t.Run("no spaces", func(t *testing.T) {
|
||||
str := "hello-world"
|
||||
err := NoSpaces()(&str)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("contains spaces", func(t *testing.T) {
|
||||
str := "hello world"
|
||||
err := NoSpaces()(&str)
|
||||
if err == nil {
|
||||
t.Error("expected validation error")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("multiple spaces", func(t *testing.T) {
|
||||
str := "hello world test"
|
||||
err := NoSpaces()(&str)
|
||||
if err == nil {
|
||||
t.Error("expected validation error")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty string", func(t *testing.T) {
|
||||
str := ""
|
||||
err := NoSpaces()(&str)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error for empty string, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nil pointer", func(t *testing.T) {
|
||||
var str *string
|
||||
err := NoSpaces()(str)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error for nil, got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSlug(t *testing.T) {
|
||||
t.Run("valid slug", func(t *testing.T) {
|
||||
str := "hello-world"
|
||||
err := Slug()(&str)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("valid slug with numbers", func(t *testing.T) {
|
||||
str := "hello-world-123"
|
||||
err := Slug()(&str)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("single word", func(t *testing.T) {
|
||||
str := "hello"
|
||||
err := Slug()(&str)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("contains uppercase", func(t *testing.T) {
|
||||
str := "Hello-World"
|
||||
err := Slug()(&str)
|
||||
if err == nil {
|
||||
t.Error("expected validation error for uppercase")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("contains spaces", func(t *testing.T) {
|
||||
str := "hello world"
|
||||
err := Slug()(&str)
|
||||
if err == nil {
|
||||
t.Error("expected validation error for spaces")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("contains underscores", func(t *testing.T) {
|
||||
str := "hello_world"
|
||||
err := Slug()(&str)
|
||||
if err == nil {
|
||||
t.Error("expected validation error for underscores")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("starts with hyphen", func(t *testing.T) {
|
||||
str := "-hello"
|
||||
err := Slug()(&str)
|
||||
if err == nil {
|
||||
t.Error("expected validation error for leading hyphen")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ends with hyphen", func(t *testing.T) {
|
||||
str := "hello-"
|
||||
err := Slug()(&str)
|
||||
if err == nil {
|
||||
t.Error("expected validation error for trailing hyphen")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty string", func(t *testing.T) {
|
||||
str := ""
|
||||
err := Slug()(&str)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error for empty string, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nil pointer", func(t *testing.T) {
|
||||
var str *string
|
||||
err := Slug()(str)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error for nil, got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestOneOf(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value any
|
||||
allowed []string
|
||||
wantError bool
|
||||
}{
|
||||
{"valid value", "apple", []string{"apple", "banana", "orange"}, false},
|
||||
{"invalid value", "grape", []string{"apple", "banana", "orange"}, true},
|
||||
{"nil pointer", (*string)(nil), []string{"apple"}, false},
|
||||
{"empty string", "", []string{"apple", ""}, false},
|
||||
{"non-string", 123, []string{"apple"}, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := OneOfSlice(tt.allowed)(tt.value)
|
||||
if (err != nil) != tt.wantError {
|
||||
t.Errorf("OneOfSlice() error = %v, wantError %v", err, tt.wantError)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
208
pkg/validator/validator_time.go
Normal file
208
pkg/validator/validator_time.go
Normal file
@@ -0,0 +1,208 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package validator
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// After validates that a time is after the specified reference time.
|
||||
// The reference time can be either time.Time or *time.Time.
|
||||
func After(t any) ValidatorFunc {
|
||||
return func(value any) *ValidationError {
|
||||
// Extract the reference time
|
||||
refValue, refIsNil := dereferenceValue(t)
|
||||
if refIsNil {
|
||||
return nil // No reference time to compare against
|
||||
}
|
||||
|
||||
refTime, ok := refValue.(time.Time)
|
||||
if !ok {
|
||||
return newValidationError(ErrorCodeInvalidFormat, "reference time must be time.Time")
|
||||
}
|
||||
|
||||
// Extract the value being validated
|
||||
actualValue, isNil := dereferenceValue(value)
|
||||
if isNil {
|
||||
return nil
|
||||
}
|
||||
|
||||
timeVal, ok := actualValue.(time.Time)
|
||||
if !ok {
|
||||
return newValidationError(ErrorCodeInvalidFormat, "value must be a time.Time")
|
||||
}
|
||||
|
||||
if !timeVal.After(refTime) {
|
||||
return newValidationError(
|
||||
ErrorCodeOutOfRange,
|
||||
fmt.Sprintf("must be after %s", refTime.Format(time.RFC3339)),
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Before validates that a time is before the specified reference time.
|
||||
// The reference time can be either time.Time or *time.Time.
|
||||
func Before(t any) ValidatorFunc {
|
||||
return func(value any) *ValidationError {
|
||||
// Extract the reference time
|
||||
refValue, refIsNil := dereferenceValue(t)
|
||||
if refIsNil {
|
||||
return nil // No reference time to compare against
|
||||
}
|
||||
|
||||
refTime, ok := refValue.(time.Time)
|
||||
if !ok {
|
||||
return newValidationError(ErrorCodeInvalidFormat, "reference time must be time.Time")
|
||||
}
|
||||
|
||||
// Extract the value being validated
|
||||
actualValue, isNil := dereferenceValue(value)
|
||||
if isNil {
|
||||
return nil
|
||||
}
|
||||
|
||||
timeVal, ok := actualValue.(time.Time)
|
||||
if !ok {
|
||||
return newValidationError(ErrorCodeInvalidFormat, "value must be a time.Time")
|
||||
}
|
||||
|
||||
if !timeVal.Before(refTime) {
|
||||
return newValidationError(
|
||||
ErrorCodeOutOfRange,
|
||||
fmt.Sprintf("must be before %s", refTime.Format(time.RFC3339)),
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// FutureDate validates that a time is in the future.
|
||||
func FutureDate() ValidatorFunc {
|
||||
return func(value any) *ValidationError {
|
||||
actualValue, isNil := dereferenceValue(value)
|
||||
if isNil {
|
||||
return nil
|
||||
}
|
||||
|
||||
timeVal, ok := actualValue.(time.Time)
|
||||
if !ok {
|
||||
return newValidationError(ErrorCodeInvalidFormat, "value must be a time.Time")
|
||||
}
|
||||
|
||||
if !timeVal.After(time.Now()) {
|
||||
return newValidationError(ErrorCodeOutOfRange, "must be a future date")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// PastDate validates that a time is in the past.
|
||||
func PastDate() ValidatorFunc {
|
||||
return func(value any) *ValidationError {
|
||||
actualValue, isNil := dereferenceValue(value)
|
||||
if isNil {
|
||||
return nil
|
||||
}
|
||||
|
||||
timeVal, ok := actualValue.(time.Time)
|
||||
if !ok {
|
||||
return newValidationError(ErrorCodeInvalidFormat, "value must be a time.Time")
|
||||
}
|
||||
|
||||
if !timeVal.Before(time.Now()) {
|
||||
return newValidationError(ErrorCodeOutOfRange, "must be a past date")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// MinDuration validates that a duration is at least the specified minimum value.
|
||||
func MinDuration(min time.Duration) ValidatorFunc {
|
||||
return func(value any) *ValidationError {
|
||||
actualValue, isNil := dereferenceValue(value)
|
||||
if isNil {
|
||||
return nil
|
||||
}
|
||||
|
||||
duration, ok := actualValue.(time.Duration)
|
||||
if !ok {
|
||||
return newValidationError(ErrorCodeInvalidFormat, "value must be a time.Duration")
|
||||
}
|
||||
|
||||
if duration < min {
|
||||
return newValidationError(
|
||||
ErrorCodeOutOfRange,
|
||||
fmt.Sprintf("must be at least %s", min),
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// MaxDuration validates that a duration does not exceed the specified maximum value.
|
||||
func MaxDuration(max time.Duration) ValidatorFunc {
|
||||
return func(value any) *ValidationError {
|
||||
actualValue, isNil := dereferenceValue(value)
|
||||
if isNil {
|
||||
return nil
|
||||
}
|
||||
|
||||
duration, ok := actualValue.(time.Duration)
|
||||
if !ok {
|
||||
return newValidationError(ErrorCodeInvalidFormat, "value must be a time.Duration")
|
||||
}
|
||||
|
||||
if duration > max {
|
||||
return newValidationError(
|
||||
ErrorCodeOutOfRange,
|
||||
fmt.Sprintf("must be at most %s", max),
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// RangeDuration validates that a duration is within the specified range (inclusive).
|
||||
func RangeDuration(min, max time.Duration) ValidatorFunc {
|
||||
return func(value any) *ValidationError {
|
||||
actualValue, isNil := dereferenceValue(value)
|
||||
if isNil {
|
||||
return nil
|
||||
}
|
||||
|
||||
duration, ok := actualValue.(time.Duration)
|
||||
if !ok {
|
||||
return newValidationError(ErrorCodeInvalidFormat, "value must be a time.Duration")
|
||||
}
|
||||
|
||||
if duration < min || duration > max {
|
||||
return newValidationError(
|
||||
ErrorCodeOutOfRange,
|
||||
fmt.Sprintf("must be between %s and %s", min, max),
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
293
pkg/validator/validator_time_test.go
Normal file
293
pkg/validator/validator_time_test.go
Normal file
@@ -0,0 +1,293 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
package validator
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestAfter(t *testing.T) {
|
||||
now := time.Now()
|
||||
past := now.Add(-24 * time.Hour)
|
||||
future := now.Add(24 * time.Hour)
|
||||
|
||||
t.Run("time after reference", func(t *testing.T) {
|
||||
err := After(past)(&future)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("time before reference", func(t *testing.T) {
|
||||
err := After(future)(&past)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error")
|
||||
}
|
||||
if err.Code != ErrorCodeOutOfRange {
|
||||
t.Errorf("expected error code %s, got %s", ErrorCodeOutOfRange, err.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("same time", func(t *testing.T) {
|
||||
err := After(now)(&now)
|
||||
if err == nil {
|
||||
t.Error("expected validation error for equal times")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nil pointer", func(t *testing.T) {
|
||||
var timeVal *time.Time
|
||||
err := After(now)(timeVal)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error for nil, got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBefore(t *testing.T) {
|
||||
now := time.Now()
|
||||
past := now.Add(-24 * time.Hour)
|
||||
future := now.Add(24 * time.Hour)
|
||||
|
||||
t.Run("time before reference", func(t *testing.T) {
|
||||
err := Before(future)(&past)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("time after reference", func(t *testing.T) {
|
||||
err := Before(past)(&future)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error")
|
||||
}
|
||||
if err.Code != ErrorCodeOutOfRange {
|
||||
t.Errorf("expected error code %s, got %s", ErrorCodeOutOfRange, err.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("same time", func(t *testing.T) {
|
||||
err := Before(now)(&now)
|
||||
if err == nil {
|
||||
t.Error("expected validation error for equal times")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nil pointer", func(t *testing.T) {
|
||||
var timeVal *time.Time
|
||||
err := Before(now)(timeVal)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error for nil, got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestFutureDate(t *testing.T) {
|
||||
future := time.Now().Add(24 * time.Hour)
|
||||
past := time.Now().Add(-24 * time.Hour)
|
||||
|
||||
t.Run("future date", func(t *testing.T) {
|
||||
err := FutureDate()(&future)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("past date", func(t *testing.T) {
|
||||
err := FutureDate()(&past)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error")
|
||||
}
|
||||
if err.Code != ErrorCodeOutOfRange {
|
||||
t.Errorf("expected error code %s, got %s", ErrorCodeOutOfRange, err.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nil pointer", func(t *testing.T) {
|
||||
var timeVal *time.Time
|
||||
err := FutureDate()(timeVal)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error for nil, got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPastDate(t *testing.T) {
|
||||
future := time.Now().Add(24 * time.Hour)
|
||||
past := time.Now().Add(-24 * time.Hour)
|
||||
|
||||
t.Run("past date", func(t *testing.T) {
|
||||
err := PastDate()(&past)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("future date", func(t *testing.T) {
|
||||
err := PastDate()(&future)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error")
|
||||
}
|
||||
if err.Code != ErrorCodeOutOfRange {
|
||||
t.Errorf("expected error code %s, got %s", ErrorCodeOutOfRange, err.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nil pointer", func(t *testing.T) {
|
||||
var timeVal *time.Time
|
||||
err := PastDate()(timeVal)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error for nil, got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestMinDuration(t *testing.T) {
|
||||
minDuration := 10 * time.Minute
|
||||
|
||||
t.Run("duration above minimum", func(t *testing.T) {
|
||||
duration := 20 * time.Minute
|
||||
err := MinDuration(minDuration)(&duration)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("duration equal to minimum", func(t *testing.T) {
|
||||
duration := 10 * time.Minute
|
||||
err := MinDuration(minDuration)(&duration)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("duration below minimum", func(t *testing.T) {
|
||||
duration := 5 * time.Minute
|
||||
err := MinDuration(minDuration)(&duration)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error")
|
||||
}
|
||||
if err.Code != ErrorCodeOutOfRange {
|
||||
t.Errorf("expected error code %s, got %s", ErrorCodeOutOfRange, err.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nil pointer", func(t *testing.T) {
|
||||
var duration *time.Duration
|
||||
err := MinDuration(minDuration)(duration)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error for nil, got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestMaxDuration(t *testing.T) {
|
||||
maxDuration := 1 * time.Hour
|
||||
|
||||
t.Run("duration below maximum", func(t *testing.T) {
|
||||
duration := 30 * time.Minute
|
||||
err := MaxDuration(maxDuration)(&duration)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("duration equal to maximum", func(t *testing.T) {
|
||||
duration := 1 * time.Hour
|
||||
err := MaxDuration(maxDuration)(&duration)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("duration above maximum", func(t *testing.T) {
|
||||
duration := 2 * time.Hour
|
||||
err := MaxDuration(maxDuration)(&duration)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error")
|
||||
}
|
||||
if err.Code != ErrorCodeOutOfRange {
|
||||
t.Errorf("expected error code %s, got %s", ErrorCodeOutOfRange, err.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nil pointer", func(t *testing.T) {
|
||||
var duration *time.Duration
|
||||
err := MaxDuration(maxDuration)(duration)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error for nil, got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRangeDuration(t *testing.T) {
|
||||
minDuration := 10 * time.Minute
|
||||
maxDuration := 1 * time.Hour
|
||||
|
||||
t.Run("duration within range", func(t *testing.T) {
|
||||
duration := 30 * time.Minute
|
||||
err := RangeDuration(minDuration, maxDuration)(&duration)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("duration at minimum", func(t *testing.T) {
|
||||
duration := 10 * time.Minute
|
||||
err := RangeDuration(minDuration, maxDuration)(&duration)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("duration at maximum", func(t *testing.T) {
|
||||
duration := 1 * time.Hour
|
||||
err := RangeDuration(minDuration, maxDuration)(&duration)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("duration below minimum", func(t *testing.T) {
|
||||
duration := 5 * time.Minute
|
||||
err := RangeDuration(minDuration, maxDuration)(&duration)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error")
|
||||
}
|
||||
if err.Code != ErrorCodeOutOfRange {
|
||||
t.Errorf("expected error code %s, got %s", ErrorCodeOutOfRange, err.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("duration above maximum", func(t *testing.T) {
|
||||
duration := 2 * time.Hour
|
||||
err := RangeDuration(minDuration, maxDuration)(&duration)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error")
|
||||
}
|
||||
if err.Code != ErrorCodeOutOfRange {
|
||||
t.Errorf("expected error code %s, got %s", ErrorCodeOutOfRange, err.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nil pointer", func(t *testing.T) {
|
||||
var duration *time.Duration
|
||||
err := RangeDuration(minDuration, maxDuration)(duration)
|
||||
if err != nil {
|
||||
t.Errorf("expected no error for nil, got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user