mirror of
https://github.com/SamyRai/tercul-backend.git
synced 2025-12-27 02:51:34 +00:00
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.
314 lines
9.1 KiB
Go
314 lines
9.1 KiB
Go
package user
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"testing"
|
|
|
|
"tercul/internal/app/authz"
|
|
"tercul/internal/domain"
|
|
platform_auth "tercul/internal/platform/auth"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/mock"
|
|
"github.com/stretchr/testify/suite"
|
|
)
|
|
|
|
type UserCommandsSuite struct {
|
|
suite.Suite
|
|
repo *mockUserRepository
|
|
authzSvc *authz.Service
|
|
commands *UserCommands
|
|
}
|
|
|
|
func (s *UserCommandsSuite) SetupTest() {
|
|
s.repo = new(mockUserRepository)
|
|
// None of the repos are used by the authz checks in these command tests
|
|
s.authzSvc = authz.NewService(nil, nil, nil, nil)
|
|
s.commands = NewUserCommands(s.repo, s.authzSvc)
|
|
}
|
|
|
|
func TestUserCommandsSuite(t *testing.T) {
|
|
suite.Run(t, new(UserCommandsSuite))
|
|
}
|
|
|
|
func (s *UserCommandsSuite) TestUpdateUser_Success_Self() {
|
|
// Arrange
|
|
ctx := platform_auth.ContextWithUserID(context.Background(), 1)
|
|
input := UpdateUserInput{ID: 1, Username: strPtr("new_username")}
|
|
|
|
s.repo.On("GetByID", ctx, uint(1)).Return(&domain.User{BaseModel: domain.BaseModel{ID: 1}}, nil)
|
|
s.repo.On("Update", ctx, mock.AnythingOfType("*domain.User")).Return(nil)
|
|
|
|
// Act
|
|
updatedUser, err := s.commands.UpdateUser(ctx, input)
|
|
|
|
// Assert
|
|
assert.NoError(s.T(), err)
|
|
assert.NotNil(s.T(), updatedUser)
|
|
assert.Equal(s.T(), "new_username", updatedUser.Username)
|
|
s.repo.AssertExpectations(s.T())
|
|
}
|
|
|
|
func (s *UserCommandsSuite) TestUpdateUser_Success_Admin() {
|
|
// Arrange
|
|
ctx := platform_auth.ContextWithAdminUser(context.Background(), 99) // Admin user
|
|
input := UpdateUserInput{ID: 1, Username: strPtr("new_username_by_admin")}
|
|
|
|
s.repo.On("GetByID", ctx, uint(1)).Return(&domain.User{BaseModel: domain.BaseModel{ID: 1}}, nil)
|
|
s.repo.On("Update", ctx, mock.AnythingOfType("*domain.User")).Return(nil)
|
|
|
|
// Act
|
|
updatedUser, err := s.commands.UpdateUser(ctx, input)
|
|
|
|
// Assert
|
|
assert.NoError(s.T(), err)
|
|
assert.NotNil(s.T(), updatedUser)
|
|
assert.Equal(s.T(), "new_username_by_admin", updatedUser.Username)
|
|
s.repo.AssertExpectations(s.T())
|
|
}
|
|
|
|
func (s *UserCommandsSuite) TestUpdateUser_Forbidden() {
|
|
// Arrange
|
|
ctx := platform_auth.ContextWithUserID(context.Background(), 2) // Different user
|
|
input := UpdateUserInput{ID: 1, Username: strPtr("forbidden_username")}
|
|
|
|
// No need to mock GetByID, as the auth check happens first.
|
|
|
|
// Act
|
|
_, err := s.commands.UpdateUser(ctx, input)
|
|
|
|
// Assert
|
|
assert.Error(s.T(), err)
|
|
assert.ErrorIs(s.T(), err, domain.ErrForbidden)
|
|
s.repo.AssertNotCalled(s.T(), "GetByID", mock.Anything, mock.Anything)
|
|
}
|
|
|
|
func (s *UserCommandsSuite) TestUpdateUser_Unauthorized() {
|
|
// Arrange
|
|
ctx := context.Background() // No user in context
|
|
input := UpdateUserInput{ID: 1, Username: strPtr("unauthorized_username")}
|
|
|
|
// Act
|
|
_, err := s.commands.UpdateUser(ctx, input)
|
|
|
|
// Assert
|
|
assert.Error(s.T(), err)
|
|
assert.ErrorIs(s.T(), err, domain.ErrUnauthorized)
|
|
s.repo.AssertNotCalled(s.T(), "GetByID", mock.Anything, mock.Anything)
|
|
}
|
|
|
|
// Helper to get a pointer to a string
|
|
func strPtr(s string) *string {
|
|
return &s
|
|
}
|
|
|
|
func (s *UserCommandsSuite) TestCreateUser() {
|
|
// Arrange
|
|
ctx := context.Background()
|
|
input := CreateUserInput{
|
|
Username: "newuser",
|
|
Email: "new@example.com",
|
|
Password: "password",
|
|
}
|
|
s.repo.On("Create", ctx, mock.AnythingOfType("*domain.User")).Return(nil)
|
|
|
|
// Act
|
|
user, err := s.commands.CreateUser(ctx, input)
|
|
|
|
// Assert
|
|
assert.NoError(s.T(), err)
|
|
assert.NotNil(s.T(), user)
|
|
assert.Equal(s.T(), "newuser", user.Username)
|
|
s.repo.AssertExpectations(s.T())
|
|
}
|
|
|
|
func (s *UserCommandsSuite) TestDeleteUser_Success() {
|
|
// Arrange
|
|
ctx := platform_auth.ContextWithAdminUser(context.Background(), 99)
|
|
s.repo.On("Delete", ctx, uint(1)).Return(nil)
|
|
|
|
// Act
|
|
err := s.commands.DeleteUser(ctx, 1)
|
|
|
|
// Assert
|
|
assert.NoError(s.T(), err)
|
|
s.repo.AssertExpectations(s.T())
|
|
}
|
|
|
|
func (s *UserCommandsSuite) TestDeleteUser_Forbidden() {
|
|
// Arrange
|
|
ctx := platform_auth.ContextWithUserID(context.Background(), 2) // Non-admin user
|
|
|
|
// Act
|
|
err := s.commands.DeleteUser(ctx, 1)
|
|
|
|
// Assert
|
|
assert.Error(s.T(), err)
|
|
assert.ErrorIs(s.T(), err, domain.ErrForbidden)
|
|
s.repo.AssertNotCalled(s.T(), "Delete", mock.Anything, mock.Anything)
|
|
}
|
|
|
|
func (s *UserCommandsSuite) TestUpdateUser_NotFound() {
|
|
// Arrange
|
|
ctx := platform_auth.ContextWithUserID(context.Background(), 1)
|
|
input := UpdateUserInput{ID: 1, Username: strPtr("new_username")}
|
|
|
|
s.repo.On("GetByID", ctx, uint(1)).Return(nil, domain.ErrEntityNotFound)
|
|
|
|
// Act
|
|
_, err := s.commands.UpdateUser(ctx, input)
|
|
|
|
// Assert
|
|
assert.Error(s.T(), err)
|
|
assert.ErrorIs(s.T(), err, domain.ErrEntityNotFound)
|
|
s.repo.AssertExpectations(s.T())
|
|
}
|
|
|
|
func (s *UserCommandsSuite) TestCreateUser_Fails() {
|
|
// Arrange
|
|
ctx := context.Background()
|
|
input := CreateUserInput{
|
|
Username: "newuser",
|
|
Email: "new@example.com",
|
|
Password: "password",
|
|
}
|
|
s.repo.On("Create", ctx, mock.AnythingOfType("*domain.User")).Return(errors.New("db error"))
|
|
|
|
// Act
|
|
_, err := s.commands.CreateUser(ctx, input)
|
|
|
|
// Assert
|
|
assert.Error(s.T(), err)
|
|
assert.EqualError(s.T(), err, "db error")
|
|
s.repo.AssertExpectations(s.T())
|
|
}
|
|
|
|
func (s *UserCommandsSuite) TestDeleteUser_Unauthorized() {
|
|
// Arrange
|
|
ctx := context.Background() // No user in context
|
|
|
|
// Act
|
|
err := s.commands.DeleteUser(ctx, 1)
|
|
|
|
// Assert
|
|
assert.Error(s.T(), err)
|
|
assert.ErrorIs(s.T(), err, domain.ErrUnauthorized)
|
|
s.repo.AssertNotCalled(s.T(), "Delete", mock.Anything, mock.Anything)
|
|
}
|
|
|
|
func (s *UserCommandsSuite) TestDeleteUser_AuthzFails() {
|
|
// Arrange
|
|
// This test requires a mock for the authz service, which is not currently mocked.
|
|
// For now, this highlights a gap. To properly test this, we would need to
|
|
// inject a mockable authz service.
|
|
// Since the current authz service is a concrete implementation, we can't easily
|
|
// simulate an error from `CanUpdateUser`. We will skip this test for now
|
|
// as it requires a larger refactoring of the authz service dependency.
|
|
s.T().Skip("Skipping test for authz failure as it requires mockable authz service")
|
|
}
|
|
|
|
func (s *UserCommandsSuite) TestUpdateUser_UpdateFails() {
|
|
// Arrange
|
|
ctx := platform_auth.ContextWithUserID(context.Background(), 1)
|
|
input := UpdateUserInput{ID: 1, Username: strPtr("new_username")}
|
|
testUser := &domain.User{BaseModel: domain.BaseModel{ID: 1}}
|
|
|
|
s.repo.On("GetByID", ctx, uint(1)).Return(testUser, nil)
|
|
s.repo.On("Update", ctx, mock.AnythingOfType("*domain.User")).Return(errors.New("db error"))
|
|
|
|
// Act
|
|
_, err := s.commands.UpdateUser(ctx, input)
|
|
|
|
// Assert
|
|
assert.Error(s.T(), err)
|
|
assert.EqualError(s.T(), err, "db error")
|
|
s.repo.AssertExpectations(s.T())
|
|
}
|
|
|
|
func (s *UserCommandsSuite) TestUpdateUser_SetPasswordFails() {
|
|
// Arrange
|
|
ctx := platform_auth.ContextWithUserID(context.Background(), 1)
|
|
emptyPassword := ""
|
|
input := UpdateUserInput{ID: 1, Password: &emptyPassword}
|
|
testUser := &domain.User{BaseModel: domain.BaseModel{ID: 1}}
|
|
|
|
s.repo.On("GetByID", ctx, uint(1)).Return(testUser, nil)
|
|
|
|
// Act
|
|
_, err := s.commands.UpdateUser(ctx, input)
|
|
|
|
// Assert
|
|
assert.Error(s.T(), err)
|
|
assert.EqualError(s.T(), err, "password cannot be empty")
|
|
s.repo.AssertExpectations(s.T())
|
|
}
|
|
|
|
func (s *UserCommandsSuite) TestUpdateUser_AllFields() {
|
|
// Arrange
|
|
ctx := platform_auth.ContextWithUserID(context.Background(), 1)
|
|
countryID := uint(10)
|
|
cityID := uint(20)
|
|
addressID := uint(30)
|
|
newRole := domain.UserRoleEditor
|
|
verified := true
|
|
active := false
|
|
|
|
input := UpdateUserInput{
|
|
ID: 1,
|
|
Username: strPtr("all_fields"),
|
|
Email: strPtr("all@fields.com"),
|
|
Password: strPtr("new_password"),
|
|
FirstName: strPtr("First"),
|
|
LastName: strPtr("Last"),
|
|
DisplayName: strPtr("Display"),
|
|
Bio: strPtr("Bio"),
|
|
AvatarURL: strPtr("http://avatar.url"),
|
|
Role: &newRole,
|
|
Verified: &verified,
|
|
Active: &active,
|
|
CountryID: &countryID,
|
|
CityID: &cityID,
|
|
AddressID: &addressID,
|
|
}
|
|
|
|
s.repo.On("GetByID", ctx, uint(1)).Return(&domain.User{BaseModel: domain.BaseModel{ID: 1}}, nil)
|
|
s.repo.On("Update", ctx, mock.AnythingOfType("*domain.User")).Run(func(args mock.Arguments) {
|
|
userArg := args.Get(1).(*domain.User)
|
|
assert.Equal(s.T(), "all_fields", userArg.Username)
|
|
assert.Equal(s.T(), "all@fields.com", userArg.Email)
|
|
assert.True(s.T(), userArg.CheckPassword("new_password"))
|
|
assert.Equal(s.T(), "First", userArg.FirstName)
|
|
assert.Equal(s.T(), "Last", userArg.LastName)
|
|
assert.Equal(s.T(), "Display", userArg.DisplayName)
|
|
assert.Equal(s.T(), "Bio", userArg.Bio)
|
|
assert.Equal(s.T(), "http://avatar.url", userArg.AvatarURL)
|
|
assert.Equal(s.T(), newRole, userArg.Role)
|
|
assert.Equal(s.T(), verified, userArg.Verified)
|
|
assert.Equal(s.T(), active, userArg.Active)
|
|
assert.Equal(s.T(), &countryID, userArg.CountryID)
|
|
assert.Equal(s.T(), &cityID, userArg.CityID)
|
|
assert.Equal(s.T(), &addressID, userArg.AddressID)
|
|
}).Return(nil)
|
|
|
|
// Act
|
|
_, err := s.commands.UpdateUser(ctx, input)
|
|
|
|
// Assert
|
|
assert.NoError(s.T(), err)
|
|
s.repo.AssertExpectations(s.T())
|
|
}
|
|
|
|
func (s *UserCommandsSuite) TestDeleteUser_NotFound() {
|
|
// Arrange
|
|
ctx := platform_auth.ContextWithAdminUser(context.Background(), 99)
|
|
s.repo.On("Delete", ctx, uint(1)).Return(domain.ErrEntityNotFound)
|
|
|
|
// Act
|
|
err := s.commands.DeleteUser(ctx, 1)
|
|
|
|
// Assert
|
|
assert.Error(s.T(), err)
|
|
assert.ErrorIs(s.T(), err, domain.ErrEntityNotFound)
|
|
s.repo.AssertExpectations(s.T())
|
|
} |