feat(valid): initial stable release v0.9.0
Struct validation backed by go-playground/validator/v10 with xerrors integration and pluggable i18n message providers. What's included: - Validator interface with Struct(v any) error method - New(...Option) constructor with WithMessageProvider functional option - MessageProvider interface for i18n; DefaultMessages (EN) and SpanishMessages (ES) built in - ValidationErrors mapped to xerrors.ErrInvalidInput with field and tag context keys - InvalidValidationError (non-struct input) mapped to xerrors.ErrInternal - Full playground.ValidationErrors attached via WithError for callers needing all failures Tested-via: todo-api POC integration Reviewed-against: docs/adr/
This commit is contained in:
289
valid_test.go
Normal file
289
valid_test.go
Normal file
@@ -0,0 +1,289 @@
|
||||
package valid
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"code.nochebuena.dev/go/xerrors"
|
||||
playground "github.com/go-playground/validator/v10"
|
||||
)
|
||||
|
||||
type testUser struct {
|
||||
Name string `validate:"required"`
|
||||
Email string `validate:"required,email"`
|
||||
Age int `validate:"min=18,max=120"`
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Constructor tests
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
func TestNew_Defaults(t *testing.T) {
|
||||
v := New()
|
||||
if v == nil {
|
||||
t.Fatal("New() returned nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNew_WithMessageProvider(t *testing.T) {
|
||||
called := false
|
||||
mp := &testMP{fn: func(field, tag, param string) string {
|
||||
called = true
|
||||
return "custom: " + field
|
||||
}}
|
||||
|
||||
v := New(WithMessageProvider(mp))
|
||||
_ = v.Struct(testUser{}) // triggers validation failure (missing Name/Email)
|
||||
if !called {
|
||||
t.Error("custom MessageProvider was not called")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Struct validation tests
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
func TestValidator_Struct_Valid(t *testing.T) {
|
||||
v := New()
|
||||
u := testUser{Name: "Alice", Email: "alice@example.com", Age: 25}
|
||||
if err := v.Struct(u); err != nil {
|
||||
t.Errorf("expected nil, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidator_Struct_MissingRequired(t *testing.T) {
|
||||
v := New()
|
||||
u := testUser{Email: "a@b.com", Age: 20} // Name is missing
|
||||
err := v.Struct(u)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
|
||||
var xe *xerrors.Err
|
||||
if !errors.As(err, &xe) {
|
||||
t.Fatalf("expected *xerrors.Err, got %T", err)
|
||||
}
|
||||
if xe.Code() != xerrors.ErrInvalidInput {
|
||||
t.Errorf("code = %s, want %s", xe.Code(), xerrors.ErrInvalidInput)
|
||||
}
|
||||
if xe.Fields()["field"] != "Name" {
|
||||
t.Errorf("field = %v, want Name", xe.Fields()["field"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidator_Struct_InvalidEmail(t *testing.T) {
|
||||
v := New()
|
||||
u := testUser{Name: "Bob", Email: "not-an-email", Age: 25}
|
||||
err := v.Struct(u)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
|
||||
var xe *xerrors.Err
|
||||
if !errors.As(err, &xe) {
|
||||
t.Fatalf("expected *xerrors.Err, got %T", err)
|
||||
}
|
||||
if xe.Fields()["tag"] != "email" {
|
||||
t.Errorf("tag = %v, want email", xe.Fields()["tag"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidator_Struct_BelowMin(t *testing.T) {
|
||||
v := New()
|
||||
u := testUser{Name: "Carol", Email: "carol@example.com", Age: 10}
|
||||
err := v.Struct(u)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
|
||||
var xe *xerrors.Err
|
||||
if !errors.As(err, &xe) {
|
||||
t.Fatalf("expected *xerrors.Err, got %T", err)
|
||||
}
|
||||
if xe.Code() != xerrors.ErrInvalidInput {
|
||||
t.Errorf("code = %s, want %s", xe.Code(), xerrors.ErrInvalidInput)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidator_Struct_AboveMax(t *testing.T) {
|
||||
v := New()
|
||||
u := testUser{Name: "Dave", Email: "dave@example.com", Age: 200}
|
||||
err := v.Struct(u)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
|
||||
var xe *xerrors.Err
|
||||
if !errors.As(err, &xe) {
|
||||
t.Fatalf("expected *xerrors.Err, got %T", err)
|
||||
}
|
||||
if xe.Code() != xerrors.ErrInvalidInput {
|
||||
t.Errorf("code = %s, want %s", xe.Code(), xerrors.ErrInvalidInput)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidator_Struct_NotAStruct(t *testing.T) {
|
||||
v := New()
|
||||
err := v.Struct("not a struct")
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
|
||||
var xe *xerrors.Err
|
||||
if !errors.As(err, &xe) {
|
||||
t.Fatalf("expected *xerrors.Err, got %T", err)
|
||||
}
|
||||
if xe.Code() != xerrors.ErrInternal {
|
||||
t.Errorf("code = %s, want %s", xe.Code(), xerrors.ErrInternal)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidator_Struct_ErrorCode(t *testing.T) {
|
||||
v := New()
|
||||
u := testUser{} // all fields fail
|
||||
err := v.Struct(u)
|
||||
|
||||
var xe *xerrors.Err
|
||||
if !errors.As(err, &xe) {
|
||||
t.Fatalf("errors.As failed: %T", err)
|
||||
}
|
||||
if xe.Code() != xerrors.ErrInvalidInput {
|
||||
t.Errorf("Code() = %s, want %s", xe.Code(), xerrors.ErrInvalidInput)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidator_Struct_Fields(t *testing.T) {
|
||||
v := New()
|
||||
u := testUser{} // missing Name triggers required
|
||||
err := v.Struct(u)
|
||||
|
||||
var xe *xerrors.Err
|
||||
errors.As(err, &xe)
|
||||
fields := xe.Fields()
|
||||
if _, ok := fields["field"]; !ok {
|
||||
t.Error("Fields() missing 'field' key")
|
||||
}
|
||||
if _, ok := fields["tag"]; !ok {
|
||||
t.Error("Fields() missing 'tag' key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidator_Struct_Unwrap(t *testing.T) {
|
||||
v := New()
|
||||
u := testUser{} // triggers validation error
|
||||
err := v.Struct(u)
|
||||
|
||||
var xe *xerrors.Err
|
||||
errors.As(err, &xe)
|
||||
|
||||
// The wrapped error must be a playground.ValidationErrors.
|
||||
cause := errors.Unwrap(xe)
|
||||
if cause == nil {
|
||||
t.Fatal("Unwrap returned nil")
|
||||
}
|
||||
var ve playground.ValidationErrors
|
||||
if !errors.As(cause, &ve) {
|
||||
t.Errorf("Unwrap should return validator.ValidationErrors, got %T", cause)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidator_Struct_SpanishMessages(t *testing.T) {
|
||||
v := New(WithMessageProvider(SpanishMessages))
|
||||
u := testUser{Email: "a@b.com", Age: 20} // missing Name
|
||||
|
||||
err := v.Struct(u)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "obligatorio") {
|
||||
t.Errorf("expected Spanish message, got: %s", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidator_Struct_EnglishMessage_Required(t *testing.T) {
|
||||
v := New()
|
||||
u := testUser{Email: "a@b.com", Age: 20} // missing Name
|
||||
|
||||
err := v.Struct(u)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "required") {
|
||||
t.Errorf("expected English 'required' message, got: %s", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// MessageProvider tests
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
func TestDefaultMessages_AllTags(t *testing.T) {
|
||||
mp := DefaultMessages
|
||||
tags := []struct {
|
||||
tag string
|
||||
param string
|
||||
}{
|
||||
{"required", ""},
|
||||
{"email", ""},
|
||||
{"min", "5"},
|
||||
{"max", "100"},
|
||||
{"unknown_rule", ""},
|
||||
}
|
||||
for _, tt := range tags {
|
||||
msg := mp.Message("Field", tt.tag, tt.param)
|
||||
if msg == "" {
|
||||
t.Errorf("DefaultMessages.Message(%q) returned empty string", tt.tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpanishMessages_AllTags(t *testing.T) {
|
||||
mp := SpanishMessages
|
||||
tags := []struct {
|
||||
tag string
|
||||
param string
|
||||
}{
|
||||
{"required", ""},
|
||||
{"email", ""},
|
||||
{"min", "5"},
|
||||
{"max", "100"},
|
||||
{"unknown_rule", ""},
|
||||
}
|
||||
for _, tt := range tags {
|
||||
msg := mp.Message("Campo", tt.tag, tt.param)
|
||||
if msg == "" {
|
||||
t.Errorf("SpanishMessages.Message(%q) returned empty string", tt.tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithMessageProvider_CustomImpl(t *testing.T) {
|
||||
mp := &testMP{fn: func(field, tag, param string) string {
|
||||
return "CUSTOM:" + field + ":" + tag
|
||||
}}
|
||||
v := New(WithMessageProvider(mp))
|
||||
u := testUser{Email: "a@b.com", Age: 20} // missing Name
|
||||
|
||||
err := v.Struct(u)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "CUSTOM:") {
|
||||
t.Errorf("expected custom message prefix, got: %s", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
type testMP struct {
|
||||
fn func(field, tag, param string) string
|
||||
}
|
||||
|
||||
func (m *testMP) Message(field, tag, param string) string {
|
||||
return m.fn(field, tag, param)
|
||||
}
|
||||
Reference in New Issue
Block a user