package service import ( "context" "fmt" "testing" "github.com/gochat/gochat/internal/llm" "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/gorm" ) type fakeArticleTranslator struct{} func (fakeArticleTranslator) TranslateArticleText(_ context.Context, _ *model.Account, text string, targetLanguage string, kind string) (string, error) { return fmt.Sprintf("%s[%s:%s]", text, targetLanguage, kind), nil } type recordingArticleLLM struct { requests []llm.ChatRequest } func (m *recordingArticleLLM) ChatCompletion(_ context.Context, req llm.ChatRequest) (*llm.ChatResponse, error) { m.requests = append(m.requests, req) return &llm.ChatResponse{Choices: []llm.ChatChoice{{Message: llm.ChatMessage{Content: " Texte traduit "}}}}, nil } func (m *recordingArticleLLM) CreateEmbedding(context.Context, llm.EmbeddingRequest) (*llm.EmbeddingResponse, error) { return nil, fmt.Errorf("not used") } func (m *recordingArticleLLM) ChatCompletionStream(context.Context, llm.ChatRequest, func(llm.StreamChunk) error) error { return fmt.Errorf("not used") } // ========== Article test helpers ========== // setupArticleService creates ArticleRepo + ArticleService test instances. func setupArticleService(t *testing.T) (*gorm.DB, *repository.ArticleRepo, *ArticleService) { t.Helper() db := setupServiceTestDB(t) repo := repository.NewArticleRepo(db) svc := NewArticleService(repo) return db, repo, svc } // ========== BulkActions ========== func TestArticleService_BulkActions_Publish(t *testing.T) { db, _, svc := setupArticleService(t) account := createTestAccount(t, db) a1 := createTestArticle(t, db, account.ID, 1, func(a *model.Article) { a.Slug = "publish-art-1" }) a2 := createTestArticle(t, db, account.ID, 1, func(a *model.Article) { a.Slug = "publish-art-2" }) err := svc.BulkActions(context.Background(), &BulkActionsRequest{ Action: "publish", IDs: []uint{a1.ID, a2.ID}, }) require.NoError(t, err) // Verify both articles have status "published" var updated1, updated2 model.Article require.NoError(t, db.First(&updated1, a1.ID).Error) require.NoError(t, db.First(&updated2, a2.ID).Error) assert.Equal(t, string(model.ArticleStatusPublished), updated1.Status) assert.Equal(t, string(model.ArticleStatusPublished), updated2.Status) } func TestArticleService_BulkActions_Archive(t *testing.T) { db, _, svc := setupArticleService(t) account := createTestAccount(t, db) a1 := createTestArticle(t, db, account.ID, 1, func(a *model.Article) { a.Slug = "archive-art-1" }) a2 := createTestArticle(t, db, account.ID, 1, func(a *model.Article) { a.Slug = "archive-art-2" }) err := svc.BulkActions(context.Background(), &BulkActionsRequest{ Action: "archive", IDs: []uint{a1.ID, a2.ID}, }) require.NoError(t, err) var updated1, updated2 model.Article require.NoError(t, db.First(&updated1, a1.ID).Error) require.NoError(t, db.First(&updated2, a2.ID).Error) assert.Equal(t, string(model.ArticleStatusArchived), updated1.Status) assert.Equal(t, string(model.ArticleStatusArchived), updated2.Status) } func TestArticleService_BulkActions_Delete(t *testing.T) { db, _, svc := setupArticleService(t) account := createTestAccount(t, db) a1 := createTestArticle(t, db, account.ID, 1, func(a *model.Article) { a.Slug = "delete-art-1" }) a2 := createTestArticle(t, db, account.ID, 1, func(a *model.Article) { a.Slug = "delete-art-2" }) err := svc.BulkActions(context.Background(), &BulkActionsRequest{ Action: "delete", IDs: []uint{a1.ID, a2.ID}, }) require.NoError(t, err) // Verify both articles are deleted (not found in DB) var found model.Article assert.ErrorIs(t, db.First(&found, a1.ID).Error, gorm.ErrRecordNotFound) assert.ErrorIs(t, db.First(&found, a2.ID).Error, gorm.ErrRecordNotFound) } func TestArticleService_BulkActions_InvalidAction(t *testing.T) { _, _, svc := setupArticleService(t) err := svc.BulkActions(context.Background(), &BulkActionsRequest{ Action: "invalid", IDs: []uint{1, 2}, }) assert.Error(t, err) assert.Contains(t, err.Error(), "unsupported action") } // ========== BulkUpdateStatus ========== func TestArticleService_BulkUpdateStatus_ValidStatus(t *testing.T) { db, _, svc := setupArticleService(t) account := createTestAccount(t, db) a1 := createTestArticle(t, db, account.ID, 1, func(a *model.Article) { a.Slug = "status-art-1" }) a2 := createTestArticle(t, db, account.ID, 1, func(a *model.Article) { a.Slug = "status-art-2" }) err := svc.BulkUpdateStatus(context.Background(), []uint{a1.ID, a2.ID}, string(model.ArticleStatusPublished)) require.NoError(t, err) var updated1, updated2 model.Article require.NoError(t, db.First(&updated1, a1.ID).Error) require.NoError(t, db.First(&updated2, a2.ID).Error) assert.Equal(t, string(model.ArticleStatusPublished), updated1.Status) assert.Equal(t, string(model.ArticleStatusPublished), updated2.Status) } func TestArticleService_BulkUpdateStatus_InvalidStatus(t *testing.T) { _, _, svc := setupArticleService(t) err := svc.BulkUpdateStatus(context.Background(), []uint{1, 2}, "invalid_status") assert.Error(t, err) assert.Contains(t, err.Error(), "invalid status") } // ========== BulkDelete ========== func TestArticleService_BulkDelete_Success(t *testing.T) { db, _, svc := setupArticleService(t) account := createTestAccount(t, db) a1 := createTestArticle(t, db, account.ID, 1, func(a *model.Article) { a.Slug = "bulkdel-art-1" }) a2 := createTestArticle(t, db, account.ID, 1, func(a *model.Article) { a.Slug = "bulkdel-art-2" }) err := svc.BulkDelete(context.Background(), []uint{a1.ID, a2.ID}) require.NoError(t, err) var found model.Article assert.ErrorIs(t, db.First(&found, a1.ID).Error, gorm.ErrRecordNotFound) assert.ErrorIs(t, db.First(&found, a2.ID).Error, gorm.ErrRecordNotFound) } func TestArticleService_BulkTranslate_QueuesCaptainJobsAndDetectsDuplicates(t *testing.T) { db, _, svc := setupArticleService(t) wp := worker.NewWorkerPool(db) svc.SetWorkerPool(wp) account := createTestAccount(t, db) account.FeatureFlags = `{"captain_tasks":true}` require.NoError(t, db.Save(account).Error) portal := &model.Portal{AccountID: account.ID, Name: "Translate Portal", Slug: "translate-service", Locale: "en", PortalConfiguration: []byte(`{"allowed_locales":["en","fr"]}`)} require.NoError(t, db.Create(portal).Error) article := &model.Article{AccountID: account.ID, PortalID: portal.ID, Title: "Shipping", Slug: "shipping-service", Content: "Ship fast", Status: "published", Locale: "en"} require.NoError(t, db.Create(article).Error) duplicate := &model.Article{AccountID: account.ID, PortalID: portal.ID, Title: "Expedition", Slug: "expedition-service", Status: "draft", Locale: "fr", AssociatedArticleID: &article.ID} require.NoError(t, db.Create(duplicate).Error) err := svc.BulkTranslate(context.Background(), account.ID, portal, 42, BulkTranslateRequest{IDs: []uint{article.ID}, Locale: "fr"}) var conflict *ArticleTranslationConflictError require.ErrorAs(t, err, &conflict) require.Len(t, conflict.Duplicates, 1) assert.Equal(t, duplicate.ID, conflict.Duplicates[0].ID) err = svc.BulkTranslate(context.Background(), account.ID, portal, 42, BulkTranslateRequest{IDs: []uint{article.ID}, Locale: "fr", Force: true}) require.NoError(t, err) var count int64 require.NoError(t, db.Model(&model.BackgroundJob{}).Where("job_type = ? AND queue = ?", TaskTypeCaptainArticleTranslate, "low").Count(&count).Error) assert.Equal(t, int64(1), count) } func TestArticleService_ArticleTranslateJobCreatesAndUpdatesTranslations(t *testing.T) { db, _, svc := setupArticleService(t) wp := worker.NewWorkerPool(db) svc.SetWorkerPool(wp) svc.SetArticleTranslationBackend(fakeArticleTranslator{}) account := createTestAccount(t, db) portal := &model.Portal{AccountID: account.ID, Name: "Translate Job Portal", Slug: "translate-job-service", Locale: "en", PortalConfiguration: []byte(`{"allowed_locales":["en","fr"]}`)} require.NoError(t, db.Create(portal).Error) category := &model.Category{AccountID: account.ID, PortalID: portal.ID, Name: "Francais", Slug: "fr-service", Locale: "fr"} require.NoError(t, db.Create(category).Error) article := &model.Article{AccountID: account.ID, PortalID: portal.ID, Title: "Shipping", Slug: "shipping-job-service", Description: "Delivery", Content: "Ship fast", Status: "published", Locale: "en"} require.NoError(t, db.Create(article).Error) _, err := wp.Enqueue(context.Background(), TaskTypeCaptainArticleTranslate, captainArticleTranslateJob{AccountID: account.ID, ArticleID: article.ID, Locale: "fr", CategoryID: category.ID, UserID: 7}, worker.WithQueue("low")) require.NoError(t, err) processed, err := wp.ProcessOne(context.Background()) require.NoError(t, err) require.True(t, processed) var translated model.Article require.NoError(t, db.Where("portal_id = ? AND associated_article_id = ? AND locale = ?", portal.ID, article.ID, "fr").First(&translated).Error) assert.Equal(t, "Shipping[French:title]", translated.Title) assert.Equal(t, "Ship fast[French:content]", translated.Content) assert.Equal(t, "Delivery", translated.Description) require.NotNil(t, translated.CategoryID) assert.Equal(t, category.ID, *translated.CategoryID) assert.Equal(t, string(model.ArticleStatusDraft), translated.Status) article.Title = "Updated Shipping" article.Content = "Ship faster" require.NoError(t, db.Save(article).Error) _, err = wp.Enqueue(context.Background(), TaskTypeCaptainArticleTranslate, captainArticleTranslateJob{AccountID: account.ID, ArticleID: article.ID, Locale: "fr", CategoryID: category.ID, UserID: 7}, worker.WithQueue("low")) require.NoError(t, err) processed, err = wp.ProcessOne(context.Background()) require.NoError(t, err) require.True(t, processed) var translations int64 require.NoError(t, db.Model(&model.Article{}).Where("portal_id = ? AND associated_article_id = ? AND locale = ?", portal.ID, article.ID, "fr").Count(&translations).Error) assert.Equal(t, int64(1), translations) require.NoError(t, db.First(&translated, translated.ID).Error) assert.Equal(t, "Updated Shipping[French:title]", translated.Title) assert.Equal(t, "Ship faster[French:content]", translated.Content) } func TestLLMArticleTranslationBackend_UsesChatwootPromptsAndLanguageNames(t *testing.T) { provider := &recordingArticleLLM{} backend := NewLLMArticleTranslationBackend(provider) translated, err := backend.TranslateArticleText(context.Background(), &model.Account{}, "# Hello", "fr", "content") require.NoError(t, err) assert.Equal(t, "Texte traduit", translated) require.Len(t, provider.requests, 1) req := provider.requests[0] require.Len(t, req.Messages, 2) assert.Contains(t, req.Messages[0].Content, "Translate the following content to French") assert.Contains(t, req.Messages[0].Content, "Preserve ALL markdown formatting exactly") assert.Equal(t, "# Hello", req.Messages[1].Content) _, err = backend.TranslateArticleText(context.Background(), &model.Account{}, "Hello", "fr", "summary") require.Error(t, err) }