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 := context.WithValue(context.Background(), platform_auth.ClaimsContextKey, &platform_auth.Claims{UserID: 1, Role: string(domain.UserRoleAdmin)}) 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() { ctx := context.WithValue(context.Background(), platform_auth.ClaimsContextKey, &platform_auth.Claims{UserID: 1, Role: string(domain.UserRoleAdmin)}) 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(ctx, work) assert.Error(s.T(), err) } func (s *WorkCommandsSuite) TestUpdateWork_Forbidden() { ctx := context.WithValue(context.Background(), platform_auth.ClaimsContextKey, &platform_auth.Claims{UserID: 2, Role: string(domain.UserRoleReader)}) // Not an admin work := &domain.Work{Title: "Test Work", TranslatableModel: domain.TranslatableModel{Language: "en"}} work.ID = 1 s.repo.isAuthorFunc = func(ctx context.Context, workID uint, authorID uint) (bool, error) { return false, nil // User is not an author } err := s.commands.UpdateWork(ctx, work) assert.Error(s.T(), err) assert.True(s.T(), errors.Is(err, domain.ErrForbidden)) } func (s *WorkCommandsSuite) TestUpdateWork_Unauthorized() { work := &domain.Work{Title: "Test Work", TranslatableModel: domain.TranslatableModel{Language: "en"}} work.ID = 1 err := s.commands.UpdateWork(context.Background(), work) // No user in context assert.Error(s.T(), err) assert.True(s.T(), errors.Is(err, domain.ErrUnauthorized)) } func (s *WorkCommandsSuite) TestDeleteWork_Success() { ctx := context.WithValue(context.Background(), platform_auth.ClaimsContextKey, &platform_auth.Claims{UserID: 1, Role: string(domain.UserRoleAdmin)}) 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() { ctx := context.WithValue(context.Background(), platform_auth.ClaimsContextKey, &platform_auth.Claims{UserID: 1, Role: string(domain.UserRoleAdmin)}) s.repo.deleteFunc = func(ctx context.Context, id uint) error { return errors.New("db error") } err := s.commands.DeleteWork(ctx, 1) assert.Error(s.T(), err) } func (s *WorkCommandsSuite) TestDeleteWork_Forbidden() { ctx := context.WithValue(context.Background(), platform_auth.ClaimsContextKey, &platform_auth.Claims{UserID: 2, Role: string(domain.UserRoleReader)}) // Not an admin err := s.commands.DeleteWork(ctx, 1) assert.Error(s.T(), err) assert.True(s.T(), errors.Is(err, domain.ErrForbidden)) } func (s *WorkCommandsSuite) TestDeleteWork_Unauthorized() { err := s.commands.DeleteWork(context.Background(), 1) // No user in context assert.Error(s.T(), err) assert.True(s.T(), errors.Is(err, domain.ErrUnauthorized)) } 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) // Provide a realistic implementation for the GetOrCreateWorkStats mock analyticsSvc.getOrCreateWorkStatsFunc = func(ctx context.Context, workID uint) (*domain.WorkStats, error) { var stats domain.WorkStats if err := db.Where(domain.WorkStats{WorkID: workID}).FirstOrCreate(&stats).Error; err != nil { return nil, err } return &stats, nil } // --- Seed Data --- t.Run("Success", func(t *testing.T) { 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 := context.WithValue(context.Background(), platform_auth.ClaimsContextKey, &platform_auth.Claims{UserID: 1, Role: string(domain.UserRoleAdmin)}) 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)) }) t.Run("Success with no target stats", func(t *testing.T) { sourceWork := &domain.Work{Title: "Source with Stats"} db.Create(sourceWork) db.Create(&domain.WorkStats{WorkID: sourceWork.ID, Views: 15, Likes: 7}) targetWork := &domain.Work{Title: "Target without Stats"} db.Create(targetWork) ctx := context.WithValue(context.Background(), platform_auth.ClaimsContextKey, &platform_auth.Claims{UserID: 1, Role: string(domain.UserRoleAdmin)}) err := commands.MergeWork(ctx, sourceWork.ID, targetWork.ID) assert.NoError(t, err) var finalStats domain.WorkStats db.Where("work_id = ?", targetWork.ID).First(&finalStats) assert.Equal(t, int64(15), finalStats.Views) assert.Equal(t, int64(7), finalStats.Likes) }) t.Run("Forbidden for non-admin", func(t *testing.T) { sourceWork := &domain.Work{Title: "Forbidden Source"} db.Create(sourceWork) targetWork := &domain.Work{Title: "Forbidden Target"} db.Create(targetWork) ctx := context.WithValue(context.Background(), platform_auth.ClaimsContextKey, &platform_auth.Claims{UserID: 2, Role: string(domain.UserRoleReader)}) err := commands.MergeWork(ctx, sourceWork.ID, targetWork.ID) assert.Error(t, err) assert.True(t, errors.Is(err, domain.ErrForbidden)) }) t.Run("Source work not found", func(t *testing.T) { targetWork := &domain.Work{Title: "Existing Target"} db.Create(targetWork) ctx := context.WithValue(context.Background(), platform_auth.ClaimsContextKey, &platform_auth.Claims{UserID: 1, Role: string(domain.UserRoleAdmin)}) err := commands.MergeWork(ctx, 99999, targetWork.ID) assert.Error(t, err) assert.Contains(t, err.Error(), "entity not found") }) t.Run("Target work not found", func(t *testing.T) { sourceWork := &domain.Work{Title: "Existing Source"} db.Create(sourceWork) ctx := context.WithValue(context.Background(), platform_auth.ClaimsContextKey, &platform_auth.Claims{UserID: 1, Role: string(domain.UserRoleAdmin)}) err := commands.MergeWork(ctx, sourceWork.ID, 99999) assert.Error(t, err) assert.Contains(t, err.Error(), "entity not found") }) t.Run("Cannot merge work into itself", func(t *testing.T) { work := &domain.Work{Title: "Self Merge Work"} db.Create(work) ctx := context.WithValue(context.Background(), platform_auth.ClaimsContextKey, &platform_auth.Claims{UserID: 1, Role: string(domain.UserRoleAdmin)}) err := commands.MergeWork(ctx, work.ID, work.ID) assert.Error(t, err) assert.Contains(t, err.Error(), "source and target work IDs cannot be the same") }) }