package gateway import ( "context" "encoding/json" "errors" "net/http" "net/http/httptest" "os" "testing" "time" "git.dcentral.systems/toolz/goplt/internal/client" "git.dcentral.systems/toolz/goplt/pkg/config" "git.dcentral.systems/toolz/goplt/pkg/logger" "git.dcentral.systems/toolz/goplt/pkg/registry" "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // TestMain sets up the test environment before running tests. func TestMain(m *testing.M) { // Set Gin to test mode once for all tests to avoid race conditions gin.SetMode(gin.TestMode) // Run tests os.Exit(m.Run()) } func TestNewGateway(t *testing.T) { t.Parallel() tests := []struct { name string cfg config.ConfigProvider log logger.Logger factory *client.ServiceClientFactory reg registry.ServiceRegistry wantErr bool }{ { name: "successful creation", cfg: &mockConfigProvider{}, log: &mockLogger{}, factory: &client.ServiceClientFactory{}, reg: &mockServiceRegistry{}, wantErr: false, }, { name: "nil config", cfg: nil, log: &mockLogger{}, factory: &client.ServiceClientFactory{}, reg: &mockServiceRegistry{}, wantErr: false, // NewGateway doesn't validate nil config currently }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { gateway, err := NewGateway(tt.cfg, tt.log, tt.factory, tt.reg) if tt.wantErr { require.Error(t, err) assert.Nil(t, gateway) } else { require.NoError(t, err) require.NotNil(t, gateway) assert.Equal(t, tt.cfg, gateway.config) assert.Equal(t, tt.log, gateway.log) assert.Equal(t, tt.factory, gateway.clientFactory) assert.Equal(t, tt.reg, gateway.registry) } }) } } func TestGateway_SetupRoutes(t *testing.T) { t.Parallel() tests := []struct { name string routes []RouteConfig requestPath string expectedStatus int }{ { name: "route configured", routes: []RouteConfig{ {Path: "/api/v1/test", Service: "test-service", AuthRequired: false}, }, requestPath: "/api/v1/test", expectedStatus: http.StatusNotFound, // Unknown service returns 404 }, { name: "no routes configured", routes: []RouteConfig{}, requestPath: "/api/v1/test", expectedStatus: http.StatusNotFound, // NoRoute handler }, { name: "unmatched route", routes: []RouteConfig{ {Path: "/api/v1/test", Service: "test-service", AuthRequired: false}, }, requestPath: "/api/v1/other", expectedStatus: http.StatusNotFound, // NoRoute handler }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { gateway := &Gateway{ config: &mockConfigProvider{}, log: &mockLogger{}, clientFactory: &client.ServiceClientFactory{}, registry: &mockServiceRegistry{}, routes: tt.routes, } router := gin.New() gateway.SetupRoutes(router) req := httptest.NewRequest(http.MethodGet, tt.requestPath, nil) w := httptest.NewRecorder() router.ServeHTTP(w, req) assert.Equal(t, tt.expectedStatus, w.Code) }) } } func TestGateway_handleRoute(t *testing.T) { t.Parallel() tests := []struct { name string route RouteConfig registrySetup func() *mockServiceRegistry requestPath string expectedStatus int }{ { name: "service discovery failure", route: RouteConfig{ Path: "/api/v1/test", Service: "test-service", }, registrySetup: func() *mockServiceRegistry { return &mockServiceRegistry{ discoverError: errors.New("service discovery failed"), } }, requestPath: "/api/v1/test", expectedStatus: http.StatusNotFound, // Unknown service returns 404 }, { name: "no service instances", route: RouteConfig{ Path: "/api/v1/test", Service: "test-service", }, registrySetup: func() *mockServiceRegistry { return &mockServiceRegistry{ instances: []*registry.ServiceInstance{}, } }, requestPath: "/api/v1/test", expectedStatus: http.StatusNotFound, // Unknown service returns 404 }, // Note: Testing invalid target URL and proxy forwarding requires integration tests // with real HTTP servers, as httptest.ResponseRecorder doesn't support CloseNotify // which is required by the reverse proxy. } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { mockReg := tt.registrySetup() gateway := &Gateway{ config: &mockConfigProvider{}, log: &mockLogger{}, clientFactory: &client.ServiceClientFactory{}, registry: mockReg, routes: []RouteConfig{tt.route}, } router := gin.New() gateway.SetupRoutes(router) req := httptest.NewRequest(http.MethodGet, tt.requestPath, nil) w := httptest.NewRecorder() router.ServeHTTP(w, req) assert.Equal(t, tt.expectedStatus, w.Code) }) } } // Note: Proxy forwarding tests should be in integration tests with testcontainers. // The reverse proxy requires a real HTTP connection which httptest.ResponseRecorder // cannot provide due to missing CloseNotify interface. func TestGateway_handleRoute_AllMethods(t *testing.T) { t.Parallel() methods := []string{ http.MethodGet, http.MethodPost, http.MethodPut, http.MethodDelete, http.MethodPatch, http.MethodOptions, http.MethodHead, } // Use a registry that will fail discovery to avoid proxy forwarding issues mockReg := &mockServiceRegistry{ instances: []*registry.ServiceInstance{}, // Empty to trigger service unavailable } gateway := &Gateway{ config: &mockConfigProvider{}, log: &mockLogger{}, clientFactory: &client.ServiceClientFactory{}, registry: mockReg, routes: []RouteConfig{ {Path: "/api/v1/test/**", Service: "test-service"}, // Use wildcard pattern }, } router := gin.New() gateway.SetupRoutes(router) for _, method := range methods { t.Run(method, func(t *testing.T) { req := httptest.NewRequest(method, "/api/v1/test", nil) w := httptest.NewRecorder() router.ServeHTTP(w, req) // All methods should be handled (route should match, even if service unavailable) // New implementation returns 500 for unknown services, not 404 assert.NotEqual(t, http.StatusNotFound, w.Code, "Route should match for %s", method) }) } } func TestLoadRoutes(t *testing.T) { t.Parallel() tests := []struct { name string cfg config.ConfigProvider }{ { name: "empty config", cfg: &mockConfigProvider{}, }, { name: "nil config", cfg: nil, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { routes := loadRoutes(tt.cfg) // Currently loadRoutes returns empty slice // This test ensures it doesn't panic assert.NotNil(t, routes) }) } } func TestGateway_NoRoute(t *testing.T) { t.Parallel() gateway := &Gateway{ config: &mockConfigProvider{}, log: &mockLogger{}, clientFactory: &client.ServiceClientFactory{}, registry: &mockServiceRegistry{}, routes: []RouteConfig{}, } router := gin.New() gateway.SetupRoutes(router) req := httptest.NewRequest(http.MethodGet, "/unknown/path", nil) w := httptest.NewRecorder() router.ServeHTTP(w, req) assert.Equal(t, http.StatusNotFound, w.Code) var response map[string]interface{} err := json.Unmarshal(w.Body.Bytes(), &response) require.NoError(t, err) assert.Equal(t, "Route not found", response["error"]) assert.Equal(t, "/unknown/path", response["path"]) } // mockConfigProvider implements config.ConfigProvider for testing. type mockConfigProvider struct { values map[string]any } func (m *mockConfigProvider) Get(key string) any { if m.values == nil { return nil } return m.values[key] } func (m *mockConfigProvider) GetString(key string) string { if m.values == nil { return "" } if val, ok := m.values[key].(string); ok { return val } return "" } func (m *mockConfigProvider) GetInt(key string) int { if m.values == nil { return 0 } if val, ok := m.values[key].(int); ok { return val } return 0 } func (m *mockConfigProvider) GetBool(key string) bool { if m.values == nil { return false } if val, ok := m.values[key].(bool); ok { return val } return false } func (m *mockConfigProvider) GetDuration(key string) time.Duration { return 0 } func (m *mockConfigProvider) GetStringSlice(key string) []string { if m.values == nil { return nil } if val, ok := m.values[key].([]string); ok { return val } return nil } func (m *mockConfigProvider) IsSet(key string) bool { if m.values == nil { return false } _, ok := m.values[key] return ok } func (m *mockConfigProvider) Unmarshal(v any) error { return nil } // mockLogger implements logger.Logger for testing. type mockLogger struct { infoLogs []string errorLogs []string warnLogs []string } func (m *mockLogger) Debug(msg string, fields ...logger.Field) {} func (m *mockLogger) Info(msg string, fields ...logger.Field) { m.infoLogs = append(m.infoLogs, msg) } func (m *mockLogger) Warn(msg string, fields ...logger.Field) { m.warnLogs = append(m.warnLogs, msg) } func (m *mockLogger) Error(msg string, fields ...logger.Field) { m.errorLogs = append(m.errorLogs, msg) } func (m *mockLogger) With(fields ...logger.Field) logger.Logger { return m } func (m *mockLogger) WithContext(ctx context.Context) logger.Logger { return m } // mockServiceRegistry implements registry.ServiceRegistry for testing. type mockServiceRegistry struct { instances []*registry.ServiceInstance discoverError error registerError error deregError error } func (m *mockServiceRegistry) Register(ctx context.Context, service *registry.ServiceInstance) error { return m.registerError } func (m *mockServiceRegistry) Deregister(ctx context.Context, serviceID string) error { return m.deregError } func (m *mockServiceRegistry) Discover(ctx context.Context, serviceName string) ([]*registry.ServiceInstance, error) { if m.discoverError != nil { return nil, m.discoverError } return m.instances, nil } func (m *mockServiceRegistry) Watch(ctx context.Context, serviceName string) (<-chan []*registry.ServiceInstance, error) { ch := make(chan []*registry.ServiceInstance, 1) ch <- m.instances close(ch) return ch, nil } func (m *mockServiceRegistry) Health(ctx context.Context, serviceID string) (*registry.HealthStatus, error) { return ®istry.HealthStatus{ ServiceID: serviceID, Status: "healthy", }, nil }