package service import ( "context" "encoding/json" "errors" "fmt" "testing" "time" "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 setupCopilotResponseWorkerTest(t *testing.T) (*gorm.DB, *CopilotService, *model.Account, *model.User, *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.User{}, &model.CaptainAssistant{}, &model.CopilotThread{}, &model.CopilotMessage{}, &model.CopilotSuggestionMessage{}, &model.BackgroundJob{})) t.Cleanup(func() { sqlDB, _ := db.DB() sqlDB.Close() }) account := &model.Account{Name: "Copilot Org", Active: true} require.NoError(t, db.Create(account).Error) user := &model.User{AccountID: account.ID, Name: "Agent", Email: "agent@example.com", Password: "secret", Active: true} require.NoError(t, db.Create(user).Error) assistant := &model.CaptainAssistant{AccountID: account.ID, Name: "Helper", Config: json.RawMessage(`{}`), Status: model.AssistantStatusActive} require.NoError(t, db.Create(assistant).Error) svc := NewCopilotService(repository.NewCopilotThreadRepo(db), repository.NewCopilotMessageRepo(db), repository.NewCopilotSuggestionRepo(db), nil, repository.NewCaptainAssistantRepo(db)) return db, svc, account, user, assistant } func TestCopilotResponseJobQueuesAndPersistsAssistantReply(t *testing.T) { db, svc, account, user, assistant := setupCopilotResponseWorkerTest(t) svc.SetResponseBackend(&fakeCopilotResponseBackend{messages: []CopilotGeneratedMessage{{MessageType: model.CopilotMessageTypeAssistant, Message: map[string]any{"content": "Here is the answer"}}}}) wp := worker.NewWorkerPool(db) svc.SetWorkerPool(wp) thread, err := svc.CreateThread(context.Background(), account.ID, user.ID, &CreateThreadRequest{Message: "Need help", AssistantID: assistant.ID, ConversationID: 123}) require.NoError(t, err) require.NotNil(t, thread) var count int64 require.NoError(t, db.Model(&model.CopilotMessage{}).Where("copilot_thread_id = ?", thread.ID).Count(&count).Error) assert.Equal(t, int64(1), count) require.NoError(t, db.Model(&model.BackgroundJob{}).Where("job_type = ? AND status = ?", TaskTypeCaptainCopilotResponse, 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 messages []model.CopilotMessage require.NoError(t, db.Where("copilot_thread_id = ?", thread.ID).Order("id ASC").Find(&messages).Error) require.Len(t, messages, 2) assert.Equal(t, model.CopilotMessageTypeUser, messages[0].MessageType) assert.Equal(t, model.CopilotMessageTypeAssistant, messages[1].MessageType) assert.Equal(t, "Here is the answer", messages[1].GetMessageContent()) } func TestCopilotResponseJobUsesDisabledFallbackWithoutProvider(t *testing.T) { db, svc, account, user, assistant := setupCopilotResponseWorkerTest(t) wp := worker.NewWorkerPool(db) svc.SetWorkerPool(wp) thread, err := svc.CreateThread(context.Background(), account.ID, user.ID, &CreateThreadRequest{Message: "Need help", AssistantID: assistant.ID}) require.NoError(t, err) processed, err := wp.ProcessOne(context.Background()) require.NoError(t, err) assert.True(t, processed) var assistantMsg model.CopilotMessage require.NoError(t, db.Where("copilot_thread_id = ? AND message_type = ?", thread.ID, model.CopilotMessageTypeAssistant).First(&assistantMsg).Error) assert.Equal(t, CopilotUnavailableMessage, assistantMsg.GetMessageContent()) } func TestCopilotResponseJobRetriesBackendFailure(t *testing.T) { db, svc, account, user, assistant := setupCopilotResponseWorkerTest(t) svc.SetResponseBackend(&fakeCopilotResponseBackend{err: errors.New("backend down")}) now := time.Date(2026, 6, 6, 4, 0, 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 })) svc.SetWorkerPool(wp) _, err := svc.CreateThread(context.Background(), account.ID, user.ID, &CreateThreadRequest{Message: "Need help", AssistantID: assistant.ID}) 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 = ?", TaskTypeCaptainCopilotResponse).First(&job).Error) assert.Equal(t, model.BackgroundJobStatusRetrying, job.Status) assert.Contains(t, job.LastError, "backend down") } type fakeCopilotResponseBackend struct { messages []CopilotGeneratedMessage err error } func (b *fakeCopilotResponseBackend) GenerateCopilotResponse(ctx context.Context, req CopilotResponseRequest) ([]CopilotGeneratedMessage, error) { if b.err != nil { return nil, b.err } return b.messages, nil }