tercul-backend/internal/adapters/graphql/user_resolvers_unit_test.go
google-labs-jules[bot] c2e9a118e2 feat(testing): Increase test coverage and fix authz bugs
This commit significantly increases the test coverage across the application and fixes several underlying bugs that were discovered while writing the new tests.

The key changes include:

- **New Tests:** Added extensive integration and unit tests for GraphQL resolvers, application services, and data repositories, substantially increasing the test coverage for packages like `graphql`, `user`, `translation`, and `analytics`.

- **Authorization Bug Fixes:**
  - Fixed a critical bug where a user creating a `Work` was not correctly associated as its author, causing subsequent permission failures.
  - Corrected the authorization logic in `authz.Service` to properly check for entity ownership by non-admin users.

- **Test Refactoring:**
  - Refactored numerous test suites to use `testify/mock` instead of manual mocks, improving test clarity and maintainability.
  - Isolated integration tests by creating a fresh admin user and token for each test run, eliminating test pollution.
  - Centralized domain errors into `internal/domain/errors.go` and updated repositories to use them, making error handling more consistent.

- **Code Quality Improvements:**
  - Replaced manual mock implementations with `testify/mock` for better consistency.
  - Cleaned up redundant and outdated test files.

These changes stabilize the test suite, improve the overall quality of the codebase, and move the project closer to the goal of 80% test coverage.
2025-10-09 07:03:45 +00:00

637 lines
20 KiB
Go

package graphql
import (
"context"
"fmt"
"testing"
"tercul/internal/app"
"tercul/internal/app/authz"
"tercul/internal/app/user"
"tercul/internal/domain"
"tercul/internal/adapters/graphql/model"
platform_auth "tercul/internal/platform/auth"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/suite"
"gorm.io/gorm"
)
// mockUserRepositoryForUserResolver is a mock for the user repository.
type mockUserRepositoryForUserResolver struct{ mock.Mock }
// Implement domain.UserRepository
func (m *mockUserRepositoryForUserResolver) GetByID(ctx context.Context, id uint) (*domain.User, error) {
args := m.Called(ctx, id)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*domain.User), args.Error(1)
}
func (m *mockUserRepositoryForUserResolver) FindByUsername(ctx context.Context, username string) (*domain.User, error) {
args := m.Called(ctx, username)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*domain.User), args.Error(1)
}
func (m *mockUserRepositoryForUserResolver) FindByEmail(ctx context.Context, email string) (*domain.User, error) {
args := m.Called(ctx, email)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*domain.User), args.Error(1)
}
func (m *mockUserRepositoryForUserResolver) ListByRole(ctx context.Context, role domain.UserRole) ([]domain.User, error) {
args := m.Called(ctx, role)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).([]domain.User), args.Error(1)
}
func (m *mockUserRepositoryForUserResolver) Create(ctx context.Context, entity *domain.User) error {
return m.Called(ctx, entity).Error(0)
}
func (m *mockUserRepositoryForUserResolver) CreateInTx(ctx context.Context, tx *gorm.DB, entity *domain.User) error {
return nil
}
func (m *mockUserRepositoryForUserResolver) GetByIDWithOptions(ctx context.Context, id uint, options *domain.QueryOptions) (*domain.User, error) {
return nil, nil
}
func (m *mockUserRepositoryForUserResolver) Update(ctx context.Context, entity *domain.User) error {
return m.Called(ctx, entity).Error(0)
}
func (m *mockUserRepositoryForUserResolver) UpdateInTx(ctx context.Context, tx *gorm.DB, entity *domain.User) error {
return nil
}
func (m *mockUserRepositoryForUserResolver) Delete(ctx context.Context, id uint) error {
return m.Called(ctx, id).Error(0)
}
func (m *mockUserRepositoryForUserResolver) DeleteInTx(ctx context.Context, tx *gorm.DB, id uint) error {
return nil
}
func (m *mockUserRepositoryForUserResolver) List(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[domain.User], error) {
return nil, nil
}
func (m *mockUserRepositoryForUserResolver) ListWithOptions(ctx context.Context, options *domain.QueryOptions) ([]domain.User, error) {
return nil, nil
}
func (m *mockUserRepositoryForUserResolver) ListAll(ctx context.Context) ([]domain.User, error) {
args := m.Called(ctx)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).([]domain.User), args.Error(1)
}
func (m *mockUserRepositoryForUserResolver) Count(ctx context.Context) (int64, error) { return 0, nil }
func (m *mockUserRepositoryForUserResolver) CountWithOptions(ctx context.Context, options *domain.QueryOptions) (int64, error) {
return 0, nil
}
func (m *mockUserRepositoryForUserResolver) FindWithPreload(ctx context.Context, preloads []string, id uint) (*domain.User, error) {
return nil, nil
}
func (m *mockUserRepositoryForUserResolver) GetAllForSync(ctx context.Context, batchSize, offset int) ([]domain.User, error) {
return nil, nil
}
func (m *mockUserRepositoryForUserResolver) Exists(ctx context.Context, id uint) (bool, error) {
return false, nil
}
func (m *mockUserRepositoryForUserResolver) BeginTx(ctx context.Context) (*gorm.DB, error) {
return nil, nil
}
func (m *mockUserRepositoryForUserResolver) WithTx(ctx context.Context, fn func(tx *gorm.DB) error) error {
return nil
}
// mockUserProfileRepository is a mock for the user profile repository.
type mockUserProfileRepository struct{ mock.Mock }
// Implement domain.UserProfileRepository
func (m *mockUserProfileRepository) GetByUserID(ctx context.Context, userID uint) (*domain.UserProfile, error) {
args := m.Called(ctx, userID)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*domain.UserProfile), args.Error(1)
}
// Implement BaseRepository methods for UserProfile
func (m *mockUserProfileRepository) Create(ctx context.Context, entity *domain.UserProfile) error {
return nil
}
func (m *mockUserProfileRepository) CreateInTx(ctx context.Context, tx *gorm.DB, entity *domain.UserProfile) error {
return nil
}
func (m *mockUserProfileRepository) GetByID(ctx context.Context, id uint) (*domain.UserProfile, error) {
return nil, nil
}
func (m *mockUserProfileRepository) GetByIDWithOptions(ctx context.Context, id uint, options *domain.QueryOptions) (*domain.UserProfile, error) {
return nil, nil
}
func (m *mockUserProfileRepository) Update(ctx context.Context, entity *domain.UserProfile) error {
return nil
}
func (m *mockUserProfileRepository) UpdateInTx(ctx context.Context, tx *gorm.DB, entity *domain.UserProfile) error {
return nil
}
func (m *mockUserProfileRepository) Delete(ctx context.Context, id uint) error { return nil }
func (m *mockUserProfileRepository) DeleteInTx(ctx context.Context, tx *gorm.DB, id uint) error {
return nil
}
func (m *mockUserProfileRepository) List(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[domain.UserProfile], error) {
return nil, nil
}
func (m *mockUserProfileRepository) ListWithOptions(ctx context.Context, options *domain.QueryOptions) ([]domain.UserProfile, error) {
return nil, nil
}
func (m *mockUserProfileRepository) ListAll(ctx context.Context) ([]domain.UserProfile, error) {
return nil, nil
}
func (m *mockUserProfileRepository) Count(ctx context.Context) (int64, error) { return 0, nil }
func (m *mockUserProfileRepository) CountWithOptions(ctx context.Context, options *domain.QueryOptions) (int64, error) {
return 0, nil
}
func (m *mockUserProfileRepository) FindWithPreload(ctx context.Context, preloads []string, id uint) (*domain.UserProfile, error) {
return nil, nil
}
func (m *mockUserProfileRepository) GetAllForSync(ctx context.Context, batchSize, offset int) ([]domain.UserProfile, error) {
return nil, nil
}
func (m *mockUserProfileRepository) Exists(ctx context.Context, id uint) (bool, error) {
return false, nil
}
func (m *mockUserProfileRepository) BeginTx(ctx context.Context) (*gorm.DB, error) { return nil, nil }
func (m *mockUserProfileRepository) WithTx(ctx context.Context, fn func(tx *gorm.DB) error) error {
return nil
}
// UserResolversUnitSuite is a unit test suite for the user resolvers.
type UserResolversUnitSuite struct {
suite.Suite
resolver *Resolver
mockUserRepo *mockUserRepositoryForUserResolver
mockUserProfileRepo *mockUserProfileRepository
}
// SetupTest sets up the test suite
func (s *UserResolversUnitSuite) SetupTest() {
s.mockUserRepo = new(mockUserRepositoryForUserResolver)
s.mockUserProfileRepo = new(mockUserProfileRepository)
// The authz service dependencies are not needed for the user commands being tested.
authzSvc := authz.NewService(nil, nil, s.mockUserRepo, nil)
userCommands := user.NewUserCommands(s.mockUserRepo, authzSvc)
userQueries := user.NewUserQueries(s.mockUserRepo, s.mockUserProfileRepo)
userService := &user.Service{
Commands: userCommands,
Queries: userQueries,
}
s.resolver = &Resolver{
App: &app.Application{
User: userService,
},
}
}
// TestUserResolversUnitSuite runs the test suite
func TestUserResolversUnitSuite(t *testing.T) {
suite.Run(t, new(UserResolversUnitSuite))
}
func (s *UserResolversUnitSuite) TestUserQuery() {
s.Run("Success", func() {
s.SetupTest()
userID := uint(1)
userIDStr := "1"
ctx := context.Background()
expectedUser := &domain.User{
Username: "testuser",
Email: "test@test.com",
Role: domain.UserRoleReader,
}
expectedUser.ID = userID
s.mockUserRepo.On("GetByID", mock.Anything, userID).Return(expectedUser, nil).Once()
gqlUser, err := s.resolver.Query().User(ctx, userIDStr)
s.Require().NoError(err)
s.Require().NotNil(gqlUser)
s.Equal(userIDStr, gqlUser.ID)
s.Equal(expectedUser.Username, gqlUser.Username)
s.mockUserRepo.AssertExpectations(s.T())
})
s.Run("Not Found", func() {
s.SetupTest()
userID := uint(99)
userIDStr := "99"
ctx := context.Background()
s.mockUserRepo.On("GetByID", mock.Anything, userID).Return(nil, domain.ErrEntityNotFound).Once()
gqlUser, err := s.resolver.Query().User(ctx, userIDStr)
s.Require().Error(err) // The resolver should propagate the error
s.Require().Nil(gqlUser)
s.mockUserRepo.AssertExpectations(s.T())
})
s.Run("Invalid ID", func() {
s.SetupTest()
ctx := context.Background()
_, err := s.resolver.Query().User(ctx, "invalid")
s.Require().Error(err)
})
}
func (s *UserResolversUnitSuite) TestUserProfileQuery() {
s.Run("Success", func() {
s.SetupTest()
userID := uint(1)
userIDStr := "1"
ctx := context.Background()
expectedProfile := &domain.UserProfile{
UserID: userID,
PhoneNumber: "12345",
}
expectedProfile.ID = 1
expectedUser := &domain.User{
Username: "testuser",
}
expectedUser.ID = userID
s.mockUserProfileRepo.On("GetByUserID", mock.Anything, userID).Return(expectedProfile, nil).Once()
s.mockUserRepo.On("GetByID", mock.Anything, userID).Return(expectedUser, nil).Once()
gqlProfile, err := s.resolver.Query().UserProfile(ctx, userIDStr)
s.Require().NoError(err)
s.Require().NotNil(gqlProfile)
s.Equal(userIDStr, gqlProfile.UserID)
s.Equal(&expectedProfile.PhoneNumber, gqlProfile.PhoneNumber)
s.mockUserProfileRepo.AssertExpectations(s.T())
s.mockUserRepo.AssertExpectations(s.T())
})
s.Run("Profile Not Found", func() {
s.SetupTest()
userID := uint(99)
userIDStr := "99"
ctx := context.Background()
s.mockUserProfileRepo.On("GetByUserID", mock.Anything, userID).Return(nil, domain.ErrEntityNotFound).Once()
gqlProfile, err := s.resolver.Query().UserProfile(ctx, userIDStr)
s.Require().Error(err)
s.Require().Nil(gqlProfile)
s.mockUserProfileRepo.AssertExpectations(s.T())
})
s.Run("User Not Found for profile", func() {
s.SetupTest()
userID := uint(1)
userIDStr := "1"
ctx := context.Background()
expectedProfile := &domain.UserProfile{
UserID: userID,
}
expectedProfile.ID = 1
s.mockUserProfileRepo.On("GetByUserID", mock.Anything, userID).Return(expectedProfile, nil).Once()
s.mockUserRepo.On("GetByID", mock.Anything, userID).Return(nil, domain.ErrEntityNotFound).Once()
_, err := s.resolver.Query().UserProfile(ctx, userIDStr)
s.Require().Error(err)
s.mockUserProfileRepo.AssertExpectations(s.T())
s.mockUserRepo.AssertExpectations(s.T())
})
}
func (s *UserResolversUnitSuite) TestUpdateProfileMutation() {
s.Run("Success", func() {
s.SetupTest()
actorID := uint(1)
ctx := context.WithValue(context.Background(), platform_auth.ClaimsContextKey, &platform_auth.Claims{
UserID: actorID,
Role: string(domain.UserRoleReader),
})
displayName := "New Name"
input := model.UserInput{DisplayName: &displayName}
originalUser := &domain.User{DisplayName: "Old Name"}
originalUser.ID = actorID
s.mockUserRepo.On("GetByID", mock.Anything, actorID).Return(originalUser, nil).Once()
s.mockUserRepo.On("Update", mock.Anything, mock.MatchedBy(func(u *domain.User) bool {
return u.ID == actorID && u.DisplayName == displayName
})).Return(nil).Once()
updatedUser, err := s.resolver.Mutation().UpdateProfile(ctx, input)
s.Require().NoError(err)
s.Require().NotNil(updatedUser)
s.Equal(displayName, *updatedUser.DisplayName)
s.mockUserRepo.AssertExpectations(s.T())
})
s.Run("Unauthorized", func() {
s.SetupTest()
ctx := context.Background() // no user
displayName := "New Name"
input := model.UserInput{DisplayName: &displayName}
_, err := s.resolver.Mutation().UpdateProfile(ctx, input)
s.Require().Error(err)
s.ErrorIs(err, domain.ErrUnauthorized)
})
}
func (s *UserResolversUnitSuite) TestUpdateUserMutation() {
s.Run("Success as self", func() {
s.SetupTest()
actorID := uint(1)
targetID := uint(1)
targetIDStr := "1"
username := "new_username"
ctx := context.WithValue(context.Background(), platform_auth.ClaimsContextKey, &platform_auth.Claims{
UserID: actorID,
Role: string(domain.UserRoleReader),
})
input := model.UserInput{Username: &username}
originalUser := &domain.User{Username: "old_username"}
originalUser.ID = targetID
s.mockUserRepo.On("GetByID", mock.Anything, targetID).Return(originalUser, nil).Once()
s.mockUserRepo.On("Update", mock.Anything, mock.MatchedBy(func(u *domain.User) bool {
return u.ID == targetID && u.Username == username
})).Return(nil).Once()
updatedUser, err := s.resolver.Mutation().UpdateUser(ctx, targetIDStr, input)
s.Require().NoError(err)
s.Require().NotNil(updatedUser)
s.Equal(username, updatedUser.Username)
s.mockUserRepo.AssertExpectations(s.T())
})
s.Run("Success as admin", func() {
s.SetupTest()
actorID := uint(99) // Admin
targetID := uint(1)
targetIDStr := "1"
username := "new_username"
ctx := context.WithValue(context.Background(), platform_auth.ClaimsContextKey, &platform_auth.Claims{
UserID: actorID,
Role: string(domain.UserRoleAdmin),
})
input := model.UserInput{Username: &username}
originalUser := &domain.User{Username: "old_username"}
originalUser.ID = targetID
s.mockUserRepo.On("GetByID", mock.Anything, targetID).Return(originalUser, nil).Once()
s.mockUserRepo.On("Update", mock.Anything, mock.MatchedBy(func(u *domain.User) bool {
return u.ID == targetID && u.Username == username
})).Return(nil).Once()
updatedUser, err := s.resolver.Mutation().UpdateUser(ctx, targetIDStr, input)
s.Require().NoError(err)
s.Require().NotNil(updatedUser)
s.Equal(username, updatedUser.Username)
s.mockUserRepo.AssertExpectations(s.T())
})
s.Run("Forbidden", func() {
s.SetupTest()
actorID := uint(2)
targetIDStr := "1"
username := "new_username"
ctx := context.WithValue(context.Background(), platform_auth.ClaimsContextKey, &platform_auth.Claims{
UserID: actorID,
Role: string(domain.UserRoleReader),
})
input := model.UserInput{Username: &username}
_, err := s.resolver.Mutation().UpdateUser(ctx, targetIDStr, input)
s.Require().Error(err)
s.ErrorIs(err, domain.ErrForbidden)
s.mockUserRepo.AssertNotCalled(s.T(), "GetByID")
s.mockUserRepo.AssertNotCalled(s.T(), "Update")
})
s.Run("User not found", func() {
s.SetupTest()
actorID := uint(1)
targetID := uint(1)
targetIDStr := "1"
username := "new_username"
ctx := context.WithValue(context.Background(), platform_auth.ClaimsContextKey, &platform_auth.Claims{
UserID: actorID,
Role: string(domain.UserRoleReader),
})
input := model.UserInput{Username: &username}
s.mockUserRepo.On("GetByID", mock.Anything, targetID).Return(nil, domain.ErrEntityNotFound).Once()
_, err := s.resolver.Mutation().UpdateUser(ctx, targetIDStr, input)
s.Require().Error(err)
s.ErrorIs(err, domain.ErrEntityNotFound)
s.mockUserRepo.AssertExpectations(s.T())
s.mockUserRepo.AssertNotCalled(s.T(), "Update")
})
}
func (s *UserResolversUnitSuite) TestUsersQuery() {
s.Run("Success without role", func() {
s.SetupTest()
ctx := context.Background()
expectedUsers := []domain.User{
{Username: "user1"},
{Username: "user2"},
}
s.mockUserRepo.On("ListAll", mock.Anything).Return(expectedUsers, nil).Once()
users, err := s.resolver.Query().Users(ctx, nil, nil, nil)
s.Require().NoError(err)
s.Len(users, 2)
s.mockUserRepo.AssertExpectations(s.T())
})
s.Run("Success with role", func() {
s.SetupTest()
ctx := context.Background()
role := domain.UserRoleAdmin
modelRole := model.UserRoleAdmin
expectedUsers := []domain.User{
{Username: "admin1", Role: role},
}
s.mockUserRepo.On("ListByRole", mock.Anything, role).Return(expectedUsers, nil).Once()
users, err := s.resolver.Query().Users(ctx, nil, nil, &modelRole)
s.Require().NoError(err)
s.Len(users, 1)
s.Equal(model.UserRoleAdmin, users[0].Role)
s.mockUserRepo.AssertExpectations(s.T())
})
}
func (s *UserResolversUnitSuite) TestUserByEmailQuery() {
s.Run("Success", func() {
s.SetupTest()
email := "test@test.com"
ctx := context.Background()
expectedUser := &domain.User{
Username: "testuser",
Email: email,
Role: domain.UserRoleReader,
}
expectedUser.ID = 1
s.mockUserRepo.On("FindByEmail", mock.Anything, email).Return(expectedUser, nil).Once()
gqlUser, err := s.resolver.Query().UserByEmail(ctx, email)
s.Require().NoError(err)
s.Require().NotNil(gqlUser)
s.Equal(email, gqlUser.Email)
s.mockUserRepo.AssertExpectations(s.T())
})
}
func (s *UserResolversUnitSuite) TestUserByUsernameQuery() {
s.Run("Success", func() {
s.SetupTest()
username := "testuser"
ctx := context.Background()
expectedUser := &domain.User{
Username: username,
Email: "test@test.com",
Role: domain.UserRoleReader,
}
expectedUser.ID = 1
s.mockUserRepo.On("FindByUsername", mock.Anything, username).Return(expectedUser, nil).Once()
gqlUser, err := s.resolver.Query().UserByUsername(ctx, username)
s.Require().NoError(err)
s.Require().NotNil(gqlUser)
s.Equal(username, gqlUser.Username)
s.mockUserRepo.AssertExpectations(s.T())
})
}
func (s *UserResolversUnitSuite) TestMeQuery() {
s.Run("Success", func() {
s.SetupTest()
userID := uint(1)
ctx := platform_auth.ContextWithUserID(context.Background(), userID)
expectedUser := &domain.User{
Username: "testuser",
Email: "test@test.com",
Role: domain.UserRoleReader,
}
expectedUser.ID = userID
s.mockUserRepo.On("GetByID", mock.Anything, userID).Return(expectedUser, nil).Once()
gqlUser, err := s.resolver.Query().Me(ctx)
s.Require().NoError(err)
s.Require().NotNil(gqlUser)
s.Equal(fmt.Sprintf("%d", userID), gqlUser.ID)
s.mockUserRepo.AssertExpectations(s.T())
})
s.Run("Unauthorized", func() {
s.SetupTest()
ctx := context.Background() // No user in context
_, err := s.resolver.Query().Me(ctx)
s.Require().Error(err)
s.Equal(domain.ErrUnauthorized, err)
})
}
func (s *UserResolversUnitSuite) TestDeleteUserMutation() {
s.Run("Success as self", func() {
s.SetupTest()
actorID := uint(1)
targetID := uint(1)
targetIDStr := "1"
ctx := context.WithValue(context.Background(), platform_auth.ClaimsContextKey, &platform_auth.Claims{
UserID: actorID,
Role: string(domain.UserRoleReader),
})
s.mockUserRepo.On("Delete", mock.Anything, targetID).Return(nil).Once()
ok, err := s.resolver.Mutation().DeleteUser(ctx, targetIDStr)
s.Require().NoError(err)
s.True(ok)
s.mockUserRepo.AssertExpectations(s.T())
})
s.Run("Success as admin", func() {
s.SetupTest()
actorID := uint(99) // Admin
targetID := uint(1)
targetIDStr := "1"
ctx := context.WithValue(context.Background(), platform_auth.ClaimsContextKey, &platform_auth.Claims{
UserID: actorID,
Role: string(domain.UserRoleAdmin),
})
s.mockUserRepo.On("Delete", mock.Anything, targetID).Return(nil).Once()
ok, err := s.resolver.Mutation().DeleteUser(ctx, targetIDStr)
s.Require().NoError(err)
s.True(ok)
s.mockUserRepo.AssertExpectations(s.T())
})
s.Run("Forbidden", func() {
s.SetupTest()
actorID := uint(2)
targetIDStr := "1"
ctx := context.WithValue(context.Background(), platform_auth.ClaimsContextKey, &platform_auth.Claims{
UserID: actorID,
Role: string(domain.UserRoleReader),
})
ok, err := s.resolver.Mutation().DeleteUser(ctx, targetIDStr)
s.Require().Error(err)
s.ErrorIs(err, domain.ErrForbidden)
s.False(ok)
s.mockUserRepo.AssertNotCalled(s.T(), "Delete")
})
s.Run("Invalid ID", func() {
s.SetupTest()
ctx := context.Background()
_, err := s.resolver.Mutation().DeleteUser(ctx, "invalid")
s.Require().Error(err)
})
}