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 d01aeaba68 - Show all commits

View File

@@ -0,0 +1,575 @@
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"
"github.com/hhs/camtalk/internal/models"
"github.com/hhs/camtalk/internal/session"
)
// mockSessionManager 实现 session.Manager 接口,用于 ConversationHandler 测试。
type mockSessionManager struct {
CreateFunc func(ctx context.Context, userID string, config models.SessionConfig) (string, error)
GetFunc func(ctx context.Context, sessionID string) (*models.Session, error)
UpdateConfigFunc func(ctx context.Context, sessionID string, patch models.SessionConfigPatch) error
UpdateTitleFunc func(ctx context.Context, sessionID string, title string) error
ListByUserFunc func(ctx context.Context, userID string, page, size int) ([]session.ConversationSummary, int, error)
GetHistoryFunc func(ctx context.Context, sessionID string, limit int) ([]models.Message, error)
AppendMessageFunc func(ctx context.Context, sessionID string, msg models.Message) error
SetActiveRequestFunc func(ctx context.Context, sessionID string, requestID string) error
GetActiveRequestIDFunc func(ctx context.Context, sessionID string) (string, error)
ClearActiveRequestFunc func(ctx context.Context, sessionID string) error
TouchFunc func(ctx context.Context, sessionID string) error
DestroyFunc func(ctx context.Context, sessionID string) error
ActiveCountFunc func() int
}
func (m *mockSessionManager) Create(ctx context.Context, userID string, config models.SessionConfig) (string, error) {
return m.CreateFunc(ctx, userID, config)
}
func (m *mockSessionManager) Get(ctx context.Context, sessionID string) (*models.Session, error) {
return m.GetFunc(ctx, sessionID)
}
func (m *mockSessionManager) UpdateConfig(ctx context.Context, sessionID string, patch models.SessionConfigPatch) error {
return m.UpdateConfigFunc(ctx, sessionID, patch)
}
func (m *mockSessionManager) UpdateTitle(ctx context.Context, sessionID string, title string) error {
return m.UpdateTitleFunc(ctx, sessionID, title)
}
func (m *mockSessionManager) ListByUser(ctx context.Context, userID string, page, size int) ([]session.ConversationSummary, int, error) {
return m.ListByUserFunc(ctx, userID, page, size)
}
func (m *mockSessionManager) GetHistory(ctx context.Context, sessionID string, limit int) ([]models.Message, error) {
return m.GetHistoryFunc(ctx, sessionID, limit)
}
func (m *mockSessionManager) AppendMessage(ctx context.Context, sessionID string, msg models.Message) error {
return m.AppendMessageFunc(ctx, sessionID, msg)
}
func (m *mockSessionManager) SetActiveRequest(ctx context.Context, sessionID string, requestID string) error {
return m.SetActiveRequestFunc(ctx, sessionID, requestID)
}
func (m *mockSessionManager) GetActiveRequestID(ctx context.Context, sessionID string) (string, error) {
return m.GetActiveRequestIDFunc(ctx, sessionID)
}
func (m *mockSessionManager) ClearActiveRequest(ctx context.Context, sessionID string) error {
return m.ClearActiveRequestFunc(ctx, sessionID)
}
func (m *mockSessionManager) Touch(ctx context.Context, sessionID string) error {
return m.TouchFunc(ctx, sessionID)
}
func (m *mockSessionManager) Destroy(ctx context.Context, sessionID string) error {
return m.DestroyFunc(ctx, sessionID)
}
func (m *mockSessionManager) ActiveCount() int {
return m.ActiveCountFunc()
}
// newConvTestRouter 创建带 ConversationHandler 路由的测试引擎,同时返回 TokenManager。
func newConvTestRouter(mgr session.Manager) (*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.NewConversationHandler(mgr, tm)
h.RegisterRoutes(r.Group("/api"))
return r, tm
}
// --- List ---
func TestConversationList_Success(t *testing.T) {
now := time.Now()
mgr := &mockSessionManager{
ListByUserFunc: func(_ context.Context, userID string, page, size int) ([]session.ConversationSummary, int, error) {
assert.Equal(t, "user-123", userID)
assert.Equal(t, 1, page)
assert.Equal(t, 20, size)
return []session.ConversationSummary{
{ID: "sess-1", Title: "对话一", MessageCount: 3, UpdatedAt: now},
{ID: "sess-2", Title: "对话二", MessageCount: 1, UpdatedAt: now.Add(-time.Hour)},
}, 2, nil
},
}
r, tm := newConvTestRouter(mgr)
access, _, err := tm.GeneratePair("user-123", "alice")
require.NoError(t, err)
req := httptest.NewRequest(http.MethodGet, "/api/conversations", nil)
req.Header.Set("Authorization", "Bearer "+access)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var resp map[string]interface{}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
assert.Equal(t, float64(2), resp["total"])
convs := resp["conversations"].([]interface{})
assert.Len(t, convs, 2)
}
func TestConversationList_WithPagination(t *testing.T) {
mgr := &mockSessionManager{
ListByUserFunc: func(_ context.Context, _ string, page, size int) ([]session.ConversationSummary, int, error) {
assert.Equal(t, 2, page)
assert.Equal(t, 10, size)
return []session.ConversationSummary{}, 0, nil
},
}
r, tm := newConvTestRouter(mgr)
access, _, err := tm.GeneratePair("user-123", "alice")
require.NoError(t, err)
req := httptest.NewRequest(http.MethodGet, "/api/conversations?page=2&size=10", nil)
req.Header.Set("Authorization", "Bearer "+access)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
}
func TestConversationList_MissingAuth(t *testing.T) {
mgr := &mockSessionManager{}
r, _ := newConvTestRouter(mgr)
req := httptest.NewRequest(http.MethodGet, "/api/conversations", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusUnauthorized, w.Code)
}
// --- Create ---
func TestConversationCreate_Success(t *testing.T) {
createdID := "new-session-id"
now := time.Now()
mgr := &mockSessionManager{
CreateFunc: func(_ context.Context, userID string, cfg models.SessionConfig) (string, error) {
assert.Equal(t, "user-123", userID)
return createdID, nil
},
GetFunc: func(_ context.Context, sessionID string) (*models.Session, error) {
assert.Equal(t, createdID, sessionID)
return &models.Session{
ID: createdID,
UserID: "user-123",
Title: models.DefaultSessionTitle,
CreatedAt: now,
UpdatedAt: now,
Config: models.DefaultConfig(),
}, nil
},
}
r, tm := newConvTestRouter(mgr)
access, _, err := tm.GeneratePair("user-123", "alice")
require.NoError(t, err)
req := httptest.NewRequest(http.MethodPost, "/api/conversations", nil)
req.Header.Set("Authorization", "Bearer "+access)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusCreated, w.Code)
var resp map[string]interface{}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
assert.Equal(t, createdID, resp["id"])
assert.Equal(t, models.DefaultSessionTitle, resp["title"])
}
func TestConversationCreate_WithConfig(t *testing.T) {
mgr := &mockSessionManager{
CreateFunc: func(_ context.Context, _ string, cfg models.SessionConfig) (string, error) {
assert.False(t, cfg.TTSEnabled)
assert.Equal(t, "high", cfg.DetailLevel)
return "sess-1", nil
},
GetFunc: func(_ context.Context, _ string) (*models.Session, error) {
return &models.Session{
ID: "sess-1",
UserID: "user-123",
Title: models.DefaultSessionTitle,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}, nil
},
}
r, tm := newConvTestRouter(mgr)
access, _, err := tm.GeneratePair("user-123", "alice")
require.NoError(t, err)
body, _ := json.Marshal(api.CreateConversationRequest{
Config: &models.SessionConfig{TTSEnabled: false, DetailLevel: "high", Language: "zh-CN"},
})
req := httptest.NewRequest(http.MethodPost, "/api/conversations", 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.StatusCreated, w.Code)
}
// --- Get ---
func TestConversationGet_Success(t *testing.T) {
now := time.Now()
mgr := &mockSessionManager{
GetFunc: func(_ context.Context, sessionID string) (*models.Session, error) {
assert.Equal(t, "sess-1", sessionID)
return &models.Session{
ID: "sess-1",
UserID: "user-123",
Title: "我的对话",
CreatedAt: now,
UpdatedAt: now,
Config: models.DefaultConfig(),
}, nil
},
}
r, tm := newConvTestRouter(mgr)
access, _, err := tm.GeneratePair("user-123", "alice")
require.NoError(t, err)
req := httptest.NewRequest(http.MethodGet, "/api/conversations/sess-1", nil)
req.Header.Set("Authorization", "Bearer "+access)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var resp map[string]interface{}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
assert.Equal(t, "我的对话", resp["title"])
}
func TestConversationGet_NotFound(t *testing.T) {
mgr := &mockSessionManager{
GetFunc: func(_ context.Context, _ string) (*models.Session, error) {
return nil, session.ErrSessionNotFound
},
}
r, tm := newConvTestRouter(mgr)
access, _, err := tm.GeneratePair("user-123", "alice")
require.NoError(t, err)
req := httptest.NewRequest(http.MethodGet, "/api/conversations/nonexistent", nil)
req.Header.Set("Authorization", "Bearer "+access)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusNotFound, w.Code)
assert.Contains(t, w.Body.String(), "SESSION_NOT_FOUND")
}
func TestConversationGet_Forbidden(t *testing.T) {
mgr := &mockSessionManager{
GetFunc: func(_ context.Context, _ string) (*models.Session, error) {
// 会话属于另一个用户
return &models.Session{
ID: "sess-1",
UserID: "other-user",
Title: "他人对话",
}, nil
},
}
r, tm := newConvTestRouter(mgr)
access, _, err := tm.GeneratePair("user-123", "alice")
require.NoError(t, err)
req := httptest.NewRequest(http.MethodGet, "/api/conversations/sess-1", nil)
req.Header.Set("Authorization", "Bearer "+access)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
// 返回 404 而非 403避免信息泄露
assert.Equal(t, http.StatusNotFound, w.Code)
assert.Contains(t, w.Body.String(), "SESSION_NOT_FOUND")
}
// --- UpdateTitle ---
func TestConversationUpdateTitle_Success(t *testing.T) {
mgr := &mockSessionManager{
GetFunc: func(_ context.Context, _ string) (*models.Session, error) {
return &models.Session{ID: "sess-1", UserID: "user-123"}, nil
},
UpdateTitleFunc: func(_ context.Context, sessionID, title string) error {
assert.Equal(t, "sess-1", sessionID)
assert.Equal(t, "新标题", title)
return nil
},
}
r, tm := newConvTestRouter(mgr)
access, _, err := tm.GeneratePair("user-123", "alice")
require.NoError(t, err)
body, _ := json.Marshal(api.UpdateTitleRequest{Title: "新标题"})
req := httptest.NewRequest(http.MethodPatch, "/api/conversations/sess-1", 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.Contains(t, w.Body.String(), "title updated")
}
func TestConversationUpdateTitle_EmptyTitle(t *testing.T) {
mgr := &mockSessionManager{
GetFunc: func(_ context.Context, _ string) (*models.Session, error) {
return &models.Session{ID: "sess-1", UserID: "user-123"}, nil
},
}
r, tm := newConvTestRouter(mgr)
access, _, err := tm.GeneratePair("user-123", "alice")
require.NoError(t, err)
body, _ := json.Marshal(api.UpdateTitleRequest{Title: ""})
req := httptest.NewRequest(http.MethodPatch, "/api/conversations/sess-1", 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(), "title is required")
}
func TestConversationUpdateTitle_TooLong(t *testing.T) {
mgr := &mockSessionManager{
GetFunc: func(_ context.Context, _ string) (*models.Session, error) {
return &models.Session{ID: "sess-1", UserID: "user-123"}, nil
},
}
r, tm := newConvTestRouter(mgr)
access, _, err := tm.GeneratePair("user-123", "alice")
require.NoError(t, err)
longTitle := ""
for i := 0; i < 101; i++ {
longTitle += "测"
}
body, _ := json.Marshal(api.UpdateTitleRequest{Title: longTitle})
req := httptest.NewRequest(http.MethodPatch, "/api/conversations/sess-1", 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(), "title must be 100 characters or less")
}
// --- Delete ---
func TestConversationDelete_Success(t *testing.T) {
destroyCalled := false
mgr := &mockSessionManager{
GetFunc: func(_ context.Context, _ string) (*models.Session, error) {
return &models.Session{ID: "sess-1", UserID: "user-123"}, nil
},
DestroyFunc: func(_ context.Context, sessionID string) error {
assert.Equal(t, "sess-1", sessionID)
destroyCalled = true
return nil
},
}
r, tm := newConvTestRouter(mgr)
access, _, err := tm.GeneratePair("user-123", "alice")
require.NoError(t, err)
req := httptest.NewRequest(http.MethodDelete, "/api/conversations/sess-1", nil)
req.Header.Set("Authorization", "Bearer "+access)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusNoContent, w.Code)
assert.True(t, destroyCalled)
}
func TestConversationDelete_Forbidden(t *testing.T) {
mgr := &mockSessionManager{
GetFunc: func(_ context.Context, _ string) (*models.Session, error) {
return &models.Session{ID: "sess-1", UserID: "other-user"}, nil
},
}
r, tm := newConvTestRouter(mgr)
access, _, err := tm.GeneratePair("user-123", "alice")
require.NoError(t, err)
req := httptest.NewRequest(http.MethodDelete, "/api/conversations/sess-1", nil)
req.Header.Set("Authorization", "Bearer "+access)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusNotFound, w.Code)
}
// --- GetMessages ---
func TestConversationGetMessages_Success(t *testing.T) {
mgr := &mockSessionManager{
GetFunc: func(_ context.Context, _ string) (*models.Session, error) {
return &models.Session{ID: "sess-1", UserID: "user-123"}, nil
},
GetHistoryFunc: func(_ context.Context, sessionID string, limit int) ([]models.Message, error) {
assert.Equal(t, "sess-1", sessionID)
assert.Equal(t, 0, limit) // 获取全量
return []models.Message{
{Role: "user", Content: "你好"},
{Role: "assistant", Content: "你好!有什么可以帮助你的吗?"},
{Role: "user", Content: "今天天气怎么样?"},
{Role: "assistant", Content: "今天天气不错!"},
}, nil
},
}
r, tm := newConvTestRouter(mgr)
access, _, err := tm.GeneratePair("user-123", "alice")
require.NoError(t, err)
req := httptest.NewRequest(http.MethodGet, "/api/conversations/sess-1/messages", nil)
req.Header.Set("Authorization", "Bearer "+access)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var resp map[string]interface{}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
assert.Equal(t, float64(4), resp["total"])
msgs := resp["messages"].([]interface{})
assert.Len(t, msgs, 4)
}
func TestConversationGetMessages_WithLimit(t *testing.T) {
mgr := &mockSessionManager{
GetFunc: func(_ context.Context, _ string) (*models.Session, error) {
return &models.Session{ID: "sess-1", UserID: "user-123"}, nil
},
GetHistoryFunc: func(_ context.Context, _ string, _ int) ([]models.Message, error) {
return []models.Message{
{Role: "user", Content: "消息1"},
{Role: "assistant", Content: "回复1"},
{Role: "user", Content: "消息2"},
{Role: "assistant", Content: "回复2"},
}, nil
},
}
r, tm := newConvTestRouter(mgr)
access, _, err := tm.GeneratePair("user-123", "alice")
require.NoError(t, err)
req := httptest.NewRequest(http.MethodGet, "/api/conversations/sess-1/messages?limit=2", nil)
req.Header.Set("Authorization", "Bearer "+access)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var resp map[string]interface{}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
msgs := resp["messages"].([]interface{})
assert.Len(t, msgs, 2)
}
func TestConversationGetMessages_WithBefore(t *testing.T) {
mgr := &mockSessionManager{
GetFunc: func(_ context.Context, _ string) (*models.Session, error) {
return &models.Session{ID: "sess-1", UserID: "user-123"}, nil
},
GetHistoryFunc: func(_ context.Context, _ string, _ int) ([]models.Message, error) {
return []models.Message{
{Role: "user", Content: "消息1"},
{Role: "assistant", Content: "回复1"},
{Role: "user", Content: "消息2"},
{Role: "assistant", Content: "回复2"},
}, nil
},
}
r, tm := newConvTestRouter(mgr)
access, _, err := tm.GeneratePair("user-123", "alice")
require.NoError(t, err)
req := httptest.NewRequest(http.MethodGet, "/api/conversations/sess-1/messages?before=2&limit=10", nil)
req.Header.Set("Authorization", "Bearer "+access)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var resp map[string]interface{}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
// before=2 表示取 index 0..1,共 2 条
msgs := resp["messages"].([]interface{})
assert.Len(t, msgs, 2)
}
func TestConversationGetMessages_Forbidden(t *testing.T) {
mgr := &mockSessionManager{
GetFunc: func(_ context.Context, _ string) (*models.Session, error) {
return &models.Session{ID: "sess-1", UserID: "other-user"}, nil
},
}
r, tm := newConvTestRouter(mgr)
access, _, err := tm.GeneratePair("user-123", "alice")
require.NoError(t, err)
req := httptest.NewRequest(http.MethodGet, "/api/conversations/sess-1/messages", nil)
req.Header.Set("Authorization", "Bearer "+access)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusNotFound, w.Code)
}