From f22658e49a88b9a5e6b5e40110381df14947f11e Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Sat, 27 Jun 2026 17:27:38 +0900 Subject: [PATCH 001/110] feat: add users table migration and User model Co-Authored-By: Claude Sonnet 4.6 --- src/app/Models/User.php | 34 +++++++++++++++++++ .../2026_06_27_000000_create_users_table.php | 29 ++++++++++++++++ 2 files changed, 63 insertions(+) create mode 100644 src/app/Models/User.php create mode 100644 src/database/migrations/2026_06_27_000000_create_users_table.php diff --git a/src/app/Models/User.php b/src/app/Models/User.php new file mode 100644 index 0000000..4eb13ff --- /dev/null +++ b/src/app/Models/User.php @@ -0,0 +1,34 @@ + 'hashed', + 'subscription_expires_at' => 'datetime', + 'email_verified_at' => 'datetime', + ]; + } +} \ No newline at end of file diff --git a/src/database/migrations/2026_06_27_000000_create_users_table.php b/src/database/migrations/2026_06_27_000000_create_users_table.php new file mode 100644 index 0000000..d58b8e4 --- /dev/null +++ b/src/database/migrations/2026_06_27_000000_create_users_table.php @@ -0,0 +1,29 @@ +id(); + $table->string('first_name'); + $table->string('last_name'); + $table->string('email')->unique(); + $table->string('password'); + $table->enum('age_range', ['teens', '20s', '30s', '40s', '50s', '60plus']); + $table->enum('subscription_tier', ['free', 'premium'])->default('free'); + $table->timestamp('subscription_expires_at')->nullable(); + $table->timestamp('email_verified_at')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('users'); + } +}; \ No newline at end of file From 11e509c63c6e131f0bb723eaae83472cd0be08e0 Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Sat, 27 Jun 2026 21:21:33 +0900 Subject: [PATCH 002/110] feat: add AgeRange enum --- src/app/Packages/Domains/User/AgeRange.php | 13 +++++++++ .../User/Factory/SubscriptionFactory.php | 1 + .../User/Factory/UserEntityFactory.php | 28 +++++++++++++++++++ 3 files changed, 42 insertions(+) create mode 100644 src/app/Packages/Domains/User/AgeRange.php create mode 100644 src/app/Packages/Domains/User/Factory/SubscriptionFactory.php create mode 100644 src/app/Packages/Domains/User/Factory/UserEntityFactory.php diff --git a/src/app/Packages/Domains/User/AgeRange.php b/src/app/Packages/Domains/User/AgeRange.php new file mode 100644 index 0000000..e10e2d8 --- /dev/null +++ b/src/app/Packages/Domains/User/AgeRange.php @@ -0,0 +1,13 @@ + Date: Sat, 27 Jun 2026 21:22:21 +0900 Subject: [PATCH 003/110] feat: add SubscriptionTier enum and Subscription value object --- .../User/Subscription/Subscription.php | 46 +++++++++++++++++++ .../User/Subscription/SubscriptionTier.php | 9 ++++ 2 files changed, 55 insertions(+) create mode 100644 src/app/Packages/Domains/User/Subscription/Subscription.php create mode 100644 src/app/Packages/Domains/User/Subscription/SubscriptionTier.php diff --git a/src/app/Packages/Domains/User/Subscription/Subscription.php b/src/app/Packages/Domains/User/Subscription/Subscription.php new file mode 100644 index 0000000..4e834d3 --- /dev/null +++ b/src/app/Packages/Domains/User/Subscription/Subscription.php @@ -0,0 +1,46 @@ +now = $now ?? new DateTimeImmutable(); + } + + public function isActive(): bool + { + return $this->tier === SubscriptionTier::Premium + && $this->expiresAt !== null + && $this->expiresAt > $this->now; + } + + public function isExpired(): bool + { + return $this->tier === SubscriptionTier::Premium + && ($this->expiresAt === null || $this->expiresAt <= $this->now); + } + + public function isFree(): bool + { + return $this->tier === SubscriptionTier::Free; + } + + public function getTier(): SubscriptionTier + { + return $this->tier; + } + + public function getExpiresAt(): ?DateTimeImmutable + { + return $this->expiresAt; + } +} \ No newline at end of file diff --git a/src/app/Packages/Domains/User/Subscription/SubscriptionTier.php b/src/app/Packages/Domains/User/Subscription/SubscriptionTier.php new file mode 100644 index 0000000..3a06cee --- /dev/null +++ b/src/app/Packages/Domains/User/Subscription/SubscriptionTier.php @@ -0,0 +1,9 @@ + Date: Sat, 27 Jun 2026 21:22:39 +0900 Subject: [PATCH 004/110] feat: add UserEntity value object --- src/app/Packages/Domains/User/UserEntity.php | 52 ++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 src/app/Packages/Domains/User/UserEntity.php diff --git a/src/app/Packages/Domains/User/UserEntity.php b/src/app/Packages/Domains/User/UserEntity.php new file mode 100644 index 0000000..961595e --- /dev/null +++ b/src/app/Packages/Domains/User/UserEntity.php @@ -0,0 +1,52 @@ +id; + } + + public function getFirstName(): string + { + return $this->firstName; + } + + public function getLastName(): string + { + return $this->lastName; + } + + public function getFullName(): string + { + return $this->firstName . ' ' . $this->lastName; + } + + public function getEmail(): string + { + return $this->email; + } + + public function getAgeRange(): AgeRange + { + return $this->ageRange; + } + + public function getSubscription(): Subscription + { + return $this->subscription; + } +} \ No newline at end of file From ffb1ec1a83565a9cd34f4040fae88fdaed67c49c Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Sat, 27 Jun 2026 21:23:05 +0900 Subject: [PATCH 005/110] feat: implement SubscriptionFactory --- .../User/Factory/SubscriptionFactory.php | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/app/Packages/Domains/User/Factory/SubscriptionFactory.php b/src/app/Packages/Domains/User/Factory/SubscriptionFactory.php index b3d9bbc..a8a3c3c 100644 --- a/src/app/Packages/Domains/User/Factory/SubscriptionFactory.php +++ b/src/app/Packages/Domains/User/Factory/SubscriptionFactory.php @@ -1 +1,27 @@ Date: Sat, 27 Jun 2026 21:23:31 +0900 Subject: [PATCH 006/110] feat: implement UserEntityFactory --- .../Domains/User/Factory/UserEntityFactory.php | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/app/Packages/Domains/User/Factory/UserEntityFactory.php b/src/app/Packages/Domains/User/Factory/UserEntityFactory.php index 3a7a389..f6b630a 100644 --- a/src/app/Packages/Domains/User/Factory/UserEntityFactory.php +++ b/src/app/Packages/Domains/User/Factory/UserEntityFactory.php @@ -1,20 +1,20 @@ $data['subscription_tier'], + 'expires_at' => $data['subscription_expires_at'] ?? null, + 'now' => $data['now'] ?? null, + ]); return new UserEntity( $data['id'], From 81ecfaf4454e1b448b7f12f3158933133131bb51 Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Sun, 28 Jun 2026 17:13:26 +0900 Subject: [PATCH 007/110] fix: correct SubscriptionFactory namespace and add missing use statements --- .../Packages/Domains/Interface/UserRepositroyInterface.php | 1 + src/app/Packages/Domains/User/Factory/SubscriptionFactory.php | 4 +++- src/app/Packages/Domains/User/Factory/UserEntityFactory.php | 2 +- src/app/Packages/Domains/UserRepository.php | 1 + src/app/Packages/Features/QueryUseCases/Dto/User/UserDto.php | 1 + 5 files changed, 7 insertions(+), 2 deletions(-) create mode 100644 src/app/Packages/Domains/Interface/UserRepositroyInterface.php create mode 100644 src/app/Packages/Domains/UserRepository.php create mode 100644 src/app/Packages/Features/QueryUseCases/Dto/User/UserDto.php diff --git a/src/app/Packages/Domains/Interface/UserRepositroyInterface.php b/src/app/Packages/Domains/Interface/UserRepositroyInterface.php new file mode 100644 index 0000000..b3d9bbc --- /dev/null +++ b/src/app/Packages/Domains/Interface/UserRepositroyInterface.php @@ -0,0 +1 @@ + Date: Sun, 28 Jun 2026 17:14:31 +0900 Subject: [PATCH 008/110] feat: add UserRepositoryInterface, UserRepository, and UserDto stub --- .../Interface/UserRepositroyInterface.php | 12 +++++++ src/app/Packages/Domains/UserRepository.php | 36 +++++++++++++++++++ .../QueryUseCases/Dto/User/UserDto.php | 7 ++++ 3 files changed, 55 insertions(+) diff --git a/src/app/Packages/Domains/Interface/UserRepositroyInterface.php b/src/app/Packages/Domains/Interface/UserRepositroyInterface.php index b3d9bbc..3c1abf1 100644 --- a/src/app/Packages/Domains/Interface/UserRepositroyInterface.php +++ b/src/app/Packages/Domains/Interface/UserRepositroyInterface.php @@ -1 +1,13 @@ userModel->create([ + 'email' => $entity->getEmail(), + 'first_name' => $entity->getFirstName(), + 'last_name' => $entity->getLastName(), + 'age_range' => $entity->getAgeRange()->value, + 'subscription_tier' => $entity->getSubscription()->getTier()->value, + 'subscription_expires_at' => $entity->getSubscription()->getExpiresAt(), + ]); + + if (!$insertUser->wasRecentlyCreated) { + throw new Exception('Failed to create user.'); + } + + return new UserDto(); + + } +} \ No newline at end of file diff --git a/src/app/Packages/Features/QueryUseCases/Dto/User/UserDto.php b/src/app/Packages/Features/QueryUseCases/Dto/User/UserDto.php index b3d9bbc..9dd3653 100644 --- a/src/app/Packages/Features/QueryUseCases/Dto/User/UserDto.php +++ b/src/app/Packages/Features/QueryUseCases/Dto/User/UserDto.php @@ -1 +1,8 @@ Date: Sun, 28 Jun 2026 17:14:55 +0900 Subject: [PATCH 009/110] test: add unit tests for UserRepository --- .../Packages/Domains/UserRepositoryTest.php | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 src/tests/Unit/Packages/Domains/UserRepositoryTest.php diff --git a/src/tests/Unit/Packages/Domains/UserRepositoryTest.php b/src/tests/Unit/Packages/Domains/UserRepositoryTest.php new file mode 100644 index 0000000..2592dd0 --- /dev/null +++ b/src/tests/Unit/Packages/Domains/UserRepositoryTest.php @@ -0,0 +1,67 @@ + $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' => $faker->randomElement(['free', 'premium']), + 'subscription_expires_at' => null, + ]); + } + + private function buildUserModelMock(bool $wasRecentlyCreated): User + { + /** @var User|MockInterface $createdUser */ + $createdUser = Mockery::mock(User::class); + $createdUser->wasRecentlyCreated = $wasRecentlyCreated; + + /** @var User|MockInterface $userModel */ + $userModel = Mockery::mock(User::class); + $userModel->shouldReceive('create')->andReturn($createdUser); + + return $userModel; + } + + public function test_createUser_returns_user_dto_on_success(): void + { + $repository = new UserRepository($this->buildUserModelMock(wasRecentlyCreated: true)); + $result = $repository->createUser($this->buildUserEntity()); + + $this->assertInstanceOf(UserDto::class, $result); + } + + public function test_createUser_throws_exception_when_insert_fails(): void + { + $repository = new UserRepository($this->buildUserModelMock(wasRecentlyCreated: false)); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('Failed to create user.'); + + $repository->createUser($this->buildUserEntity()); + } +} \ No newline at end of file From 06dc4fe16653fbc1410bef696ac6704b8eaf18e1 Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Sun, 28 Jun 2026 18:07:04 +0900 Subject: [PATCH 010/110] feat: add CreateUserCommand with required key validation --- .../UseCommand/User/CreateUserCommand.php | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 src/app/Packages/Features/CommandUseCases/UseCommand/User/CreateUserCommand.php diff --git a/src/app/Packages/Features/CommandUseCases/UseCommand/User/CreateUserCommand.php b/src/app/Packages/Features/CommandUseCases/UseCommand/User/CreateUserCommand.php new file mode 100644 index 0000000..da4894e --- /dev/null +++ b/src/app/Packages/Features/CommandUseCases/UseCommand/User/CreateUserCommand.php @@ -0,0 +1,45 @@ + Date: Sun, 28 Jun 2026 18:07:15 +0900 Subject: [PATCH 011/110] feat: add CreateUserUseCase --- .../UseCase/User/CreateUserUseCase.php | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 src/app/Packages/Features/CommandUseCases/UseCase/User/CreateUserUseCase.php diff --git a/src/app/Packages/Features/CommandUseCases/UseCase/User/CreateUserUseCase.php b/src/app/Packages/Features/CommandUseCases/UseCase/User/CreateUserUseCase.php new file mode 100644 index 0000000..29c4289 --- /dev/null +++ b/src/app/Packages/Features/CommandUseCases/UseCase/User/CreateUserUseCase.php @@ -0,0 +1,19 @@ +userRepository->createUser($command); + } +} \ No newline at end of file From e40fcf71a947ed7d755cff97e80f595768e13881 Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Sun, 28 Jun 2026 18:07:43 +0900 Subject: [PATCH 012/110] feat: add UserDtoFactory --- .../Factory/Dto/UserDtoFactory.php | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 src/app/Packages/Features/QueryUseCases/Factory/Dto/UserDtoFactory.php diff --git a/src/app/Packages/Features/QueryUseCases/Factory/Dto/UserDtoFactory.php b/src/app/Packages/Features/QueryUseCases/Factory/Dto/UserDtoFactory.php new file mode 100644 index 0000000..eaad779 --- /dev/null +++ b/src/app/Packages/Features/QueryUseCases/Factory/Dto/UserDtoFactory.php @@ -0,0 +1,21 @@ + Date: Sun, 28 Jun 2026 18:07:49 +0900 Subject: [PATCH 013/110] feat: update UserDto with typed public readonly properties --- .../Features/QueryUseCases/Dto/User/UserDto.php | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/app/Packages/Features/QueryUseCases/Dto/User/UserDto.php b/src/app/Packages/Features/QueryUseCases/Dto/User/UserDto.php index 9dd3653..b8a8d9a 100644 --- a/src/app/Packages/Features/QueryUseCases/Dto/User/UserDto.php +++ b/src/app/Packages/Features/QueryUseCases/Dto/User/UserDto.php @@ -2,7 +2,15 @@ namespace App\Packages\Features\QueryUseCases\Dto\User; -class UserDto +final class UserDto { - + public 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 $subscriptionExpiresAt, + ) {} } \ No newline at end of file From c24585cd91c108e77b2601fddea9ecfe451f0aa2 Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Sun, 28 Jun 2026 18:08:27 +0900 Subject: [PATCH 014/110] refactor: update UserRepositoryInterface and UserRepository to use CreateUserCommand and UserDtoFactory --- .../Interface/UserRepositroyInterface.php | 6 ++--- src/app/Packages/Domains/UserRepository.php | 24 +++++++++---------- 2 files changed, 13 insertions(+), 17 deletions(-) diff --git a/src/app/Packages/Domains/Interface/UserRepositroyInterface.php b/src/app/Packages/Domains/Interface/UserRepositroyInterface.php index 3c1abf1..a71cbd8 100644 --- a/src/app/Packages/Domains/Interface/UserRepositroyInterface.php +++ b/src/app/Packages/Domains/Interface/UserRepositroyInterface.php @@ -2,12 +2,10 @@ namespace App\Packages\Domains\Interface; -use App\Packages\Domains\User\UserEntity; +use App\Packages\Features\CommandUseCases\UseCommand\User\CreateUserCommand; use App\Packages\Features\QueryUseCases\Dto\User\UserDto; interface UserRepositroyInterface { - public function createUser( - UserEntity $entity - ): UserDto; + public function createUser(CreateUserCommand $command): UserDto; } \ No newline at end of file diff --git a/src/app/Packages/Domains/UserRepository.php b/src/app/Packages/Domains/UserRepository.php index 3df866a..8afbffd 100644 --- a/src/app/Packages/Domains/UserRepository.php +++ b/src/app/Packages/Domains/UserRepository.php @@ -2,10 +2,11 @@ namespace App\Packages\Domains; -use App\Packages\Domains\Interface\UserRepositroyInterface; use App\Models\User; -use App\Packages\Domains\User\UserEntity; +use App\Packages\Domains\Interface\UserRepositroyInterface; +use App\Packages\Features\CommandUseCases\UseCommand\User\CreateUserCommand; use App\Packages\Features\QueryUseCases\Dto\User\UserDto; +use App\Packages\Features\QueryUseCases\Factory\Dto\UserDtoFactory; use Exception; class UserRepository implements UserRepositroyInterface @@ -14,24 +15,21 @@ public function __construct( private User $userModel ) {} - public function createUser( - UserEntity $entity - ): UserDto + public function createUser(CreateUserCommand $command): UserDto { $insertUser = $this->userModel->create([ - 'email' => $entity->getEmail(), - 'first_name' => $entity->getFirstName(), - 'last_name' => $entity->getLastName(), - 'age_range' => $entity->getAgeRange()->value, - 'subscription_tier' => $entity->getSubscription()->getTier()->value, - 'subscription_expires_at' => $entity->getSubscription()->getExpiresAt(), + 'email' => $command->email, + 'first_name' => $command->firstName, + 'last_name' => $command->lastName, + 'age_range' => $command->ageRange, + 'subscription_tier' => $command->subscriptionTier, + 'subscription_expires_at' => $command->subscriptionExpiresAt, ]); if (!$insertUser->wasRecentlyCreated) { throw new Exception('Failed to create user.'); } - return new UserDto(); - + return UserDtoFactory::build($insertUser->toArray()); } } \ No newline at end of file From e4e580747305124771a6c99f388db386b2356444 Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Sun, 28 Jun 2026 18:08:57 +0900 Subject: [PATCH 015/110] test: update UserRepository tests to use CreateUserCommand --- .../Packages/Domains/UserRepositoryTest.php | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/src/tests/Unit/Packages/Domains/UserRepositoryTest.php b/src/tests/Unit/Packages/Domains/UserRepositoryTest.php index 2592dd0..8fccd2d 100644 --- a/src/tests/Unit/Packages/Domains/UserRepositoryTest.php +++ b/src/tests/Unit/Packages/Domains/UserRepositoryTest.php @@ -3,8 +3,8 @@ namespace Tests\Unit\Packages\Domains; use App\Models\User; -use App\Packages\Domains\User\Factory\UserEntityFactory; use App\Packages\Domains\UserRepository; +use App\Packages\Features\CommandUseCases\UseCommand\User\CreateUserCommand; use App\Packages\Features\QueryUseCases\Dto\User\UserDto; use Exception; use Faker\Factory as FakerFactory; @@ -19,12 +19,11 @@ protected function tearDown(): void Mockery::close(); } - private function buildUserEntity(): \App\Packages\Domains\User\UserEntity + private function buildCommand(): CreateUserCommand { $faker = FakerFactory::create(); - return UserEntityFactory::build([ - 'id' => $faker->unique()->randomNumber(5), + return CreateUserCommand::fromArray([ 'first_name' => $faker->firstName(), 'last_name' => $faker->lastName(), 'email' => $faker->unique()->safeEmail(), @@ -36,9 +35,22 @@ private function buildUserEntity(): \App\Packages\Domains\User\UserEntity private function buildUserModelMock(bool $wasRecentlyCreated): User { + $faker = FakerFactory::create(); + + $attributes = [ + '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', + 'subscription_expires_at' => null, + ]; + /** @var User|MockInterface $createdUser */ $createdUser = Mockery::mock(User::class); $createdUser->wasRecentlyCreated = $wasRecentlyCreated; + $createdUser->shouldReceive('toArray')->andReturn($attributes); /** @var User|MockInterface $userModel */ $userModel = Mockery::mock(User::class); @@ -50,7 +62,7 @@ private function buildUserModelMock(bool $wasRecentlyCreated): User public function test_createUser_returns_user_dto_on_success(): void { $repository = new UserRepository($this->buildUserModelMock(wasRecentlyCreated: true)); - $result = $repository->createUser($this->buildUserEntity()); + $result = $repository->createUser($this->buildCommand()); $this->assertInstanceOf(UserDto::class, $result); } @@ -62,6 +74,6 @@ public function test_createUser_throws_exception_when_insert_fails(): void $this->expectException(Exception::class); $this->expectExceptionMessage('Failed to create user.'); - $repository->createUser($this->buildUserEntity()); + $repository->createUser($this->buildCommand()); } } \ No newline at end of file From 45a76ebf4bb62f3043f6cf6bcf85064273b28dda Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Sun, 28 Jun 2026 19:14:17 +0900 Subject: [PATCH 016/110] feat: add password field to CreateUserCommand and UserRepository --- src/app/Packages/Domains/UserRepository.php | 1 + .../CommandUseCases/UseCommand/User/CreateUserCommand.php | 3 +++ 2 files changed, 4 insertions(+) diff --git a/src/app/Packages/Domains/UserRepository.php b/src/app/Packages/Domains/UserRepository.php index 8afbffd..10478b4 100644 --- a/src/app/Packages/Domains/UserRepository.php +++ b/src/app/Packages/Domains/UserRepository.php @@ -21,6 +21,7 @@ public function createUser(CreateUserCommand $command): UserDto 'email' => $command->email, 'first_name' => $command->firstName, 'last_name' => $command->lastName, + 'password' => bcrypt($command->password), 'age_range' => $command->ageRange, 'subscription_tier' => $command->subscriptionTier, 'subscription_expires_at' => $command->subscriptionExpiresAt, diff --git a/src/app/Packages/Features/CommandUseCases/UseCommand/User/CreateUserCommand.php b/src/app/Packages/Features/CommandUseCases/UseCommand/User/CreateUserCommand.php index da4894e..271cdd2 100644 --- a/src/app/Packages/Features/CommandUseCases/UseCommand/User/CreateUserCommand.php +++ b/src/app/Packages/Features/CommandUseCases/UseCommand/User/CreateUserCommand.php @@ -10,6 +10,7 @@ final class CreateUserCommand 'first_name', 'last_name', 'email', + 'password', 'age_range', 'subscription_tier', ]; @@ -18,6 +19,7 @@ private function __construct( public readonly string $firstName, public readonly string $lastName, public readonly string $email, + public readonly string $password, public readonly string $ageRange, public readonly string $subscriptionTier, public readonly ?string $subscriptionExpiresAt, @@ -37,6 +39,7 @@ public static function fromArray(array $data): self firstName: $data['first_name'], lastName: $data['last_name'], email: $data['email'], + password: $data['password'], ageRange: $data['age_range'], subscriptionTier: $data['subscription_tier'], subscriptionExpiresAt: $data['subscription_expires_at'] ?? null, From f7fa76a8e28e3cb71689071fae554662d36bf6ca Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Sun, 28 Jun 2026 19:14:25 +0900 Subject: [PATCH 017/110] feat: add UserViewModel and UserViewModelFactory --- .../ViewModel/UserViewModelFactory.php | 14 +++++++++++ .../ViewModel/User/UserViewModel.php | 25 +++++++++++++++++++ 2 files changed, 39 insertions(+) create mode 100644 src/app/Packages/Features/CommandUseCases/Factory/ViewModel/UserViewModelFactory.php create mode 100644 src/app/Packages/Features/CommandUseCases/ViewModel/User/UserViewModel.php diff --git a/src/app/Packages/Features/CommandUseCases/Factory/ViewModel/UserViewModelFactory.php b/src/app/Packages/Features/CommandUseCases/Factory/ViewModel/UserViewModelFactory.php new file mode 100644 index 0000000..a4e2a3d --- /dev/null +++ b/src/app/Packages/Features/CommandUseCases/Factory/ViewModel/UserViewModelFactory.php @@ -0,0 +1,14 @@ + $this->dto->id, + 'first_name' => $this->dto->firstName, + 'last_name' => $this->dto->lastName, + 'email' => $this->dto->email, + 'age_range' => $this->dto->ageRange, + 'subscription_tier' => $this->dto->subscriptionTier, + 'subscription_expires_at' => $this->dto->subscriptionExpiresAt, + ]; + } +} \ No newline at end of file From 873f64410beee6d1555c62c5b4582ccb9537e86c Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Sun, 28 Jun 2026 19:14:33 +0900 Subject: [PATCH 018/110] feat: add UserController with try/catch and 500 error logging --- .../Features/Controller/UserController.php | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 src/app/Packages/Features/Controller/UserController.php diff --git a/src/app/Packages/Features/Controller/UserController.php b/src/app/Packages/Features/Controller/UserController.php new file mode 100644 index 0000000..302a1f6 --- /dev/null +++ b/src/app/Packages/Features/Controller/UserController.php @@ -0,0 +1,39 @@ +all()); + $dto = $useCase->handle($command); + + return response()->json([ + 'status' => 'success', + 'data' => UserViewModelFactory::build($dto)->toArray(), + ], 201); + } catch (\Throwable $throwable) { + Log::error('Failed to create user', [ + 'message' => $throwable->getMessage(), + 'trace' => $throwable->getTraceAsString(), + ]); + + return response()->json([ + 'status' => 'error', + 'message' => 'Internal Server Error', + ], 500); + } + } +} \ No newline at end of file From 93b1dea2a3cb78ec444a24d4811d22369e251f15 Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Sun, 28 Jun 2026 19:14:47 +0900 Subject: [PATCH 019/110] feat: register POST /api/v1/user/create route --- src/routes/api.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/routes/api.php b/src/routes/api.php index 68ce170..fc480aa 100644 --- a/src/routes/api.php +++ b/src/routes/api.php @@ -1,11 +1,14 @@ group(function (): void { Route::get('/heritages', [WorldHeritageController::class, 'getWorldHeritages']); Route::get('/heritages/search', [WorldHeritageController::class, 'searchWorldHeritages']); Route::get('heritages/region-count', [WorldHeritageController::class, 'getWorldHeritagesCountByRegion']); Route::get('/heritages/{id}', [WorldHeritageController::class, 'getWorldHeritageById']); + + Route::post('/user/create', [UserController::class, 'createUser']); }); \ No newline at end of file From d083413562861c94eb7f8135c3d6da5823f20935 Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Sun, 28 Jun 2026 19:14:57 +0900 Subject: [PATCH 020/110] feat: bind UserRepositroyInterface to UserRepository in RepositoryProvider --- src/app/Providers/RepositoryProvider.php | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/app/Providers/RepositoryProvider.php b/src/app/Providers/RepositoryProvider.php index eb3d6f2..acde5b0 100644 --- a/src/app/Providers/RepositoryProvider.php +++ b/src/app/Providers/RepositoryProvider.php @@ -2,26 +2,27 @@ namespace App\Providers; -use Illuminate\Support\ServiceProvider; -use App\Packages\Domains\WorldHeritageRepositoryInterface; +use App\Packages\Domains\Interface\UserRepositroyInterface; +use App\Packages\Domains\UserRepository; use App\Packages\Domains\WorldHeritageRepository; +use App\Packages\Domains\WorldHeritageRepositoryInterface; +use Illuminate\Support\ServiceProvider; class RepositoryProvider extends ServiceProvider { - /** - * Register services. - */ public function register(): void { $this->app->bind( WorldHeritageRepositoryInterface::class, WorldHeritageRepository::class ); + + $this->app->bind( + UserRepositroyInterface::class, + UserRepository::class + ); } - /** - * Bootstrap services. - */ public function boot(): void { // From 658fd8bfacf9d77176012aea501d749122362063 Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Sun, 28 Jun 2026 19:15:03 +0900 Subject: [PATCH 021/110] feat: add coverage report configuration to phpunit.xml --- src/phpunit.xml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/phpunit.xml b/src/phpunit.xml index f2373c9..c39fe1f 100644 --- a/src/phpunit.xml +++ b/src/phpunit.xml @@ -14,6 +14,12 @@ app + + + + + + From 6f67251c881a6d9dbc56a0b9c4f6ffaa2f7ff741 Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Sun, 28 Jun 2026 19:15:16 +0900 Subject: [PATCH 022/110] test: add integration tests for user creation endpoint --- .../Features/Tests/CreateUserTest.php | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 src/app/Packages/Features/Tests/CreateUserTest.php diff --git a/src/app/Packages/Features/Tests/CreateUserTest.php b/src/app/Packages/Features/Tests/CreateUserTest.php new file mode 100644 index 0000000..254fd2f --- /dev/null +++ b/src/app/Packages/Features/Tests/CreateUserTest.php @@ -0,0 +1,88 @@ +truncate(); + } + + protected function tearDown(): void + { + $this->truncate(); + parent::tearDown(); + } + + private function truncate(): void + { + if (env('APP_ENV') === 'testing') { + DB::connection('mysql')->statement('SET FOREIGN_KEY_CHECKS=0;'); + User::truncate(); + DB::connection('mysql')->statement('SET FOREIGN_KEY_CHECKS=1;'); + } + } + + private function validPayload(array $overrides = []): array + { + $faker = FakerFactory::create(); + + return array_merge([ + 'first_name' => $faker->firstName(), + 'last_name' => $faker->lastName(), + 'email' => $faker->unique()->safeEmail(), + 'password' => $faker->password(8), + 'age_range' => $faker->randomElement(['teens', '20s', '30s', '40s', '50s', '60plus']), + 'subscription_tier' => $faker->randomElement(['free', 'premium']), + ], $overrides); + } + + public function test_create_user_returns_201_with_user_data(): void + { + $response = $this->postJson('/api/v1/user/create', $this->validPayload()); + + $response->assertStatus(201) + ->assertJsonStructure([ + 'status', + 'data' => [ + 'id', + 'first_name', + 'last_name', + 'email', + 'age_range', + 'subscription_tier', + 'subscription_expires_at', + ], + ]) + ->assertJsonFragment(['status' => 'success']); + } + + public function test_create_user_returns_500_when_required_key_is_missing(): void + { + $payload = $this->validPayload(); + unset($payload['email']); + + $response = $this->postJson('/api/v1/user/create', $payload); + + $response->assertStatus(500) + ->assertJsonFragment(['status' => 'error']); + } + + public function test_create_user_returns_500_on_duplicate_email(): void + { + $payload = $this->validPayload(); + + $this->postJson('/api/v1/user/create', $payload); + $response = $this->postJson('/api/v1/user/create', $payload); + + $response->assertStatus(500) + ->assertJsonFragment(['status' => 'error']); + } +} \ No newline at end of file From 199df7472f1b13205d813eb39e18cbb4b7d077f0 Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Sun, 28 Jun 2026 20:08:00 +0900 Subject: [PATCH 023/110] feat: add Codecov integration for PR coverage reporting --- .github/workflows/server_test.yml | 12 ++++++++++-- codecov.yml | 14 ++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) create mode 100644 codecov.yml diff --git a/.github/workflows/server_test.yml b/.github/workflows/server_test.yml index 2c2229c..aa36c02 100644 --- a/.github/workflows/server_test.yml +++ b/.github/workflows/server_test.yml @@ -48,6 +48,7 @@ jobs: with: php-version: "8.2" tools: composer:v2 + coverage: pcov - name: Cache Composer dependencies uses: actions/cache@v4 @@ -102,5 +103,12 @@ jobs: APP_ENV: testing - name: Run tests - run: ./vendor/bin/phpunit --stop-on-failure --testdox - working-directory: ./src \ No newline at end of file + run: ./vendor/bin/phpunit --stop-on-failure --testdox --coverage-clover coverage/clover.xml + working-directory: ./src + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: ./src/coverage/clover.xml + fail_ci_if_error: false \ No newline at end of file diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000..40f0bed --- /dev/null +++ b/codecov.yml @@ -0,0 +1,14 @@ +coverage: + status: + project: + default: + target: auto + threshold: 1% + patch: + default: + target: 85% + +comment: + layout: "reach,diff,flags,files" + behavior: default + require_changes: false \ No newline at end of file From 48e9576ecb770e9e0d8172648b05c8fe83f22a49 Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Sun, 28 Jun 2026 20:36:25 +0900 Subject: [PATCH 024/110] test: add unit tests for Subscription isActive, isExpired, and isFree --- .../User/Subscription/SubscriptionTest.php | 121 ++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 src/tests/Unit/Packages/Domains/User/Subscription/SubscriptionTest.php diff --git a/src/tests/Unit/Packages/Domains/User/Subscription/SubscriptionTest.php b/src/tests/Unit/Packages/Domains/User/Subscription/SubscriptionTest.php new file mode 100644 index 0000000..bbac58a --- /dev/null +++ b/src/tests/Unit/Packages/Domains/User/Subscription/SubscriptionTest.php @@ -0,0 +1,121 @@ +assertTrue($subscription->isActive()); + } + + public function test_isActive_returns_false_when_free(): void + { + $subscription = new Subscription( + tier: SubscriptionTier::Free, + expiresAt: new DateTimeImmutable('+1 day'), + now: new DateTimeImmutable(), + ); + + $this->assertFalse($subscription->isActive()); + } + + public function test_isActive_returns_false_when_premium_and_expires_at_is_null(): void + { + $subscription = new Subscription( + tier: SubscriptionTier::Premium, + expiresAt: null, + now: new DateTimeImmutable(), + ); + + $this->assertFalse($subscription->isActive()); + } + + public function test_isActive_returns_false_when_premium_and_expired(): void + { + $subscription = new Subscription( + tier: SubscriptionTier::Premium, + expiresAt: new DateTimeImmutable('-1 day'), + now: new DateTimeImmutable(), + ); + + $this->assertFalse($subscription->isActive()); + } + + public function test_isExpired_returns_true_when_premium_and_past_expiry(): void + { + $subscription = new Subscription( + tier: SubscriptionTier::Premium, + expiresAt: new DateTimeImmutable('-1 day'), + now: new DateTimeImmutable(), + ); + + $this->assertTrue($subscription->isExpired()); + } + + public function test_isExpired_returns_true_when_premium_and_expires_at_is_null(): void + { + $subscription = new Subscription( + tier: SubscriptionTier::Premium, + expiresAt: null, + now: new DateTimeImmutable(), + ); + + $this->assertTrue($subscription->isExpired()); + } + + public function test_isExpired_returns_false_when_free(): void + { + $subscription = new Subscription( + tier: SubscriptionTier::Free, + expiresAt: null, + now: new DateTimeImmutable(), + ); + + $this->assertFalse($subscription->isExpired()); + } + + public function test_isExpired_returns_false_when_premium_and_not_expired(): void + { + $subscription = new Subscription( + tier: SubscriptionTier::Premium, + expiresAt: new DateTimeImmutable('+1 day'), + now: new DateTimeImmutable(), + ); + + $this->assertFalse($subscription->isExpired()); + } + + public function test_isFree_returns_true_when_free(): void + { + $subscription = new Subscription( + tier: SubscriptionTier::Free, + expiresAt: null, + now: new DateTimeImmutable(), + ); + + $this->assertTrue($subscription->isFree()); + } + + public function test_isFree_returns_false_when_premium(): void + { + $subscription = new Subscription( + tier: SubscriptionTier::Premium, + expiresAt: new DateTimeImmutable('+1 day'), + now: new DateTimeImmutable(), + ); + + $this->assertFalse($subscription->isFree()); + } +} \ No newline at end of file From bd032abbdf193cd4b11155c1821ef1face0c6f6c Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Sun, 28 Jun 2026 20:36:34 +0900 Subject: [PATCH 025/110] test: add unit tests for CreateUserCommand key validation --- .../UseCommand/CreateUserCommandTest.php | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 src/tests/Unit/Packages/Features/CommandUseCases/UseCommand/CreateUserCommandTest.php diff --git a/src/tests/Unit/Packages/Features/CommandUseCases/UseCommand/CreateUserCommandTest.php b/src/tests/Unit/Packages/Features/CommandUseCases/UseCommand/CreateUserCommandTest.php new file mode 100644 index 0000000..057b5aa --- /dev/null +++ b/src/tests/Unit/Packages/Features/CommandUseCases/UseCommand/CreateUserCommandTest.php @@ -0,0 +1,73 @@ + $faker->firstName(), + 'last_name' => $faker->lastName(), + 'email' => $faker->unique()->safeEmail(), + 'password' => $faker->password(8), + 'age_range' => $faker->randomElement(['teens', '20s', '30s', '40s', '50s', '60plus']), + 'subscription_tier' => $faker->randomElement(['free', 'premium']), + ], $overrides); + } + + public function test_fromArray_creates_command_with_valid_data(): void + { + $data = $this->validData(); + + $command = CreateUserCommand::fromArray($data); + + $this->assertSame($data['first_name'], $command->firstName); + $this->assertSame($data['last_name'], $command->lastName); + $this->assertSame($data['email'], $command->email); + $this->assertSame($data['password'], $command->password); + $this->assertSame($data['age_range'], $command->ageRange); + $this->assertSame($data['subscription_tier'], $command->subscriptionTier); + $this->assertNull($command->subscriptionExpiresAt); + } + + public function test_fromArray_sets_subscription_expires_at_when_provided(): void + { + $command = CreateUserCommand::fromArray($this->validData([ + 'subscription_expires_at' => '2027-01-01 00:00:00', + ])); + + $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 + { + $data = $this->validData(); + unset($data[$missingKey]); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("Missing required key: {$missingKey}"); + + CreateUserCommand::fromArray($data); + } + + public static function missingKeyProvider(): array + { + return [ + ['first_name'], + ['last_name'], + ['email'], + ['password'], + ['age_range'], + ['subscription_tier'], + ]; + } +} \ No newline at end of file From 231d5c22f5c95eedf82303dee2b3c4b5bf177ca0 Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Sun, 28 Jun 2026 20:36:40 +0900 Subject: [PATCH 026/110] test: add unit tests for CreateUserUseCase --- .../UseCase/CreateUserUseCaseTest.php | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 src/tests/Unit/Packages/Features/CommandUseCases/UseCase/CreateUserUseCaseTest.php diff --git a/src/tests/Unit/Packages/Features/CommandUseCases/UseCase/CreateUserUseCaseTest.php b/src/tests/Unit/Packages/Features/CommandUseCases/UseCase/CreateUserUseCaseTest.php new file mode 100644 index 0000000..0756789 --- /dev/null +++ b/src/tests/Unit/Packages/Features/CommandUseCases/UseCase/CreateUserUseCaseTest.php @@ -0,0 +1,64 @@ + $faker->firstName(), + 'last_name' => $faker->lastName(), + 'email' => $faker->unique()->safeEmail(), + 'password' => $faker->password(8), + 'age_range' => $faker->randomElement(['teens', '20s', '30s', '40s', '50s', '60plus']), + 'subscription_tier' => 'free', + ]); + } + + private function buildDto(): UserDto + { + $faker = FakerFactory::create(); + + return new UserDto( + id: $faker->unique()->randomNumber(5), + firstName: $faker->firstName(), + lastName: $faker->lastName(), + email: $faker->unique()->safeEmail(), + ageRange: 'free', + subscriptionTier: 'free', + subscriptionExpiresAt: null, + ); + } + + public function test_handle_returns_user_dto_from_repository(): void + { + $command = $this->buildCommand(); + $dto = $this->buildDto(); + + /** @var UserRepositroyInterface|MockInterface $repository */ + $repository = Mockery::mock(UserRepositroyInterface::class); + $repository->shouldReceive('createUser')->with($command)->andReturn($dto); + + $useCase = new CreateUserUseCase($repository); + $result = $useCase->handle($command); + + $this->assertSame($dto, $result); + } +} \ No newline at end of file From 35f8178cc76e5c085ae66e8341e32877df4ef930 Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Sun, 28 Jun 2026 20:38:19 +0900 Subject: [PATCH 027/110] test: add unit tests for UserViewModel toArray --- .../ViewModel/UserViewModelTest.php | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 src/tests/Unit/Packages/Features/CommandUseCases/ViewModel/UserViewModelTest.php diff --git a/src/tests/Unit/Packages/Features/CommandUseCases/ViewModel/UserViewModelTest.php b/src/tests/Unit/Packages/Features/CommandUseCases/ViewModel/UserViewModelTest.php new file mode 100644 index 0000000..0c508ae --- /dev/null +++ b/src/tests/Unit/Packages/Features/CommandUseCases/ViewModel/UserViewModelTest.php @@ -0,0 +1,55 @@ +unique()->randomNumber(5), + firstName: $faker->firstName(), + lastName: $faker->lastName(), + email: $faker->unique()->safeEmail(), + ageRange: $faker->randomElement(['teens', '20s', '30s', '40s', '50s', '60plus']), + subscriptionTier: 'free', + subscriptionExpiresAt: null, + ); + + $result = (new UserViewModel($dto))->toArray(); + + $this->assertSame($dto->id, $result['id']); + $this->assertSame($dto->firstName, $result['first_name']); + $this->assertSame($dto->lastName, $result['last_name']); + $this->assertSame($dto->email, $result['email']); + $this->assertSame($dto->ageRange, $result['age_range']); + $this->assertSame($dto->subscriptionTier, $result['subscription_tier']); + $this->assertNull($result['subscription_expires_at']); + } + + public function test_toArray_includes_subscription_expires_at_when_set(): void + { + $faker = FakerFactory::create(); + + $dto = new UserDto( + id: $faker->unique()->randomNumber(5), + firstName: $faker->firstName(), + lastName: $faker->lastName(), + email: $faker->unique()->safeEmail(), + ageRange: '20s', + subscriptionTier: 'premium', + subscriptionExpiresAt: '2027-01-01 00:00:00', + ); + + $result = (new UserViewModel($dto))->toArray(); + + $this->assertSame('2027-01-01 00:00:00', $result['subscription_expires_at']); + } +} \ No newline at end of file From 692a143160490805fc7e9c11b0182e0d560655c2 Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Sun, 28 Jun 2026 20:38:25 +0900 Subject: [PATCH 028/110] test: add unit tests for UserDtoFactory --- .../Factory/Dto/UserDtoFactoryTest.php | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 src/tests/Unit/Packages/Features/QueryUseCases/Factory/Dto/UserDtoFactoryTest.php diff --git a/src/tests/Unit/Packages/Features/QueryUseCases/Factory/Dto/UserDtoFactoryTest.php b/src/tests/Unit/Packages/Features/QueryUseCases/Factory/Dto/UserDtoFactoryTest.php new file mode 100644 index 0000000..fec7962 --- /dev/null +++ b/src/tests/Unit/Packages/Features/QueryUseCases/Factory/Dto/UserDtoFactoryTest.php @@ -0,0 +1,54 @@ + $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', + 'subscription_expires_at' => null, + ]; + + $dto = UserDtoFactory::build($data); + + $this->assertInstanceOf(UserDto::class, $dto); + $this->assertSame((int)$data['id'], $dto->id); + $this->assertSame($data['first_name'], $dto->firstName); + $this->assertSame($data['last_name'], $dto->lastName); + $this->assertSame($data['email'], $dto->email); + $this->assertSame($data['age_range'], $dto->ageRange); + $this->assertSame($data['subscription_tier'], $dto->subscriptionTier); + $this->assertNull($dto->subscriptionExpiresAt); + } + + public function test_build_casts_id_to_int(): void + { + $faker = FakerFactory::create(); + + $dto = UserDtoFactory::build([ + 'id' => '42', + 'first_name' => $faker->firstName(), + 'last_name' => $faker->lastName(), + 'email' => $faker->unique()->safeEmail(), + 'age_range' => 'teens', + 'subscription_tier' => 'free', + 'subscription_expires_at' => null, + ]); + + $this->assertSame(42, $dto->id); + } +} \ No newline at end of file From e803a08700a4d920efdfbb7019fab5dd81630394 Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Sun, 28 Jun 2026 20:40:52 +0900 Subject: [PATCH 029/110] test: update UserRepositoryTest to use Tests\TestCase and add password field --- src/tests/Unit/Packages/Domains/UserRepositoryTest.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/tests/Unit/Packages/Domains/UserRepositoryTest.php b/src/tests/Unit/Packages/Domains/UserRepositoryTest.php index 8fccd2d..b72718e 100644 --- a/src/tests/Unit/Packages/Domains/UserRepositoryTest.php +++ b/src/tests/Unit/Packages/Domains/UserRepositoryTest.php @@ -10,13 +10,14 @@ use Faker\Factory as FakerFactory; use Mockery; use Mockery\MockInterface; -use PHPUnit\Framework\TestCase; +use Tests\TestCase; class UserRepositoryTest extends TestCase { protected function tearDown(): void { Mockery::close(); + parent::tearDown(); } private function buildCommand(): CreateUserCommand @@ -27,6 +28,7 @@ private function buildCommand(): CreateUserCommand 'first_name' => $faker->firstName(), 'last_name' => $faker->lastName(), 'email' => $faker->unique()->safeEmail(), + 'password' => $faker->password(8), 'age_range' => $faker->randomElement(['teens', '20s', '30s', '40s', '50s', '60plus']), 'subscription_tier' => $faker->randomElement(['free', 'premium']), 'subscription_expires_at' => null, From f9bdc18971e5993604e002a7d346cf0e2c4c5182 Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Sun, 28 Jun 2026 22:02:42 +0900 Subject: [PATCH 030/110] chore: add storage/certs/ to .gitignore --- src/.gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/src/.gitignore b/src/.gitignore index 59c942c..c4b6a95 100644 --- a/src/.gitignore +++ b/src/.gitignore @@ -7,6 +7,7 @@ /storage/app/public /storage/app/private/unesco/world-heritage-sites.json /storage/app/private/unesco/normalized/world_heritage_sites_translation.json +/storage/certs/ /vendor .env From ccc5e72f799d4d73eea5f12bc722afa2070bfd84 Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Sun, 28 Jun 2026 22:02:51 +0900 Subject: [PATCH 031/110] chore: update UNESCO world heritage site data (image URLs, typo fix, timestamps) --- .../unesco/normalized/world_heritage_sites.json | 10 +++++----- .../unesco/world_heritage_sites_translation.json | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/storage/app/private/unesco/normalized/world_heritage_sites.json b/src/storage/app/private/unesco/normalized/world_heritage_sites.json index 0b2f406..e6303f0 100644 --- a/src/storage/app/private/unesco/normalized/world_heritage_sites.json +++ b/src/storage/app/private/unesco/normalized/world_heritage_sites.json @@ -2,7 +2,7 @@ "meta": { "schema": "world_heritage_sites.import.v1", "source_raw": "unesco/world-heritage-sites.json", - "generated_at": "2026-05-04T20:29:46+09:00", + "generated_at": "2026-06-26T08:46:51+09:00", "rows_scanned": 1248, "sites": 1248, "target_table": "world_heritage_sites" @@ -2087,7 +2087,7 @@ "longitude": 29.25, "short_description": "The park's immense savannahs, grasslands and woodlands, interspersed with gallery forests along the river banks and the swampy depressions, are home to four large mammals: the elephant, giraffe, hippopotamus and above all the white rhinoceros. Though much larger than the black rhino, it is harmless; only some 30 individuals remain.", "unesco_site_url": null, - "main_image_url": "https://whc.unesco.org/document/224889" + "main_image_url": "https://whc.unesco.org/document/225843" }, { "id": 137, @@ -5521,7 +5521,7 @@ "buffer_zone_hectares": null, "latitude": 31.63139, "longitude": -7.98667, - "short_description": "Founded in 1070–72 by the Almoravids, Marrakesh remained a political, economic and cultural centre for a long period. Its influence was felt throughout the western Muslim world, from North Africa to Andalusia. It has several impressive monuments dating from that period: the Koutoubiya Mosque, the Kasbah, the battlements, monumental doors, gardens, etc. Later architectural jewels include the Bandiâ Palace, the Ben Youssef Madrasa, the Saadian Tombs, several great residences and Place Jamaâ El Fna, a veritable open-air theatre.", + "short_description": "Founded in 1070–72 by the Almoravids, Marrakesh remained a political, economic and cultural centre for a long period. Its influence was felt throughout the western Muslim world, from North Africa to Andalusia. It has several impressive monuments dating from that period: the Koutoubiya Mosque, the Kasbah, the battlements, monumental doors, gardens, etc. Later architectural jewels include the Badiâ Palace, the Ben Youssef Madrasa, the Saadian Tombs, several great residences and Place Jamaâ El Fna, a veritable open-air theatre.", "unesco_site_url": null, "main_image_url": "https://whc.unesco.org/document/110419" }, @@ -21172,7 +21172,7 @@ "longitude": 13.45, "short_description": "Berlin Modernism Housing Estates. The property consists of six housing estates that testify to innovative housing policies from 1910 to 1933, especially during the Weimar Republic, when the city of Berlin was particularly progressive socially, politically and culturally. The property is an outstanding example of the building reform movement that contributed to improving housing and living conditions for people with low incomes through novel approaches to town planning, architecture and garden design. The estates also provide exceptional examples of new urban and architectural typologies, featuring fresh design solutions, as well as technical and aesthetic innovations. Bruno Taut, Martin Wagner and Walter Gropius were among the leading architects of these projects which exercised considerable influence on the development of housing around the world.", "unesco_site_url": null, - "main_image_url": "https://whc.unesco.org/document/195415" + "main_image_url": "https://whc.unesco.org/document/220337" }, { "id": 1240, @@ -27408,7 +27408,7 @@ "longitude": 126.1044444444, "short_description": "Situated in the eastern Yellow Sea on the southwestern and southern coast of the Republic of Korea, the site comprises four component parts: Seocheon Getbol, Gochang Getbol, Shinan Getbol and Boseong-Suncheon Getbol. The site exhibits a complex combination of geological, oceanographic and climatologic conditions that have led to the development of coastal diverse sedimentary systems. Each component represents one of four tidal flat subtypes (estuarine type, open embayed type, archipelago type and semi-enclosed type). The site hosts high levels of biodiversity, with reports of 2,150 species of flora and fauna, including 22 globally threatened or near-threatened species. It is home to 47 endemic and five endangered marine invertebrate species besides a total of 118 migratory bird species for which the site provides critical habitats. Endemic fauna includes Mud Octopuses (Octopus minor), and deposit feeders like Japanese Mud Crabs (Macrophthalmus japonica), Fiddler Crabs (Uca lactea), and Polychaetes (bristle worms), Stimpson’s Ghost Crabs (Ocypode stimpsoni), Yellow Sea Sand Snails (Umbonium thomasi), , as well as various suspension feeders like clams. The site demonstrates the link between geodiversity and biodiversity, and demonstrates the dependence of cultural diversity and human activity on the natural environment.", "unesco_site_url": null, - "main_image_url": "https://whc.unesco.org/document/172487" + "main_image_url": "https://whc.unesco.org/document/225482" }, { "id": 1592, diff --git a/src/storage/app/private/unesco/world_heritage_sites_translation.json b/src/storage/app/private/unesco/world_heritage_sites_translation.json index b401ac3..343efb6 100644 --- a/src/storage/app/private/unesco/world_heritage_sites_translation.json +++ b/src/storage/app/private/unesco/world_heritage_sites_translation.json @@ -4,7 +4,7 @@ "scope": "ALL", "fetched": 1248, "total_count": 1248, - "dumped_at": "2026-06-22T08:46:36+09:00", + "dumped_at": "2026-06-26T08:14:48+09:00", "source": "https:\/\/data.unesco.org\/api\/explore\/v2.1\/catalog\/datasets\/whc001\/records", "order_by": "id_no asc" }, From c4b682a00d25a6f1969a2ec9ac6c6b9135fddd02 Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Sun, 28 Jun 2026 22:24:37 +0900 Subject: [PATCH 032/110] test: add unit tests for SubscriptionFactory --- .../User/Factory/SubscriptionFactoryTest.php | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 src/tests/Unit/Packages/Domains/User/Factory/SubscriptionFactoryTest.php diff --git a/src/tests/Unit/Packages/Domains/User/Factory/SubscriptionFactoryTest.php b/src/tests/Unit/Packages/Domains/User/Factory/SubscriptionFactoryTest.php new file mode 100644 index 0000000..291c018 --- /dev/null +++ b/src/tests/Unit/Packages/Domains/User/Factory/SubscriptionFactoryTest.php @@ -0,0 +1,70 @@ + 'free', + 'expires_at' => null, + ]); + + $this->assertInstanceOf(Subscription::class, $subscription); + $this->assertTrue($subscription->isFree()); + } + + public function test_build_returns_premium_subscription_with_string_expires_at(): void + { + $subscription = SubscriptionFactory::build([ + 'tier' => 'premium', + 'expires_at' => '2027-01-01 00:00:00', + ]); + + $this->assertInstanceOf(Subscription::class, $subscription); + $this->assertFalse($subscription->isFree()); + $this->assertTrue($subscription->isActive(new DateTimeImmutable('2026-01-01'))); + } + + public function test_build_accepts_datetime_immutable_for_expires_at(): void + { + $expiresAt = new DateTimeImmutable('2027-06-01'); + + $subscription = SubscriptionFactory::build([ + 'tier' => 'premium', + 'expires_at' => $expiresAt, + ]); + + $this->assertInstanceOf(Subscription::class, $subscription); + $this->assertTrue($subscription->isActive(new DateTimeImmutable('2026-01-01'))); + } + + public function test_build_accepts_string_for_now(): void + { + $subscription = SubscriptionFactory::build([ + 'tier' => 'premium', + 'expires_at' => '2025-01-01 00:00:00', + 'now' => '2026-01-01 00:00:00', + ]); + + $this->assertTrue($subscription->isExpired()); + } + + public function test_build_accepts_datetime_immutable_for_now(): void + { + $subscription = SubscriptionFactory::build([ + 'tier' => 'premium', + 'expires_at' => '2025-01-01 00:00:00', + 'now' => new DateTimeImmutable('2026-01-01'), + ]); + + $this->assertTrue($subscription->isExpired()); + } +} \ No newline at end of file From d3721f198ea3d84b75969f4d8cd6de0a48828ebf Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Sun, 28 Jun 2026 22:24:55 +0900 Subject: [PATCH 033/110] test: add unit tests for UserEntityFactory --- .../User/Factory/UserEntityFactoryTest.php | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 src/tests/Unit/Packages/Domains/User/Factory/UserEntityFactoryTest.php diff --git a/src/tests/Unit/Packages/Domains/User/Factory/UserEntityFactoryTest.php b/src/tests/Unit/Packages/Domains/User/Factory/UserEntityFactoryTest.php new file mode 100644 index 0000000..e7f27ff --- /dev/null +++ b/src/tests/Unit/Packages/Domains/User/Factory/UserEntityFactoryTest.php @@ -0,0 +1,54 @@ + $faker->unique()->randomNumber(5), + 'first_name' => $faker->firstName(), + 'last_name' => $faker->lastName(), + 'email' => $faker->unique()->safeEmail(), + 'age_range' => 'teens', + 'subscription_tier' => 'free', + 'subscription_expires_at' => null, + ]; + + $entity = UserEntityFactory::build($data); + + $this->assertInstanceOf(UserEntity::class, $entity); + $this->assertSame($data['id'], $entity->getId()); + $this->assertSame($data['first_name'], $entity->getFirstName()); + $this->assertSame($data['last_name'], $entity->getLastName()); + $this->assertSame($data['email'], $entity->getEmail()); + $this->assertSame(AgeRange::Teens, $entity->getAgeRange()); + $this->assertTrue($entity->getSubscription()->isFree()); + } + + public function test_build_passes_expires_at_to_subscription(): void + { + $faker = FakerFactory::create(); + + $entity = UserEntityFactory::build([ + 'id' => $faker->unique()->randomNumber(5), + 'first_name' => $faker->firstName(), + 'last_name' => $faker->lastName(), + 'email' => $faker->unique()->safeEmail(), + 'age_range' => '20s', + 'subscription_tier' => 'premium', + 'subscription_expires_at' => '2027-01-01 00:00:00', + ]); + + $this->assertFalse($entity->getSubscription()->isFree()); + } +} \ No newline at end of file From f18131070fe88b904c80764fe785ad051a7229a6 Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Sun, 28 Jun 2026 22:25:03 +0900 Subject: [PATCH 034/110] test: add unit tests for UserEntity getters including getFullName --- .../Packages/Domains/User/UserEntityTest.php | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 src/tests/Unit/Packages/Domains/User/UserEntityTest.php diff --git a/src/tests/Unit/Packages/Domains/User/UserEntityTest.php b/src/tests/Unit/Packages/Domains/User/UserEntityTest.php new file mode 100644 index 0000000..ca1c121 --- /dev/null +++ b/src/tests/Unit/Packages/Domains/User/UserEntityTest.php @@ -0,0 +1,72 @@ +unique()->randomNumber(5), + $overrides['first_name'] ?? $faker->firstName(), + $overrides['last_name'] ?? $faker->lastName(), + $overrides['email'] ?? $faker->unique()->safeEmail(), + $overrides['age_range'] ?? AgeRange::Twenties, + $overrides['subscription'] ?? $subscription, + ); + } + + public function test_getId_returns_correct_value(): void + { + $entity = $this->buildEntity(['id' => 42]); + $this->assertSame(42, $entity->getId()); + } + + public function test_getFirstName_returns_correct_value(): void + { + $entity = $this->buildEntity(['first_name' => 'John']); + $this->assertSame('John', $entity->getFirstName()); + } + + public function test_getLastName_returns_correct_value(): void + { + $entity = $this->buildEntity(['last_name' => 'Doe']); + $this->assertSame('Doe', $entity->getLastName()); + } + + public function test_getFullName_concatenates_first_and_last_name(): void + { + $entity = $this->buildEntity(['first_name' => 'John', 'last_name' => 'Doe']); + $this->assertSame('John Doe', $entity->getFullName()); + } + + public function test_getEmail_returns_correct_value(): void + { + $entity = $this->buildEntity(['email' => 'john@example.com']); + $this->assertSame('john@example.com', $entity->getEmail()); + } + + public function test_getAgeRange_returns_correct_enum(): void + { + $entity = $this->buildEntity(['age_range' => AgeRange::Thirties]); + $this->assertSame(AgeRange::Thirties, $entity->getAgeRange()); + } + + public function test_getSubscription_returns_subscription_instance(): void + { + $subscription = new Subscription(SubscriptionTier::Premium, null); + $entity = $this->buildEntity(['subscription' => $subscription]); + $this->assertSame($subscription, $entity->getSubscription()); + } +} \ No newline at end of file From a77ced6f675bf2a9e06afa7daca07ada0fac43c4 Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Sun, 28 Jun 2026 22:25:17 +0900 Subject: [PATCH 035/110] test: add unit test for UserViewModelFactory --- .../ViewModel/UserViewModelFactoryTest.php | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 src/tests/Unit/Packages/Features/CommandUseCases/Factory/ViewModel/UserViewModelFactoryTest.php diff --git a/src/tests/Unit/Packages/Features/CommandUseCases/Factory/ViewModel/UserViewModelFactoryTest.php b/src/tests/Unit/Packages/Features/CommandUseCases/Factory/ViewModel/UserViewModelFactoryTest.php new file mode 100644 index 0000000..4d2763c --- /dev/null +++ b/src/tests/Unit/Packages/Features/CommandUseCases/Factory/ViewModel/UserViewModelFactoryTest.php @@ -0,0 +1,31 @@ +unique()->randomNumber(5), + firstName: $faker->firstName(), + lastName: $faker->lastName(), + email: $faker->unique()->safeEmail(), + ageRange: 'free', + subscriptionTier: 'free', + subscriptionExpiresAt: null, + ); + + $viewModel = UserViewModelFactory::build($dto); + + $this->assertInstanceOf(UserViewModel::class, $viewModel); + } +} \ No newline at end of file From b39540292b0d38c8d6c66dc85315b60a40817c38 Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Sun, 28 Jun 2026 23:00:46 +0900 Subject: [PATCH 036/110] refactor: move unit tests from tests/Unit/ to app/Packages/ and update namespaces --- .../Domains/Tests}/User/Factory/SubscriptionFactoryTest.php | 3 +-- .../Domains/Tests}/User/Factory/UserEntityFactoryTest.php | 2 +- .../Domains/Tests}/User/Subscription/SubscriptionTest.php | 2 +- .../Packages/Domains/Tests}/User/UserEntityTest.php | 5 ++--- .../Packages/Domains/Tests}/UserRepositoryTest.php | 4 ++-- .../Tests/CommandUseCases}/CreateUserCommandTest.php | 5 ++--- .../Tests/CommandUseCases}/CreateUserUseCaseTest.php | 2 +- .../Tests/CommandUseCases}/UserViewModelFactoryTest.php | 2 +- .../Features/Tests/CommandUseCases}/UserViewModelTest.php | 2 +- .../Features/Tests/QueryUseCases}/UserDtoFactoryTest.php | 2 +- 10 files changed, 13 insertions(+), 16 deletions(-) rename src/{tests/Unit/Packages/Domains => app/Packages/Domains/Tests}/User/Factory/SubscriptionFactoryTest.php (95%) rename src/{tests/Unit/Packages/Domains => app/Packages/Domains/Tests}/User/Factory/UserEntityFactoryTest.php (97%) rename src/{tests/Unit/Packages/Domains => app/Packages/Domains/Tests}/User/Subscription/SubscriptionTest.php (98%) rename src/{tests/Unit/Packages/Domains => app/Packages/Domains/Tests}/User/UserEntityTest.php (96%) rename src/{tests/Unit/Packages/Domains => app/Packages/Domains/Tests}/UserRepositoryTest.php (96%) rename src/{tests/Unit/Packages/Features/CommandUseCases/UseCommand => app/Packages/Features/Tests/CommandUseCases}/CreateUserCommandTest.php (95%) rename src/{tests/Unit/Packages/Features/CommandUseCases/UseCase => app/Packages/Features/Tests/CommandUseCases}/CreateUserUseCaseTest.php (96%) rename src/{tests/Unit/Packages/Features/CommandUseCases/Factory/ViewModel => app/Packages/Features/Tests/CommandUseCases}/UserViewModelFactoryTest.php (92%) rename src/{tests/Unit/Packages/Features/CommandUseCases/ViewModel => app/Packages/Features/Tests/CommandUseCases}/UserViewModelTest.php (96%) rename src/{tests/Unit/Packages/Features/QueryUseCases/Factory/Dto => app/Packages/Features/Tests/QueryUseCases}/UserDtoFactoryTest.php (96%) diff --git a/src/tests/Unit/Packages/Domains/User/Factory/SubscriptionFactoryTest.php b/src/app/Packages/Domains/Tests/User/Factory/SubscriptionFactoryTest.php similarity index 95% rename from src/tests/Unit/Packages/Domains/User/Factory/SubscriptionFactoryTest.php rename to src/app/Packages/Domains/Tests/User/Factory/SubscriptionFactoryTest.php index 291c018..b137593 100644 --- a/src/tests/Unit/Packages/Domains/User/Factory/SubscriptionFactoryTest.php +++ b/src/app/Packages/Domains/Tests/User/Factory/SubscriptionFactoryTest.php @@ -1,10 +1,9 @@ buildUserModelMock(wasRecentlyCreated: true)); - $result = $repository->createUser($this->buildCommand()); + $result = $repository->createUser($this->buildCommand()); $this->assertInstanceOf(UserDto::class, $result); } diff --git a/src/tests/Unit/Packages/Features/CommandUseCases/UseCommand/CreateUserCommandTest.php b/src/app/Packages/Features/Tests/CommandUseCases/CreateUserCommandTest.php similarity index 95% rename from src/tests/Unit/Packages/Features/CommandUseCases/UseCommand/CreateUserCommandTest.php rename to src/app/Packages/Features/Tests/CommandUseCases/CreateUserCommandTest.php index 057b5aa..8957489 100644 --- a/src/tests/Unit/Packages/Features/CommandUseCases/UseCommand/CreateUserCommandTest.php +++ b/src/app/Packages/Features/Tests/CommandUseCases/CreateUserCommandTest.php @@ -1,6 +1,6 @@ validData(); - + $data = $this->validData(); $command = CreateUserCommand::fromArray($data); $this->assertSame($data['first_name'], $command->firstName); diff --git a/src/tests/Unit/Packages/Features/CommandUseCases/UseCase/CreateUserUseCaseTest.php b/src/app/Packages/Features/Tests/CommandUseCases/CreateUserUseCaseTest.php similarity index 96% rename from src/tests/Unit/Packages/Features/CommandUseCases/UseCase/CreateUserUseCaseTest.php rename to src/app/Packages/Features/Tests/CommandUseCases/CreateUserUseCaseTest.php index 0756789..683fe4f 100644 --- a/src/tests/Unit/Packages/Features/CommandUseCases/UseCase/CreateUserUseCaseTest.php +++ b/src/app/Packages/Features/Tests/CommandUseCases/CreateUserUseCaseTest.php @@ -1,6 +1,6 @@ Date: Mon, 29 Jun 2026 08:47:55 +0900 Subject: [PATCH 037/110] refactor: change Repository interface argument from UseCommand to UserEntity --- .../Interface/UserRepositroyInterface.php | 4 +- .../User/Factory/UserEntityFactory.php | 13 +-- src/app/Packages/Domains/User/UserEntity.php | 10 ++- src/app/Packages/Domains/UserRepository.php | 18 ++--- .../UseCase/User/CreateUserUseCase.php | 14 +++- .../Packages/Domains/UserRepositoryTest.php | 79 ------------------- 6 files changed, 39 insertions(+), 99 deletions(-) delete mode 100644 src/tests/Unit/Packages/Domains/UserRepositoryTest.php diff --git a/src/app/Packages/Domains/Interface/UserRepositroyInterface.php b/src/app/Packages/Domains/Interface/UserRepositroyInterface.php index a71cbd8..1210fd0 100644 --- a/src/app/Packages/Domains/Interface/UserRepositroyInterface.php +++ b/src/app/Packages/Domains/Interface/UserRepositroyInterface.php @@ -2,10 +2,10 @@ namespace App\Packages\Domains\Interface; -use App\Packages\Features\CommandUseCases\UseCommand\User\CreateUserCommand; +use App\Packages\Domains\User\UserEntity; use App\Packages\Features\QueryUseCases\Dto\User\UserDto; interface UserRepositroyInterface { - public function createUser(CreateUserCommand $command): UserDto; + public function createUser(UserEntity $entity): UserDto; } \ No newline at end of file diff --git a/src/app/Packages/Domains/User/Factory/UserEntityFactory.php b/src/app/Packages/Domains/User/Factory/UserEntityFactory.php index a2deba3..5f7825e 100644 --- a/src/app/Packages/Domains/User/Factory/UserEntityFactory.php +++ b/src/app/Packages/Domains/User/Factory/UserEntityFactory.php @@ -17,12 +17,13 @@ public static function build(array $data): UserEntity ]); return new UserEntity( - $data['id'], - $data['first_name'], - $data['last_name'], - $data['email'], - AgeRange::from($data['age_range']), - $subscription, + id: isset($data['id']) ? (int) $data['id'] : null, + firstName: $data['first_name'], + lastName: $data['last_name'], + email: $data['email'], + ageRange: AgeRange::from($data['age_range']), + subscription: $subscription, + passwordHash: $data['password_hash'] ?? null, ); } } \ No newline at end of file diff --git a/src/app/Packages/Domains/User/UserEntity.php b/src/app/Packages/Domains/User/UserEntity.php index 961595e..a8f517c 100644 --- a/src/app/Packages/Domains/User/UserEntity.php +++ b/src/app/Packages/Domains/User/UserEntity.php @@ -7,19 +7,25 @@ final readonly class UserEntity { public function __construct( - private readonly int $id, + private readonly ?int $id, private readonly string $firstName, private readonly string $lastName, private readonly string $email, private readonly AgeRange $ageRange, private readonly Subscription $subscription, + private readonly ?string $passwordHash = null, ) {} - public function getId(): int + public function getId(): ?int { return $this->id; } + public function getPasswordHash(): ?string + { + return $this->passwordHash; + } + public function getFirstName(): string { return $this->firstName; diff --git a/src/app/Packages/Domains/UserRepository.php b/src/app/Packages/Domains/UserRepository.php index 10478b4..a8df637 100644 --- a/src/app/Packages/Domains/UserRepository.php +++ b/src/app/Packages/Domains/UserRepository.php @@ -4,7 +4,7 @@ use App\Models\User; use App\Packages\Domains\Interface\UserRepositroyInterface; -use App\Packages\Features\CommandUseCases\UseCommand\User\CreateUserCommand; +use App\Packages\Domains\User\UserEntity; use App\Packages\Features\QueryUseCases\Dto\User\UserDto; use App\Packages\Features\QueryUseCases\Factory\Dto\UserDtoFactory; use Exception; @@ -15,16 +15,16 @@ public function __construct( private User $userModel ) {} - public function createUser(CreateUserCommand $command): UserDto + public function createUser(UserEntity $entity): UserDto { $insertUser = $this->userModel->create([ - 'email' => $command->email, - 'first_name' => $command->firstName, - 'last_name' => $command->lastName, - 'password' => bcrypt($command->password), - 'age_range' => $command->ageRange, - 'subscription_tier' => $command->subscriptionTier, - 'subscription_expires_at' => $command->subscriptionExpiresAt, + 'email' => $entity->getEmail(), + 'first_name' => $entity->getFirstName(), + 'last_name' => $entity->getLastName(), + 'password' => $entity->getPasswordHash(), + 'age_range' => $entity->getAgeRange()->value, + 'subscription_tier' => $entity->getSubscription()->getTier()->value, + 'subscription_expires_at' => $entity->getSubscription()->getExpiresAt()?->format('Y-m-d H:i:s'), ]); if (!$insertUser->wasRecentlyCreated) { diff --git a/src/app/Packages/Features/CommandUseCases/UseCase/User/CreateUserUseCase.php b/src/app/Packages/Features/CommandUseCases/UseCase/User/CreateUserUseCase.php index 29c4289..1abeb92 100644 --- a/src/app/Packages/Features/CommandUseCases/UseCase/User/CreateUserUseCase.php +++ b/src/app/Packages/Features/CommandUseCases/UseCase/User/CreateUserUseCase.php @@ -3,6 +3,7 @@ namespace App\Packages\Features\CommandUseCases\UseCase\User; use App\Packages\Domains\Interface\UserRepositroyInterface; +use App\Packages\Domains\User\Factory\UserEntityFactory; use App\Packages\Features\CommandUseCases\UseCommand\User\CreateUserCommand; use App\Packages\Features\QueryUseCases\Dto\User\UserDto; @@ -14,6 +15,17 @@ public function __construct( public function handle(CreateUserCommand $command): UserDto { - return $this->userRepository->createUser($command); + $entity = UserEntityFactory::build([ + 'id' => null, + 'first_name' => $command->firstName, + 'last_name' => $command->lastName, + 'email' => $command->email, + 'age_range' => $command->ageRange, + 'subscription_tier' => $command->subscriptionTier, + 'subscription_expires_at' => $command->subscriptionExpiresAt, + 'password_hash' => bcrypt($command->password), + ]); + + return $this->userRepository->createUser($entity); } } \ No newline at end of file diff --git a/src/tests/Unit/Packages/Domains/UserRepositoryTest.php b/src/tests/Unit/Packages/Domains/UserRepositoryTest.php deleted file mode 100644 index 8fccd2d..0000000 --- a/src/tests/Unit/Packages/Domains/UserRepositoryTest.php +++ /dev/null @@ -1,79 +0,0 @@ - $faker->firstName(), - 'last_name' => $faker->lastName(), - 'email' => $faker->unique()->safeEmail(), - 'age_range' => $faker->randomElement(['teens', '20s', '30s', '40s', '50s', '60plus']), - 'subscription_tier' => $faker->randomElement(['free', 'premium']), - 'subscription_expires_at' => null, - ]); - } - - private function buildUserModelMock(bool $wasRecentlyCreated): User - { - $faker = FakerFactory::create(); - - $attributes = [ - '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', - 'subscription_expires_at' => null, - ]; - - /** @var User|MockInterface $createdUser */ - $createdUser = Mockery::mock(User::class); - $createdUser->wasRecentlyCreated = $wasRecentlyCreated; - $createdUser->shouldReceive('toArray')->andReturn($attributes); - - /** @var User|MockInterface $userModel */ - $userModel = Mockery::mock(User::class); - $userModel->shouldReceive('create')->andReturn($createdUser); - - return $userModel; - } - - public function test_createUser_returns_user_dto_on_success(): void - { - $repository = new UserRepository($this->buildUserModelMock(wasRecentlyCreated: true)); - $result = $repository->createUser($this->buildCommand()); - - $this->assertInstanceOf(UserDto::class, $result); - } - - public function test_createUser_throws_exception_when_insert_fails(): void - { - $repository = new UserRepository($this->buildUserModelMock(wasRecentlyCreated: false)); - - $this->expectException(Exception::class); - $this->expectExceptionMessage('Failed to create user.'); - - $repository->createUser($this->buildCommand()); - } -} \ No newline at end of file From 43d9c6f24b69effcf69c2aadbf9de25acd02d52c Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Mon, 29 Jun 2026 08:48:16 +0900 Subject: [PATCH 038/110] test: update and add unit tests for Entity-based Repository interface --- .../User/Factory/UserEntityFactoryTest.php | 74 +++++++++++++++ .../Domains/Tests/User/UserEntityTest.php | 90 +++++++++++++++++++ .../Domains/Tests/UserRepositoryTest.php | 83 +++++++++++++++++ .../CommandUseCases/CreateUserUseCaseTest.php | 69 ++++++++++++++ 4 files changed, 316 insertions(+) create mode 100644 src/app/Packages/Domains/Tests/User/Factory/UserEntityFactoryTest.php create mode 100644 src/app/Packages/Domains/Tests/User/UserEntityTest.php create mode 100644 src/app/Packages/Domains/Tests/UserRepositoryTest.php create mode 100644 src/app/Packages/Features/Tests/CommandUseCases/CreateUserUseCaseTest.php diff --git a/src/app/Packages/Domains/Tests/User/Factory/UserEntityFactoryTest.php b/src/app/Packages/Domains/Tests/User/Factory/UserEntityFactoryTest.php new file mode 100644 index 0000000..3b8119e --- /dev/null +++ b/src/app/Packages/Domains/Tests/User/Factory/UserEntityFactoryTest.php @@ -0,0 +1,74 @@ + $faker->unique()->randomNumber(5), + 'first_name' => $faker->firstName(), + 'last_name' => $faker->lastName(), + 'email' => $faker->unique()->safeEmail(), + 'age_range' => 'teens', + 'subscription_tier' => 'free', + 'subscription_expires_at' => null, + ]; + + $entity = UserEntityFactory::build($data); + + $this->assertInstanceOf(UserEntity::class, $entity); + $this->assertSame($data['id'], $entity->getId()); + $this->assertSame($data['first_name'], $entity->getFirstName()); + $this->assertSame($data['last_name'], $entity->getLastName()); + $this->assertSame($data['email'], $entity->getEmail()); + $this->assertSame(AgeRange::Teens, $entity->getAgeRange()); + $this->assertTrue($entity->getSubscription()->isFree()); + $this->assertNull($entity->getPasswordHash()); + } + + public function test_build_sets_password_hash_when_provided(): void + { + $faker = FakerFactory::create(); + + $entity = UserEntityFactory::build([ + 'id' => null, + 'first_name' => $faker->firstName(), + 'last_name' => $faker->lastName(), + 'email' => $faker->unique()->safeEmail(), + 'age_range' => '20s', + 'subscription_tier' => 'free', + 'subscription_expires_at' => null, + 'password_hash' => 'hashed_value', + ]); + + $this->assertNull($entity->getId()); + $this->assertSame('hashed_value', $entity->getPasswordHash()); + } + + public function test_build_passes_expires_at_to_subscription(): void + { + $faker = FakerFactory::create(); + + $entity = UserEntityFactory::build([ + 'id' => $faker->unique()->randomNumber(5), + 'first_name' => $faker->firstName(), + 'last_name' => $faker->lastName(), + 'email' => $faker->unique()->safeEmail(), + 'age_range' => '20s', + 'subscription_tier' => 'premium', + 'subscription_expires_at' => '2027-01-01 00:00:00', + ]); + + $this->assertFalse($entity->getSubscription()->isFree()); + } +} \ No newline at end of file diff --git a/src/app/Packages/Domains/Tests/User/UserEntityTest.php b/src/app/Packages/Domains/Tests/User/UserEntityTest.php new file mode 100644 index 0000000..6ce7e46 --- /dev/null +++ b/src/app/Packages/Domains/Tests/User/UserEntityTest.php @@ -0,0 +1,90 @@ +unique()->randomNumber(5), + firstName: $overrides['first_name'] ?? $faker->firstName(), + lastName: $overrides['last_name'] ?? $faker->lastName(), + email: $overrides['email'] ?? $faker->unique()->safeEmail(), + ageRange: $overrides['age_range'] ?? AgeRange::Twenties, + subscription: $overrides['subscription'] ?? $subscription, + passwordHash: $overrides['password_hash'] ?? null, + ); + } + + public function test_getId_returns_correct_value(): void + { + $entity = $this->buildEntity(['id' => 42]); + $this->assertSame(42, $entity->getId()); + } + + public function test_getId_returns_null_when_not_yet_persisted(): void + { + $entity = $this->buildEntity(['id' => null]); + $this->assertNull($entity->getId()); + } + + public function test_getFirstName_returns_correct_value(): void + { + $entity = $this->buildEntity(['first_name' => 'John']); + $this->assertSame('John', $entity->getFirstName()); + } + + public function test_getLastName_returns_correct_value(): void + { + $entity = $this->buildEntity(['last_name' => 'Doe']); + $this->assertSame('Doe', $entity->getLastName()); + } + + public function test_getFullName_concatenates_first_and_last_name(): void + { + $entity = $this->buildEntity(['first_name' => 'John', 'last_name' => 'Doe']); + $this->assertSame('John Doe', $entity->getFullName()); + } + + public function test_getEmail_returns_correct_value(): void + { + $entity = $this->buildEntity(['email' => 'john@example.com']); + $this->assertSame('john@example.com', $entity->getEmail()); + } + + public function test_getAgeRange_returns_correct_enum(): void + { + $entity = $this->buildEntity(['age_range' => AgeRange::Thirties]); + $this->assertSame(AgeRange::Thirties, $entity->getAgeRange()); + } + + public function test_getSubscription_returns_subscription_instance(): void + { + $subscription = new Subscription(SubscriptionTier::Premium, null); + $entity = $this->buildEntity(['subscription' => $subscription]); + $this->assertSame($subscription, $entity->getSubscription()); + } + + public function test_getPasswordHash_returns_hashed_value(): void + { + $entity = $this->buildEntity(['password_hash' => 'hashed_secret']); + $this->assertSame('hashed_secret', $entity->getPasswordHash()); + } + + public function test_getPasswordHash_returns_null_when_not_set(): void + { + $entity = $this->buildEntity(['password_hash' => null]); + $this->assertNull($entity->getPasswordHash()); + } +} \ No newline at end of file diff --git a/src/app/Packages/Domains/Tests/UserRepositoryTest.php b/src/app/Packages/Domains/Tests/UserRepositoryTest.php new file mode 100644 index 0000000..f79c482 --- /dev/null +++ b/src/app/Packages/Domains/Tests/UserRepositoryTest.php @@ -0,0 +1,83 @@ + null, + 'first_name' => $faker->firstName(), + 'last_name' => $faker->lastName(), + 'email' => $faker->unique()->safeEmail(), + 'age_range' => $faker->randomElement(['teens', '20s', '30s', '40s', '50s', '60plus']), + 'subscription_tier' => $faker->randomElement(['free', 'premium']), + 'subscription_expires_at' => null, + 'password_hash' => 'hashed_password', + ]); + } + + private function buildUserModelMock(bool $wasRecentlyCreated): User + { + $faker = FakerFactory::create(); + + $attributes = [ + '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', + 'subscription_expires_at' => null, + ]; + + /** @var User|MockInterface $createdUser */ + $createdUser = Mockery::mock(User::class); + $createdUser->wasRecentlyCreated = $wasRecentlyCreated; + $createdUser->shouldReceive('toArray')->andReturn($attributes); + + /** @var User|MockInterface $userModel */ + $userModel = Mockery::mock(User::class); + $userModel->shouldReceive('create')->andReturn($createdUser); + + return $userModel; + } + + public function test_createUser_returns_user_dto_on_success(): void + { + $repository = new UserRepository($this->buildUserModelMock(wasRecentlyCreated: true)); + $result = $repository->createUser($this->buildEntity()); + + $this->assertInstanceOf(UserDto::class, $result); + } + + public function test_createUser_throws_exception_when_insert_fails(): void + { + $repository = new UserRepository($this->buildUserModelMock(wasRecentlyCreated: false)); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('Failed to create user.'); + + $repository->createUser($this->buildEntity()); + } +} \ No newline at end of file diff --git a/src/app/Packages/Features/Tests/CommandUseCases/CreateUserUseCaseTest.php b/src/app/Packages/Features/Tests/CommandUseCases/CreateUserUseCaseTest.php new file mode 100644 index 0000000..233553d --- /dev/null +++ b/src/app/Packages/Features/Tests/CommandUseCases/CreateUserUseCaseTest.php @@ -0,0 +1,69 @@ + $faker->firstName(), + 'last_name' => $faker->lastName(), + 'email' => $faker->unique()->safeEmail(), + 'password' => $faker->password(8), + 'age_range' => $faker->randomElement(['teens', '20s', '30s', '40s', '50s', '60plus']), + 'subscription_tier' => 'free', + ]); + } + + private function buildDto(): UserDto + { + $faker = FakerFactory::create(); + + return new UserDto( + id: $faker->unique()->randomNumber(5), + firstName: $faker->firstName(), + lastName: $faker->lastName(), + email: $faker->unique()->safeEmail(), + ageRange: 'free', + subscriptionTier: 'free', + subscriptionExpiresAt: null, + ); + } + + public function test_handle_passes_entity_to_repository_and_returns_dto(): void + { + $command = $this->buildCommand(); + $dto = $this->buildDto(); + + /** @var UserRepositroyInterface|MockInterface $repository */ + $repository = Mockery::mock(UserRepositroyInterface::class); + $repository->shouldReceive('createUser') + ->once() + ->with(Mockery::type(UserEntity::class)) + ->andReturn($dto); + + $useCase = new CreateUserUseCase($repository); + $result = $useCase->handle($command); + + $this->assertSame($dto, $result); + } +} \ No newline at end of file From a884076bb787226074386d9c16293cdcedde31df Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Mon, 29 Jun 2026 09:05:51 +0900 Subject: [PATCH 039/110] fix: use array_key_exists to correctly handle null id in UserEntityTest --- src/app/Packages/Domains/Tests/User/UserEntityTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/Packages/Domains/Tests/User/UserEntityTest.php b/src/app/Packages/Domains/Tests/User/UserEntityTest.php index d209315..94c8635 100644 --- a/src/app/Packages/Domains/Tests/User/UserEntityTest.php +++ b/src/app/Packages/Domains/Tests/User/UserEntityTest.php @@ -17,7 +17,7 @@ private function buildEntity(array $overrides = []): UserEntity $subscription = new Subscription(SubscriptionTier::Free, null); return new UserEntity( - id: $overrides['id'] ?? $faker->unique()->randomNumber(5), + id: array_key_exists('id', $overrides) ? $overrides['id'] : $faker->unique()->randomNumber(5), firstName: $overrides['first_name'] ?? $faker->firstName(), lastName: $overrides['last_name'] ?? $faker->lastName(), email: $overrides['email'] ?? $faker->unique()->safeEmail(), From 0a1611c47bc56acb4e899e87e04cf2431d2bfcc9 Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Mon, 29 Jun 2026 09:13:48 +0900 Subject: [PATCH 040/110] feat: add updateUser to UserRepositroyInterface and UserRepository --- .../Interface/UserRepositroyInterface.php | 2 ++ src/app/Packages/Domains/UserRepository.php | 22 +++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/src/app/Packages/Domains/Interface/UserRepositroyInterface.php b/src/app/Packages/Domains/Interface/UserRepositroyInterface.php index 1210fd0..ab2f3b0 100644 --- a/src/app/Packages/Domains/Interface/UserRepositroyInterface.php +++ b/src/app/Packages/Domains/Interface/UserRepositroyInterface.php @@ -8,4 +8,6 @@ interface UserRepositroyInterface { public function createUser(UserEntity $entity): UserDto; + + public function updateUser(UserEntity $entity): UserDto; } \ No newline at end of file diff --git a/src/app/Packages/Domains/UserRepository.php b/src/app/Packages/Domains/UserRepository.php index a8df637..3b011df 100644 --- a/src/app/Packages/Domains/UserRepository.php +++ b/src/app/Packages/Domains/UserRepository.php @@ -33,4 +33,26 @@ public function createUser(UserEntity $entity): UserDto return UserDtoFactory::build($insertUser->toArray()); } + + public function updateUser(UserEntity $entity): UserDto + { + $user = $this->userModel->find($entity->getId()); + + if ($user === null) { + throw new Exception('User not found.'); + } + + $user->update([ + 'first_name' => $entity->getFirstName(), + 'last_name' => $entity->getLastName(), + 'email' => $entity->getEmail(), + 'age_range' => $entity->getAgeRange()->value, + 'subscription_tier' => $entity->getSubscription()->getTier()->value, + 'subscription_expires_at' => $entity->getSubscription()->getExpiresAt()?->format('Y-m-d H:i:s'), + ]); + + $user->refresh(); + + return UserDtoFactory::build($user->toArray()); + } } \ No newline at end of file From d410cdcf69f2cfe8c87b830d0c3173e1066599fc Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Mon, 29 Jun 2026 09:14:02 +0900 Subject: [PATCH 041/110] test: add unit tests for updateUser in UserRepositoryTest --- .../Domains/Tests/UserRepositoryTest.php | 74 ++++++++++++++++++- 1 file changed, 70 insertions(+), 4 deletions(-) diff --git a/src/app/Packages/Domains/Tests/UserRepositoryTest.php b/src/app/Packages/Domains/Tests/UserRepositoryTest.php index f79c482..1801cf1 100644 --- a/src/app/Packages/Domains/Tests/UserRepositoryTest.php +++ b/src/app/Packages/Domains/Tests/UserRepositoryTest.php @@ -37,11 +37,11 @@ private function buildEntity(): UserEntity ]); } - private function buildUserModelMock(bool $wasRecentlyCreated): User + private function buildAttributes(): array { $faker = FakerFactory::create(); - $attributes = [ + return [ 'id' => $faker->unique()->randomNumber(5), 'first_name' => $faker->firstName(), 'last_name' => $faker->lastName(), @@ -50,6 +50,11 @@ private function buildUserModelMock(bool $wasRecentlyCreated): User 'subscription_tier' => 'free', 'subscription_expires_at' => null, ]; + } + + private function buildCreateModelMock(bool $wasRecentlyCreated): User + { + $attributes = $this->buildAttributes(); /** @var User|MockInterface $createdUser */ $createdUser = Mockery::mock(User::class); @@ -63,9 +68,31 @@ private function buildUserModelMock(bool $wasRecentlyCreated): User return $userModel; } + private function buildUpdateModelMock(?int $foundId): User + { + $attributes = $this->buildAttributes(); + + /** @var User|MockInterface $userModel */ + $userModel = Mockery::mock(User::class); + + if ($foundId === null) { + $userModel->shouldReceive('find')->andReturn(null); + } else { + /** @var User|MockInterface $foundUser */ + $foundUser = Mockery::mock(User::class)->makePartial(); + $foundUser->shouldReceive('update')->andReturn(true); + $foundUser->shouldReceive('refresh')->andReturn(null); + $foundUser->shouldReceive('toArray')->andReturn($attributes); + + $userModel->shouldReceive('find')->with($foundId)->andReturn($foundUser); + } + + return $userModel; + } + public function test_createUser_returns_user_dto_on_success(): void { - $repository = new UserRepository($this->buildUserModelMock(wasRecentlyCreated: true)); + $repository = new UserRepository($this->buildCreateModelMock(wasRecentlyCreated: true)); $result = $repository->createUser($this->buildEntity()); $this->assertInstanceOf(UserDto::class, $result); @@ -73,11 +100,50 @@ public function test_createUser_returns_user_dto_on_success(): void public function test_createUser_throws_exception_when_insert_fails(): void { - $repository = new UserRepository($this->buildUserModelMock(wasRecentlyCreated: false)); + $repository = new UserRepository($this->buildCreateModelMock(wasRecentlyCreated: false)); $this->expectException(Exception::class); $this->expectExceptionMessage('Failed to create user.'); $repository->createUser($this->buildEntity()); } + + public function test_updateUser_returns_user_dto_on_success(): void + { + $existingId = 1; + $entity = UserEntityFactory::build([ + 'id' => $existingId, + 'first_name' => 'Updated', + 'last_name' => 'Name', + 'email' => 'updated@example.com', + 'age_range' => 'teens', + 'subscription_tier' => 'free', + 'subscription_expires_at' => null, + ]); + + $repository = new UserRepository($this->buildUpdateModelMock(foundId: $existingId)); + $result = $repository->updateUser($entity); + + $this->assertInstanceOf(UserDto::class, $result); + } + + public function test_updateUser_throws_exception_when_user_not_found(): void + { + $entity = UserEntityFactory::build([ + 'id' => 999, + 'first_name' => 'Ghost', + 'last_name' => 'User', + 'email' => 'ghost@example.com', + 'age_range' => 'teens', + 'subscription_tier' => 'free', + 'subscription_expires_at' => null, + ]); + + $repository = new UserRepository($this->buildUpdateModelMock(foundId: null)); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('User not found.'); + + $repository->updateUser($entity); + } } \ No newline at end of file From 52c87fa97450b22ad902d1b148ad03991e0c8886 Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Mon, 29 Jun 2026 15:38:03 +0900 Subject: [PATCH 042/110] feat: add findById signature to UserRepositroyInterface --- src/app/Packages/Domains/Interface/UserRepositroyInterface.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/app/Packages/Domains/Interface/UserRepositroyInterface.php b/src/app/Packages/Domains/Interface/UserRepositroyInterface.php index ab2f3b0..cad8f5e 100644 --- a/src/app/Packages/Domains/Interface/UserRepositroyInterface.php +++ b/src/app/Packages/Domains/Interface/UserRepositroyInterface.php @@ -9,5 +9,7 @@ interface UserRepositroyInterface { public function createUser(UserEntity $entity): UserDto; + public function findById(int $id): ?UserDto; + public function updateUser(UserEntity $entity): UserDto; } \ No newline at end of file From 80986e2b86aa35dc479e1dbb80c9d4f8762236cd Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Mon, 29 Jun 2026 15:38:08 +0900 Subject: [PATCH 043/110] feat: implement findById in UserRepository --- src/app/Packages/Domains/UserRepository.php | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/app/Packages/Domains/UserRepository.php b/src/app/Packages/Domains/UserRepository.php index 3b011df..87166cd 100644 --- a/src/app/Packages/Domains/UserRepository.php +++ b/src/app/Packages/Domains/UserRepository.php @@ -34,6 +34,17 @@ public function createUser(UserEntity $entity): UserDto return UserDtoFactory::build($insertUser->toArray()); } + public function findById(int $id): ?UserDto + { + $user = $this->userModel->find($id); + + if ($user === null) { + return null; + } + + return UserDtoFactory::build($user->toArray()); + } + public function updateUser(UserEntity $entity): UserDto { $user = $this->userModel->find($entity->getId()); From 030a03e4cfefabe42aea59b42ea2c65bb9403ff4 Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Mon, 29 Jun 2026 15:38:20 +0900 Subject: [PATCH 044/110] test: rewrite UserRepositoryTest as DB integration tests --- .../Domains/Tests/UserRepositoryTest.php | 147 ++++++++---------- 1 file changed, 61 insertions(+), 86 deletions(-) diff --git a/src/app/Packages/Domains/Tests/UserRepositoryTest.php b/src/app/Packages/Domains/Tests/UserRepositoryTest.php index 1801cf1..9262cfd 100644 --- a/src/app/Packages/Domains/Tests/UserRepositoryTest.php +++ b/src/app/Packages/Domains/Tests/UserRepositoryTest.php @@ -3,147 +3,122 @@ namespace App\Packages\Domains\Tests; use App\Models\User; -use App\Packages\Domains\UserRepository; +use App\Packages\Domains\User\AgeRange; use App\Packages\Domains\User\Factory\UserEntityFactory; +use App\Packages\Domains\User\Subscription\Subscription; +use App\Packages\Domains\User\Subscription\SubscriptionTier; use App\Packages\Domains\User\UserEntity; +use App\Packages\Domains\UserRepository; use App\Packages\Features\QueryUseCases\Dto\User\UserDto; -use Exception; use Faker\Factory as FakerFactory; -use Mockery; -use Mockery\MockInterface; -use PHPUnit\Framework\TestCase; +use Illuminate\Support\Facades\DB; +use Tests\TestCase; class UserRepositoryTest extends TestCase { + protected function setUp(): void + { + parent::setUp(); + $this->truncate(); + } + protected function tearDown(): void { - Mockery::close(); + $this->truncate(); parent::tearDown(); } - private function buildEntity(): UserEntity + private function truncate(): void + { + if (env('APP_ENV') === 'testing') { + DB::connection('mysql')->statement('SET FOREIGN_KEY_CHECKS=0;'); + User::truncate(); + DB::connection('mysql')->statement('SET FOREIGN_KEY_CHECKS=1;'); + } + } + + private function repository(): UserRepository + { + return new UserRepository(new User()); + } + + private function seedUser(array $overrides = []): User { $faker = FakerFactory::create(); - return UserEntityFactory::build([ - 'id' => null, + return User::create(array_merge([ 'first_name' => $faker->firstName(), 'last_name' => $faker->lastName(), 'email' => $faker->unique()->safeEmail(), - 'age_range' => $faker->randomElement(['teens', '20s', '30s', '40s', '50s', '60plus']), - 'subscription_tier' => $faker->randomElement(['free', 'premium']), + 'password' => bcrypt('password'), + 'age_range' => 'teens', + 'subscription_tier' => 'free', 'subscription_expires_at' => null, - 'password_hash' => 'hashed_password', - ]); + ], $overrides)); } - private function buildAttributes(): array + private function buildEntity(array $overrides = []): UserEntity { $faker = FakerFactory::create(); - return [ - 'id' => $faker->unique()->randomNumber(5), + return UserEntityFactory::build(array_merge([ + 'id' => null, 'first_name' => $faker->firstName(), 'last_name' => $faker->lastName(), 'email' => $faker->unique()->safeEmail(), - 'age_range' => $faker->randomElement(['teens', '20s', '30s', '40s', '50s', '60plus']), + 'age_range' => 'teens', 'subscription_tier' => 'free', 'subscription_expires_at' => null, - ]; - } - - private function buildCreateModelMock(bool $wasRecentlyCreated): User - { - $attributes = $this->buildAttributes(); - - /** @var User|MockInterface $createdUser */ - $createdUser = Mockery::mock(User::class); - $createdUser->wasRecentlyCreated = $wasRecentlyCreated; - $createdUser->shouldReceive('toArray')->andReturn($attributes); - - /** @var User|MockInterface $userModel */ - $userModel = Mockery::mock(User::class); - $userModel->shouldReceive('create')->andReturn($createdUser); - - return $userModel; + 'password_hash' => bcrypt('password'), + ], $overrides)); } - private function buildUpdateModelMock(?int $foundId): User + public function test_createUser_persists_user_and_returns_dto(): void { - $attributes = $this->buildAttributes(); + $result = $this->repository()->createUser($this->buildEntity()); - /** @var User|MockInterface $userModel */ - $userModel = Mockery::mock(User::class); - - if ($foundId === null) { - $userModel->shouldReceive('find')->andReturn(null); - } else { - /** @var User|MockInterface $foundUser */ - $foundUser = Mockery::mock(User::class)->makePartial(); - $foundUser->shouldReceive('update')->andReturn(true); - $foundUser->shouldReceive('refresh')->andReturn(null); - $foundUser->shouldReceive('toArray')->andReturn($attributes); - - $userModel->shouldReceive('find')->with($foundId)->andReturn($foundUser); - } - - return $userModel; + $this->assertInstanceOf(UserDto::class, $result); + $this->assertDatabaseHas('users', ['email' => $result->email]); } - public function test_createUser_returns_user_dto_on_success(): void + public function test_findById_returns_user_dto_when_user_exists(): void { - $repository = new UserRepository($this->buildCreateModelMock(wasRecentlyCreated: true)); - $result = $repository->createUser($this->buildEntity()); + $user = $this->seedUser(); + $result = $this->repository()->findById($user->id); $this->assertInstanceOf(UserDto::class, $result); + $this->assertSame($user->id, $result->id); + $this->assertSame($user->email, $result->email); } - public function test_createUser_throws_exception_when_insert_fails(): void + public function test_findById_returns_null_when_user_not_found(): void { - $repository = new UserRepository($this->buildCreateModelMock(wasRecentlyCreated: false)); + $result = $this->repository()->findById(999999); - $this->expectException(Exception::class); - $this->expectExceptionMessage('Failed to create user.'); - - $repository->createUser($this->buildEntity()); + $this->assertNull($result); } - public function test_updateUser_returns_user_dto_on_success(): void + public function test_updateUser_persists_changes_and_returns_dto(): void { - $existingId = 1; - $entity = UserEntityFactory::build([ - 'id' => $existingId, - 'first_name' => 'Updated', - 'last_name' => 'Name', - 'email' => 'updated@example.com', - 'age_range' => 'teens', - 'subscription_tier' => 'free', - 'subscription_expires_at' => null, + $user = $this->seedUser(['email' => 'before@example.com']); + $entity = $this->buildEntity([ + 'id' => $user->id, + 'email' => 'after@example.com', ]); - $repository = new UserRepository($this->buildUpdateModelMock(foundId: $existingId)); - $result = $repository->updateUser($entity); + $result = $this->repository()->updateUser($entity); $this->assertInstanceOf(UserDto::class, $result); + $this->assertDatabaseHas('users', ['id' => $user->id, 'email' => 'after@example.com']); } public function test_updateUser_throws_exception_when_user_not_found(): void { - $entity = UserEntityFactory::build([ - 'id' => 999, - 'first_name' => 'Ghost', - 'last_name' => 'User', - 'email' => 'ghost@example.com', - 'age_range' => 'teens', - 'subscription_tier' => 'free', - 'subscription_expires_at' => null, - ]); - - $repository = new UserRepository($this->buildUpdateModelMock(foundId: null)); + $entity = $this->buildEntity(['id' => 999999]); - $this->expectException(Exception::class); + $this->expectException(\Exception::class); $this->expectExceptionMessage('User not found.'); - $repository->updateUser($entity); + $this->repository()->updateUser($entity); } } \ No newline at end of file From 416137f885c9ade30fbc107292403c547500d1ea Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Mon, 29 Jun 2026 15:38:03 +0900 Subject: [PATCH 045/110] feat: add findById signature to UserRepositroyInterface --- src/app/Packages/Domains/Interface/UserRepositroyInterface.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/app/Packages/Domains/Interface/UserRepositroyInterface.php b/src/app/Packages/Domains/Interface/UserRepositroyInterface.php index ab2f3b0..cad8f5e 100644 --- a/src/app/Packages/Domains/Interface/UserRepositroyInterface.php +++ b/src/app/Packages/Domains/Interface/UserRepositroyInterface.php @@ -9,5 +9,7 @@ interface UserRepositroyInterface { public function createUser(UserEntity $entity): UserDto; + public function findById(int $id): ?UserDto; + public function updateUser(UserEntity $entity): UserDto; } \ No newline at end of file From 11a15b5f382a2baf42890050bd4c25e5bad29dc7 Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Mon, 29 Jun 2026 15:38:08 +0900 Subject: [PATCH 046/110] feat: implement findById in UserRepository --- src/app/Packages/Domains/UserRepository.php | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/app/Packages/Domains/UserRepository.php b/src/app/Packages/Domains/UserRepository.php index 3b011df..87166cd 100644 --- a/src/app/Packages/Domains/UserRepository.php +++ b/src/app/Packages/Domains/UserRepository.php @@ -34,6 +34,17 @@ public function createUser(UserEntity $entity): UserDto return UserDtoFactory::build($insertUser->toArray()); } + public function findById(int $id): ?UserDto + { + $user = $this->userModel->find($id); + + if ($user === null) { + return null; + } + + return UserDtoFactory::build($user->toArray()); + } + public function updateUser(UserEntity $entity): UserDto { $user = $this->userModel->find($entity->getId()); From 5abc5e2e01e02807311cc5b5d6063207dac48a2d Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Mon, 29 Jun 2026 15:38:20 +0900 Subject: [PATCH 047/110] test: rewrite UserRepositoryTest as DB integration tests --- .../Domains/Tests/UserRepositoryTest.php | 147 ++++++++---------- 1 file changed, 61 insertions(+), 86 deletions(-) diff --git a/src/app/Packages/Domains/Tests/UserRepositoryTest.php b/src/app/Packages/Domains/Tests/UserRepositoryTest.php index 1801cf1..9262cfd 100644 --- a/src/app/Packages/Domains/Tests/UserRepositoryTest.php +++ b/src/app/Packages/Domains/Tests/UserRepositoryTest.php @@ -3,147 +3,122 @@ namespace App\Packages\Domains\Tests; use App\Models\User; -use App\Packages\Domains\UserRepository; +use App\Packages\Domains\User\AgeRange; use App\Packages\Domains\User\Factory\UserEntityFactory; +use App\Packages\Domains\User\Subscription\Subscription; +use App\Packages\Domains\User\Subscription\SubscriptionTier; use App\Packages\Domains\User\UserEntity; +use App\Packages\Domains\UserRepository; use App\Packages\Features\QueryUseCases\Dto\User\UserDto; -use Exception; use Faker\Factory as FakerFactory; -use Mockery; -use Mockery\MockInterface; -use PHPUnit\Framework\TestCase; +use Illuminate\Support\Facades\DB; +use Tests\TestCase; class UserRepositoryTest extends TestCase { + protected function setUp(): void + { + parent::setUp(); + $this->truncate(); + } + protected function tearDown(): void { - Mockery::close(); + $this->truncate(); parent::tearDown(); } - private function buildEntity(): UserEntity + private function truncate(): void + { + if (env('APP_ENV') === 'testing') { + DB::connection('mysql')->statement('SET FOREIGN_KEY_CHECKS=0;'); + User::truncate(); + DB::connection('mysql')->statement('SET FOREIGN_KEY_CHECKS=1;'); + } + } + + private function repository(): UserRepository + { + return new UserRepository(new User()); + } + + private function seedUser(array $overrides = []): User { $faker = FakerFactory::create(); - return UserEntityFactory::build([ - 'id' => null, + return User::create(array_merge([ 'first_name' => $faker->firstName(), 'last_name' => $faker->lastName(), 'email' => $faker->unique()->safeEmail(), - 'age_range' => $faker->randomElement(['teens', '20s', '30s', '40s', '50s', '60plus']), - 'subscription_tier' => $faker->randomElement(['free', 'premium']), + 'password' => bcrypt('password'), + 'age_range' => 'teens', + 'subscription_tier' => 'free', 'subscription_expires_at' => null, - 'password_hash' => 'hashed_password', - ]); + ], $overrides)); } - private function buildAttributes(): array + private function buildEntity(array $overrides = []): UserEntity { $faker = FakerFactory::create(); - return [ - 'id' => $faker->unique()->randomNumber(5), + return UserEntityFactory::build(array_merge([ + 'id' => null, 'first_name' => $faker->firstName(), 'last_name' => $faker->lastName(), 'email' => $faker->unique()->safeEmail(), - 'age_range' => $faker->randomElement(['teens', '20s', '30s', '40s', '50s', '60plus']), + 'age_range' => 'teens', 'subscription_tier' => 'free', 'subscription_expires_at' => null, - ]; - } - - private function buildCreateModelMock(bool $wasRecentlyCreated): User - { - $attributes = $this->buildAttributes(); - - /** @var User|MockInterface $createdUser */ - $createdUser = Mockery::mock(User::class); - $createdUser->wasRecentlyCreated = $wasRecentlyCreated; - $createdUser->shouldReceive('toArray')->andReturn($attributes); - - /** @var User|MockInterface $userModel */ - $userModel = Mockery::mock(User::class); - $userModel->shouldReceive('create')->andReturn($createdUser); - - return $userModel; + 'password_hash' => bcrypt('password'), + ], $overrides)); } - private function buildUpdateModelMock(?int $foundId): User + public function test_createUser_persists_user_and_returns_dto(): void { - $attributes = $this->buildAttributes(); + $result = $this->repository()->createUser($this->buildEntity()); - /** @var User|MockInterface $userModel */ - $userModel = Mockery::mock(User::class); - - if ($foundId === null) { - $userModel->shouldReceive('find')->andReturn(null); - } else { - /** @var User|MockInterface $foundUser */ - $foundUser = Mockery::mock(User::class)->makePartial(); - $foundUser->shouldReceive('update')->andReturn(true); - $foundUser->shouldReceive('refresh')->andReturn(null); - $foundUser->shouldReceive('toArray')->andReturn($attributes); - - $userModel->shouldReceive('find')->with($foundId)->andReturn($foundUser); - } - - return $userModel; + $this->assertInstanceOf(UserDto::class, $result); + $this->assertDatabaseHas('users', ['email' => $result->email]); } - public function test_createUser_returns_user_dto_on_success(): void + public function test_findById_returns_user_dto_when_user_exists(): void { - $repository = new UserRepository($this->buildCreateModelMock(wasRecentlyCreated: true)); - $result = $repository->createUser($this->buildEntity()); + $user = $this->seedUser(); + $result = $this->repository()->findById($user->id); $this->assertInstanceOf(UserDto::class, $result); + $this->assertSame($user->id, $result->id); + $this->assertSame($user->email, $result->email); } - public function test_createUser_throws_exception_when_insert_fails(): void + public function test_findById_returns_null_when_user_not_found(): void { - $repository = new UserRepository($this->buildCreateModelMock(wasRecentlyCreated: false)); + $result = $this->repository()->findById(999999); - $this->expectException(Exception::class); - $this->expectExceptionMessage('Failed to create user.'); - - $repository->createUser($this->buildEntity()); + $this->assertNull($result); } - public function test_updateUser_returns_user_dto_on_success(): void + public function test_updateUser_persists_changes_and_returns_dto(): void { - $existingId = 1; - $entity = UserEntityFactory::build([ - 'id' => $existingId, - 'first_name' => 'Updated', - 'last_name' => 'Name', - 'email' => 'updated@example.com', - 'age_range' => 'teens', - 'subscription_tier' => 'free', - 'subscription_expires_at' => null, + $user = $this->seedUser(['email' => 'before@example.com']); + $entity = $this->buildEntity([ + 'id' => $user->id, + 'email' => 'after@example.com', ]); - $repository = new UserRepository($this->buildUpdateModelMock(foundId: $existingId)); - $result = $repository->updateUser($entity); + $result = $this->repository()->updateUser($entity); $this->assertInstanceOf(UserDto::class, $result); + $this->assertDatabaseHas('users', ['id' => $user->id, 'email' => 'after@example.com']); } public function test_updateUser_throws_exception_when_user_not_found(): void { - $entity = UserEntityFactory::build([ - 'id' => 999, - 'first_name' => 'Ghost', - 'last_name' => 'User', - 'email' => 'ghost@example.com', - 'age_range' => 'teens', - 'subscription_tier' => 'free', - 'subscription_expires_at' => null, - ]); - - $repository = new UserRepository($this->buildUpdateModelMock(foundId: null)); + $entity = $this->buildEntity(['id' => 999999]); - $this->expectException(Exception::class); + $this->expectException(\Exception::class); $this->expectExceptionMessage('User not found.'); - $repository->updateUser($entity); + $this->repository()->updateUser($entity); } } \ No newline at end of file From 6de59b43898f3eb58bc8b14c316afdf793033f91 Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Mon, 29 Jun 2026 21:04:35 +0900 Subject: [PATCH 048/110] feat: implement GetUserByIdUseCase --- .../UseCase/User/GetUserByIdUseCase.php | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 src/app/Packages/Features/QueryUseCases/UseCase/User/GetUserByIdUseCase.php diff --git a/src/app/Packages/Features/QueryUseCases/UseCase/User/GetUserByIdUseCase.php b/src/app/Packages/Features/QueryUseCases/UseCase/User/GetUserByIdUseCase.php new file mode 100644 index 0000000..1fe1893 --- /dev/null +++ b/src/app/Packages/Features/QueryUseCases/UseCase/User/GetUserByIdUseCase.php @@ -0,0 +1,25 @@ +userRepository->findById($id); + + if ($userDto === null) { + throw new Exception('User not found.'); + } + + return $userDto; + } +} From ef5b06263e6cef20712d25bd85d59390405000b5 Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Mon, 29 Jun 2026 21:04:42 +0900 Subject: [PATCH 049/110] test: add unit tests for GetUserByIdUseCase --- .../Tests/UseCase/GetUserByIdUseCaseTest.php | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 src/app/Packages/Features/QueryUseCases/Tests/UseCase/GetUserByIdUseCaseTest.php diff --git a/src/app/Packages/Features/QueryUseCases/Tests/UseCase/GetUserByIdUseCaseTest.php b/src/app/Packages/Features/QueryUseCases/Tests/UseCase/GetUserByIdUseCaseTest.php new file mode 100644 index 0000000..42e71fc --- /dev/null +++ b/src/app/Packages/Features/QueryUseCases/Tests/UseCase/GetUserByIdUseCaseTest.php @@ -0,0 +1,67 @@ +unique()->randomNumber(5), + firstName: $faker->firstName(), + lastName: $faker->lastName(), + email: $faker->unique()->safeEmail(), + ageRange: 'teens', + subscriptionTier: 'free', + subscriptionExpiresAt: null, + ); + } + + public function test_handle_returns_user_dto_when_user_exists(): void + { + $dto = $this->buildDto(); + + /** @var UserRepositroyInterface|MockInterface $repository */ + $repository = Mockery::mock(UserRepositroyInterface::class); + $repository->shouldReceive('findById') + ->once() + ->with($dto->id) + ->andReturn($dto); + + $result = (new GetUserByIdUseCase($repository))->handle($dto->id); + + $this->assertSame($dto, $result); + } + + public function test_handle_throws_exception_when_user_not_found(): void + { + /** @var UserRepositroyInterface|MockInterface $repository */ + $repository = Mockery::mock(UserRepositroyInterface::class); + $repository->shouldReceive('findById') + ->once() + ->with(999) + ->andReturn(null); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('User not found.'); + + (new GetUserByIdUseCase($repository))->handle(999); + } +} From 41626cf20d0a00c2ad461ffccc068d58fa80a6e5 Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Tue, 30 Jun 2026 20:41:52 +0900 Subject: [PATCH 050/110] feat: add getUserById action to UserController --- .../Features/Controller/UserController.php | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/app/Packages/Features/Controller/UserController.php b/src/app/Packages/Features/Controller/UserController.php index 302a1f6..b16b67f 100644 --- a/src/app/Packages/Features/Controller/UserController.php +++ b/src/app/Packages/Features/Controller/UserController.php @@ -6,12 +6,44 @@ use App\Packages\Features\CommandUseCases\Factory\ViewModel\UserViewModelFactory; use App\Packages\Features\CommandUseCases\UseCase\User\CreateUserUseCase; use App\Packages\Features\CommandUseCases\UseCommand\User\CreateUserCommand; +use App\Packages\Features\QueryUseCases\UseCase\User\GetUserByIdUseCase; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Log; class UserController extends Controller { + public function getUserById( + Request $request, + GetUserByIdUseCase $useCase, + ): JsonResponse { + try { + $dto = $useCase->handle($request->route('id')); + + return response()->json([ + 'status' => 'success', + 'data' => UserViewModelFactory::build($dto)->toArray(), + ], 200); + } catch (\Exception $exception) { + if ($exception->getMessage() === 'User not found.') { + return response()->json([ + 'status' => 'error', + 'message' => 'User not found.', + ], 404); + } + + Log::error('Failed to get user by id', [ + 'message' => $exception->getMessage(), + 'trace' => $exception->getTraceAsString(), + ]); + + return response()->json([ + 'status' => 'error', + 'message' => 'Internal Server Error', + ], 500); + } + } + public function createUser( Request $request, CreateUserUseCase $useCase, From 7568313a71eb2d0143c3eed12079ee94494fa9b2 Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Tue, 30 Jun 2026 20:41:57 +0900 Subject: [PATCH 051/110] feat: register GET /api/v1/users/{id} route --- src/routes/api.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/routes/api.php b/src/routes/api.php index fc480aa..7f5dcb8 100644 --- a/src/routes/api.php +++ b/src/routes/api.php @@ -10,5 +10,6 @@ Route::get('heritages/region-count', [WorldHeritageController::class, 'getWorldHeritagesCountByRegion']); Route::get('/heritages/{id}', [WorldHeritageController::class, 'getWorldHeritageById']); + Route::get('/users/{id}', [UserController::class, 'getUserById']); Route::post('/user/create', [UserController::class, 'createUser']); }); \ No newline at end of file From 658cadb9e4f97fd8bf3761deadd6f20275bbe775 Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Tue, 30 Jun 2026 20:42:06 +0900 Subject: [PATCH 052/110] test: add integration test for GET /api/v1/users/{id} --- .../Features/Tests/GetUserByIdTest.php | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 src/app/Packages/Features/Tests/GetUserByIdTest.php diff --git a/src/app/Packages/Features/Tests/GetUserByIdTest.php b/src/app/Packages/Features/Tests/GetUserByIdTest.php new file mode 100644 index 0000000..8bae776 --- /dev/null +++ b/src/app/Packages/Features/Tests/GetUserByIdTest.php @@ -0,0 +1,84 @@ +truncate(); + } + + protected function tearDown(): void + { + $this->truncate(); + parent::tearDown(); + } + + private function truncate(): void + { + if (env('APP_ENV') === 'testing') { + DB::connection('mysql')->statement('SET FOREIGN_KEY_CHECKS=0;'); + User::truncate(); + DB::connection('mysql')->statement('SET FOREIGN_KEY_CHECKS=1;'); + } + } + + private function seedUser(array $overrides = []): User + { + $faker = FakerFactory::create(); + + return User::create(array_merge([ + 'first_name' => $faker->firstName(), + 'last_name' => $faker->lastName(), + 'email' => $faker->unique()->safeEmail(), + 'password' => bcrypt('password'), + 'age_range' => 'teens', + 'subscription_tier' => 'free', + 'subscription_expires_at' => null, + ], $overrides)); + } + + public function test_get_user_by_id_returns_200_with_user_data(): void + { + $user = $this->seedUser(); + + $response = $this->getJson("/api/v1/users/{$user->id}"); + + $response->assertStatus(200) + ->assertJsonStructure([ + 'status', + 'data' => [ + 'id', + 'first_name', + 'last_name', + 'email', + 'age_range', + 'subscription_tier', + 'subscription_expires_at', + ], + ]) + ->assertJsonFragment([ + 'status' => 'success', + 'id' => $user->id, + 'email' => $user->email, + ]); + } + + public function test_get_user_by_id_returns_404_when_user_not_found(): void + { + $response = $this->getJson('/api/v1/users/999999'); + + $response->assertStatus(404) + ->assertJsonFragment([ + 'status' => 'error', + 'message' => 'User not found.', + ]); + } +} \ No newline at end of file From bd9317780eb2957e6bfd7575a3b341be3c89c6fb Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Thu, 2 Jul 2026 08:03:04 +0900 Subject: [PATCH 053/110] feat: add UpdateUserCommand --- .../UseCommand/User/UpdateUserCommand.php | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 src/app/Packages/Features/CommandUseCases/UseCommand/User/UpdateUserCommand.php diff --git a/src/app/Packages/Features/CommandUseCases/UseCommand/User/UpdateUserCommand.php b/src/app/Packages/Features/CommandUseCases/UseCommand/User/UpdateUserCommand.php new file mode 100644 index 0000000..4176a5c --- /dev/null +++ b/src/app/Packages/Features/CommandUseCases/UseCommand/User/UpdateUserCommand.php @@ -0,0 +1,46 @@ + Date: Thu, 2 Jul 2026 08:03:04 +0900 Subject: [PATCH 054/110] feat: implement UpdateUserUseCase --- .../UseCase/User/UpdateUserUseCase.php | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 src/app/Packages/Features/CommandUseCases/UseCase/User/UpdateUserUseCase.php diff --git a/src/app/Packages/Features/CommandUseCases/UseCase/User/UpdateUserUseCase.php b/src/app/Packages/Features/CommandUseCases/UseCase/User/UpdateUserUseCase.php new file mode 100644 index 0000000..a99e045 --- /dev/null +++ b/src/app/Packages/Features/CommandUseCases/UseCase/User/UpdateUserUseCase.php @@ -0,0 +1,30 @@ + $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, + ]); + + return $this->userRepository->updateUser($entity); + } +} \ No newline at end of file From 62ea7ce748ae6f0c991af7b7833087b49253b711 Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Thu, 2 Jul 2026 08:03:04 +0900 Subject: [PATCH 055/110] test: add unit tests for UpdateUserCommand --- .../CommandUseCases/UpdateUserCommandTest.php | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 src/app/Packages/Features/Tests/CommandUseCases/UpdateUserCommandTest.php diff --git a/src/app/Packages/Features/Tests/CommandUseCases/UpdateUserCommandTest.php b/src/app/Packages/Features/Tests/CommandUseCases/UpdateUserCommandTest.php new file mode 100644 index 0000000..0e4b4ad --- /dev/null +++ b/src/app/Packages/Features/Tests/CommandUseCases/UpdateUserCommandTest.php @@ -0,0 +1,72 @@ + $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' => $faker->randomElement(['free', 'premium']), + ], $overrides); + } + + public function test_fromArray_creates_command_with_valid_data(): void + { + $data = $this->validData(); + $command = UpdateUserCommand::fromArray($data); + + $this->assertSame((int) $data['id'], $command->id); + $this->assertSame($data['first_name'], $command->firstName); + $this->assertSame($data['last_name'], $command->lastName); + $this->assertSame($data['email'], $command->email); + $this->assertSame($data['age_range'], $command->ageRange); + $this->assertSame($data['subscription_tier'], $command->subscriptionTier); + $this->assertNull($command->subscriptionExpiresAt); + } + + public function test_fromArray_sets_subscription_expires_at_when_provided(): void + { + $command = UpdateUserCommand::fromArray($this->validData([ + 'subscription_expires_at' => '2027-01-01 00:00:00', + ])); + + $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 + { + $data = $this->validData(); + unset($data[$missingKey]); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("Missing required key: {$missingKey}"); + + UpdateUserCommand::fromArray($data); + } + + public static function missingKeyProvider(): array + { + return [ + ['id'], + ['first_name'], + ['last_name'], + ['email'], + ['age_range'], + ['subscription_tier'], + ]; + } +} From 45effe3146631073008a2ecc9b3d420f8d072297 Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Thu, 2 Jul 2026 08:03:04 +0900 Subject: [PATCH 056/110] test: add unit tests for UpdateUserUseCase --- .../CommandUseCases/UpdateUserUseCaseTest.php | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 src/app/Packages/Features/Tests/CommandUseCases/UpdateUserUseCaseTest.php diff --git a/src/app/Packages/Features/Tests/CommandUseCases/UpdateUserUseCaseTest.php b/src/app/Packages/Features/Tests/CommandUseCases/UpdateUserUseCaseTest.php new file mode 100644 index 0000000..1c205db --- /dev/null +++ b/src/app/Packages/Features/Tests/CommandUseCases/UpdateUserUseCaseTest.php @@ -0,0 +1,68 @@ + $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', + ]); + } + + private function buildDto(): UserDto + { + $faker = FakerFactory::create(); + + return new UserDto( + id: $faker->unique()->randomNumber(5), + firstName: $faker->firstName(), + lastName: $faker->lastName(), + email: $faker->unique()->safeEmail(), + ageRange: 'teens', + subscriptionTier: 'free', + subscriptionExpiresAt: null, + ); + } + + public function test_handle_passes_entity_to_repository_and_returns_dto(): void + { + $command = $this->buildCommand(); + $dto = $this->buildDto(); + + /** @var UserRepositroyInterface|MockInterface $repository */ + $repository = Mockery::mock(UserRepositroyInterface::class); + $repository->shouldReceive('updateUser') + ->once() + ->with(Mockery::type(UserEntity::class)) + ->andReturn($dto); + + $result = (new UpdateUserUseCase($repository))->handle($command); + + $this->assertSame($dto, $result); + } +} \ No newline at end of file From 68d41e378975bfd33f115d6b3068e49a499c9422 Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Thu, 2 Jul 2026 08:55:51 +0900 Subject: [PATCH 057/110] refactor: move UserViewModel to QueryUseCases/ViewModel/User --- .../ViewModel/User/UserViewModel.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename src/app/Packages/Features/{CommandUseCases => QueryUseCases}/ViewModel/User/UserViewModel.php (91%) diff --git a/src/app/Packages/Features/CommandUseCases/ViewModel/User/UserViewModel.php b/src/app/Packages/Features/QueryUseCases/ViewModel/User/UserViewModel.php similarity index 91% rename from src/app/Packages/Features/CommandUseCases/ViewModel/User/UserViewModel.php rename to src/app/Packages/Features/QueryUseCases/ViewModel/User/UserViewModel.php index 96ca814..17402a1 100644 --- a/src/app/Packages/Features/CommandUseCases/ViewModel/User/UserViewModel.php +++ b/src/app/Packages/Features/QueryUseCases/ViewModel/User/UserViewModel.php @@ -1,6 +1,6 @@ Date: Thu, 2 Jul 2026 08:55:51 +0900 Subject: [PATCH 058/110] refactor: move UserViewModelFactory to QueryUseCases/Factory/ViewModel --- .../Factory/ViewModel/UserViewModelFactory.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename src/app/Packages/Features/{CommandUseCases => QueryUseCases}/Factory/ViewModel/UserViewModelFactory.php (60%) diff --git a/src/app/Packages/Features/CommandUseCases/Factory/ViewModel/UserViewModelFactory.php b/src/app/Packages/Features/QueryUseCases/Factory/ViewModel/UserViewModelFactory.php similarity index 60% rename from src/app/Packages/Features/CommandUseCases/Factory/ViewModel/UserViewModelFactory.php rename to src/app/Packages/Features/QueryUseCases/Factory/ViewModel/UserViewModelFactory.php index a4e2a3d..519e15b 100644 --- a/src/app/Packages/Features/CommandUseCases/Factory/ViewModel/UserViewModelFactory.php +++ b/src/app/Packages/Features/QueryUseCases/Factory/ViewModel/UserViewModelFactory.php @@ -1,8 +1,8 @@ Date: Thu, 2 Jul 2026 08:55:51 +0900 Subject: [PATCH 059/110] refactor: update UserController import to use QueryUseCases UserViewModelFactory --- src/app/Packages/Features/Controller/UserController.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/Packages/Features/Controller/UserController.php b/src/app/Packages/Features/Controller/UserController.php index b16b67f..eec537f 100644 --- a/src/app/Packages/Features/Controller/UserController.php +++ b/src/app/Packages/Features/Controller/UserController.php @@ -3,7 +3,7 @@ namespace App\Packages\Features\Controller; use App\Http\Controllers\Controller; -use App\Packages\Features\CommandUseCases\Factory\ViewModel\UserViewModelFactory; +use App\Packages\Features\QueryUseCases\Factory\ViewModel\UserViewModelFactory; use App\Packages\Features\CommandUseCases\UseCase\User\CreateUserUseCase; use App\Packages\Features\CommandUseCases\UseCommand\User\CreateUserCommand; use App\Packages\Features\QueryUseCases\UseCase\User\GetUserByIdUseCase; From 807cf03db9eb7d71bfbf075a76ab2ff693b63686 Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Thu, 2 Jul 2026 08:55:52 +0900 Subject: [PATCH 060/110] test: move UserViewModelTest to QueryUseCases/Tests/ViewModel --- .../Tests/ViewModel}/UserViewModelTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename src/app/Packages/Features/{Tests/CommandUseCases => QueryUseCases/Tests/ViewModel}/UserViewModelTest.php (93%) diff --git a/src/app/Packages/Features/Tests/CommandUseCases/UserViewModelTest.php b/src/app/Packages/Features/QueryUseCases/Tests/ViewModel/UserViewModelTest.php similarity index 93% rename from src/app/Packages/Features/Tests/CommandUseCases/UserViewModelTest.php rename to src/app/Packages/Features/QueryUseCases/Tests/ViewModel/UserViewModelTest.php index ffde5ce..007cd58 100644 --- a/src/app/Packages/Features/Tests/CommandUseCases/UserViewModelTest.php +++ b/src/app/Packages/Features/QueryUseCases/Tests/ViewModel/UserViewModelTest.php @@ -1,8 +1,8 @@ Date: Thu, 2 Jul 2026 08:55:52 +0900 Subject: [PATCH 061/110] test: move UserViewModelFactoryTest to QueryUseCases/Tests/ViewModel --- .../Tests/ViewModel}/UserViewModelFactoryTest.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) rename src/app/Packages/Features/{Tests/CommandUseCases => QueryUseCases/Tests/ViewModel}/UserViewModelFactoryTest.php (78%) diff --git a/src/app/Packages/Features/Tests/CommandUseCases/UserViewModelFactoryTest.php b/src/app/Packages/Features/QueryUseCases/Tests/ViewModel/UserViewModelFactoryTest.php similarity index 78% rename from src/app/Packages/Features/Tests/CommandUseCases/UserViewModelFactoryTest.php rename to src/app/Packages/Features/QueryUseCases/Tests/ViewModel/UserViewModelFactoryTest.php index 32938a5..13074c3 100644 --- a/src/app/Packages/Features/Tests/CommandUseCases/UserViewModelFactoryTest.php +++ b/src/app/Packages/Features/QueryUseCases/Tests/ViewModel/UserViewModelFactoryTest.php @@ -1,9 +1,9 @@ Date: Thu, 2 Jul 2026 08:55:52 +0900 Subject: [PATCH 062/110] chore: add root .gitignore to exclude .idea --- .gitignore | 1 + 1 file changed, 1 insertion(+) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9f11b75 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.idea/ From d8bcc714208ff110c9f3b43d72215d33b3224196 Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Thu, 2 Jul 2026 09:37:44 +0900 Subject: [PATCH 063/110] feat: add updateUser action to UserController --- .../Features/Controller/UserController.php | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/src/app/Packages/Features/Controller/UserController.php b/src/app/Packages/Features/Controller/UserController.php index eec537f..9917562 100644 --- a/src/app/Packages/Features/Controller/UserController.php +++ b/src/app/Packages/Features/Controller/UserController.php @@ -5,7 +5,9 @@ use App\Http\Controllers\Controller; use App\Packages\Features\QueryUseCases\Factory\ViewModel\UserViewModelFactory; use App\Packages\Features\CommandUseCases\UseCase\User\CreateUserUseCase; +use App\Packages\Features\CommandUseCases\UseCase\User\UpdateUserUseCase; use App\Packages\Features\CommandUseCases\UseCommand\User\CreateUserCommand; +use App\Packages\Features\CommandUseCases\UseCommand\User\UpdateUserCommand; use App\Packages\Features\QueryUseCases\UseCase\User\GetUserByIdUseCase; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -44,6 +46,41 @@ public function getUserById( } } + public function updateUser( + Request $request, + UpdateUserUseCase $useCase, + ): JsonResponse { + try { + $command = UpdateUserCommand::fromArray(array_merge( + $request->all(), + ['id' => $request->route('id')], + )); + $dto = $useCase->handle($command); + + return response()->json([ + 'status' => 'success', + 'data' => UserViewModelFactory::build($dto)->toArray(), + ], 200); + } catch (\Exception $exception) { + if ($exception->getMessage() === 'User not found.') { + return response()->json([ + 'status' => 'error', + 'message' => 'User not found.', + ], 404); + } + + Log::error('Failed to update user', [ + 'message' => $exception->getMessage(), + 'trace' => $exception->getTraceAsString(), + ]); + + return response()->json([ + 'status' => 'error', + 'message' => 'Internal Server Error', + ], 500); + } + } + public function createUser( Request $request, CreateUserUseCase $useCase, From 1e7423bea2ab88d978f8d46d87eb175250948be4 Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Thu, 2 Jul 2026 09:37:44 +0900 Subject: [PATCH 064/110] feat: register PATCH /api/v1/users/{id} route --- src/routes/api.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/routes/api.php b/src/routes/api.php index 7f5dcb8..a3d59bd 100644 --- a/src/routes/api.php +++ b/src/routes/api.php @@ -11,5 +11,6 @@ Route::get('/heritages/{id}', [WorldHeritageController::class, 'getWorldHeritageById']); Route::get('/users/{id}', [UserController::class, 'getUserById']); + Route::patch('/users/{id}', [UserController::class, 'updateUser']); Route::post('/user/create', [UserController::class, 'createUser']); }); \ No newline at end of file From 0af8b3d6800e59762b1970f8194b2b337aa22c30 Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Thu, 2 Jul 2026 09:37:44 +0900 Subject: [PATCH 065/110] test: add integration test for PATCH /api/v1/users/{id} --- .../Features/Tests/UpdateUserTest.php | 109 ++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 src/app/Packages/Features/Tests/UpdateUserTest.php diff --git a/src/app/Packages/Features/Tests/UpdateUserTest.php b/src/app/Packages/Features/Tests/UpdateUserTest.php new file mode 100644 index 0000000..b375ab5 --- /dev/null +++ b/src/app/Packages/Features/Tests/UpdateUserTest.php @@ -0,0 +1,109 @@ +truncate(); + } + + protected function tearDown(): void + { + $this->truncate(); + parent::tearDown(); + } + + private function truncate(): void + { + if (env('APP_ENV') === 'testing') { + DB::connection('mysql')->statement('SET FOREIGN_KEY_CHECKS=0;'); + User::truncate(); + DB::connection('mysql')->statement('SET FOREIGN_KEY_CHECKS=1;'); + } + } + + private function seedUser(array $overrides = []): User + { + $faker = FakerFactory::create(); + + return User::create(array_merge([ + 'first_name' => $faker->firstName(), + 'last_name' => $faker->lastName(), + 'email' => $faker->unique()->safeEmail(), + 'password' => bcrypt('password'), + 'age_range' => 'teens', + 'subscription_tier' => 'free', + 'subscription_expires_at' => null, + ], $overrides)); + } + + private function validPayload(array $overrides = []): array + { + $faker = FakerFactory::create(); + + return array_merge([ + 'first_name' => $faker->firstName(), + 'last_name' => $faker->lastName(), + 'email' => $faker->unique()->safeEmail(), + 'age_range' => '30s', + 'subscription_tier' => 'premium', + ], $overrides); + } + + public function test_update_user_returns_200_with_updated_data(): void + { + $user = $this->seedUser(); + + $payload = $this->validPayload(['email' => 'updated@example.com']); + $response = $this->patchJson("/api/v1/users/{$user->id}", $payload); + + $response->assertStatus(200) + ->assertJsonStructure([ + 'status', + 'data' => [ + 'id', + 'first_name', + 'last_name', + 'email', + 'age_range', + 'subscription_tier', + 'subscription_expires_at', + ], + ]) + ->assertJsonFragment([ + 'status' => 'success', + 'email' => 'updated@example.com', + ]); + } + + public function test_update_user_returns_404_when_user_not_found(): void + { + $response = $this->patchJson('/api/v1/users/999999', $this->validPayload()); + + $response->assertStatus(404) + ->assertJsonFragment([ + 'status' => 'error', + 'message' => 'User not found.', + ]); + } + + public function test_update_user_returns_500_when_required_key_is_missing(): void + { + $user = $this->seedUser(); + $payload = $this->validPayload(); + unset($payload['email']); + + $response = $this->patchJson("/api/v1/users/{$user->id}", $payload); + + $response->assertStatus(500) + ->assertJsonFragment(['status' => 'error']); + } +} From 39ad00351e6c0296b6ddd549083255aa97f0d2aa Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Fri, 3 Jul 2026 20:21:18 +0900 Subject: [PATCH 066/110] feat: add deleteUser to UserRepositroyInterface --- src/app/Packages/Domains/Interface/UserRepositroyInterface.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/app/Packages/Domains/Interface/UserRepositroyInterface.php b/src/app/Packages/Domains/Interface/UserRepositroyInterface.php index cad8f5e..5c9af12 100644 --- a/src/app/Packages/Domains/Interface/UserRepositroyInterface.php +++ b/src/app/Packages/Domains/Interface/UserRepositroyInterface.php @@ -12,4 +12,6 @@ public function createUser(UserEntity $entity): UserDto; public function findById(int $id): ?UserDto; public function updateUser(UserEntity $entity): UserDto; + + public function deleteUser(int $id): void; } \ No newline at end of file From f086bc05b02292aa391f71d6476f192ace8a00c2 Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Fri, 3 Jul 2026 20:21:21 +0900 Subject: [PATCH 067/110] feat: implement deleteUser in UserRepository --- src/app/Packages/Domains/UserRepository.php | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/app/Packages/Domains/UserRepository.php b/src/app/Packages/Domains/UserRepository.php index 87166cd..8794058 100644 --- a/src/app/Packages/Domains/UserRepository.php +++ b/src/app/Packages/Domains/UserRepository.php @@ -66,4 +66,15 @@ public function updateUser(UserEntity $entity): UserDto return UserDtoFactory::build($user->toArray()); } + + public function deleteUser(int $id): void + { + $user = $this->userModel->find($id); + + if ($user === null) { + throw new Exception('User not found.'); + } + + $user->delete(); + } } \ No newline at end of file From dd244d5f86059cb3b9e286b0f5943ab9a3e17ec1 Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Fri, 3 Jul 2026 20:21:23 +0900 Subject: [PATCH 068/110] test: add deleteUser integration tests to UserRepositoryTest --- .../Domains/Tests/UserRepositoryTest.php | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/app/Packages/Domains/Tests/UserRepositoryTest.php b/src/app/Packages/Domains/Tests/UserRepositoryTest.php index 9262cfd..96c3be0 100644 --- a/src/app/Packages/Domains/Tests/UserRepositoryTest.php +++ b/src/app/Packages/Domains/Tests/UserRepositoryTest.php @@ -121,4 +121,21 @@ public function test_updateUser_throws_exception_when_user_not_found(): void $this->repository()->updateUser($entity); } + + public function test_deleteUser_removes_user_when_exists(): void + { + $user = $this->seedUser(); + + $this->repository()->deleteUser($user->id); + + $this->assertDatabaseMissing('users', ['id' => $user->id]); + } + + public function test_deleteUser_throws_exception_when_user_not_found(): void + { + $this->expectException(\Exception::class); + $this->expectExceptionMessage('User not found.'); + + $this->repository()->deleteUser(999999); + } } \ No newline at end of file From 304e16c715d0cac94145bff5673df6a2b4a69527 Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Fri, 3 Jul 2026 22:02:32 +0900 Subject: [PATCH 069/110] feat: implement DeleteUserUseCase --- .../UseCase/User/DeleteUserUseCase.php | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 src/app/Packages/Features/CommandUseCases/UseCase/User/DeleteUserUseCase.php diff --git a/src/app/Packages/Features/CommandUseCases/UseCase/User/DeleteUserUseCase.php b/src/app/Packages/Features/CommandUseCases/UseCase/User/DeleteUserUseCase.php new file mode 100644 index 0000000..d13b437 --- /dev/null +++ b/src/app/Packages/Features/CommandUseCases/UseCase/User/DeleteUserUseCase.php @@ -0,0 +1,17 @@ +userRepository->deleteUser($id); + } +} From adf71bcbfc41778aee892feb2bf4938730657b69 Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Fri, 3 Jul 2026 22:02:41 +0900 Subject: [PATCH 070/110] test: add unit tests for DeleteUserUseCase --- .../CommandUseCases/DeleteUserUseCaseTest.php | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 src/app/Packages/Features/Tests/CommandUseCases/DeleteUserUseCaseTest.php diff --git a/src/app/Packages/Features/Tests/CommandUseCases/DeleteUserUseCaseTest.php b/src/app/Packages/Features/Tests/CommandUseCases/DeleteUserUseCaseTest.php new file mode 100644 index 0000000..e052074 --- /dev/null +++ b/src/app/Packages/Features/Tests/CommandUseCases/DeleteUserUseCaseTest.php @@ -0,0 +1,45 @@ +shouldReceive('deleteUser') + ->once() + ->with(1); + + (new DeleteUserUseCase($repository))->handle(1); + } + + public function test_handle_propagates_exception_when_user_not_found(): void + { + /** @var UserRepositroyInterface|MockInterface $repository */ + $repository = Mockery::mock(UserRepositroyInterface::class); + $repository->shouldReceive('deleteUser') + ->once() + ->with(999999) + ->andThrow(new \Exception('User not found.')); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('User not found.'); + + (new DeleteUserUseCase($repository))->handle(999999); + } +} From a08344b6499b4024fa315b91d75a80a1ec70a6e0 Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Sat, 4 Jul 2026 20:06:52 +0900 Subject: [PATCH 071/110] feat: add deleteUser action to UserController --- .../Features/Controller/UserController.php | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/src/app/Packages/Features/Controller/UserController.php b/src/app/Packages/Features/Controller/UserController.php index 9917562..49589f8 100644 --- a/src/app/Packages/Features/Controller/UserController.php +++ b/src/app/Packages/Features/Controller/UserController.php @@ -5,6 +5,7 @@ use App\Http\Controllers\Controller; use App\Packages\Features\QueryUseCases\Factory\ViewModel\UserViewModelFactory; use App\Packages\Features\CommandUseCases\UseCase\User\CreateUserUseCase; +use App\Packages\Features\CommandUseCases\UseCase\User\DeleteUserUseCase; use App\Packages\Features\CommandUseCases\UseCase\User\UpdateUserUseCase; use App\Packages\Features\CommandUseCases\UseCommand\User\CreateUserCommand; use App\Packages\Features\CommandUseCases\UseCommand\User\UpdateUserCommand; @@ -81,6 +82,36 @@ public function updateUser( } } + public function deleteUser( + Request $request, + DeleteUserUseCase $useCase, + ): JsonResponse { + try { + $useCase->handle((int) $request->route('id')); + + return response()->json([ + 'status' => 'success', + ], 200); + } catch (\Exception $exception) { + if ($exception->getMessage() === 'User not found.') { + return response()->json([ + 'status' => 'error', + 'message' => 'User not found.', + ], 404); + } + + Log::error('Failed to delete user', [ + 'message' => $exception->getMessage(), + 'trace' => $exception->getTraceAsString(), + ]); + + return response()->json([ + 'status' => 'error', + 'message' => 'Internal Server Error', + ], 500); + } + } + public function createUser( Request $request, CreateUserUseCase $useCase, From ab5e26a79ea2e1015b783e087dc6938de6cb38eb Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Sat, 4 Jul 2026 20:06:55 +0900 Subject: [PATCH 072/110] feat: register DELETE /api/v1/users/{id} route --- src/routes/api.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/routes/api.php b/src/routes/api.php index a3d59bd..d39dc95 100644 --- a/src/routes/api.php +++ b/src/routes/api.php @@ -12,5 +12,6 @@ Route::get('/users/{id}', [UserController::class, 'getUserById']); Route::patch('/users/{id}', [UserController::class, 'updateUser']); + Route::delete('/users/{id}', [UserController::class, 'deleteUser']); Route::post('/user/create', [UserController::class, 'createUser']); }); \ No newline at end of file From 195533f931da674211d513c9cc58722e95721008 Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Sat, 4 Jul 2026 20:06:57 +0900 Subject: [PATCH 073/110] test: add integration tests for DELETE /api/v1/users/{id} --- .../Features/Tests/DeleteUserTest.php | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 src/app/Packages/Features/Tests/DeleteUserTest.php diff --git a/src/app/Packages/Features/Tests/DeleteUserTest.php b/src/app/Packages/Features/Tests/DeleteUserTest.php new file mode 100644 index 0000000..7b78711 --- /dev/null +++ b/src/app/Packages/Features/Tests/DeleteUserTest.php @@ -0,0 +1,70 @@ +truncate(); + } + + protected function tearDown(): void + { + $this->truncate(); + parent::tearDown(); + } + + private function truncate(): void + { + if (env('APP_ENV') === 'testing') { + DB::connection('mysql')->statement('SET FOREIGN_KEY_CHECKS=0;'); + User::truncate(); + DB::connection('mysql')->statement('SET FOREIGN_KEY_CHECKS=1;'); + } + } + + private function seedUser(array $overrides = []): User + { + $faker = FakerFactory::create(); + + return User::create(array_merge([ + 'first_name' => $faker->firstName(), + 'last_name' => $faker->lastName(), + 'email' => $faker->unique()->safeEmail(), + 'password' => bcrypt('password'), + 'age_range' => 'teens', + 'subscription_tier' => 'free', + 'subscription_expires_at' => null, + ], $overrides)); + } + + public function test_delete_user_returns_200_when_user_exists(): void + { + $user = $this->seedUser(); + + $response = $this->deleteJson("/api/v1/users/{$user->id}"); + + $response->assertStatus(200) + ->assertJsonFragment(['status' => 'success']); + + $this->assertDatabaseMissing('users', ['id' => $user->id]); + } + + public function test_delete_user_returns_404_when_user_not_found(): void + { + $response = $this->deleteJson('/api/v1/users/999999'); + + $response->assertStatus(404) + ->assertJsonFragment([ + 'status' => 'error', + 'message' => 'User not found.', + ]); + } +} From 42b54c3b49cae1fb5b3458b779d2ea02b19e0bef Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Sun, 12 Jul 2026 20:00:34 +0900 Subject: [PATCH 074/110] refactor: move User domain files under Domains/User/ --- .../Interface/UserRepositroyInterface.php | 17 +++ .../Tests/Factory/SubscriptionFactoryTest.php | 69 +++++++++ .../Tests/Factory/UserEntityFactoryTest.php | 74 +++++++++ .../Tests/Subscription/SubscriptionTest.php | 121 +++++++++++++++ .../Domains/User/Tests/UserEntityTest.php | 90 +++++++++++ .../Domains/User/Tests/UserRepositoryTest.php | 141 ++++++++++++++++++ .../Packages/Domains/User/UserRepository.php | 80 ++++++++++ .../UseCase/User/CreateUserUseCase.php | 2 +- .../UseCase/User/DeleteUserUseCase.php | 2 +- .../UseCase/User/UpdateUserUseCase.php | 2 +- .../Tests/UseCase/GetUserByIdUseCaseTest.php | 2 +- .../UseCase/User/GetUserByIdUseCase.php | 2 +- .../CommandUseCases/CreateUserUseCaseTest.php | 2 +- .../CommandUseCases/DeleteUserUseCaseTest.php | 2 +- .../CommandUseCases/UpdateUserUseCaseTest.php | 2 +- src/app/Providers/RepositoryProvider.php | 4 +- 16 files changed, 602 insertions(+), 10 deletions(-) create mode 100644 src/app/Packages/Domains/User/Interface/UserRepositroyInterface.php create mode 100644 src/app/Packages/Domains/User/Tests/Factory/SubscriptionFactoryTest.php create mode 100644 src/app/Packages/Domains/User/Tests/Factory/UserEntityFactoryTest.php create mode 100644 src/app/Packages/Domains/User/Tests/Subscription/SubscriptionTest.php create mode 100644 src/app/Packages/Domains/User/Tests/UserEntityTest.php create mode 100644 src/app/Packages/Domains/User/Tests/UserRepositoryTest.php create mode 100644 src/app/Packages/Domains/User/UserRepository.php diff --git a/src/app/Packages/Domains/User/Interface/UserRepositroyInterface.php b/src/app/Packages/Domains/User/Interface/UserRepositroyInterface.php new file mode 100644 index 0000000..2ca67e5 --- /dev/null +++ b/src/app/Packages/Domains/User/Interface/UserRepositroyInterface.php @@ -0,0 +1,17 @@ + 'free', + 'expires_at' => null, + ]); + + $this->assertInstanceOf(Subscription::class, $subscription); + $this->assertTrue($subscription->isFree()); + } + + public function test_build_returns_premium_subscription_with_string_expires_at(): void + { + $subscription = SubscriptionFactory::build([ + 'tier' => 'premium', + 'expires_at' => '2027-01-01 00:00:00', + ]); + + $this->assertInstanceOf(Subscription::class, $subscription); + $this->assertFalse($subscription->isFree()); + $this->assertTrue($subscription->isActive(new DateTimeImmutable('2026-01-01'))); + } + + public function test_build_accepts_datetime_immutable_for_expires_at(): void + { + $expiresAt = new DateTimeImmutable('2027-06-01'); + + $subscription = SubscriptionFactory::build([ + 'tier' => 'premium', + 'expires_at' => $expiresAt, + ]); + + $this->assertInstanceOf(Subscription::class, $subscription); + $this->assertTrue($subscription->isActive(new DateTimeImmutable('2026-01-01'))); + } + + public function test_build_accepts_string_for_now(): void + { + $subscription = SubscriptionFactory::build([ + 'tier' => 'premium', + 'expires_at' => '2025-01-01 00:00:00', + 'now' => '2026-01-01 00:00:00', + ]); + + $this->assertTrue($subscription->isExpired()); + } + + public function test_build_accepts_datetime_immutable_for_now(): void + { + $subscription = SubscriptionFactory::build([ + 'tier' => 'premium', + 'expires_at' => '2025-01-01 00:00:00', + 'now' => new DateTimeImmutable('2026-01-01'), + ]); + + $this->assertTrue($subscription->isExpired()); + } +} \ No newline at end of file diff --git a/src/app/Packages/Domains/User/Tests/Factory/UserEntityFactoryTest.php b/src/app/Packages/Domains/User/Tests/Factory/UserEntityFactoryTest.php new file mode 100644 index 0000000..a26b6bd --- /dev/null +++ b/src/app/Packages/Domains/User/Tests/Factory/UserEntityFactoryTest.php @@ -0,0 +1,74 @@ + $faker->unique()->randomNumber(5), + 'first_name' => $faker->firstName(), + 'last_name' => $faker->lastName(), + 'email' => $faker->unique()->safeEmail(), + 'age_range' => 'teens', + 'subscription_tier' => 'free', + 'subscription_expires_at' => null, + ]; + + $entity = UserEntityFactory::build($data); + + $this->assertInstanceOf(UserEntity::class, $entity); + $this->assertSame($data['id'], $entity->getId()); + $this->assertSame($data['first_name'], $entity->getFirstName()); + $this->assertSame($data['last_name'], $entity->getLastName()); + $this->assertSame($data['email'], $entity->getEmail()); + $this->assertSame(AgeRange::Teens, $entity->getAgeRange()); + $this->assertTrue($entity->getSubscription()->isFree()); + $this->assertNull($entity->getPasswordHash()); + } + + public function test_build_sets_password_hash_when_provided(): void + { + $faker = FakerFactory::create(); + + $entity = UserEntityFactory::build([ + 'id' => null, + 'first_name' => $faker->firstName(), + 'last_name' => $faker->lastName(), + 'email' => $faker->unique()->safeEmail(), + 'age_range' => '20s', + 'subscription_tier' => 'free', + 'subscription_expires_at' => null, + 'password_hash' => 'hashed_value', + ]); + + $this->assertNull($entity->getId()); + $this->assertSame('hashed_value', $entity->getPasswordHash()); + } + + public function test_build_passes_expires_at_to_subscription(): void + { + $faker = FakerFactory::create(); + + $entity = UserEntityFactory::build([ + 'id' => $faker->unique()->randomNumber(5), + 'first_name' => $faker->firstName(), + 'last_name' => $faker->lastName(), + 'email' => $faker->unique()->safeEmail(), + 'age_range' => '20s', + 'subscription_tier' => 'premium', + 'subscription_expires_at' => '2027-01-01 00:00:00', + ]); + + $this->assertFalse($entity->getSubscription()->isFree()); + } +} \ No newline at end of file diff --git a/src/app/Packages/Domains/User/Tests/Subscription/SubscriptionTest.php b/src/app/Packages/Domains/User/Tests/Subscription/SubscriptionTest.php new file mode 100644 index 0000000..f603f9e --- /dev/null +++ b/src/app/Packages/Domains/User/Tests/Subscription/SubscriptionTest.php @@ -0,0 +1,121 @@ +assertTrue($subscription->isActive()); + } + + public function test_isActive_returns_false_when_free(): void + { + $subscription = new Subscription( + tier: SubscriptionTier::Free, + expiresAt: new DateTimeImmutable('+1 day'), + now: new DateTimeImmutable(), + ); + + $this->assertFalse($subscription->isActive()); + } + + public function test_isActive_returns_false_when_premium_and_expires_at_is_null(): void + { + $subscription = new Subscription( + tier: SubscriptionTier::Premium, + expiresAt: null, + now: new DateTimeImmutable(), + ); + + $this->assertFalse($subscription->isActive()); + } + + public function test_isActive_returns_false_when_premium_and_expired(): void + { + $subscription = new Subscription( + tier: SubscriptionTier::Premium, + expiresAt: new DateTimeImmutable('-1 day'), + now: new DateTimeImmutable(), + ); + + $this->assertFalse($subscription->isActive()); + } + + public function test_isExpired_returns_true_when_premium_and_past_expiry(): void + { + $subscription = new Subscription( + tier: SubscriptionTier::Premium, + expiresAt: new DateTimeImmutable('-1 day'), + now: new DateTimeImmutable(), + ); + + $this->assertTrue($subscription->isExpired()); + } + + public function test_isExpired_returns_true_when_premium_and_expires_at_is_null(): void + { + $subscription = new Subscription( + tier: SubscriptionTier::Premium, + expiresAt: null, + now: new DateTimeImmutable(), + ); + + $this->assertTrue($subscription->isExpired()); + } + + public function test_isExpired_returns_false_when_free(): void + { + $subscription = new Subscription( + tier: SubscriptionTier::Free, + expiresAt: null, + now: new DateTimeImmutable(), + ); + + $this->assertFalse($subscription->isExpired()); + } + + public function test_isExpired_returns_false_when_premium_and_not_expired(): void + { + $subscription = new Subscription( + tier: SubscriptionTier::Premium, + expiresAt: new DateTimeImmutable('+1 day'), + now: new DateTimeImmutable(), + ); + + $this->assertFalse($subscription->isExpired()); + } + + public function test_isFree_returns_true_when_free(): void + { + $subscription = new Subscription( + tier: SubscriptionTier::Free, + expiresAt: null, + now: new DateTimeImmutable(), + ); + + $this->assertTrue($subscription->isFree()); + } + + public function test_isFree_returns_false_when_premium(): void + { + $subscription = new Subscription( + tier: SubscriptionTier::Premium, + expiresAt: new DateTimeImmutable('+1 day'), + now: new DateTimeImmutable(), + ); + + $this->assertFalse($subscription->isFree()); + } +} \ No newline at end of file diff --git a/src/app/Packages/Domains/User/Tests/UserEntityTest.php b/src/app/Packages/Domains/User/Tests/UserEntityTest.php new file mode 100644 index 0000000..13b4cb5 --- /dev/null +++ b/src/app/Packages/Domains/User/Tests/UserEntityTest.php @@ -0,0 +1,90 @@ +unique()->randomNumber(5), + firstName: $overrides['first_name'] ?? $faker->firstName(), + lastName: $overrides['last_name'] ?? $faker->lastName(), + email: $overrides['email'] ?? $faker->unique()->safeEmail(), + ageRange: $overrides['age_range'] ?? AgeRange::Twenties, + subscription: $overrides['subscription'] ?? $subscription, + passwordHash: $overrides['password_hash'] ?? null, + ); + } + + public function test_getId_returns_correct_value(): void + { + $entity = $this->buildEntity(['id' => 42]); + $this->assertSame(42, $entity->getId()); + } + + public function test_getId_returns_null_when_not_yet_persisted(): void + { + $entity = $this->buildEntity(['id' => null]); + $this->assertNull($entity->getId()); + } + + public function test_getFirstName_returns_correct_value(): void + { + $entity = $this->buildEntity(['first_name' => 'John']); + $this->assertSame('John', $entity->getFirstName()); + } + + public function test_getLastName_returns_correct_value(): void + { + $entity = $this->buildEntity(['last_name' => 'Doe']); + $this->assertSame('Doe', $entity->getLastName()); + } + + public function test_getFullName_concatenates_first_and_last_name(): void + { + $entity = $this->buildEntity(['first_name' => 'John', 'last_name' => 'Doe']); + $this->assertSame('John Doe', $entity->getFullName()); + } + + public function test_getEmail_returns_correct_value(): void + { + $entity = $this->buildEntity(['email' => 'john@example.com']); + $this->assertSame('john@example.com', $entity->getEmail()); + } + + public function test_getAgeRange_returns_correct_enum(): void + { + $entity = $this->buildEntity(['age_range' => AgeRange::Thirties]); + $this->assertSame(AgeRange::Thirties, $entity->getAgeRange()); + } + + public function test_getSubscription_returns_subscription_instance(): void + { + $subscription = new Subscription(SubscriptionTier::Premium, null); + $entity = $this->buildEntity(['subscription' => $subscription]); + $this->assertSame($subscription, $entity->getSubscription()); + } + + public function test_getPasswordHash_returns_hashed_value(): void + { + $entity = $this->buildEntity(['password_hash' => 'hashed_secret']); + $this->assertSame('hashed_secret', $entity->getPasswordHash()); + } + + public function test_getPasswordHash_returns_null_when_not_set(): void + { + $entity = $this->buildEntity(['password_hash' => null]); + $this->assertNull($entity->getPasswordHash()); + } +} \ No newline at end of file diff --git a/src/app/Packages/Domains/User/Tests/UserRepositoryTest.php b/src/app/Packages/Domains/User/Tests/UserRepositoryTest.php new file mode 100644 index 0000000..4cc6bed --- /dev/null +++ b/src/app/Packages/Domains/User/Tests/UserRepositoryTest.php @@ -0,0 +1,141 @@ +truncate(); + } + + protected function tearDown(): void + { + $this->truncate(); + parent::tearDown(); + } + + private function truncate(): void + { + if (env('APP_ENV') === 'testing') { + DB::connection('mysql')->statement('SET FOREIGN_KEY_CHECKS=0;'); + User::truncate(); + DB::connection('mysql')->statement('SET FOREIGN_KEY_CHECKS=1;'); + } + } + + private function repository(): UserRepository + { + return new UserRepository(new User()); + } + + private function seedUser(array $overrides = []): User + { + $faker = FakerFactory::create(); + + return User::create(array_merge([ + 'first_name' => $faker->firstName(), + 'last_name' => $faker->lastName(), + 'email' => $faker->unique()->safeEmail(), + 'password' => bcrypt('password'), + 'age_range' => 'teens', + 'subscription_tier' => 'free', + 'subscription_expires_at' => null, + ], $overrides)); + } + + private function buildEntity(array $overrides = []): UserEntity + { + $faker = FakerFactory::create(); + + return UserEntityFactory::build(array_merge([ + 'id' => null, + 'first_name' => $faker->firstName(), + 'last_name' => $faker->lastName(), + 'email' => $faker->unique()->safeEmail(), + 'age_range' => 'teens', + 'subscription_tier' => 'free', + 'subscription_expires_at' => null, + 'password_hash' => bcrypt('password'), + ], $overrides)); + } + + public function test_createUser_persists_user_and_returns_dto(): void + { + $result = $this->repository()->createUser($this->buildEntity()); + + $this->assertInstanceOf(UserDto::class, $result); + $this->assertDatabaseHas('users', ['email' => $result->email]); + } + + public function test_findById_returns_user_dto_when_user_exists(): void + { + $user = $this->seedUser(); + $result = $this->repository()->findById($user->id); + + $this->assertInstanceOf(UserDto::class, $result); + $this->assertSame($user->id, $result->id); + $this->assertSame($user->email, $result->email); + } + + public function test_findById_returns_null_when_user_not_found(): void + { + $result = $this->repository()->findById(999999); + + $this->assertNull($result); + } + + public function test_updateUser_persists_changes_and_returns_dto(): void + { + $user = $this->seedUser(['email' => 'before@example.com']); + $entity = $this->buildEntity([ + 'id' => $user->id, + 'email' => 'after@example.com', + ]); + + $result = $this->repository()->updateUser($entity); + + $this->assertInstanceOf(UserDto::class, $result); + $this->assertDatabaseHas('users', ['id' => $user->id, 'email' => 'after@example.com']); + } + + public function test_updateUser_throws_exception_when_user_not_found(): void + { + $entity = $this->buildEntity(['id' => 999999]); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('User not found.'); + + $this->repository()->updateUser($entity); + } + + public function test_deleteUser_removes_user_when_exists(): void + { + $user = $this->seedUser(); + + $this->repository()->deleteUser($user->id); + + $this->assertDatabaseMissing('users', ['id' => $user->id]); + } + + public function test_deleteUser_throws_exception_when_user_not_found(): void + { + $this->expectException(\Exception::class); + $this->expectExceptionMessage('User not found.'); + + $this->repository()->deleteUser(999999); + } +} \ No newline at end of file diff --git a/src/app/Packages/Domains/User/UserRepository.php b/src/app/Packages/Domains/User/UserRepository.php new file mode 100644 index 0000000..a3b8dee --- /dev/null +++ b/src/app/Packages/Domains/User/UserRepository.php @@ -0,0 +1,80 @@ +userModel->create([ + 'email' => $entity->getEmail(), + 'first_name' => $entity->getFirstName(), + 'last_name' => $entity->getLastName(), + 'password' => $entity->getPasswordHash(), + 'age_range' => $entity->getAgeRange()->value, + 'subscription_tier' => $entity->getSubscription()->getTier()->value, + 'subscription_expires_at' => $entity->getSubscription()->getExpiresAt()?->format('Y-m-d H:i:s'), + ]); + + if (!$insertUser->wasRecentlyCreated) { + throw new Exception('Failed to create user.'); + } + + return UserDtoFactory::build($insertUser->toArray()); + } + + public function findById(int $id): ?UserDto + { + $user = $this->userModel->find($id); + + if ($user === null) { + return null; + } + + return UserDtoFactory::build($user->toArray()); + } + + public function updateUser(UserEntity $entity): UserDto + { + $user = $this->userModel->find($entity->getId()); + + if ($user === null) { + throw new Exception('User not found.'); + } + + $user->update([ + 'first_name' => $entity->getFirstName(), + 'last_name' => $entity->getLastName(), + 'email' => $entity->getEmail(), + 'age_range' => $entity->getAgeRange()->value, + 'subscription_tier' => $entity->getSubscription()->getTier()->value, + 'subscription_expires_at' => $entity->getSubscription()->getExpiresAt()?->format('Y-m-d H:i:s'), + ]); + + $user->refresh(); + + return UserDtoFactory::build($user->toArray()); + } + + public function deleteUser(int $id): void + { + $user = $this->userModel->find($id); + + if ($user === null) { + throw new Exception('User not found.'); + } + + $user->delete(); + } +} \ No newline at end of file diff --git a/src/app/Packages/Features/CommandUseCases/UseCase/User/CreateUserUseCase.php b/src/app/Packages/Features/CommandUseCases/UseCase/User/CreateUserUseCase.php index 1abeb92..92cade5 100644 --- a/src/app/Packages/Features/CommandUseCases/UseCase/User/CreateUserUseCase.php +++ b/src/app/Packages/Features/CommandUseCases/UseCase/User/CreateUserUseCase.php @@ -2,7 +2,7 @@ namespace App\Packages\Features\CommandUseCases\UseCase\User; -use App\Packages\Domains\Interface\UserRepositroyInterface; +use App\Packages\Domains\User\Interface\UserRepositroyInterface; use App\Packages\Domains\User\Factory\UserEntityFactory; use App\Packages\Features\CommandUseCases\UseCommand\User\CreateUserCommand; use App\Packages\Features\QueryUseCases\Dto\User\UserDto; diff --git a/src/app/Packages/Features/CommandUseCases/UseCase/User/DeleteUserUseCase.php b/src/app/Packages/Features/CommandUseCases/UseCase/User/DeleteUserUseCase.php index d13b437..6c982bd 100644 --- a/src/app/Packages/Features/CommandUseCases/UseCase/User/DeleteUserUseCase.php +++ b/src/app/Packages/Features/CommandUseCases/UseCase/User/DeleteUserUseCase.php @@ -2,7 +2,7 @@ namespace App\Packages\Features\CommandUseCases\UseCase\User; -use App\Packages\Domains\Interface\UserRepositroyInterface; +use App\Packages\Domains\User\Interface\UserRepositroyInterface; final class DeleteUserUseCase { diff --git a/src/app/Packages/Features/CommandUseCases/UseCase/User/UpdateUserUseCase.php b/src/app/Packages/Features/CommandUseCases/UseCase/User/UpdateUserUseCase.php index a99e045..266d8b2 100644 --- a/src/app/Packages/Features/CommandUseCases/UseCase/User/UpdateUserUseCase.php +++ b/src/app/Packages/Features/CommandUseCases/UseCase/User/UpdateUserUseCase.php @@ -2,7 +2,7 @@ namespace App\Packages\Features\CommandUseCases\UseCase\User; -use App\Packages\Domains\Interface\UserRepositroyInterface; +use App\Packages\Domains\User\Interface\UserRepositroyInterface; use App\Packages\Domains\User\Factory\UserEntityFactory; use App\Packages\Features\CommandUseCases\UseCommand\User\UpdateUserCommand; use App\Packages\Features\QueryUseCases\Dto\User\UserDto; diff --git a/src/app/Packages/Features/QueryUseCases/Tests/UseCase/GetUserByIdUseCaseTest.php b/src/app/Packages/Features/QueryUseCases/Tests/UseCase/GetUserByIdUseCaseTest.php index 42e71fc..ae15c34 100644 --- a/src/app/Packages/Features/QueryUseCases/Tests/UseCase/GetUserByIdUseCaseTest.php +++ b/src/app/Packages/Features/QueryUseCases/Tests/UseCase/GetUserByIdUseCaseTest.php @@ -2,7 +2,7 @@ namespace App\Packages\Features\QueryUseCases\Tests\UseCase; -use App\Packages\Domains\Interface\UserRepositroyInterface; +use App\Packages\Domains\User\Interface\UserRepositroyInterface; use App\Packages\Features\QueryUseCases\Dto\User\UserDto; use App\Packages\Features\QueryUseCases\UseCase\User\GetUserByIdUseCase; use Exception; diff --git a/src/app/Packages/Features/QueryUseCases/UseCase/User/GetUserByIdUseCase.php b/src/app/Packages/Features/QueryUseCases/UseCase/User/GetUserByIdUseCase.php index 1fe1893..788aa02 100644 --- a/src/app/Packages/Features/QueryUseCases/UseCase/User/GetUserByIdUseCase.php +++ b/src/app/Packages/Features/QueryUseCases/UseCase/User/GetUserByIdUseCase.php @@ -2,7 +2,7 @@ namespace App\Packages\Features\QueryUseCases\UseCase\User; -use App\Packages\Domains\Interface\UserRepositroyInterface; +use App\Packages\Domains\User\Interface\UserRepositroyInterface; use App\Packages\Features\QueryUseCases\Dto\User\UserDto; use Exception; diff --git a/src/app/Packages/Features/Tests/CommandUseCases/CreateUserUseCaseTest.php b/src/app/Packages/Features/Tests/CommandUseCases/CreateUserUseCaseTest.php index 233553d..81e1632 100644 --- a/src/app/Packages/Features/Tests/CommandUseCases/CreateUserUseCaseTest.php +++ b/src/app/Packages/Features/Tests/CommandUseCases/CreateUserUseCaseTest.php @@ -2,7 +2,7 @@ namespace App\Packages\Features\Tests\CommandUseCases; -use App\Packages\Domains\Interface\UserRepositroyInterface; +use App\Packages\Domains\User\Interface\UserRepositroyInterface; use App\Packages\Domains\User\UserEntity; use App\Packages\Features\CommandUseCases\UseCase\User\CreateUserUseCase; use App\Packages\Features\CommandUseCases\UseCommand\User\CreateUserCommand; diff --git a/src/app/Packages/Features/Tests/CommandUseCases/DeleteUserUseCaseTest.php b/src/app/Packages/Features/Tests/CommandUseCases/DeleteUserUseCaseTest.php index e052074..74f4650 100644 --- a/src/app/Packages/Features/Tests/CommandUseCases/DeleteUserUseCaseTest.php +++ b/src/app/Packages/Features/Tests/CommandUseCases/DeleteUserUseCaseTest.php @@ -2,7 +2,7 @@ namespace App\Packages\Features\Tests\CommandUseCases; -use App\Packages\Domains\Interface\UserRepositroyInterface; +use App\Packages\Domains\User\Interface\UserRepositroyInterface; use App\Packages\Features\CommandUseCases\UseCase\User\DeleteUserUseCase; use Mockery; use Mockery\MockInterface; diff --git a/src/app/Packages/Features/Tests/CommandUseCases/UpdateUserUseCaseTest.php b/src/app/Packages/Features/Tests/CommandUseCases/UpdateUserUseCaseTest.php index 1c205db..63f14d3 100644 --- a/src/app/Packages/Features/Tests/CommandUseCases/UpdateUserUseCaseTest.php +++ b/src/app/Packages/Features/Tests/CommandUseCases/UpdateUserUseCaseTest.php @@ -2,7 +2,7 @@ namespace App\Packages\Features\Tests\CommandUseCases; -use App\Packages\Domains\Interface\UserRepositroyInterface; +use App\Packages\Domains\User\Interface\UserRepositroyInterface; use App\Packages\Domains\User\UserEntity; use App\Packages\Features\CommandUseCases\UseCase\User\UpdateUserUseCase; use App\Packages\Features\CommandUseCases\UseCommand\User\UpdateUserCommand; diff --git a/src/app/Providers/RepositoryProvider.php b/src/app/Providers/RepositoryProvider.php index acde5b0..cbba05b 100644 --- a/src/app/Providers/RepositoryProvider.php +++ b/src/app/Providers/RepositoryProvider.php @@ -2,8 +2,8 @@ namespace App\Providers; -use App\Packages\Domains\Interface\UserRepositroyInterface; -use App\Packages\Domains\UserRepository; +use App\Packages\Domains\User\Interface\UserRepositroyInterface; +use App\Packages\Domains\User\UserRepository; use App\Packages\Domains\WorldHeritageRepository; use App\Packages\Domains\WorldHeritageRepositoryInterface; use Illuminate\Support\ServiceProvider; From af6af97bdf34ba4628f0bb5f0aa341bec01f4f24 Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Sun, 12 Jul 2026 20:01:00 +0900 Subject: [PATCH 075/110] refactor: move WorldHeritage domain files under Domains/WorldHeritage/ --- .../Domains/{ => Image}/ImageEntity.php | 2 +- .../{ => Image}/ImageEntityCollection.php | 2 +- .../Tests}/ImageEntityCollectionTest.php | 6 +- .../{Test => Image/Tests}/ImageEntityTest.php | 4 +- .../Interface/UserRepositroyInterface.php | 17 --- .../User/Factory/SubscriptionFactoryTest.php | 69 --------- .../User/Factory/UserEntityFactoryTest.php | 74 --------- .../User/Subscription/SubscriptionTest.php | 121 --------------- .../Domains/Tests/User/UserEntityTest.php | 90 ----------- .../Domains/Tests/UserRepositoryTest.php | 141 ------------------ src/app/Packages/Domains/UserRepository.php | 80 ---------- .../AlgoliaWorldHeritageSearchAdapter.php | 6 +- .../Infra/CountryResolver.php | 2 +- .../Ports/Dto/HeritageSearchResult.php | 2 +- .../Ports/WorldHeritageSearchPort.php | 4 +- .../StudyRegion/CountryAliases.php | 2 +- .../StudyRegion/CountryToStudyRegionMap.php | 2 +- .../StudyRegion/ExceptionalStudyRegions.php | 2 +- .../StudyRegion/Iso3ToStudyRegionMap.php | 2 +- .../AlgoliaWorldHeritageSearchAdapterTest.php | 4 +- ...ritageQueryService_countEachRegionTest.php | 8 +- ...ritageQueryService_getAllHeritagesTest.php | 8 +- .../WorldHeritageQueryService_getByIdTest.php | 8 +- ...ritageQueryService_searchHeritagesTest.php | 8 +- ...WorldHeritageReadQueryService_findTest.php | 4 +- .../WorldHeritageComponentsListParserTest.php | 4 +- .../WorldHeritageEntityCollectionTest.php | 6 +- .../Tests}/WorldHeritageEntityTest.php | 6 +- .../WorldHeritageComponentsListParser.php | 2 +- .../WorldHeritageEntity.php | 2 +- .../WorldHeritageEntityCollection.php | 2 +- .../WorldHeritageQueryService.php | 4 +- .../WorldHeritageReadQueryService.php | 2 +- ...chWorldHeritagesWithAlgoliaUseCaseTest.php | 2 +- ...SearchWorldHeritagesWithAlgoliaUseCase.php | 2 +- .../Features/Tests/GetCountEachRegionTest.php | 4 +- .../Tests/GetWorldHeritageByIdTest.php | 4 +- .../Features/Tests/GetWorldHeritagesTest.php | 4 +- .../Tests/SearchWorldHeritagesTest.php | 4 +- src/app/Providers/AppServiceProvider.php | 4 +- src/app/Providers/PortsServiceProvider.php | 10 +- src/app/Providers/QueryServiceProvider.php | 4 +- src/app/Support/StudyRegionResolver.php | 8 +- 43 files changed, 75 insertions(+), 667 deletions(-) rename src/app/Packages/Domains/{ => Image}/ImageEntity.php (93%) rename src/app/Packages/Domains/{ => Image}/ImageEntityCollection.php (90%) rename src/app/Packages/Domains/{Test => Image/Tests}/ImageEntityCollectionTest.php (93%) rename src/app/Packages/Domains/{Test => Image/Tests}/ImageEntityTest.php (96%) delete mode 100644 src/app/Packages/Domains/Interface/UserRepositroyInterface.php delete mode 100644 src/app/Packages/Domains/Tests/User/Factory/SubscriptionFactoryTest.php delete mode 100644 src/app/Packages/Domains/Tests/User/Factory/UserEntityFactoryTest.php delete mode 100644 src/app/Packages/Domains/Tests/User/Subscription/SubscriptionTest.php delete mode 100644 src/app/Packages/Domains/Tests/User/UserEntityTest.php delete mode 100644 src/app/Packages/Domains/Tests/UserRepositoryTest.php delete mode 100644 src/app/Packages/Domains/UserRepository.php rename src/app/Packages/Domains/{ => WorldHeritage}/Adapter/AlgoliaWorldHeritageSearchAdapter.php (97%) rename src/app/Packages/Domains/{ => WorldHeritage}/Infra/CountryResolver.php (98%) rename src/app/Packages/Domains/{ => WorldHeritage}/Ports/Dto/HeritageSearchResult.php (82%) rename src/app/Packages/Domains/{ => WorldHeritage}/Ports/WorldHeritageSearchPort.php (65%) rename src/app/Packages/Domains/{ => WorldHeritage}/StudyRegion/CountryAliases.php (96%) rename src/app/Packages/Domains/{ => WorldHeritage}/StudyRegion/CountryToStudyRegionMap.php (99%) rename src/app/Packages/Domains/{ => WorldHeritage}/StudyRegion/ExceptionalStudyRegions.php (71%) rename src/app/Packages/Domains/{ => WorldHeritage}/StudyRegion/Iso3ToStudyRegionMap.php (99%) rename src/app/Packages/Domains/{Test => WorldHeritage/Tests}/AlgoliaWorldHeritageSearchAdapterTest.php (94%) rename src/app/Packages/Domains/{Test => WorldHeritage/Tests}/QueryService/WorldHeritageQueryService_countEachRegionTest.php (97%) rename src/app/Packages/Domains/{Test => WorldHeritage/Tests}/QueryService/WorldHeritageQueryService_getAllHeritagesTest.php (94%) rename src/app/Packages/Domains/{Test => WorldHeritage/Tests}/QueryService/WorldHeritageQueryService_getByIdTest.php (97%) rename src/app/Packages/Domains/{Test => WorldHeritage/Tests}/QueryService/WorldHeritageQueryService_searchHeritagesTest.php (94%) rename src/app/Packages/Domains/{Test => WorldHeritage/Tests}/QueryService/WorldHeritageReadQueryService_findTest.php (95%) rename src/app/Packages/Domains/{Test => WorldHeritage/Tests}/WorldHeritageComponentsListParserTest.php (94%) rename src/app/Packages/Domains/{Test => WorldHeritage/Tests}/WorldHeritageEntityCollectionTest.php (98%) rename src/app/Packages/Domains/{Test => WorldHeritage/Tests}/WorldHeritageEntityTest.php (98%) rename src/app/Packages/Domains/{ => WorldHeritage}/WorldHeritageComponentsListParser.php (97%) rename src/app/Packages/Domains/{ => WorldHeritage}/WorldHeritageEntity.php (99%) rename src/app/Packages/Domains/{ => WorldHeritage}/WorldHeritageEntityCollection.php (90%) rename src/app/Packages/Domains/{ => WorldHeritage}/WorldHeritageQueryService.php (99%) rename src/app/Packages/Domains/{ => WorldHeritage}/WorldHeritageReadQueryService.php (98%) diff --git a/src/app/Packages/Domains/ImageEntity.php b/src/app/Packages/Domains/Image/ImageEntity.php similarity index 93% rename from src/app/Packages/Domains/ImageEntity.php rename to src/app/Packages/Domains/Image/ImageEntity.php index bfa8d34..8598382 100644 --- a/src/app/Packages/Domains/ImageEntity.php +++ b/src/app/Packages/Domains/Image/ImageEntity.php @@ -1,6 +1,6 @@ 'free', - 'expires_at' => null, - ]); - - $this->assertInstanceOf(Subscription::class, $subscription); - $this->assertTrue($subscription->isFree()); - } - - public function test_build_returns_premium_subscription_with_string_expires_at(): void - { - $subscription = SubscriptionFactory::build([ - 'tier' => 'premium', - 'expires_at' => '2027-01-01 00:00:00', - ]); - - $this->assertInstanceOf(Subscription::class, $subscription); - $this->assertFalse($subscription->isFree()); - $this->assertTrue($subscription->isActive(new DateTimeImmutable('2026-01-01'))); - } - - public function test_build_accepts_datetime_immutable_for_expires_at(): void - { - $expiresAt = new DateTimeImmutable('2027-06-01'); - - $subscription = SubscriptionFactory::build([ - 'tier' => 'premium', - 'expires_at' => $expiresAt, - ]); - - $this->assertInstanceOf(Subscription::class, $subscription); - $this->assertTrue($subscription->isActive(new DateTimeImmutable('2026-01-01'))); - } - - public function test_build_accepts_string_for_now(): void - { - $subscription = SubscriptionFactory::build([ - 'tier' => 'premium', - 'expires_at' => '2025-01-01 00:00:00', - 'now' => '2026-01-01 00:00:00', - ]); - - $this->assertTrue($subscription->isExpired()); - } - - public function test_build_accepts_datetime_immutable_for_now(): void - { - $subscription = SubscriptionFactory::build([ - 'tier' => 'premium', - 'expires_at' => '2025-01-01 00:00:00', - 'now' => new DateTimeImmutable('2026-01-01'), - ]); - - $this->assertTrue($subscription->isExpired()); - } -} \ No newline at end of file diff --git a/src/app/Packages/Domains/Tests/User/Factory/UserEntityFactoryTest.php b/src/app/Packages/Domains/Tests/User/Factory/UserEntityFactoryTest.php deleted file mode 100644 index 3b8119e..0000000 --- a/src/app/Packages/Domains/Tests/User/Factory/UserEntityFactoryTest.php +++ /dev/null @@ -1,74 +0,0 @@ - $faker->unique()->randomNumber(5), - 'first_name' => $faker->firstName(), - 'last_name' => $faker->lastName(), - 'email' => $faker->unique()->safeEmail(), - 'age_range' => 'teens', - 'subscription_tier' => 'free', - 'subscription_expires_at' => null, - ]; - - $entity = UserEntityFactory::build($data); - - $this->assertInstanceOf(UserEntity::class, $entity); - $this->assertSame($data['id'], $entity->getId()); - $this->assertSame($data['first_name'], $entity->getFirstName()); - $this->assertSame($data['last_name'], $entity->getLastName()); - $this->assertSame($data['email'], $entity->getEmail()); - $this->assertSame(AgeRange::Teens, $entity->getAgeRange()); - $this->assertTrue($entity->getSubscription()->isFree()); - $this->assertNull($entity->getPasswordHash()); - } - - public function test_build_sets_password_hash_when_provided(): void - { - $faker = FakerFactory::create(); - - $entity = UserEntityFactory::build([ - 'id' => null, - 'first_name' => $faker->firstName(), - 'last_name' => $faker->lastName(), - 'email' => $faker->unique()->safeEmail(), - 'age_range' => '20s', - 'subscription_tier' => 'free', - 'subscription_expires_at' => null, - 'password_hash' => 'hashed_value', - ]); - - $this->assertNull($entity->getId()); - $this->assertSame('hashed_value', $entity->getPasswordHash()); - } - - public function test_build_passes_expires_at_to_subscription(): void - { - $faker = FakerFactory::create(); - - $entity = UserEntityFactory::build([ - 'id' => $faker->unique()->randomNumber(5), - 'first_name' => $faker->firstName(), - 'last_name' => $faker->lastName(), - 'email' => $faker->unique()->safeEmail(), - 'age_range' => '20s', - 'subscription_tier' => 'premium', - 'subscription_expires_at' => '2027-01-01 00:00:00', - ]); - - $this->assertFalse($entity->getSubscription()->isFree()); - } -} \ No newline at end of file diff --git a/src/app/Packages/Domains/Tests/User/Subscription/SubscriptionTest.php b/src/app/Packages/Domains/Tests/User/Subscription/SubscriptionTest.php deleted file mode 100644 index dbfdbe0..0000000 --- a/src/app/Packages/Domains/Tests/User/Subscription/SubscriptionTest.php +++ /dev/null @@ -1,121 +0,0 @@ -assertTrue($subscription->isActive()); - } - - public function test_isActive_returns_false_when_free(): void - { - $subscription = new Subscription( - tier: SubscriptionTier::Free, - expiresAt: new DateTimeImmutable('+1 day'), - now: new DateTimeImmutable(), - ); - - $this->assertFalse($subscription->isActive()); - } - - public function test_isActive_returns_false_when_premium_and_expires_at_is_null(): void - { - $subscription = new Subscription( - tier: SubscriptionTier::Premium, - expiresAt: null, - now: new DateTimeImmutable(), - ); - - $this->assertFalse($subscription->isActive()); - } - - public function test_isActive_returns_false_when_premium_and_expired(): void - { - $subscription = new Subscription( - tier: SubscriptionTier::Premium, - expiresAt: new DateTimeImmutable('-1 day'), - now: new DateTimeImmutable(), - ); - - $this->assertFalse($subscription->isActive()); - } - - public function test_isExpired_returns_true_when_premium_and_past_expiry(): void - { - $subscription = new Subscription( - tier: SubscriptionTier::Premium, - expiresAt: new DateTimeImmutable('-1 day'), - now: new DateTimeImmutable(), - ); - - $this->assertTrue($subscription->isExpired()); - } - - public function test_isExpired_returns_true_when_premium_and_expires_at_is_null(): void - { - $subscription = new Subscription( - tier: SubscriptionTier::Premium, - expiresAt: null, - now: new DateTimeImmutable(), - ); - - $this->assertTrue($subscription->isExpired()); - } - - public function test_isExpired_returns_false_when_free(): void - { - $subscription = new Subscription( - tier: SubscriptionTier::Free, - expiresAt: null, - now: new DateTimeImmutable(), - ); - - $this->assertFalse($subscription->isExpired()); - } - - public function test_isExpired_returns_false_when_premium_and_not_expired(): void - { - $subscription = new Subscription( - tier: SubscriptionTier::Premium, - expiresAt: new DateTimeImmutable('+1 day'), - now: new DateTimeImmutable(), - ); - - $this->assertFalse($subscription->isExpired()); - } - - public function test_isFree_returns_true_when_free(): void - { - $subscription = new Subscription( - tier: SubscriptionTier::Free, - expiresAt: null, - now: new DateTimeImmutable(), - ); - - $this->assertTrue($subscription->isFree()); - } - - public function test_isFree_returns_false_when_premium(): void - { - $subscription = new Subscription( - tier: SubscriptionTier::Premium, - expiresAt: new DateTimeImmutable('+1 day'), - now: new DateTimeImmutable(), - ); - - $this->assertFalse($subscription->isFree()); - } -} \ No newline at end of file diff --git a/src/app/Packages/Domains/Tests/User/UserEntityTest.php b/src/app/Packages/Domains/Tests/User/UserEntityTest.php deleted file mode 100644 index 94c8635..0000000 --- a/src/app/Packages/Domains/Tests/User/UserEntityTest.php +++ /dev/null @@ -1,90 +0,0 @@ -unique()->randomNumber(5), - firstName: $overrides['first_name'] ?? $faker->firstName(), - lastName: $overrides['last_name'] ?? $faker->lastName(), - email: $overrides['email'] ?? $faker->unique()->safeEmail(), - ageRange: $overrides['age_range'] ?? AgeRange::Twenties, - subscription: $overrides['subscription'] ?? $subscription, - passwordHash: $overrides['password_hash'] ?? null, - ); - } - - public function test_getId_returns_correct_value(): void - { - $entity = $this->buildEntity(['id' => 42]); - $this->assertSame(42, $entity->getId()); - } - - public function test_getId_returns_null_when_not_yet_persisted(): void - { - $entity = $this->buildEntity(['id' => null]); - $this->assertNull($entity->getId()); - } - - public function test_getFirstName_returns_correct_value(): void - { - $entity = $this->buildEntity(['first_name' => 'John']); - $this->assertSame('John', $entity->getFirstName()); - } - - public function test_getLastName_returns_correct_value(): void - { - $entity = $this->buildEntity(['last_name' => 'Doe']); - $this->assertSame('Doe', $entity->getLastName()); - } - - public function test_getFullName_concatenates_first_and_last_name(): void - { - $entity = $this->buildEntity(['first_name' => 'John', 'last_name' => 'Doe']); - $this->assertSame('John Doe', $entity->getFullName()); - } - - public function test_getEmail_returns_correct_value(): void - { - $entity = $this->buildEntity(['email' => 'john@example.com']); - $this->assertSame('john@example.com', $entity->getEmail()); - } - - public function test_getAgeRange_returns_correct_enum(): void - { - $entity = $this->buildEntity(['age_range' => AgeRange::Thirties]); - $this->assertSame(AgeRange::Thirties, $entity->getAgeRange()); - } - - public function test_getSubscription_returns_subscription_instance(): void - { - $subscription = new Subscription(SubscriptionTier::Premium, null); - $entity = $this->buildEntity(['subscription' => $subscription]); - $this->assertSame($subscription, $entity->getSubscription()); - } - - public function test_getPasswordHash_returns_hashed_value(): void - { - $entity = $this->buildEntity(['password_hash' => 'hashed_secret']); - $this->assertSame('hashed_secret', $entity->getPasswordHash()); - } - - public function test_getPasswordHash_returns_null_when_not_set(): void - { - $entity = $this->buildEntity(['password_hash' => null]); - $this->assertNull($entity->getPasswordHash()); - } -} \ No newline at end of file diff --git a/src/app/Packages/Domains/Tests/UserRepositoryTest.php b/src/app/Packages/Domains/Tests/UserRepositoryTest.php deleted file mode 100644 index 96c3be0..0000000 --- a/src/app/Packages/Domains/Tests/UserRepositoryTest.php +++ /dev/null @@ -1,141 +0,0 @@ -truncate(); - } - - protected function tearDown(): void - { - $this->truncate(); - parent::tearDown(); - } - - private function truncate(): void - { - if (env('APP_ENV') === 'testing') { - DB::connection('mysql')->statement('SET FOREIGN_KEY_CHECKS=0;'); - User::truncate(); - DB::connection('mysql')->statement('SET FOREIGN_KEY_CHECKS=1;'); - } - } - - private function repository(): UserRepository - { - return new UserRepository(new User()); - } - - private function seedUser(array $overrides = []): User - { - $faker = FakerFactory::create(); - - return User::create(array_merge([ - 'first_name' => $faker->firstName(), - 'last_name' => $faker->lastName(), - 'email' => $faker->unique()->safeEmail(), - 'password' => bcrypt('password'), - 'age_range' => 'teens', - 'subscription_tier' => 'free', - 'subscription_expires_at' => null, - ], $overrides)); - } - - private function buildEntity(array $overrides = []): UserEntity - { - $faker = FakerFactory::create(); - - return UserEntityFactory::build(array_merge([ - 'id' => null, - 'first_name' => $faker->firstName(), - 'last_name' => $faker->lastName(), - 'email' => $faker->unique()->safeEmail(), - 'age_range' => 'teens', - 'subscription_tier' => 'free', - 'subscription_expires_at' => null, - 'password_hash' => bcrypt('password'), - ], $overrides)); - } - - public function test_createUser_persists_user_and_returns_dto(): void - { - $result = $this->repository()->createUser($this->buildEntity()); - - $this->assertInstanceOf(UserDto::class, $result); - $this->assertDatabaseHas('users', ['email' => $result->email]); - } - - public function test_findById_returns_user_dto_when_user_exists(): void - { - $user = $this->seedUser(); - $result = $this->repository()->findById($user->id); - - $this->assertInstanceOf(UserDto::class, $result); - $this->assertSame($user->id, $result->id); - $this->assertSame($user->email, $result->email); - } - - public function test_findById_returns_null_when_user_not_found(): void - { - $result = $this->repository()->findById(999999); - - $this->assertNull($result); - } - - public function test_updateUser_persists_changes_and_returns_dto(): void - { - $user = $this->seedUser(['email' => 'before@example.com']); - $entity = $this->buildEntity([ - 'id' => $user->id, - 'email' => 'after@example.com', - ]); - - $result = $this->repository()->updateUser($entity); - - $this->assertInstanceOf(UserDto::class, $result); - $this->assertDatabaseHas('users', ['id' => $user->id, 'email' => 'after@example.com']); - } - - public function test_updateUser_throws_exception_when_user_not_found(): void - { - $entity = $this->buildEntity(['id' => 999999]); - - $this->expectException(\Exception::class); - $this->expectExceptionMessage('User not found.'); - - $this->repository()->updateUser($entity); - } - - public function test_deleteUser_removes_user_when_exists(): void - { - $user = $this->seedUser(); - - $this->repository()->deleteUser($user->id); - - $this->assertDatabaseMissing('users', ['id' => $user->id]); - } - - public function test_deleteUser_throws_exception_when_user_not_found(): void - { - $this->expectException(\Exception::class); - $this->expectExceptionMessage('User not found.'); - - $this->repository()->deleteUser(999999); - } -} \ No newline at end of file diff --git a/src/app/Packages/Domains/UserRepository.php b/src/app/Packages/Domains/UserRepository.php deleted file mode 100644 index 8794058..0000000 --- a/src/app/Packages/Domains/UserRepository.php +++ /dev/null @@ -1,80 +0,0 @@ -userModel->create([ - 'email' => $entity->getEmail(), - 'first_name' => $entity->getFirstName(), - 'last_name' => $entity->getLastName(), - 'password' => $entity->getPasswordHash(), - 'age_range' => $entity->getAgeRange()->value, - 'subscription_tier' => $entity->getSubscription()->getTier()->value, - 'subscription_expires_at' => $entity->getSubscription()->getExpiresAt()?->format('Y-m-d H:i:s'), - ]); - - if (!$insertUser->wasRecentlyCreated) { - throw new Exception('Failed to create user.'); - } - - return UserDtoFactory::build($insertUser->toArray()); - } - - public function findById(int $id): ?UserDto - { - $user = $this->userModel->find($id); - - if ($user === null) { - return null; - } - - return UserDtoFactory::build($user->toArray()); - } - - public function updateUser(UserEntity $entity): UserDto - { - $user = $this->userModel->find($entity->getId()); - - if ($user === null) { - throw new Exception('User not found.'); - } - - $user->update([ - 'first_name' => $entity->getFirstName(), - 'last_name' => $entity->getLastName(), - 'email' => $entity->getEmail(), - 'age_range' => $entity->getAgeRange()->value, - 'subscription_tier' => $entity->getSubscription()->getTier()->value, - 'subscription_expires_at' => $entity->getSubscription()->getExpiresAt()?->format('Y-m-d H:i:s'), - ]); - - $user->refresh(); - - return UserDtoFactory::build($user->toArray()); - } - - public function deleteUser(int $id): void - { - $user = $this->userModel->find($id); - - if ($user === null) { - throw new Exception('User not found.'); - } - - $user->delete(); - } -} \ No newline at end of file diff --git a/src/app/Packages/Domains/Adapter/AlgoliaWorldHeritageSearchAdapter.php b/src/app/Packages/Domains/WorldHeritage/Adapter/AlgoliaWorldHeritageSearchAdapter.php similarity index 97% rename from src/app/Packages/Domains/Adapter/AlgoliaWorldHeritageSearchAdapter.php rename to src/app/Packages/Domains/WorldHeritage/Adapter/AlgoliaWorldHeritageSearchAdapter.php index cd44160..97f7271 100644 --- a/src/app/Packages/Domains/Adapter/AlgoliaWorldHeritageSearchAdapter.php +++ b/src/app/Packages/Domains/WorldHeritage/Adapter/AlgoliaWorldHeritageSearchAdapter.php @@ -1,10 +1,10 @@ Date: Sun, 12 Jul 2026 20:01:11 +0900 Subject: [PATCH 076/110] docs: add CLAUDE.md with project conventions --- src/CLAUDE.md | 71 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 src/CLAUDE.md diff --git a/src/CLAUDE.md b/src/CLAUDE.md new file mode 100644 index 0000000..c59dddc --- /dev/null +++ b/src/CLAUDE.md @@ -0,0 +1,71 @@ +# CLAUDE.md + +## Commit Rules + +- コミットは必ず細分化する(1つの論理的変更 = 1コミット) +- コミットメッセージに Claude の署名(`Co-Authored-By`, `Generated with Claude Code` など)を含めない +- コミットメッセージは英語で記述する(例: `feat: add deleteUser to UserRepositroyInterface`) + +## Branch Rules + +- 機能ごとに親ブランチを切り、層ごとに子ブランチを切る + - 例: `feat/user-delete`(親) + - 例: `feat/user-delete_infra-layer`(子) + - 例: `feat/user-delete_application-layer`(子) + - 例: `feat/user-delete_presentation-layer`(子) +- 子ブランチは親ブランチへ PR を出す +- 親ブランチはさらに上位ブランチへ PR を出す + +## PR Rules + +### Title +- 日英併記にする +- 例: `feat: implement Presentation layer for DELETE /api/v1/users/{id} / ユーザー削除APIのPresentation層実装` + +### Description +以下の構成で記述する: + +``` +## Motivation / 目的 + +(英語で動機・背景を説明。日本語でも補足する) + +## What I have done / 実施内容 + +(実装内容を箇条書きで記述) + +## Test Results / テスト結果 + +(実行したテストのメソッド名を列挙) + +Closes #xxx +``` + +- PR description に Claude の署名(`Generated with Claude Code` など)を含めない + +## Testing Rules + +- テストファイルはすべて `app/Packages/` 配下に置く(`tests/` ではない) + - phpunit.xml が `app/Packages` のみをスキャンするため +- Infra 層のテストは実 DB(Docker の `heritage-mysql-test`)を使ったインテグレーションテストにする(Mockery 不使用) +- Application 層・Domain 層のテストは Mockery を使ったユニットテストにする +- void を返すメソッドのテストでは `#[\PHPUnit\Framework\Attributes\DoesNotPerformAssertions]` アトリビュートを使う + +## Architecture Rules (DDD / CQRS) + +- `QueryUseCases`: 読み取り専用(副作用なし) + - UseCase, Dto, Factory/Dto, ViewModel, Factory/ViewModel +- `CommandUseCases`: 書き込み(状態変更あり) + - UseCase, UseCommand +- `UserRepositroyInterface` の引数は `UserEntity`(Command オブジェクトを直接渡さない) +- パスワードのハッシュ化は Application 層(UseCase)で行う +- パスワード変更は独立した別 API とし、更新系 Command には含めない + +## Docker File Sync + +- macOS + Docker Desktop ではファイル同期に遅延が発生することがある +- テストが古い挙動を示す場合は `docker cp` でファイルを強制同期する + ```bash + docker cp heritage-app: + docker exec heritage-app php artisan route:clear + ``` From d5ae586b3e6260277167126e429b67dbc181a7f6 Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Mon, 13 Jul 2026 08:53:17 +0900 Subject: [PATCH 077/110] feat: add Email ValueObject to User domain --- .../Domains/User/ValueObject/Email.php | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 src/app/Packages/Domains/User/ValueObject/Email.php diff --git a/src/app/Packages/Domains/User/ValueObject/Email.php b/src/app/Packages/Domains/User/ValueObject/Email.php new file mode 100644 index 0000000..4c8eecc --- /dev/null +++ b/src/app/Packages/Domains/User/ValueObject/Email.php @@ -0,0 +1,27 @@ +value; + } + + public function isEqual(self $otherEmail): bool + { + return $this->value === $otherEmail->value; + } +} \ No newline at end of file From 08c65506753bdf2984abe0f9f3137b8e76d1d4aa Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Mon, 13 Jul 2026 08:53:27 +0900 Subject: [PATCH 078/110] feat: add findByEmail to UserRepositroyInterface --- .../Domains/User/Interface/UserRepositroyInterface.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/app/Packages/Domains/User/Interface/UserRepositroyInterface.php b/src/app/Packages/Domains/User/Interface/UserRepositroyInterface.php index 2ca67e5..52eb0bd 100644 --- a/src/app/Packages/Domains/User/Interface/UserRepositroyInterface.php +++ b/src/app/Packages/Domains/User/Interface/UserRepositroyInterface.php @@ -4,6 +4,7 @@ use App\Packages\Domains\User\UserEntity; use App\Packages\Features\QueryUseCases\Dto\User\UserDto; +use App\Packages\Domains\User\ValueObject\Email; interface UserRepositroyInterface { @@ -14,4 +15,6 @@ public function findById(int $id): ?UserDto; public function updateUser(UserEntity $entity): UserDto; public function deleteUser(int $id): void; + + public function findByEmail(Email $email): ?UserEntity; } \ No newline at end of file From 172e35868f1538930f4ac7d1fb7b8bcc8b6d9ffd Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Mon, 13 Jul 2026 08:53:31 +0900 Subject: [PATCH 079/110] refactor: update UserEntityFactory to use Email ValueObject --- src/app/Packages/Domains/User/Factory/UserEntityFactory.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/app/Packages/Domains/User/Factory/UserEntityFactory.php b/src/app/Packages/Domains/User/Factory/UserEntityFactory.php index 5f7825e..5082d3f 100644 --- a/src/app/Packages/Domains/User/Factory/UserEntityFactory.php +++ b/src/app/Packages/Domains/User/Factory/UserEntityFactory.php @@ -5,6 +5,7 @@ use App\Packages\Domains\User\Factory\SubscriptionFactory; use App\Packages\Domains\User\UserEntity; use App\Packages\Domains\User\AgeRange; +use App\Packages\Domains\User\ValueObject\Email; final class UserEntityFactory { @@ -20,7 +21,7 @@ public static function build(array $data): UserEntity id: isset($data['id']) ? (int) $data['id'] : null, firstName: $data['first_name'], lastName: $data['last_name'], - email: $data['email'], + email: new Email($data['email']), ageRange: AgeRange::from($data['age_range']), subscription: $subscription, passwordHash: $data['password_hash'] ?? null, From 92fa72ef62771dbb77616906d67a93b081b64017 Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Mon, 13 Jul 2026 08:53:38 +0900 Subject: [PATCH 080/110] refactor: update UserEntity to use Email ValueObject --- src/app/Packages/Domains/User/UserEntity.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/app/Packages/Domains/User/UserEntity.php b/src/app/Packages/Domains/User/UserEntity.php index a8f517c..7caa778 100644 --- a/src/app/Packages/Domains/User/UserEntity.php +++ b/src/app/Packages/Domains/User/UserEntity.php @@ -3,6 +3,7 @@ namespace App\Packages\Domains\User; use App\Packages\Domains\User\Subscription\Subscription; +use App\Packages\Domains\User\ValueObject\Email; final readonly class UserEntity { @@ -10,7 +11,7 @@ public function __construct( private readonly ?int $id, private readonly string $firstName, private readonly string $lastName, - private readonly string $email, + private readonly Email $email, private readonly AgeRange $ageRange, private readonly Subscription $subscription, private readonly ?string $passwordHash = null, @@ -41,7 +42,7 @@ public function getFullName(): string return $this->firstName . ' ' . $this->lastName; } - public function getEmail(): string + public function getEmail(): Email { return $this->email; } From 435dc905ef0659b72ccc34d54d10a34a1e08607e Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Mon, 13 Jul 2026 08:53:52 +0900 Subject: [PATCH 081/110] feat: implement findByEmail in UserRepository --- .../Packages/Domains/User/UserRepository.php | 26 +++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/src/app/Packages/Domains/User/UserRepository.php b/src/app/Packages/Domains/User/UserRepository.php index a3b8dee..43d1657 100644 --- a/src/app/Packages/Domains/User/UserRepository.php +++ b/src/app/Packages/Domains/User/UserRepository.php @@ -5,6 +5,8 @@ use App\Models\User; use App\Packages\Domains\User\Interface\UserRepositroyInterface; use App\Packages\Domains\User\UserEntity; +use App\Packages\Domains\User\ValueObject\Email; +use App\Packages\Domains\User\Factory\UserEntityFactory; use App\Packages\Features\QueryUseCases\Dto\User\UserDto; use App\Packages\Features\QueryUseCases\Factory\Dto\UserDtoFactory; use Exception; @@ -18,7 +20,7 @@ public function __construct( public function createUser(UserEntity $entity): UserDto { $insertUser = $this->userModel->create([ - 'email' => $entity->getEmail(), + 'email' => $entity->getEmail()->value(), 'first_name' => $entity->getFirstName(), 'last_name' => $entity->getLastName(), 'password' => $entity->getPasswordHash(), @@ -56,7 +58,7 @@ public function updateUser(UserEntity $entity): UserDto $user->update([ 'first_name' => $entity->getFirstName(), 'last_name' => $entity->getLastName(), - 'email' => $entity->getEmail(), + 'email' => $entity->getEmail()->value(), 'age_range' => $entity->getAgeRange()->value, 'subscription_tier' => $entity->getSubscription()->getTier()->value, 'subscription_expires_at' => $entity->getSubscription()->getExpiresAt()?->format('Y-m-d H:i:s'), @@ -77,4 +79,24 @@ public function deleteUser(int $id): void $user->delete(); } + + public function findByEmail(Email $email): ?UserEntity + { + $user = $this->userModel->where('email', $email->value())->first(); + + if ($user === null) { + return null; + } + + return UserEntityFactory::build([ + 'id' => $user->id, + 'first_name' => $user->first_name, + 'last_name' => $user->last_name, + 'email' => $user->email, + 'age_range' => $user->age_range, + 'subscription_tier' => $user->subscription_tier, + 'subscription_expires_at' => $user->subscription_expires_at, + 'password_hash' => $user->password, + ]); + } } \ No newline at end of file From 2fabbf385fc5dfb5d6ef554b1f611b8f73166904 Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Mon, 13 Jul 2026 08:54:01 +0900 Subject: [PATCH 082/110] test: add findByEmail integration tests to UserRepositoryTest --- .../Domains/User/Tests/UserRepositoryTest.php | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src/app/Packages/Domains/User/Tests/UserRepositoryTest.php b/src/app/Packages/Domains/User/Tests/UserRepositoryTest.php index 4cc6bed..839e1b5 100644 --- a/src/app/Packages/Domains/User/Tests/UserRepositoryTest.php +++ b/src/app/Packages/Domains/User/Tests/UserRepositoryTest.php @@ -9,6 +9,7 @@ use App\Packages\Domains\User\Subscription\SubscriptionTier; use App\Packages\Domains\User\UserEntity; use App\Packages\Domains\User\UserRepository; +use App\Packages\Domains\User\ValueObject\Email; use App\Packages\Features\QueryUseCases\Dto\User\UserDto; use Faker\Factory as FakerFactory; use Illuminate\Support\Facades\DB; @@ -138,4 +139,38 @@ public function test_deleteUser_throws_exception_when_user_not_found(): void $this->repository()->deleteUser(999999); } + + public function test_findByEmail_returns_user_entity_when_user_exists(): void + { + $user = $this->seedUser(['email' => 'john@example.com']); + + $result = $this->repository()->findByEmail(new Email('john@example.com')); + + $this->assertInstanceOf(UserEntity::class, $result); + $this->assertSame($user->email, $result->getEmail()->value()); + $this->assertSame($user->first_name, $result->getFirstName()); + $this->assertSame($user->last_name, $result->getLastName()); + } + + public function test_findByEmail_returns_null_when_user_does_not_exist(): void + { + $result = $this->repository()->findByEmail(new Email('notfound@example.com')); + + $this->assertNull($result); + } + + public function test_findByEmail_returns_correct_password_hash(): void + { + $plainPassword = 'secret123'; + $this->seedUser([ + 'email' => 'hash@example.com', + 'password' => bcrypt($plainPassword), + ]); + + $result = $this->repository()->findByEmail(new Email('hash@example.com')); + + $this->assertNotNull($result->getPasswordHash()); + $this->assertTrue(password_verify($plainPassword, $result->getPasswordHash())); + } + } \ No newline at end of file From 0657750c7748d9d2904e30b36f959242ae581649 Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Mon, 13 Jul 2026 09:07:24 +0900 Subject: [PATCH 083/110] fix: update UserEntityFactoryTest to call getEmail()->value() after Email VO introduction --- .../Domains/User/Tests/Factory/UserEntityFactoryTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/Packages/Domains/User/Tests/Factory/UserEntityFactoryTest.php b/src/app/Packages/Domains/User/Tests/Factory/UserEntityFactoryTest.php index a26b6bd..c5649cf 100644 --- a/src/app/Packages/Domains/User/Tests/Factory/UserEntityFactoryTest.php +++ b/src/app/Packages/Domains/User/Tests/Factory/UserEntityFactoryTest.php @@ -30,7 +30,7 @@ public function test_build_returns_user_entity_with_correct_values(): void $this->assertSame($data['id'], $entity->getId()); $this->assertSame($data['first_name'], $entity->getFirstName()); $this->assertSame($data['last_name'], $entity->getLastName()); - $this->assertSame($data['email'], $entity->getEmail()); + $this->assertSame($data['email'], $entity->getEmail()->value()); $this->assertSame(AgeRange::Teens, $entity->getAgeRange()); $this->assertTrue($entity->getSubscription()->isFree()); $this->assertNull($entity->getPasswordHash()); From f4f1f882affac8bb93985fe8dc9e896fcc5619ae Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Mon, 13 Jul 2026 09:10:03 +0900 Subject: [PATCH 084/110] fix: update UserEntityTest to wrap email string in Email VO --- src/app/Packages/Domains/User/Tests/UserEntityTest.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/app/Packages/Domains/User/Tests/UserEntityTest.php b/src/app/Packages/Domains/User/Tests/UserEntityTest.php index 13b4cb5..5211af0 100644 --- a/src/app/Packages/Domains/User/Tests/UserEntityTest.php +++ b/src/app/Packages/Domains/User/Tests/UserEntityTest.php @@ -6,6 +6,7 @@ use App\Packages\Domains\User\Subscription\Subscription; use App\Packages\Domains\User\Subscription\SubscriptionTier; use App\Packages\Domains\User\UserEntity; +use App\Packages\Domains\User\ValueObject\Email; use Faker\Factory as FakerFactory; use PHPUnit\Framework\TestCase; @@ -20,7 +21,7 @@ private function buildEntity(array $overrides = []): UserEntity id: array_key_exists('id', $overrides) ? $overrides['id'] : $faker->unique()->randomNumber(5), firstName: $overrides['first_name'] ?? $faker->firstName(), lastName: $overrides['last_name'] ?? $faker->lastName(), - email: $overrides['email'] ?? $faker->unique()->safeEmail(), + email: isset($overrides['email']) ? new Email($overrides['email']) : new Email($faker->unique()->safeEmail()), ageRange: $overrides['age_range'] ?? AgeRange::Twenties, subscription: $overrides['subscription'] ?? $subscription, passwordHash: $overrides['password_hash'] ?? null, @@ -60,7 +61,7 @@ public function test_getFullName_concatenates_first_and_last_name(): void public function test_getEmail_returns_correct_value(): void { $entity = $this->buildEntity(['email' => 'john@example.com']); - $this->assertSame('john@example.com', $entity->getEmail()); + $this->assertSame('john@example.com', $entity->getEmail()->value()); } public function test_getAgeRange_returns_correct_enum(): void From c931f741cc22b857961278b13af61d95b1d19332 Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Mon, 13 Jul 2026 09:21:34 +0900 Subject: [PATCH 085/110] feat: install laravel/sanctum --- src/composer.json | 1 + src/composer.lock | 65 ++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/src/composer.json b/src/composer.json index 49f6da7..c82bc29 100644 --- a/src/composer.json +++ b/src/composer.json @@ -12,6 +12,7 @@ "algolia/algoliasearch-client-php": "^4.0", "google/cloud-storage": "^1", "laravel/framework": "^11.31", + "laravel/sanctum": "^4.3", "laravel/tinker": "^2.9", "league/flysystem-google-cloud-storage": "^3", "symfony/intl": "^7.4" diff --git a/src/composer.lock b/src/composer.lock index 1aabe4a..68421a8 100644 --- a/src/composer.lock +++ b/src/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "68110e920a7d7e2ac88297daac555d38", + "content-hash": "3720ffca039e682d60884862ba8486cd", "packages": [ { "name": "algolia/algoliasearch-client-php", @@ -1935,6 +1935,69 @@ }, "time": "2026-02-06T12:17:10+00:00" }, + { + "name": "laravel/sanctum", + "version": "v4.3.2", + "source": { + "type": "git", + "url": "https://github.com/laravel/sanctum.git", + "reference": "2a9bccc18e9907808e0018dd15fa643937886b1e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/sanctum/zipball/2a9bccc18e9907808e0018dd15fa643937886b1e", + "reference": "2a9bccc18e9907808e0018dd15fa643937886b1e", + "shasum": "" + }, + "require": { + "ext-json": "*", + "illuminate/console": "^11.0|^12.0|^13.0", + "illuminate/contracts": "^11.0|^12.0|^13.0", + "illuminate/database": "^11.0|^12.0|^13.0", + "illuminate/support": "^11.0|^12.0|^13.0", + "php": "^8.2", + "symfony/console": "^7.0|^8.0" + }, + "require-dev": { + "mockery/mockery": "^1.6", + "orchestra/testbench": "^9.15|^10.8|^11.0", + "phpstan/phpstan": "^1.10" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Sanctum\\SanctumServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Sanctum\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Laravel Sanctum provides a featherweight authentication system for SPAs and simple APIs.", + "keywords": [ + "auth", + "laravel", + "sanctum" + ], + "support": { + "issues": "https://github.com/laravel/sanctum/issues", + "source": "https://github.com/laravel/sanctum" + }, + "time": "2026-04-30T11:46:25+00:00" + }, { "name": "laravel/serializable-closure", "version": "v2.0.10", From 918f1b7bbbbebd36200332a3f86ab7bf664cd9bd Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Mon, 13 Jul 2026 09:21:48 +0900 Subject: [PATCH 086/110] feat: add personal_access_tokens migration for Sanctum --- ...36_create_personal_access_tokens_table.php | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 src/database/migrations/2026_07_13_091936_create_personal_access_tokens_table.php diff --git a/src/database/migrations/2026_07_13_091936_create_personal_access_tokens_table.php b/src/database/migrations/2026_07_13_091936_create_personal_access_tokens_table.php new file mode 100644 index 0000000..40ff706 --- /dev/null +++ b/src/database/migrations/2026_07_13_091936_create_personal_access_tokens_table.php @@ -0,0 +1,33 @@ +id(); + $table->morphs('tokenable'); + $table->text('name'); + $table->string('token', 64)->unique(); + $table->text('abilities')->nullable(); + $table->timestamp('last_used_at')->nullable(); + $table->timestamp('expires_at')->nullable()->index(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('personal_access_tokens'); + } +}; From 4fa03ddbc989d9ca85696462f2054bc0125a9737 Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Mon, 13 Jul 2026 09:21:55 +0900 Subject: [PATCH 087/110] feat: add HasApiTokens trait to User model --- src/app/Models/User.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/app/Models/User.php b/src/app/Models/User.php index 4eb13ff..741315d 100644 --- a/src/app/Models/User.php +++ b/src/app/Models/User.php @@ -3,9 +3,11 @@ namespace App\Models; use Illuminate\Foundation\Auth\User as Authenticatable; +use Laravel\Sanctum\HasApiTokens; class User extends Authenticatable { + use HasApiTokens; protected $table = 'users'; protected $fillable = [ From f913adb7cc3ddaede5745da49d86a6d7b20bf650 Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Mon, 13 Jul 2026 09:21:55 +0900 Subject: [PATCH 088/110] feat: add TokenServiceInterface to User domain --- .../Domains/User/Interface/TokenServiceInterface.php | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 src/app/Packages/Domains/User/Interface/TokenServiceInterface.php diff --git a/src/app/Packages/Domains/User/Interface/TokenServiceInterface.php b/src/app/Packages/Domains/User/Interface/TokenServiceInterface.php new file mode 100644 index 0000000..c224937 --- /dev/null +++ b/src/app/Packages/Domains/User/Interface/TokenServiceInterface.php @@ -0,0 +1,12 @@ + Date: Mon, 13 Jul 2026 09:21:55 +0900 Subject: [PATCH 089/110] feat: implement SanctumTokenService --- .../User/Service/SanctumTokenService.php | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 src/app/Packages/Domains/User/Service/SanctumTokenService.php diff --git a/src/app/Packages/Domains/User/Service/SanctumTokenService.php b/src/app/Packages/Domains/User/Service/SanctumTokenService.php new file mode 100644 index 0000000..1c9c84d --- /dev/null +++ b/src/app/Packages/Domains/User/Service/SanctumTokenService.php @@ -0,0 +1,33 @@ +getId()); + + if ($user === null) { + throw new RuntimeException('User not found.'); + } + + return $user->createToken('auth-token')->plainTextToken; + } + + public function revokeAllTokens(UserEntity $entity): void + { + $user = User::find($entity->getId()); + + if ($user === null) { + throw new RuntimeException('User not found.'); + } + + $user->tokens()->delete(); + } +} From 7f686de3c17c87523f60c30105fd67aea5803871 Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Mon, 13 Jul 2026 09:21:55 +0900 Subject: [PATCH 090/110] feat: bind TokenServiceInterface to SanctumTokenService in AppServiceProvider --- src/app/Providers/AppServiceProvider.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/app/Providers/AppServiceProvider.php b/src/app/Providers/AppServiceProvider.php index f49b0e4..a12b9fe 100644 --- a/src/app/Providers/AppServiceProvider.php +++ b/src/app/Providers/AppServiceProvider.php @@ -3,6 +3,8 @@ namespace App\Providers; use Algolia\AlgoliaSearch\Api\SearchClient; +use App\Packages\Domains\User\Interface\TokenServiceInterface; +use App\Packages\Domains\User\Service\SanctumTokenService; use App\Packages\Domains\WorldHeritage\Adapter\AlgoliaWorldHeritageSearchAdapter; use App\Packages\Domains\WorldHeritage\Ports\WorldHeritageSearchPort; use Google\Cloud\Storage\StorageClient; @@ -22,6 +24,8 @@ public function register(): void { // We bind via a factory closure (instead of a simple class binding) because: // - The adapter needs runtime configuration (Algolia app id / API key / index name) + $this->app->bind(TokenServiceInterface::class, SanctumTokenService::class); + $this->app->bind(WorldHeritageSearchPort::class, function () { $client = SearchClient::create(config('algolia.algolia_app_id'), config('algolia.algolia_search_api_key')); From 582e539d3aadadc425942a6efc5eecc426ccd6b8 Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Mon, 13 Jul 2026 09:21:55 +0900 Subject: [PATCH 091/110] test: add integration tests for SanctumTokenService --- .../Tests/Service/SanctumTokenServiceTest.php | 136 ++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 src/app/Packages/Domains/User/Tests/Service/SanctumTokenServiceTest.php diff --git a/src/app/Packages/Domains/User/Tests/Service/SanctumTokenServiceTest.php b/src/app/Packages/Domains/User/Tests/Service/SanctumTokenServiceTest.php new file mode 100644 index 0000000..98db8ee --- /dev/null +++ b/src/app/Packages/Domains/User/Tests/Service/SanctumTokenServiceTest.php @@ -0,0 +1,136 @@ +truncate(); + } + + protected function tearDown(): void + { + $this->truncate(); + parent::tearDown(); + } + + private function truncate(): void + { + if (env('APP_ENV') === 'testing') { + DB::connection('mysql')->statement('SET FOREIGN_KEY_CHECKS=0;'); + DB::connection('mysql')->table('personal_access_tokens')->truncate(); + User::truncate(); + DB::connection('mysql')->statement('SET FOREIGN_KEY_CHECKS=1;'); + } + } + + private function service(): SanctumTokenService + { + return new SanctumTokenService(); + } + + private function seedUser(array $overrides = []): User + { + $faker = FakerFactory::create(); + + return User::create(array_merge([ + 'first_name' => $faker->firstName(), + 'last_name' => $faker->lastName(), + 'email' => $faker->unique()->safeEmail(), + 'password' => bcrypt('password'), + 'age_range' => 'teens', + 'subscription_tier' => 'free', + 'subscription_expires_at' => null, + ], $overrides)); + } + + private function buildEntityFromUser(User $user): UserEntity + { + return UserEntityFactory::build([ + 'id' => $user->id, + 'first_name' => $user->first_name, + 'last_name' => $user->last_name, + 'email' => $user->email, + 'age_range' => $user->age_range, + 'subscription_tier' => $user->subscription_tier, + 'subscription_expires_at' => $user->subscription_expires_at, + 'password_hash' => $user->password, + ]); + } + + public function test_createToken_returns_plain_text_token(): void + { + $user = $this->seedUser(); + $entity = $this->buildEntityFromUser($user); + + $token = $this->service()->createToken($entity); + + $this->assertIsString($token); + $this->assertNotEmpty($token); + $this->assertDatabaseHas('personal_access_tokens', [ + 'tokenable_id' => $user->id, + 'tokenable_type' => User::class, + ]); + } + + public function test_createToken_throws_when_user_not_found(): void + { + $entity = UserEntityFactory::build([ + 'id' => 999999, + 'first_name' => 'Ghost', + 'last_name' => 'User', + 'email' => 'ghost@example.com', + 'age_range' => 'teens', + 'subscription_tier' => 'free', + 'subscription_expires_at' => null, + ]); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('User not found.'); + + $this->service()->createToken($entity); + } + + public function test_revokeAllTokens_deletes_tokens_for_user(): void + { + $user = $this->seedUser(); + $entity = $this->buildEntityFromUser($user); + + $user->createToken('token-1'); + $user->createToken('token-2'); + + $this->service()->revokeAllTokens($entity); + + $this->assertDatabaseMissing('personal_access_tokens', [ + 'tokenable_id' => $user->id, + ]); + } + + public function test_revokeAllTokens_throws_when_user_not_found(): void + { + $entity = UserEntityFactory::build([ + 'id' => 999999, + 'first_name' => 'Ghost', + 'last_name' => 'User', + 'email' => 'ghost@example.com', + 'age_range' => 'teens', + 'subscription_tier' => 'free', + 'subscription_expires_at' => null, + ]); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('User not found.'); + + $this->service()->revokeAllTokens($entity); + } +} From 743bb12e34db4f15a454eb1b23be1fae5e8301a3 Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Mon, 13 Jul 2026 09:29:30 +0900 Subject: [PATCH 092/110] feat: publish Sanctum config and set token expiration to 1 day --- src/config/sanctum.php | 87 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 src/config/sanctum.php diff --git a/src/config/sanctum.php b/src/config/sanctum.php new file mode 100644 index 0000000..ff9ca03 --- /dev/null +++ b/src/config/sanctum.php @@ -0,0 +1,87 @@ + explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf( + '%s%s', + 'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1', + Sanctum::currentApplicationUrlWithPort(), + // Sanctum::currentRequestHost(), + ))), + + /* + |-------------------------------------------------------------------------- + | Sanctum Guards + |-------------------------------------------------------------------------- + | + | This array contains the authentication guards that will be checked when + | Sanctum is trying to authenticate a request. If none of these guards + | are able to authenticate the request, Sanctum will use the bearer + | token that's present on an incoming request for authentication. + | + */ + + 'guard' => ['web'], + + /* + |-------------------------------------------------------------------------- + | Expiration Minutes + |-------------------------------------------------------------------------- + | + | This value controls the number of minutes until an issued token will be + | considered expired. This will override any values set in the token's + | "expires_at" attribute, but first-party sessions are not affected. + | + */ + + 'expiration' => 1440, + + /* + |-------------------------------------------------------------------------- + | Token Prefix + |-------------------------------------------------------------------------- + | + | Sanctum can prefix new tokens in order to take advantage of numerous + | security scanning initiatives maintained by open source platforms + | that notify developers if they commit tokens into repositories. + | + | See: https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning + | + */ + + 'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''), + + /* + |-------------------------------------------------------------------------- + | Sanctum Middleware + |-------------------------------------------------------------------------- + | + | When authenticating your first-party SPA with Sanctum you may need to + | customize some of the middleware Sanctum uses while processing the + | request. You may change the middleware listed below as required. + | + */ + + 'middleware' => [ + 'authenticate_session' => AuthenticateSession::class, + 'encrypt_cookies' => EncryptCookies::class, + 'validate_csrf_token' => ValidateCsrfToken::class, + ], + +]; From 324300412fba47c005db8cd3dddd15114c592161 Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Mon, 13 Jul 2026 20:39:50 +0900 Subject: [PATCH 093/110] feat: add AuthTokenDto with token and token_type fields --- .../UseCase/User/LoginUseCase.php | 34 +++++++ .../QueryUseCases/Dto/User/AuthTokenDto.php | 11 +++ .../QueryUseCases/Dto/User/TokenDto.php | 10 ++ .../CommandUseCases/LoginUseCaseTest.php | 98 +++++++++++++++++++ 4 files changed, 153 insertions(+) create mode 100644 src/app/Packages/Features/CommandUseCases/UseCase/User/LoginUseCase.php create mode 100644 src/app/Packages/Features/QueryUseCases/Dto/User/AuthTokenDto.php create mode 100644 src/app/Packages/Features/QueryUseCases/Dto/User/TokenDto.php create mode 100644 src/app/Packages/Features/Tests/CommandUseCases/LoginUseCaseTest.php diff --git a/src/app/Packages/Features/CommandUseCases/UseCase/User/LoginUseCase.php b/src/app/Packages/Features/CommandUseCases/UseCase/User/LoginUseCase.php new file mode 100644 index 0000000..429241d --- /dev/null +++ b/src/app/Packages/Features/CommandUseCases/UseCase/User/LoginUseCase.php @@ -0,0 +1,34 @@ +userRepository->findByEmail(new Email($email)); + + if ($entity === null) { + throw new InvalidArgumentException('Invalid credentials.'); + } + + if (!password_verify($password, $entity->getPasswordHash())) { + throw new InvalidArgumentException('Invalid credentials.'); + } + + $token = $this->tokenService->createToken($entity); + + return new TokenDto(token: $token); + } +} diff --git a/src/app/Packages/Features/QueryUseCases/Dto/User/AuthTokenDto.php b/src/app/Packages/Features/QueryUseCases/Dto/User/AuthTokenDto.php new file mode 100644 index 0000000..0524ed3 --- /dev/null +++ b/src/app/Packages/Features/QueryUseCases/Dto/User/AuthTokenDto.php @@ -0,0 +1,11 @@ + 1, + 'first_name' => 'John', + 'last_name' => 'Doe', + 'email' => $email, + 'age_range' => '20s', + 'subscription_tier' => 'free', + 'subscription_expires_at' => null, + 'password_hash' => $passwordHash ?? password_hash('secret', PASSWORD_BCRYPT), + ]); + } + + public function test_handle_returns_token_dto_on_valid_credentials(): void + { + $entity = $this->buildEntity(); + + /** @var UserRepositroyInterface|MockInterface $userRepository */ + $userRepository = Mockery::mock(UserRepositroyInterface::class); + $userRepository->shouldReceive('findByEmail') + ->once() + ->andReturn($entity); + + /** @var TokenServiceInterface|MockInterface $tokenService */ + $tokenService = Mockery::mock(TokenServiceInterface::class); + $tokenService->shouldReceive('createToken') + ->once() + ->with($entity) + ->andReturn('plain-text-token'); + + $result = (new LoginUseCase($userRepository, $tokenService))->handle('john@example.com', 'secret'); + + $this->assertInstanceOf(TokenDto::class, $result); + $this->assertSame('plain-text-token', $result->token); + } + + public function test_handle_throws_when_user_not_found(): void + { + /** @var UserRepositroyInterface|MockInterface $userRepository */ + $userRepository = Mockery::mock(UserRepositroyInterface::class); + $userRepository->shouldReceive('findByEmail') + ->once() + ->andReturn(null); + + /** @var TokenServiceInterface|MockInterface $tokenService */ + $tokenService = Mockery::mock(TokenServiceInterface::class); + $tokenService->shouldNotReceive('createToken'); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid credentials.'); + + (new LoginUseCase($userRepository, $tokenService))->handle('john@example.com', 'secret'); + } + + public function test_handle_throws_on_wrong_password(): void + { + $entity = $this->buildEntity(); + + /** @var UserRepositroyInterface|MockInterface $userRepository */ + $userRepository = Mockery::mock(UserRepositroyInterface::class); + $userRepository->shouldReceive('findByEmail') + ->once() + ->andReturn($entity); + + /** @var TokenServiceInterface|MockInterface $tokenService */ + $tokenService = Mockery::mock(TokenServiceInterface::class); + $tokenService->shouldNotReceive('createToken'); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid credentials.'); + + (new LoginUseCase($userRepository, $tokenService))->handle('john@example.com', 'wrong-password'); + } +} From 868800e4b48a99f5393b20752dea40d30b35544a Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Mon, 13 Jul 2026 20:40:16 +0900 Subject: [PATCH 094/110] refactor: rename TokenDto to AuthTokenDto and update LoginUseCase and LoginUseCaseTest --- .../CommandUseCases/UseCase/User/LoginUseCase.php | 8 ++++---- .../Features/QueryUseCases/Dto/User/TokenDto.php | 10 ---------- .../Tests/CommandUseCases/LoginUseCaseTest.php | 7 ++++--- 3 files changed, 8 insertions(+), 17 deletions(-) delete mode 100644 src/app/Packages/Features/QueryUseCases/Dto/User/TokenDto.php diff --git a/src/app/Packages/Features/CommandUseCases/UseCase/User/LoginUseCase.php b/src/app/Packages/Features/CommandUseCases/UseCase/User/LoginUseCase.php index 429241d..9c8d050 100644 --- a/src/app/Packages/Features/CommandUseCases/UseCase/User/LoginUseCase.php +++ b/src/app/Packages/Features/CommandUseCases/UseCase/User/LoginUseCase.php @@ -5,7 +5,7 @@ use App\Packages\Domains\User\Interface\TokenServiceInterface; use App\Packages\Domains\User\Interface\UserRepositroyInterface; use App\Packages\Domains\User\ValueObject\Email; -use App\Packages\Features\QueryUseCases\Dto\User\TokenDto; +use App\Packages\Features\QueryUseCases\Dto\User\AuthTokenDto; use InvalidArgumentException; final class LoginUseCase @@ -15,7 +15,7 @@ public function __construct( private readonly TokenServiceInterface $tokenService, ) {} - public function handle(string $email, string $password): TokenDto + public function handle(string $email, string $password): AuthTokenDto { $entity = $this->userRepository->findByEmail(new Email($email)); @@ -24,11 +24,11 @@ public function handle(string $email, string $password): TokenDto } if (!password_verify($password, $entity->getPasswordHash())) { - throw new InvalidArgumentException('Invalid credentials.'); + throw new InvalidArgumentException('Invalid user data. Please try again.'); } $token = $this->tokenService->createToken($entity); - return new TokenDto(token: $token); + return new AuthTokenDto(token: $token); } } diff --git a/src/app/Packages/Features/QueryUseCases/Dto/User/TokenDto.php b/src/app/Packages/Features/QueryUseCases/Dto/User/TokenDto.php deleted file mode 100644 index d90e287..0000000 --- a/src/app/Packages/Features/QueryUseCases/Dto/User/TokenDto.php +++ /dev/null @@ -1,10 +0,0 @@ -handle('john@example.com', 'secret'); - $this->assertInstanceOf(TokenDto::class, $result); + $this->assertInstanceOf(AuthTokenDto::class, $result); $this->assertSame('plain-text-token', $result->token); + $this->assertSame('Bearer', $result->tokenType); } public function test_handle_throws_when_user_not_found(): void @@ -91,7 +92,7 @@ public function test_handle_throws_on_wrong_password(): void $tokenService->shouldNotReceive('createToken'); $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Invalid credentials.'); + $this->expectExceptionMessage('Invalid user data. Please try again.'); (new LoginUseCase($userRepository, $tokenService))->handle('john@example.com', 'wrong-password'); } From 1fa30f4720b1e907fc96c18d2a84e2ba142a5288 Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Tue, 14 Jul 2026 08:36:51 +0900 Subject: [PATCH 095/110] feat: add AuthController with login action --- .../Features/Controller/AuthController.php | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 src/app/Packages/Features/Controller/AuthController.php diff --git a/src/app/Packages/Features/Controller/AuthController.php b/src/app/Packages/Features/Controller/AuthController.php new file mode 100644 index 0000000..b7c18d1 --- /dev/null +++ b/src/app/Packages/Features/Controller/AuthController.php @@ -0,0 +1,49 @@ +handle( + email: $request->input('email'), + password: $request->input('password'), + ); + + return response()->json([ + 'status' => 'success', + 'data' => [ + 'token' => $dto->token, + 'token_type' => $dto->tokenType, + ], + ], 200); + } catch (InvalidArgumentException $exception) { + return response()->json([ + 'status' => 'error', + 'message' => $exception->getMessage(), + ], 401); + } catch (Throwable $throw) { + Log::error('Failed to login', [ + 'message' => $throw->getMessage(), + 'trace' => $throw->getTraceAsString(), + ]); + + return response()->json([ + 'status' => 'error', + 'message' => 'Internal Server Error', + ], 500); + } + } +} From 449f04c31c76a723ffe3c4acd0ca6520e4af39ca Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Tue, 14 Jul 2026 08:37:38 +0900 Subject: [PATCH 096/110] feat: register POST /v1/user/login route and refactor routes with Route::controller() --- src/routes/api.php | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/src/routes/api.php b/src/routes/api.php index d39dc95..d499e71 100644 --- a/src/routes/api.php +++ b/src/routes/api.php @@ -1,17 +1,24 @@ group(function (): void { - Route::get('/heritages', [WorldHeritageController::class, 'getWorldHeritages']); - Route::get('/heritages/search', [WorldHeritageController::class, 'searchWorldHeritages']); - Route::get('heritages/region-count', [WorldHeritageController::class, 'getWorldHeritagesCountByRegion']); - Route::get('/heritages/{id}', [WorldHeritageController::class, 'getWorldHeritageById']); + Route::controller(WorldHeritageController::class)->prefix('heritages')->group(function (): void { + Route::get('/', 'getWorldHeritages'); + Route::get('/search', 'searchWorldHeritages'); + Route::get('/region-count', 'getWorldHeritagesCountByRegion'); + Route::get('/{id}', 'getWorldHeritageById'); + }); - Route::get('/users/{id}', [UserController::class, 'getUserById']); - Route::patch('/users/{id}', [UserController::class, 'updateUser']); - Route::delete('/users/{id}', [UserController::class, 'deleteUser']); Route::post('/user/create', [UserController::class, 'createUser']); + Route::post('/user/login', [AuthController::class, 'login']); + + Route::controller(UserController::class)->prefix('users')->group(function (): void { + Route::get('/{id}', 'getUserById'); + Route::patch('/{id}', 'updateUser'); + Route::delete('/{id}', 'deleteUser'); + }); }); \ No newline at end of file From 17259ceb0e698be3d110bf3c39bd0f1c14ef1143 Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Tue, 14 Jul 2026 08:37:46 +0900 Subject: [PATCH 097/110] test: add integration tests for POST /api/v1/user/login --- src/app/Packages/Features/Tests/LoginTest.php | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 src/app/Packages/Features/Tests/LoginTest.php diff --git a/src/app/Packages/Features/Tests/LoginTest.php b/src/app/Packages/Features/Tests/LoginTest.php new file mode 100644 index 0000000..888a374 --- /dev/null +++ b/src/app/Packages/Features/Tests/LoginTest.php @@ -0,0 +1,89 @@ +truncate(); + } + + protected function tearDown(): void + { + $this->truncate(); + parent::tearDown(); + } + + private function truncate(): void + { + if (env('APP_ENV') === 'testing') { + DB::connection('mysql')->statement('SET FOREIGN_KEY_CHECKS=0;'); + DB::connection('mysql')->table('personal_access_tokens')->truncate(); + User::truncate(); + DB::connection('mysql')->statement('SET FOREIGN_KEY_CHECKS=1;'); + } + } + + private function seedUser(string $email, string $password): User + { + return User::create([ + 'first_name' => 'John', + 'last_name' => 'Doe', + 'email' => $email, + 'password' => bcrypt($password), + 'age_range' => '20s', + 'subscription_tier' => 'free', + 'subscription_expires_at' => null, + ]); + } + + public function test_login_returns_200_with_token_on_valid_credentials(): void + { + $this->seedUser('john@example.com', 'secret123'); + + $response = $this->postJson('/api/v1/user/login', [ + 'email' => 'john@example.com', + 'password' => 'secret123', + ]); + + $response->assertStatus(200) + ->assertJsonStructure([ + 'status', + 'data' => ['token', 'token_type'], + ]) + ->assertJsonFragment([ + 'status' => 'success', + 'token_type' => 'Bearer', + ]); + } + + public function test_login_returns_401_when_user_not_found(): void + { + $response = $this->postJson('/api/v1/user/login', [ + 'email' => 'notfound@example.com', + 'password' => 'secret123', + ]); + + $response->assertStatus(401) + ->assertJsonFragment(['status' => 'error']); + } + + public function test_login_returns_401_on_wrong_password(): void + { + $this->seedUser('john@example.com', 'secret123'); + + $response = $this->postJson('/api/v1/user/login', [ + 'email' => 'john@example.com', + 'password' => 'wrong-password', + ]); + + $response->assertStatus(401) + ->assertJsonFragment(['status' => 'error']); + } +} From 84811c8618db46e1d5e297b7bb76c95c071913ec Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Tue, 14 Jul 2026 08:37:50 +0900 Subject: [PATCH 098/110] feat: add mago.toml with lint-based static analysis config --- src/mago.toml | 180 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 180 insertions(+) create mode 100644 src/mago.toml diff --git a/src/mago.toml b/src/mago.toml new file mode 100644 index 0000000..afdf9e6 --- /dev/null +++ b/src/mago.toml @@ -0,0 +1,180 @@ +[source] +paths = ["app", "config", "database", "routes"] + +[linter] +excludes = ["database/migrations"] + +# --- 無効化: スタイル・冗長性系(厳しすぎるため外す) --- +[linter.rules.strict-types] +enabled = false + +[linter.rules.no-else-clause] +enabled = false + +[linter.rules.no-redundant-write-visibility] +enabled = false + +[linter.rules.no-redundant-method-override] +enabled = false + +[linter.rules.no-redundant-parentheses] +enabled = false + +[linter.rules.no-redundant-string-concat] +enabled = false + +[linter.rules.no-redundant-isset] +enabled = false + +[linter.rules.no-redundant-final] +enabled = false + +[linter.rules.no-redundant-readonly] +enabled = false + +[linter.rules.no-redundant-block] +enabled = false + +[linter.rules.no-redundant-continue] +enabled = false + +[linter.rules.no-redundant-use] +enabled = false + +[linter.rules.no-redundant-nullsafe] +enabled = false + +[linter.rules.no-redundant-label] +enabled = false + +[linter.rules.no-redundant-literal-return] +enabled = false + +[linter.rules.prefer-arrow-function] +enabled = false + +[linter.rules.prefer-early-continue] +enabled = false + +[linter.rules.prefer-static-closure] +enabled = false + +[linter.rules.inline-variable-return] +enabled = false + +[linter.rules.no-boolean-flag-parameter] +enabled = false + +[linter.rules.no-isset] +enabled = false + +[linter.rules.halstead] +enabled = false + +[linter.rules.readable-literal] +enabled = false + +[linter.rules.literal-named-argument] +enabled = false + +[linter.rules.no-shorthand-ternary] +enabled = false + +[linter.rules.kan-defect] +enabled = false + +[linter.rules.sensitive-parameter] +enabled = false + +[linter.rules.explicit-nullable-param] +enabled = false + +[linter.rules.tagged-todo] +enabled = false + +[linter.rules.tagged-fixme] +enabled = false + +[linter.rules.valid-docblock] +enabled = false + +[linter.rules.no-trailing-space] +enabled = false + +[linter.rules.no-closing-tag] +enabled = false + +[linter.rules.no-php-tag-terminator] +enabled = false + +# --- 有効化: バグ・セキュリティ系 --- +[linter.rules.no-eval] +enabled = true +level = "Error" + +[linter.rules.no-global] +enabled = true +level = "Error" + +[linter.rules.no-error-control-operator] +enabled = true +level = "Error" + +[linter.rules.no-empty-catch-clause] +enabled = true +level = "Error" + +[linter.rules.no-insecure-comparison] +enabled = true +level = "Error" + +[linter.rules.no-literal-password] +enabled = true +level = "Error" + +[linter.rules.no-shell-execute-string] +enabled = true +level = "Error" + +[linter.rules.no-empty] +enabled = true +level = "Error" + +[linter.rules.no-debug-symbols] +enabled = true +level = "Warning" + +[linter.rules.loop-does-not-iterate] +enabled = true +level = "Warning" + +[linter.rules.no-self-assignment] +enabled = true +level = "Warning" + +[linter.rules.no-nested-ternary] +enabled = true +level = "Warning" + +[linter.rules.no-assign-in-condition] +enabled = true +level = "Warning" + +[linter.rules.identity-comparison] +enabled = true +level = "Warning" + +[linter.rules.cyclomatic-complexity] +enabled = true +level = "Error" +threshold = 15 + +[linter.rules.excessive-parameter-list] +enabled = true +level = "Warning" +threshold = 6 + +[linter.rules.too-many-methods] +enabled = true +level = "Warning" +threshold = 10 From 44d0ba0da76fcd1f47951c6d4f29ff21de2e8297 Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Tue, 14 Jul 2026 08:37:57 +0900 Subject: [PATCH 099/110] feat: add static_analysis.yml CI workflow for mago lint --- .github/workflows/static_analysis.yml | 41 +++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 .github/workflows/static_analysis.yml diff --git a/.github/workflows/static_analysis.yml b/.github/workflows/static_analysis.yml new file mode 100644 index 0000000..42a7049 --- /dev/null +++ b/.github/workflows/static_analysis.yml @@ -0,0 +1,41 @@ +name: Static Analysis + +on: + pull_request: + types: [opened, synchronize] + paths: + - "src/**/*.php" + workflow_dispatch: {} + +jobs: + mago: + runs-on: ubuntu-latest + timeout-minutes: 5 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: "8.2" + coverage: none + tools: composer + + - name: Cache Composer dependencies + uses: actions/cache@v4 + with: + path: ~/.cache/composer + key: ${{ runner.os }}-composer-${{ hashFiles('src/composer.lock') }} + + - name: Install dependencies + run: composer install --prefer-dist --no-progress + working-directory: ./src + + - name: Setup Mago + uses: nhedger/setup-mago@v1 + + - name: Run Mago lint + run: ./vendor/bin/mago lint + working-directory: ./src \ No newline at end of file From 50d401758ea053f939f4b9b2fe2889a9c84603eb Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Tue, 14 Jul 2026 08:38:09 +0900 Subject: [PATCH 100/110] chore: add .composer to .gitignore --- src/.gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/src/.gitignore b/src/.gitignore index c4b6a95..453f061 100644 --- a/src/.gitignore +++ b/src/.gitignore @@ -1,4 +1,5 @@ /.phpunit.cache +/.composer /node_modules /public/build /public/hot From b75a51f2f0a41bcaf8185ed07f938ec254dfe223 Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Tue, 14 Jul 2026 08:55:43 +0900 Subject: [PATCH 101/110] chore: tune mago.toml lint scope and rule thresholds - Narrow source path to app/Packages only - Disable false-positive rules: no-literal-password, no-empty, no-nested-ternary, prefer-first-class-callable, block-statement, class-name - Raise cyclomatic-complexity threshold to 60, excessive-parameter-list to 25, too-many-methods to 30 --- src/mago.toml | 34 ++++++++++++++++++++-------------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/src/mago.toml b/src/mago.toml index afdf9e6..1914325 100644 --- a/src/mago.toml +++ b/src/mago.toml @@ -1,5 +1,5 @@ [source] -paths = ["app", "config", "database", "routes"] +paths = ["app/Packages"] [linter] excludes = ["database/migrations"] @@ -107,6 +107,21 @@ enabled = false [linter.rules.no-php-tag-terminator] enabled = false +[linter.rules.prefer-first-class-callable] +enabled = false + +[linter.rules.no-nested-ternary] +enabled = false + +[linter.rules.no-empty] +enabled = false + +[linter.rules.block-statement] +enabled = false + +[linter.rules.class-name] +enabled = false + # --- 有効化: バグ・セキュリティ系 --- [linter.rules.no-eval] enabled = true @@ -129,17 +144,12 @@ enabled = true level = "Error" [linter.rules.no-literal-password] -enabled = true -level = "Error" +enabled = false [linter.rules.no-shell-execute-string] enabled = true level = "Error" -[linter.rules.no-empty] -enabled = true -level = "Error" - [linter.rules.no-debug-symbols] enabled = true level = "Warning" @@ -152,10 +162,6 @@ level = "Warning" enabled = true level = "Warning" -[linter.rules.no-nested-ternary] -enabled = true -level = "Warning" - [linter.rules.no-assign-in-condition] enabled = true level = "Warning" @@ -167,14 +173,14 @@ level = "Warning" [linter.rules.cyclomatic-complexity] enabled = true level = "Error" -threshold = 15 +threshold = 60 [linter.rules.excessive-parameter-list] enabled = true level = "Warning" -threshold = 6 +threshold = 25 [linter.rules.too-many-methods] enabled = true level = "Warning" -threshold = 10 +threshold = 30 From e53fed5c7fb833e18f7f90218181964961f5df32 Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Tue, 14 Jul 2026 09:24:13 +0900 Subject: [PATCH 102/110] refactor: remove final from LoginUseCase to allow mocking in tests --- .../Features/CommandUseCases/UseCase/User/LoginUseCase.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/Packages/Features/CommandUseCases/UseCase/User/LoginUseCase.php b/src/app/Packages/Features/CommandUseCases/UseCase/User/LoginUseCase.php index 9c8d050..e6207e9 100644 --- a/src/app/Packages/Features/CommandUseCases/UseCase/User/LoginUseCase.php +++ b/src/app/Packages/Features/CommandUseCases/UseCase/User/LoginUseCase.php @@ -8,7 +8,7 @@ use App\Packages\Features\QueryUseCases\Dto\User\AuthTokenDto; use InvalidArgumentException; -final class LoginUseCase +class LoginUseCase { public function __construct( private readonly UserRepositroyInterface $userRepository, From 16fc4dfd7895dfe21bc35d0973fc0152c21d93fa Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Tue, 14 Jul 2026 09:24:18 +0900 Subject: [PATCH 103/110] test: add 500 error case for POST /api/v1/user/login --- src/app/Packages/Features/Tests/LoginTest.php | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/app/Packages/Features/Tests/LoginTest.php b/src/app/Packages/Features/Tests/LoginTest.php index 888a374..5559375 100644 --- a/src/app/Packages/Features/Tests/LoginTest.php +++ b/src/app/Packages/Features/Tests/LoginTest.php @@ -3,7 +3,9 @@ namespace App\Packages\Features\Tests; use App\Models\User; +use App\Packages\Features\CommandUseCases\UseCase\User\LoginUseCase; use Illuminate\Support\Facades\DB; +use RuntimeException; use Tests\TestCase; class LoginTest extends TestCase @@ -86,4 +88,22 @@ public function test_login_returns_401_on_wrong_password(): void $response->assertStatus(401) ->assertJsonFragment(['status' => 'error']); } + + public function test_login_returns_500_on_unexpected_error(): void + { + $this->mock(LoginUseCase::class) + ->shouldReceive('handle') + ->andThrow(new RuntimeException('Unexpected error')); + + $response = $this->postJson('/api/v1/user/login', [ + 'email' => 'john@example.com', + 'password' => 'secret123', + ]); + + $response->assertStatus(500) + ->assertJsonFragment([ + 'status' => 'error', + 'message' => 'Internal Server Error', + ]); + } } From e24f4c2a2c644dcc02bc040e7483906f44ba1382 Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Tue, 14 Jul 2026 09:43:00 +0900 Subject: [PATCH 104/110] test: add unit tests for Email ValueObject --- .../User/Tests/ValueObject/EmailTest.php | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 src/app/Packages/Domains/User/Tests/ValueObject/EmailTest.php diff --git a/src/app/Packages/Domains/User/Tests/ValueObject/EmailTest.php b/src/app/Packages/Domains/User/Tests/ValueObject/EmailTest.php new file mode 100644 index 0000000..4861109 --- /dev/null +++ b/src/app/Packages/Domains/User/Tests/ValueObject/EmailTest.php @@ -0,0 +1,41 @@ +assertSame('user@example.com', $email->value()); + } + + public function test_constructor_throws_on_invalid_email(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid email format'); + + new Email('not-an-email'); + } + + public function test_isEqual_returns_true_when_emails_match(): void + { + $email = new Email('user@example.com'); + $otherEmail = new Email('user@example.com'); + + $this->assertTrue($email->isEqual($otherEmail)); + } + + public function test_isEqual_returns_false_when_emails_differ(): void + { + $email = new Email('user@example.com'); + $otherEmail = new Email('other@example.com'); + + $this->assertFalse($email->isEqual($otherEmail)); + } +} From 392adf94a5f35b0acc483df74098bab62e0a25a7 Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Wed, 15 Jul 2026 20:26:55 +0900 Subject: [PATCH 105/110] feat: add revokeCurrentToken to TokenServiceInterface and SanctumTokenService --- .../Domains/User/Interface/TokenServiceInterface.php | 2 ++ .../Domains/User/Service/SanctumTokenService.php | 12 ++++++++++++ 2 files changed, 14 insertions(+) diff --git a/src/app/Packages/Domains/User/Interface/TokenServiceInterface.php b/src/app/Packages/Domains/User/Interface/TokenServiceInterface.php index c224937..d9719a0 100644 --- a/src/app/Packages/Domains/User/Interface/TokenServiceInterface.php +++ b/src/app/Packages/Domains/User/Interface/TokenServiceInterface.php @@ -9,4 +9,6 @@ interface TokenServiceInterface public function createToken(UserEntity $entity): string; public function revokeAllTokens(UserEntity $entity): void; + + public function revokeCurrentToken(string $plainTextToken): void; } diff --git a/src/app/Packages/Domains/User/Service/SanctumTokenService.php b/src/app/Packages/Domains/User/Service/SanctumTokenService.php index 1c9c84d..2af30c5 100644 --- a/src/app/Packages/Domains/User/Service/SanctumTokenService.php +++ b/src/app/Packages/Domains/User/Service/SanctumTokenService.php @@ -5,6 +5,7 @@ use App\Models\User; use App\Packages\Domains\User\Interface\TokenServiceInterface; use App\Packages\Domains\User\UserEntity; +use Laravel\Sanctum\PersonalAccessToken; use RuntimeException; class SanctumTokenService implements TokenServiceInterface @@ -30,4 +31,15 @@ public function revokeAllTokens(UserEntity $entity): void $user->tokens()->delete(); } + + // Logout lives here rather than in UserRepository because it operates on personal_access_tokens, + // not on user data. No user lookup is needed — the plain-text token alone identifies the session to end. + public function revokeCurrentToken(string $plainTextToken): void + { + $token = PersonalAccessToken::findToken($plainTextToken); + + if ($token !== null) { + $token->delete(); + } + } } From d6a5f044df5d816c542783402b0b6fb0006b1136 Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Wed, 15 Jul 2026 20:27:05 +0900 Subject: [PATCH 106/110] test: add integration tests for revokeCurrentToken in SanctumTokenServiceTest --- .../Tests/Service/SanctumTokenServiceTest.php | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/app/Packages/Domains/User/Tests/Service/SanctumTokenServiceTest.php b/src/app/Packages/Domains/User/Tests/Service/SanctumTokenServiceTest.php index 98db8ee..bd71646 100644 --- a/src/app/Packages/Domains/User/Tests/Service/SanctumTokenServiceTest.php +++ b/src/app/Packages/Domains/User/Tests/Service/SanctumTokenServiceTest.php @@ -133,4 +133,26 @@ public function test_revokeAllTokens_throws_when_user_not_found(): void $this->service()->revokeAllTokens($entity); } + + public function test_revokeCurrentToken_deletes_the_given_token(): void + { + $user = $this->seedUser(); + $plainText = $user->createToken('auth-token')->plainTextToken; + + $this->assertDatabaseHas('personal_access_tokens', [ + 'tokenable_id' => $user->id, + ]); + + $this->service()->revokeCurrentToken($plainText); + + $this->assertDatabaseMissing('personal_access_tokens', [ + 'tokenable_id' => $user->id, + ]); + } + + #[\PHPUnit\Framework\Attributes\DoesNotPerformAssertions] + public function test_revokeCurrentToken_does_nothing_when_token_not_found(): void + { + $this->service()->revokeCurrentToken('invalid-token-that-does-not-exist'); + } } From 495523d5d5c4715d65ed2024d80a2fc50ccabc9a Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Wed, 15 Jul 2026 20:47:55 +0900 Subject: [PATCH 107/110] feat: add LogoutUseCase --- .../UseCase/User/LogoutUseCase.php | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 src/app/Packages/Features/CommandUseCases/UseCase/User/LogoutUseCase.php diff --git a/src/app/Packages/Features/CommandUseCases/UseCase/User/LogoutUseCase.php b/src/app/Packages/Features/CommandUseCases/UseCase/User/LogoutUseCase.php new file mode 100644 index 0000000..432496b --- /dev/null +++ b/src/app/Packages/Features/CommandUseCases/UseCase/User/LogoutUseCase.php @@ -0,0 +1,17 @@ +tokenService->revokeCurrentToken($plainTextToken); + } +} From 4ccb5595d5d19831308a89efac779e2efaebc0ad Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Wed, 15 Jul 2026 20:47:57 +0900 Subject: [PATCH 108/110] test: add unit tests for LogoutUseCase --- .../CommandUseCases/LogoutUseCaseTest.php | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 src/app/Packages/Features/Tests/CommandUseCases/LogoutUseCaseTest.php diff --git a/src/app/Packages/Features/Tests/CommandUseCases/LogoutUseCaseTest.php b/src/app/Packages/Features/Tests/CommandUseCases/LogoutUseCaseTest.php new file mode 100644 index 0000000..ad1b7d9 --- /dev/null +++ b/src/app/Packages/Features/Tests/CommandUseCases/LogoutUseCaseTest.php @@ -0,0 +1,45 @@ +shouldReceive('revokeCurrentToken') + ->once() + ->with('plain-text-token'); + + (new LogoutUseCase($tokenService))->handle('plain-text-token'); + } + + public function test_handle_propagates_exception_when_revocation_fails(): void + { + /** @var TokenServiceInterface|MockInterface $tokenService */ + $tokenService = Mockery::mock(TokenServiceInterface::class); + $tokenService->shouldReceive('revokeCurrentToken') + ->once() + ->andThrow(new RuntimeException('Revocation failed.')); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Revocation failed.'); + + (new LogoutUseCase($tokenService))->handle('plain-text-token'); + } +} From c2a6884d0a4129eb17b74c52601a1b8a14b8f985 Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Wed, 15 Jul 2026 21:15:55 +0900 Subject: [PATCH 109/110] feat: add logout action to AuthController and register POST /v1/user/logout route --- .../Features/Controller/AuthController.php | 22 +++++++++++++++++++ src/routes/api.php | 6 ++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/app/Packages/Features/Controller/AuthController.php b/src/app/Packages/Features/Controller/AuthController.php index b7c18d1..40ef398 100644 --- a/src/app/Packages/Features/Controller/AuthController.php +++ b/src/app/Packages/Features/Controller/AuthController.php @@ -4,6 +4,7 @@ use App\Http\Controllers\Controller; use App\Packages\Features\CommandUseCases\UseCase\User\LoginUseCase; +use App\Packages\Features\CommandUseCases\UseCase\User\LogoutUseCase; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Log; @@ -46,4 +47,25 @@ public function login( ], 500); } } + + public function logout(Request $request, LogoutUseCase $useCase): JsonResponse + { + try { + $useCase->handle($request->bearerToken() ?? ''); + + return response()->json([ + 'status' => 'success', + ], 200); + } catch (Throwable $throw) { + Log::error('Failed to logout', [ + 'message' => $throw->getMessage(), + 'trace' => $throw->getTraceAsString(), + ]); + + return response()->json([ + 'status' => 'error', + 'message' => 'Internal Server Error', + ], 500); + } + } } diff --git a/src/routes/api.php b/src/routes/api.php index d499e71..3b734cc 100644 --- a/src/routes/api.php +++ b/src/routes/api.php @@ -14,7 +14,11 @@ }); Route::post('/user/create', [UserController::class, 'createUser']); - Route::post('/user/login', [AuthController::class, 'login']); + + Route::controller(AuthController::class)->prefix('user')->group(function (): void { + Route::post('/login', 'login'); + Route::post('/logout', 'logout')->middleware('auth:sanctum'); + }); Route::controller(UserController::class)->prefix('users')->group(function (): void { Route::get('/{id}', 'getUserById'); From cd841fba27d96eb8ace185bd6c94f9b6e4d5f17b Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Wed, 15 Jul 2026 21:16:43 +0900 Subject: [PATCH 110/110] test: add integration tests for POST /api/v1/user/logout --- .../Packages/Features/Tests/LogoutTest.php | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 src/app/Packages/Features/Tests/LogoutTest.php diff --git a/src/app/Packages/Features/Tests/LogoutTest.php b/src/app/Packages/Features/Tests/LogoutTest.php new file mode 100644 index 0000000..ac09939 --- /dev/null +++ b/src/app/Packages/Features/Tests/LogoutTest.php @@ -0,0 +1,89 @@ +truncate(); + } + + protected function tearDown(): void + { + $this->truncate(); + parent::tearDown(); + } + + private function truncate(): void + { + if (env('APP_ENV') === 'testing') { + DB::connection('mysql')->statement('SET FOREIGN_KEY_CHECKS=0;'); + DB::connection('mysql')->table('personal_access_tokens')->truncate(); + User::truncate(); + DB::connection('mysql')->statement('SET FOREIGN_KEY_CHECKS=1;'); + } + } + + private function seedUser(): User + { + return User::create([ + 'first_name' => 'John', + 'last_name' => 'Doe', + 'email' => 'john@example.com', + 'password' => bcrypt('secret123'), + 'age_range' => '20s', + 'subscription_tier' => 'free', + 'subscription_expires_at' => null, + ]); + } + + public function test_logout_returns_200_when_authenticated(): void + { + $user = $this->seedUser(); + $token = $user->createToken('auth-token')->plainTextToken; + + $response = $this->withToken($token) + ->postJson('/api/v1/user/logout'); + + $response->assertStatus(200) + ->assertJsonFragment(['status' => 'success']); + + $this->assertDatabaseMissing('personal_access_tokens', [ + 'tokenable_id' => $user->id, + ]); + } + + public function test_logout_returns_401_when_unauthenticated(): void + { + $response = $this->postJson('/api/v1/user/logout'); + + $response->assertStatus(401); + } + + public function test_logout_returns_500_on_unexpected_error(): void + { + $user = $this->seedUser(); + $token = $user->createToken('auth-token')->plainTextToken; + + $this->mock(LogoutUseCase::class) + ->shouldReceive('handle') + ->andThrow(new RuntimeException('Unexpected error')); + + $response = $this->withToken($token) + ->postJson('/api/v1/user/logout'); + + $response->assertStatus(500) + ->assertJsonFragment([ + 'status' => 'error', + 'message' => 'Internal Server Error', + ]); + } +}