Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,6 @@ interface TokenServiceInterface
public function createToken(UserEntity $entity): string;

public function revokeAllTokens(UserEntity $entity): void;

public function revokeCurrentToken(string $plainTextToken): void;
}
12 changes: 12 additions & 0 deletions src/app/Packages/Domains/User/Service/SanctumTokenService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

namespace App\Packages\Features\CommandUseCases\UseCase\User;

use App\Packages\Domains\User\Interface\TokenServiceInterface;

class LogoutUseCase
{
public function __construct(
private readonly TokenServiceInterface $tokenService,
) {}

public function handle(string $plainTextToken): void
{
$this->tokenService->revokeCurrentToken($plainTextToken);
}
}
22 changes: 22 additions & 0 deletions src/app/Packages/Features/Controller/AuthController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<?php

namespace App\Packages\Features\Tests\CommandUseCases;

use App\Packages\Domains\User\Interface\TokenServiceInterface;
use App\Packages\Features\CommandUseCases\UseCase\User\LogoutUseCase;
use Mockery;
use Mockery\MockInterface;
use RuntimeException;
use Tests\TestCase;

class LogoutUseCaseTest extends TestCase
{
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}

#[\PHPUnit\Framework\Attributes\DoesNotPerformAssertions]
public function test_handle_revokes_current_token(): void
{
/** @var TokenServiceInterface|MockInterface $tokenService */
$tokenService = Mockery::mock(TokenServiceInterface::class);
$tokenService->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');
}
}
89 changes: 89 additions & 0 deletions src/app/Packages/Features/Tests/LogoutTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
<?php

namespace App\Packages\Features\Tests;

use App\Models\User;
use App\Packages\Features\CommandUseCases\UseCase\User\LogoutUseCase;
use Illuminate\Support\Facades\DB;
use RuntimeException;
use Tests\TestCase;

class LogoutTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
$this->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',
]);
}
}
6 changes: 5 additions & 1 deletion src/routes/api.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
Loading