diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 213569d..aae3d9b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -6,25 +6,19 @@ name: Tests # - Minimal dependencies (core routing: stdlib only, middleware: jwt + x/time) # - GOEXPERIMENT=jsonv2 required for encoding/json/v2 support # -# Branch Strategy (Git Flow): -# - feature/** branches: Development work -# - release/** branches: Pre-release testing (test here BEFORE merging to main) -# - develop branch: Integration branch +# Branch Strategy (GitHub Flow): +# - feature/*, release/*, hotfix/* branches: Development work # - main branch: Production-ready code only (protected) # - Pull requests: Must pass all tests before merge +# - Push to main triggers tests as final validation after merge on: push: branches: - main - - develop - - 'feature/**' - - 'release/**' - - 'hotfix/**' pull_request: branches: - main - - develop jobs: # Unit tests - Cross-platform diff --git a/CHANGELOG.md b/CHANGELOG.md index e72e651..4f36c71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Planned - Future features and enhancements (Phase 4: Ecosystem) +## [0.3.4] - 2026-03-05 + +### Changed +- **Test Coverage**: Comprehensive test coverage improvements for awesome-go submission + - Total coverage: 90.1% → **93.8%** (+3.7%) + - Core (`fursy`): 93.1% → **94.3%** (+1.2%) + - Binding (`internal/binding`): 64.4% → **97.7%** (+33.3%) + - Middleware: 91.7% → **95.6%** (+3.9%) + +### Added +- **Binding Tests**: Full coverage for `setField` (all Go types: int8-64, uint8-64, float32/64, bool, string), `mapForm` edge cases (non-pointer, non-struct, unexported fields, missing values), XML/multipart binder error paths +- **Box Tests**: Tests for `Unauthorized()` and `Forbidden()` response methods +- **Generic Router Tests**: Tests for `HEAD[Req,Res]()` and `OPTIONS[Req,Res]()` (new file `router_generic_test.go`) +- **OpenAPI Tests**: Tests for `WriteJSON()` (Content-Type, body validation) and `WriteYAML()` (not-implemented error) +- **CircuitBreaker Tests**: Tests for `CircuitBreaker()` default constructor, `CircuitBreakerWithName()`, `GetState()`, `GetCounts()`, `Reset()`, `FormatState()` +- **Middleware Tests**: Tests for `Logger()` and `Recovery()` default constructors + +### Fixed +- **README.md**: Fixed 5 critical API mismatches between documentation and actual code + - `JWT()` signature: was showing config struct, corrected to `JWT(signingKey)` / `JWTWithConfig(config)` + - `RateLimit()` signature: was showing config struct, corrected to `RateLimit(rate, burst)` / `RateLimitWithConfig(config)` + - `CircuitBreaker()` signature: removed non-existent fields (`ResetTimeout`, `FailureRatio`), added helper functions + - `router.Run()`: replaced with `http.ListenAndServe()` (Run method does not exist) + - Package godoc: fixed `*fursy.Box` without type params → `*fursy.Context` + +### Testing +- All tests passing, 0 linter issues (golangci-lint clean) +- No performance regressions + ## [0.3.3] - 2025-11-24 ### Fixed @@ -514,4 +543,10 @@ See [ROADMAP.md](ROADMAP.md) for detailed plans and timelines. --- +[0.3.4]: https://github.com/coregx/fursy/releases/tag/v0.3.4 +[0.3.3]: https://github.com/coregx/fursy/releases/tag/v0.3.3 +[0.3.2]: https://github.com/coregx/fursy/releases/tag/v0.3.2 +[0.3.1]: https://github.com/coregx/fursy/releases/tag/v0.3.1 +[0.3.0]: https://github.com/coregx/fursy/releases/tag/v0.3.0 +[0.2.0]: https://github.com/coregx/fursy/releases/tag/v0.2.0 [0.1.0]: https://github.com/coregx/fursy/releases/tag/v0.1.0 diff --git a/README.md b/README.md index a5bfa95..c29462e 100644 --- a/README.md +++ b/README.md @@ -322,20 +322,22 @@ router.Use(middleware.SecureWithConfig(middleware.SecureConfig{ JWT token validation with algorithm confusion prevention. ```go -import "github.com/golang-jwt/jwt/v5" +// Simple usage with signing key +router.Use(middleware.JWT([]byte("your-secret-key"))) -router.Use(middleware.JWT(middleware.JWTConfig{ +// With full configuration +router.Use(middleware.JWTWithConfig(middleware.JWTConfig{ SigningKey: []byte("your-secret-key"), - SigningMethod: jwt.SigningMethodHS256, - TokenLookup: "header:Authorization", + SigningMethod: "HS256", + TokenLookup: "header:Authorization", })) -// With custom validation -router.Use(middleware.JWT(middleware.JWTConfig{ - SigningKey: []byte("secret"), - SigningMethod: jwt.SigningMethodHS256, - Issuer: "my-app", - Audience: []string{"api"}, +// With issuer and audience validation +router.Use(middleware.JWTWithConfig(middleware.JWTConfig{ + SigningKey: []byte("secret"), + SigningMethod: "HS256", + ValidateIssuer: "my-app", + ValidateAudience: "api", })) ``` @@ -356,20 +358,16 @@ router.Use(middleware.JWT(middleware.JWTConfig{ Token bucket rate limiting with RFC-compliant headers. ```go -router.Use(middleware.RateLimit(middleware.RateLimitConfig{ - Rate: 100, // 100 requests per second - Burst: 200, // burst of 200 - KeyFunc: middleware.RateLimitByIP, -})) +// Simple usage (100 req/s, burst of 200) +router.Use(middleware.RateLimit(100, 200)) -// Custom key function -router.Use(middleware.RateLimit(middleware.RateLimitConfig{ +// With full configuration and custom key function +router.Use(middleware.RateLimitWithConfig(middleware.RateLimitConfig{ Rate: 10, Burst: 20, KeyFunc: func(c *fursy.Context) string { // Rate limit by user ID - userID := c.Get("user_id").(string) - return userID + return c.GetString("user_id") }, })) ``` @@ -393,19 +391,20 @@ router.Use(middleware.RateLimit(middleware.RateLimitConfig{ Zero-dependency circuit breaker for fault tolerance. ```go -router.Use(middleware.CircuitBreaker(middleware.CircuitBreakerConfig{ - MaxRequests: 100, +// Simple usage with defaults (5 consecutive failures, 60s timeout) +router.Use(middleware.CircuitBreaker()) + +// With consecutive failures threshold +router.Use(middleware.CircuitBreakerConsecutive(5, 30*time.Second)) + +// With ratio-based threshold (3 failures out of 10 requests) +router.Use(middleware.CircuitBreakerRatio(3, 10, 30*time.Second)) + +// With full configuration +router.Use(middleware.CircuitBreakerWithConfig(middleware.CircuitBreakerConfig{ ConsecutiveFailures: 5, Timeout: 30 * time.Second, - ResetTimeout: 60 * time.Second, -})) - -// With ratio-based threshold -router.Use(middleware.CircuitBreaker(middleware.CircuitBreakerConfig{ - MaxRequests: 1000, - FailureRatio: 0.25, // Open circuit when 25% of requests fail - Timeout: 30 * time.Second, - ResetTimeout: 60 * time.Second, + MaxRequests: 2, // Allow 2 requests in half-open state })) ``` @@ -419,7 +418,7 @@ router.Use(middleware.CircuitBreaker(middleware.CircuitBreakerConfig{ - ✅ Custom error handler - ✅ Thread-safe (concurrent request handling) -**Coverage**: 95.5% +**Coverage**: 95.6% **Dependencies**: Zero (stdlib only) --- @@ -436,7 +435,7 @@ router.Use(middleware.CircuitBreaker(middleware.CircuitBreakerConfig{ | **Rate Limit** | ✅ Built-in (RFC headers) | 🔧 Plugin | 🔧 Plugin | ✅ Built-in | | **Security Headers** | ✅ OWASP 2025 | ❌ | 🔧 Plugin | ✅ Basic | | **Circuit Breaker** | ✅ Zero deps | ❌ | ❌ | ❌ | -| **Test Coverage** | **93.1%** | ? | ? | ? | +| **Test Coverage** | **93.8%** | ? | ? | ? | | **Dependencies** | **Core: 0, JWT: 1, RateLimit: 1** | Multiple | Multiple | Multiple | **Legend**: @@ -748,7 +747,7 @@ func main() { return c.Created("/users/"+user.ID, user) }) - router.Run(":8080") + log.Fatal(http.ListenAndServe(":8080", router)) } ``` @@ -1149,11 +1148,11 @@ Your fursy application will automatically send traces to Jaeger. No configuratio ## 📈 Status -**Current Version**: v0.3.0 (Production Ready) +**Current Version**: v0.3.3 (Production Ready) **Status**: Production Ready - Complete ecosystem with real-time, database, and production examples -**Coverage**: 93.1% test coverage (core), 650+ tests total +**Coverage**: 93.8% test coverage (total), 94.3% core, 97.7% binding, 95.6% middleware **Performance**: 256 ns/op (static), 326 ns/op (parametric), 1 alloc/op @@ -1171,7 +1170,7 @@ Production Validation 2 Plugins (NOT Rushing!) Features OpenAPI DDD Boilerplate ``` -**Current Status**: v0.3.0 Production Ready ✅ +**Current Status**: v0.3.3 Production Ready ✅ **Ecosystem**: stream v0.1.0 (SSE + WebSocket), 2 production plugins, 10 examples **Next**: v0.x.x feature releases as needed (Cache, more plugins, community tools) **v1.0.0 LTS**: After 6-12 months of production usage and full API stabilization @@ -1251,7 +1250,7 @@ FURSY stands on the shoulders of giants: *Built with ❤️ by the coregx team* -**Version**: v0.3.0 - Production Ready +**Version**: v0.3.3 - Production Ready **Ecosystem**: stream v0.1.0 + 2 plugins + 10 examples + DDD boilerplate **Next**: v1.0.0 LTS (after full API stabilization) diff --git a/ROADMAP.md b/ROADMAP.md index bd69aeb..32322ae 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -3,7 +3,7 @@ > **Strategic Advantage**: Modern Go 1.25+ features + proven routing patterns! > **Approach**: Combine best practices from httprouter, Gin, Echo with type-safe generics -**Last Updated**: 2025-11-24 | **Current Version**: v0.3.3 (Production Ready + Real-Time + Database + Hotfix) | **Phase**: Phase 4 In Progress (Ecosystem) | **Target**: v1.0.0 LTS (TBD, after full API stabilization) +**Last Updated**: 2026-03-05 | **Current Version**: v0.3.4 (Production Ready + 93.8% Coverage) | **Phase**: Phase 4 In Progress (Ecosystem + Awesome-Go Submission) | **Target**: v1.0.0 LTS (TBD, after full API stabilization) --- @@ -127,11 +127,11 @@ v1.0.0 LTS → Long-term support (Q3 2026) --- -## 📊 Current Status (v0.2.0 - Production Ready + Documentation & Examples) +## 📊 Current Status (v0.3.4 - Production Ready + Awesome-Go Ready) -**Phase**: 🚀 Phase 4 In Progress (Ecosystem Building) +**Phase**: 🚀 Phase 4 In Progress (Ecosystem Building + Awesome-Go Submission) **Performance**: Production-ready! (256-326 ns/op, 1 alloc/op) -**Coverage**: 91.7% overall, 94.3% validator plugin +**Coverage**: 93.8% total (94.3% core, 97.7% binding, 95.6% middleware) **What Works**: - ✅ **Radix tree routing** (zero-allocation lookup, <500ns parametric routes) @@ -153,7 +153,7 @@ v1.0.0 LTS → Long-term support (Q3 2026) - ✅ Memory efficient: context pooling prevents leaks **Validation**: -- ✅ 91.7% test coverage (exceeded Phase 2 target of 88%) +- ✅ 93.8% test coverage (exceeded all phase targets) - ✅ 0 linter issues (34+ linters via golangci-lint) - ✅ Race detector clean - ✅ Cross-platform (Linux, macOS, Windows) diff --git a/box_test.go b/box_test.go index 5cb99c6..725aeb2 100644 --- a/box_test.go +++ b/box_test.go @@ -455,3 +455,47 @@ func TestBox_ConvenienceMethods_RESTWorkflow(t *testing.T) { t.Errorf("DELETE: expected status 204, got %d", w.Code) } } + +// TestBox_Unauthorized tests the Unauthorized convenience method. +func TestBox_Unauthorized(t *testing.T) { + r := New() + + GET[Empty, TestResponse](r, "/protected", func(c *Box[Empty, TestResponse]) error { + return c.Unauthorized(TestResponse{Message: "Invalid credentials"}) + }) + + req := httptest.NewRequest(http.MethodGet, "/protected", http.NoBody) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("expected status 401, got %d", w.Code) + } + + expectedBody := `{"id":0,"message":"Invalid credentials"}` + "\n" + if w.Body.String() != expectedBody { + t.Errorf("expected body %q, got %q", expectedBody, w.Body.String()) + } +} + +// TestBox_Forbidden tests the Forbidden convenience method. +func TestBox_Forbidden(t *testing.T) { + r := New() + + GET[Empty, TestResponse](r, "/admin", func(c *Box[Empty, TestResponse]) error { + return c.Forbidden(TestResponse{Message: "Insufficient permissions"}) + }) + + req := httptest.NewRequest(http.MethodGet, "/admin", http.NoBody) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusForbidden { + t.Errorf("expected status 403, got %d", w.Code) + } + + expectedBody := `{"id":0,"message":"Insufficient permissions"}` + "\n" + if w.Body.String() != expectedBody { + t.Errorf("expected body %q, got %q", expectedBody, w.Body.String()) + } +} diff --git a/internal/binding/binding_test.go b/internal/binding/binding_test.go index 7d7259e..2d787a4 100644 --- a/internal/binding/binding_test.go +++ b/internal/binding/binding_test.go @@ -212,6 +212,301 @@ func TestGetBinder(t *testing.T) { } } +// TestXMLBinder_InvalidXML tests XML binding with invalid XML. +func TestXMLBinder_InvalidXML(t *testing.T) { + body := ` 3.15 { + t.Errorf("Float32 = %f, want ~3.14", result.Float32) + } + if result.Float64 < 2.71 || result.Float64 > 2.72 { + t.Errorf("Float64 = %f, want ~2.718", result.Float64) + } + if !result.Bool { + t.Error("Bool = false, want true") + } + if result.Ignored != "" { + t.Errorf("Ignored = %q, want empty (tag is -)", result.Ignored) + } + if result.NoTag != "no tag value" { + t.Errorf("NoTag = %q, want %q", result.NoTag, "no tag value") + } +} + +// TestSetField_InvalidValues tests setField error handling for invalid type conversions. +func TestSetField_InvalidValues(t *testing.T) { + tests := []struct { + name string + form url.Values + field string + }{ + { + name: "invalid int", + form: url.Values{"int": {"not_a_number"}}, + field: "Int", + }, + { + name: "invalid uint", + form: url.Values{"uint": {"not_a_number"}}, + field: "Uint", + }, + { + name: "invalid float", + form: url.Values{"float64": {"not_a_float"}}, + field: "Float64", + }, + { + name: "invalid bool", + form: url.Values{"bool": {"not_a_bool"}}, + field: "Bool", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/test", strings.NewReader(tt.form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + var result BindAllTypesStruct + err := formBinding.Bind(req, &result) + if err == nil { + t.Error("expected error for invalid value, got nil") + } + }) + } +} + +// TestSetField_UnsupportedType tests setField with an unsupported field type. +func TestSetField_UnsupportedType(t *testing.T) { + type withComplex struct { + Data complex128 `form:"data"` + } + + form := url.Values{"data": {"1+2i"}} + var result withComplex + err := mapForm(&result, form) + if err == nil { + t.Error("expected error for unsupported field type, got nil") + } +} + +// TestMapForm_NonPointer tests mapForm with non-pointer argument. +func TestMapForm_NonPointer(t *testing.T) { + form := url.Values{"name": {"test"}} + var s BindTestStruct + err := mapForm(s, form) + if err == nil { + t.Error("expected error for non-pointer, got nil") + } +} + +// TestMapForm_NonStruct tests mapForm with pointer to non-struct. +func TestMapForm_NonStruct(t *testing.T) { + form := url.Values{"name": {"test"}} + s := "not a struct" + err := mapForm(&s, form) + if err == nil { + t.Error("expected error for non-struct pointer, got nil") + } +} + +// TestMapForm_MissingFormValues tests mapForm when form values are missing. +func TestMapForm_MissingFormValues(t *testing.T) { + form := url.Values{} // No values + var result BindTestStruct + err := mapForm(&result, form) + if err != nil { + t.Errorf("expected nil error for missing values, got %v", err) + } + // All fields should have zero values. + if result.Name != "" { + t.Errorf("Name = %q, want empty", result.Name) + } + if result.Age != 0 { + t.Errorf("Age = %d, want 0", result.Age) + } +} + +// TestMapForm_UnexportedFields tests that unexported fields are skipped. +func TestMapForm_UnexportedFields(t *testing.T) { + type withUnexported struct { + Name string `form:"name"` + hidden string `form:"hidden"` //nolint:unused // testing unexported field behavior + } + + form := url.Values{ + "name": {"visible"}, + "hidden": {"should be ignored"}, + } + var result withUnexported + err := mapForm(&result, form) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if result.Name != "visible" { + t.Errorf("Name = %q, want %q", result.Name, "visible") + } +} + +// TestFormBinder_InvalidForm tests form binding with invalid request body. +func TestFormBinder_InvalidForm(_ *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/test", strings.NewReader("%invalid")) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + // Force ContentLength to indicate there is a body. + var result BindTestStruct + err := formBinding.Bind(req, &result) + // ParseForm may or may not error on partially invalid URL-encoded data. + // The important thing is it doesn't panic. + _ = err +} + // TestBind_Integration tests the Bind convenience function. func TestBind_Integration(t *testing.T) { tests := []struct { diff --git a/middleware/circuitbreaker_test.go b/middleware/circuitbreaker_test.go index 7c9d9f8..c5bec62 100644 --- a/middleware/circuitbreaker_test.go +++ b/middleware/circuitbreaker_test.go @@ -582,6 +582,224 @@ func TestCircuitBreaker_StateString(t *testing.T) { } } +// TestCircuitBreaker_DefaultConstructor tests that CircuitBreaker() uses sensible defaults. +func TestCircuitBreaker_DefaultConstructor(t *testing.T) { + router := fursy.New() + router.Use(CircuitBreaker()) + + var failCount int32 + + router.GET("/test", func(c *fursy.Context) error { + count := atomic.AddInt32(&failCount, 1) + if count <= 5 { + return errors.New("simulated failure") + } + return c.String(http.StatusOK, "OK") + }) + + // Default threshold is 5 consecutive failures. + for i := 0; i < 5; i++ { + req := httptest.NewRequest(http.MethodGet, "/test", http.NoBody) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + } + + // 6th request should be blocked (circuit open after 5 consecutive failures). + req := httptest.NewRequest(http.MethodGet, "/test", http.NoBody) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusServiceUnavailable { + t.Errorf("expected circuit open (503) with default constructor, got %d", rec.Code) + } +} + +// TestCircuitBreakerWithName_SetsName tests CircuitBreakerWithName sets the name correctly. +func TestCircuitBreakerWithName_SetsName(t *testing.T) { + router := fursy.New() + + // CircuitBreakerWithName must produce working middleware (named "payments"). + router.Use(CircuitBreakerWithName("payments", CircuitBreakerConfig{ + ConsecutiveFailures: 2, + Timeout: 1 * time.Second, + })) + + router.GET("/pay", func(_ *fursy.Context) error { + return errors.New("payment gateway down") + }) + + // 2 failures → open. + for i := 0; i < 2; i++ { + req := httptest.NewRequest(http.MethodGet, "/pay", http.NoBody) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + } + + // 3rd request should be blocked. + req := httptest.NewRequest(http.MethodGet, "/pay", http.NoBody) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusServiceUnavailable { + t.Errorf("CircuitBreakerWithName: expected 503, got %d", rec.Code) + } +} + +// TestCircuitBreaker_GetState tests GetState returns the current state. +func TestCircuitBreaker_GetState(t *testing.T) { + cb := &circuitBreaker{ + state: StateClosed, + counts: Counts{}, + } + + t.Run("initial state is closed", func(t *testing.T) { + if got := cb.GetState(); got != StateClosed { + t.Errorf("expected StateClosed, got %s", got) + } + }) + + t.Run("open state", func(t *testing.T) { + cb.mu.Lock() + cb.state = StateOpen + cb.mu.Unlock() + + if got := cb.GetState(); got != StateOpen { + t.Errorf("expected StateOpen, got %s", got) + } + }) + + t.Run("half-open state", func(t *testing.T) { + cb.mu.Lock() + cb.state = StateHalfOpen + cb.mu.Unlock() + + if got := cb.GetState(); got != StateHalfOpen { + t.Errorf("expected StateHalfOpen, got %s", got) + } + }) +} + +// TestCircuitBreaker_GetCounts tests GetCounts returns a copy of current counts. +func TestCircuitBreaker_GetCounts(t *testing.T) { + cb := &circuitBreaker{ + state: StateClosed, + counts: Counts{ + Requests: 10, + TotalSuccesses: 7, + TotalFailures: 3, + ConsecutiveSuccesses: 2, + ConsecutiveFailures: 1, + }, + } + + counts := cb.GetCounts() + + if counts.Requests != 10 { + t.Errorf("expected Requests 10, got %d", counts.Requests) + } + + if counts.TotalSuccesses != 7 { + t.Errorf("expected TotalSuccesses 7, got %d", counts.TotalSuccesses) + } + + if counts.TotalFailures != 3 { + t.Errorf("expected TotalFailures 3, got %d", counts.TotalFailures) + } + + if counts.ConsecutiveSuccesses != 2 { + t.Errorf("expected ConsecutiveSuccesses 2, got %d", counts.ConsecutiveSuccesses) + } + + if counts.ConsecutiveFailures != 1 { + t.Errorf("expected ConsecutiveFailures 1, got %d", counts.ConsecutiveFailures) + } + + // GetCounts must return a copy — modifying it must not affect the internal state. + counts.Requests = 999 + if cb.GetCounts().Requests == 999 { + t.Error("GetCounts should return a copy, not a reference") + } +} + +// TestCircuitBreaker_Reset tests Reset returns the circuit breaker to closed state. +func TestCircuitBreaker_Reset(t *testing.T) { + cb := &circuitBreaker{ + state: StateOpen, + counts: Counts{ + Requests: 5, + TotalFailures: 5, + ConsecutiveFailures: 5, + }, + expiry: time.Now().Add(10 * time.Minute), + requests: []requestRecord{{timestamp: time.Now(), success: false}}, + } + + cb.Reset() + + if cb.GetState() != StateClosed { + t.Errorf("after Reset, expected StateClosed, got %s", cb.GetState()) + } + + counts := cb.GetCounts() + + if counts.Requests != 0 { + t.Errorf("after Reset, expected Requests 0, got %d", counts.Requests) + } + + if counts.TotalFailures != 0 { + t.Errorf("after Reset, expected TotalFailures 0, got %d", counts.TotalFailures) + } + + if counts.ConsecutiveFailures != 0 { + t.Errorf("after Reset, expected ConsecutiveFailures 0, got %d", counts.ConsecutiveFailures) + } + + cb.mu.RLock() + isZeroExpiry := cb.expiry.IsZero() + isNilRequests := cb.requests == nil + cb.mu.RUnlock() + + if !isZeroExpiry { + t.Error("after Reset, expiry should be zero time") + } + + if !isNilRequests { + t.Error("after Reset, requests slice should be nil") + } +} + +// TestCircuitBreaker_FormatState tests FormatState formats the state correctly. +func TestCircuitBreaker_FormatState(t *testing.T) { + cb := &circuitBreaker{ + state: StateClosed, + counts: Counts{ + Requests: 10, + TotalSuccesses: 8, + TotalFailures: 2, + ConsecutiveFailures: 0, + }, + } + + result := FormatState(cb) + + expected := "State: closed, Requests: 10, Successes: 8, Failures: 2, Consecutive Failures: 0" + if result != expected { + t.Errorf("FormatState = %q, want %q", result, expected) + } + + // Verify it reflects state changes. + cb.mu.Lock() + cb.state = StateOpen + cb.counts.ConsecutiveFailures = 5 + cb.mu.Unlock() + + result = FormatState(cb) + + if result[:12] != "State: open," { + t.Errorf("FormatState after state change should start with 'State: open,', got %q", result) + } +} + // contains checks if a string contains a substring. func contains(s, substr string) bool { return len(s) >= len(substr) && (s == substr || len(s) > len(substr) && containsSubstring(s, substr)) diff --git a/middleware/logger_test.go b/middleware/logger_test.go index c44fc5b..80439fd 100644 --- a/middleware/logger_test.go +++ b/middleware/logger_test.go @@ -465,6 +465,30 @@ func TestLogResponseWriter(t *testing.T) { }) } +// TestLogger_DefaultConstructor tests that Logger() wrapper uses default configuration. +func TestLogger_DefaultConstructor(t *testing.T) { + // Logger() is a thin wrapper over LoggerWithConfig(LoggerConfig{}). + // We verify it returns a working middleware by using it with a real router. + r := fursy.New() + r.Use(Logger()) + + r.GET("/health", func(c *fursy.Context) error { + return c.String(http.StatusOK, "OK") + }) + + req := httptest.NewRequest(http.MethodGet, "/health", http.NoBody) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected status 200, got %d", w.Code) + } + + if w.Body.String() != "OK" { + t.Errorf("expected body 'OK', got %q", w.Body.String()) + } +} + // TestLogger_IntegrationWithGroups tests logger with route groups. func TestLogger_IntegrationWithGroups(t *testing.T) { var buf bytes.Buffer diff --git a/middleware/recovery_test.go b/middleware/recovery_test.go index 95a3a8b..796574e 100644 --- a/middleware/recovery_test.go +++ b/middleware/recovery_test.go @@ -375,6 +375,44 @@ func TestRecovery_MiddlewareChain(t *testing.T) { } } +// TestRecovery_DefaultConstructor tests that Recovery() wrapper uses default configuration. +func TestRecovery_DefaultConstructor(t *testing.T) { + // Recovery() is a thin wrapper over RecoveryWithConfig(RecoveryConfig{}). + // We verify it returns working middleware that recovers from panics. + r := fursy.New() + r.Use(Recovery()) + + r.GET("/boom", func(_ *fursy.Context) error { + panic("default recovery test") + }) + + r.GET("/ok", func(c *fursy.Context) error { + return c.String(http.StatusOK, "fine") + }) + + // Panic request must be recovered and return 500. + req := httptest.NewRequest(http.MethodGet, "/boom", http.NoBody) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusInternalServerError { + t.Errorf("expected status 500, got %d", w.Code) + } + + if !strings.Contains(w.Body.String(), "Internal Server Error") { + t.Errorf("expected 'Internal Server Error' in body, got %q", w.Body.String()) + } + + // Normal request must pass through unaffected. + req2 := httptest.NewRequest(http.MethodGet, "/ok", http.NoBody) + w2 := httptest.NewRecorder() + r.ServeHTTP(w2, req2) + + if w2.Code != http.StatusOK { + t.Errorf("expected status 200, got %d", w2.Code) + } +} + // TestRecovery_CustomType tests panic with custom error type. func TestRecovery_CustomType(t *testing.T) { var buf bytes.Buffer diff --git a/openapi_test.go b/openapi_test.go index e344fe0..de52f42 100644 --- a/openapi_test.go +++ b/openapi_test.go @@ -598,6 +598,86 @@ func TestRouter_ServeOpenAPI(t *testing.T) { } } +func TestOpenAPI_WriteJSON(t *testing.T) { + router := New() + router.WithInfo(Info{ + Title: "Write JSON Test", + Version: "1.0.0", + }) + router.GET("/ping", func(_ *Context) error { + return nil + }) + + doc, err := router.GenerateOpenAPI(Info{}) + if err != nil { + t.Fatalf("GenerateOpenAPI failed: %v", err) + } + + w := httptest.NewRecorder() + if err := doc.WriteJSON(w); err != nil { + t.Fatalf("WriteJSON failed: %v", err) + } + + if w.Code != http.StatusOK { + t.Errorf("expected status 200, got %d", w.Code) + } + + contentType := w.Header().Get("Content-Type") + if contentType != "application/json; charset=utf-8" { + t.Errorf("expected Content-Type 'application/json; charset=utf-8', got %q", contentType) + } + + if w.Body.Len() == 0 { + t.Error("expected non-empty body") + } + + // Verify valid JSON with expected openapi version. + var result map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil { + t.Fatalf("body is not valid JSON: %v", err) + } + + if result["openapi"] != "3.1.0" { + t.Errorf("expected openapi '3.1.0', got %v", result["openapi"]) + } + + info, ok := result["info"].(map[string]any) + if !ok { + t.Fatal("expected info object in JSON") + } + + if info["title"] != "Write JSON Test" { + t.Errorf("expected title 'Write JSON Test', got %v", info["title"]) + } +} + +func TestOpenAPI_WriteYAML(t *testing.T) { + router := New() + router.GET("/test", func(_ *Context) error { + return nil + }) + + doc, err := router.GenerateOpenAPI(Info{ + Title: "YAML Test", + Version: "1.0.0", + }) + if err != nil { + t.Fatalf("GenerateOpenAPI failed: %v", err) + } + + w := httptest.NewRecorder() + err = doc.WriteYAML(w) + + // WriteYAML is not yet implemented and must return an error. + if err == nil { + t.Fatal("expected error from WriteYAML, got nil") + } + + if err.Error() != "YAML output not yet implemented (requires external dependency)" { + t.Errorf("unexpected error message: %q", err.Error()) + } +} + func TestRouter_ServeOpenAPI_DefaultInfo(t *testing.T) { router := New() diff --git a/problem.go b/problem.go index 323edfa..a94ef2b 100644 --- a/problem.go +++ b/problem.go @@ -231,7 +231,7 @@ func ValidationProblem(errs ValidationErrors) Problem { detail := fmt.Sprintf("%d field(s) failed validation", len(errs)) if len(errs) == 1 { - detail = errs[0].Message + detail = errs[0].Message //nolint:gosec // G602 false positive: len check above guarantees index 0 exists. } return Problem{ diff --git a/router.go b/router.go index 843fe5a..743bd57 100644 --- a/router.go +++ b/router.go @@ -7,7 +7,7 @@ // // router := fursy.New() // -// router.GET("/users/:id", func(c *fursy.Box) error { +// router.GET("/users/:id", func(c *fursy.Context) error { // id := c.Param("id") // return c.String(200, "User ID: "+id) // }) @@ -34,7 +34,7 @@ // // # URL Parameters // -// Extract parameters using Box methods: +// Extract parameters using Context methods: // // id := c.Param("id") // page := c.Query("page") @@ -69,7 +69,7 @@ import ( // Example: // // router := fursy.New() -// router.GET("/users/:id", func(c *fursy.Box) error { +// router.GET("/users/:id", func(c *fursy.Context) error { // id := c.Param("id") // return c.String(200, "User ID: "+id) // }) @@ -315,7 +315,7 @@ func (r *Router) Group(prefix string, middleware ...HandlerFunc) *RouteGroup { // // Example: // -// router.GET("/users", func(c *fursy.Box) error { +// router.GET("/users", func(c *fursy.Context) error { // return c.JSON(200, users) // }) func (r *Router) GET(path string, handler HandlerFunc) { @@ -326,7 +326,7 @@ func (r *Router) GET(path string, handler HandlerFunc) { // // Example: // -// router.POST("/users", func(c *fursy.Box) error { +// router.POST("/users", func(c *fursy.Context) error { // return c.JSON(201, newUser) // }) func (r *Router) POST(path string, handler HandlerFunc) { @@ -337,7 +337,7 @@ func (r *Router) POST(path string, handler HandlerFunc) { // // Example: // -// router.PUT("/users/:id", func(c *fursy.Box) error { +// router.PUT("/users/:id", func(c *fursy.Context) error { // id := c.Param("id") // return c.NoContent(204) // }) @@ -349,7 +349,7 @@ func (r *Router) PUT(path string, handler HandlerFunc) { // // Example: // -// router.DELETE("/users/:id", func(c *fursy.Box) error { +// router.DELETE("/users/:id", func(c *fursy.Context) error { // id := c.Param("id") // return c.NoContent(204) // }) @@ -361,7 +361,7 @@ func (r *Router) DELETE(path string, handler HandlerFunc) { // // Example: // -// router.PATCH("/users/:id", func(c *fursy.Box) error { +// router.PATCH("/users/:id", func(c *fursy.Context) error { // id := c.Param("id") // return c.JSON(200, updatedUser) // }) @@ -373,7 +373,7 @@ func (r *Router) PATCH(path string, handler HandlerFunc) { // // Example: // -// router.HEAD("/users/:id", func(c *fursy.Box) error { +// router.HEAD("/users/:id", func(c *fursy.Context) error { // return c.NoContent(200) // }) func (r *Router) HEAD(path string, handler HandlerFunc) { @@ -384,7 +384,7 @@ func (r *Router) HEAD(path string, handler HandlerFunc) { // // Example: // -// router.OPTIONS("/users", func(c *fursy.Box) error { +// router.OPTIONS("/users", func(c *fursy.Context) error { // c.SetHeader("Allow", "GET, POST, PUT, DELETE") // return c.NoContent(200) // }) @@ -404,7 +404,7 @@ func (r *Router) OPTIONS(path string, handler HandlerFunc) { // // Example: // -// router.Handle("GET", "/users/:id", func(c *fursy.Box) error { +// router.Handle("GET", "/users/:id", func(c *fursy.Context) error { // id := c.Param("id") // return c.String(200, "User ID: "+id) // }) diff --git a/router_generic_test.go b/router_generic_test.go new file mode 100644 index 0000000..1266cb8 --- /dev/null +++ b/router_generic_test.go @@ -0,0 +1,111 @@ +// Copyright 2025 coregx. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package fursy + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +// TestGenericHEAD tests the generic HEAD() function registration. +func TestGenericHEAD(t *testing.T) { + r := New() + + HEAD[Empty, Empty](r, "/resources/:id", func(c *Box[Empty, Empty]) error { + c.SetHeader("X-Resource-ID", c.Param("id")) + return c.NoContent(http.StatusOK) + }) + + req := httptest.NewRequest(http.MethodHead, "/resources/42", http.NoBody) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected status 200, got %d", w.Code) + } + + if w.Header().Get("X-Resource-ID") != "42" { + t.Errorf("expected X-Resource-ID header '42', got %q", w.Header().Get("X-Resource-ID")) + } + + // HEAD response must have empty body per HTTP spec. + if w.Body.Len() != 0 { + t.Errorf("HEAD response body must be empty, got %d bytes", w.Body.Len()) + } +} + +// TestGenericHEAD_NotFound tests HEAD returning 404 when resource is absent. +func TestGenericHEAD_NotFound(t *testing.T) { + r := New() + + HEAD[Empty, Empty](r, "/items/:id", func(c *Box[Empty, Empty]) error { + id := c.Param("id") + if id == "0" { + return c.NoContent(http.StatusNotFound) + } + return c.NoContent(http.StatusOK) + }) + + req := httptest.NewRequest(http.MethodHead, "/items/0", http.NoBody) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("expected status 404, got %d", w.Code) + } +} + +// TestGenericOPTIONS tests the generic OPTIONS() function registration. +func TestGenericOPTIONS(t *testing.T) { + r := New() + + OPTIONS[Empty, Empty](r, "/users", func(c *Box[Empty, Empty]) error { + c.SetHeader("Allow", "GET, POST, PUT, DELETE, OPTIONS") + c.SetHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE") + c.SetHeader("Access-Control-Allow-Origin", "*") + return c.NoContent(http.StatusOK) + }) + + req := httptest.NewRequest(http.MethodOptions, "/users", http.NoBody) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected status 200, got %d", w.Code) + } + + allow := w.Header().Get("Allow") + if allow != "GET, POST, PUT, DELETE, OPTIONS" { + t.Errorf("expected Allow header 'GET, POST, PUT, DELETE, OPTIONS', got %q", allow) + } + + acam := w.Header().Get("Access-Control-Allow-Methods") + if acam != "GET, POST, PUT, DELETE" { + t.Errorf("expected Access-Control-Allow-Methods 'GET, POST, PUT, DELETE', got %q", acam) + } +} + +// TestGenericOPTIONS_WithParam tests OPTIONS on a parameterized path. +func TestGenericOPTIONS_WithParam(t *testing.T) { + r := New() + + OPTIONS[Empty, Empty](r, "/users/:id", func(c *Box[Empty, Empty]) error { + c.SetHeader("Allow", "GET, PUT, DELETE, OPTIONS") + return c.NoContent(http.StatusNoContent) + }) + + req := httptest.NewRequest(http.MethodOptions, "/users/123", http.NoBody) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusNoContent { + t.Errorf("expected status 204, got %d", w.Code) + } + + if w.Header().Get("Allow") != "GET, PUT, DELETE, OPTIONS" { + t.Errorf("expected Allow header 'GET, PUT, DELETE, OPTIONS', got %q", w.Header().Get("Allow")) + } +}