Files
agentic-coding-workflow/internal/chat/service_conversation_test.go
2026-06-10 17:53:09 +08:00

470 lines
13 KiB
Go

package chat
import (
"context"
"sync"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yan1h/agent-coding-workflow/internal/audit"
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
)
// ===== conversation fake repo extension =====
// convFakeRepo embeds fakeRepo and overrides conversation/template/model methods.
type convFakeRepo struct {
*fakeRepo
mu sync.Mutex
conversations map[uuid.UUID]*Conversation
messages map[uuid.UUID][]Message
templates map[uuid.UUID]*PromptTemplate
titleUpdated map[uuid.UUID]*string
softDeleted []uuid.UUID
}
func newConvFakeRepo() *convFakeRepo {
return &convFakeRepo{
fakeRepo: newFakeRepo(),
conversations: make(map[uuid.UUID]*Conversation),
messages: make(map[uuid.UUID][]Message),
templates: make(map[uuid.UUID]*PromptTemplate),
titleUpdated: make(map[uuid.UUID]*string),
}
}
func (r *convFakeRepo) InsertConversation(_ context.Context, p InsertConversationParams) (*Conversation, error) {
r.mu.Lock()
defer r.mu.Unlock()
c := &Conversation{
ID: p.ID,
UserID: p.UserID,
ProjectID: p.ProjectID,
WorkspaceID: p.WorkspaceID,
IssueID: p.IssueID,
RequirementID: p.RequirementID,
ModelID: p.ModelID,
SystemPrompt: p.SystemPrompt,
Title: p.Title,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
r.conversations[p.ID] = c
return c, nil
}
func (r *convFakeRepo) GetConversation(_ context.Context, id uuid.UUID) (*Conversation, error) {
r.mu.Lock()
defer r.mu.Unlock()
c, ok := r.conversations[id]
if !ok {
return nil, errs.New(errs.CodeChatConversationNotFound, "conversation not found")
}
return c, nil
}
func (r *convFakeRepo) ListConversationsByUser(_ context.Context, userID uuid.UUID, f ConversationFilter) ([]Conversation, error) {
r.mu.Lock()
defer r.mu.Unlock()
var out []Conversation
for _, c := range r.conversations {
if c.UserID != userID || c.DeletedAt != nil {
continue
}
if f.RequirementID != nil && (c.RequirementID == nil || *c.RequirementID != *f.RequirementID) {
continue
}
out = append(out, *c)
}
limit := f.Limit
if limit > 0 && len(out) > limit {
out = out[:limit]
}
return out, nil
}
func (r *convFakeRepo) SetConversationTitleManual(_ context.Context, id uuid.UUID, title *string) error {
r.mu.Lock()
defer r.mu.Unlock()
c, ok := r.conversations[id]
if !ok {
return errs.New(errs.CodeChatConversationNotFound, "conversation not found")
}
c.Title = title
r.titleUpdated[id] = title
return nil
}
func (r *convFakeRepo) UpdateConversationTitle(_ context.Context, id uuid.UUID, title *string) error {
r.mu.Lock()
defer r.mu.Unlock()
c, ok := r.conversations[id]
if !ok {
return errs.New(errs.CodeChatConversationNotFound, "conversation not found")
}
c.Title = title
r.titleUpdated[id] = title
return nil
}
func (r *convFakeRepo) SoftDeleteConversation(_ context.Context, id uuid.UUID) error {
r.mu.Lock()
defer r.mu.Unlock()
c, ok := r.conversations[id]
if !ok {
return errs.New(errs.CodeChatConversationNotFound, "conversation not found")
}
now := time.Now()
c.DeletedAt = &now
r.softDeleted = append(r.softDeleted, id)
return nil
}
func (r *convFakeRepo) ListAttachmentsForMessage(_ context.Context, _ int64) ([]Attachment, error) {
return nil, nil
}
func (r *convFakeRepo) ListMessagesByConversation(_ context.Context, convID uuid.UUID, _ *int64, limit int) ([]Message, error) {
r.mu.Lock()
defer r.mu.Unlock()
msgs := r.messages[convID]
if limit > 0 && len(msgs) > limit {
msgs = msgs[:limit]
}
return msgs, nil
}
func (r *convFakeRepo) GetLLMModel(_ context.Context, id uuid.UUID) (*LLMModel, error) {
for _, m := range r.fakeRepo.models {
if m.ID == id {
cp := m
return &cp, nil
}
}
return nil, errs.New(errs.CodeChatModelNotFound, "model not found")
}
func (r *convFakeRepo) GetPromptTemplate(_ context.Context, id uuid.UUID) (*PromptTemplate, error) {
t, ok := r.templates[id]
if !ok {
return nil, errs.New(errs.CodeChatTemplateNotFound, "template not found")
}
return t, nil
}
// ===== recording auditor =====
type recordingAuditor struct {
mu sync.Mutex
entries []audit.Entry
}
func (a *recordingAuditor) Record(_ context.Context, e audit.Entry) error {
a.mu.Lock()
defer a.mu.Unlock()
a.entries = append(a.entries, e)
return nil
}
func (a *recordingAuditor) lastEntry() (audit.Entry, bool) {
a.mu.Lock()
defer a.mu.Unlock()
if len(a.entries) == 0 {
return audit.Entry{}, false
}
return a.entries[len(a.entries)-1], true
}
// ===== helpers =====
func newConvSvc(repo *convFakeRepo, aud audit.Recorder) ConversationService {
return NewConversationService(repo, nil, aud, discardLogger())
}
func addModel(repo *convFakeRepo, enabled bool) LLMModel {
m := LLMModel{
ID: uuid.Must(uuid.NewV7()),
ModelID: "test-model",
Enabled: enabled,
}
repo.fakeRepo.models = append(repo.fakeRepo.models, m)
return m
}
func addTemplate(repo *convFakeRepo, scope TemplateScope, ownerID *uuid.UUID) *PromptTemplate {
t := &PromptTemplate{
ID: uuid.Must(uuid.NewV7()),
Name: "test-tpl",
Content: "system prompt from template",
Scope: scope,
OwnerID: ownerID,
}
repo.templates[t.ID] = t
return t
}
func addConversation(repo *convFakeRepo, userID uuid.UUID) *Conversation {
c := &Conversation{
ID: uuid.Must(uuid.NewV7()),
UserID: userID,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
repo.conversations[c.ID] = c
return c
}
// ===== tests =====
func TestConversationService_Create_HappyPath(t *testing.T) {
t.Helper()
repo := newConvFakeRepo()
aud := &recordingAuditor{}
svc := newConvSvc(repo, aud)
userID := uuid.Must(uuid.NewV7())
m := addModel(repo, true)
c, err := svc.Create(context.Background(), userID, CreateConversationInput{ModelID: m.ID})
require.NoError(t, err)
require.NotNil(t, c)
assert.Equal(t, userID, c.UserID)
assert.Equal(t, m.ID, c.ModelID)
assert.Empty(t, c.SystemPrompt)
entry, ok := aud.lastEntry()
require.True(t, ok)
assert.Equal(t, "chat.conversation.created", entry.Action)
assert.Equal(t, &userID, entry.UserID)
}
func TestConversationService_Create_WithRequirementMount(t *testing.T) {
repo := newConvFakeRepo()
svc := newConvSvc(repo, noopAuditor{})
userID := uuid.Must(uuid.NewV7())
m := addModel(repo, true)
reqID := uuid.Must(uuid.NewV7())
projID := uuid.Must(uuid.NewV7())
c, err := svc.Create(context.Background(), userID, CreateConversationInput{
ModelID: m.ID, ProjectID: &projID, RequirementID: &reqID,
})
require.NoError(t, err)
require.NotNil(t, c.RequirementID)
assert.Equal(t, reqID, *c.RequirementID)
// List 按 requirement 过滤:命中 1 条;其他 requirement 不命中。
_, err = svc.Create(context.Background(), userID, CreateConversationInput{ModelID: m.ID})
require.NoError(t, err)
list, err := svc.List(context.Background(), userID, ConversationFilter{RequirementID: &reqID})
require.NoError(t, err)
require.Len(t, list, 1)
other := uuid.Must(uuid.NewV7())
list, err = svc.List(context.Background(), userID, ConversationFilter{RequirementID: &other})
require.NoError(t, err)
require.Empty(t, list)
}
func TestConversationService_Create_WithSystemTemplate(t *testing.T) {
repo := newConvFakeRepo()
svc := newConvSvc(repo, noopAuditor{})
userID := uuid.Must(uuid.NewV7())
m := addModel(repo, true)
tpl := addTemplate(repo, TemplateScopeSystem, nil)
c, err := svc.Create(context.Background(), userID, CreateConversationInput{
ModelID: m.ID,
TemplateID: &tpl.ID,
})
require.NoError(t, err)
assert.Equal(t, "system prompt from template", c.SystemPrompt)
}
func TestConversationService_Create_TemplateUserScopeOwnerMismatch(t *testing.T) {
repo := newConvFakeRepo()
svc := newConvSvc(repo, noopAuditor{})
userID := uuid.Must(uuid.NewV7())
otherID := uuid.Must(uuid.NewV7())
m := addModel(repo, true)
tpl := addTemplate(repo, TemplateScopeUser, &otherID)
_, err := svc.Create(context.Background(), userID, CreateConversationInput{
ModelID: m.ID,
TemplateID: &tpl.ID,
})
require.Error(t, err)
var appErr *errs.AppError
require.ErrorAs(t, err, &appErr)
assert.Equal(t, errs.CodeForbidden, appErr.Code)
}
func TestConversationService_Create_WithSystemPromptOverride(t *testing.T) {
repo := newConvFakeRepo()
svc := newConvSvc(repo, noopAuditor{})
userID := uuid.Must(uuid.NewV7())
m := addModel(repo, true)
tpl := addTemplate(repo, TemplateScopeSystem, nil)
override := "custom system prompt"
c, err := svc.Create(context.Background(), userID, CreateConversationInput{
ModelID: m.ID,
TemplateID: &tpl.ID,
SystemPrompt: &override,
})
require.NoError(t, err)
// override takes precedence; template content ignored
assert.Equal(t, override, c.SystemPrompt)
}
func TestConversationService_Create_ModelNotFound(t *testing.T) {
repo := newConvFakeRepo()
svc := newConvSvc(repo, noopAuditor{})
userID := uuid.Must(uuid.NewV7())
_, err := svc.Create(context.Background(), userID, CreateConversationInput{
ModelID: uuid.Must(uuid.NewV7()),
})
require.Error(t, err)
var appErr *errs.AppError
require.ErrorAs(t, err, &appErr)
assert.Equal(t, errs.CodeChatModelNotFound, appErr.Code)
}
func TestConversationService_Create_ModelDisabled(t *testing.T) {
repo := newConvFakeRepo()
svc := newConvSvc(repo, noopAuditor{})
userID := uuid.Must(uuid.NewV7())
m := addModel(repo, false)
_, err := svc.Create(context.Background(), userID, CreateConversationInput{ModelID: m.ID})
require.Error(t, err)
var appErr *errs.AppError
require.ErrorAs(t, err, &appErr)
assert.Equal(t, errs.CodeChatModelNotFound, appErr.Code)
}
func TestConversationService_Create_TemplateNotFound(t *testing.T) {
repo := newConvFakeRepo()
svc := newConvSvc(repo, noopAuditor{})
userID := uuid.Must(uuid.NewV7())
m := addModel(repo, true)
missingID := uuid.Must(uuid.NewV7())
_, err := svc.Create(context.Background(), userID, CreateConversationInput{
ModelID: m.ID,
TemplateID: &missingID,
})
require.Error(t, err)
var appErr *errs.AppError
require.ErrorAs(t, err, &appErr)
assert.Equal(t, errs.CodeChatTemplateNotFound, appErr.Code)
}
func TestConversationService_Get_HappyPath(t *testing.T) {
repo := newConvFakeRepo()
svc := newConvSvc(repo, noopAuditor{})
userID := uuid.Must(uuid.NewV7())
conv := addConversation(repo, userID)
repo.messages[conv.ID] = []Message{
{ID: 1, ConversationID: conv.ID, Role: RoleUser, Content: "hello"},
}
got, msgs, err := svc.Get(context.Background(), userID, conv.ID, 50)
require.NoError(t, err)
require.NotNil(t, got)
assert.Equal(t, conv.ID, got.ID)
require.Len(t, msgs, 1)
assert.Equal(t, "hello", msgs[0].Content)
}
func TestConversationService_Get_NotOwner(t *testing.T) {
repo := newConvFakeRepo()
svc := newConvSvc(repo, noopAuditor{})
ownerID := uuid.Must(uuid.NewV7())
callerID := uuid.Must(uuid.NewV7())
conv := addConversation(repo, ownerID)
_, _, err := svc.Get(context.Background(), callerID, conv.ID, 50)
require.Error(t, err)
var appErr *errs.AppError
require.ErrorAs(t, err, &appErr)
assert.Equal(t, errs.CodeForbidden, appErr.Code)
}
func TestConversationService_List_DefaultLimit(t *testing.T) {
repo := newConvFakeRepo()
svc := newConvSvc(repo, noopAuditor{})
userID := uuid.Must(uuid.NewV7())
// Add 3 conversations
for i := 0; i < 3; i++ {
addConversation(repo, userID)
}
// Pass Limit=0 — should default to 50 internally (all 3 returned)
convs, err := svc.List(context.Background(), userID, ConversationFilter{Limit: 0})
require.NoError(t, err)
assert.Len(t, convs, 3)
}
func TestConversationService_UpdateTitle_HappyPath(t *testing.T) {
repo := newConvFakeRepo()
svc := newConvSvc(repo, noopAuditor{})
userID := uuid.Must(uuid.NewV7())
conv := addConversation(repo, userID)
err := svc.UpdateTitle(context.Background(), userID, conv.ID, "new title")
require.NoError(t, err)
// Verify title was written
title, ok := repo.titleUpdated[conv.ID]
require.True(t, ok)
require.NotNil(t, title)
assert.Equal(t, "new title", *title)
}
func TestConversationService_UpdateTitle_NotOwner(t *testing.T) {
repo := newConvFakeRepo()
svc := newConvSvc(repo, noopAuditor{})
ownerID := uuid.Must(uuid.NewV7())
callerID := uuid.Must(uuid.NewV7())
conv := addConversation(repo, ownerID)
err := svc.UpdateTitle(context.Background(), callerID, conv.ID, "hack")
require.Error(t, err)
var appErr *errs.AppError
require.ErrorAs(t, err, &appErr)
assert.Equal(t, errs.CodeForbidden, appErr.Code)
}
func TestConversationService_SoftDelete_HappyPath(t *testing.T) {
repo := newConvFakeRepo()
aud := &recordingAuditor{}
svc := newConvSvc(repo, aud)
userID := uuid.Must(uuid.NewV7())
conv := addConversation(repo, userID)
err := svc.SoftDelete(context.Background(), userID, conv.ID)
require.NoError(t, err)
require.Contains(t, repo.softDeleted, conv.ID)
entry, ok := aud.lastEntry()
require.True(t, ok)
assert.Equal(t, "chat.conversation.deleted", entry.Action)
assert.Equal(t, &userID, entry.UserID)
}
func TestConversationService_SoftDelete_NotOwner(t *testing.T) {
repo := newConvFakeRepo()
svc := newConvSvc(repo, noopAuditor{})
ownerID := uuid.Must(uuid.NewV7())
callerID := uuid.Must(uuid.NewV7())
conv := addConversation(repo, ownerID)
err := svc.SoftDelete(context.Background(), callerID, conv.ID)
require.Error(t, err)
var appErr *errs.AppError
require.ErrorAs(t, err, &appErr)
assert.Equal(t, errs.CodeForbidden, appErr.Code)
}