package api_test import ( "bytes" "context" "encoding/json" "net/http" "net/http/httptest" "testing" "time" "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/hhs/camtalk/internal/api" "github.com/hhs/camtalk/internal/auth" ) // mockAuthService 实现 auth.Service 接口,用于 API 测试。 type mockAuthService struct { RegisterFunc func(ctx context.Context, req auth.RegisterRequest) (*auth.AuthResponse, error) LoginFunc func(ctx context.Context, req auth.LoginRequest) (*auth.AuthResponse, error) RefreshFunc func(ctx context.Context, req auth.RefreshRequest) (*auth.AuthResponse, error) LogoutFunc func(ctx context.Context, userID, refreshToken string) error } func (m *mockAuthService) Register(ctx context.Context, req auth.RegisterRequest) (*auth.AuthResponse, error) { return m.RegisterFunc(ctx, req) } func (m *mockAuthService) Login(ctx context.Context, req auth.LoginRequest) (*auth.AuthResponse, error) { return m.LoginFunc(ctx, req) } func (m *mockAuthService) Refresh(ctx context.Context, req auth.RefreshRequest) (*auth.AuthResponse, error) { return m.RefreshFunc(ctx, req) } func (m *mockAuthService) Logout(ctx context.Context, userID, refreshToken string) error { return m.LogoutFunc(ctx, userID, refreshToken) } // newTestRouter 创建带 AuthHandler 路由的测试 Gin 引擎。 func newTestRouter(svc auth.Service) *gin.Engine { gin.SetMode(gin.TestMode) r := gin.New() tm := auth.NewTokenManager("test-secret", 15*time.Minute, 7*24*time.Hour) h := api.NewAuthHandler(svc, tm) h.RegisterRoutes(r.Group("/api"), nil) // 测试时不启用限流 return r } // newTestRouterWithToken 创建带 AuthHandler 路由的测试引擎,同时返回 TokenManager 以便生成测试 token。 func newTestRouterWithToken(svc auth.Service) (*gin.Engine, *auth.TokenManager) { gin.SetMode(gin.TestMode) r := gin.New() tm := auth.NewTokenManager("test-secret", 15*time.Minute, 7*24*time.Hour) h := api.NewAuthHandler(svc, tm) h.RegisterRoutes(r.Group("/api"), nil) // 测试时不启用限流 return r, tm } func sampleAuthResponse() *auth.AuthResponse { return &auth.AuthResponse{ User: auth.UserResponse{ ID: "user-123", Username: "alice", }, AccessToken: "access-token", RefreshToken: "refresh-token", } } // --- Register --- func TestRegister_Success(t *testing.T) { svc := &mockAuthService{ RegisterFunc: func(_ context.Context, req auth.RegisterRequest) (*auth.AuthResponse, error) { assert.Equal(t, "alice", req.Username) assert.Equal(t, "password123", req.Password) return sampleAuthResponse(), nil }, } r := newTestRouter(svc) body, _ := json.Marshal(auth.RegisterRequest{Username: "alice", Password: "password123"}) req := httptest.NewRequest(http.MethodPost, "/api/auth/register", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() r.ServeHTTP(w, req) assert.Equal(t, http.StatusCreated, w.Code) var resp auth.AuthResponse require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) assert.Equal(t, "alice", resp.User.Username) assert.NotEmpty(t, resp.AccessToken) } func TestRegister_InvalidInput_EmptyBody(t *testing.T) { svc := &mockAuthService{} r := newTestRouter(svc) req := httptest.NewRequest(http.MethodPost, "/api/auth/register", nil) w := httptest.NewRecorder() r.ServeHTTP(w, req) assert.Equal(t, http.StatusBadRequest, w.Code) assert.Contains(t, w.Body.String(), "INVALID_INPUT") } func TestRegister_InvalidInput_UsernameTooShort(t *testing.T) { svc := &mockAuthService{} r := newTestRouter(svc) body, _ := json.Marshal(auth.RegisterRequest{Username: "ab", Password: "password123"}) req := httptest.NewRequest(http.MethodPost, "/api/auth/register", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() r.ServeHTTP(w, req) assert.Equal(t, http.StatusBadRequest, w.Code) assert.Contains(t, w.Body.String(), "username must be 3-64 characters") } func TestRegister_InvalidInput_PasswordTooShort(t *testing.T) { svc := &mockAuthService{} r := newTestRouter(svc) body, _ := json.Marshal(auth.RegisterRequest{Username: "alice", Password: "short"}) req := httptest.NewRequest(http.MethodPost, "/api/auth/register", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() r.ServeHTTP(w, req) assert.Equal(t, http.StatusBadRequest, w.Code) assert.Contains(t, w.Body.String(), "password must be 8-72 characters") } func TestRegister_UsernameTaken(t *testing.T) { svc := &mockAuthService{ RegisterFunc: func(_ context.Context, _ auth.RegisterRequest) (*auth.AuthResponse, error) { return nil, auth.ErrUsernameTaken }, } r := newTestRouter(svc) body, _ := json.Marshal(auth.RegisterRequest{Username: "alice", Password: "password123"}) req := httptest.NewRequest(http.MethodPost, "/api/auth/register", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() r.ServeHTTP(w, req) assert.Equal(t, http.StatusConflict, w.Code) assert.Contains(t, w.Body.String(), "USERNAME_TAKEN") } // --- Login --- func TestLogin_Success(t *testing.T) { svc := &mockAuthService{ LoginFunc: func(_ context.Context, req auth.LoginRequest) (*auth.AuthResponse, error) { assert.Equal(t, "alice", req.Username) assert.Equal(t, "password123", req.Password) return sampleAuthResponse(), nil }, } r := newTestRouter(svc) body, _ := json.Marshal(auth.LoginRequest{Username: "alice", Password: "password123"}) req := httptest.NewRequest(http.MethodPost, "/api/auth/login", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() r.ServeHTTP(w, req) assert.Equal(t, http.StatusOK, w.Code) var resp auth.AuthResponse require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) assert.Equal(t, "alice", resp.User.Username) } func TestLogin_InvalidCredentials(t *testing.T) { svc := &mockAuthService{ LoginFunc: func(_ context.Context, _ auth.LoginRequest) (*auth.AuthResponse, error) { return nil, auth.ErrInvalidCredentials }, } r := newTestRouter(svc) body, _ := json.Marshal(auth.LoginRequest{Username: "alice", Password: "wrong-password"}) req := httptest.NewRequest(http.MethodPost, "/api/auth/login", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() r.ServeHTTP(w, req) assert.Equal(t, http.StatusUnauthorized, w.Code) assert.Contains(t, w.Body.String(), "INVALID_CREDENTIALS") } // --- Refresh --- func TestRefresh_Success(t *testing.T) { svc := &mockAuthService{ RefreshFunc: func(_ context.Context, req auth.RefreshRequest) (*auth.AuthResponse, error) { assert.Equal(t, "some-refresh-token", req.RefreshToken) return sampleAuthResponse(), nil }, } r := newTestRouter(svc) body, _ := json.Marshal(auth.RefreshRequest{RefreshToken: "some-refresh-token"}) req := httptest.NewRequest(http.MethodPost, "/api/auth/refresh", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() r.ServeHTTP(w, req) assert.Equal(t, http.StatusOK, w.Code) } func TestRefresh_MissingToken(t *testing.T) { svc := &mockAuthService{} r := newTestRouter(svc) body, _ := json.Marshal(auth.RefreshRequest{RefreshToken: ""}) req := httptest.NewRequest(http.MethodPost, "/api/auth/refresh", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() r.ServeHTTP(w, req) assert.Equal(t, http.StatusBadRequest, w.Code) assert.Contains(t, w.Body.String(), "refresh_token is required") } func TestRefresh_UsedToken(t *testing.T) { svc := &mockAuthService{ RefreshFunc: func(_ context.Context, _ auth.RefreshRequest) (*auth.AuthResponse, error) { return nil, auth.ErrRefreshTokenUsed }, } r := newTestRouter(svc) body, _ := json.Marshal(auth.RefreshRequest{RefreshToken: "used-token"}) req := httptest.NewRequest(http.MethodPost, "/api/auth/refresh", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() r.ServeHTTP(w, req) assert.Equal(t, http.StatusUnauthorized, w.Code) assert.Contains(t, w.Body.String(), "INVALID_TOKEN") } // --- Logout --- func TestLogout_Success(t *testing.T) { logoutCalled := false svc := &mockAuthService{ LogoutFunc: func(_ context.Context, userID, refreshToken string) error { assert.Equal(t, "user-123", userID) assert.Equal(t, "refresh-token-to-revoke", refreshToken) logoutCalled = true return nil }, } r, tm := newTestRouterWithToken(svc) // 生成有效 token access, _, err := tm.GeneratePair("user-123", "alice") require.NoError(t, err) body, _ := json.Marshal(map[string]string{"refresh_token": "refresh-token-to-revoke"}) req := httptest.NewRequest(http.MethodPost, "/api/auth/logout", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+access) w := httptest.NewRecorder() r.ServeHTTP(w, req) assert.Equal(t, http.StatusOK, w.Code) assert.True(t, logoutCalled) assert.Contains(t, w.Body.String(), "logged out successfully") } func TestLogout_MissingAuth(t *testing.T) { svc := &mockAuthService{} r := newTestRouter(svc) body, _ := json.Marshal(map[string]string{"refresh_token": "some-token"}) req := httptest.NewRequest(http.MethodPost, "/api/auth/logout", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() r.ServeHTTP(w, req) assert.Equal(t, http.StatusUnauthorized, w.Code) } func TestLogout_MissingRefreshToken(t *testing.T) { svc := &mockAuthService{} r, tm := newTestRouterWithToken(svc) access, _, err := tm.GeneratePair("user-123", "alice") require.NoError(t, err) body, _ := json.Marshal(map[string]string{"refresh_token": ""}) req := httptest.NewRequest(http.MethodPost, "/api/auth/logout", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+access) w := httptest.NewRecorder() r.ServeHTTP(w, req) assert.Equal(t, http.StatusBadRequest, w.Code) assert.Contains(t, w.Body.String(), "refresh_token is required") }