From d5ae586b3e6260277167126e429b67dbc181a7f6 Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Mon, 13 Jul 2026 08:53:17 +0900 Subject: [PATCH 01/28] 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 02/28] 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 03/28] 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 04/28] 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 05/28] 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 06/28] 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 07/28] 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 08/28] 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 09/28] 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 10/28] 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 11/28] 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 12/28] 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 13/28] 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 14/28] 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 15/28] 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 16/28] 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 17/28] 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 18/28] 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 19/28] 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 20/28] 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 21/28] 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 22/28] 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 23/28] 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 24/28] 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 25/28] 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 26/28] 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 27/28] 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 28/28] 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)); + } +}