package service import ( "context" "errors" "fmt" "testing" "time" "github.com/gochat/gochat/internal/channel" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" "github.com/gochat/gochat/internal/worker" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gorm.io/driver/sqlite" "gorm.io/gorm" ) func setupCaptainConversationWorkerTest(t *testing.T) (*gorm.DB, *CaptainConversationService, *MessageService, *model.Account, *model.Inbox, *model.Conversation, *model.CaptainAssistant) { t.Helper() dbName := fmt.Sprintf("file:%s?mode=memory&cache=private", t.Name()) db, err := gorm.Open(sqlite.Open(dbName), &gorm.Config{}) require.NoError(t, err) require.NoError(t, db.AutoMigrate(&model.Account{}, &model.Inbox{}, &model.Contact{}, &model.Conversation{}, &model.Message{}, &model.Attachment{}, &model.CaptainAssistant{}, &model.CaptainInbox{}, &model.BackgroundJob{})) t.Cleanup(func() { sqlDB, _ := db.DB() sqlDB.Close() }) account := &model.Account{Name: "Captain Org", Active: true} require.NoError(t, db.Create(account).Error) inbox := &model.Inbox{AccountID: account.ID, Name: "Web", ChannelType: "web_widget", ChannelID: 1} require.NoError(t, db.Create(inbox).Error) contact := &model.Contact{AccountID: account.ID, Name: "Customer", Email: "customer@example.com"} require.NoError(t, db.Create(contact).Error) conversation := &model.Conversation{AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, Status: string(model.ConversationStatusPending), ChannelType: inbox.ChannelType, Channel: inbox.ChannelType} require.NoError(t, db.Create(conversation).Error) assistant := &model.CaptainAssistant{AccountID: account.ID, Name: "Fin", Config: []byte(`{"handoff_message":"Let me connect you."}`), Status: model.AssistantStatusActive} require.NoError(t, db.Create(assistant).Error) require.NoError(t, db.Create(&model.CaptainInbox{AccountID: account.ID, AssistantID: assistant.ID, InboxID: inbox.ID}).Error) conversationSvc := NewCaptainConversationService(db, nil) messageSvc := NewMessageService(repository.NewMessageRepo(db), channel.NewDispatcher(), nil) return db, conversationSvc, messageSvc, account, inbox, conversation, assistant } func TestCaptainConversationResponseJobQueuesFromIncomingMessage(t *testing.T) { db, conversationSvc, messageSvc, account, _, conversation, assistant := setupCaptainConversationWorkerTest(t) conversationSvc.SetResponseBackend(&fakeCaptainConversationBackend{response: &CaptainConversationResponse{Content: "Welcome to Captain", AgentName: "Fin"}}) wp := worker.NewWorkerPool(db) conversationSvc.SetWorkerPool(wp) messageSvc.SetWorkerPool(wp) incoming, err := messageSvc.Create(context.Background(), account.ID, 99, CreateMessageRequest{ConversationID: conversation.ID, Content: "Hello", MessageType: string(model.MessageTypeIncoming), ContentType: string(model.MessageContentTypeText)}) require.NoError(t, err) require.NotZero(t, incoming.ID) var count int64 require.NoError(t, db.Model(&model.BackgroundJob{}).Where("job_type = ? AND status = ?", TaskTypeCaptainConversationResponseBuilder, model.BackgroundJobStatusQueued).Count(&count).Error) assert.Equal(t, int64(1), count) processed, err := wp.ProcessOne(context.Background()) require.NoError(t, err) assert.True(t, processed) var outgoing model.Message require.NoError(t, db.Where("conversation_id = ? AND message_type = ?", conversation.ID, model.MessageTypeOutgoing).First(&outgoing).Error) assert.Equal(t, assistant.ID, *outgoing.SenderID) assert.Equal(t, "CaptainAssistant", outgoing.SenderType) assert.Equal(t, "Welcome to Captain", outgoing.Content) require.NoError(t, db.Model(&model.BackgroundJob{}).Where("job_type = ?", TaskTypeMessageSendReply).Count(&count).Error) assert.Equal(t, int64(1), count) } func TestCaptainConversationResponseJobHandoffOpensConversation(t *testing.T) { db, conversationSvc, _, account, _, conversation, _ := setupCaptainConversationWorkerTest(t) conversationSvc.SetResponseBackend(&fakeCaptainConversationBackend{response: &CaptainConversationResponse{Action: "handoff"}}) wp := worker.NewWorkerPool(db) conversationSvc.SetWorkerPool(wp) _, err := wp.Enqueue(context.Background(), TaskTypeCaptainConversationResponseBuilder, captainConversationResponseBuilderJob{AccountID: account.ID, ConversationID: conversation.ID, AssistantID: 1, MessageID: 1}, worker.WithMaxAttempts(3)) require.NoError(t, err) processed, err := wp.ProcessOne(context.Background()) require.NoError(t, err) assert.True(t, processed) var updated model.Conversation require.NoError(t, db.First(&updated, conversation.ID).Error) assert.Equal(t, string(model.ConversationStatusOpen), updated.Status) var outgoing model.Message require.NoError(t, db.Where("conversation_id = ? AND message_type = ?", conversation.ID, model.MessageTypeOutgoing).First(&outgoing).Error) assert.Equal(t, "Let me connect you.", outgoing.Content) } func TestCaptainConversationResponseJobRetriesWhenProviderDisabled(t *testing.T) { db, conversationSvc, _, account, _, conversation, _ := setupCaptainConversationWorkerTest(t) now := time.Date(2026, 6, 6, 4, 30, 0, 0, time.UTC) wp := worker.NewWorkerPoolWithOptions(db, worker.WithNow(func() time.Time { return now }), worker.WithBackoff(func(attempt int) time.Duration { return time.Minute })) conversationSvc.SetWorkerPool(wp) _, err := wp.Enqueue(context.Background(), TaskTypeCaptainConversationResponseBuilder, captainConversationResponseBuilderJob{AccountID: account.ID, ConversationID: conversation.ID, AssistantID: 1, MessageID: 1}, worker.WithMaxAttempts(3)) require.NoError(t, err) processed, err := wp.ProcessOne(context.Background()) require.Error(t, err) assert.True(t, processed) var job model.BackgroundJob require.NoError(t, db.Where("job_type = ?", TaskTypeCaptainConversationResponseBuilder).First(&job).Error) assert.Equal(t, model.BackgroundJobStatusRetrying, job.Status) assert.Contains(t, job.LastError, "captain conversation response generation disabled") } func TestCaptainConversationResponseSkipsNonPendingConversation(t *testing.T) { db, conversationSvc, messageSvc, account, _, conversation, _ := setupCaptainConversationWorkerTest(t) require.NoError(t, db.Model(&model.Conversation{}).Where("id = ?", conversation.ID).Update("status", string(model.ConversationStatusOpen)).Error) wp := worker.NewWorkerPool(db) conversationSvc.SetWorkerPool(wp) messageSvc.SetWorkerPool(wp) _, err := messageSvc.Create(context.Background(), account.ID, 99, CreateMessageRequest{ConversationID: conversation.ID, Content: "Hello", MessageType: string(model.MessageTypeIncoming), ContentType: string(model.MessageContentTypeText)}) require.NoError(t, err) var count int64 require.NoError(t, db.Model(&model.BackgroundJob{}).Where("job_type = ?", TaskTypeCaptainConversationResponseBuilder).Count(&count).Error) assert.Equal(t, int64(0), count) } type fakeCaptainConversationBackend struct { response *CaptainConversationResponse err error } func (b *fakeCaptainConversationBackend) GenerateCaptainConversationResponse(ctx context.Context, req CaptainConversationResponseRequest) (*CaptainConversationResponse, error) { if b.err != nil { return nil, b.err } if b.response == nil { return nil, errors.New("missing response") } return b.response, nil }