Files
gochat/backend/internal/service/captain_document_service_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

530 lines
23 KiB
Go

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)
var responseBuilderJob model.BackgroundJob
require.NoError(t, db.Where("job_type = ?", TaskTypeCaptainDocumentResponseBuilder).First(&responseBuilderJob).Error)
assert.Equal(t, "low", responseBuilderJob.Queue)
assert.Equal(t, model.BackgroundJobStatusQueued, responseBuilderJob.Status)
assert.Equal(t, fmt.Sprintf("captain:document_response_builder:%d:%d", account.ID, doc.ID), responseBuilderJob.IdempotencyKey)
assert.Contains(t, string(responseBuilderJob.Payload), fmt.Sprintf("\"account_id\":%d", account.ID))
assert.Contains(t, string(responseBuilderJob.Payload), fmt.Sprintf("\"document_id\":%d", doc.ID))
}
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)
var responseBuilderJobs []model.BackgroundJob
require.NoError(t, db.Where("job_type = ?", TaskTypeCaptainDocumentResponseBuilder).Order("id ASC").Find(&responseBuilderJobs).Error)
require.Len(t, responseBuilderJobs, 2)
assert.Equal(t, fmt.Sprintf("captain:document_response_builder:%d:%d", account.ID, doc.ID), responseBuilderJobs[0].IdempotencyKey)
assert.Equal(t, fmt.Sprintf("captain:document_response_builder:%d:%d", account.ID, created.ID), responseBuilderJobs[1].IdempotencyKey)
}
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,
}
legacyUnedited := &model.CaptainAssistantResponse{
AccountID: account.ID,
AssistantID: doc.AssistantID,
DocumentableID: &uneditedDocID,
DocumentableType: "CaptainDocument",
Question: "legacy old",
Answer: "legacy 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(legacyUnedited).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 legacyDeleted model.CaptainAssistantResponse
assert.Error(t, db.First(&legacyDeleted, legacyUnedited.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
}