feat: 构建用户模块,实现用户对话历史持久化,完善接口文档 #96

Merged
huanghaosheng merged 20 commits from build/backend into develop 2026-06-14 18:08:14 +08:00
Showing only changes of commit c01ee1d14c - Show all commits

View File

@@ -0,0 +1,324 @@
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"))
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"))
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")
}