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

257 lines
11 KiB
Go

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)
}