Compare commits
2 Commits
fbc3fc37e4
...
557e6a009e
| Author | SHA1 | Date | |
|---|---|---|---|
| 557e6a009e | |||
| 260bc07114 |
12
.github/workflows/ci.yml
vendored
12
.github/workflows/ci.yml
vendored
@@ -78,13 +78,11 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
go-version: '1.25.3'
|
go-version: '1.25.3'
|
||||||
|
|
||||||
- name: Install golangci-lint v2.1.6
|
- name: golangci-lint
|
||||||
run: |
|
uses: golangci/golangci-lint-action@v6
|
||||||
curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v2.1.6
|
with:
|
||||||
echo "$(go env GOPATH)/bin" >> $GITHUB_PATH
|
version: v2.6.0
|
||||||
|
args: --timeout=5m
|
||||||
- name: Run golangci-lint
|
|
||||||
run: golangci-lint run --timeout=5m
|
|
||||||
|
|
||||||
build:
|
build:
|
||||||
name: Build
|
name: Build
|
||||||
|
|||||||
3
go.mod
3
go.mod
@@ -33,6 +33,7 @@ require (
|
|||||||
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||||
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||||
github.com/fatih/color v1.16.0 // indirect
|
github.com/fatih/color v1.16.0 // indirect
|
||||||
github.com/fsnotify/fsnotify v1.7.0 // indirect
|
github.com/fsnotify/fsnotify v1.7.0 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.10 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.10 // indirect
|
||||||
@@ -70,6 +71,7 @@ require (
|
|||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||||
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||||
github.com/prometheus/client_model v0.6.2 // indirect
|
github.com/prometheus/client_model v0.6.2 // indirect
|
||||||
github.com/prometheus/common v0.66.1 // indirect
|
github.com/prometheus/common v0.66.1 // indirect
|
||||||
github.com/prometheus/procfs v0.16.1 // indirect
|
github.com/prometheus/procfs v0.16.1 // indirect
|
||||||
@@ -79,6 +81,7 @@ require (
|
|||||||
github.com/spf13/afero v1.11.0 // indirect
|
github.com/spf13/afero v1.11.0 // indirect
|
||||||
github.com/spf13/cast v1.6.0 // indirect
|
github.com/spf13/cast v1.6.0 // indirect
|
||||||
github.com/spf13/pflag v1.0.5 // indirect
|
github.com/spf13/pflag v1.0.5 // indirect
|
||||||
|
github.com/stretchr/testify v1.11.1 // indirect
|
||||||
github.com/subosito/gotenv v1.6.0 // indirect
|
github.com/subosito/gotenv v1.6.0 // indirect
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.3.0 // indirect
|
github.com/ugorji/go/codec v1.3.0 // indirect
|
||||||
|
|||||||
427
services/gateway/gateway_test.go
Normal file
427
services/gateway/gateway_test.go
Normal file
@@ -0,0 +1,427 @@
|
|||||||
|
package gateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewGateway(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
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.StatusServiceUnavailable, // Will fail service discovery
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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()
|
||||||
|
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
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.StatusServiceUnavailable,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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.StatusServiceUnavailable,
|
||||||
|
},
|
||||||
|
// 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()
|
||||||
|
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
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"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
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()
|
||||||
|
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user