package service import ( "context" "errors" "fmt" "testing" "time" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" "github.com/gochat/gochat/internal/worker" "github.com/pgvector/pgvector-go" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gorm.io/driver/sqlite" "gorm.io/gorm" ) func setupCaptainDocumentServiceTest(t *testing.T) (*gorm.DB, *CaptainDocumentService) { 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.CaptainAssistant{}, &model.CaptainDocument{}, &model.CaptainAssistantResponse{}, &model.BackgroundJob{})) t.Cleanup(func() { sqlDB, _ := db.DB() sqlDB.Close() }) documentRepo := repository.NewCaptainDocumentRepo(db) assistantRepo := repository.NewCaptainAssistantRepo(db) responseRepo := repository.NewCaptainAssistantResponseRepo(db) svc := NewCaptainDocumentService(documentRepo, nil, assistantRepo) svc.SetResponseRepo(responseRepo) return db, svc } func seedCaptainDocumentSyncFixture(t *testing.T, db *gorm.DB) (*model.Account, *model.Account, *model.CaptainDocument) { t.Helper() account := &model.Account{Name: "Captain Org", Active: true} otherAccount := &model.Account{Name: "Other Org", Active: true} require.NoError(t, db.Create(account).Error) require.NoError(t, db.Create(otherAccount).Error) assistant := &model.CaptainAssistant{AccountID: account.ID, Name: "Fin", Description: "Support", Status: model.AssistantStatusActive} require.NoError(t, db.Create(assistant).Error) doc := &model.CaptainDocument{ AccountID: account.ID, AssistantID: assistant.ID, Name: "Help", ExternalLink: "https://example.com/help", Status: model.DocumentStatusCompleted, SyncStatus: model.DocumentSyncStatusSynced, } require.NoError(t, db.Create(doc).Error) return account, otherAccount, doc } func TestCaptainDocumentService_SyncDocumentByAccountDisabledMarksFailed(t *testing.T) { db, svc := setupCaptainDocumentServiceTest(t) account, _, doc := seedCaptainDocumentSyncFixture(t, db) updated, err := svc.SyncDocumentByAccount(context.Background(), account.ID, doc.ID) require.NoError(t, err) assert.Equal(t, model.DocumentSyncStatusFailed, updated.SyncStatus) assert.Equal(t, "sync_disabled", updated.LastSyncErrorCode) assert.NotNil(t, updated.LastSyncAttemptedAt) } func TestCaptainDocumentService_SyncDocumentByAccountUsesFakeBackend(t *testing.T) { db, svc := setupCaptainDocumentServiceTest(t) account, _, doc := seedCaptainDocumentSyncFixture(t, db) backend := &captainDocumentFakeSyncBackend{result: &CaptainDocumentSyncResult{Title: "Fresh Help", Content: "hello\n\nworld"}} svc.SetSyncBackend(backend) updated, err := svc.SyncDocumentByAccount(context.Background(), account.ID, doc.ID) require.NoError(t, err) assert.Equal(t, doc.ID, backend.documentID) assert.Equal(t, "Fresh Help", updated.Name) assert.Equal(t, "hello\n\nworld", updated.Content) assert.Equal(t, computeFingerprint("hello world"), updated.ContentFingerprint) assert.Equal(t, model.DocumentStatusCompleted, updated.Status) assert.Equal(t, model.DocumentSyncStatusSynced, updated.SyncStatus) assert.Empty(t, updated.LastSyncErrorCode) assert.NotNil(t, updated.LastSyncedAt) } func TestCaptainDocumentService_SyncDocumentByAccountRecordsBackendFailures(t *testing.T) { db, svc := setupCaptainDocumentServiceTest(t) account, _, doc := seedCaptainDocumentSyncFixture(t, db) svc.SetSyncBackend(&captainDocumentFakeSyncBackend{err: errors.New("boom")}) updated, err := svc.SyncDocumentByAccount(context.Background(), account.ID, doc.ID) require.Error(t, err) assert.Equal(t, model.DocumentSyncStatusFailed, updated.SyncStatus) assert.Equal(t, "sync_error", updated.LastSyncErrorCode) svc.SetSyncBackend(&captainDocumentFakeSyncBackend{result: &CaptainDocumentSyncResult{ErrorCode: "access_denied"}}) updated, err = svc.SyncDocumentByAccount(context.Background(), account.ID, doc.ID) require.NoError(t, err) assert.Equal(t, model.DocumentSyncStatusFailed, updated.SyncStatus) assert.Equal(t, "access_denied", updated.LastSyncErrorCode) } func TestCaptainDocumentService_SyncDocumentByAccountScopesDocument(t *testing.T) { db, svc := setupCaptainDocumentServiceTest(t) _, otherAccount, doc := seedCaptainDocumentSyncFixture(t, db) svc.SetSyncBackend(&captainDocumentFakeSyncBackend{result: &CaptainDocumentSyncResult{Content: "fresh"}}) _, err := svc.SyncDocumentByAccount(context.Background(), otherAccount.ID, doc.ID) require.Error(t, err) } func TestCaptainDocumentService_RequestSyncQueuesDurableJob(t *testing.T) { db, svc := setupCaptainDocumentServiceTest(t) account, _, doc := seedCaptainDocumentSyncFixture(t, db) backend := &captainDocumentFakeSyncBackend{result: &CaptainDocumentSyncResult{Title: "Durable Help", Content: "fresh durable content"}} svc.SetSyncBackend(backend) now := time.Date(2026, 6, 5, 23, 0, 0, 0, time.UTC) wp := worker.NewWorkerPoolWithOptions(db, worker.WithNow(func() time.Time { return now })) svc.SetWorkerPool(wp) queued, err := svc.RequestSyncDocumentByAccount(context.Background(), account.ID, doc.ID) require.NoError(t, err) assert.Equal(t, model.DocumentSyncStatusPending, queued.SyncStatus) assert.Equal(t, uint(0), backend.documentID) var jobCount int64 require.NoError(t, db.Model(&model.BackgroundJob{}).Where("job_type = ? AND queue = ? AND status = ?", TaskTypeCaptainDocumentSync, "low", model.BackgroundJobStatusQueued).Count(&jobCount).Error) assert.Equal(t, int64(1), jobCount) processed, err := wp.ProcessOne(context.Background()) require.NoError(t, err) assert.True(t, processed) assert.Equal(t, doc.ID, backend.documentID) var synced model.CaptainDocument require.NoError(t, db.First(&synced, doc.ID).Error) assert.Equal(t, "Durable Help", synced.Name) assert.Equal(t, "fresh durable content", synced.Content) assert.Equal(t, model.DocumentSyncStatusSynced, synced.SyncStatus) assert.Empty(t, synced.LastSyncErrorCode) } func TestCaptainDocumentService_DocumentSyncJobRetriesMissingDocument(t *testing.T) { db, svc := setupCaptainDocumentServiceTest(t) now := time.Date(2026, 6, 5, 23, 15, 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 := wp.Enqueue(context.Background(), TaskTypeCaptainDocumentSync, captainDocumentSyncJob{AccountID: 999, DocumentID: 9999}, worker.WithQueue("low"), 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 = ?", TaskTypeCaptainDocumentSync).First(&job).Error) assert.Equal(t, model.BackgroundJobStatusRetrying, job.Status) assert.NotEmpty(t, job.LastError) } func TestCaptainDocumentService_RequestCrawlQueuesParserJobs(t *testing.T) { db, svc := setupCaptainDocumentServiceTest(t) account, _, doc := seedCaptainDocumentSyncFixture(t, db) now := time.Date(2026, 6, 5, 23, 30, 0, 0, time.UTC) wp := worker.NewWorkerPoolWithOptions(db, worker.WithNow(func() time.Time { return now })) svc.SetWorkerPool(wp) svc.SetCrawlBackend(&captainDocumentFakeCrawlBackend{links: []string{"https://example.com/help", "https://example.com/faq/"}}) svc.SetPageParserBackend(&captainDocumentFakePageParserBackend{pages: map[string]*CaptainDocumentSyncResult{ "https://example.com/help": {Title: "Fresh Help", Content: "fresh help content"}, "https://example.com/faq": {Title: "FAQ", Content: "answer one"}, }}) queued, err := svc.RequestCrawlDocumentByAccount(context.Background(), account.ID, doc.ID) require.NoError(t, err) assert.Equal(t, model.DocumentStatusInProgress, queued.Status) assert.Equal(t, model.DocumentSyncStatusPending, queued.SyncStatus) processed, err := wp.ProcessOne(context.Background()) require.NoError(t, err) assert.True(t, processed) var parserJobCount int64 require.NoError(t, db.Model(&model.BackgroundJob{}).Where("job_type = ? AND queue = ?", TaskTypeCaptainDocumentPageCrawlParse, "low").Count(&parserJobCount).Error) assert.Equal(t, int64(2), parserJobCount) processed, err = wp.ProcessOne(context.Background()) require.NoError(t, err) assert.True(t, processed) processed, err = wp.ProcessOne(context.Background()) require.NoError(t, err) assert.True(t, processed) var updated model.CaptainDocument require.NoError(t, db.First(&updated, doc.ID).Error) assert.Equal(t, "Fresh Help", updated.Name) assert.Equal(t, "fresh help content", updated.Content) assert.Equal(t, model.DocumentStatusCompleted, updated.Status) assert.Equal(t, model.DocumentSyncStatusSynced, updated.SyncStatus) var created model.CaptainDocument require.NoError(t, db.Where("assistant_id = ? AND external_link = ?", doc.AssistantID, "https://example.com/faq").First(&created).Error) assert.Equal(t, account.ID, created.AccountID) assert.Equal(t, "FAQ", created.Name) assert.Equal(t, "answer one", created.Content) } func TestCaptainDocumentService_CrawlJobDisabledMarksFailed(t *testing.T) { db, svc := setupCaptainDocumentServiceTest(t) account, _, doc := seedCaptainDocumentSyncFixture(t, db) wp := worker.NewWorkerPool(db) svc.SetWorkerPool(wp) _, err := svc.RequestCrawlDocumentByAccount(context.Background(), account.ID, doc.ID) require.NoError(t, err) processed, err := wp.ProcessOne(context.Background()) require.NoError(t, err) assert.True(t, processed) var updated model.CaptainDocument require.NoError(t, db.First(&updated, doc.ID).Error) assert.Equal(t, model.DocumentSyncStatusFailed, updated.SyncStatus) assert.Equal(t, "crawl_disabled", updated.LastSyncErrorCode) } func TestCaptainDocumentService_ScheduleDueDocumentSyncsQueuesDueDocs(t *testing.T) { db, svc := setupCaptainDocumentServiceTest(t) account, _, doc := seedCaptainDocumentSyncFixture(t, db) account.FeatureFlags = `{"captain_document_auto_sync":true}` require.NoError(t, db.Save(account).Error) now := time.Date(2026, 6, 6, 0, 0, 0, 0, time.UTC) oldSync := now.Add(-13 * time.Hour).Unix() doc.LastSyncedAt = &oldSync doc.LastSyncAttemptedAt = &oldSync doc.Status = model.DocumentStatusCompleted doc.SyncStatus = model.DocumentSyncStatusSynced require.NoError(t, db.Save(doc).Error) freshSync := now.Add(-time.Hour).Unix() fresh := &model.CaptainDocument{AccountID: account.ID, AssistantID: doc.AssistantID, Name: "Fresh", ExternalLink: "https://example.com/fresh", Status: model.DocumentStatusCompleted, SyncStatus: model.DocumentSyncStatusSynced, LastSyncedAt: &freshSync, LastSyncAttemptedAt: &freshSync} require.NoError(t, db.Create(fresh).Error) wp := worker.NewWorkerPoolWithOptions(db, worker.WithNow(func() time.Time { return now })) svc.SetWorkerPool(wp) enqueued, err := svc.ScheduleDueDocumentSyncs(context.Background(), now) require.NoError(t, err) assert.Equal(t, 1, enqueued) var jobs []model.BackgroundJob require.NoError(t, db.Where("job_type = ?", TaskTypeCaptainDocumentSync).Find(&jobs).Error) require.Len(t, jobs, 1) assert.Equal(t, "purgable", jobs[0].Queue) assert.Contains(t, string(jobs[0].Payload), fmt.Sprintf("\"document_id\":%d", doc.ID)) } func TestEnqueueCaptainDocumentScheduleSyncsUsesDailyIdempotency(t *testing.T) { db, _ := setupCaptainDocumentServiceTest(t) now := time.Date(2026, 6, 6, 1, 0, 0, 0, time.UTC) wp := worker.NewWorkerPoolWithOptions(db, worker.WithNow(func() time.Time { return now })) first, err := EnqueueCaptainDocumentScheduleSyncs(context.Background(), wp, now) require.NoError(t, err) second, err := EnqueueCaptainDocumentScheduleSyncs(context.Background(), wp, now.Add(time.Hour)) require.NoError(t, err) assert.Equal(t, first.ID, second.ID) assert.Equal(t, TaskTypeCaptainDocumentScheduleSyncs, first.JobType) assert.Equal(t, "scheduled_jobs", first.Queue) var count int64 require.NoError(t, db.Model(&model.BackgroundJob{}).Where("job_type = ?", TaskTypeCaptainDocumentScheduleSyncs).Count(&count).Error) assert.Equal(t, int64(1), count) } func TestCaptainDocumentService_ResponseBuilderCreatesResponsesAndEmbeddingJobs(t *testing.T) { db, svc := setupCaptainDocumentServiceTest(t) account, _, doc := seedCaptainDocumentSyncFixture(t, db) doc.Content = "How refunds work. How billing works." doc.Status = model.DocumentStatusCompleted require.NoError(t, db.Save(doc).Error) uneditedDocID := doc.ID unedited := &model.CaptainAssistantResponse{ AccountID: account.ID, AssistantID: doc.AssistantID, DocumentableID: &uneditedDocID, DocumentableType: "Captain::Document", Question: "old", Answer: "old answer", Status: model.ResponseStatusApproved, Edited: false, } edited := &model.CaptainAssistantResponse{ AccountID: account.ID, AssistantID: doc.AssistantID, DocumentableID: &uneditedDocID, DocumentableType: "Captain::Document", Question: "keep", Answer: "edited answer", Status: model.ResponseStatusApproved, Edited: true, } require.NoError(t, db.Create(unedited).Error) require.NoError(t, db.Create(edited).Error) svc.SetFAQBackend(&captainDocumentFakeFAQBackend{faqs: []CaptainDocumentFAQ{ {Question: "How do refunds work?", Answer: "Refunds take five days."}, {Question: "How does billing work?", Answer: "Billing is monthly."}, }}) now := time.Date(2026, 6, 6, 2, 0, 0, 0, time.UTC) wp := worker.NewWorkerPoolWithOptions(db, worker.WithNow(func() time.Time { return now })) svc.SetWorkerPool(wp) _, err := wp.Enqueue(context.Background(), TaskTypeCaptainDocumentResponseBuilder, captainDocumentResponseBuilderJob{AccountID: account.ID, DocumentID: doc.ID}, worker.WithQueue("low"), worker.WithMaxAttempts(3)) require.NoError(t, err) processed, err := wp.ProcessOne(context.Background()) require.NoError(t, err) assert.True(t, processed) var deleted model.CaptainAssistantResponse assert.Error(t, db.First(&deleted, unedited.ID).Error) var kept model.CaptainAssistantResponse require.NoError(t, db.First(&kept, edited.ID).Error) assert.True(t, kept.Edited) var responses []model.CaptainAssistantResponse require.NoError(t, db.Where("account_id = ? AND documentable_id = ? AND edited = ?", account.ID, doc.ID, false).Order("id ASC").Find(&responses).Error) require.Len(t, responses, 2) assert.Equal(t, "Captain::Document", responses[0].DocumentableType) assert.Equal(t, model.ResponseStatusApproved, responses[0].Status) var embeddingJobs []model.BackgroundJob require.NoError(t, db.Where("job_type = ? AND queue = ?", TaskTypeCaptainLLMUpdateEmbedding, "low").Find(&embeddingJobs).Error) require.Len(t, embeddingJobs, 2) assert.Contains(t, string(embeddingJobs[0].Payload), fmt.Sprintf("\"response_id\":%d", responses[0].ID)) } func TestCaptainDocumentService_EmbeddingJobUpdatesResponse(t *testing.T) { db, svc := setupCaptainDocumentServiceTest(t) account, _, doc := seedCaptainDocumentSyncFixture(t, db) documentID := doc.ID resp := &model.CaptainAssistantResponse{ AccountID: account.ID, AssistantID: doc.AssistantID, DocumentableID: &documentID, DocumentableType: "Captain::Document", Question: "What is Captain?", Answer: "Captain answers customers.", Status: model.ResponseStatusApproved, } require.NoError(t, db.Create(resp).Error) embeddings := &captainDocumentFakeEmbeddingBackend{embedding: pgvector.NewVector([]float32{0.1, 0.2, 0.3})} svc.SetEmbeddingBackend(embeddings) wp := worker.NewWorkerPool(db) svc.SetWorkerPool(wp) _, err := wp.Enqueue(context.Background(), TaskTypeCaptainLLMUpdateEmbedding, captainLLMUpdateEmbeddingJob{AccountID: account.ID, ResponseID: resp.ID}, worker.WithQueue("low"), worker.WithMaxAttempts(3)) require.NoError(t, err) processed, err := wp.ProcessOne(context.Background()) require.NoError(t, err) assert.True(t, processed) assert.Equal(t, fmt.Sprintf("%s: %s", resp.Question, resp.Answer), embeddings.content) var job model.BackgroundJob require.NoError(t, db.Where("job_type = ?", TaskTypeCaptainLLMUpdateEmbedding).First(&job).Error) assert.Equal(t, model.BackgroundJobStatusCompleted, job.Status) } func TestCaptainDocumentService_EmbeddingJobRetriesWhenProviderDisabled(t *testing.T) { db, svc := setupCaptainDocumentServiceTest(t) account, _, doc := seedCaptainDocumentSyncFixture(t, db) documentID := doc.ID resp := &model.CaptainAssistantResponse{ AccountID: account.ID, AssistantID: doc.AssistantID, DocumentableID: &documentID, DocumentableType: "Captain::Document", Question: "What is Captain?", Answer: "Captain answers customers.", Status: model.ResponseStatusApproved, } require.NoError(t, db.Create(resp).Error) now := time.Date(2026, 6, 6, 2, 15, 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 := wp.Enqueue(context.Background(), TaskTypeCaptainLLMUpdateEmbedding, captainLLMUpdateEmbeddingJob{AccountID: account.ID, ResponseID: resp.ID}, worker.WithQueue("low"), 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 = ?", TaskTypeCaptainLLMUpdateEmbedding).First(&job).Error) assert.Equal(t, model.BackgroundJobStatusRetrying, job.Status) assert.Contains(t, job.LastError, "embedding generation disabled") } func TestCaptainDocumentService_ResponseBuilderRetriesWhenFAQDisabled(t *testing.T) { db, svc := setupCaptainDocumentServiceTest(t) account, _, doc := seedCaptainDocumentSyncFixture(t, db) doc.Content = "ready content" doc.Status = model.DocumentStatusCompleted require.NoError(t, db.Save(doc).Error) now := time.Date(2026, 6, 6, 2, 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 })) svc.SetWorkerPool(wp) _, err := wp.Enqueue(context.Background(), TaskTypeCaptainDocumentResponseBuilder, captainDocumentResponseBuilderJob{AccountID: account.ID, DocumentID: doc.ID}, worker.WithQueue("low"), 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 = ?", TaskTypeCaptainDocumentResponseBuilder).First(&job).Error) assert.Equal(t, model.BackgroundJobStatusRetrying, job.Status) assert.Contains(t, job.LastError, "faq generation disabled") } func TestCaptainDocumentService_ResponseBuilderScopesAccount(t *testing.T) { db, svc := setupCaptainDocumentServiceTest(t) _, otherAccount, doc := seedCaptainDocumentSyncFixture(t, db) doc.Content = "ready content" doc.Status = model.DocumentStatusCompleted require.NoError(t, db.Save(doc).Error) svc.SetFAQBackend(&captainDocumentFakeFAQBackend{faqs: []CaptainDocumentFAQ{{Question: "Q", Answer: "A"}}}) wp := worker.NewWorkerPoolWithOptions(db, worker.WithBackoff(func(attempt int) time.Duration { return time.Minute })) svc.SetWorkerPool(wp) _, err := wp.Enqueue(context.Background(), TaskTypeCaptainDocumentResponseBuilder, captainDocumentResponseBuilderJob{AccountID: otherAccount.ID, DocumentID: doc.ID}, worker.WithQueue("low"), worker.WithMaxAttempts(3)) require.NoError(t, err) processed, err := wp.ProcessOne(context.Background()) require.Error(t, err) assert.True(t, processed) var count int64 require.NoError(t, db.Model(&model.CaptainAssistantResponse{}).Where("documentable_id = ?", doc.ID).Count(&count).Error) assert.Equal(t, int64(0), count) } type captainDocumentFakeSyncBackend struct { result *CaptainDocumentSyncResult err error documentID uint } func (b *captainDocumentFakeSyncBackend) SyncCaptainDocument(ctx context.Context, doc *model.CaptainDocument) (*CaptainDocumentSyncResult, error) { b.documentID = doc.ID if b.err != nil { return nil, b.err } return b.result, nil } type captainDocumentFakeCrawlBackend struct { links []string err error documentID uint } func (b *captainDocumentFakeCrawlBackend) CrawlCaptainDocument(ctx context.Context, doc *model.CaptainDocument) (*CaptainDocumentCrawlResult, error) { b.documentID = doc.ID if b.err != nil { return nil, b.err } return &CaptainDocumentCrawlResult{PageLinks: b.links}, nil } type captainDocumentFakePageParserBackend struct { pages map[string]*CaptainDocumentSyncResult err error } func (b *captainDocumentFakePageParserBackend) ParseCaptainDocumentPage(ctx context.Context, pageLink string) (*CaptainDocumentSyncResult, error) { if b.err != nil { return nil, b.err } return b.pages[pageLink], nil } type captainDocumentFakeFAQBackend struct { faqs []CaptainDocumentFAQ err error } func (b *captainDocumentFakeFAQBackend) GenerateCaptainDocumentFAQs(ctx context.Context, doc *model.CaptainDocument) ([]CaptainDocumentFAQ, error) { if b.err != nil { return nil, b.err } return b.faqs, nil } type captainDocumentFakeEmbeddingBackend struct { embedding pgvector.Vector err error content string } func (b *captainDocumentFakeEmbeddingBackend) GenerateCaptainEmbedding(ctx context.Context, accountID uint, content string) (pgvector.Vector, error) { b.content = content if b.err != nil { return pgvector.Vector{}, b.err } return b.embedding, nil }