commit a6b4e519fb525cfe2aafbc870bd8a95af35fd222 Author: Your Name Date: Fri May 1 08:47:04 2026 +0800 test: add router and health handler tests for Phase 2 coverage - TestRouter_HealthEndpoint: health/live/ready endpoints return 200 - TestRouter_UnknownPath: unknown paths return 404 - TestRouter_WebhookChannel_MissingChannel: empty channel returns 400 - TestRouter_WebhookPath_CanBeCalledWithGET: GET /webhook returns 405 - TestRouter_TicketsList_POST_Returns405: POST /tickets returns 405 - TestRouter_SessionsRoute_OnlyPOST: nil Sessions returns 404 - TestProbe defaults: IsLive=true, IsReady=false on NewProbe() - TestProbe_SetLive/SetReady: atomic load/store correctness Ref: PRODUCTION_PHASE1_STATUS.md §8.3 P1/P2 coverage gaps diff --git a/internal/http/router_test.go b/internal/http/router_test.go new file mode 100644 index 0000000..aa494cc --- /dev/null +++ b/internal/http/router_test.go @@ -0,0 +1,117 @@ +package httpserver + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/bridge/ai-customer-service/internal/http/handlers" + "github.com/bridge/ai-customer-service/internal/platform/health" +) + +func TestRouter_HealthEndpoint(t *testing.T) { + probe := health.NewProbe() + h := handlers.NewHealthHandler(probe) + router := NewRouter(RouterDeps{Health: h}) + + tests := []struct { + name string + path string + wantStatus int + }{ + {"health root returns 200", "/actuator/health", http.StatusOK}, + {"live returns 200", "/actuator/health/live", http.StatusOK}, + {"ready returns 200 when ready", "/actuator/health/ready", http.StatusOK}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, tc.path, nil) + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + if rr.Code != tc.wantStatus { + t.Errorf("GET %s = %d, want %d", tc.path, rr.Code, tc.wantStatus) + } + }) + } +} + +func TestRouter_UnknownPath_Returns404(t *testing.T) { + probe := health.NewProbe() + h := handlers.NewHealthHandler(probe) + router := NewRouter(RouterDeps{Health: h}) + + tests := []struct { + name string + path string + }{ + {"unknown root path", "/unknown"}, + {"unknown nested path", "/api/v1/unknown"}, + {"unknown deep path", "/api/v1/customer-service/unknown"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, tc.path, nil) + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + if rr.Code != http.StatusNotFound { + t.Errorf("GET %s = %d, want 404", tc.path, rr.Code) + } + }) + } +} + +func TestRouter_WebhookChannel_MissingChannel_Returns400(t *testing.T) { + probe := health.NewProbe() + h := handlers.NewHealthHandler(probe) + router := NewRouter(RouterDeps{Health: h}) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/customer-service/webhook/", nil) + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + if rr.Code != http.StatusBadRequest { + t.Errorf("GET /webhook/ = %d, want 400; body: %s", rr.Code, rr.Body.String()) + } +} + +func TestRouter_WebhookPath_CanBeCalledWithGET(t *testing.T) { + probe := health.NewProbe() + h := handlers.NewHealthHandler(probe) + router := NewRouter(RouterDeps{Health: h}) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/customer-service/webhook", nil) + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + if rr.Code != http.StatusMethodNotAllowed { + t.Errorf("GET /webhook = %d, want 405", rr.Code) + } +} + +func TestRouter_TicketsList_POST_Returns405(t *testing.T) { + probe := health.NewProbe() + h := handlers.NewHealthHandler(probe) + ticketHandler := &handlers.TicketHandler{} + router := NewRouter(RouterDeps{Health: h, Tickets: ticketHandler}) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/customer-service/tickets", nil) + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + if rr.Code != http.StatusMethodNotAllowed { + t.Errorf("POST /tickets = %d, want 405", rr.Code) + } +} + +func TestRouter_SessionsRoute_OnlyPOST(t *testing.T) { + probe := health.NewProbe() + h := handlers.NewHealthHandler(probe) + router := NewRouter(RouterDeps{Health: h, Sessions: nil}) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/customer-service/sessions/s1/feedback", nil) + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + // When Sessions is nil, route not registered → 404 + if rr.Code != http.StatusNotFound { + t.Errorf("GET /sessions/s1/feedback with nil Sessions = %d, want 404", rr.Code) + } +} \ No newline at end of file diff --git a/internal/platform/health/health_test.go b/internal/platform/health/health_test.go new file mode 100644 index 0000000..9defd44 --- /dev/null +++ b/internal/platform/health/health_test.go @@ -0,0 +1,63 @@ +package health + +import ( + "testing" +) + +func TestProbe_IsReady_DefaultsToFalse(t *testing.T) { + // NewProbe sets ready to false by default + probe := NewProbe() + if got := probe.IsReady(); got != false { + t.Errorf("IsReady() on new probe = %v, want false", got) + } +} + +func TestProbe_IsLive_DefaultsToTrue(t *testing.T) { + // NewProbe sets live to true by default + probe := NewProbe() + if got := probe.IsLive(); got != true { + t.Errorf("IsLive() on new probe = %v, want true", got) + } +} + +func TestProbe_SetLive_IsLive(t *testing.T) { + tests := []struct { + name string + setValue bool + want bool + }{ + {"SetLive(false) returns false", false, false}, + {"SetLive(true) returns true", true, true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + probe := NewProbe() + probe.SetLive(tc.setValue) + if got := probe.IsLive(); got != tc.want { + t.Errorf("IsLive() = %v, want %v", got, tc.want) + } + }) + } +} + +func TestProbe_SetReady_IsReady(t *testing.T) { + tests := []struct { + name string + setValue bool + want bool + }{ + {"SetReady(false) returns false", false, false}, + {"SetReady(true) returns true", true, true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + probe := NewProbe() + probe.SetReady(tc.setValue) + if got := probe.IsReady(); got != tc.want { + t.Errorf("IsReady() = %v, want %v", got, tc.want) + } + }) + } +}