You've already forked agentic-coding-workflow
feat(chat): ConversationService with title generation hook
This commit is contained in:
@@ -0,0 +1,203 @@
|
|||||||
|
package chat
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"github.com/yan1h/agent-coding-workflow/internal/audit"
|
||||||
|
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
|
||||||
|
"github.com/yan1h/agent-coding-workflow/internal/infra/llm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// compile-time assertion
|
||||||
|
var _ ConversationService = (*conversationService)(nil)
|
||||||
|
|
||||||
|
type conversationService struct {
|
||||||
|
repo Repository
|
||||||
|
registry *llm.Registry
|
||||||
|
auditor audit.Recorder
|
||||||
|
log *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewConversationService constructs a ConversationService.
|
||||||
|
func NewConversationService(repo Repository, reg *llm.Registry, a audit.Recorder, log *slog.Logger) ConversationService {
|
||||||
|
return &conversationService{repo: repo, registry: reg, auditor: a, log: log}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create validates the model and optional template, then inserts a new conversation.
|
||||||
|
func (s *conversationService) Create(ctx context.Context, userID uuid.UUID, in CreateConversationInput) (*Conversation, error) {
|
||||||
|
// Validate model exists and is enabled.
|
||||||
|
model, err := s.repo.GetLLMModel(ctx, in.ModelID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !model.Enabled {
|
||||||
|
return nil, errs.New(errs.CodeChatModelNotFound, "model is disabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve system prompt: custom override > template content > empty string.
|
||||||
|
var systemPrompt string
|
||||||
|
if in.SystemPrompt != nil {
|
||||||
|
systemPrompt = *in.SystemPrompt
|
||||||
|
} else if in.TemplateID != nil {
|
||||||
|
tpl, err := s.repo.GetPromptTemplate(ctx, *in.TemplateID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// Check user-scoped template ownership.
|
||||||
|
if tpl.Scope == TemplateScopeUser && (tpl.OwnerID == nil || *tpl.OwnerID != userID) {
|
||||||
|
return nil, errs.New(errs.CodeForbidden, "template not accessible")
|
||||||
|
}
|
||||||
|
systemPrompt = tpl.Content
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := uuid.NewV7()
|
||||||
|
if err != nil {
|
||||||
|
return nil, errs.Wrap(err, errs.CodeInternal, "generate conversation id")
|
||||||
|
}
|
||||||
|
|
||||||
|
c, err := s.repo.InsertConversation(ctx, InsertConversationParams{
|
||||||
|
ID: id,
|
||||||
|
UserID: userID,
|
||||||
|
ProjectID: in.ProjectID,
|
||||||
|
WorkspaceID: in.WorkspaceID,
|
||||||
|
IssueID: in.IssueID,
|
||||||
|
ModelID: in.ModelID,
|
||||||
|
SystemPrompt: systemPrompt,
|
||||||
|
Title: nil,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
s.tryAudit(ctx, audit.Entry{
|
||||||
|
UserID: &userID,
|
||||||
|
Action: "chat.conversation.created",
|
||||||
|
TargetType: "conversation",
|
||||||
|
TargetID: c.ID.String(),
|
||||||
|
})
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns a conversation and its most recent messages, enforcing ownership.
|
||||||
|
func (s *conversationService) Get(ctx context.Context, userID, id uuid.UUID) (*Conversation, []Message, error) {
|
||||||
|
c, err := s.repo.GetConversation(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if c.UserID != userID {
|
||||||
|
return nil, nil, errs.New(errs.CodeForbidden, "not conversation owner")
|
||||||
|
}
|
||||||
|
msgs, err := s.repo.ListMessagesByConversation(ctx, id, nil, 50)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
return c, msgs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// List returns conversations for the given user, defaulting limit to 50.
|
||||||
|
func (s *conversationService) List(ctx context.Context, userID uuid.UUID, f ConversationFilter) ([]Conversation, error) {
|
||||||
|
if f.Limit == 0 {
|
||||||
|
f.Limit = 50
|
||||||
|
}
|
||||||
|
return s.repo.ListConversationsByUser(ctx, userID, f)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateTitle manually sets a conversation title, enforcing ownership.
|
||||||
|
func (s *conversationService) UpdateTitle(ctx context.Context, userID, id uuid.UUID, title string) error {
|
||||||
|
c, err := s.repo.GetConversation(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if c.UserID != userID {
|
||||||
|
return errs.New(errs.CodeForbidden, "not conversation owner")
|
||||||
|
}
|
||||||
|
return s.repo.SetConversationTitleManual(ctx, id, &title)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SoftDelete marks a conversation as deleted, enforcing ownership.
|
||||||
|
func (s *conversationService) SoftDelete(ctx context.Context, userID, id uuid.UUID) error {
|
||||||
|
c, err := s.repo.GetConversation(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if c.UserID != userID {
|
||||||
|
return errs.New(errs.CodeForbidden, "not conversation owner")
|
||||||
|
}
|
||||||
|
if err := s.repo.SoftDeleteConversation(ctx, id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.tryAudit(ctx, audit.Entry{
|
||||||
|
UserID: &userID,
|
||||||
|
Action: "chat.conversation.deleted",
|
||||||
|
TargetType: "conversation",
|
||||||
|
TargetID: id.String(),
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateTitle is NOT on the ConversationService interface.
|
||||||
|
// It is called by the streamer (T14) via type assertion on the concrete type.
|
||||||
|
// It runs in a fire-and-forget goroutine; errors are silently swallowed.
|
||||||
|
func (s *conversationService) GenerateTitle(ctx context.Context, conversationID uuid.UUID) {
|
||||||
|
tCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
titleModel, err := s.repo.GetTitleGeneratorModel(tCtx)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Warn("GenerateTitle: no title model", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
first, err := s.repo.GetFirstUserMessage(tCtx, conversationID)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Warn("GenerateTitle: no first user message", "conversation_id", conversationID, "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := s.registry.GetClient(tCtx, titleModel.EndpointID)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Warn("GenerateTitle: get client failed", "endpoint_id", titleModel.EndpointID, "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
req := llm.Request{
|
||||||
|
SystemPrompt: "用 ≤20 字概括下面对话主题,仅输出标题:",
|
||||||
|
Messages: []llm.Message{{
|
||||||
|
Role: llm.RoleUser,
|
||||||
|
Blocks: []llm.ContentBlock{{Type: "text", Text: first.Content}},
|
||||||
|
}},
|
||||||
|
MaxOutputTokens: 64,
|
||||||
|
}
|
||||||
|
|
||||||
|
ch, err := client.Stream(tCtx, titleModel.ModelID, req)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Warn("GenerateTitle: stream failed", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var title string
|
||||||
|
for ev := range ch {
|
||||||
|
if ev.Type == llm.EventTextDelta {
|
||||||
|
title += ev.Text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
title = strings.TrimSpace(title)
|
||||||
|
if title == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.repo.UpdateConversationTitle(tCtx, conversationID, &title); err != nil {
|
||||||
|
s.log.Warn("GenerateTitle: update title failed", "conversation_id", conversationID, "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *conversationService) tryAudit(ctx context.Context, e audit.Entry) {
|
||||||
|
if err := s.auditor.Record(ctx, e); err != nil {
|
||||||
|
s.log.Warn("audit record failed", "action", e.Action, "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,433 @@
|
|||||||
|
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) 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)
|
||||||
|
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)
|
||||||
|
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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user