add SessionTimeout logic

This commit is contained in:
root
2026-03-10 16:45:08 -04:00
parent 2e9a391280
commit 311bb93977
15 changed files with 534 additions and 292 deletions
@@ -0,0 +1,67 @@
<?php
namespace App\Http\Controllers\Api\Auth;
use App\Http\Controllers\Api\BaseApiController;
use App\Http\Requests\Auth\SessionTimeoutCheckRequest;
use App\Http\Requests\Auth\SessionTimeoutConfigRequest;
use App\Http\Requests\Auth\SessionTimeoutPingRequest;
use App\Http\Resources\Auth\SessionTimeoutConfigResource;
use App\Http\Resources\Auth\SessionTimeoutStatusResource;
use App\Services\Auth\SessionTimeoutConfigService;
use App\Services\Auth\SessionTimeoutService;
use Illuminate\Http\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
class SessionTimeoutController extends BaseApiController
{
public function __construct(
private SessionTimeoutConfigService $configService,
private SessionTimeoutService $timeoutService
) {
parent::__construct();
}
public function config(SessionTimeoutConfigRequest $request): JsonResponse
{
$config = $this->configService->config();
return $this->success([
'config' => new SessionTimeoutConfigResource($config),
]);
}
public function check(SessionTimeoutCheckRequest $request): JsonResponse
{
$result = $this->timeoutService->checkTimeout();
if (($result['status'] ?? '') === 'expired') {
return $this->error(
$result['message'] ?? 'Session expired.',
Response::HTTP_UNAUTHORIZED,
(new SessionTimeoutStatusResource($result))->toArray($request)
);
}
return $this->success([
'session' => new SessionTimeoutStatusResource($result),
]);
}
public function ping(SessionTimeoutPingRequest $request): JsonResponse
{
$result = $this->timeoutService->pingActivity();
if (($result['status'] ?? '') === 'expired') {
return $this->error(
$result['message'] ?? 'Session expired.',
Response::HTTP_UNAUTHORIZED,
(new SessionTimeoutStatusResource($result))->toArray($request)
);
}
return $this->success([
'session' => new SessionTimeoutStatusResource($result),
]);
}
}
@@ -0,0 +1,18 @@
<?php
namespace App\Http\Requests\Auth;
use App\Http\Requests\ApiFormRequest;
class SessionTimeoutCheckRequest extends ApiFormRequest
{
public function authorize(): bool
{
return auth()->check();
}
public function rules(): array
{
return [];
}
}
@@ -0,0 +1,18 @@
<?php
namespace App\Http\Requests\Auth;
use App\Http\Requests\ApiFormRequest;
class SessionTimeoutConfigRequest extends ApiFormRequest
{
public function authorize(): bool
{
return auth()->check();
}
public function rules(): array
{
return [];
}
}
@@ -0,0 +1,18 @@
<?php
namespace App\Http\Requests\Auth;
use App\Http\Requests\ApiFormRequest;
class SessionTimeoutPingRequest extends ApiFormRequest
{
public function authorize(): bool
{
return auth()->check();
}
public function rules(): array
{
return [];
}
}
@@ -0,0 +1,20 @@
<?php
namespace App\Http\Resources\Auth;
use Illuminate\Http\Resources\Json\JsonResource;
class SessionTimeoutConfigResource extends JsonResource
{
public function toArray($request): array
{
return [
'timeout' => (int) ($this['timeout'] ?? 0),
'warning_time' => (int) ($this['warning_time'] ?? 0),
'check_interval' => (int) ($this['check_interval'] ?? 0),
'logout_url' => (string) ($this['logout_url'] ?? ''),
'keep_alive_url' => (string) ($this['keep_alive_url'] ?? ''),
'check_url' => (string) ($this['check_url'] ?? ''),
];
}
}
@@ -0,0 +1,18 @@
<?php
namespace App\Http\Resources\Auth;
use Illuminate\Http\Resources\Json\JsonResource;
class SessionTimeoutStatusResource extends JsonResource
{
public function toArray($request): array
{
return [
'status' => (string) ($this['status'] ?? ''),
'time_remaining' => (int) ($this['time_remaining'] ?? 0),
'redirect' => $this['redirect'] ?? null,
'message' => $this['message'] ?? null,
];
}
}
@@ -0,0 +1,39 @@
<?php
namespace App\Services\Auth;
use Illuminate\Session\Store;
class SessionActivityService
{
public function __construct(private Store $session)
{
}
public function hasLastActivity(): bool
{
return $this->session->has('last_activity');
}
public function getLastActivity(): ?int
{
$value = $this->session->get('last_activity');
return $value !== null ? (int) $value : null;
}
public function setLastActivity(int $timestamp): void
{
$this->session->put('last_activity', $timestamp);
}
public function clearLastActivity(): void
{
$this->session->forget('last_activity');
}
public function invalidate(): void
{
$this->session->invalidate();
$this->session->regenerateToken();
}
}
@@ -0,0 +1,21 @@
<?php
namespace App\Services\Auth;
use App\Config\SessionTimeout;
use Illuminate\Support\Facades\URL;
class SessionTimeoutConfigService
{
public function config(): array
{
return [
'timeout' => SessionTimeout::TIMEOUT_DURATION,
'warning_time' => SessionTimeout::WARNING_THRESHOLD,
'check_interval' => SessionTimeout::CHECK_INTERVAL,
'logout_url' => URL::to('/auth/logout'),
'keep_alive_url' => route('session.timeout.ping'),
'check_url' => route('session.timeout.check'),
];
}
}
@@ -0,0 +1,72 @@
<?php
namespace App\Services\Auth;
use App\Config\SessionTimeout;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\URL;
class SessionTimeoutService
{
public function __construct(
private SessionActivityService $activityService
) {
}
public function checkTimeout(): array
{
if (!$this->activityService->hasLastActivity()) {
return $this->expireSession();
}
$lastActivity = (int) $this->activityService->getLastActivity();
$elapsed = time() - $lastActivity;
if ($elapsed > SessionTimeout::TIMEOUT_DURATION) {
return $this->expireSession();
}
if ($elapsed > SessionTimeout::WARNING_THRESHOLD) {
return [
'status' => 'warning',
'time_remaining' => SessionTimeout::TIMEOUT_DURATION - $elapsed,
];
}
return [
'status' => 'active',
'time_remaining' => SessionTimeout::TIMEOUT_DURATION - $elapsed,
];
}
public function pingActivity(): array
{
if (
!$this->activityService->hasLastActivity()
|| (time() - (int) $this->activityService->getLastActivity() > SessionTimeout::TIMEOUT_DURATION)
) {
return $this->expireSession();
}
$this->activityService->setLastActivity(time());
return [
'status' => 'active',
'time_remaining' => SessionTimeout::TIMEOUT_DURATION,
];
}
private function expireSession(): array
{
Log::info('Session expired due to inactivity.');
$this->activityService->clearLastActivity();
$this->activityService->invalidate();
return [
'status' => 'expired',
'redirect' => URL::to('/login'),
'message' => 'Your session has expired due to inactivity.',
'time_remaining' => 0,
];
}
}
+5
View File
@@ -182,6 +182,11 @@ Route::prefix('v1')->group(function () {
Route::middleware('auth:sanctum')->group(function () {
Route::get('stats', [StatsController::class, 'index']);
Route::prefix('session')->group(function () {
Route::get('timeout-config', [SessionTimeoutController::class, 'config'])->name('session.timeout.config');
Route::get('check-timeout', [SessionTimeoutController::class, 'check'])->name('session.timeout.check');
Route::post('ping', [SessionTimeoutController::class, 'ping'])->name('session.timeout.ping');
});
Route::prefix('whatsapp')->group(function () {
Route::get('links', [WhatsappController::class, 'index']);
-292
View File
@@ -1,292 +0,0 @@
Xdebug: [Step Debug] Could not connect to debugging client. Tried: 127.0.0.1:9003 (through xdebug.client_host/xdebug.client_port).
Xdebug: [Step Debug] Could not connect to debugging client. Tried: 127.0.0.1:9003 (through xdebug.client_host/xdebug.client_port).
PASS Tests\Unit\Models\WhatsappGroupLinkTest
✓ model metadata is converted 0.01s
✓ model class loads
PASS Tests\Unit\Models\WhatsappGroupMembershipTest
✓ model metadata is converted 0.01s
✓ model class loads
PASS Tests\Unit\Models\WhatsappInviteLogTest
✓ model metadata is converted
✓ model class loads
FAIL Tests\Unit\Services\Whatsapp\WhatsappContactServiceTest
list parent contacts returns primary and second 0.40s
PASS Tests\Unit\Services\Whatsapp\WhatsappContextServiceTest
✓ school year aliases generate expected values 0.28s
✓ has roster for year 0.28s
FAIL Tests\Unit\Services\Whatsapp\WhatsappInviteBundleServiceTest
bundle for parent returns links and emails 0.28s
✓ consolidate bundles merges by parent 0.28s
PASS Tests\Unit\Services\Whatsapp\WhatsappInviteNotificationServiceTest
✓ dispatch returns error when no listeners 0.29s
✓ dispatch triggers event when listener registered 0.28s
FAIL Tests\Unit\Services\Whatsapp\WhatsappInviteServiceTest
send returns success for parent mode 0.28s
PASS Tests\Unit\Services\Whatsapp\WhatsappLinkServiceTest
✓ upsert creates link 0.28s
✓ paginate filters by active 0.28s
PASS Tests\Unit\Services\Whatsapp\WhatsappMembershipServiceTest
✓ update membership saves primary 0.29s
✓ update membership returns error when table missing 0.29s
FAIL Tests\Feature\Api\V1\Whatsapp\WhatsappInviteControllerTest
parent contacts endpoint returns contacts 0.31s
parent contacts by class endpoint returns sections 0.29s
send invites returns success when listener registered 0.29s
send invites returns error when no listener 0.29s
membership update saves record 0.29s
✓ send invites validation rejects payload 0.31s
FAIL Tests\Feature\Api\V1\Whatsapp\WhatsappLinkControllerTest
✓ index returns paginated links 0.30s
✓ show returns single link 0.30s
store creates link 0.29s
update updates link 0.29s
✓ destroy deletes link 0.31s
✓ store validation rejects invalid payload 0.30s
✓ requires authentication 0.29s
────────────────────────────────────────────────────────────────────────────
FAILED Tests\Unit\Services\Whatsapp\WhatsappContactServi… QueryException
SQLSTATE[23000]: Integrity constraint violation: 19 NOT NULL constraint failed: parents.secondparent_id (Connection: sqlite, Database: :memory:, SQL: insert into "parents" ("secondparent_firstname", "secondparent_lastname", "secondparent_email", "secondparent_phone", "firstparent_id", "school_year", "semester", "created_at", "updated_at") values (Parent, Two, parent2@example.com, 5555555555, 10, 2025-2026, Fall, 2026-03-10 20:29:52, 2026-03-10 20:29:52))
at vendor/laravel/framework/src/Illuminate/Database/Connection.php:838
834▕ $exceptionType = $this->isUniqueConstraintError($e)
835▕ ? UniqueConstraintViolationException::class
836▕ : QueryException::class;
837▕
➜ 838▕ throw new $exceptionType(
839▕ $this->getNameWithReadWriteType(),
840▕ $query,
841▕ $this->prepareBindings($bindings),
842▕ $e,
+8 vendor frames 
9 tests/Unit/Services/Whatsapp/WhatsappContactServiceTest.php:38
────────────────────────────────────────────────────────────────────────────
FAILED Tests\Unit\Services\Whatsapp\WhatsappInviteBundle… QueryException
SQLSTATE[23000]: Integrity constraint violation: 19 NOT NULL constraint failed: parents.secondparent_id (Connection: sqlite, Database: :memory:, SQL: insert into "parents" ("secondparent_firstname", "secondparent_lastname", "secondparent_email", "secondparent_phone", "firstparent_id", "school_year", "semester", "created_at", "updated_at") values (Parent, Two, parent2@example.com, 5555555555, 10, 2025-2026, Fall, 2026-03-10 20:29:53, 2026-03-10 20:29:53))
at vendor/laravel/framework/src/Illuminate/Database/Connection.php:838
834▕ $exceptionType = $this->isUniqueConstraintError($e)
835▕ ? UniqueConstraintViolationException::class
836▕ : QueryException::class;
837▕
➜ 838▕ throw new $exceptionType(
839▕ $this->getNameWithReadWriteType(),
840▕ $query,
841▕ $this->prepareBindings($bindings),
842▕ $e,
+8 vendor frames 
9 tests/Unit/Services/Whatsapp/WhatsappInviteBundleServiceTest.php:37
────────────────────────────────────────────────────────────────────────────
FAILED Tests\Unit\Services\Whatsapp\WhatsappInviteServic… QueryException
SQLSTATE[23000]: Integrity constraint violation: 19 NOT NULL constraint failed: parents.secondparent_id (Connection: sqlite, Database: :memory:, SQL: insert into "parents" ("secondparent_firstname", "secondparent_lastname", "secondparent_email", "secondparent_phone", "firstparent_id", "school_year", "semester", "created_at", "updated_at") values (Parent, Two, parent2@example.com, 5555555555, 10, 2025-2026, Fall, 2026-03-10 20:29:54, 2026-03-10 20:29:54))
at vendor/laravel/framework/src/Illuminate/Database/Connection.php:838
834▕ $exceptionType = $this->isUniqueConstraintError($e)
835▕ ? UniqueConstraintViolationException::class
836▕ : QueryException::class;
837▕
➜ 838▕ throw new $exceptionType(
839▕ $this->getNameWithReadWriteType(),
840▕ $query,
841▕ $this->prepareBindings($bindings),
842▕ $e,
+8 vendor frames 
9 tests/Unit/Services/Whatsapp/WhatsappInviteServiceTest.php:65
10 tests/Unit/Services/Whatsapp/WhatsappInviteServiceTest.php:21
────────────────────────────────────────────────────────────────────────────
FAILED Tests\Feature\Api\V1\Whatsapp\WhatsappInviteContr… QueryException
SQLSTATE[23000]: Integrity constraint violation: 19 NOT NULL constraint failed: parents.secondparent_id (Connection: sqlite, Database: :memory:, SQL: insert into "parents" ("secondparent_firstname", "secondparent_lastname", "secondparent_email", "secondparent_phone", "firstparent_id", "school_year", "semester", "created_at", "updated_at") values (Parent, Two, parent2@example.com, 5555555555, 10, 2025-2026, Fall, 2026-03-10 20:29:56, 2026-03-10 20:29:56))
at vendor/laravel/framework/src/Illuminate/Database/Connection.php:838
834▕ $exceptionType = $this->isUniqueConstraintError($e)
835▕ ? UniqueConstraintViolationException::class
836▕ : QueryException::class;
837▕
➜ 838▕ throw new $exceptionType(
839▕ $this->getNameWithReadWriteType(),
840▕ $query,
841▕ $this->prepareBindings($bindings),
842▕ $e,
+8 vendor frames 
9 tests/Feature/Api/V1/Whatsapp/WhatsappInviteControllerTest.php:22
────────────────────────────────────────────────────────────────────────────
FAILED Tests\Feature\Api\V1\Whatsapp\WhatsappInviteContr… QueryException
SQLSTATE[23000]: Integrity constraint violation: 19 NOT NULL constraint failed: classSection.semester (Connection: sqlite, Database: :memory:, SQL: insert into "classSection" ("class_section_id", "class_section_name", "class_id", "created_at", "updated_at") values (200, Grade 1, 1, 2026-03-10 20:29:56, 2026-03-10 20:29:56))
at vendor/laravel/framework/src/Illuminate/Database/Connection.php:838
834▕ $exceptionType = $this->isUniqueConstraintError($e)
835▕ ? UniqueConstraintViolationException::class
836▕ : QueryException::class;
837▕
➜ 838▕ throw new $exceptionType(
839▕ $this->getNameWithReadWriteType(),
840▕ $query,
841▕ $this->prepareBindings($bindings),
842▕ $e,
+8 vendor frames 
9 tests/Feature/Api/V1/Whatsapp/WhatsappInviteControllerTest.php:134
10 tests/Feature/Api/V1/Whatsapp/WhatsappInviteControllerTest.php:46
────────────────────────────────────────────────────────────────────────────
FAILED Tests\Feature\Api\V1\Whatsapp\WhatsappInviteContr… QueryException
SQLSTATE[23000]: Integrity constraint violation: 19 NOT NULL constraint failed: classSection.semester (Connection: sqlite, Database: :memory:, SQL: insert into "classSection" ("class_section_id", "class_section_name", "class_id", "created_at", "updated_at") values (200, Grade 1, 1, 2026-03-10 20:29:56, 2026-03-10 20:29:56))
at vendor/laravel/framework/src/Illuminate/Database/Connection.php:838
834▕ $exceptionType = $this->isUniqueConstraintError($e)
835▕ ? UniqueConstraintViolationException::class
836▕ : QueryException::class;
837▕
➜ 838▕ throw new $exceptionType(
839▕ $this->getNameWithReadWriteType(),
840▕ $query,
841▕ $this->prepareBindings($bindings),
842▕ $e,
+8 vendor frames 
9 tests/Feature/Api/V1/Whatsapp/WhatsappInviteControllerTest.php:134
10 tests/Feature/Api/V1/Whatsapp/WhatsappInviteControllerTest.php:60
────────────────────────────────────────────────────────────────────────────
FAILED Tests\Feature\Api\V1\Whatsapp\WhatsappInviteContr… QueryException
SQLSTATE[23000]: Integrity constraint violation: 19 NOT NULL constraint failed: classSection.semester (Connection: sqlite, Database: :memory:, SQL: insert into "classSection" ("class_section_id", "class_section_name", "class_id", "created_at", "updated_at") values (200, Grade 1, 1, 2026-03-10 20:29:56, 2026-03-10 20:29:56))
at vendor/laravel/framework/src/Illuminate/Database/Connection.php:838
834▕ $exceptionType = $this->isUniqueConstraintError($e)
835▕ ? UniqueConstraintViolationException::class
836▕ : QueryException::class;
837▕
➜ 838▕ throw new $exceptionType(
839▕ $this->getNameWithReadWriteType(),
840▕ $query,
841▕ $this->prepareBindings($bindings),
842▕ $e,
+8 vendor frames 
9 tests/Feature/Api/V1/Whatsapp/WhatsappInviteControllerTest.php:134
10 tests/Feature/Api/V1/Whatsapp/WhatsappInviteControllerTest.php:81
────────────────────────────────────────────────────────────────────────────
FAILED Tests\Feature\Api\V1\Whatsapp\WhatsappInviteContr… QueryException
SQLSTATE[23000]: Integrity constraint violation: 19 NOT NULL constraint failed: classSection.semester (Connection: sqlite, Database: :memory:, SQL: insert into "classSection" ("class_section_id", "class_section_name", "class_id", "created_at", "updated_at") values (200, Grade 1, 1, 2026-03-10 20:29:57, 2026-03-10 20:29:57))
at vendor/laravel/framework/src/Illuminate/Database/Connection.php:838
834▕ $exceptionType = $this->isUniqueConstraintError($e)
835▕ ? UniqueConstraintViolationException::class
836▕ : QueryException::class;
837▕
➜ 838▕ throw new $exceptionType(
839▕ $this->getNameWithReadWriteType(),
840▕ $query,
841▕ $this->prepareBindings($bindings),
842▕ $e,
+8 vendor frames 
9 tests/Feature/Api/V1/Whatsapp/WhatsappInviteControllerTest.php:134
10 tests/Feature/Api/V1/Whatsapp/WhatsappInviteControllerTest.php:99
────────────────────────────────────────────────────────────────────────────
FAILED Tests\Feature\Api\V1\Whatsapp\WhatsappLinkControllerTest > store…
Expected response status code [201] but received 422.
Failed asserting that 422 is identical to 201.
The following errors occurred during the last request:
{
"message": "Validation failed.",
"errors": {
"class_section_id": [
"The class section id field is required."
],
"invite_link": [
"The invite link field is required."
]
}
}
at tests/Feature/Api/V1/Whatsapp/WhatsappLinkControllerTest.php:78
74▕ ];
75▕
76▕ $response = $this->postJson('/api/v1/whatsapp/links', $payload);
77▕
➜ 78▕ $response->assertStatus(201);
79▕ $response->assertJsonPath('status', true);
80▕ $this->assertDatabaseHas('whatsapp_group_links', [
81▕ 'class_section_id' => 202,
82▕ 'invite_link' => 'https://chat.whatsapp.com/new',
────────────────────────────────────────────────────────────────────────────
FAILED Tests\Feature\Api\V1\Whatsapp\WhatsappLinkControllerTest > update…
Failed asserting that a row in the table [whatsapp_group_links] matches the attributes {
"id": 1,
"invite_link": "https:\/\/chat.whatsapp.com\/updated",
"active": 0
}.
Found similar results: [
{
"id": 1,
"invite_link": "https:\/\/chat.whatsapp.com\/old",
"active": 1
}
].
at tests/Feature/Api/V1/Whatsapp/WhatsappLinkControllerTest.php:109
105▕ ]);
106▕
107▕ $response->assertOk();
108▕ $response->assertJsonPath('status', true);
➜ 109▕ $this->assertDatabaseHas('whatsapp_group_links', [
110▕ 'id' => $linkId,
111▕ 'invite_link' => 'https://chat.whatsapp.com/updated',
112▕ 'active' => 0,
113▕ ]);
Tests: 10 failed, 21 passed (52 assertions)
Duration: 7.96s
@@ -0,0 +1,123 @@
<?php
namespace Tests\Feature\Api\V1\Auth;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
class SessionTimeoutControllerTest extends TestCase
{
use RefreshDatabase;
public function test_config_returns_timeout_values(): void
{
$user = $this->seedUser();
Sanctum::actingAs($user);
$response = $this->getJson('/api/v1/session/timeout-config');
$response->assertOk();
$response->assertJsonPath('status', true);
$this->assertArrayHasKey('timeout', $response->json('data.config'));
$this->assertArrayHasKey('keep_alive_url', $response->json('data.config'));
}
public function test_check_timeout_returns_active_status(): void
{
$user = $this->seedUser();
Sanctum::actingAs($user);
$response = $this
->withSession(['last_activity' => time() - 10])
->getJson('/api/v1/session/check-timeout');
$response->assertOk();
$response->assertJsonPath('status', true);
$response->assertJsonPath('data.session.status', 'active');
}
public function test_check_timeout_returns_warning_status(): void
{
$user = $this->seedUser();
Sanctum::actingAs($user);
$response = $this
->withSession(['last_activity' => time() - 1000])
->getJson('/api/v1/session/check-timeout');
$response->assertOk();
$response->assertJsonPath('status', true);
$response->assertJsonPath('data.session.status', 'warning');
}
public function test_check_timeout_returns_expired_status(): void
{
$user = $this->seedUser();
Sanctum::actingAs($user);
$response = $this
->withSession(['last_activity' => time() - 5000])
->getJson('/api/v1/session/check-timeout');
$response->assertStatus(401);
$response->assertJsonPath('status', false);
$response->assertJsonPath('errors.status', 'expired');
}
public function test_ping_updates_activity(): void
{
$user = $this->seedUser();
Sanctum::actingAs($user);
$response = $this
->withSession(['last_activity' => time() - 10])
->postJson('/api/v1/session/ping');
$response->assertOk();
$response->assertJsonPath('status', true);
$response->assertJsonPath('data.session.status', 'active');
}
public function test_ping_returns_expired_when_missing_activity(): void
{
$user = $this->seedUser();
Sanctum::actingAs($user);
$response = $this->postJson('/api/v1/session/ping');
$response->assertStatus(401);
$response->assertJsonPath('status', false);
}
public function test_requires_authentication(): void
{
$response = $this->getJson('/api/v1/session/timeout-config');
$response->assertStatus(401);
}
private function seedUser(): User
{
$userId = DB::table('users')->insertGetId([
'firstname' => 'Admin',
'lastname' => 'User',
'cellphone' => '9999999999',
'email' => 'session@example.com',
'address_street' => '123 Street',
'city' => 'City',
'state' => 'ST',
'zip' => '12345',
'accept_school_policy' => 1,
'password' => bcrypt('password'),
'user_type' => 'primary',
'semester' => 'Fall',
'school_year' => '2025-2026',
'status' => 'Active',
]);
return User::query()->findOrFail($userId);
}
}
@@ -0,0 +1,42 @@
<?php
namespace Tests\Unit\Services\Auth;
use App\Services\Auth\SessionActivityService;
use Illuminate\Session\ArraySessionHandler;
use Illuminate\Session\Store;
use Tests\TestCase;
class SessionActivityServiceTest extends TestCase
{
public function test_set_and_get_last_activity(): void
{
$store = $this->makeSessionStore();
$service = new SessionActivityService($store);
$service->setLastActivity(12345);
$this->assertTrue($service->hasLastActivity());
$this->assertSame(12345, $service->getLastActivity());
}
public function test_clear_last_activity(): void
{
$store = $this->makeSessionStore();
$service = new SessionActivityService($store);
$service->setLastActivity(12345);
$service->clearLastActivity();
$this->assertFalse($service->hasLastActivity());
}
private function makeSessionStore(): Store
{
$handler = new ArraySessionHandler(10);
$store = new Store('test', $handler);
$store->start();
return $store;
}
}
@@ -0,0 +1,22 @@
<?php
namespace Tests\Unit\Services\Auth;
use App\Services\Auth\SessionTimeoutConfigService;
use Tests\TestCase;
class SessionTimeoutConfigServiceTest extends TestCase
{
public function test_config_returns_expected_keys(): void
{
$service = new SessionTimeoutConfigService();
$config = $service->config();
$this->assertArrayHasKey('timeout', $config);
$this->assertArrayHasKey('warning_time', $config);
$this->assertArrayHasKey('check_interval', $config);
$this->assertArrayHasKey('logout_url', $config);
$this->assertArrayHasKey('keep_alive_url', $config);
$this->assertArrayHasKey('check_url', $config);
}
}
@@ -0,0 +1,51 @@
<?php
namespace Tests\Unit\Services\Auth;
use App\Config\SessionTimeout;
use App\Services\Auth\SessionActivityService;
use App\Services\Auth\SessionTimeoutService;
use Mockery;
use Tests\TestCase;
class SessionTimeoutServiceTest extends TestCase
{
public function test_check_timeout_returns_expired_when_missing_activity(): void
{
$activity = Mockery::mock(SessionActivityService::class);
$activity->shouldReceive('hasLastActivity')->andReturn(false);
$activity->shouldReceive('clearLastActivity')->once();
$activity->shouldReceive('invalidate')->once();
$service = new SessionTimeoutService($activity);
$result = $service->checkTimeout();
$this->assertSame('expired', $result['status']);
}
public function test_check_timeout_returns_warning(): void
{
$activity = Mockery::mock(SessionActivityService::class);
$activity->shouldReceive('hasLastActivity')->andReturn(true);
$activity->shouldReceive('getLastActivity')->andReturn(time() - (SessionTimeout::WARNING_THRESHOLD + 10));
$service = new SessionTimeoutService($activity);
$result = $service->checkTimeout();
$this->assertSame('warning', $result['status']);
}
public function test_ping_returns_expired_when_timed_out(): void
{
$activity = Mockery::mock(SessionActivityService::class);
$activity->shouldReceive('hasLastActivity')->andReturn(true);
$activity->shouldReceive('getLastActivity')->andReturn(time() - (SessionTimeout::TIMEOUT_DURATION + 10));
$activity->shouldReceive('clearLastActivity')->once();
$activity->shouldReceive('invalidate')->once();
$service = new SessionTimeoutService($activity);
$result = $service->pingActivity();
$this->assertSame('expired', $result['status']);
}
}