298 lines
12 KiB
Go
298 lines
12 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"testing"
|
|
"time"
|
|
|
|
"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
|
|
}
|
|
|
|
type embeddingArticleLLM struct{}
|
|
|
|
func (embeddingArticleLLM) ChatCompletion(context.Context, llm.ChatRequest) (*llm.ChatResponse, error) {
|
|
return nil, fmt.Errorf("not used")
|
|
}
|
|
|
|
func (embeddingArticleLLM) CreateEmbedding(context.Context, llm.EmbeddingRequest) (*llm.EmbeddingResponse, error) {
|
|
return &llm.EmbeddingResponse{Data: []llm.EmbeddingData{{Embedding: []float64{0.1, 0.2, 0.3}}}}, nil
|
|
}
|
|
|
|
func (embeddingArticleLLM) ChatCompletionStream(context.Context, llm.ChatRequest, func(llm.StreamChunk) error) error {
|
|
return fmt.Errorf("not used")
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
func TestArticleServiceEmbeddingReindexTracksProgress(t *testing.T) {
|
|
db, _, svc := setupArticleService(t)
|
|
require.NoError(t, db.AutoMigrate(&model.ArticleEmbedding{}))
|
|
account := createTestAccount(t, db)
|
|
createTestArticle(t, db, account.ID, 1, func(a *model.Article) { a.Slug = "reindex-one" })
|
|
createTestArticle(t, db, account.ID, 1, func(a *model.Article) { a.Slug = "reindex-two" })
|
|
svc.SetEmbeddingRepo(repository.NewArticleEmbeddingRepo(db))
|
|
svc.SetLLMProvider(embeddingArticleLLM{})
|
|
|
|
status, err := svc.StartEmbeddingReindex()
|
|
require.NoError(t, err)
|
|
require.True(t, status.Running)
|
|
require.Equal(t, 2, status.Total)
|
|
require.Eventually(t, func() bool {
|
|
return !svc.EmbeddingReindexStatus().Running
|
|
}, time.Second, 10*time.Millisecond)
|
|
|
|
status = svc.EmbeddingReindexStatus()
|
|
require.Equal(t, 2, status.Processed)
|
|
require.Equal(t, 0, status.Failed, status.Error)
|
|
require.NotNil(t, status.CompletedAt)
|
|
var count int64
|
|
require.NoError(t, db.Model(&model.ArticleEmbedding{}).Count(&count).Error)
|
|
require.Equal(t, int64(2), count)
|
|
}
|
|
|
|
// ========== 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)
|
|
}
|