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:
google-labs-jules[bot] 2025-10-05 09:41:40 +00:00
parent 23a6b6d569
commit b03820de02
15 changed files with 553 additions and 229 deletions

View File

@ -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",

View File

@ -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,

View File

@ -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()

View File

@ -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)

View File

@ -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
}

View File

@ -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
}

View 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))
}

View File

@ -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

View File

@ -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")

View File

@ -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")

View File

@ -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
}

View File

@ -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.

View File

@ -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)
}

View File

@ -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)
}

View 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 }