Files
gochat/backend/internal/service/captain_conversation_worker_test.go
T
rogee aeddedf2a3 Reorganize repo: backend/, deploy/, docs/ layout + AGENTS.md
Restructure the monorepo into clear top-level directories:
- backend/: Go module root (cmd, internal, pkg, configs, migrations,
  docs/swagger, scripts, tests, go.mod, Makefile, .air.toml)
- deploy/: Docker (Dockerfile, docker-compose*), quickstart, fluentd
- docs/: project documentation + reports/ (moved from repo root)
- AGENTS.md: new AI coding-agent guide at repo root

Update all references to the new layout:
- Dockerfile: COPY backend/go.mod, COPY backend/ (context = repo root)
- docker-compose files: context ../.., dockerfile deploy/docker/Dockerfile,
  env_file ../../.env, volume mounts ../../backend:/app
- deploy/quickstart/compose.yaml: dockerfile deploy/docker/Dockerfile
- CI: working-directory: backend for go commands, file deploy/docker/Dockerfile,
  coverage path backend/coverage.out, health_check backend/scripts/
- backend/Makefile: docker target uses -f ../deploy/docker/Dockerfile ../
- README: architecture tree, quickstart, config paths updated

Move root stray scripts (rename_models.*, run_m11_tests.sh, verify_build.sh,
gorm_bool_main.go) to backend/scripts/legacy/. All moves via git mv to
preserve history. Build, vet, SQLite tests, and docker compose config verified.
2026-07-07 14:44:12 +08:00

141 lines
7.2 KiB
Go

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: "API", ChannelType: "api", 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
}