diff --git a/internal/api/http_metrics_test.go b/internal/api/http_metrics_test.go new file mode 100644 index 0000000..7322065 --- /dev/null +++ b/internal/api/http_metrics_test.go @@ -0,0 +1,233 @@ +package api + +import "testing" + +func TestClassifyStatus(t *testing.T) { + tests := []struct { + name string + status int + want string + }{ + // Server errors (5xx) + {"500 internal server error", 500, "server_error"}, + {"501 not implemented", 501, "server_error"}, + {"502 bad gateway", 502, "server_error"}, + {"503 service unavailable", 503, "server_error"}, + {"599 edge case", 599, "server_error"}, + + // Client errors (4xx) + {"400 bad request", 400, "client_error"}, + {"401 unauthorized", 401, "client_error"}, + {"403 forbidden", 403, "client_error"}, + {"404 not found", 404, "client_error"}, + {"429 too many requests", 429, "client_error"}, + {"499 edge case", 499, "client_error"}, + + // Success (2xx) + {"200 OK", 200, "none"}, + {"201 created", 201, "none"}, + {"204 no content", 204, "none"}, + + // Redirects (3xx) + {"301 moved permanently", 301, "none"}, + {"302 found", 302, "none"}, + {"304 not modified", 304, "none"}, + + // Informational (1xx) + {"100 continue", 100, "none"}, + {"101 switching protocols", 101, "none"}, + + // Edge cases + {"0 zero status", 0, "none"}, + {"399 boundary below client error", 399, "none"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := classifyStatus(tt.status) + if got != tt.want { + t.Errorf("classifyStatus(%d) = %q, want %q", tt.status, got, tt.want) + } + }) + } +} + +func TestIsNumeric(t *testing.T) { + tests := []struct { + name string + input string + want bool + }{ + // Valid numeric strings + {"single digit", "0", true}, + {"single digit 9", "9", true}, + {"multiple digits", "123", true}, + {"large number", "9876543210", true}, + {"leading zeros", "007", true}, + + // Invalid strings + {"empty string", "", false}, + {"letter", "a", false}, + {"mixed alphanumeric", "123abc", false}, + {"space", " ", false}, + {"number with space", "12 34", false}, + {"negative number", "-123", false}, + {"decimal number", "12.34", false}, + {"hexadecimal prefix", "0x10", false}, + {"special characters", "12@34", false}, + {"unicode digits", "123", false}, // fullwidth digits + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := isNumeric(tt.input) + if got != tt.want { + t.Errorf("isNumeric(%q) = %v, want %v", tt.input, got, tt.want) + } + }) + } +} + +func TestLooksLikeUUID(t *testing.T) { + tests := []struct { + name string + input string + want bool + }{ + // Valid UUIDs + {"lowercase uuid", "550e8400-e29b-41d4-a716-446655440000", true}, + {"uppercase uuid", "550E8400-E29B-41D4-A716-446655440000", true}, + {"mixed case uuid", "550e8400-E29B-41d4-A716-446655440000", true}, + {"nil uuid", "00000000-0000-0000-0000-000000000000", true}, + {"max uuid", "ffffffff-ffff-ffff-ffff-ffffffffffff", true}, + + // Invalid UUIDs - wrong length + {"empty string", "", false}, + {"too short", "550e8400-e29b-41d4-a716", false}, + {"too long", "550e8400-e29b-41d4-a716-4466554400001", false}, + {"35 chars", "550e8400-e29b-41d4-a716-44665544000", false}, + {"37 chars", "550e8400-e29b-41d4-a716-4466554400001", false}, + + // Invalid UUIDs - wrong dash positions + {"dash at wrong position 0", "-50e8400-e29b-41d4-a716-44665544000", false}, + {"dash at wrong position 7", "550e840-0e29b-41d4-a716-446655440000", false}, + {"no dashes", "550e8400e29b41d4a716446655440000xxxx", false}, + {"extra dash", "550e8400-e29b-41d4-a716-4466-5544000", false}, + + // Invalid UUIDs - invalid characters + {"letter g", "g50e8400-e29b-41d4-a716-446655440000", false}, + {"letter z", "550e8400-z29b-41d4-a716-446655440000", false}, + {"space in uuid", "550e8400 e29b-41d4-a716-446655440000", false}, + {"underscore", "550e8400_e29b-41d4-a716-446655440000", false}, + {"special char", "550e8400-e29b-41d4-a716-44665544000!", false}, + + // Edge cases + {"all zeros no dashes wrong length", "00000000000000000000000000000000xxxx", false}, + {"uuid with braces", "{550e8400-e29b-41d4-a716-446655440000}", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := looksLikeUUID(tt.input) + if got != tt.want { + t.Errorf("looksLikeUUID(%q) = %v, want %v", tt.input, got, tt.want) + } + }) + } +} + +func TestNormalizeSegment(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + // Numeric segments -> :id + {"numeric id", "123", ":id"}, + {"single digit", "5", ":id"}, + {"large number", "9999999999", ":id"}, + + // UUID segments -> :uuid + {"uuid", "550e8400-e29b-41d4-a716-446655440000", ":uuid"}, + {"uppercase uuid", "550E8400-E29B-41D4-A716-446655440000", ":uuid"}, + + // Long tokens -> :token + {"33 char string", "abcdefghijklmnopqrstuvwxyz1234567", ":token"}, + {"64 char string", "abcdefghijklmnopqrstuvwxyz1234567890abcdefghijklmnopqrstuvwxyz12", ":token"}, + + // Regular segments preserved + {"api", "api", "api"}, + {"v1", "v1", "v1"}, + {"users", "users", "users"}, + {"settings", "settings", "settings"}, + {"exactly 32 chars", "abcdefghijklmnopqrstuvwxyz123456", "abcdefghijklmnopqrstuvwxyz123456"}, + + // Edge cases + {"empty string", "", ""}, + {"single letter", "a", "a"}, + {"mixed alphanumeric short", "user123", "user123"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := normalizeSegment(tt.input) + if got != tt.want { + t.Errorf("normalizeSegment(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestNormalizeRoute(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + // Root and empty paths + {"empty string", "", "/"}, + {"root path", "/", "/"}, + {"root with trailing slash", "//", "/"}, + + // Simple paths + {"api path", "/api", "/api"}, + {"two segments", "/api/v1", "/api/v1"}, + {"three segments", "/api/v1/users", "/api/v1/users"}, + + // Paths with numeric IDs + {"path with numeric id", "/api/users/123", "/api/users/:id"}, + {"path with multiple ids", "/api/users/123/posts/456", "/api/users/:id/posts/:id"}, + + // Paths with UUIDs + {"path with uuid", "/api/users/550e8400-e29b-41d4-a716-446655440000", "/api/users/:uuid"}, + + // Paths with long tokens + {"path with long token", "/api/auth/abcdefghijklmnopqrstuvwxyz1234567", "/api/auth/:token"}, + + // Query parameters stripped + {"path with query params", "/api/users?page=1&limit=10", "/api/users"}, + {"path with id and query", "/api/users/123?include=posts", "/api/users/:id"}, + + // Segment limit (max 5) + {"exactly 5 segments", "/a/b/c/d/e", "/a/b/c/d/e"}, + {"6 segments truncated", "/a/b/c/d/e/f", "/a/b/c/d/e"}, + {"7 segments truncated", "/a/b/c/d/e/f/g", "/a/b/c/d/e"}, + + // Mixed content + {"complex path", "/api/v1/users/123/posts/550e8400-e29b-41d4-a716-446655440000", "/api/v1/users/:id/posts"}, + + // Edge cases + {"double slashes", "/api//users", "/api/users"}, + {"trailing slash", "/api/users/", "/api/users"}, + {"leading double slash", "//api/users", "/api/users"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := normalizeRoute(tt.input) + if got != tt.want { + t.Errorf("normalizeRoute(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +}