From 392adf94a5f35b0acc483df74098bab62e0a25a7 Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Wed, 15 Jul 2026 20:26:55 +0900 Subject: [PATCH 1/6] 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 2/6] 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 3/6] 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 4/6] 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 5/6] 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 6/6] 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', + ]); + } +}