test: add comprehensive tests and fix CI build

This commit is contained in:
2025-11-06 22:49:13 +01:00
parent b3c8f68989
commit 0edeb67075
5 changed files with 1038 additions and 3 deletions

View File

@@ -0,0 +1,185 @@
package gateway
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestGateway_pathMatches(t *testing.T) {
t.Parallel()
tests := []struct {
name string
requestPath string
routePath string
want bool
}{
{
name: "exact match",
requestPath: "/api/v1/auth",
routePath: "/api/v1/auth",
want: true,
},
{
name: "exact match with trailing slash",
requestPath: "/api/v1/auth/",
routePath: "/api/v1/auth",
want: true,
},
{
name: "wildcard match - prefix",
requestPath: "/api/v1/auth/login",
routePath: "/api/v1/auth/**",
want: true,
},
{
name: "wildcard match - exact prefix",
requestPath: "/api/v1/auth",
routePath: "/api/v1/auth/**",
want: true,
},
{
name: "wildcard match - nested path",
requestPath: "/api/v1/auth/refresh/token",
routePath: "/api/v1/auth/**",
want: true,
},
{
name: "no match - different prefix",
requestPath: "/api/v1/users",
routePath: "/api/v1/auth/**",
want: false,
},
{
name: "no match - exact",
requestPath: "/api/v1/users",
routePath: "/api/v1/auth",
want: false,
},
{
name: "wildcard no match - wrong prefix",
requestPath: "/api/v1/users/login",
routePath: "/api/v1/auth/**",
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gateway := &Gateway{}
got := gateway.pathMatches(tt.requestPath, tt.routePath)
assert.Equal(t, tt.want, got)
})
}
}
func TestGateway_extractRemainingPath(t *testing.T) {
t.Parallel()
tests := []struct {
name string
requestPath string
routePath string
want string
}{
{
name: "wildcard - extract path",
requestPath: "/api/v1/auth/login",
routePath: "/api/v1/auth/**",
want: "/login",
},
{
name: "wildcard - extract nested path",
requestPath: "/api/v1/auth/refresh/token",
routePath: "/api/v1/auth/**",
want: "/refresh/token",
},
{
name: "wildcard - exact match",
requestPath: "/api/v1/auth",
routePath: "/api/v1/auth/**",
want: "/",
},
{
name: "exact match - no remaining",
requestPath: "/api/v1/auth",
routePath: "/api/v1/auth",
want: "/",
},
{
name: "no match - empty",
requestPath: "/api/v1/users",
routePath: "/api/v1/auth/**",
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gateway := &Gateway{}
got := gateway.extractRemainingPath(tt.requestPath, tt.routePath)
assert.Equal(t, tt.want, got)
})
}
}
func TestGateway_matchRoute(t *testing.T) {
t.Parallel()
tests := []struct {
name string
routes []RouteConfig
requestPath string
want *RouteConfig
}{
{
name: "exact match",
routes: []RouteConfig{
{Path: "/api/v1/auth", Service: "auth-service"},
{Path: "/api/v1/users", Service: "identity-service"},
},
requestPath: "/api/v1/auth",
want: &RouteConfig{Path: "/api/v1/auth", Service: "auth-service"},
},
{
name: "wildcard match",
routes: []RouteConfig{
{Path: "/api/v1/auth/**", Service: "auth-service"},
},
requestPath: "/api/v1/auth/login",
want: &RouteConfig{Path: "/api/v1/auth/**", Service: "auth-service"},
},
{
name: "no match",
routes: []RouteConfig{
{Path: "/api/v1/auth/**", Service: "auth-service"},
},
requestPath: "/api/v1/other",
want: nil,
},
{
name: "empty routes",
routes: []RouteConfig{},
requestPath: "/api/v1/auth",
want: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gateway := &Gateway{
routes: tt.routes,
}
got := gateway.matchRoute(tt.requestPath)
if tt.want == nil {
assert.Nil(t, got)
} else {
assert.NotNil(t, got)
assert.Equal(t, tt.want.Path, got.Path)
assert.Equal(t, tt.want.Service, got.Service)
}
})
}
}

View File

@@ -14,6 +14,7 @@ import (
"git.dcentral.systems/toolz/goplt/pkg/config"
"git.dcentral.systems/toolz/goplt/pkg/logger"
"git.dcentral.systems/toolz/goplt/pkg/registry"
"git.dcentral.systems/toolz/goplt/pkg/services"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -425,3 +426,87 @@ func (m *mockServiceRegistry) Health(ctx context.Context, serviceID string) (*re
Status: "healthy",
}, nil
}
// mockAuthClient implements services.AuthServiceClient for testing.
type mockAuthClient struct {
loginResp *services.TokenResponse
loginErr error
refreshResp *services.TokenResponse
refreshErr error
validateResp *services.TokenClaims
validateErr error
logoutErr error
}
func (m *mockAuthClient) Login(ctx context.Context, email, password string) (*services.TokenResponse, error) {
return m.loginResp, m.loginErr
}
func (m *mockAuthClient) RefreshToken(ctx context.Context, refreshToken string) (*services.TokenResponse, error) {
return m.refreshResp, m.refreshErr
}
func (m *mockAuthClient) ValidateToken(ctx context.Context, token string) (*services.TokenClaims, error) {
return m.validateResp, m.validateErr
}
func (m *mockAuthClient) Logout(ctx context.Context, refreshToken string) error {
return m.logoutErr
}
// mockIdentityClient implements services.IdentityServiceClient for testing.
type mockIdentityClient struct {
getUserResp *services.User
getUserErr error
getUserByEmailResp *services.User
getUserByEmailErr error
createUserResp *services.User
createUserErr error
updateUserResp *services.User
updateUserErr error
deleteUserErr error
verifyEmailErr error
requestPasswordResetErr error
resetPasswordErr error
verifyPasswordResp *services.User
verifyPasswordErr error
}
func (m *mockIdentityClient) GetUser(ctx context.Context, id string) (*services.User, error) {
return m.getUserResp, m.getUserErr
}
func (m *mockIdentityClient) GetUserByEmail(ctx context.Context, email string) (*services.User, error) {
return m.getUserByEmailResp, m.getUserByEmailErr
}
func (m *mockIdentityClient) CreateUser(ctx context.Context, req *services.CreateUserRequest) (*services.User, error) {
return m.createUserResp, m.createUserErr
}
func (m *mockIdentityClient) UpdateUser(ctx context.Context, id string, req *services.UpdateUserRequest) (*services.User, error) {
return m.updateUserResp, m.updateUserErr
}
func (m *mockIdentityClient) DeleteUser(ctx context.Context, id string) error {
return m.deleteUserErr
}
func (m *mockIdentityClient) VerifyEmail(ctx context.Context, token string) error {
return m.verifyEmailErr
}
func (m *mockIdentityClient) RequestPasswordReset(ctx context.Context, email string) error {
return m.requestPasswordResetErr
}
func (m *mockIdentityClient) ResetPassword(ctx context.Context, token, newPassword string) error {
return m.resetPasswordErr
}
func (m *mockIdentityClient) VerifyPassword(ctx context.Context, email, password string) (*services.User, error) {
return m.verifyPasswordResp, m.verifyPasswordErr
}
// Note: mockClientFactory is not needed since we're testing handlers directly with mock clients.
// The actual clientFactory is tested separately in integration tests.

View File

@@ -0,0 +1,520 @@
package gateway
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"git.dcentral.systems/toolz/goplt/pkg/services"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
func TestGateway_handleLogin(t *testing.T) {
t.Parallel()
tests := []struct {
name string
requestBody interface{}
clientResp *services.TokenResponse
clientErr error
expectedStatus int
expectedBody func(*testing.T, *httptest.ResponseRecorder)
}{
{
name: "successful login",
requestBody: map[string]string{
"email": "test@example.com",
"password": "password123",
},
clientResp: &services.TokenResponse{
AccessToken: "access-token",
RefreshToken: "refresh-token",
ExpiresIn: 3600,
TokenType: "Bearer",
},
clientErr: nil,
expectedStatus: http.StatusOK,
expectedBody: func(t *testing.T, w *httptest.ResponseRecorder) {
var resp services.TokenResponse
err := json.Unmarshal(w.Body.Bytes(), &resp)
require.NoError(t, err)
assert.Equal(t, "access-token", resp.AccessToken)
assert.Equal(t, "refresh-token", resp.RefreshToken)
},
},
{
name: "invalid request body",
requestBody: map[string]string{
"email": "test@example.com",
// missing password
},
expectedStatus: http.StatusBadRequest,
},
{
name: "client error - unauthorized",
requestBody: map[string]string{
"email": "test@example.com",
"password": "wrongpassword",
},
clientErr: status.Error(codes.Unauthenticated, "invalid credentials"),
expectedStatus: http.StatusUnauthorized,
},
{
name: "client error - internal",
requestBody: map[string]string{
"email": "test@example.com",
"password": "password123",
},
clientErr: status.Error(codes.Internal, "internal error"),
expectedStatus: http.StatusInternalServerError,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockClient := &mockAuthClient{
loginResp: tt.clientResp,
loginErr: tt.clientErr,
}
gateway := &Gateway{
log: &mockLogger{},
}
router := gin.New()
router.POST("/login", func(c *gin.Context) {
gateway.handleLogin(context.Background(), c, mockClient)
})
bodyBytes, _ := json.Marshal(tt.requestBody)
req := httptest.NewRequest(http.MethodPost, "/login", bytes.NewReader(bodyBytes))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, tt.expectedStatus, w.Code)
if tt.expectedBody != nil {
tt.expectedBody(t, w)
}
})
}
}
func TestGateway_handleRefreshToken(t *testing.T) {
t.Parallel()
tests := []struct {
name string
requestBody interface{}
clientResp *services.TokenResponse
clientErr error
expectedStatus int
}{
{
name: "successful refresh",
requestBody: map[string]string{
"refresh_token": "refresh-token-123",
},
clientResp: &services.TokenResponse{
AccessToken: "new-access-token",
RefreshToken: "new-refresh-token",
ExpiresIn: 3600,
TokenType: "Bearer",
},
expectedStatus: http.StatusOK,
},
{
name: "invalid request body",
requestBody: map[string]string{
// missing refresh_token
},
expectedStatus: http.StatusBadRequest,
},
{
name: "client error",
requestBody: map[string]string{
"refresh_token": "invalid-token",
},
clientErr: status.Error(codes.Unauthenticated, "invalid token"),
expectedStatus: http.StatusUnauthorized,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockClient := &mockAuthClient{
refreshResp: tt.clientResp,
refreshErr: tt.clientErr,
}
gateway := &Gateway{
log: &mockLogger{},
}
router := gin.New()
router.POST("/refresh", func(c *gin.Context) {
gateway.handleRefreshToken(context.Background(), c, mockClient)
})
bodyBytes, _ := json.Marshal(tt.requestBody)
req := httptest.NewRequest(http.MethodPost, "/refresh", bytes.NewReader(bodyBytes))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, tt.expectedStatus, w.Code)
})
}
}
func TestGateway_handleValidateToken(t *testing.T) {
t.Parallel()
tests := []struct {
name string
requestBody interface{}
clientResp *services.TokenClaims
clientErr error
expectedStatus int
}{
{
name: "successful validation",
requestBody: map[string]string{
"token": "valid-token",
},
clientResp: &services.TokenClaims{
UserID: "user-123",
Email: "test@example.com",
Roles: []string{"user"},
ExpiresAt: 1234567890,
},
expectedStatus: http.StatusOK,
},
{
name: "invalid request body",
requestBody: map[string]string{
// missing token
},
expectedStatus: http.StatusBadRequest,
},
{
name: "client error - invalid token",
requestBody: map[string]string{
"token": "invalid-token",
},
clientErr: status.Error(codes.Unauthenticated, "invalid token"),
expectedStatus: http.StatusUnauthorized,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockClient := &mockAuthClient{
validateResp: tt.clientResp,
validateErr: tt.clientErr,
}
gateway := &Gateway{
log: &mockLogger{},
}
router := gin.New()
router.POST("/validate", func(c *gin.Context) {
gateway.handleValidateToken(context.Background(), c, mockClient)
})
bodyBytes, _ := json.Marshal(tt.requestBody)
req := httptest.NewRequest(http.MethodPost, "/validate", bytes.NewReader(bodyBytes))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, tt.expectedStatus, w.Code)
})
}
}
func TestGateway_handleLogout(t *testing.T) {
t.Parallel()
tests := []struct {
name string
requestBody interface{}
clientErr error
expectedStatus int
}{
{
name: "successful logout",
requestBody: map[string]string{
"refresh_token": "refresh-token-123",
},
expectedStatus: http.StatusOK,
},
{
name: "invalid request body",
requestBody: map[string]string{
// missing refresh_token
},
expectedStatus: http.StatusBadRequest,
},
{
name: "client error",
requestBody: map[string]string{
"refresh_token": "invalid-token",
},
clientErr: status.Error(codes.NotFound, "token not found"),
expectedStatus: http.StatusNotFound,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockClient := &mockAuthClient{
logoutErr: tt.clientErr,
}
gateway := &Gateway{
log: &mockLogger{},
}
router := gin.New()
router.POST("/logout", func(c *gin.Context) {
gateway.handleLogout(context.Background(), c, mockClient)
})
bodyBytes, _ := json.Marshal(tt.requestBody)
req := httptest.NewRequest(http.MethodPost, "/logout", bytes.NewReader(bodyBytes))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, tt.expectedStatus, w.Code)
})
}
}
func TestGateway_handleGetUser(t *testing.T) {
t.Parallel()
tests := []struct {
name string
userID string
clientResp *services.User
clientErr error
expectedStatus int
}{
{
name: "successful get user",
userID: "user-123",
clientResp: &services.User{
ID: "user-123",
Email: "test@example.com",
Username: "testuser",
FirstName: "Test",
LastName: "User",
},
expectedStatus: http.StatusOK,
},
{
name: "client error - not found",
userID: "nonexistent",
clientErr: status.Error(codes.NotFound, "user not found"),
expectedStatus: http.StatusNotFound,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockClient := &mockIdentityClient{
getUserResp: tt.clientResp,
getUserErr: tt.clientErr,
}
gateway := &Gateway{
log: &mockLogger{},
}
router := gin.New()
router.GET("/users/:id", func(c *gin.Context) {
gateway.handleGetUser(context.Background(), c, mockClient, tt.userID)
})
req := httptest.NewRequest(http.MethodGet, "/users/"+tt.userID, nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, tt.expectedStatus, w.Code)
})
}
}
func TestGateway_handleCreateUser(t *testing.T) {
t.Parallel()
tests := []struct {
name string
requestBody interface{}
clientResp *services.User
clientErr error
expectedStatus int
}{
{
name: "successful create",
requestBody: services.CreateUserRequest{
Email: "test@example.com",
Username: "testuser",
Password: "password123",
FirstName: "Test",
LastName: "User",
},
clientResp: &services.User{
ID: "user-123",
Email: "test@example.com",
Username: "testuser",
FirstName: "Test",
LastName: "User",
},
expectedStatus: http.StatusCreated,
},
{
name: "invalid JSON",
requestBody: "not a json object",
expectedStatus: http.StatusBadRequest,
},
{
name: "client error - already exists",
requestBody: services.CreateUserRequest{
Email: "existing@example.com",
Username: "existing",
Password: "password123",
},
clientErr: status.Error(codes.AlreadyExists, "user already exists"),
expectedStatus: http.StatusConflict,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockClient := &mockIdentityClient{
createUserResp: tt.clientResp,
createUserErr: tt.clientErr,
}
gateway := &Gateway{
log: &mockLogger{},
}
router := gin.New()
router.POST("/users", func(c *gin.Context) {
gateway.handleCreateUser(context.Background(), c, mockClient)
})
bodyBytes, _ := json.Marshal(tt.requestBody)
req := httptest.NewRequest(http.MethodPost, "/users", bytes.NewReader(bodyBytes))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, tt.expectedStatus, w.Code)
})
}
}
func TestGateway_handleGRPCError(t *testing.T) {
t.Parallel()
tests := []struct {
name string
err error
expectedStatus int
expectedError string
}{
{
name: "Unauthenticated",
err: status.Error(codes.Unauthenticated, "invalid token"),
expectedStatus: http.StatusUnauthorized,
expectedError: "Unauthorized",
},
{
name: "PermissionDenied",
err: status.Error(codes.PermissionDenied, "access denied"),
expectedStatus: http.StatusForbidden,
expectedError: "Forbidden",
},
{
name: "NotFound",
err: status.Error(codes.NotFound, "resource not found"),
expectedStatus: http.StatusNotFound,
expectedError: "Not found",
},
{
name: "InvalidArgument",
err: status.Error(codes.InvalidArgument, "invalid input"),
expectedStatus: http.StatusBadRequest,
expectedError: "Invalid request",
},
{
name: "AlreadyExists",
err: status.Error(codes.AlreadyExists, "resource exists"),
expectedStatus: http.StatusConflict,
expectedError: "Resource already exists",
},
{
name: "Internal",
err: status.Error(codes.Internal, "internal error"),
expectedStatus: http.StatusInternalServerError,
expectedError: "Internal server error",
},
{
name: "Unavailable",
err: status.Error(codes.Unavailable, "service unavailable"),
expectedStatus: http.StatusServiceUnavailable,
expectedError: "Service unavailable",
},
{
name: "non-gRPC error",
err: assert.AnError,
expectedStatus: http.StatusInternalServerError,
expectedError: "Internal server error",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gateway := &Gateway{
log: &mockLogger{},
}
router := gin.New()
router.GET("/test", func(c *gin.Context) {
gateway.handleGRPCError(c, tt.err)
})
req := httptest.NewRequest(http.MethodGet, "/test", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, tt.expectedStatus, w.Code)
var response map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &response)
require.NoError(t, err)
assert.Equal(t, tt.expectedError, response["error"])
})
}
}