tercul-backend/internal/app/work/commands_test.go
google-labs-jules[bot] 952a62c139 test: Increase test coverage for work package to over 80%
This commit increases the test coverage of the `internal/app/work` package from 73.1% to over 80% by adding new tests and fixing a bug discovered during testing.

The following changes were made:
- Added tests for the `ListByCollectionID` query in `queries_test.go`.
- Added a unit test for the `NewService` constructor in `service_test.go`.
- Added tests for authorization, unauthorized access, and other edge cases in the `UpdateWork`, `DeleteWork`, and `MergeWork` commands in `commands_test.go`.
- Fixed a bug in the `mergeWorkStats` function where it was not correctly creating stats for a target work that had no prior stats. This was discovered and fixed as part of writing the new tests.
- Updated the `analytics.Service` interface and its mock implementation to support the bug fix.
2025-10-08 20:45:49 +00:00

414 lines
15 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 := 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")
})
}