tercul-backend/internal/app/user/commands_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

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