tercul-backend/internal/app/work/commands_test.go
google-labs-jules[bot] 0a27c84771 This commit introduces a series of significant improvements to bring the codebase closer to a production-ready state.
Key changes include:

- **Architectural Refactoring (CQRS/DTOs):** Refactored the `work` and `translation` application services to use Data Transfer Objects (DTOs) for query responses. This separates the domain layer from the API layer, improving maintainability and performance.

- **Implemented Core Business Logic:** Implemented the `AnalyzeWork` command, which was previously a stub. This command now performs linguistic analysis on works and translations by calling the analytics service.

- **Dependency Injection Improvements:**
    - Refactored the configuration loading in `internal/platform/config/config.go` to use a local `viper` instance, removing the reliance on a global singleton.
    - Injected the `analytics.Service` into the `work.Service` to support the `AnalyzeWork` command.

- **Comprehensive Documentation:**
    - Created a new root `README.md` with a project overview, setup instructions, and architectural principles.
    - Added detailed `README.md` files to key packages (`api`, `analytics`, `auth`, `work`, `db`) to document their purpose and usage.

- **Improved Test Coverage:**
    - Added new unit tests for the refactored `work` and `translation` query handlers.
    - Added a new test suite for the `translation` queries, which were previously untested.
    - Added tests for the new `AnalyzeWork` command.
    - Fixed numerous compilation errors in the test suites caused by the refactoring.
2025-10-08 17:25:02 +00:00

302 lines
10 KiB
Go

package work
import (
"context"
"errors"
"testing"
"tercul/internal/platform/config"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/suite"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"tercul/internal/app/authz"
"tercul/internal/data/sql"
"tercul/internal/domain"
platform_auth "tercul/internal/platform/auth"
)
type WorkCommandsSuite struct {
suite.Suite
repo *mockWorkRepository
searchClient *mockSearchClient
authzSvc *authz.Service
analyticsSvc *mockAnalyticsService
commands *WorkCommands
}
func (s *WorkCommandsSuite) SetupTest() {
s.repo = &mockWorkRepository{}
s.searchClient = &mockSearchClient{}
s.authzSvc = authz.NewService(s.repo, nil)
s.analyticsSvc = &mockAnalyticsService{}
s.commands = NewWorkCommands(s.repo, s.searchClient, s.authzSvc, s.analyticsSvc)
}
func TestWorkCommandsSuite(t *testing.T) {
suite.Run(t, new(WorkCommandsSuite))
}
func (s *WorkCommandsSuite) TestCreateWork_Success() {
work := &domain.Work{Title: "Test Work", TranslatableModel: domain.TranslatableModel{Language: "en"}}
_, err := s.commands.CreateWork(context.Background(), work)
assert.NoError(s.T(), err)
}
func (s *WorkCommandsSuite) TestCreateWork_Nil() {
_, err := s.commands.CreateWork(context.Background(), nil)
assert.Error(s.T(), err)
}
func (s *WorkCommandsSuite) TestCreateWork_EmptyTitle() {
work := &domain.Work{TranslatableModel: domain.TranslatableModel{Language: "en"}}
_, err := s.commands.CreateWork(context.Background(), work)
assert.Error(s.T(), err)
}
func (s *WorkCommandsSuite) TestCreateWork_EmptyLanguage() {
work := &domain.Work{Title: "Test Work"}
_, err := s.commands.CreateWork(context.Background(), work)
assert.Error(s.T(), err)
}
func (s *WorkCommandsSuite) TestCreateWork_RepoError() {
work := &domain.Work{Title: "Test Work", TranslatableModel: domain.TranslatableModel{Language: "en"}}
s.repo.createFunc = func(ctx context.Context, w *domain.Work) error {
return errors.New("db error")
}
_, err := s.commands.CreateWork(context.Background(), work)
assert.Error(s.T(), err)
}
func (s *WorkCommandsSuite) TestUpdateWork_Success() {
ctx := platform_auth.ContextWithAdminUser(context.Background(), 1)
work := &domain.Work{Title: "Test Work", TranslatableModel: domain.TranslatableModel{Language: "en"}}
work.ID = 1
s.repo.getByIDFunc = func(ctx context.Context, id uint) (*domain.Work, error) {
return work, nil
}
s.repo.isAuthorFunc = func(ctx context.Context, workID uint, authorID uint) (bool, error) {
return true, nil
}
err := s.commands.UpdateWork(ctx, work)
assert.NoError(s.T(), err)
}
func (s *WorkCommandsSuite) TestUpdateWork_Nil() {
err := s.commands.UpdateWork(context.Background(), nil)
assert.Error(s.T(), err)
}
func (s *WorkCommandsSuite) TestUpdateWork_ZeroID() {
work := &domain.Work{Title: "Test Work", TranslatableModel: domain.TranslatableModel{Language: "en"}}
err := s.commands.UpdateWork(context.Background(), work)
assert.Error(s.T(), err)
}
func (s *WorkCommandsSuite) TestUpdateWork_EmptyTitle() {
work := &domain.Work{TranslatableModel: domain.TranslatableModel{Language: "en"}}
work.ID = 1
err := s.commands.UpdateWork(context.Background(), work)
assert.Error(s.T(), err)
}
func (s *WorkCommandsSuite) TestUpdateWork_EmptyLanguage() {
work := &domain.Work{Title: "Test Work"}
work.ID = 1
err := s.commands.UpdateWork(context.Background(), work)
assert.Error(s.T(), err)
}
func (s *WorkCommandsSuite) TestUpdateWork_RepoError() {
work := &domain.Work{Title: "Test Work", TranslatableModel: domain.TranslatableModel{Language: "en"}}
work.ID = 1
s.repo.updateFunc = func(ctx context.Context, w *domain.Work) error {
return errors.New("db error")
}
err := s.commands.UpdateWork(context.Background(), work)
assert.Error(s.T(), err)
}
func (s *WorkCommandsSuite) TestDeleteWork_Success() {
ctx := platform_auth.ContextWithAdminUser(context.Background(), 1)
work := &domain.Work{Title: "Test Work", TranslatableModel: domain.TranslatableModel{Language: "en"}}
work.ID = 1
s.repo.getByIDFunc = func(ctx context.Context, id uint) (*domain.Work, error) {
return work, nil
}
s.repo.isAuthorFunc = func(ctx context.Context, workID uint, authorID uint) (bool, error) {
return true, nil
}
err := s.commands.DeleteWork(ctx, 1)
assert.NoError(s.T(), err)
}
func (s *WorkCommandsSuite) TestDeleteWork_ZeroID() {
err := s.commands.DeleteWork(context.Background(), 0)
assert.Error(s.T(), err)
}
func (s *WorkCommandsSuite) TestDeleteWork_RepoError() {
s.repo.deleteFunc = func(ctx context.Context, id uint) error {
return errors.New("db error")
}
err := s.commands.DeleteWork(context.Background(), 1)
assert.Error(s.T(), err)
}
func (s *WorkCommandsSuite) TestAnalyzeWork_Success() {
work := &domain.Work{
TranslatableModel: domain.TranslatableModel{BaseModel: domain.BaseModel{ID: 1}},
Translations: []*domain.Translation{
{BaseModel: domain.BaseModel{ID: 101}},
{BaseModel: domain.BaseModel{ID: 102}},
},
}
s.repo.getWithTranslationsFunc = func(ctx context.Context, id uint) (*domain.Work, error) {
return work, nil
}
var readingTime, complexity, sentiment, tReadingTime, tSentiment int
s.analyticsSvc.updateWorkReadingTimeFunc = func(ctx context.Context, workID uint) error {
readingTime++
return nil
}
s.analyticsSvc.updateWorkComplexityFunc = func(ctx context.Context, workID uint) error {
complexity++
return nil
}
s.analyticsSvc.updateWorkSentimentFunc = func(ctx context.Context, workID uint) error {
sentiment++
return nil
}
s.analyticsSvc.updateTranslationReadingTimeFunc = func(ctx context.Context, translationID uint) error {
tReadingTime++
return nil
}
s.analyticsSvc.updateTranslationSentimentFunc = func(ctx context.Context, translationID uint) error {
tSentiment++
return nil
}
err := s.commands.AnalyzeWork(context.Background(), 1)
assert.NoError(s.T(), err)
assert.Equal(s.T(), 1, readingTime, "UpdateWorkReadingTime should be called once")
assert.Equal(s.T(), 1, complexity, "UpdateWorkComplexity should be called once")
assert.Equal(s.T(), 1, sentiment, "UpdateWorkSentiment should be called once")
assert.Equal(s.T(), 2, tReadingTime, "UpdateTranslationReadingTime should be called for each translation")
assert.Equal(s.T(), 2, tSentiment, "UpdateTranslationSentiment should be called for each translation")
}
func TestMergeWork_Integration(t *testing.T) {
// Setup in-memory SQLite DB
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
assert.NoError(t, err)
// Run migrations for all relevant tables
err = db.AutoMigrate(
&domain.Work{},
&domain.Translation{},
&domain.Author{},
&domain.Tag{},
&domain.Category{},
&domain.Copyright{},
&domain.Monetization{},
&domain.WorkStats{},
&domain.WorkAuthor{},
)
assert.NoError(t, err)
// Create real repositories and services pointing to the test DB
cfg, err := config.LoadConfig()
assert.NoError(t, err)
workRepo := sql.NewWorkRepository(db, cfg)
authzSvc := authz.NewService(workRepo, nil) // Using real repo for authz checks
searchClient := &mockSearchClient{} // Mock search client is fine
analyticsSvc := &mockAnalyticsService{}
commands := NewWorkCommands(workRepo, searchClient, authzSvc, analyticsSvc)
// --- Seed Data ---
author1 := &domain.Author{Name: "Author One"}
db.Create(author1)
author2 := &domain.Author{Name: "Author Two"}
db.Create(author2)
tag1 := &domain.Tag{Name: "Tag One"}
db.Create(tag1)
tag2 := &domain.Tag{Name: "Tag Two"}
db.Create(tag2)
sourceWork := &domain.Work{
TranslatableModel: domain.TranslatableModel{Language: "en"},
Title: "Source Work",
Authors: []*domain.Author{author1},
Tags: []*domain.Tag{tag1},
}
db.Create(sourceWork)
db.Create(&domain.Translation{Title: "Source English", Language: "en", TranslatableID: sourceWork.ID, TranslatableType: "works"})
db.Create(&domain.Translation{Title: "Source French", Language: "fr", TranslatableID: sourceWork.ID, TranslatableType: "works"})
db.Create(&domain.WorkStats{WorkID: sourceWork.ID, Views: 10, Likes: 5})
targetWork := &domain.Work{
TranslatableModel: domain.TranslatableModel{Language: "en"},
Title: "Target Work",
Authors: []*domain.Author{author2},
Tags: []*domain.Tag{tag2},
}
db.Create(targetWork)
db.Create(&domain.Translation{Title: "Target English", Language: "en", TranslatableID: targetWork.ID, TranslatableType: "works"})
db.Create(&domain.WorkStats{WorkID: targetWork.ID, Views: 20, Likes: 10})
// --- Execute Merge ---
ctx := platform_auth.ContextWithAdminUser(context.Background(), 1)
err = commands.MergeWork(ctx, sourceWork.ID, targetWork.ID)
assert.NoError(t, err)
// --- Assertions ---
// 1. Source work should be deleted
var deletedWork domain.Work
err = db.First(&deletedWork, sourceWork.ID).Error
assert.Error(t, err)
assert.True(t, errors.Is(err, gorm.ErrRecordNotFound))
// 2. Target work should have merged data
var finalTargetWork domain.Work
db.Preload("Translations").Preload("Authors").Preload("Tags").First(&finalTargetWork, targetWork.ID)
assert.Len(t, finalTargetWork.Translations, 2, "Should have two translations after merge")
foundEn := false
foundFr := false
for _, tr := range finalTargetWork.Translations {
if tr.Language == "en" {
foundEn = true
assert.Equal(t, "Target English", tr.Title, "Should keep target's English translation")
}
if tr.Language == "fr" {
foundFr = true
assert.Equal(t, "Source French", tr.Title, "Should merge source's French translation")
}
}
assert.True(t, foundEn, "English translation should be present")
assert.True(t, foundFr, "French translation should be present")
assert.Len(t, finalTargetWork.Authors, 2, "Authors should be merged")
assert.Len(t, finalTargetWork.Tags, 2, "Tags should be merged")
// 3. Stats should be merged
var finalStats domain.WorkStats
db.Where("work_id = ?", targetWork.ID).First(&finalStats)
assert.Equal(t, int64(30), finalStats.Views, "Views should be summed")
assert.Equal(t, int64(15), finalStats.Likes, "Likes should be summed")
// 4. Source stats should be deleted
var deletedStats domain.WorkStats
err = db.First(&deletedStats, "work_id = ?", sourceWork.ID).Error
assert.Error(t, err, "Source stats should be deleted")
assert.True(t, errors.Is(err, gorm.ErrRecordNotFound))
}