mirror of
https://github.com/SamyRai/tercul-backend.git
synced 2025-12-27 05:11:34 +00:00
Merge pull request #13 from SamyRai/feat/improve-quality-and-testing
Refactor Core Logic and Improve Test Suite
This commit is contained in:
commit
c066219323
@ -493,7 +493,7 @@ func (s *GraphQLIntegrationSuite) TestUpdateTranslationValidation() {
|
|||||||
s.Run("should return error for invalid input", func() {
|
s.Run("should return error for invalid input", func() {
|
||||||
// Arrange
|
// Arrange
|
||||||
work := s.CreateTestWork("Test Work", "en", "Test content")
|
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",
|
Title: "Test Translation",
|
||||||
Language: "en",
|
Language: "en",
|
||||||
Content: "Test content",
|
Content: "Test content",
|
||||||
@ -600,7 +600,7 @@ func (s *GraphQLIntegrationSuite) TestDeleteTranslation() {
|
|||||||
s.Run("should delete a translation", func() {
|
s.Run("should delete a translation", func() {
|
||||||
// Arrange
|
// Arrange
|
||||||
work := s.CreateTestWork("Test Work", "en", "Test content")
|
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",
|
Title: "Test Translation",
|
||||||
Language: "en",
|
Language: "en",
|
||||||
Content: "Test content",
|
Content: "Test content",
|
||||||
|
|||||||
@ -108,7 +108,7 @@ func (r *mutationResolver) CreateWork(ctx context.Context, input model.WorkInput
|
|||||||
}
|
}
|
||||||
|
|
||||||
if input.Content != nil && *input.Content != "" {
|
if input.Content != nil && *input.Content != "" {
|
||||||
translationInput := translation.CreateTranslationInput{
|
translationInput := translation.CreateOrUpdateTranslationInput{
|
||||||
Title: input.Name,
|
Title: input.Name,
|
||||||
Content: *input.Content,
|
Content: *input.Content,
|
||||||
Language: input.Language,
|
Language: input.Language,
|
||||||
@ -116,9 +116,11 @@ func (r *mutationResolver) CreateWork(ctx context.Context, input model.WorkInput
|
|||||||
TranslatableType: "works",
|
TranslatableType: "works",
|
||||||
IsOriginalLanguage: true,
|
IsOriginalLanguage: true,
|
||||||
}
|
}
|
||||||
_, err := r.App.Translation.Commands.CreateTranslation(ctx, translationInput)
|
_, err := r.App.Translation.Commands.CreateOrUpdateTranslation(ctx, translationInput)
|
||||||
if err != nil {
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
can, err := r.App.Authz.CanCreateTranslation(ctx)
|
// The authorization is now handled inside the command, so we don't need a separate check here.
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if !can {
|
|
||||||
return nil, domain.ErrForbidden
|
|
||||||
}
|
|
||||||
|
|
||||||
workID, err := strconv.ParseUint(input.WorkID, 10, 32)
|
workID, err := strconv.ParseUint(input.WorkID, 10, 32)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("%w: invalid work ID", domain.ErrValidation)
|
return nil, fmt.Errorf("%w: invalid work ID", domain.ErrValidation)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create domain model
|
var content string
|
||||||
translationModel := &domain.Translation{
|
|
||||||
Title: input.Name,
|
|
||||||
Language: input.Language,
|
|
||||||
TranslatableID: uint(workID),
|
|
||||||
TranslatableType: "Work",
|
|
||||||
}
|
|
||||||
if input.Content != nil {
|
if input.Content != nil {
|
||||||
translationModel.Content = *input.Content
|
content = *input.Content
|
||||||
}
|
}
|
||||||
|
|
||||||
// Call translation service
|
// Call translation service using the new CreateOrUpdate command
|
||||||
createInput := translation.CreateTranslationInput{
|
createInput := translation.CreateOrUpdateTranslationInput{
|
||||||
Title: translationModel.Title,
|
Title: input.Name,
|
||||||
Content: translationModel.Content,
|
Content: content,
|
||||||
Description: translationModel.Description,
|
Language: input.Language,
|
||||||
Language: translationModel.Language,
|
TranslatableID: uint(workID),
|
||||||
Status: translationModel.Status,
|
TranslatableType: "works", // Assuming "works" for now, schema should be more generic
|
||||||
TranslatableID: translationModel.TranslatableID,
|
|
||||||
TranslatableType: translationModel.TranslatableType,
|
|
||||||
TranslatorID: translationModel.TranslatorID,
|
|
||||||
}
|
}
|
||||||
createdTranslation, err := r.App.Translation.Commands.CreateTranslation(ctx, createInput)
|
createdTranslation, err := r.App.Translation.Commands.CreateOrUpdateTranslation(ctx, createInput)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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.
|
// UpdateTranslation is the resolver for the updateTranslation field.
|
||||||
func (r *mutationResolver) UpdateTranslation(ctx context.Context, id string, input model.TranslationInput) (*model.Translation, error) {
|
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 {
|
if err := Validate(input); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
translationID, err := strconv.ParseUint(id, 10, 32)
|
|
||||||
|
workID, err := strconv.ParseUint(input.WorkID, 10, 32)
|
||||||
if err != nil {
|
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
|
var content string
|
||||||
updateInput := translation.UpdateTranslationInput{
|
|
||||||
ID: uint(translationID),
|
|
||||||
Title: input.Name,
|
|
||||||
Language: input.Language,
|
|
||||||
}
|
|
||||||
if input.Content != nil {
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert to GraphQL model
|
// Convert to GraphQL model
|
||||||
return &model.Translation{
|
return &model.Translation{
|
||||||
ID: id,
|
ID: fmt.Sprintf("%d", updatedTranslation.ID), // Return the potentially new ID
|
||||||
Name: updatedTranslation.Title,
|
Name: updatedTranslation.Title,
|
||||||
Language: updatedTranslation.Language,
|
Language: updatedTranslation.Language,
|
||||||
Content: &updatedTranslation.Content,
|
Content: &updatedTranslation.Content,
|
||||||
|
|||||||
@ -25,6 +25,8 @@ type Service interface {
|
|||||||
IncrementWorkTranslationCount(ctx context.Context, workID uint) error
|
IncrementWorkTranslationCount(ctx context.Context, workID uint) error
|
||||||
IncrementTranslationViews(ctx context.Context, translationID uint) error
|
IncrementTranslationViews(ctx context.Context, translationID uint) error
|
||||||
IncrementTranslationLikes(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
|
IncrementTranslationComments(ctx context.Context, translationID uint) error
|
||||||
IncrementTranslationShares(ctx context.Context, translationID uint) error
|
IncrementTranslationShares(ctx context.Context, translationID uint) error
|
||||||
GetOrCreateWorkStats(ctx context.Context, workID uint) (*work.WorkStats, 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)
|
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 {
|
func (s *service) IncrementWorkComments(ctx context.Context, workID uint) error {
|
||||||
ctx, span := s.tracer.Start(ctx, "IncrementWorkComments")
|
ctx, span := s.tracer.Start(ctx, "IncrementWorkComments")
|
||||||
defer span.End()
|
defer span.End()
|
||||||
@ -109,6 +117,12 @@ func (s *service) IncrementTranslationLikes(ctx context.Context, translationID u
|
|||||||
return s.repo.IncrementTranslationCounter(ctx, translationID, "likes", 1)
|
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 {
|
func (s *service) IncrementTranslationComments(ctx context.Context, translationID uint) error {
|
||||||
ctx, span := s.tracer.Start(ctx, "IncrementTranslationComments")
|
ctx, span := s.tracer.Start(ctx, "IncrementTranslationComments")
|
||||||
defer span.End()
|
defer span.End()
|
||||||
|
|||||||
@ -58,6 +58,24 @@ func (s *Service) CanDeleteWork(ctx context.Context) (bool, error) {
|
|||||||
return false, domain.ErrForbidden
|
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.
|
// CanDeleteTranslation checks if a user can delete a translation.
|
||||||
func (s *Service) CanDeleteTranslation(ctx context.Context) (bool, error) {
|
func (s *Service) CanDeleteTranslation(ctx context.Context) (bool, error) {
|
||||||
claims, ok := platform_auth.GetClaimsFromContext(ctx)
|
claims, ok := platform_auth.GetClaimsFromContext(ctx)
|
||||||
|
|||||||
@ -2,6 +2,7 @@ package like
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"tercul/internal/app/analytics"
|
"tercul/internal/app/analytics"
|
||||||
"tercul/internal/domain"
|
"tercul/internal/domain"
|
||||||
)
|
)
|
||||||
@ -58,7 +59,34 @@ func (c *LikeCommands) CreateLike(ctx context.Context, input CreateLikeInput) (*
|
|||||||
return like, nil
|
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 {
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"tercul/internal/app/authz"
|
"tercul/internal/app/authz"
|
||||||
"tercul/internal/domain"
|
"tercul/internal/domain"
|
||||||
@ -28,8 +27,8 @@ func NewTranslationCommands(repo domain.TranslationRepository, authzSvc *authz.S
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateTranslationInput represents the input for creating a new translation.
|
// CreateOrUpdateTranslationInput represents the input for creating or updating a translation.
|
||||||
type CreateTranslationInput struct {
|
type CreateOrUpdateTranslationInput struct {
|
||||||
Title string
|
Title string
|
||||||
Content string
|
Content string
|
||||||
Description string
|
Description string
|
||||||
@ -41,10 +40,43 @@ type CreateTranslationInput struct {
|
|||||||
IsOriginalLanguage bool
|
IsOriginalLanguage bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateTranslation creates a new translation.
|
// CreateOrUpdateTranslation creates a new translation or updates an existing one.
|
||||||
func (c *TranslationCommands) CreateTranslation(ctx context.Context, input CreateTranslationInput) (*domain.Translation, error) {
|
func (c *TranslationCommands) CreateOrUpdateTranslation(ctx context.Context, input CreateOrUpdateTranslationInput) (*domain.Translation, error) {
|
||||||
ctx, span := c.tracer.Start(ctx, "CreateTranslation")
|
ctx, span := c.tracer.Start(ctx, "CreateOrUpdateTranslation")
|
||||||
defer span.End()
|
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{
|
translation := &domain.Translation{
|
||||||
Title: input.Title,
|
Title: input.Title,
|
||||||
Content: input.Content,
|
Content: input.Content,
|
||||||
@ -53,60 +85,14 @@ func (c *TranslationCommands) CreateTranslation(ctx context.Context, input Creat
|
|||||||
Status: input.Status,
|
Status: input.Status,
|
||||||
TranslatableID: input.TranslatableID,
|
TranslatableID: input.TranslatableID,
|
||||||
TranslatableType: input.TranslatableType,
|
TranslatableType: input.TranslatableType,
|
||||||
TranslatorID: input.TranslatorID,
|
TranslatorID: &translatorID,
|
||||||
IsOriginalLanguage: input.IsOriginalLanguage,
|
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.
|
if err := c.repo.Upsert(ctx, translation); err != nil {
|
||||||
type UpdateTranslationInput struct {
|
return nil, fmt.Errorf("failed to upsert translation: %w", err)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-associate polymorphic Translations
|
// Merge translations intelligently to avoid unique constraint violations.
|
||||||
if err = tx.Model(&domain.Translation{}).
|
targetLanguages := make(map[string]bool)
|
||||||
Where("translatable_id = ? AND translatable_type = ?", sourceID, "works").
|
for _, t := range targetWork.Translations {
|
||||||
Update("translatable_id", targetID).Error; err != nil {
|
targetLanguages[t.Language] = true
|
||||||
return fmt.Errorf("failed to merge translations: %w", err)
|
}
|
||||||
|
|
||||||
|
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
|
// Append many-to-many associations
|
||||||
|
|||||||
@ -195,7 +195,8 @@ func TestMergeWork_Integration(t *testing.T) {
|
|||||||
Tags: []*domain.Tag{tag1},
|
Tags: []*domain.Tag{tag1},
|
||||||
}
|
}
|
||||||
db.Create(sourceWork)
|
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})
|
db.Create(&workdomain.WorkStats{WorkID: sourceWork.ID, Views: 10, Likes: 5})
|
||||||
|
|
||||||
targetWork := &workdomain.Work{
|
targetWork := &workdomain.Work{
|
||||||
@ -205,7 +206,7 @@ func TestMergeWork_Integration(t *testing.T) {
|
|||||||
Tags: []*domain.Tag{tag2},
|
Tags: []*domain.Tag{tag2},
|
||||||
}
|
}
|
||||||
db.Create(targetWork)
|
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})
|
db.Create(&workdomain.WorkStats{WorkID: targetWork.ID, Views: 20, Likes: 10})
|
||||||
|
|
||||||
// --- Execute Merge ---
|
// --- Execute Merge ---
|
||||||
@ -224,7 +225,22 @@ func TestMergeWork_Integration(t *testing.T) {
|
|||||||
var finalTargetWork workdomain.Work
|
var finalTargetWork workdomain.Work
|
||||||
db.Preload("Translations").Preload("Authors").Preload("Tags").First(&finalTargetWork, targetWork.ID)
|
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.Authors, 2, "Authors should be merged")
|
||||||
assert.Len(t, finalTargetWork.Tags, 2, "Tags 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"
|
||||||
"go.opentelemetry.io/otel/trace"
|
"go.opentelemetry.io/otel/trace"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
)
|
)
|
||||||
|
|
||||||
type translationRepository struct {
|
type translationRepository struct {
|
||||||
@ -35,6 +36,20 @@ func (r *translationRepository) ListByWorkID(ctx context.Context, workID uint) (
|
|||||||
return translations, nil
|
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
|
// ListByEntity finds translations by entity type and ID
|
||||||
func (r *translationRepository) ListByEntity(ctx context.Context, entityType string, entityID uint) ([]domain.Translation, error) {
|
func (r *translationRepository) ListByEntity(ctx context.Context, entityType string, entityID uint) ([]domain.Translation, error) {
|
||||||
ctx, span := r.tracer.Start(ctx, "ListByEntity")
|
ctx, span := r.tracer.Start(ctx, "ListByEntity")
|
||||||
|
|||||||
@ -455,17 +455,17 @@ type Series struct {
|
|||||||
|
|
||||||
type Translation struct {
|
type Translation struct {
|
||||||
BaseModel
|
BaseModel
|
||||||
Title string `gorm:"size:255;not null"`
|
Title string `gorm:"size:255;not null"`
|
||||||
Content string `gorm:"type:text"`
|
Content string `gorm:"type:text"`
|
||||||
Description string `gorm:"type:text"`
|
Description string `gorm:"type:text"`
|
||||||
Language string `gorm:"size:50;not null"`
|
Language string `gorm:"size:50;not null;uniqueIndex:idx_translation_target_language"`
|
||||||
Status TranslationStatus `gorm:"size:50;default:'draft'"`
|
Status TranslationStatus `gorm:"size:50;default:'draft'"`
|
||||||
PublishedAt *time.Time
|
PublishedAt *time.Time
|
||||||
TranslatableID uint `gorm:"not null"`
|
TranslatableID uint `gorm:"not null;uniqueIndex:idx_translation_target_language"`
|
||||||
TranslatableType string `gorm:"size:50;not null"`
|
TranslatableType string `gorm:"size:50;not null;uniqueIndex:idx_translation_target_language"`
|
||||||
TranslatorID *uint
|
TranslatorID *uint
|
||||||
Translator *User `gorm:"foreignKey:TranslatorID"`
|
Translator *User `gorm:"foreignKey:TranslatorID"`
|
||||||
IsOriginalLanguage bool `gorm:"default:false"`
|
IsOriginalLanguage bool `gorm:"default:false"`
|
||||||
AudioURL string `gorm:"size:512"`
|
AudioURL string `gorm:"size:512"`
|
||||||
DateTranslated *time.Time
|
DateTranslated *time.Time
|
||||||
}
|
}
|
||||||
|
|||||||
@ -179,6 +179,7 @@ type TranslationRepository interface {
|
|||||||
ListByEntity(ctx context.Context, entityType string, entityID uint) ([]Translation, error)
|
ListByEntity(ctx context.Context, entityType string, entityID uint) ([]Translation, error)
|
||||||
ListByTranslatorID(ctx context.Context, translatorID uint) ([]Translation, error)
|
ListByTranslatorID(ctx context.Context, translatorID uint) ([]Translation, error)
|
||||||
ListByStatus(ctx context.Context, status TranslationStatus) ([]Translation, error)
|
ListByStatus(ctx context.Context, status TranslationStatus) ([]Translation, error)
|
||||||
|
Upsert(ctx context.Context, translation *Translation) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// UserRepository defines CRUD methods specific to User.
|
// UserRepository defines CRUD methods specific to User.
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import (
|
|||||||
"tercul/internal/data/sql"
|
"tercul/internal/data/sql"
|
||||||
"tercul/internal/domain"
|
"tercul/internal/domain"
|
||||||
"tercul/internal/domain/search"
|
"tercul/internal/domain/search"
|
||||||
|
platform_auth "tercul/internal/platform/auth"
|
||||||
"tercul/internal/domain/work"
|
"tercul/internal/domain/work"
|
||||||
"tercul/internal/jobs/linguistics"
|
"tercul/internal/jobs/linguistics"
|
||||||
"time"
|
"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
|
// IntegrationTestSuite provides a comprehensive test environment with either in-memory SQLite or mock repositories
|
||||||
type IntegrationTestSuite struct {
|
type IntegrationTestSuite struct {
|
||||||
suite.Suite
|
suite.Suite
|
||||||
App *app.Application
|
App *app.Application
|
||||||
DB *gorm.DB
|
DB *gorm.DB
|
||||||
|
AdminCtx context.Context
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestConfig holds configuration for the test environment
|
// TestConfig holds configuration for the test environment
|
||||||
@ -94,9 +96,11 @@ type TestConfig struct {
|
|||||||
|
|
||||||
// DefaultTestConfig returns a default test configuration
|
// DefaultTestConfig returns a default test configuration
|
||||||
func DefaultTestConfig() *TestConfig {
|
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{
|
return &TestConfig{
|
||||||
UseInMemoryDB: true,
|
UseInMemoryDB: false,
|
||||||
DBPath: "",
|
DBPath: "test.db",
|
||||||
LogLevel: logger.Silent,
|
LogLevel: logger.Silent,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -108,7 +112,9 @@ func (s *IntegrationTestSuite) SetupSuite(config *TestConfig) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var dbPath string
|
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
|
// Ensure directory exists
|
||||||
dir := filepath.Dir(config.DBPath)
|
dir := filepath.Dir(config.DBPath)
|
||||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
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)
|
analyticsService := analytics.NewService(repos.Analytics, analysisRepo, repos.Translation, repos.Work, sentimentProvider)
|
||||||
s.App = app.NewApplication(repos, searchClient, analyticsService)
|
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
|
// TearDownSuite cleans up the test suite
|
||||||
@ -192,17 +213,21 @@ func (s *IntegrationTestSuite) CreateTestWork(title, language string, content st
|
|||||||
Language: language,
|
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)
|
s.Require().NoError(err)
|
||||||
|
|
||||||
if content != "" {
|
if content != "" {
|
||||||
translationInput := translation.CreateTranslationInput{
|
translationInput := translation.CreateOrUpdateTranslationInput{
|
||||||
Title: title,
|
Title: title,
|
||||||
Content: content,
|
Content: content,
|
||||||
Language: language,
|
Language: language,
|
||||||
TranslatableID: createdWork.ID,
|
TranslatableID: createdWork.ID,
|
||||||
TranslatableType: "works",
|
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)
|
s.Require().NoError(err)
|
||||||
}
|
}
|
||||||
return createdWork
|
return createdWork
|
||||||
@ -210,14 +235,19 @@ func (s *IntegrationTestSuite) CreateTestWork(title, language string, content st
|
|||||||
|
|
||||||
// CreateTestTranslation creates a test translation for a work.
|
// CreateTestTranslation creates a test translation for a work.
|
||||||
func (s *IntegrationTestSuite) CreateTestTranslation(workID uint, language, content string) *domain.Translation {
|
func (s *IntegrationTestSuite) CreateTestTranslation(workID uint, language, content string) *domain.Translation {
|
||||||
translationInput := translation.CreateTranslationInput{
|
translationInput := translation.CreateOrUpdateTranslationInput{
|
||||||
Title: "Test Translation",
|
Title: "Test Translation",
|
||||||
Content: content,
|
Content: content,
|
||||||
Language: language,
|
Language: language,
|
||||||
TranslatableID: workID,
|
TranslatableID: workID,
|
||||||
TranslatableType: "works",
|
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)
|
s.Require().NoError(err)
|
||||||
return createdTranslation
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"tercul/internal/domain"
|
"tercul/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
import "github.com/stretchr/testify/mock"
|
||||||
|
|
||||||
// MockTranslationRepository is an in-memory implementation of TranslationRepository
|
// MockTranslationRepository is an in-memory implementation of TranslationRepository
|
||||||
type MockTranslationRepository struct {
|
type MockTranslationRepository struct {
|
||||||
items []domain.Translation
|
mock.Mock
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewMockTranslationRepository() *MockTranslationRepository {
|
func NewMockTranslationRepository() *MockTranslationRepository {
|
||||||
return &MockTranslationRepository{items: []domain.Translation{}}
|
return new(MockTranslationRepository)
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ domain.TranslationRepository = (*MockTranslationRepository)(nil)
|
var _ domain.TranslationRepository = (*MockTranslationRepository)(nil)
|
||||||
|
|
||||||
// BaseRepository methods with context support
|
// BaseRepository methods with context support
|
||||||
func (m *MockTranslationRepository) Create(ctx context.Context, t *domain.Translation) error {
|
func (m *MockTranslationRepository) Create(ctx context.Context, t *domain.Translation) error {
|
||||||
if t == nil {
|
args := m.Called(ctx, t)
|
||||||
return errors.New("nil translation")
|
return args.Error(0)
|
||||||
}
|
|
||||||
t.ID = uint(len(m.items) + 1)
|
|
||||||
m.items = append(m.items, *t)
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockTranslationRepository) GetByID(ctx context.Context, id uint) (*domain.Translation, error) {
|
func (m *MockTranslationRepository) GetByID(ctx context.Context, id uint) (*domain.Translation, error) {
|
||||||
for i := range m.items {
|
args := m.Called(ctx, id)
|
||||||
if m.items[i].ID == id {
|
if args.Get(0) == nil {
|
||||||
cp := m.items[i]
|
return nil, args.Error(1)
|
||||||
return &cp, nil
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return nil, domain.ErrEntityNotFound
|
return args.Get(0).(*domain.Translation), args.Error(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockTranslationRepository) Update(ctx context.Context, t *domain.Translation) error {
|
func (m *MockTranslationRepository) Update(ctx context.Context, t *domain.Translation) error {
|
||||||
for i := range m.items {
|
args := m.Called(ctx, t)
|
||||||
if m.items[i].ID == t.ID {
|
return args.Error(0)
|
||||||
m.items[i] = *t
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return domain.ErrEntityNotFound
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockTranslationRepository) Delete(ctx context.Context, id uint) error {
|
func (m *MockTranslationRepository) Delete(ctx context.Context, id uint) error {
|
||||||
for i := range m.items {
|
args := m.Called(ctx, id)
|
||||||
if m.items[i].ID == id {
|
return args.Error(0)
|
||||||
m.items = append(m.items[:i], m.items[i+1:]...)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return domain.ErrEntityNotFound
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockTranslationRepository) List(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[domain.Translation], error) {
|
func (m *MockTranslationRepository) List(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[domain.Translation], error) {
|
||||||
all := append([]domain.Translation(nil), m.items...)
|
args := m.Called(ctx, page, pageSize)
|
||||||
total := int64(len(all))
|
if args.Get(0) == nil {
|
||||||
start := (page - 1) * pageSize
|
return nil, args.Error(1)
|
||||||
end := start + pageSize
|
|
||||||
if start > len(all) {
|
|
||||||
return &domain.PaginatedResult[domain.Translation]{Items: []domain.Translation{}, TotalCount: total}, nil
|
|
||||||
}
|
}
|
||||||
if end > len(all) {
|
return args.Get(0).(*domain.PaginatedResult[domain.Translation]), args.Error(1)
|
||||||
end = len(all)
|
|
||||||
}
|
|
||||||
return &domain.PaginatedResult[domain.Translation]{Items: all[start:end], TotalCount: total}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockTranslationRepository) ListAll(ctx context.Context) ([]domain.Translation, error) {
|
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) {
|
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) {
|
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) {
|
func (m *MockTranslationRepository) GetAllForSync(ctx context.Context, batchSize, offset int) ([]domain.Translation, error) {
|
||||||
all := append([]domain.Translation(nil), m.items...)
|
args := m.Called(ctx, batchSize, offset)
|
||||||
end := offset + batchSize
|
if args.Get(0) == nil {
|
||||||
if end > len(all) {
|
return nil, args.Error(1)
|
||||||
end = len(all)
|
|
||||||
}
|
}
|
||||||
if offset > len(all) {
|
return args.Get(0).([]domain.Translation), args.Error(1)
|
||||||
return []domain.Translation{}, nil
|
|
||||||
}
|
|
||||||
return all[offset:end], nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// New BaseRepository methods
|
// New BaseRepository methods
|
||||||
func (m *MockTranslationRepository) CreateInTx(ctx context.Context, tx *gorm.DB, entity *domain.Translation) error {
|
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) {
|
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 {
|
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 {
|
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) {
|
func (m *MockTranslationRepository) ListWithOptions(ctx context.Context, options *domain.QueryOptions) ([]domain.Translation, error) {
|
||||||
result, err := m.List(ctx, 1, 1000)
|
args := m.Called(ctx, options)
|
||||||
if err != nil {
|
if args.Get(0) == nil {
|
||||||
return nil, err
|
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) {
|
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) {
|
func (m *MockTranslationRepository) Exists(ctx context.Context, id uint) (bool, error) {
|
||||||
_, err := m.GetByID(ctx, id)
|
args := m.Called(ctx, id)
|
||||||
return err == nil, nil
|
return args.Bool(0), args.Error(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockTranslationRepository) BeginTx(ctx context.Context) (*gorm.DB, error) {
|
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 {
|
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)
|
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
|
// TranslationRepository specific methods
|
||||||
func (m *MockTranslationRepository) ListByWorkID(ctx context.Context, workID uint) ([]domain.Translation, error) {
|
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) {
|
func (m *MockTranslationRepository) ListByEntity(ctx context.Context, entityType string, entityID uint) ([]domain.Translation, error) {
|
||||||
var out []domain.Translation
|
args := m.Called(ctx, entityType, entityID)
|
||||||
for i := range m.items {
|
if args.Get(0) == nil {
|
||||||
tr := m.items[i]
|
return nil, args.Error(1)
|
||||||
if tr.TranslatableType == entityType && tr.TranslatableID == entityID {
|
|
||||||
out = append(out, tr)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return out, nil
|
return args.Get(0).([]domain.Translation), args.Error(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockTranslationRepository) ListByTranslatorID(ctx context.Context, translatorID uint) ([]domain.Translation, error) {
|
func (m *MockTranslationRepository) ListByTranslatorID(ctx context.Context, translatorID uint) ([]domain.Translation, error) {
|
||||||
var out []domain.Translation
|
args := m.Called(ctx, translatorID)
|
||||||
for i := range m.items {
|
if args.Get(0) == nil {
|
||||||
if m.items[i].TranslatorID != nil && *m.items[i].TranslatorID == translatorID {
|
return nil, args.Error(1)
|
||||||
out = append(out, m.items[i])
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
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) {
|
func (m *MockTranslationRepository) ListByStatus(ctx context.Context, status domain.TranslationStatus) ([]domain.Translation, error) {
|
||||||
var out []domain.Translation
|
args := m.Called(ctx, status)
|
||||||
for i := range m.items {
|
if args.Get(0) == nil {
|
||||||
if m.items[i].Status == status {
|
return nil, args.Error(1)
|
||||||
out = append(out, m.items[i])
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return out, nil
|
return args.Get(0).([]domain.Translation), args.Error(1)
|
||||||
}
|
|
||||||
|
|
||||||
// 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,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|||||||
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