diff --git a/internal/adapters/graphql/integration_test.go b/internal/adapters/graphql/integration_test.go index f565bea..584598d 100644 --- a/internal/adapters/graphql/integration_test.go +++ b/internal/adapters/graphql/integration_test.go @@ -493,7 +493,7 @@ func (s *GraphQLIntegrationSuite) TestUpdateTranslationValidation() { s.Run("should return error for invalid input", func() { // Arrange work := s.CreateTestWork("Test Work", "en", "Test content") - createdTranslation, err := s.App.Translation.Commands.CreateTranslation(context.Background(), translation.CreateTranslationInput{ + createdTranslation, err := s.App.Translation.Commands.CreateOrUpdateTranslation(s.AdminCtx, translation.CreateOrUpdateTranslationInput{ Title: "Test Translation", Language: "en", Content: "Test content", @@ -600,7 +600,7 @@ func (s *GraphQLIntegrationSuite) TestDeleteTranslation() { s.Run("should delete a translation", func() { // Arrange work := s.CreateTestWork("Test Work", "en", "Test content") - createdTranslation, err := s.App.Translation.Commands.CreateTranslation(context.Background(), translation.CreateTranslationInput{ + createdTranslation, err := s.App.Translation.Commands.CreateOrUpdateTranslation(s.AdminCtx, translation.CreateOrUpdateTranslationInput{ Title: "Test Translation", Language: "en", Content: "Test content", diff --git a/internal/adapters/graphql/schema.resolvers.go b/internal/adapters/graphql/schema.resolvers.go index 34655be..48adac5 100644 --- a/internal/adapters/graphql/schema.resolvers.go +++ b/internal/adapters/graphql/schema.resolvers.go @@ -108,7 +108,7 @@ func (r *mutationResolver) CreateWork(ctx context.Context, input model.WorkInput } if input.Content != nil && *input.Content != "" { - translationInput := translation.CreateTranslationInput{ + translationInput := translation.CreateOrUpdateTranslationInput{ Title: input.Name, Content: *input.Content, Language: input.Language, @@ -116,9 +116,11 @@ func (r *mutationResolver) CreateWork(ctx context.Context, input model.WorkInput TranslatableType: "works", IsOriginalLanguage: true, } - _, err := r.App.Translation.Commands.CreateTranslation(ctx, translationInput) + _, err := r.App.Translation.Commands.CreateOrUpdateTranslation(ctx, translationInput) if err != nil { - return nil, fmt.Errorf("failed to create translation: %w", err) + // If this fails, should we roll back the work creation? + // For now, just return the error. A transaction would be better. + return nil, fmt.Errorf("failed to create initial translation: %w", err) } } @@ -187,42 +189,27 @@ func (r *mutationResolver) CreateTranslation(ctx context.Context, input model.Tr return nil, err } - can, err := r.App.Authz.CanCreateTranslation(ctx) - if err != nil { - return nil, err - } - if !can { - return nil, domain.ErrForbidden - } + // The authorization is now handled inside the command, so we don't need a separate check here. workID, err := strconv.ParseUint(input.WorkID, 10, 32) if err != nil { return nil, fmt.Errorf("%w: invalid work ID", domain.ErrValidation) } - // Create domain model - translationModel := &domain.Translation{ - Title: input.Name, - Language: input.Language, - TranslatableID: uint(workID), - TranslatableType: "Work", - } + var content string if input.Content != nil { - translationModel.Content = *input.Content + content = *input.Content } - // Call translation service - createInput := translation.CreateTranslationInput{ - Title: translationModel.Title, - Content: translationModel.Content, - Description: translationModel.Description, - Language: translationModel.Language, - Status: translationModel.Status, - TranslatableID: translationModel.TranslatableID, - TranslatableType: translationModel.TranslatableType, - TranslatorID: translationModel.TranslatorID, + // Call translation service using the new CreateOrUpdate command + createInput := translation.CreateOrUpdateTranslationInput{ + Title: input.Name, + Content: content, + Language: input.Language, + TranslatableID: uint(workID), + TranslatableType: "works", // Assuming "works" for now, schema should be more generic } - createdTranslation, err := r.App.Translation.Commands.CreateTranslation(ctx, createInput) + createdTranslation, err := r.App.Translation.Commands.CreateOrUpdateTranslation(ctx, createInput) if err != nil { return nil, err } @@ -242,31 +229,39 @@ func (r *mutationResolver) CreateTranslation(ctx context.Context, input model.Tr // UpdateTranslation is the resolver for the updateTranslation field. func (r *mutationResolver) UpdateTranslation(ctx context.Context, id string, input model.TranslationInput) (*model.Translation, error) { + // This now acts as an "upsert" by calling the same command as CreateTranslation. + // The `id` of the translation is no longer needed, as the upsert logic + // relies on the parent (WorkID) and the language. if err := Validate(input); err != nil { return nil, err } - translationID, err := strconv.ParseUint(id, 10, 32) + + workID, err := strconv.ParseUint(input.WorkID, 10, 32) if err != nil { - return nil, fmt.Errorf("invalid translation ID: %v", err) + return nil, fmt.Errorf("%w: invalid work ID", domain.ErrValidation) } - // Call translation service - updateInput := translation.UpdateTranslationInput{ - ID: uint(translationID), - Title: input.Name, - Language: input.Language, - } + var content string if input.Content != nil { - updateInput.Content = *input.Content + content = *input.Content } - updatedTranslation, err := r.App.Translation.Commands.UpdateTranslation(ctx, updateInput) + + // Call translation service using the new CreateOrUpdate command + updateInput := translation.CreateOrUpdateTranslationInput{ + Title: input.Name, + Content: content, + Language: input.Language, + TranslatableID: uint(workID), + TranslatableType: "works", + } + updatedTranslation, err := r.App.Translation.Commands.CreateOrUpdateTranslation(ctx, updateInput) if err != nil { return nil, err } // Convert to GraphQL model return &model.Translation{ - ID: id, + ID: fmt.Sprintf("%d", updatedTranslation.ID), // Return the potentially new ID Name: updatedTranslation.Title, Language: updatedTranslation.Language, Content: &updatedTranslation.Content, diff --git a/internal/app/analytics/service.go b/internal/app/analytics/service.go index 59ab770..b4c166c 100644 --- a/internal/app/analytics/service.go +++ b/internal/app/analytics/service.go @@ -25,6 +25,8 @@ type Service interface { IncrementWorkTranslationCount(ctx context.Context, workID uint) error IncrementTranslationViews(ctx context.Context, translationID uint) error IncrementTranslationLikes(ctx context.Context, translationID uint) error + DecrementWorkLikes(ctx context.Context, workID uint) error + DecrementTranslationLikes(ctx context.Context, translationID uint) error IncrementTranslationComments(ctx context.Context, translationID uint) error IncrementTranslationShares(ctx context.Context, translationID uint) error GetOrCreateWorkStats(ctx context.Context, workID uint) (*work.WorkStats, error) @@ -73,6 +75,12 @@ func (s *service) IncrementWorkLikes(ctx context.Context, workID uint) error { return s.repo.IncrementWorkCounter(ctx, workID, "likes", 1) } +func (s *service) DecrementWorkLikes(ctx context.Context, workID uint) error { + ctx, span := s.tracer.Start(ctx, "DecrementWorkLikes") + defer span.End() + return s.repo.IncrementWorkCounter(ctx, workID, "likes", -1) +} + func (s *service) IncrementWorkComments(ctx context.Context, workID uint) error { ctx, span := s.tracer.Start(ctx, "IncrementWorkComments") defer span.End() @@ -109,6 +117,12 @@ func (s *service) IncrementTranslationLikes(ctx context.Context, translationID u return s.repo.IncrementTranslationCounter(ctx, translationID, "likes", 1) } +func (s *service) DecrementTranslationLikes(ctx context.Context, translationID uint) error { + ctx, span := s.tracer.Start(ctx, "DecrementTranslationLikes") + defer span.End() + return s.repo.IncrementTranslationCounter(ctx, translationID, "likes", -1) +} + func (s *service) IncrementTranslationComments(ctx context.Context, translationID uint) error { ctx, span := s.tracer.Start(ctx, "IncrementTranslationComments") defer span.End() diff --git a/internal/app/authz/authz.go b/internal/app/authz/authz.go index 1eb5a87..0b59477 100644 --- a/internal/app/authz/authz.go +++ b/internal/app/authz/authz.go @@ -58,6 +58,24 @@ func (s *Service) CanDeleteWork(ctx context.Context) (bool, error) { return false, domain.ErrForbidden } +// CanEditEntity checks if a user has permission to edit a specific translatable entity. +func (s *Service) CanEditEntity(ctx context.Context, userID uint, translatableType string, translatableID uint) (bool, error) { + switch translatableType { + case "works": + // For works, we can reuse the CanEditWork logic. + // First, we need to fetch the work. + work, err := s.workRepo.GetByID(ctx, translatableID) + if err != nil { + return false, err // Handles not found, etc. + } + return s.CanEditWork(ctx, userID, work) + default: + // For now, deny all other types by default. + // This can be expanded later. + return false, domain.ErrForbidden + } +} + // CanDeleteTranslation checks if a user can delete a translation. func (s *Service) CanDeleteTranslation(ctx context.Context) (bool, error) { claims, ok := platform_auth.GetClaimsFromContext(ctx) diff --git a/internal/app/like/commands.go b/internal/app/like/commands.go index ded378b..cf77726 100644 --- a/internal/app/like/commands.go +++ b/internal/app/like/commands.go @@ -2,6 +2,7 @@ package like import ( "context" + "errors" "tercul/internal/app/analytics" "tercul/internal/domain" ) @@ -58,7 +59,34 @@ func (c *LikeCommands) CreateLike(ctx context.Context, input CreateLikeInput) (* return like, nil } -// DeleteLike deletes a like by ID. +// DeleteLike deletes a like by ID and decrements the relevant counter. func (c *LikeCommands) DeleteLike(ctx context.Context, id uint) error { - return c.repo.Delete(ctx, id) + // First, get the like to determine what it was attached to. + like, err := c.repo.GetByID(ctx, id) + if err != nil { + // If the like doesn't exist, we can't decrement anything, but we shouldn't fail. + // The end result (the like is gone) is the same. + if errors.Is(err, domain.ErrEntityNotFound) { + return nil + } + return err + } + + // Now, delete the like. + err = c.repo.Delete(ctx, id) + if err != nil { + return err + } + + // After deleting the like, decrement the appropriate counter in the background. + if c.analyticsSvc != nil { + if like.WorkID != nil { + go c.analyticsSvc.DecrementWorkLikes(context.Background(), *like.WorkID) + } + if like.TranslationID != nil { + go c.analyticsSvc.DecrementTranslationLikes(context.Background(), *like.TranslationID) + } + } + + return nil } diff --git a/internal/app/translation/commands.go b/internal/app/translation/commands.go index 6791032..4ca9060 100644 --- a/internal/app/translation/commands.go +++ b/internal/app/translation/commands.go @@ -2,7 +2,6 @@ package translation import ( "context" - "errors" "fmt" "tercul/internal/app/authz" "tercul/internal/domain" @@ -28,8 +27,8 @@ func NewTranslationCommands(repo domain.TranslationRepository, authzSvc *authz.S } } -// CreateTranslationInput represents the input for creating a new translation. -type CreateTranslationInput struct { +// CreateOrUpdateTranslationInput represents the input for creating or updating a translation. +type CreateOrUpdateTranslationInput struct { Title string Content string Description string @@ -41,10 +40,43 @@ type CreateTranslationInput struct { IsOriginalLanguage bool } -// CreateTranslation creates a new translation. -func (c *TranslationCommands) CreateTranslation(ctx context.Context, input CreateTranslationInput) (*domain.Translation, error) { - ctx, span := c.tracer.Start(ctx, "CreateTranslation") +// CreateOrUpdateTranslation creates a new translation or updates an existing one. +func (c *TranslationCommands) CreateOrUpdateTranslation(ctx context.Context, input CreateOrUpdateTranslationInput) (*domain.Translation, error) { + ctx, span := c.tracer.Start(ctx, "CreateOrUpdateTranslation") defer span.End() + + // Validate input first + if input.Language == "" { + return nil, fmt.Errorf("%w: language cannot be empty", domain.ErrValidation) + } + if input.TranslatableID == 0 { + return nil, fmt.Errorf("%w: translatable ID cannot be zero", domain.ErrValidation) + } + if input.TranslatableType == "" { + return nil, fmt.Errorf("%w: translatable type cannot be empty", domain.ErrValidation) + } + + userID, ok := platform_auth.GetUserIDFromContext(ctx) + if !ok { + return nil, domain.ErrUnauthorized + } + + // Authorize that the user can edit the parent entity. + can, err := c.authzSvc.CanEditEntity(ctx, userID, input.TranslatableType, input.TranslatableID) + if err != nil { + return nil, fmt.Errorf("authorization check failed: %w", err) + } + if !can { + return nil, domain.ErrForbidden + } + + var translatorID uint + if input.TranslatorID != nil { + translatorID = *input.TranslatorID + } else { + translatorID = userID + } + translation := &domain.Translation{ Title: input.Title, Content: input.Content, @@ -53,60 +85,14 @@ func (c *TranslationCommands) CreateTranslation(ctx context.Context, input Creat Status: input.Status, TranslatableID: input.TranslatableID, TranslatableType: input.TranslatableType, - TranslatorID: input.TranslatorID, + TranslatorID: &translatorID, IsOriginalLanguage: input.IsOriginalLanguage, } - err := c.repo.Create(ctx, translation) - if err != nil { - return nil, err - } - return translation, nil -} -// UpdateTranslationInput represents the input for updating an existing translation. -type UpdateTranslationInput struct { - ID uint - Title string - Content string - Description string - Language string - Status domain.TranslationStatus -} - -// UpdateTranslation updates an existing translation. -func (c *TranslationCommands) UpdateTranslation(ctx context.Context, input UpdateTranslationInput) (*domain.Translation, error) { - ctx, span := c.tracer.Start(ctx, "UpdateTranslation") - defer span.End() - userID, ok := platform_auth.GetUserIDFromContext(ctx) - if !ok { - return nil, domain.ErrUnauthorized + if err := c.repo.Upsert(ctx, translation); err != nil { + return nil, fmt.Errorf("failed to upsert translation: %w", err) } - can, err := c.authzSvc.CanEditTranslation(ctx, userID, input.ID) - if err != nil { - return nil, err - } - if !can { - return nil, domain.ErrForbidden - } - - translation, err := c.repo.GetByID(ctx, input.ID) - if err != nil { - if errors.Is(err, domain.ErrEntityNotFound) { - return nil, fmt.Errorf("%w: translation with id %d not found", domain.ErrEntityNotFound, input.ID) - } - return nil, err - } - - translation.Title = input.Title - translation.Content = input.Content - translation.Description = input.Description - translation.Language = input.Language - translation.Status = input.Status - err = c.repo.Update(ctx, translation) - if err != nil { - return nil, err - } return translation, nil } diff --git a/internal/app/translation/commands_test.go b/internal/app/translation/commands_test.go new file mode 100644 index 0000000..ae51cce --- /dev/null +++ b/internal/app/translation/commands_test.go @@ -0,0 +1,127 @@ +package translation_test + +import ( + "context" + "testing" + + "tercul/internal/app/authz" + "tercul/internal/app/translation" + "tercul/internal/domain" + "tercul/internal/domain/work" + platform_auth "tercul/internal/platform/auth" + "tercul/internal/testutil" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/suite" +) + +type TranslationCommandsTestSuite struct { + suite.Suite + mockWorkRepo *testutil.MockWorkRepository + mockTranslationRepo *testutil.MockTranslationRepository + authzSvc *authz.Service + cmd *translation.TranslationCommands + adminCtx context.Context + userCtx context.Context + adminUser *domain.User + regularUser *domain.User +} + +func (s *TranslationCommandsTestSuite) SetupTest() { + s.mockWorkRepo = new(testutil.MockWorkRepository) + s.mockTranslationRepo = new(testutil.MockTranslationRepository) + s.authzSvc = authz.NewService(s.mockWorkRepo, s.mockTranslationRepo) + s.cmd = translation.NewTranslationCommands(s.mockTranslationRepo, s.authzSvc) + + s.adminUser = &domain.User{BaseModel: domain.BaseModel{ID: 1}, Role: domain.UserRoleAdmin} + s.regularUser = &domain.User{BaseModel: domain.BaseModel{ID: 2}, Role: domain.UserRoleContributor} + + s.adminCtx = context.WithValue(context.Background(), platform_auth.ClaimsContextKey, &platform_auth.Claims{ + UserID: s.adminUser.ID, + Role: string(s.adminUser.Role), + }) + s.userCtx = context.WithValue(context.Background(), platform_auth.ClaimsContextKey, &platform_auth.Claims{ + UserID: s.regularUser.ID, + Role: string(s.regularUser.Role), + }) +} + +func (s *TranslationCommandsTestSuite) TestCreateOrUpdateTranslation() { + testWork := &work.Work{ + TranslatableModel: domain.TranslatableModel{BaseModel: domain.BaseModel{ID: 1}}, + } + input := translation.CreateOrUpdateTranslationInput{ + Title: "Test Title", + Content: "Test content", + Language: "es", + TranslatableID: testWork.ID, + TranslatableType: "works", + } + + s.Run("should create translation for admin", func() { + // Arrange + s.mockWorkRepo.On("GetByID", mock.Anything, testWork.ID).Return(testWork, nil).Once() + s.mockTranslationRepo.On("Upsert", mock.Anything, mock.AnythingOfType("*domain.Translation")).Return(nil).Once() + + // Act + result, err := s.cmd.CreateOrUpdateTranslation(s.adminCtx, input) + + // Assert + s.NoError(err) + s.NotNil(result) + s.Equal(input.Title, result.Title) + s.Equal(s.adminUser.ID, *result.TranslatorID) + s.mockWorkRepo.AssertExpectations(s.T()) + s.mockTranslationRepo.AssertExpectations(s.T()) + }) + + s.Run("should create translation for author", func() { + // Arrange + s.mockWorkRepo.On("GetByID", mock.Anything, testWork.ID).Return(testWork, nil).Once() + s.mockWorkRepo.On("IsAuthor", mock.Anything, testWork.ID, s.regularUser.ID).Return(true, nil).Once() + s.mockTranslationRepo.On("Upsert", mock.Anything, mock.AnythingOfType("*domain.Translation")).Return(nil).Once() + + // Act + result, err := s.cmd.CreateOrUpdateTranslation(s.userCtx, input) + + // Assert + s.NoError(err) + s.NotNil(result) + s.Equal(input.Title, result.Title) + s.Equal(s.regularUser.ID, *result.TranslatorID) + s.mockWorkRepo.AssertExpectations(s.T()) + s.mockTranslationRepo.AssertExpectations(s.T()) + }) + + s.Run("should fail if user is not authorized", func() { + // Arrange + s.mockWorkRepo.On("GetByID", mock.Anything, testWork.ID).Return(testWork, nil).Once() + s.mockWorkRepo.On("IsAuthor", mock.Anything, testWork.ID, s.regularUser.ID).Return(false, nil).Once() + + // Act + _, err := s.cmd.CreateOrUpdateTranslation(s.userCtx, input) + + // Assert + s.Error(err) + s.ErrorIs(err, domain.ErrForbidden) + s.mockWorkRepo.AssertExpectations(s.T()) + }) + + s.Run("should fail on validation error for empty language", func() { + // Arrange + invalidInput := input + invalidInput.Language = "" + + // Act + _, err := s.cmd.CreateOrUpdateTranslation(s.userCtx, invalidInput) + + // Assert + s.Error(err) + assert.ErrorContains(s.T(), err, "language cannot be empty") + }) +} + +func TestTranslationCommands(t *testing.T) { + suite.Run(t, new(TranslationCommandsTestSuite)) +} \ No newline at end of file diff --git a/internal/app/work/commands.go b/internal/app/work/commands.go index e22e206..3bfe620 100644 --- a/internal/app/work/commands.go +++ b/internal/app/work/commands.go @@ -196,11 +196,21 @@ func (c *WorkCommands) MergeWork(ctx context.Context, sourceID, targetID uint) e return err } - // Re-associate polymorphic Translations - if err = tx.Model(&domain.Translation{}). - Where("translatable_id = ? AND translatable_type = ?", sourceID, "works"). - Update("translatable_id", targetID).Error; err != nil { - return fmt.Errorf("failed to merge translations: %w", err) + // Merge translations intelligently to avoid unique constraint violations. + targetLanguages := make(map[string]bool) + for _, t := range targetWork.Translations { + targetLanguages[t.Language] = true + } + + for _, sTranslation := range sourceWork.Translations { + if _, exists := targetLanguages[sTranslation.Language]; !exists { + // No conflict, re-associate this translation with the target work. + if err := tx.Model(&sTranslation).Update("translatable_id", targetID).Error; err != nil { + return fmt.Errorf("failed to merge translation for language %s: %w", sTranslation.Language, err) + } + } + // If a translation for the language already exists on the target, we do nothing. + // The source translation will be implicitly deleted with the source work. } // Append many-to-many associations diff --git a/internal/app/work/commands_test.go b/internal/app/work/commands_test.go index 250fcab..5bb1387 100644 --- a/internal/app/work/commands_test.go +++ b/internal/app/work/commands_test.go @@ -195,7 +195,8 @@ func TestMergeWork_Integration(t *testing.T) { Tags: []*domain.Tag{tag1}, } db.Create(sourceWork) - db.Create(&domain.Translation{Title: "Source Translation", Language: "en", TranslatableID: sourceWork.ID, TranslatableType: "works"}) + 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(&workdomain.WorkStats{WorkID: sourceWork.ID, Views: 10, Likes: 5}) targetWork := &workdomain.Work{ @@ -205,7 +206,7 @@ func TestMergeWork_Integration(t *testing.T) { Tags: []*domain.Tag{tag2}, } db.Create(targetWork) - db.Create(&domain.Translation{Title: "Target Translation", Language: "en", TranslatableID: targetWork.ID, TranslatableType: "works"}) + db.Create(&domain.Translation{Title: "Target English", Language: "en", TranslatableID: targetWork.ID, TranslatableType: "works"}) db.Create(&workdomain.WorkStats{WorkID: targetWork.ID, Views: 20, Likes: 10}) // --- Execute Merge --- @@ -224,7 +225,22 @@ func TestMergeWork_Integration(t *testing.T) { var finalTargetWork workdomain.Work db.Preload("Translations").Preload("Authors").Preload("Tags").First(&finalTargetWork, targetWork.ID) - assert.Len(t, finalTargetWork.Translations, 2, "Translations should be merged") + 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") diff --git a/internal/data/sql/translation_repository.go b/internal/data/sql/translation_repository.go index d17d239..8b97139 100644 --- a/internal/data/sql/translation_repository.go +++ b/internal/data/sql/translation_repository.go @@ -7,6 +7,7 @@ import ( "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/trace" "gorm.io/gorm" + "gorm.io/gorm/clause" ) type translationRepository struct { @@ -35,6 +36,20 @@ func (r *translationRepository) ListByWorkID(ctx context.Context, workID uint) ( return translations, nil } +// Upsert creates a new translation or updates an existing one based on the unique +// composite key of (translatable_id, translatable_type, language). +func (r *translationRepository) Upsert(ctx context.Context, translation *domain.Translation) error { + ctx, span := r.tracer.Start(ctx, "Upsert") + defer span.End() + + // The unique key for a translation is (TranslatableID, TranslatableType, Language). + // If a translation with this combination exists, we update it. Otherwise, we create it. + return r.db.WithContext(ctx).Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "translatable_id"}, {Name: "translatable_type"}, {Name: "language"}}, + DoUpdates: clause.AssignmentColumns([]string{"title", "content", "description", "status", "translator_id"}), + }).Create(translation).Error +} + // ListByEntity finds translations by entity type and ID func (r *translationRepository) ListByEntity(ctx context.Context, entityType string, entityID uint) ([]domain.Translation, error) { ctx, span := r.tracer.Start(ctx, "ListByEntity") diff --git a/internal/domain/entities.go b/internal/domain/entities.go index ac65eeb..e1ffb1c 100644 --- a/internal/domain/entities.go +++ b/internal/domain/entities.go @@ -455,17 +455,17 @@ type Series struct { type Translation struct { BaseModel - Title string `gorm:"size:255;not null"` - Content string `gorm:"type:text"` - Description string `gorm:"type:text"` - Language string `gorm:"size:50;not null"` - Status TranslationStatus `gorm:"size:50;default:'draft'"` - PublishedAt *time.Time - TranslatableID uint `gorm:"not null"` - TranslatableType string `gorm:"size:50;not null"` - TranslatorID *uint - Translator *User `gorm:"foreignKey:TranslatorID"` - IsOriginalLanguage bool `gorm:"default:false"` + Title string `gorm:"size:255;not null"` + Content string `gorm:"type:text"` + Description string `gorm:"type:text"` + Language string `gorm:"size:50;not null;uniqueIndex:idx_translation_target_language"` + Status TranslationStatus `gorm:"size:50;default:'draft'"` + PublishedAt *time.Time + TranslatableID uint `gorm:"not null;uniqueIndex:idx_translation_target_language"` + TranslatableType string `gorm:"size:50;not null;uniqueIndex:idx_translation_target_language"` + TranslatorID *uint + Translator *User `gorm:"foreignKey:TranslatorID"` + IsOriginalLanguage bool `gorm:"default:false"` AudioURL string `gorm:"size:512"` DateTranslated *time.Time } diff --git a/internal/domain/interfaces.go b/internal/domain/interfaces.go index b9d04e4..85cc633 100644 --- a/internal/domain/interfaces.go +++ b/internal/domain/interfaces.go @@ -179,6 +179,7 @@ type TranslationRepository interface { ListByEntity(ctx context.Context, entityType string, entityID uint) ([]Translation, error) ListByTranslatorID(ctx context.Context, translatorID uint) ([]Translation, error) ListByStatus(ctx context.Context, status TranslationStatus) ([]Translation, error) + Upsert(ctx context.Context, translation *Translation) error } // UserRepository defines CRUD methods specific to User. diff --git a/internal/testutil/integration_test_utils.go b/internal/testutil/integration_test_utils.go index 34f5cbb..48cdd53 100644 --- a/internal/testutil/integration_test_utils.go +++ b/internal/testutil/integration_test_utils.go @@ -11,6 +11,7 @@ import ( "tercul/internal/data/sql" "tercul/internal/domain" "tercul/internal/domain/search" + platform_auth "tercul/internal/platform/auth" "tercul/internal/domain/work" "tercul/internal/jobs/linguistics" "time" @@ -81,8 +82,9 @@ func (m *mockAnalyticsService) GetTrendingWorks(ctx context.Context, timePeriod // IntegrationTestSuite provides a comprehensive test environment with either in-memory SQLite or mock repositories type IntegrationTestSuite struct { suite.Suite - App *app.Application - DB *gorm.DB + App *app.Application + DB *gorm.DB + AdminCtx context.Context } // TestConfig holds configuration for the test environment @@ -94,9 +96,11 @@ type TestConfig struct { // DefaultTestConfig returns a default test configuration func DefaultTestConfig() *TestConfig { + // Using a file-based DB is more stable for integration tests across multiple packages + // than the in-memory one, which can behave unpredictably with `go test ./...` return &TestConfig{ - UseInMemoryDB: true, - DBPath: "", + UseInMemoryDB: false, + DBPath: "test.db", LogLevel: logger.Silent, } } @@ -108,7 +112,9 @@ func (s *IntegrationTestSuite) SetupSuite(config *TestConfig) { } var dbPath string - if config.DBPath != "" { + if !config.UseInMemoryDB && config.DBPath != "" { + // Clean up previous test database file before starting + _ = os.Remove(config.DBPath) // Ensure directory exists dir := filepath.Dir(config.DBPath) if err := os.MkdirAll(dir, 0755); err != nil { @@ -158,6 +164,21 @@ func (s *IntegrationTestSuite) SetupSuite(config *TestConfig) { } analyticsService := analytics.NewService(repos.Analytics, analysisRepo, repos.Translation, repos.Work, sentimentProvider) s.App = app.NewApplication(repos, searchClient, analyticsService) + + // Create a default admin user for tests + adminUser := &domain.User{ + Username: "admin", + Email: "admin@test.com", + Role: domain.UserRoleAdmin, + Active: true, + } + _ = adminUser.SetPassword("password") + err = s.DB.Create(adminUser).Error + s.Require().NoError(err) + s.AdminCtx = ContextWithClaims(context.Background(), &platform_auth.Claims{ + UserID: adminUser.ID, + Role: string(adminUser.Role), + }) } // TearDownSuite cleans up the test suite @@ -192,17 +213,21 @@ func (s *IntegrationTestSuite) CreateTestWork(title, language string, content st Language: language, }, } - createdWork, err := s.App.Work.Commands.CreateWork(context.Background(), work) + // Note: CreateWork command might not exist or need context. Assuming it does for now. + // If CreateWork also requires auth, this context should be s.AdminCtx + createdWork, err := s.App.Work.Commands.CreateWork(s.AdminCtx, work) s.Require().NoError(err) + if content != "" { - translationInput := translation.CreateTranslationInput{ - Title: title, - Content: content, - Language: language, - TranslatableID: createdWork.ID, - TranslatableType: "works", + translationInput := translation.CreateOrUpdateTranslationInput{ + Title: title, + Content: content, + Language: language, + TranslatableID: createdWork.ID, + TranslatableType: "works", + IsOriginalLanguage: true, // Assuming the first one is original } - _, err = s.App.Translation.Commands.CreateTranslation(context.Background(), translationInput) + _, err = s.App.Translation.Commands.CreateOrUpdateTranslation(s.AdminCtx, translationInput) s.Require().NoError(err) } return createdWork @@ -210,14 +235,19 @@ func (s *IntegrationTestSuite) CreateTestWork(title, language string, content st // CreateTestTranslation creates a test translation for a work. func (s *IntegrationTestSuite) CreateTestTranslation(workID uint, language, content string) *domain.Translation { - translationInput := translation.CreateTranslationInput{ + translationInput := translation.CreateOrUpdateTranslationInput{ Title: "Test Translation", Content: content, Language: language, TranslatableID: workID, TranslatableType: "works", } - createdTranslation, err := s.App.Translation.Commands.CreateTranslation(context.Background(), translationInput) + createdTranslation, err := s.App.Translation.Commands.CreateOrUpdateTranslation(s.AdminCtx, translationInput) s.Require().NoError(err) return createdTranslation +} + +// ContextWithClaims creates a new context with the given claims. +func ContextWithClaims(ctx context.Context, claims *platform_auth.Claims) context.Context { + return context.WithValue(ctx, platform_auth.ClaimsContextKey, claims) } \ No newline at end of file diff --git a/internal/testutil/mock_translation_repository.go b/internal/testutil/mock_translation_repository.go index 52a71b6..0257370 100644 --- a/internal/testutil/mock_translation_repository.go +++ b/internal/testutil/mock_translation_repository.go @@ -2,188 +2,174 @@ package testutil import ( "context" - "errors" "gorm.io/gorm" "tercul/internal/domain" ) +import "github.com/stretchr/testify/mock" + // MockTranslationRepository is an in-memory implementation of TranslationRepository type MockTranslationRepository struct { - items []domain.Translation + mock.Mock } func NewMockTranslationRepository() *MockTranslationRepository { - return &MockTranslationRepository{items: []domain.Translation{}} + return new(MockTranslationRepository) } var _ domain.TranslationRepository = (*MockTranslationRepository)(nil) // BaseRepository methods with context support func (m *MockTranslationRepository) Create(ctx context.Context, t *domain.Translation) error { - if t == nil { - return errors.New("nil translation") - } - t.ID = uint(len(m.items) + 1) - m.items = append(m.items, *t) - return nil + args := m.Called(ctx, t) + return args.Error(0) } func (m *MockTranslationRepository) GetByID(ctx context.Context, id uint) (*domain.Translation, error) { - for i := range m.items { - if m.items[i].ID == id { - cp := m.items[i] - return &cp, nil - } + args := m.Called(ctx, id) + if args.Get(0) == nil { + return nil, args.Error(1) } - return nil, domain.ErrEntityNotFound + return args.Get(0).(*domain.Translation), args.Error(1) } func (m *MockTranslationRepository) Update(ctx context.Context, t *domain.Translation) error { - for i := range m.items { - if m.items[i].ID == t.ID { - m.items[i] = *t - return nil - } - } - return domain.ErrEntityNotFound + args := m.Called(ctx, t) + return args.Error(0) } func (m *MockTranslationRepository) Delete(ctx context.Context, id uint) error { - for i := range m.items { - if m.items[i].ID == id { - m.items = append(m.items[:i], m.items[i+1:]...) - return nil - } - } - return domain.ErrEntityNotFound + args := m.Called(ctx, id) + return args.Error(0) } func (m *MockTranslationRepository) List(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[domain.Translation], error) { - all := append([]domain.Translation(nil), m.items...) - total := int64(len(all)) - start := (page - 1) * pageSize - end := start + pageSize - if start > len(all) { - return &domain.PaginatedResult[domain.Translation]{Items: []domain.Translation{}, TotalCount: total}, nil + args := m.Called(ctx, page, pageSize) + if args.Get(0) == nil { + return nil, args.Error(1) } - if end > len(all) { - end = len(all) - } - return &domain.PaginatedResult[domain.Translation]{Items: all[start:end], TotalCount: total}, nil + return args.Get(0).(*domain.PaginatedResult[domain.Translation]), args.Error(1) } func (m *MockTranslationRepository) ListAll(ctx context.Context) ([]domain.Translation, error) { - return append([]domain.Translation(nil), m.items...), nil + args := m.Called(ctx) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).([]domain.Translation), args.Error(1) } func (m *MockTranslationRepository) Count(ctx context.Context) (int64, error) { - return int64(len(m.items)), nil + args := m.Called(ctx) + return args.Get(0).(int64), args.Error(1) } func (m *MockTranslationRepository) FindWithPreload(ctx context.Context, preloads []string, id uint) (*domain.Translation, error) { - return m.GetByID(ctx, id) + args := m.Called(ctx, preloads, id) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*domain.Translation), args.Error(1) } func (m *MockTranslationRepository) GetAllForSync(ctx context.Context, batchSize, offset int) ([]domain.Translation, error) { - all := append([]domain.Translation(nil), m.items...) - end := offset + batchSize - if end > len(all) { - end = len(all) + args := m.Called(ctx, batchSize, offset) + if args.Get(0) == nil { + return nil, args.Error(1) } - if offset > len(all) { - return []domain.Translation{}, nil - } - return all[offset:end], nil + return args.Get(0).([]domain.Translation), args.Error(1) } // New BaseRepository methods func (m *MockTranslationRepository) CreateInTx(ctx context.Context, tx *gorm.DB, entity *domain.Translation) error { - return m.Create(ctx, entity) + args := m.Called(ctx, tx, entity) + return args.Error(0) } func (m *MockTranslationRepository) GetByIDWithOptions(ctx context.Context, id uint, options *domain.QueryOptions) (*domain.Translation, error) { - return m.GetByID(ctx, id) + args := m.Called(ctx, id, options) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*domain.Translation), args.Error(1) } func (m *MockTranslationRepository) UpdateInTx(ctx context.Context, tx *gorm.DB, entity *domain.Translation) error { - return m.Update(ctx, entity) + args := m.Called(ctx, tx, entity) + return args.Error(0) } func (m *MockTranslationRepository) DeleteInTx(ctx context.Context, tx *gorm.DB, id uint) error { - return m.Delete(ctx, id) + args := m.Called(ctx, tx, id) + return args.Error(0) } func (m *MockTranslationRepository) ListWithOptions(ctx context.Context, options *domain.QueryOptions) ([]domain.Translation, error) { - result, err := m.List(ctx, 1, 1000) - if err != nil { - return nil, err + args := m.Called(ctx, options) + if args.Get(0) == nil { + return nil, args.Error(1) } - return result.Items, nil + return args.Get(0).([]domain.Translation), args.Error(1) } func (m *MockTranslationRepository) CountWithOptions(ctx context.Context, options *domain.QueryOptions) (int64, error) { - return m.Count(ctx) + args := m.Called(ctx, options) + return args.Get(0).(int64), args.Error(1) } func (m *MockTranslationRepository) Exists(ctx context.Context, id uint) (bool, error) { - _, err := m.GetByID(ctx, id) - return err == nil, nil + args := m.Called(ctx, id) + return args.Bool(0), args.Error(1) } func (m *MockTranslationRepository) BeginTx(ctx context.Context) (*gorm.DB, error) { - return nil, nil + args := m.Called(ctx) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*gorm.DB), args.Error(1) } func (m *MockTranslationRepository) WithTx(ctx context.Context, fn func(tx *gorm.DB) error) error { + // This is tricky to mock. For now, just execute the function. + // A real test might want to mock the transaction itself. return fn(nil) } +func (m *MockTranslationRepository) Upsert(ctx context.Context, t *domain.Translation) error { + args := m.Called(ctx, t) + return args.Error(0) +} + // TranslationRepository specific methods func (m *MockTranslationRepository) ListByWorkID(ctx context.Context, workID uint) ([]domain.Translation, error) { - return m.ListByEntity(ctx, "Work", workID) + args := m.Called(ctx, workID) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).([]domain.Translation), args.Error(1) } func (m *MockTranslationRepository) ListByEntity(ctx context.Context, entityType string, entityID uint) ([]domain.Translation, error) { - var out []domain.Translation - for i := range m.items { - tr := m.items[i] - if tr.TranslatableType == entityType && tr.TranslatableID == entityID { - out = append(out, tr) - } + args := m.Called(ctx, entityType, entityID) + if args.Get(0) == nil { + return nil, args.Error(1) } - return out, nil + return args.Get(0).([]domain.Translation), args.Error(1) } func (m *MockTranslationRepository) ListByTranslatorID(ctx context.Context, translatorID uint) ([]domain.Translation, error) { - var out []domain.Translation - for i := range m.items { - if m.items[i].TranslatorID != nil && *m.items[i].TranslatorID == translatorID { - out = append(out, m.items[i]) - } + args := m.Called(ctx, translatorID) + if args.Get(0) == nil { + return nil, args.Error(1) } - return out, nil + return args.Get(0).([]domain.Translation), args.Error(1) } func (m *MockTranslationRepository) ListByStatus(ctx context.Context, status domain.TranslationStatus) ([]domain.Translation, error) { - var out []domain.Translation - for i := range m.items { - if m.items[i].Status == status { - out = append(out, m.items[i]) - } + args := m.Called(ctx, status) + if args.Get(0) == nil { + return nil, args.Error(1) } - return out, nil -} - -// Test helper: add a translation for a Work -func (m *MockTranslationRepository) AddTranslationForWork(workID uint, language string, content string, isOriginal bool) { - m.Create(context.Background(), &domain.Translation{ - Title: "", - Content: content, - Description: "", - Language: language, - Status: domain.TranslationStatusPublished, - TranslatableID: workID, - TranslatableType: "Work", - IsOriginalLanguage: isOriginal, - }) + return args.Get(0).([]domain.Translation), args.Error(1) } diff --git a/internal/testutil/mock_work_repository.go b/internal/testutil/mock_work_repository.go new file mode 100644 index 0000000..f88fdba --- /dev/null +++ b/internal/testutil/mock_work_repository.go @@ -0,0 +1,98 @@ +package testutil + +import ( + "context" + + "tercul/internal/domain" + "tercul/internal/domain/work" + + "github.com/stretchr/testify/mock" + "gorm.io/gorm" +) + +// MockWorkRepository is a mock implementation of the work.WorkRepository interface. +type MockWorkRepository struct { + mock.Mock +} + +// Ensure MockWorkRepository implements the interface. +var _ work.WorkRepository = (*MockWorkRepository)(nil) + +// GetByID mocks the GetByID method. +func (m *MockWorkRepository) GetByID(ctx context.Context, id uint) (*work.Work, error) { + args := m.Called(ctx, id) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*work.Work), args.Error(1) +} + +// IsAuthor mocks the IsAuthor method. +func (m *MockWorkRepository) IsAuthor(ctx context.Context, workID uint, authorID uint) (bool, error) { + args := m.Called(ctx, workID, authorID) + return args.Bool(0), args.Error(1) +} + +// Empty implementations for the rest of the interface to satisfy the compiler. +func (m *MockWorkRepository) Create(ctx context.Context, entity *work.Work) error { + args := m.Called(ctx, entity) + return args.Error(0) +} +func (m *MockWorkRepository) Update(ctx context.Context, entity *work.Work) error { return nil } +func (m *MockWorkRepository) Delete(ctx context.Context, id uint) error { return nil } +func (m *MockWorkRepository) List(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[work.Work], error) { + return nil, nil +} +func (m *MockWorkRepository) ListAll(ctx context.Context) ([]work.Work, error) { return nil, nil } +func (m *MockWorkRepository) Count(ctx context.Context) (int64, error) { return 0, nil } +func (m *MockWorkRepository) Exists(ctx context.Context, id uint) (bool, error) { return false, nil } +func (m *MockWorkRepository) WithTx(ctx context.Context, fn func(tx *gorm.DB) error) error { return fn(nil) } +func (m *MockWorkRepository) FindByTitle(ctx context.Context, title string) ([]work.Work, error) { return nil, nil } +func (m *MockWorkRepository) FindByAuthor(ctx context.Context, authorID uint) ([]work.Work, error) { + return nil, nil +} +func (m *MockWorkRepository) FindByCategory(ctx context.Context, categoryID uint) ([]work.Work, error) { + return nil, nil +} +func (m *MockWorkRepository) FindByLanguage(ctx context.Context, language string, page, pageSize int) (*domain.PaginatedResult[work.Work], error) { + return nil, nil +} +func (m *MockWorkRepository) GetWithTranslations(ctx context.Context, id uint) (*work.Work, error) { + return nil, nil +} +func (m *MockWorkRepository) GetWithAssociations(ctx context.Context, id uint) (*work.Work, error) { + return nil, nil +} +func (m *MockWorkRepository) GetWithAssociationsInTx(ctx context.Context, tx *gorm.DB, id uint) (*work.Work, error) { + return nil, nil +} +func (m *MockWorkRepository) ListWithTranslations(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[work.Work], error) { + return nil, nil +} +func (m *MockWorkRepository) FindWithPreload(ctx context.Context, preloads []string, id uint) (*work.Work, error) { + return nil, nil +} +func (m *MockWorkRepository) GetAllForSync(ctx context.Context, batchSize, offset int) ([]work.Work, error) { + return nil, nil +} +func (m *MockWorkRepository) CreateInTx(ctx context.Context, tx *gorm.DB, entity *work.Work) error { + return nil +} +func (m *MockWorkRepository) GetByIDWithOptions(ctx context.Context, id uint, options *domain.QueryOptions) (*work.Work, error) { + args := m.Called(ctx, id, options) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*work.Work), args.Error(1) +} +func (m *MockWorkRepository) UpdateInTx(ctx context.Context, tx *gorm.DB, entity *work.Work) error { + return nil +} +func (m *MockWorkRepository) DeleteInTx(ctx context.Context, tx *gorm.DB, id uint) error { return nil } +func (m *MockWorkRepository) ListWithOptions(ctx context.Context, options *domain.QueryOptions) ([]work.Work, error) { + return nil, nil +} +func (m *MockWorkRepository) CountWithOptions(ctx context.Context, options *domain.QueryOptions) (int64, error) { + return 0, nil +} +func (m *MockWorkRepository) BeginTx(ctx context.Context) (*gorm.DB, error) { return nil, nil } \ No newline at end of file