308 lines
13 KiB
Go
308 lines
13 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/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.BackgroundJob{}))
|
|
t.Cleanup(func() {
|
|
sqlDB, _ := db.DB()
|
|
sqlDB.Close()
|
|
})
|
|
documentRepo := repository.NewCaptainDocumentRepo(db)
|
|
assistantRepo := repository.NewCaptainAssistantRepo(db)
|
|
return db, NewCaptainDocumentService(documentRepo, nil, assistantRepo)
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
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
|
|
}
|