diff --git a/go.mod b/go.mod index 51caf33..a404c3e 100644 --- a/go.mod +++ b/go.mod @@ -66,6 +66,7 @@ require ( github.com/go-openapi/validate v0.24.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-redis/redismock/v9 v9.2.0 // indirect github.com/go-sql-driver/mysql v1.9.3 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/golang-jwt/jwt/v4 v4.5.2 // indirect diff --git a/go.sum b/go.sum index 85f8f0a..6b60f75 100644 --- a/go.sum +++ b/go.sum @@ -152,6 +152,8 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4= github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= +github.com/go-redis/redismock/v9 v9.2.0 h1:ZrMYQeKPECZPjOj5u9eyOjg8Nnb0BS9lkVIZ6IpsKLw= +github.com/go-redis/redismock/v9 v9.2.0/go.mod h1:18KHfGDK4Y6c2R0H38EUGWAdc7ZQS9gfYxc94k7rWT0= github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= diff --git a/internal/adapters/graphql/auth_mutations_test.go b/internal/adapters/graphql/auth_mutations_test.go new file mode 100644 index 0000000..680ffb6 --- /dev/null +++ b/internal/adapters/graphql/auth_mutations_test.go @@ -0,0 +1,113 @@ +package graphql_test + +import ( + "context" + "os" + "testing" + "tercul/internal/adapters/graphql" + "tercul/internal/app/auth" + "tercul/internal/domain" + platform_auth "tercul/internal/platform/auth" + "tercul/internal/testutil" + + "github.com/stretchr/testify/suite" +) + +type AuthMutationTestSuite struct { + testutil.IntegrationTestSuite + resolver graphql.MutationResolver +} + +func TestAuthMutations(t *testing.T) { + suite.Run(t, new(AuthMutationTestSuite)) +} + +func (s *AuthMutationTestSuite) SetupSuite() { + s.IntegrationTestSuite.SetupSuite(&testutil.TestConfig{ + DBPath: "auth_mutations_test.db", + }) +} + +func (s *AuthMutationTestSuite) TearDownSuite() { + s.IntegrationTestSuite.TearDownSuite() + os.Remove("auth_mutations_test.db") +} + +func (s *AuthMutationTestSuite) SetupTest() { + s.IntegrationTestSuite.SetupTest() + s.resolver = (&graphql.Resolver{App: s.App}).Mutation() +} + +func (s *AuthMutationTestSuite) TestChangePassword() { + // Helper to create a user for tests + createUser := func(username, email, password string) *domain.User { + resp, err := s.App.Auth.Commands.Register(context.Background(), auth.RegisterInput{ + Username: username, + Email: email, + Password: password, + }) + s.Require().NoError(err) + return resp.User + } + + // Helper to create a context with JWT claims + contextWithClaims := func(user *domain.User) context.Context { + return testutil.ContextWithClaims(context.Background(), &platform_auth.Claims{ + UserID: user.ID, + Role: string(user.Role), + }) + } + + s.Run("Success", func() { + // Arrange + initialPassword := "password123" + newPassword := "newPassword456" + user := createUser("testuser-changepw", "testuser.changepw@test.com", initialPassword) + ctx := contextWithClaims(user) + + // Act + success, err := s.resolver.ChangePassword(ctx, initialPassword, newPassword) + + // Assert + s.Require().NoError(err) + s.True(success) + + // Verify the password change by trying to log in with the new and old passwords + _, err = s.App.Auth.Commands.Login(context.Background(), auth.LoginInput{Email: user.Email, Password: newPassword}) + s.NoError(err, "Login with new password should succeed") + + _, err = s.App.Auth.Commands.Login(context.Background(), auth.LoginInput{Email: user.Email, Password: initialPassword}) + s.Error(err, "Login with old password should fail") + s.ErrorIs(err, auth.ErrInvalidCredentials) + }) + + s.Run("Incorrect current password", func() { + // Arrange + initialPassword := "password123" + newPassword := "newPassword456" + user := createUser("testuser-wrongpw", "testuser.wrongpw@test.com", initialPassword) + ctx := contextWithClaims(user) + + // Act + success, err := s.resolver.ChangePassword(ctx, "wrong-password", newPassword) + + // Assert + s.Require().Error(err) + s.False(success) + s.ErrorIs(err, auth.ErrInvalidCredentials) + + // Verify the password was not changed + _, loginErr := s.App.Auth.Commands.Login(context.Background(), auth.LoginInput{Email: user.Email, Password: initialPassword}) + s.NoError(loginErr, "Login with original password should still succeed") + }) + + s.Run("Unauthenticated user", func() { + // Act + success, err := s.resolver.ChangePassword(context.Background(), "any-password", "any-new-password") + + // Assert + s.Require().Error(err) + s.False(success) + s.ErrorIs(err, domain.ErrUnauthorized) + }) +} \ No newline at end of file diff --git a/internal/adapters/graphql/user_mutations_test.go b/internal/adapters/graphql/user_mutations_test.go new file mode 100644 index 0000000..5d3c0bf --- /dev/null +++ b/internal/adapters/graphql/user_mutations_test.go @@ -0,0 +1,218 @@ +package graphql_test + +import ( + "context" + "errors" + "fmt" + "os" + "testing" + "tercul/internal/adapters/graphql" + "tercul/internal/adapters/graphql/model" + "tercul/internal/app/auth" + "tercul/internal/domain" + platform_auth "tercul/internal/platform/auth" + "tercul/internal/testutil" + + "github.com/stretchr/testify/suite" +) + +type UserMutationTestSuite struct { + testutil.IntegrationTestSuite + resolver graphql.MutationResolver +} + +func TestUserMutations(t *testing.T) { + suite.Run(t, new(UserMutationTestSuite)) +} + +func (s *UserMutationTestSuite) SetupSuite() { + s.IntegrationTestSuite.SetupSuite(&testutil.TestConfig{ + DBPath: "user_mutations_test.db", + }) +} + +func (s *UserMutationTestSuite) TearDownSuite() { + s.IntegrationTestSuite.TearDownSuite() + os.Remove("user_mutations_test.db") +} + +func (s *UserMutationTestSuite) SetupTest() { + s.IntegrationTestSuite.SetupTest() + s.resolver = (&graphql.Resolver{App: s.App}).Mutation() +} + +// Helper to create a user for tests +func (s *UserMutationTestSuite) createUser(username, email, password string, role domain.UserRole) *domain.User { + resp, err := s.App.Auth.Commands.Register(context.Background(), auth.RegisterInput{ + Username: username, + Email: email, + Password: password, + }) + s.Require().NoError(err) + + user, err := s.App.User.Queries.User(context.Background(), resp.User.ID) + s.Require().NoError(err) + + if role != user.Role { + user.Role = role + err = s.DB.Save(user).Error + s.Require().NoError(err) + } + return user +} + +// Helper to create a context with JWT claims +func (s *UserMutationTestSuite) contextWithClaims(user *domain.User) context.Context { + return testutil.ContextWithClaims(context.Background(), &platform_auth.Claims{ + UserID: user.ID, + Role: string(user.Role), + }) +} + +func (s *UserMutationTestSuite) TestDeleteUser() { + s.Run("Success as admin", func() { + // Arrange + adminUser := s.createUser("admin_deleter", "admin_deleter@test.com", "password123", domain.UserRoleAdmin) + userToDelete := s.createUser("user_to_delete", "user_to_delete@test.com", "password123", domain.UserRoleReader) + ctx := s.contextWithClaims(adminUser) + userIDToDeleteStr := fmt.Sprintf("%d", userToDelete.ID) + + // Act + deleted, err := s.resolver.DeleteUser(ctx, userIDToDeleteStr) + + // Assert + s.Require().NoError(err) + s.True(deleted) + + // Verify user is deleted from DB + _, err = s.App.User.Queries.User(context.Background(), userToDelete.ID) + s.Require().Error(err) + s.Contains(err.Error(), "entity not found", "Expected user to be not found after deletion") + }) + + s.Run("Success as self", func() { + // Arrange + userToDelete := s.createUser("user_to_delete_self", "user_to_delete_self@test.com", "password123", domain.UserRoleReader) + ctx := s.contextWithClaims(userToDelete) + userIDToDeleteStr := fmt.Sprintf("%d", userToDelete.ID) + + // Act + deleted, err := s.resolver.DeleteUser(ctx, userIDToDeleteStr) + + // Assert + s.Require().NoError(err) + s.True(deleted) + + // Verify user is deleted from DB + _, err = s.App.User.Queries.User(context.Background(), userToDelete.ID) + s.Require().Error(err) + s.Contains(err.Error(), "entity not found", "Expected user to be not found after deletion") + }) + + s.Run("Forbidden as other user", func() { + // Arrange + otherUser := s.createUser("other_user_deleter", "other_user_deleter@test.com", "password123", domain.UserRoleReader) + userToDelete := s.createUser("user_to_be_kept", "user_to_be_kept@test.com", "password123", domain.UserRoleReader) + ctx := s.contextWithClaims(otherUser) + userIDToDeleteStr := fmt.Sprintf("%d", userToDelete.ID) + + // Act + deleted, err := s.resolver.DeleteUser(ctx, userIDToDeleteStr) + + // Assert + s.Require().Error(err) + s.False(deleted) + s.True(errors.Is(err, domain.ErrForbidden)) + }) + + s.Run("Invalid user ID", func() { + // Arrange + adminUser := s.createUser("admin_deleter_2", "admin_deleter_2@test.com", "password123", domain.UserRoleAdmin) + ctx := s.contextWithClaims(adminUser) + + // Act + deleted, err := s.resolver.DeleteUser(ctx, "invalid-id") + + // Assert + s.Require().Error(err) + s.False(deleted) + s.True(errors.Is(err, domain.ErrValidation)) + }) + + s.Run("User not found", func() { + // Arrange + adminUser := s.createUser("admin_deleter_3", "admin_deleter_3@test.com", "password123", domain.UserRoleAdmin) + ctx := s.contextWithClaims(adminUser) + nonExistentID := "999999" + + // Act + deleted, err := s.resolver.DeleteUser(ctx, nonExistentID) + + // Assert + s.Require().Error(err) + s.False(deleted) + s.Contains(err.Error(), "entity not found", "Expected entity not found error for non-existent user") + }) +} + +func (s *UserMutationTestSuite) TestUpdateProfile() { + s.Run("Success", func() { + // Arrange + user := s.createUser("profile_user", "profile.user@test.com", "password123", domain.UserRoleReader) + ctx := s.contextWithClaims(user) + + newFirstName := "John" + newLastName := "Doe" + newBio := "This is my new bio." + input := model.UserInput{ + FirstName: &newFirstName, + LastName: &newLastName, + Bio: &newBio, + } + + // Act + updatedUser, err := s.resolver.UpdateProfile(ctx, input) + + // Assert + s.Require().NoError(err) + s.Require().NotNil(updatedUser) + s.Equal(newFirstName, *updatedUser.FirstName) + s.Equal(newLastName, *updatedUser.LastName) + s.Equal(newBio, *updatedUser.Bio) + + // Verify in DB + dbUser, err := s.App.User.Queries.User(context.Background(), user.ID) + s.Require().NoError(err) + s.Equal(newFirstName, dbUser.FirstName) + s.Equal(newLastName, dbUser.LastName) + s.Equal(newBio, dbUser.Bio) + }) + + s.Run("Unauthenticated user", func() { + // Arrange + newFirstName := "Jane" + input := model.UserInput{FirstName: &newFirstName} + + // Act + _, err := s.resolver.UpdateProfile(context.Background(), input) + + // Assert + s.Require().Error(err) + s.ErrorIs(err, domain.ErrUnauthorized) + }) + + s.Run("Invalid country ID", func() { + // Arrange + user := s.createUser("profile_user_invalid", "profile.user.invalid@test.com", "password123", domain.UserRoleReader) + ctx := s.contextWithClaims(user) + invalidCountryID := "not-a-number" + input := model.UserInput{CountryID: &invalidCountryID} + + // Act + _, err := s.resolver.UpdateProfile(ctx, input) + + // Assert + s.Require().Error(err) + s.Contains(err.Error(), "invalid country ID") + }) +} \ No newline at end of file diff --git a/internal/app/analytics/service.go b/internal/app/analytics/service.go index 87bbcb2..feac04c 100644 --- a/internal/app/analytics/service.go +++ b/internal/app/analytics/service.go @@ -40,6 +40,7 @@ type Service interface { UpdateUserEngagement(ctx context.Context, userID uint, eventType string) error UpdateTrending(ctx context.Context) error GetTrendingWorks(ctx context.Context, timePeriod string, limit int) ([]*domain.Work, error) + UpdateWorkStats(ctx context.Context, workID uint, stats domain.WorkStats) error } type service struct { @@ -314,6 +315,12 @@ func (s *service) GetTrendingWorks(ctx context.Context, timePeriod string, limit return s.repo.GetTrendingWorks(ctx, timePeriod, limit) } +func (s *service) UpdateWorkStats(ctx context.Context, workID uint, stats domain.WorkStats) error { + ctx, span := s.tracer.Start(ctx, "UpdateWorkStats") + defer span.End() + return s.repo.UpdateWorkStats(ctx, workID, stats) +} + func (s *service) UpdateTrending(ctx context.Context) error { ctx, span := s.tracer.Start(ctx, "UpdateTrending") defer span.End() diff --git a/internal/app/work/commands.go b/internal/app/work/commands.go index 0279d81..26de6e4 100644 --- a/internal/app/work/commands.go +++ b/internal/app/work/commands.go @@ -305,14 +305,18 @@ func mergeWorkStats(tx *gorm.DB, sourceWorkID, targetWorkID uint) error { return nil } + // Store the original ID to delete later, as the sourceStats.ID might be overwritten. + originalSourceStatsID := sourceStats.ID + var targetStats domain.WorkStats err = tx.Where("work_id = ?", targetWorkID).First(&targetStats).Error if errors.Is(err, gorm.ErrRecordNotFound) { - // If target has no stats, create new ones based on source stats. - sourceStats.ID = 0 // Let GORM create a new record - sourceStats.WorkID = targetWorkID - if err = tx.Create(&sourceStats).Error; err != nil { + // If target has no stats, create a new stats record for it. + newStats := sourceStats + newStats.ID = 0 + newStats.WorkID = targetWorkID + if err = tx.Create(&newStats).Error; err != nil { return fmt.Errorf("failed to create new target stats: %w", err) } } else if err != nil { @@ -325,8 +329,8 @@ func mergeWorkStats(tx *gorm.DB, sourceWorkID, targetWorkID uint) error { } } - // Delete the old source stats - if err = tx.Delete(&domain.WorkStats{}, sourceStats.ID).Error; err != nil { + // Delete the old source stats using the stored original ID. + if err = tx.Delete(&domain.WorkStats{}, originalSourceStatsID).Error; err != nil { return fmt.Errorf("failed to delete source work stats: %w", err) } diff --git a/internal/app/work/commands_test.go b/internal/app/work/commands_test.go index cdb6a2b..eb5d055 100644 --- a/internal/app/work/commands_test.go +++ b/internal/app/work/commands_test.go @@ -70,7 +70,7 @@ func (s *WorkCommandsSuite) TestCreateWork_RepoError() { } func (s *WorkCommandsSuite) TestUpdateWork_Success() { - ctx := platform_auth.ContextWithAdminUser(context.Background(), 1) + ctx := context.WithValue(context.Background(), platform_auth.ClaimsContextKey, &platform_auth.Claims{UserID: 1, Role: string(domain.UserRoleAdmin)}) work := &domain.Work{Title: "Test Work", TranslatableModel: domain.TranslatableModel{Language: "en"}} work.ID = 1 @@ -111,17 +111,40 @@ func (s *WorkCommandsSuite) TestUpdateWork_EmptyLanguage() { } func (s *WorkCommandsSuite) TestUpdateWork_RepoError() { + ctx := context.WithValue(context.Background(), platform_auth.ClaimsContextKey, &platform_auth.Claims{UserID: 1, Role: string(domain.UserRoleAdmin)}) work := &domain.Work{Title: "Test Work", TranslatableModel: domain.TranslatableModel{Language: "en"}} work.ID = 1 s.repo.updateFunc = func(ctx context.Context, w *domain.Work) error { return errors.New("db error") } - err := s.commands.UpdateWork(context.Background(), work) + err := s.commands.UpdateWork(ctx, work) assert.Error(s.T(), err) } +func (s *WorkCommandsSuite) TestUpdateWork_Forbidden() { + ctx := context.WithValue(context.Background(), platform_auth.ClaimsContextKey, &platform_auth.Claims{UserID: 2, Role: string(domain.UserRoleReader)}) // Not an admin + work := &domain.Work{Title: "Test Work", TranslatableModel: domain.TranslatableModel{Language: "en"}} + work.ID = 1 + + s.repo.isAuthorFunc = func(ctx context.Context, workID uint, authorID uint) (bool, error) { + return false, nil // User is not an author + } + + err := s.commands.UpdateWork(ctx, work) + assert.Error(s.T(), err) + assert.True(s.T(), errors.Is(err, domain.ErrForbidden)) +} + +func (s *WorkCommandsSuite) TestUpdateWork_Unauthorized() { + work := &domain.Work{Title: "Test Work", TranslatableModel: domain.TranslatableModel{Language: "en"}} + work.ID = 1 + err := s.commands.UpdateWork(context.Background(), work) // No user in context + assert.Error(s.T(), err) + assert.True(s.T(), errors.Is(err, domain.ErrUnauthorized)) +} + func (s *WorkCommandsSuite) TestDeleteWork_Success() { - ctx := platform_auth.ContextWithAdminUser(context.Background(), 1) + ctx := context.WithValue(context.Background(), platform_auth.ClaimsContextKey, &platform_auth.Claims{UserID: 1, Role: string(domain.UserRoleAdmin)}) work := &domain.Work{Title: "Test Work", TranslatableModel: domain.TranslatableModel{Language: "en"}} work.ID = 1 @@ -142,13 +165,27 @@ func (s *WorkCommandsSuite) TestDeleteWork_ZeroID() { } func (s *WorkCommandsSuite) TestDeleteWork_RepoError() { + ctx := context.WithValue(context.Background(), platform_auth.ClaimsContextKey, &platform_auth.Claims{UserID: 1, Role: string(domain.UserRoleAdmin)}) s.repo.deleteFunc = func(ctx context.Context, id uint) error { return errors.New("db error") } - err := s.commands.DeleteWork(context.Background(), 1) + err := s.commands.DeleteWork(ctx, 1) assert.Error(s.T(), err) } +func (s *WorkCommandsSuite) TestDeleteWork_Forbidden() { + ctx := context.WithValue(context.Background(), platform_auth.ClaimsContextKey, &platform_auth.Claims{UserID: 2, Role: string(domain.UserRoleReader)}) // Not an admin + err := s.commands.DeleteWork(ctx, 1) + assert.Error(s.T(), err) + assert.True(s.T(), errors.Is(err, domain.ErrForbidden)) +} + +func (s *WorkCommandsSuite) TestDeleteWork_Unauthorized() { + err := s.commands.DeleteWork(context.Background(), 1) // No user in context + assert.Error(s.T(), err) + assert.True(s.T(), errors.Is(err, domain.ErrUnauthorized)) +} + func (s *WorkCommandsSuite) TestAnalyzeWork_Success() { work := &domain.Work{ TranslatableModel: domain.TranslatableModel{BaseModel: domain.BaseModel{ID: 1}}, @@ -221,82 +258,157 @@ func TestMergeWork_Integration(t *testing.T) { analyticsSvc := &mockAnalyticsService{} commands := NewWorkCommands(workRepo, searchClient, authzSvc, analyticsSvc) + // Provide a realistic implementation for the GetOrCreateWorkStats mock + analyticsSvc.getOrCreateWorkStatsFunc = func(ctx context.Context, workID uint) (*domain.WorkStats, error) { + var stats domain.WorkStats + if err := db.Where(domain.WorkStats{WorkID: workID}).FirstOrCreate(&stats).Error; err != nil { + return nil, err + } + return &stats, nil + } + // --- Seed Data --- - author1 := &domain.Author{Name: "Author One"} - db.Create(author1) - author2 := &domain.Author{Name: "Author Two"} - db.Create(author2) + t.Run("Success", func(t *testing.T) { + author1 := &domain.Author{Name: "Author One"} + db.Create(author1) + author2 := &domain.Author{Name: "Author Two"} + db.Create(author2) - tag1 := &domain.Tag{Name: "Tag One"} - db.Create(tag1) - tag2 := &domain.Tag{Name: "Tag Two"} - db.Create(tag2) + tag1 := &domain.Tag{Name: "Tag One"} + db.Create(tag1) + tag2 := &domain.Tag{Name: "Tag Two"} + db.Create(tag2) - sourceWork := &domain.Work{ - TranslatableModel: domain.TranslatableModel{Language: "en"}, - Title: "Source Work", - Authors: []*domain.Author{author1}, - Tags: []*domain.Tag{tag1}, - } - db.Create(sourceWork) - db.Create(&domain.Translation{Title: "Source English", Language: "en", TranslatableID: sourceWork.ID, TranslatableType: "works"}) - db.Create(&domain.Translation{Title: "Source French", Language: "fr", TranslatableID: sourceWork.ID, TranslatableType: "works"}) - db.Create(&domain.WorkStats{WorkID: sourceWork.ID, Views: 10, Likes: 5}) - - targetWork := &domain.Work{ - TranslatableModel: domain.TranslatableModel{Language: "en"}, - Title: "Target Work", - Authors: []*domain.Author{author2}, - Tags: []*domain.Tag{tag2}, - } - db.Create(targetWork) - db.Create(&domain.Translation{Title: "Target English", Language: "en", TranslatableID: targetWork.ID, TranslatableType: "works"}) - db.Create(&domain.WorkStats{WorkID: targetWork.ID, Views: 20, Likes: 10}) - - // --- Execute Merge --- - ctx := platform_auth.ContextWithAdminUser(context.Background(), 1) - err = commands.MergeWork(ctx, sourceWork.ID, targetWork.ID) - assert.NoError(t, err) - - // --- Assertions --- - // 1. Source work should be deleted - var deletedWork domain.Work - err = db.First(&deletedWork, sourceWork.ID).Error - assert.Error(t, err) - assert.True(t, errors.Is(err, gorm.ErrRecordNotFound)) - - // 2. Target work should have merged data - var finalTargetWork domain.Work - db.Preload("Translations").Preload("Authors").Preload("Tags").First(&finalTargetWork, targetWork.ID) - - assert.Len(t, finalTargetWork.Translations, 2, "Should have two translations after merge") - foundEn := false - foundFr := false - for _, tr := range finalTargetWork.Translations { - if tr.Language == "en" { - foundEn = true - assert.Equal(t, "Target English", tr.Title, "Should keep target's English translation") + sourceWork := &domain.Work{ + TranslatableModel: domain.TranslatableModel{Language: "en"}, + Title: "Source Work", + Authors: []*domain.Author{author1}, + Tags: []*domain.Tag{tag1}, } - if tr.Language == "fr" { - foundFr = true - assert.Equal(t, "Source French", tr.Title, "Should merge source's French translation") + db.Create(sourceWork) + db.Create(&domain.Translation{Title: "Source English", Language: "en", TranslatableID: sourceWork.ID, TranslatableType: "works"}) + db.Create(&domain.Translation{Title: "Source French", Language: "fr", TranslatableID: sourceWork.ID, TranslatableType: "works"}) + db.Create(&domain.WorkStats{WorkID: sourceWork.ID, Views: 10, Likes: 5}) + + targetWork := &domain.Work{ + TranslatableModel: domain.TranslatableModel{Language: "en"}, + Title: "Target Work", + Authors: []*domain.Author{author2}, + Tags: []*domain.Tag{tag2}, } - } - assert.True(t, foundEn, "English translation should be present") - assert.True(t, foundFr, "French translation should be present") + db.Create(targetWork) + db.Create(&domain.Translation{Title: "Target English", Language: "en", TranslatableID: targetWork.ID, TranslatableType: "works"}) + db.Create(&domain.WorkStats{WorkID: targetWork.ID, Views: 20, Likes: 10}) - assert.Len(t, finalTargetWork.Authors, 2, "Authors should be merged") - assert.Len(t, finalTargetWork.Tags, 2, "Tags should be merged") + // --- Execute Merge --- + ctx := context.WithValue(context.Background(), platform_auth.ClaimsContextKey, &platform_auth.Claims{UserID: 1, Role: string(domain.UserRoleAdmin)}) + err = commands.MergeWork(ctx, sourceWork.ID, targetWork.ID) + assert.NoError(t, err) - // 3. Stats should be merged - var finalStats domain.WorkStats - db.Where("work_id = ?", targetWork.ID).First(&finalStats) - assert.Equal(t, int64(30), finalStats.Views, "Views should be summed") - assert.Equal(t, int64(15), finalStats.Likes, "Likes should be summed") + // --- Assertions --- + // 1. Source work should be deleted + var deletedWork domain.Work + err = db.First(&deletedWork, sourceWork.ID).Error + assert.Error(t, err) + assert.True(t, errors.Is(err, gorm.ErrRecordNotFound)) - // 4. Source stats should be deleted - var deletedStats domain.WorkStats - err = db.First(&deletedStats, "work_id = ?", sourceWork.ID).Error - assert.Error(t, err, "Source stats should be deleted") - assert.True(t, errors.Is(err, gorm.ErrRecordNotFound)) + // 2. Target work should have merged data + var finalTargetWork domain.Work + db.Preload("Translations").Preload("Authors").Preload("Tags").First(&finalTargetWork, targetWork.ID) + + assert.Len(t, finalTargetWork.Translations, 2, "Should have two translations after merge") + foundEn := false + foundFr := false + for _, tr := range finalTargetWork.Translations { + if tr.Language == "en" { + foundEn = true + assert.Equal(t, "Target English", tr.Title, "Should keep target's English translation") + } + if tr.Language == "fr" { + foundFr = true + assert.Equal(t, "Source French", tr.Title, "Should merge source's French translation") + } + } + assert.True(t, foundEn, "English translation should be present") + assert.True(t, foundFr, "French translation should be present") + + assert.Len(t, finalTargetWork.Authors, 2, "Authors should be merged") + assert.Len(t, finalTargetWork.Tags, 2, "Tags should be merged") + + // 3. Stats should be merged + var finalStats domain.WorkStats + db.Where("work_id = ?", targetWork.ID).First(&finalStats) + assert.Equal(t, int64(30), finalStats.Views, "Views should be summed") + assert.Equal(t, int64(15), finalStats.Likes, "Likes should be summed") + + // 4. Source stats should be deleted + var deletedStats domain.WorkStats + err = db.First(&deletedStats, "work_id = ?", sourceWork.ID).Error + assert.Error(t, err, "Source stats should be deleted") + assert.True(t, errors.Is(err, gorm.ErrRecordNotFound)) + }) + + t.Run("Success with no target stats", func(t *testing.T) { + sourceWork := &domain.Work{Title: "Source with Stats"} + db.Create(sourceWork) + db.Create(&domain.WorkStats{WorkID: sourceWork.ID, Views: 15, Likes: 7}) + + targetWork := &domain.Work{Title: "Target without Stats"} + db.Create(targetWork) + + ctx := context.WithValue(context.Background(), platform_auth.ClaimsContextKey, &platform_auth.Claims{UserID: 1, Role: string(domain.UserRoleAdmin)}) + err := commands.MergeWork(ctx, sourceWork.ID, targetWork.ID) + assert.NoError(t, err) + + var finalStats domain.WorkStats + db.Where("work_id = ?", targetWork.ID).First(&finalStats) + assert.Equal(t, int64(15), finalStats.Views) + assert.Equal(t, int64(7), finalStats.Likes) + }) + + t.Run("Forbidden for non-admin", func(t *testing.T) { + sourceWork := &domain.Work{Title: "Forbidden Source"} + db.Create(sourceWork) + targetWork := &domain.Work{Title: "Forbidden Target"} + db.Create(targetWork) + + ctx := context.WithValue(context.Background(), platform_auth.ClaimsContextKey, &platform_auth.Claims{UserID: 2, Role: string(domain.UserRoleReader)}) + err := commands.MergeWork(ctx, sourceWork.ID, targetWork.ID) + + assert.Error(t, err) + assert.True(t, errors.Is(err, domain.ErrForbidden)) + }) + + t.Run("Source work not found", func(t *testing.T) { + targetWork := &domain.Work{Title: "Existing Target"} + db.Create(targetWork) + ctx := context.WithValue(context.Background(), platform_auth.ClaimsContextKey, &platform_auth.Claims{UserID: 1, Role: string(domain.UserRoleAdmin)}) + + err := commands.MergeWork(ctx, 99999, targetWork.ID) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "entity not found") + }) + + t.Run("Target work not found", func(t *testing.T) { + sourceWork := &domain.Work{Title: "Existing Source"} + db.Create(sourceWork) + ctx := context.WithValue(context.Background(), platform_auth.ClaimsContextKey, &platform_auth.Claims{UserID: 1, Role: string(domain.UserRoleAdmin)}) + + err := commands.MergeWork(ctx, sourceWork.ID, 99999) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "entity not found") + }) + + t.Run("Cannot merge work into itself", func(t *testing.T) { + work := &domain.Work{Title: "Self Merge Work"} + db.Create(work) + ctx := context.WithValue(context.Background(), platform_auth.ClaimsContextKey, &platform_auth.Claims{UserID: 1, Role: string(domain.UserRoleAdmin)}) + + err := commands.MergeWork(ctx, work.ID, work.ID) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "source and target work IDs cannot be the same") + }) } \ No newline at end of file diff --git a/internal/app/work/main_test.go b/internal/app/work/main_test.go index 02b034e..1d02791 100644 --- a/internal/app/work/main_test.go +++ b/internal/app/work/main_test.go @@ -18,6 +18,7 @@ type mockWorkRepository struct { findByCategoryFunc func(ctx context.Context, categoryID uint) ([]domain.Work, error) findByLanguageFunc func(ctx context.Context, language string, page, pageSize int) (*domain.PaginatedResult[domain.Work], error) isAuthorFunc func(ctx context.Context, workID uint, authorID uint) (bool, error) + listByCollectionIDFunc func(ctx context.Context, collectionID uint) ([]domain.Work, error) } func (m *mockWorkRepository) IsAuthor(ctx context.Context, workID uint, authorID uint) (bool, error) { @@ -57,6 +58,13 @@ func (m *mockWorkRepository) List(ctx context.Context, page, pageSize int) (*dom } return nil, nil } + +func (m *mockWorkRepository) ListByCollectionID(ctx context.Context, collectionID uint) ([]domain.Work, error) { + if m.listByCollectionIDFunc != nil { + return m.listByCollectionIDFunc(ctx, collectionID) + } + return nil, nil +} func (m *mockWorkRepository) GetWithTranslations(ctx context.Context, id uint) (*domain.Work, error) { if m.getWithTranslationsFunc != nil { return m.getWithTranslationsFunc(ctx, id) diff --git a/internal/app/work/mock_analytics_service_test.go b/internal/app/work/mock_analytics_service_test.go index 559111e..363a346 100644 --- a/internal/app/work/mock_analytics_service_test.go +++ b/internal/app/work/mock_analytics_service_test.go @@ -11,6 +11,15 @@ type mockAnalyticsService struct { updateWorkSentimentFunc func(ctx context.Context, workID uint) error updateTranslationReadingTimeFunc func(ctx context.Context, translationID uint) error updateTranslationSentimentFunc func(ctx context.Context, translationID uint) error + getOrCreateWorkStatsFunc func(ctx context.Context, workID uint) (*domain.WorkStats, error) + updateWorkStatsFunc func(ctx context.Context, workID uint, stats domain.WorkStats) error +} + +func (m *mockAnalyticsService) UpdateWorkStats(ctx context.Context, workID uint, stats domain.WorkStats) error { + if m.updateWorkStatsFunc != nil { + return m.updateWorkStatsFunc(ctx, workID, stats) + } + return nil } func (m *mockAnalyticsService) UpdateWorkReadingTime(ctx context.Context, workID uint) error { @@ -78,6 +87,9 @@ func (m *mockAnalyticsService) IncrementTranslationShares(ctx context.Context, t return nil } func (m *mockAnalyticsService) GetOrCreateWorkStats(ctx context.Context, workID uint) (*domain.WorkStats, error) { + if m.getOrCreateWorkStatsFunc != nil { + return m.getOrCreateWorkStatsFunc(ctx, workID) + } return nil, nil } func (m *mockAnalyticsService) GetOrCreateTranslationStats(ctx context.Context, translationID uint) (*domain.TranslationStats, error) { diff --git a/internal/app/work/queries_test.go b/internal/app/work/queries_test.go index 5817edc..5ca36d9 100644 --- a/internal/app/work/queries_test.go +++ b/internal/app/work/queries_test.go @@ -45,6 +45,22 @@ func (s *WorkQueriesSuite) TestGetWorkByID_ZeroID() { assert.Nil(s.T(), w) } +func (s *WorkQueriesSuite) TestListByCollectionID_Success() { + works := []domain.Work{{Title: "Test Work"}} + s.repo.listByCollectionIDFunc = func(ctx context.Context, collectionID uint) ([]domain.Work, error) { + return works, nil + } + w, err := s.queries.ListByCollectionID(context.Background(), 1) + assert.NoError(s.T(), err) + assert.Equal(s.T(), works, w) +} + +func (s *WorkQueriesSuite) TestListByCollectionID_ZeroID() { + w, err := s.queries.ListByCollectionID(context.Background(), 0) + assert.Error(s.T(), err) + assert.Nil(s.T(), w) +} + func (s *WorkQueriesSuite) TestListWorks_Success() { domainWorks := &domain.PaginatedResult[domain.Work]{ Items: []domain.Work{ diff --git a/internal/app/work/service_test.go b/internal/app/work/service_test.go new file mode 100644 index 0000000..a7a881f --- /dev/null +++ b/internal/app/work/service_test.go @@ -0,0 +1,24 @@ +package work + +import ( + "testing" + "tercul/internal/app/authz" + + "github.com/stretchr/testify/assert" +) + +func TestNewService(t *testing.T) { + // Arrange + mockRepo := &mockWorkRepository{} + mockSearchClient := &mockSearchClient{} + mockAuthzSvc := &authz.Service{} + mockAnalyticsSvc := &mockAnalyticsService{} + + // Act + service := NewService(mockRepo, mockSearchClient, mockAuthzSvc, mockAnalyticsSvc) + + // Assert + assert.NotNil(t, service, "The new service should not be nil") + assert.NotNil(t, service.Commands, "The service Commands should not be nil") + assert.NotNil(t, service.Queries, "The service Queries should not be nil") +} \ No newline at end of file diff --git a/internal/data/sql/analytics_repository_test.go b/internal/data/sql/analytics_repository_test.go new file mode 100644 index 0000000..9101cd5 --- /dev/null +++ b/internal/data/sql/analytics_repository_test.go @@ -0,0 +1,204 @@ +package sql_test + +import ( + "context" + "testing" + "tercul/internal/app/analytics" + "tercul/internal/data/sql" + "tercul/internal/domain" + "tercul/internal/platform/config" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +// newTestAnalyticsRepoWithSQLite sets up an in-memory SQLite database for testing. +func newTestAnalyticsRepoWithSQLite(t *testing.T) (analytics.Repository, *gorm.DB) { + // Using "file::memory:?cache=shared" to ensure the in-memory database is shared across connections in the same process. + db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{}) + require.NoError(t, err) + + // Auto-migrate the necessary schemas + err = db.AutoMigrate( + &domain.Work{}, + &domain.WorkStats{}, + &domain.Translation{}, + &domain.TranslationStats{}, + &domain.Trending{}, + &domain.UserEngagement{}, + &domain.User{}, + ) + require.NoError(t, err) + + cfg := &config.Config{} + repo := sql.NewAnalyticsRepository(db, cfg) + + // Clean up the database after the test + t.Cleanup(func() { + sqlDB, err := db.DB() + require.NoError(t, err) + err = sqlDB.Close() + require.NoError(t, err) + }) + + return repo, db +} + +func TestAnalyticsRepository_IncrementWorkCounter(t *testing.T) { + repo, db := newTestAnalyticsRepoWithSQLite(t) + ctx := context.Background() + + // Setup: Create a work to associate stats with + work := domain.Work{Title: "Test Work"} + require.NoError(t, db.Create(&work).Error) + + t.Run("creates_new_stats_if_not_exist", func(t *testing.T) { + err := repo.IncrementWorkCounter(ctx, work.ID, "views", 5) + require.NoError(t, err) + + var stats domain.WorkStats + err = db.Where("work_id = ?", work.ID).First(&stats).Error + require.NoError(t, err) + assert.Equal(t, int64(5), stats.Views) + }) + + t.Run("increments_existing_stats", func(t *testing.T) { + // Increment again + err := repo.IncrementWorkCounter(ctx, work.ID, "views", 3) + require.NoError(t, err) + + var stats domain.WorkStats + err = db.Where("work_id = ?", work.ID).First(&stats).Error + require.NoError(t, err) + assert.Equal(t, int64(8), stats.Views) // 5 + 3 + }) + + t.Run("invalid_field", func(t *testing.T) { + err := repo.IncrementWorkCounter(ctx, work.ID, "invalid_field", 1) + assert.Error(t, err) + }) +} + +func TestAnalyticsRepository_GetTrendingWorks(t *testing.T) { + repo, db := newTestAnalyticsRepoWithSQLite(t) + ctx := context.Background() + + // Setup: Create some works and trending data + work1 := domain.Work{Title: "Trending Work 1"} + work2 := domain.Work{Title: "Trending Work 2"} + require.NoError(t, db.Create(&work1).Error) + require.NoError(t, db.Create(&work2).Error) + + trendingData := []*domain.Trending{ + {EntityID: work1.ID, EntityType: "Work", Rank: 1, TimePeriod: "daily"}, + {EntityID: work2.ID, EntityType: "Work", Rank: 2, TimePeriod: "daily"}, + } + require.NoError(t, db.Create(&trendingData).Error) + + t.Run("success", func(t *testing.T) { + works, err := repo.GetTrendingWorks(ctx, "daily", 5) + require.NoError(t, err) + require.Len(t, works, 2) + assert.Equal(t, work1.ID, works[0].ID) + assert.Equal(t, work2.ID, works[1].ID) + }) +} + +func TestAnalyticsRepository_UpdateTrendingWorks(t *testing.T) { + repo, db := newTestAnalyticsRepoWithSQLite(t) + ctx := context.Background() + + // Setup: Create an old trending record + oldTrending := domain.Trending{EntityID: 99, EntityType: "Work", Rank: 1, TimePeriod: "daily"} + require.NoError(t, db.Create(&oldTrending).Error) + + newTrendingData := []*domain.Trending{ + {EntityID: 1, EntityType: "Work", Rank: 1, Score: 100, TimePeriod: "daily"}, + {EntityID: 2, EntityType: "Work", Rank: 2, Score: 90, TimePeriod: "daily"}, + } + + err := repo.UpdateTrendingWorks(ctx, "daily", newTrendingData) + require.NoError(t, err) + + var trendingResult []domain.Trending + db.Where("time_period = ?", "daily").Order("rank asc").Find(&trendingResult) + + require.Len(t, trendingResult, 2) + assert.Equal(t, uint(1), trendingResult[0].EntityID) + assert.Equal(t, uint(2), trendingResult[1].EntityID) +} + +func TestAnalyticsRepository_GetOrCreate(t *testing.T) { + repo, db := newTestAnalyticsRepoWithSQLite(t) + ctx := context.Background() + + work := domain.Work{Title: "Test Work"} + require.NoError(t, db.Create(&work).Error) + translation := domain.Translation{Title: "Test Translation", TranslatableID: work.ID, TranslatableType: "Work"} + require.NoError(t, db.Create(&translation).Error) + user := domain.User{Username: "testuser", Email: "test@test.com"} + require.NoError(t, db.Create(&user).Error) + + t.Run("GetOrCreateWorkStats", func(t *testing.T) { + // Create + stats, err := repo.GetOrCreateWorkStats(ctx, work.ID) + require.NoError(t, err) + assert.Equal(t, work.ID, stats.WorkID) + + // Get + stats2, err := repo.GetOrCreateWorkStats(ctx, work.ID) + require.NoError(t, err) + assert.Equal(t, stats.ID, stats2.ID) + }) + + t.Run("GetOrCreateTranslationStats", func(t *testing.T) { + // Create + stats, err := repo.GetOrCreateTranslationStats(ctx, translation.ID) + require.NoError(t, err) + assert.Equal(t, translation.ID, stats.TranslationID) + + // Get + stats2, err := repo.GetOrCreateTranslationStats(ctx, translation.ID) + require.NoError(t, err) + assert.Equal(t, stats.ID, stats2.ID) + }) + + t.Run("GetOrCreateUserEngagement", func(t *testing.T) { + date := time.Now().Truncate(24 * time.Hour) + // Create + eng, err := repo.GetOrCreateUserEngagement(ctx, user.ID, date) + require.NoError(t, err) + assert.Equal(t, user.ID, eng.UserID) + + // Get + eng2, err := repo.GetOrCreateUserEngagement(ctx, user.ID, date) + require.NoError(t, err) + assert.Equal(t, eng.ID, eng2.ID) + }) +} + +func TestAnalyticsRepository_UpdateUserEngagement(t *testing.T) { + repo, db := newTestAnalyticsRepoWithSQLite(t) + ctx := context.Background() + + user := domain.User{Username: "testuser", Email: "test@test.com"} + require.NoError(t, db.Create(&user).Error) + date := time.Now().Truncate(24 * time.Hour) + engagement, err := repo.GetOrCreateUserEngagement(ctx, user.ID, date) + require.NoError(t, err) + + engagement.LikesGiven = 15 + engagement.WorksRead = 10 + + err = repo.UpdateUserEngagement(ctx, engagement) + require.NoError(t, err) + + var updatedEngagement domain.UserEngagement + db.First(&updatedEngagement, engagement.ID) + + assert.Equal(t, 15, updatedEngagement.LikesGiven) + assert.Equal(t, 10, updatedEngagement.WorksRead) +} \ No newline at end of file diff --git a/internal/data/sql/auth_repository_test.go b/internal/data/sql/auth_repository_test.go new file mode 100644 index 0000000..4a4da96 --- /dev/null +++ b/internal/data/sql/auth_repository_test.go @@ -0,0 +1,91 @@ +package sql_test + +import ( + "context" + "testing" + "tercul/internal/data/sql" + "tercul/internal/domain" + "tercul/internal/platform/config" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +// newTestAuthRepoWithSQLite sets up an in-memory SQLite database for auth repository testing. +func newTestAuthRepoWithSQLite(t *testing.T) (domain.AuthRepository, *gorm.DB) { + db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{}) + require.NoError(t, err) + + // Auto-migrate the necessary schemas + err = db.AutoMigrate( + &domain.User{}, + &domain.UserSession{}, + ) + require.NoError(t, err) + + cfg := &config.Config{} + repo := sql.NewAuthRepository(db, cfg) + + // Clean up the database after the test + t.Cleanup(func() { + sqlDB, err := db.DB() + require.NoError(t, err) + err = sqlDB.Close() + require.NoError(t, err) + }) + + return repo, db +} + +func TestAuthRepository_StoreToken(t *testing.T) { + repo, db := newTestAuthRepoWithSQLite(t) + ctx := context.Background() + + // Setup: Create a user + user := domain.User{Username: "authuser", Email: "auth@test.com"} + require.NoError(t, db.Create(&user).Error) + + token := "my-secret-token" + expiresAt := time.Now().Add(1 * time.Hour) + + err := repo.StoreToken(ctx, user.ID, token, expiresAt) + require.NoError(t, err) + + var session domain.UserSession + err = db.Where("token = ?", token).First(&session).Error + require.NoError(t, err) + + assert.Equal(t, user.ID, session.UserID) + assert.Equal(t, token, session.Token) + // Truncate to a reasonable precision for comparison + assert.WithinDuration(t, expiresAt, session.ExpiresAt, time.Second) +} + +func TestAuthRepository_DeleteToken(t *testing.T) { + repo, db := newTestAuthRepoWithSQLite(t) + ctx := context.Background() + + // Setup: Create a user and a session + user := domain.User{Username: "authuser2", Email: "auth2@test.com"} + require.NoError(t, db.Create(&user).Error) + token := "token-to-delete" + session := &domain.UserSession{ + UserID: user.ID, + Token: token, + ExpiresAt: time.Now().Add(1 * time.Hour), + } + require.NoError(t, db.Create(session).Error) + + // Delete the token + err := repo.DeleteToken(ctx, token) + require.NoError(t, err) + + // Verify it's gone + var deletedSession domain.UserSession + err = db.Where("token = ?", token).First(&deletedSession).Error + assert.Error(t, err) + assert.Equal(t, gorm.ErrRecordNotFound, err) +} \ No newline at end of file diff --git a/internal/data/sql/copyright_claim_repository_test.go b/internal/data/sql/copyright_claim_repository_test.go new file mode 100644 index 0000000..2f31aa7 --- /dev/null +++ b/internal/data/sql/copyright_claim_repository_test.go @@ -0,0 +1,122 @@ +package sql_test + +import ( + "context" + "testing" + "tercul/internal/data/sql" + "tercul/internal/domain" + "tercul/internal/platform/config" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +// newTestCopyrightClaimRepoWithSQLite sets up an in-memory SQLite database for copyright claim repository testing. +func newTestCopyrightClaimRepoWithSQLite(t *testing.T) (domain.CopyrightClaimRepository, *gorm.DB) { + db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{}) + require.NoError(t, err) + + // Auto-migrate the necessary schemas + err = db.AutoMigrate( + &domain.User{}, + &domain.Work{}, + &domain.CopyrightClaim{}, + ) + require.NoError(t, err) + + cfg := &config.Config{} + repo := sql.NewCopyrightClaimRepository(db, cfg) + + // Clean up the database after the test + t.Cleanup(func() { + sqlDB, err := db.DB() + require.NoError(t, err) + err = sqlDB.Close() + require.NoError(t, err) + }) + + return repo, db +} + +func TestCopyrightClaimRepository_ListByWorkID(t *testing.T) { + repo, db := newTestCopyrightClaimRepoWithSQLite(t) + ctx := context.Background() + + // Setup: Create a user and a work + user := domain.User{Username: "claimuser", Email: "claim@test.com"} + require.NoError(t, db.Create(&user).Error) + work1 := domain.Work{Title: "Work 1"} + require.NoError(t, db.Create(&work1).Error) + work2 := domain.Work{Title: "Work 2"} + require.NoError(t, db.Create(&work2).Error) + + // Create some claims + claim1 := domain.CopyrightClaim{UserID: &user.ID, WorkID: &work1.ID, Details: "Claim 1", ClaimDate: time.Now()} + claim2 := domain.CopyrightClaim{UserID: &user.ID, WorkID: &work1.ID, Details: "Claim 2", ClaimDate: time.Now()} + claim3 := domain.CopyrightClaim{UserID: &user.ID, WorkID: &work2.ID, Details: "Claim 3", ClaimDate: time.Now()} + require.NoError(t, db.Create(&claim1).Error) + require.NoError(t, db.Create(&claim2).Error) + require.NoError(t, db.Create(&claim3).Error) + + t.Run("finds_claims_for_work1", func(t *testing.T) { + claims, err := repo.ListByWorkID(ctx, work1.ID) + require.NoError(t, err) + assert.Len(t, claims, 2) + }) + + t.Run("finds_claims_for_work2", func(t *testing.T) { + claims, err := repo.ListByWorkID(ctx, work2.ID) + require.NoError(t, err) + assert.Len(t, claims, 1) + assert.Equal(t, "Claim 3", claims[0].Details) + }) + + t.Run("returns_empty_slice_for_no_claims", func(t *testing.T) { + claims, err := repo.ListByWorkID(ctx, 999) // Non-existent work + require.NoError(t, err) + assert.Empty(t, claims) + }) +} + +func TestCopyrightClaimRepository_ListByUserID(t *testing.T) { + repo, db := newTestCopyrightClaimRepoWithSQLite(t) + ctx := context.Background() + + // Setup: Create users and a work + user1 := domain.User{Username: "user1", Email: "user1@test.com"} + require.NoError(t, db.Create(&user1).Error) + user2 := domain.User{Username: "user2", Email: "user2@test.com"} + require.NoError(t, db.Create(&user2).Error) + work := domain.Work{Title: "Test Work"} + require.NoError(t, db.Create(&work).Error) + + // Create some claims + claim1 := domain.CopyrightClaim{UserID: &user1.ID, WorkID: &work.ID, Details: "Claim 1", ClaimDate: time.Now()} + claim2 := domain.CopyrightClaim{UserID: &user2.ID, WorkID: &work.ID, Details: "Claim 2", ClaimDate: time.Now()} + claim3 := domain.CopyrightClaim{UserID: &user2.ID, WorkID: &work.ID, Details: "Claim 3", ClaimDate: time.Now()} + require.NoError(t, db.Create(&claim1).Error) + require.NoError(t, db.Create(&claim2).Error) + require.NoError(t, db.Create(&claim3).Error) + + t.Run("finds_claims_for_user1", func(t *testing.T) { + claims, err := repo.ListByUserID(ctx, user1.ID) + require.NoError(t, err) + assert.Len(t, claims, 1) + assert.Equal(t, "Claim 1", claims[0].Details) + }) + + t.Run("finds_claims_for_user2", func(t *testing.T) { + claims, err := repo.ListByUserID(ctx, user2.ID) + require.NoError(t, err) + assert.Len(t, claims, 2) + }) + + t.Run("returns_empty_slice_for_no_claims", func(t *testing.T) { + claims, err := repo.ListByUserID(ctx, 999) // Non-existent user + require.NoError(t, err) + assert.Empty(t, claims) + }) +} \ No newline at end of file diff --git a/internal/data/sql/copyright_repository_test.go b/internal/data/sql/copyright_repository_test.go index 78468b7..d44474e 100644 --- a/internal/data/sql/copyright_repository_test.go +++ b/internal/data/sql/copyright_repository_test.go @@ -127,7 +127,7 @@ func (s *CopyrightRepositoryTestSuite) TestGetTranslationByLanguage() { _, err := s.repo.GetTranslationByLanguage(context.Background(), copyrightID, languageCode) s.Require().Error(err) - s.Require().Equal(sql.ErrEntityNotFound, err) + s.Require().Contains(err.Error(), "entity not found") }) } diff --git a/internal/domain/entities.go b/internal/domain/entities.go index f59d1e9..792bbaf 100644 --- a/internal/domain/entities.go +++ b/internal/domain/entities.go @@ -581,8 +581,10 @@ type CopyrightClaim struct { ClaimDate time.Time `gorm:"not null"` Resolution string `gorm:"type:text"` ResolvedAt *time.Time - UserID *uint - User *User `gorm:"foreignKey:UserID"` + UserID *uint + User *User `gorm:"foreignKey:UserID"` + WorkID *uint + Work *Work `gorm:"foreignKey:WorkID"` } type MonetizationType string const ( diff --git a/internal/platform/cache/redis_cache_test.go b/internal/platform/cache/redis_cache_test.go new file mode 100644 index 0000000..43e179a --- /dev/null +++ b/internal/platform/cache/redis_cache_test.go @@ -0,0 +1,409 @@ +package cache_test + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "testing" + "tercul/internal/platform/cache" + "tercul/internal/platform/config" + "time" + + "github.com/go-redis/redismock/v9" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewRedisCache(t *testing.T) { + client, _ := redismock.NewClientMock() + keyGen := cache.NewDefaultKeyGenerator("test:") + defaultExpiry := 2 * time.Hour + + c := cache.NewRedisCache(client, keyGen, defaultExpiry) + require.NotNil(t, c) +} + +func TestNewDefaultRedisCache(t *testing.T) { + t.Run("success", func(t *testing.T) { + // This test requires a running Redis instance or a mock for the Ping command. + // For now, we'll just test that the function returns a non-nil cache + // when the config is valid. A more comprehensive test would involve + // mocking the redis.NewClient and its Ping method. + cfg := &config.Config{ + RedisAddr: "localhost:6379", + } + // Since this function actually tries to connect, we can't fully test it in a unit test + // without a live redis. We will skip a full test here and focus on the other methods. + // In a real-world scenario, we might use a test container with Redis. + // For now, we'll just ensure it doesn't panic with a basic config. + // A proper integration test would be better suited for this. + _ = cfg + // _, err := cache.NewDefaultRedisCache(cfg) + // assert.NoError(t, err) + }) + + t.Run("connection error", func(t *testing.T) { + cfg := &config.Config{ + RedisAddr: "localhost:9999", // Invalid address + } + _, err := cache.NewDefaultRedisCache(cfg) + assert.Error(t, err) + }) +} + +func TestRedisCache_Get(t *testing.T) { + client, mock := redismock.NewClientMock() + c := cache.NewRedisCache(client, nil, time.Hour) + ctx := context.Background() + key := "test-key" + expectedValue := map[string]string{"foo": "bar"} + expectedBytes, _ := json.Marshal(expectedValue) + + t.Run("success", func(t *testing.T) { + mock.ExpectGet(key).SetVal(string(expectedBytes)) + + var result map[string]string + err := c.Get(ctx, key, &result) + + assert.NoError(t, err) + assert.Equal(t, expectedValue, result) + assert.NoError(t, mock.ExpectationsWereMet()) + }) + + t.Run("cache miss", func(t *testing.T) { + mock.ExpectGet(key).RedisNil() + + var result map[string]string + err := c.Get(ctx, key, &result) + + assert.Error(t, err) + assert.Equal(t, "cache miss", err.Error()) + assert.NoError(t, mock.ExpectationsWereMet()) + }) + + t.Run("redis error", func(t *testing.T) { + mock.ExpectGet(key).SetErr(errors.New("redis error")) + + var result map[string]string + err := c.Get(ctx, key, &result) + + assert.Error(t, err) + assert.Equal(t, "redis error", err.Error()) + assert.NoError(t, mock.ExpectationsWereMet()) + }) +} + +func TestRedisCache_InvalidateEntityType(t *testing.T) { + ctx := context.Background() + entityType := "user" + pattern := "tercul:user:*" + + t.Run("success with multiple batches", func(t *testing.T) { + client, mock := redismock.NewClientMock() + keyGen := cache.NewDefaultKeyGenerator("tercul:") + c := cache.NewRedisCache(client, keyGen, time.Hour) + + keysBatch1 := make([]string, 100) + for i := 0; i < 100; i++ { + keysBatch1[i] = fmt.Sprintf("tercul:user:key%d", i) + } + keysBatch2 := []string{"tercul:user:key101", "tercul:user:key102"} + + // Mocking the SCAN and DEL calls in the correct order + mock.ExpectScan(0, pattern, 100).SetVal(keysBatch1, 1) + mock.ExpectDel(keysBatch1...).SetVal(100) + mock.ExpectScan(1, pattern, 100).SetVal(keysBatch2, 0) + mock.ExpectDel(keysBatch2...).SetVal(2) + + err := c.InvalidateEntityType(ctx, entityType) + assert.NoError(t, err) + assert.NoError(t, mock.ExpectationsWereMet()) + }) + + t.Run("success with single batch", func(t *testing.T) { + client, mock := redismock.NewClientMock() + keyGen := cache.NewDefaultKeyGenerator("tercul:") + c := cache.NewRedisCache(client, keyGen, time.Hour) + + keys := []string{"tercul:user:key1", "tercul:user:key2"} + mock.ExpectScan(0, pattern, 100).SetVal(keys, 0) + mock.ExpectDel(keys...).SetVal(2) + + err := c.InvalidateEntityType(ctx, entityType) + assert.NoError(t, err) + assert.NoError(t, mock.ExpectationsWereMet()) + }) + + t.Run("scan error", func(t *testing.T) { + client, mock := redismock.NewClientMock() + keyGen := cache.NewDefaultKeyGenerator("tercul:") + c := cache.NewRedisCache(client, keyGen, time.Hour) + + mock.ExpectScan(0, pattern, 100).SetErr(errors.New("scan error")) + + err := c.InvalidateEntityType(ctx, entityType) + assert.Error(t, err) + assert.Equal(t, "scan error", err.Error()) + assert.NoError(t, mock.ExpectationsWereMet()) + }) + + t.Run("del error", func(t *testing.T) { + client, mock := redismock.NewClientMock() + keyGen := cache.NewDefaultKeyGenerator("tercul:") + c := cache.NewRedisCache(client, keyGen, time.Hour) + + keys := []string{"tercul:user:key1"} + mock.ExpectScan(0, pattern, 100).SetVal(keys, 0) + mock.ExpectDel(keys...).SetErr(errors.New("del error")) + + err := c.InvalidateEntityType(ctx, entityType) + assert.Error(t, err) + assert.Equal(t, "del error", err.Error()) + assert.NoError(t, mock.ExpectationsWereMet()) + }) +} + +func TestRedisCache_GetMulti(t *testing.T) { + client, mock := redismock.NewClientMock() + c := cache.NewRedisCache(client, nil, time.Hour) + ctx := context.Background() + keys := []string{"key1", "key2", "key3"} + expectedValues := []interface{}{"value1", nil, "value3"} + expectedResult := map[string][]byte{ + "key1": []byte("value1"), + "key3": []byte("value3"), + } + + t.Run("success", func(t *testing.T) { + mock.ExpectMGet(keys...).SetVal(expectedValues) + + result, err := c.GetMulti(ctx, keys) + + assert.NoError(t, err) + assert.Equal(t, expectedResult, result) + assert.NoError(t, mock.ExpectationsWereMet()) + }) + + t.Run("no keys", func(t *testing.T) { + result, err := c.GetMulti(ctx, []string{}) + assert.NoError(t, err) + assert.Empty(t, result) + }) + + t.Run("redis error", func(t *testing.T) { + mock.ExpectMGet(keys...).SetErr(errors.New("redis error")) + + _, err := c.GetMulti(ctx, keys) + + assert.Error(t, err) + assert.Equal(t, "redis error", err.Error()) + assert.NoError(t, mock.ExpectationsWereMet()) + }) +} + +func TestRedisCache_SetMulti(t *testing.T) { + ctx := context.Background() + items := map[string]interface{}{ + "key1": "value1", + "key2": 123, + } + + t.Run("success", func(t *testing.T) { + client, mock := redismock.NewClientMock() + c := cache.NewRedisCache(client, nil, time.Hour) + mock.MatchExpectationsInOrder(false) + + // Expect each Set command within the pipeline + for key, value := range items { + data, _ := json.Marshal(value) + mock.ExpectSet(key, data, time.Hour).SetVal("OK") + } + + // The SetMulti function will call Exec(), which triggers the mock's pipeline hook + err := c.SetMulti(ctx, items, 0) + + assert.NoError(t, err) + assert.NoError(t, mock.ExpectationsWereMet()) + }) + + t.Run("no items", func(t *testing.T) { + client, _ := redismock.NewClientMock() + c := cache.NewRedisCache(client, nil, time.Hour) + err := c.SetMulti(ctx, map[string]interface{}{}, 0) + assert.NoError(t, err) + }) + + t.Run("redis error", func(t *testing.T) { + client, mock := redismock.NewClientMock() + c := cache.NewRedisCache(client, nil, time.Hour) + mock.MatchExpectationsInOrder(false) + + // To simulate a pipeline execution error, we make one of the inner commands fail. + // The hook will stop processing on the first error and return it. + data1, _ := json.Marshal("value1") + mock.ExpectSet("key1", data1, time.Hour).SetErr(errors.New("redis error")) + + data2, _ := json.Marshal(123) + mock.ExpectSet("key2", data2, time.Hour).SetVal("OK") + + err := c.SetMulti(ctx, items, 0) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "redis error") + }) +} + +func TestRedisCache_EntityOperations(t *testing.T) { + client, mock := redismock.NewClientMock() + keyGen := cache.NewDefaultKeyGenerator("tercul:") + c := cache.NewRedisCache(client, keyGen, time.Hour) + ctx := context.Background() + + entityType := "user" + id := uint(123) + entityKey := "tercul:user:id:123" + value := map[string]string{"name": "test"} + valueBytes, _ := json.Marshal(value) + + page := 1 + pageSize := 10 + listKey := "tercul:user:list:1:10" + listValue := []string{"user1", "user2"} + listValueBytes, _ := json.Marshal(listValue) + + t.Run("GetEntity", func(t *testing.T) { + mock.ExpectGet(entityKey).SetVal(string(valueBytes)) + var result map[string]string + err := c.GetEntity(ctx, entityType, id, &result) + assert.NoError(t, err) + assert.Equal(t, value, result) + assert.NoError(t, mock.ExpectationsWereMet()) + }) + + t.Run("SetEntity", func(t *testing.T) { + mock.ExpectSet(entityKey, valueBytes, time.Hour).SetVal("OK") + err := c.SetEntity(ctx, entityType, id, value, 0) + assert.NoError(t, err) + assert.NoError(t, mock.ExpectationsWereMet()) + }) + + t.Run("DeleteEntity", func(t *testing.T) { + mock.ExpectDel(entityKey).SetVal(1) + err := c.DeleteEntity(ctx, entityType, id) + assert.NoError(t, err) + assert.NoError(t, mock.ExpectationsWereMet()) + }) + + t.Run("GetList", func(t *testing.T) { + mock.ExpectGet(listKey).SetVal(string(listValueBytes)) + var result []string + err := c.GetList(ctx, entityType, page, pageSize, &result) + assert.NoError(t, err) + assert.Equal(t, listValue, result) + assert.NoError(t, mock.ExpectationsWereMet()) + }) + + t.Run("SetList", func(t *testing.T) { + mock.ExpectSet(listKey, listValueBytes, time.Hour).SetVal("OK") + err := c.SetList(ctx, entityType, page, pageSize, listValue, 0) + assert.NoError(t, err) + assert.NoError(t, mock.ExpectationsWereMet()) + }) + + t.Run("DeleteList", func(t *testing.T) { + mock.ExpectDel(listKey).SetVal(1) + err := c.DeleteList(ctx, entityType, page, pageSize) + assert.NoError(t, err) + assert.NoError(t, mock.ExpectationsWereMet()) + }) +} + +func TestRedisCache_Set(t *testing.T) { + client, mock := redismock.NewClientMock() + c := cache.NewRedisCache(client, nil, time.Hour) + ctx := context.Background() + key := "test-key" + value := map[string]string{"foo": "bar"} + expectedBytes, _ := json.Marshal(value) + + t.Run("success with default expiration", func(t *testing.T) { + mock.ExpectSet(key, expectedBytes, time.Hour).SetVal("OK") + + err := c.Set(ctx, key, value, 0) + + assert.NoError(t, err) + assert.NoError(t, mock.ExpectationsWereMet()) + }) + + t.Run("success with custom expiration", func(t *testing.T) { + expiration := 5 * time.Minute + mock.ExpectSet(key, expectedBytes, expiration).SetVal("OK") + + err := c.Set(ctx, key, value, expiration) + + assert.NoError(t, err) + assert.NoError(t, mock.ExpectationsWereMet()) + }) + + t.Run("redis error", func(t *testing.T) { + mock.ExpectSet(key, expectedBytes, time.Hour).SetErr(errors.New("redis error")) + + err := c.Set(ctx, key, value, 0) + + assert.Error(t, err) + assert.Equal(t, "redis error", err.Error()) + assert.NoError(t, mock.ExpectationsWereMet()) + }) +} + +func TestRedisCache_Delete(t *testing.T) { + client, mock := redismock.NewClientMock() + c := cache.NewRedisCache(client, nil, time.Hour) + ctx := context.Background() + key := "test-key" + + t.Run("success", func(t *testing.T) { + mock.ExpectDel(key).SetVal(1) + + err := c.Delete(ctx, key) + + assert.NoError(t, err) + assert.NoError(t, mock.ExpectationsWereMet()) + }) + + t.Run("redis error", func(t *testing.T) { + mock.ExpectDel(key).SetErr(errors.New("redis error")) + + err := c.Delete(ctx, key) + + assert.Error(t, err) + assert.Equal(t, "redis error", err.Error()) + assert.NoError(t, mock.ExpectationsWereMet()) + }) +} + +func TestRedisCache_Clear(t *testing.T) { + client, mock := redismock.NewClientMock() + c := cache.NewRedisCache(client, nil, time.Hour) + ctx := context.Background() + + t.Run("success", func(t *testing.T) { + mock.ExpectFlushAll().SetVal("OK") + + err := c.Clear(ctx) + + assert.NoError(t, err) + assert.NoError(t, mock.ExpectationsWereMet()) + }) + + t.Run("redis error", func(t *testing.T) { + mock.ExpectFlushAll().SetErr(errors.New("redis error")) + + err := c.Clear(ctx) + + assert.Error(t, err) + assert.Equal(t, "redis error", err.Error()) + assert.NoError(t, mock.ExpectationsWereMet()) + }) +} \ No newline at end of file