diff --git a/src/app/Packages/Features/CommandUseCases/UseCase/User/UpdateUserUseCase.php b/src/app/Packages/Features/CommandUseCases/UseCase/User/UpdateUserUseCase.php index 266d8b2..3e451b8 100644 --- a/src/app/Packages/Features/CommandUseCases/UseCase/User/UpdateUserUseCase.php +++ b/src/app/Packages/Features/CommandUseCases/UseCase/User/UpdateUserUseCase.php @@ -6,6 +6,7 @@ use App\Packages\Domains\User\Factory\UserEntityFactory; use App\Packages\Features\CommandUseCases\UseCommand\User\UpdateUserCommand; use App\Packages\Features\QueryUseCases\Dto\User\UserDto; +use Exception; final class UpdateUserUseCase { @@ -15,14 +16,20 @@ public function __construct( public function handle(UpdateUserCommand $command): UserDto { + $existing = $this->userRepository->findById($command->id); + + if ($existing === null) { + throw new Exception('User not found.'); + } + $entity = UserEntityFactory::build([ 'id' => $command->id, - 'first_name' => $command->firstName, - 'last_name' => $command->lastName, - 'email' => $command->email, - 'age_range' => $command->ageRange, - 'subscription_tier' => $command->subscriptionTier, - 'subscription_expires_at' => $command->subscriptionExpiresAt, + 'first_name' => $command->firstName ?? $existing->firstName, + 'last_name' => $command->lastName ?? $existing->lastName, + 'email' => $command->email ?? $existing->email, + 'age_range' => $command->ageRange ?? $existing->ageRange, + 'subscription_tier' => $command->subscriptionTier ?? $existing->subscriptionTier, + 'subscription_expires_at' => $command->subscriptionExpiresAt ?? $existing->subscriptionExpiresAt, ]); return $this->userRepository->updateUser($entity); diff --git a/src/app/Packages/Features/CommandUseCases/UseCommand/User/UpdateUserCommand.php b/src/app/Packages/Features/CommandUseCases/UseCommand/User/UpdateUserCommand.php index 4176a5c..2887ee2 100644 --- a/src/app/Packages/Features/CommandUseCases/UseCommand/User/UpdateUserCommand.php +++ b/src/app/Packages/Features/CommandUseCases/UseCommand/User/UpdateUserCommand.php @@ -6,40 +6,32 @@ final class UpdateUserCommand { - private const REQUIRED_KEYS = [ - 'id', - 'first_name', - 'last_name', - 'email', - 'age_range', - 'subscription_tier', - ]; - private function __construct( public readonly int $id, - public readonly string $firstName, - public readonly string $lastName, - public readonly string $email, - public readonly string $ageRange, - public readonly string $subscriptionTier, + public readonly ?string $firstName, + public readonly ?string $lastName, + public readonly ?string $email, + public readonly ?string $ageRange, + public readonly ?string $subscriptionTier, public readonly ?string $subscriptionExpiresAt, ) {} + // Fields other than `id` are optional: this supports partial updates, + // where an omitted field means "keep the current value" (resolved by + // UpdateUserUseCase), rather than requiring the full user payload. public static function fromArray(array $data): self { - foreach (self::REQUIRED_KEYS as $key) { - if (!array_key_exists($key, $data)) { - throw new InvalidArgumentException("Missing required key: {$key}"); - } + if (!array_key_exists('id', $data)) { + throw new InvalidArgumentException('Missing required key: id'); } return new self( id: (int) $data['id'], - firstName: $data['first_name'], - lastName: $data['last_name'], - email: $data['email'], - ageRange: $data['age_range'], - subscriptionTier: $data['subscription_tier'], + firstName: $data['first_name'] ?? null, + lastName: $data['last_name'] ?? null, + email: $data['email'] ?? null, + ageRange: $data['age_range'] ?? null, + subscriptionTier: $data['subscription_tier'] ?? null, subscriptionExpiresAt: $data['subscription_expires_at'] ?? null, ); } diff --git a/src/app/Packages/Features/Controller/UserController.php b/src/app/Packages/Features/Controller/UserController.php index 49589f8..9a8ea7c 100644 --- a/src/app/Packages/Features/Controller/UserController.php +++ b/src/app/Packages/Features/Controller/UserController.php @@ -62,7 +62,7 @@ public function updateUser( 'status' => 'success', 'data' => UserViewModelFactory::build($dto)->toArray(), ], 200); - } catch (\Exception $exception) { + } catch (\Throwable $exception) { if ($exception->getMessage() === 'User not found.') { return response()->json([ 'status' => 'error', diff --git a/src/app/Packages/Features/Tests/CommandUseCases/UpdateUserCommandTest.php b/src/app/Packages/Features/Tests/CommandUseCases/UpdateUserCommandTest.php index 0e4b4ad..30e8495 100644 --- a/src/app/Packages/Features/Tests/CommandUseCases/UpdateUserCommandTest.php +++ b/src/app/Packages/Features/Tests/CommandUseCases/UpdateUserCommandTest.php @@ -46,22 +46,31 @@ public function test_fromArray_sets_subscription_expires_at_when_provided(): voi $this->assertSame('2027-01-01 00:00:00', $command->subscriptionExpiresAt); } - #[\PHPUnit\Framework\Attributes\DataProvider('missingKeyProvider')] - public function test_fromArray_throws_when_required_key_is_missing(string $missingKey): void + public function test_fromArray_throws_when_id_is_missing(): void { $data = $this->validData(); - unset($data[$missingKey]); + unset($data['id']); $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage("Missing required key: {$missingKey}"); + $this->expectExceptionMessage('Missing required key: id'); UpdateUserCommand::fromArray($data); } - public static function missingKeyProvider(): array + #[\PHPUnit\Framework\Attributes\DataProvider('optionalKeyProvider')] + public function test_fromArray_allows_optional_key_to_be_omitted(string $optionalKey): void + { + $data = $this->validData(); + unset($data[$optionalKey]); + + $command = UpdateUserCommand::fromArray($data); + + $this->assertNull($command->{$this->toCommandProperty($optionalKey)}); + } + + public static function optionalKeyProvider(): array { return [ - ['id'], ['first_name'], ['last_name'], ['email'], @@ -69,4 +78,9 @@ public static function missingKeyProvider(): array ['subscription_tier'], ]; } + + private function toCommandProperty(string $key): string + { + return lcfirst(str_replace('_', '', ucwords($key, '_'))); + } } diff --git a/src/app/Packages/Features/Tests/CommandUseCases/UpdateUserUseCaseTest.php b/src/app/Packages/Features/Tests/CommandUseCases/UpdateUserUseCaseTest.php index 63f14d3..504a6e2 100644 --- a/src/app/Packages/Features/Tests/CommandUseCases/UpdateUserUseCaseTest.php +++ b/src/app/Packages/Features/Tests/CommandUseCases/UpdateUserUseCaseTest.php @@ -7,6 +7,7 @@ use App\Packages\Features\CommandUseCases\UseCase\User\UpdateUserUseCase; use App\Packages\Features\CommandUseCases\UseCommand\User\UpdateUserCommand; use App\Packages\Features\QueryUseCases\Dto\User\UserDto; +use Exception; use Faker\Factory as FakerFactory; use Mockery; use Mockery\MockInterface; @@ -20,26 +21,26 @@ protected function tearDown(): void parent::tearDown(); } - private function buildCommand(): UpdateUserCommand + private function buildCommand(array $overrides = []): UpdateUserCommand { $faker = FakerFactory::create(); - return UpdateUserCommand::fromArray([ + return UpdateUserCommand::fromArray(array_merge([ 'id' => $faker->unique()->randomNumber(5), 'first_name' => $faker->firstName(), 'last_name' => $faker->lastName(), 'email' => $faker->unique()->safeEmail(), 'age_range' => $faker->randomElement(['teens', '20s', '30s', '40s', '50s', '60plus']), 'subscription_tier' => 'free', - ]); + ], $overrides)); } - private function buildDto(): UserDto + private function buildDto(int $id): UserDto { $faker = FakerFactory::create(); return new UserDto( - id: $faker->unique()->randomNumber(5), + id: $id, firstName: $faker->firstName(), lastName: $faker->lastName(), email: $faker->unique()->safeEmail(), @@ -52,17 +53,66 @@ private function buildDto(): UserDto public function test_handle_passes_entity_to_repository_and_returns_dto(): void { $command = $this->buildCommand(); - $dto = $this->buildDto(); + $existing = $this->buildDto($command->id); + $updated = $this->buildDto($command->id); /** @var UserRepositroyInterface|MockInterface $repository */ $repository = Mockery::mock(UserRepositroyInterface::class); + $repository->shouldReceive('findById') + ->once() + ->with($command->id) + ->andReturn($existing); $repository->shouldReceive('updateUser') ->once() ->with(Mockery::type(UserEntity::class)) - ->andReturn($dto); + ->andReturn($updated); $result = (new UpdateUserUseCase($repository))->handle($command); - $this->assertSame($dto, $result); + $this->assertSame($updated, $result); + } + + public function test_handle_keeps_existing_value_when_field_is_omitted(): void + { + $command = $this->buildCommand(['first_name' => null]); + $existing = $this->buildDto($command->id); + $passedEntity = null; + + /** @var UserRepositroyInterface|MockInterface $repository */ + $repository = Mockery::mock(UserRepositroyInterface::class); + $repository->shouldReceive('findById') + ->once() + ->with($command->id) + ->andReturn($existing); + $repository->shouldReceive('updateUser') + ->once() + ->with(Mockery::on(function (UserEntity $entity) use (&$passedEntity) { + $passedEntity = $entity; + + return true; + })) + ->andReturn($existing); + + (new UpdateUserUseCase($repository))->handle($command); + + $this->assertSame($existing->firstName, $passedEntity->getFirstName()); + } + + public function test_handle_throws_when_user_not_found(): void + { + $command = $this->buildCommand(); + + /** @var UserRepositroyInterface|MockInterface $repository */ + $repository = Mockery::mock(UserRepositroyInterface::class); + $repository->shouldReceive('findById') + ->once() + ->with($command->id) + ->andReturn(null); + $repository->shouldNotReceive('updateUser'); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('User not found.'); + + (new UpdateUserUseCase($repository))->handle($command); } } \ No newline at end of file diff --git a/src/app/Packages/Features/Tests/UpdateUserTest.php b/src/app/Packages/Features/Tests/UpdateUserTest.php index b375ab5..5126f88 100644 --- a/src/app/Packages/Features/Tests/UpdateUserTest.php +++ b/src/app/Packages/Features/Tests/UpdateUserTest.php @@ -95,15 +95,18 @@ public function test_update_user_returns_404_when_user_not_found(): void ]); } - public function test_update_user_returns_500_when_required_key_is_missing(): void + public function test_update_user_returns_500_with_json_when_age_range_is_invalid(): void { - $user = $this->seedUser(); - $payload = $this->validPayload(); - unset($payload['email']); + $user = $this->seedUser(); - $response = $this->patchJson("/api/v1/users/{$user->id}", $payload); + $response = $this->patchJson("/api/v1/users/{$user->id}", [ + 'age_range' => 'not-a-real-value', + ]); $response->assertStatus(500) - ->assertJsonFragment(['status' => 'error']); + ->assertJsonFragment([ + 'status' => 'error', + 'message' => 'Internal Server Error', + ]); } }