Merge pull request #23 from SamyRai/test/increase-cache-coverage

test: Increase test coverage for internal/platform/cache
This commit is contained in:
Damir Mukimov 2025-10-09 00:05:43 +02:00 committed by GitHub
commit 3dfe5986cf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 1428 additions and 83 deletions

1
go.mod
View File

@ -66,6 +66,7 @@ require (
github.com/go-openapi/validate v0.24.0 // indirect github.com/go-openapi/validate v0.24.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.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-sql-driver/mysql v1.9.3 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect github.com/golang-jwt/jwt/v4 v4.5.2 // indirect

2
go.sum
View File

@ -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/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 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= 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 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= 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= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=

View File

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

View File

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

View File

@ -40,6 +40,7 @@ type Service interface {
UpdateUserEngagement(ctx context.Context, userID uint, eventType string) error UpdateUserEngagement(ctx context.Context, userID uint, eventType string) error
UpdateTrending(ctx context.Context) error UpdateTrending(ctx context.Context) error
GetTrendingWorks(ctx context.Context, timePeriod string, limit int) ([]*domain.Work, 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 { type service struct {
@ -314,6 +315,12 @@ func (s *service) GetTrendingWorks(ctx context.Context, timePeriod string, limit
return s.repo.GetTrendingWorks(ctx, timePeriod, 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 { func (s *service) UpdateTrending(ctx context.Context) error {
ctx, span := s.tracer.Start(ctx, "UpdateTrending") ctx, span := s.tracer.Start(ctx, "UpdateTrending")
defer span.End() defer span.End()

View File

@ -305,14 +305,18 @@ func mergeWorkStats(tx *gorm.DB, sourceWorkID, targetWorkID uint) error {
return nil return nil
} }
// Store the original ID to delete later, as the sourceStats.ID might be overwritten.
originalSourceStatsID := sourceStats.ID
var targetStats domain.WorkStats var targetStats domain.WorkStats
err = tx.Where("work_id = ?", targetWorkID).First(&targetStats).Error err = tx.Where("work_id = ?", targetWorkID).First(&targetStats).Error
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
// If target has no stats, create new ones based on source stats. // If target has no stats, create a new stats record for it.
sourceStats.ID = 0 // Let GORM create a new record newStats := sourceStats
sourceStats.WorkID = targetWorkID newStats.ID = 0
if err = tx.Create(&sourceStats).Error; err != nil { newStats.WorkID = targetWorkID
if err = tx.Create(&newStats).Error; err != nil {
return fmt.Errorf("failed to create new target stats: %w", err) return fmt.Errorf("failed to create new target stats: %w", err)
} }
} else if err != nil { } else if err != nil {
@ -325,8 +329,8 @@ func mergeWorkStats(tx *gorm.DB, sourceWorkID, targetWorkID uint) error {
} }
} }
// Delete the old source stats // Delete the old source stats using the stored original ID.
if err = tx.Delete(&domain.WorkStats{}, sourceStats.ID).Error; err != nil { if err = tx.Delete(&domain.WorkStats{}, originalSourceStatsID).Error; err != nil {
return fmt.Errorf("failed to delete source work stats: %w", err) return fmt.Errorf("failed to delete source work stats: %w", err)
} }

View File

@ -70,7 +70,7 @@ func (s *WorkCommandsSuite) TestCreateWork_RepoError() {
} }
func (s *WorkCommandsSuite) TestUpdateWork_Success() { 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 := &domain.Work{Title: "Test Work", TranslatableModel: domain.TranslatableModel{Language: "en"}}
work.ID = 1 work.ID = 1
@ -111,17 +111,40 @@ func (s *WorkCommandsSuite) TestUpdateWork_EmptyLanguage() {
} }
func (s *WorkCommandsSuite) TestUpdateWork_RepoError() { 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 := &domain.Work{Title: "Test Work", TranslatableModel: domain.TranslatableModel{Language: "en"}}
work.ID = 1 work.ID = 1
s.repo.updateFunc = func(ctx context.Context, w *domain.Work) error { s.repo.updateFunc = func(ctx context.Context, w *domain.Work) error {
return errors.New("db error") return errors.New("db error")
} }
err := s.commands.UpdateWork(context.Background(), work) err := s.commands.UpdateWork(ctx, work)
assert.Error(s.T(), err) 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() { 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 := &domain.Work{Title: "Test Work", TranslatableModel: domain.TranslatableModel{Language: "en"}}
work.ID = 1 work.ID = 1
@ -142,13 +165,27 @@ func (s *WorkCommandsSuite) TestDeleteWork_ZeroID() {
} }
func (s *WorkCommandsSuite) TestDeleteWork_RepoError() { 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 { s.repo.deleteFunc = func(ctx context.Context, id uint) error {
return errors.New("db error") return errors.New("db error")
} }
err := s.commands.DeleteWork(context.Background(), 1) err := s.commands.DeleteWork(ctx, 1)
assert.Error(s.T(), err) 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() { func (s *WorkCommandsSuite) TestAnalyzeWork_Success() {
work := &domain.Work{ work := &domain.Work{
TranslatableModel: domain.TranslatableModel{BaseModel: domain.BaseModel{ID: 1}}, TranslatableModel: domain.TranslatableModel{BaseModel: domain.BaseModel{ID: 1}},
@ -221,82 +258,157 @@ func TestMergeWork_Integration(t *testing.T) {
analyticsSvc := &mockAnalyticsService{} analyticsSvc := &mockAnalyticsService{}
commands := NewWorkCommands(workRepo, searchClient, authzSvc, analyticsSvc) 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 --- // --- Seed Data ---
author1 := &domain.Author{Name: "Author One"} t.Run("Success", func(t *testing.T) {
db.Create(author1) author1 := &domain.Author{Name: "Author One"}
author2 := &domain.Author{Name: "Author Two"} db.Create(author1)
db.Create(author2) author2 := &domain.Author{Name: "Author Two"}
db.Create(author2)
tag1 := &domain.Tag{Name: "Tag One"} tag1 := &domain.Tag{Name: "Tag One"}
db.Create(tag1) db.Create(tag1)
tag2 := &domain.Tag{Name: "Tag Two"} tag2 := &domain.Tag{Name: "Tag Two"}
db.Create(tag2) db.Create(tag2)
sourceWork := &domain.Work{ sourceWork := &domain.Work{
TranslatableModel: domain.TranslatableModel{Language: "en"}, TranslatableModel: domain.TranslatableModel{Language: "en"},
Title: "Source Work", Title: "Source Work",
Authors: []*domain.Author{author1}, Authors: []*domain.Author{author1},
Tags: []*domain.Tag{tag1}, 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")
} }
if tr.Language == "fr" { db.Create(sourceWork)
foundFr = true db.Create(&domain.Translation{Title: "Source English", Language: "en", TranslatableID: sourceWork.ID, TranslatableType: "works"})
assert.Equal(t, "Source French", tr.Title, "Should merge source's French translation") 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)
assert.True(t, foundEn, "English translation should be present") db.Create(&domain.Translation{Title: "Target English", Language: "en", TranslatableID: targetWork.ID, TranslatableType: "works"})
assert.True(t, foundFr, "French translation should be present") db.Create(&domain.WorkStats{WorkID: targetWork.ID, Views: 20, Likes: 10})
assert.Len(t, finalTargetWork.Authors, 2, "Authors should be merged") // --- Execute Merge ---
assert.Len(t, finalTargetWork.Tags, 2, "Tags should be merged") 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 // --- Assertions ---
var finalStats domain.WorkStats // 1. Source work should be deleted
db.Where("work_id = ?", targetWork.ID).First(&finalStats) var deletedWork domain.Work
assert.Equal(t, int64(30), finalStats.Views, "Views should be summed") err = db.First(&deletedWork, sourceWork.ID).Error
assert.Equal(t, int64(15), finalStats.Likes, "Likes should be summed") assert.Error(t, err)
assert.True(t, errors.Is(err, gorm.ErrRecordNotFound))
// 4. Source stats should be deleted // 2. Target work should have merged data
var deletedStats domain.WorkStats var finalTargetWork domain.Work
err = db.First(&deletedStats, "work_id = ?", sourceWork.ID).Error db.Preload("Translations").Preload("Authors").Preload("Tags").First(&finalTargetWork, targetWork.ID)
assert.Error(t, err, "Source stats should be deleted")
assert.True(t, errors.Is(err, gorm.ErrRecordNotFound)) 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")
})
} }

View File

@ -18,6 +18,7 @@ type mockWorkRepository struct {
findByCategoryFunc func(ctx context.Context, categoryID uint) ([]domain.Work, error) 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) 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) 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) { 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 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) { func (m *mockWorkRepository) GetWithTranslations(ctx context.Context, id uint) (*domain.Work, error) {
if m.getWithTranslationsFunc != nil { if m.getWithTranslationsFunc != nil {
return m.getWithTranslationsFunc(ctx, id) return m.getWithTranslationsFunc(ctx, id)

View File

@ -11,6 +11,15 @@ type mockAnalyticsService struct {
updateWorkSentimentFunc func(ctx context.Context, workID uint) error updateWorkSentimentFunc func(ctx context.Context, workID uint) error
updateTranslationReadingTimeFunc func(ctx context.Context, translationID uint) error updateTranslationReadingTimeFunc func(ctx context.Context, translationID uint) error
updateTranslationSentimentFunc 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 { func (m *mockAnalyticsService) UpdateWorkReadingTime(ctx context.Context, workID uint) error {
@ -78,6 +87,9 @@ func (m *mockAnalyticsService) IncrementTranslationShares(ctx context.Context, t
return nil return nil
} }
func (m *mockAnalyticsService) GetOrCreateWorkStats(ctx context.Context, workID uint) (*domain.WorkStats, error) { func (m *mockAnalyticsService) GetOrCreateWorkStats(ctx context.Context, workID uint) (*domain.WorkStats, error) {
if m.getOrCreateWorkStatsFunc != nil {
return m.getOrCreateWorkStatsFunc(ctx, workID)
}
return nil, nil return nil, nil
} }
func (m *mockAnalyticsService) GetOrCreateTranslationStats(ctx context.Context, translationID uint) (*domain.TranslationStats, error) { func (m *mockAnalyticsService) GetOrCreateTranslationStats(ctx context.Context, translationID uint) (*domain.TranslationStats, error) {

View File

@ -45,6 +45,22 @@ func (s *WorkQueriesSuite) TestGetWorkByID_ZeroID() {
assert.Nil(s.T(), w) 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() { func (s *WorkQueriesSuite) TestListWorks_Success() {
domainWorks := &domain.PaginatedResult[domain.Work]{ domainWorks := &domain.PaginatedResult[domain.Work]{
Items: []domain.Work{ Items: []domain.Work{

View File

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

View File

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

View File

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

View File

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

View File

@ -127,7 +127,7 @@ func (s *CopyrightRepositoryTestSuite) TestGetTranslationByLanguage() {
_, err := s.repo.GetTranslationByLanguage(context.Background(), copyrightID, languageCode) _, err := s.repo.GetTranslationByLanguage(context.Background(), copyrightID, languageCode)
s.Require().Error(err) s.Require().Error(err)
s.Require().Equal(sql.ErrEntityNotFound, err) s.Require().Contains(err.Error(), "entity not found")
}) })
} }

View File

@ -581,8 +581,10 @@ type CopyrightClaim struct {
ClaimDate time.Time `gorm:"not null"` ClaimDate time.Time `gorm:"not null"`
Resolution string `gorm:"type:text"` Resolution string `gorm:"type:text"`
ResolvedAt *time.Time ResolvedAt *time.Time
UserID *uint UserID *uint
User *User `gorm:"foreignKey:UserID"` User *User `gorm:"foreignKey:UserID"`
WorkID *uint
Work *Work `gorm:"foreignKey:WorkID"`
} }
type MonetizationType string type MonetizationType string
const ( const (

View File

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