rdev/internal/validate/validate_test.go
jordan 72d16929ca feat: Implement hexagonal architecture with services, webhooks, queue, and telemetry
Major refactoring to hexagonal (ports & adapters) architecture:

- Add service layer (apikey_service, project_service) for business logic
- Add webhook system with dispatcher and delivery tracking
- Add command queue with priority-based processing
- Add rate limiting with sliding window algorithm
- Add audit logging for command execution
- Add OpenTelemetry integration (traces, metrics, spans)
- Add circuit breaker for fault tolerance
- Add cached repository wrapper for performance
- Add comprehensive validation package
- Add Kubernetes client integration for pod management
- Add database migrations (allowed_ips, audit_log, rate_limiting, queue, webhooks)
- Add network policy and PodDisruptionBudget for k8s
- Remove legacy executor and projects/registry packages
- Untrack secrets.yaml (now managed via envault)
- Add coverage.out to .gitignore
- Add e2e test infrastructure with docker-compose
- Add comprehensive documentation (API, architecture, operations, plans)
- Add golangci-lint config and pre-commit hook

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 19:57:46 -07:00

549 lines
14 KiB
Go

package validate
import (
"regexp"
"testing"
)
func TestValidationError_Error(t *testing.T) {
err := ValidationError{Field: "name", Message: "is required"}
expected := "name: is required"
if err.Error() != expected {
t.Errorf("expected %q, got %q", expected, err.Error())
}
}
func TestValidationErrors_Error(t *testing.T) {
tests := []struct {
name string
errors ValidationErrors
expected string
}{
{
name: "no errors",
errors: ValidationErrors{},
expected: "",
},
{
name: "single error",
errors: ValidationErrors{
{Field: "name", Message: "is required"},
},
expected: "name: is required",
},
{
name: "multiple errors",
errors: ValidationErrors{
{Field: "name", Message: "is required"},
{Field: "email", Message: "is invalid"},
},
expected: "validation failed: name: is required; email: is invalid",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.errors.Error(); got != tt.expected {
t.Errorf("expected %q, got %q", tt.expected, got)
}
})
}
}
func TestValidationErrors_HasErrors(t *testing.T) {
if (ValidationErrors{}).HasErrors() {
t.Error("expected empty ValidationErrors to have no errors")
}
errs := ValidationErrors{{Field: "test", Message: "error"}}
if !errs.HasErrors() {
t.Error("expected non-empty ValidationErrors to have errors")
}
}
func TestValidationErrors_Fields(t *testing.T) {
errs := ValidationErrors{
{Field: "name", Message: "is required"},
{Field: "name", Message: "is too short"}, // Second error for same field
{Field: "email", Message: "is invalid"},
}
fields := errs.Fields()
if len(fields) != 2 {
t.Errorf("expected 2 fields, got %d", len(fields))
}
if fields["name"] != "is required" {
t.Errorf("expected name error to be 'is required', got %q", fields["name"])
}
if fields["email"] != "is invalid" {
t.Errorf("expected email error to be 'is invalid', got %q", fields["email"])
}
}
func TestValidator_Required(t *testing.T) {
tests := []struct {
name string
value string
wantErr bool
}{
{"valid", "hello", false},
{"empty", "", true},
{"whitespace only", " ", true},
{"with whitespace", " hello ", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
v := New()
v.Required(tt.value, "field")
if (v.Error() != nil) != tt.wantErr {
t.Errorf("Required(%q) error = %v, wantErr %v", tt.value, v.Error(), tt.wantErr)
}
})
}
}
func TestValidator_RequiredSlice(t *testing.T) {
tests := []struct {
name string
value []string
wantErr bool
}{
{"valid", []string{"a", "b"}, false},
{"single item", []string{"a"}, false},
{"empty", []string{}, true},
{"nil", nil, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
v := New()
v.RequiredSlice(tt.value, "field")
if (v.Error() != nil) != tt.wantErr {
t.Errorf("RequiredSlice(%v) error = %v, wantErr %v", tt.value, v.Error(), tt.wantErr)
}
})
}
}
func TestValidator_StringLength(t *testing.T) {
tests := []struct {
name string
value string
min int
max int
wantErr bool
errMsg string
}{
{"valid", "hello", 1, 10, false, ""},
{"exact min", "ab", 2, 10, false, ""},
{"exact max", "hello", 1, 5, false, ""},
{"too short", "a", 2, 10, true, "must be at least 2 characters"},
{"too long", "hello world", 1, 5, true, "must be at most 5 characters"},
{"no min check", "", 0, 10, false, ""},
{"no max check", "very long string", 1, 0, false, ""},
{"empty with min", "", 1, 10, true, "must be at least 1 characters"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
v := New()
v.StringLength(tt.value, "field", tt.min, tt.max)
err := v.Error()
if (err != nil) != tt.wantErr {
t.Errorf("StringLength(%q, %d, %d) error = %v, wantErr %v", tt.value, tt.min, tt.max, err, tt.wantErr)
}
if tt.wantErr && tt.errMsg != "" {
verr := v.errors[0]
if verr.Message != tt.errMsg {
t.Errorf("expected message %q, got %q", tt.errMsg, verr.Message)
}
}
})
}
}
func TestValidator_Pattern(t *testing.T) {
alphaOnly := regexp.MustCompile(`^[a-zA-Z]+$`)
tests := []struct {
name string
value string
wantErr bool
}{
{"valid", "hello", false},
{"empty (skipped)", "", false},
{"invalid", "hello123", true},
{"with spaces", "hello world", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
v := New()
v.Pattern(tt.value, "field", alphaOnly, "must contain only letters")
if (v.Error() != nil) != tt.wantErr {
t.Errorf("Pattern(%q) error = %v, wantErr %v", tt.value, v.Error(), tt.wantErr)
}
})
}
}
func TestValidator_Custom(t *testing.T) {
tests := []struct {
name string
valid bool
wantErr bool
}{
{"valid", true, false},
{"invalid", false, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
v := New()
v.Custom(tt.valid, "field", "custom error")
if (v.Error() != nil) != tt.wantErr {
t.Errorf("Custom(%v) error = %v, wantErr %v", tt.valid, v.Error(), tt.wantErr)
}
})
}
}
func TestValidator_AddError(t *testing.T) {
v := New()
v.AddError("field", "custom message")
if !v.HasErrors() {
t.Error("expected validator to have errors after AddError")
}
err := v.Error()
if err == nil {
t.Fatal("expected non-nil error")
}
verrs := err.(ValidationErrors)
if len(verrs) != 1 || verrs[0].Field != "field" || verrs[0].Message != "custom message" {
t.Errorf("unexpected error: %v", verrs)
}
}
func TestValidator_Composable(t *testing.T) {
// Test chaining multiple validations
v := New()
v.Required("test", "name").
StringLength("test", "name", 1, 64).
Pattern("test", "name", AlphanumericDashUnderscore, "must be alphanumeric")
if v.HasErrors() {
t.Errorf("expected no errors, got %v", v.Error())
}
// Test with failures
v2 := New()
v2.Required("", "name").
Required("", "email").
StringLength("verylongnamethatshouldexceedlimit", "description", 0, 10)
if !v2.HasErrors() {
t.Error("expected validation errors")
}
errs := v2.Errors()
if len(errs) != 3 {
t.Errorf("expected 3 errors, got %d", len(errs))
}
}
func TestValidator_Errors(t *testing.T) {
v := New()
v.Required("", "field1")
v.Required("", "field2")
errs := v.Errors()
if len(errs) != 2 {
t.Errorf("expected 2 errors, got %d", len(errs))
}
}
// --- Standalone function tests ---
func TestRequired(t *testing.T) {
tests := []struct {
name string
value string
wantErr bool
}{
{"valid", "hello", false},
{"empty", "", true},
{"whitespace", " ", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := Required(tt.value, "field")
if (err != nil) != tt.wantErr {
t.Errorf("Required(%q) error = %v, wantErr %v", tt.value, err, tt.wantErr)
}
})
}
}
func TestRequiredSlice(t *testing.T) {
if err := RequiredSlice([]string{"a"}, "field"); err != nil {
t.Errorf("expected no error, got %v", err)
}
if err := RequiredSlice([]string{}, "field"); err == nil {
t.Error("expected error for empty slice")
}
if err := RequiredSlice(nil, "field"); err == nil {
t.Error("expected error for nil slice")
}
}
func TestStringLength(t *testing.T) {
if err := StringLength("hello", "field", 1, 10); err != nil {
t.Errorf("expected no error, got %v", err)
}
if err := StringLength("a", "field", 2, 10); err == nil {
t.Error("expected error for too short string")
}
if err := StringLength("hello world", "field", 1, 5); err == nil {
t.Error("expected error for too long string")
}
}
func TestPattern(t *testing.T) {
alphaOnly := regexp.MustCompile(`^[a-zA-Z]+$`)
if err := Pattern("hello", "field", alphaOnly, "must be alpha"); err != nil {
t.Errorf("expected no error, got %v", err)
}
if err := Pattern("hello123", "field", alphaOnly, "must be alpha"); err == nil {
t.Error("expected error for invalid pattern")
}
// Empty string should pass (use Required for that check)
if err := Pattern("", "field", alphaOnly, "must be alpha"); err != nil {
t.Errorf("expected no error for empty string, got %v", err)
}
}
// --- Common patterns tests ---
func TestCommonPatterns(t *testing.T) {
tests := []struct {
name string
pattern *regexp.Regexp
valid []string
invalid []string
}{
{
name: "AlphanumericDashUnderscore",
pattern: AlphanumericDashUnderscore,
valid: []string{"hello", "hello-world", "hello_world", "HelloWorld123", "a", "A1"},
invalid: []string{"hello world", "hello.world", "@test", ""},
},
{
name: "AlphanumericDashUnderscoreDot",
pattern: AlphanumericDashUnderscoreDot,
valid: []string{"hello", "hello.world", "config.yaml", "test-file_v1.2"},
invalid: []string{"hello world", "@test", "path/to/file"},
},
{
name: "Email",
pattern: Email,
valid: []string{"test@example.com", "user.name@domain.org", "a@b.co"},
invalid: []string{"@example.com", "test@", "test@.com", "test"},
},
{
name: "UUID",
pattern: UUID,
valid: []string{"550e8400-e29b-41d4-a716-446655440000", "ABCD1234-EF56-7890-ABCD-EF1234567890"},
invalid: []string{"550e8400", "not-a-uuid", "550e8400-e29b-41d4-a716-44665544000"},
},
{
name: "Slug",
pattern: Slug,
valid: []string{"hello", "hello-world", "my-blog-post", "a1b2"},
invalid: []string{"Hello", "hello_world", "hello--world", "-hello", "hello-"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
for _, v := range tt.valid {
if !tt.pattern.MatchString(v) {
t.Errorf("%s: expected %q to match", tt.name, v)
}
}
for _, v := range tt.invalid {
if tt.pattern.MatchString(v) {
t.Errorf("%s: expected %q to not match", tt.name, v)
}
}
})
}
}
func TestName(t *testing.T) {
tests := []struct {
name string
value string
wantErr bool
}{
{"valid", "my-config", false},
{"valid with underscore", "my_config", false},
{"valid alphanumeric", "config123", false},
{"empty", "", true},
{"too long", "this-name-is-way-too-long-and-exceeds-the-sixty-four-character-limit-allowed", true},
{"invalid chars", "my config", true},
{"dots not allowed", "my.config", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := Name(tt.value, "name")
if (err != nil) != tt.wantErr {
t.Errorf("Name(%q) error = %v, wantErr %v", tt.value, err, tt.wantErr)
}
})
}
}
func TestIsValidationError(t *testing.T) {
tests := []struct {
name string
err error
expected bool
}{
{"nil", nil, false},
{"ValidationError", ValidationError{Field: "f", Message: "m"}, true},
{"ValidationErrors", ValidationErrors{{Field: "f", Message: "m"}}, true},
{"other error", errOther, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := IsValidationError(tt.err); got != tt.expected {
t.Errorf("IsValidationError(%v) = %v, want %v", tt.err, got, tt.expected)
}
})
}
}
// Custom error type for testing
type otherError struct{}
func (e otherError) Error() string { return "other error" }
var errOther error = otherError{}
func TestAsValidationErrors(t *testing.T) {
tests := []struct {
name string
err error
expectNil bool
expectCount int
}{
{"nil", nil, true, 0},
{"ValidationError", ValidationError{Field: "f", Message: "m"}, false, 1},
{"ValidationErrors", ValidationErrors{{Field: "f1", Message: "m1"}, {Field: "f2", Message: "m2"}}, false, 2},
{"other error", errOther, true, 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := AsValidationErrors(tt.err)
if tt.expectNil {
if got != nil {
t.Errorf("expected nil, got %v", got)
}
} else {
if got == nil {
t.Error("expected non-nil ValidationErrors")
} else if len(got) != tt.expectCount {
t.Errorf("expected %d errors, got %d", tt.expectCount, len(got))
}
}
})
}
}
// --- Integration-style tests ---
func TestValidator_RealWorldUsage(t *testing.T) {
// Simulating the CreateKeyRequest validation from keys.go
type CreateKeyRequest struct {
Name string
Scopes []string
}
validate := func(req CreateKeyRequest) error {
v := New()
v.Required(req.Name, "name")
v.RequiredSlice(req.Scopes, "scopes")
return v.Error()
}
// Valid request
if err := validate(CreateKeyRequest{Name: "my-key", Scopes: []string{"read"}}); err != nil {
t.Errorf("expected no error for valid request, got %v", err)
}
// Missing name
if err := validate(CreateKeyRequest{Name: "", Scopes: []string{"read"}}); err == nil {
t.Error("expected error for missing name")
}
// Missing scopes
if err := validate(CreateKeyRequest{Name: "my-key", Scopes: []string{}}); err == nil {
t.Error("expected error for missing scopes")
}
// Both missing
err := validate(CreateKeyRequest{Name: "", Scopes: []string{}})
if err == nil {
t.Error("expected error for missing name and scopes")
}
verrs := AsValidationErrors(err)
if len(verrs) != 2 {
t.Errorf("expected 2 validation errors, got %d", len(verrs))
}
}
func TestValidator_ConfigItemValidation(t *testing.T) {
// Simulating the ConfigItemRequest validation from claude_config.go
type ConfigItemRequest struct {
Name string
Content string
}
validate := func(req ConfigItemRequest) error {
v := New()
v.Required(req.Name, "name")
v.Required(req.Content, "content")
v.StringLength(req.Name, "name", 1, 64)
v.Pattern(req.Name, "name", AlphanumericDashUnderscore, "must be alphanumeric with dashes or underscores")
return v.Error()
}
// Valid request
if err := validate(ConfigItemRequest{Name: "my-skill", Content: "# Skill content"}); err != nil {
t.Errorf("expected no error for valid request, got %v", err)
}
// Invalid name pattern
err := validate(ConfigItemRequest{Name: "my skill", Content: "content"})
if err == nil {
t.Error("expected error for invalid name pattern")
}
// Name too long
longName := "this-name-is-way-too-long-and-exceeds-the-sixty-four-character-limit"
err = validate(ConfigItemRequest{Name: longName, Content: "content"})
if err == nil {
t.Error("expected error for name too long")
}
}