mirror of
https://github.com/SamyRai/tercul-backend.git
synced 2025-12-27 00:31:35 +00:00
Refactor: Improve quality, testing, and core business logic.
This commit introduces a significant refactoring to improve the application's quality, test coverage, and production readiness, focusing on core localization and business logic features. Key changes include: - Consolidated the `CreateTranslation` and `UpdateTranslation` commands into a single, more robust `CreateOrUpdateTranslation` command. This uses a database-level `Upsert` for atomicity. - Centralized authorization for translatable entities into a new `CanEditEntity` check within the application service layer. - Fixed a critical bug in the `MergeWork` command that caused a UNIQUE constraint violation when merging works with conflicting translations. The logic now intelligently handles language conflicts. - Implemented decrementing for "like" counts in the analytics service when a like is deleted, ensuring accurate statistics. - Stabilized the test suite by switching to a file-based database for integration tests, fixing test data isolation issues, and adding a unique index to the `Translation` model to enforce data integrity. - Refactored manual mocks to use the `testify/mock` library for better consistency and maintainability.
This commit is contained in:
parent
23a6b6d569
commit
b03820de02
@ -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",
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
127
internal/app/translation/commands_test.go
Normal file
127
internal/app/translation/commands_test.go
Normal file
@ -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))
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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")
|
||||
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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)
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
98
internal/testutil/mock_work_repository.go
Normal file
98
internal/testutil/mock_work_repository.go
Normal file
@ -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 }
|
||||
Loading…
Reference in New Issue
Block a user