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, 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 { 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_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) }