Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions app/Jobs/SendPushNotificationJob.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
<?php

declare(strict_types=1);

namespace App\Jobs;

use App\Services\PushNotificationService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;

/**
* Deliver a web-push notification outside the request.
*
* `PushNotificationService` ends in `foreach ($webPush->flush() as $report)`,
* and minishlink/web-push's `flush()` is `yield $promise->wait()` — a
* blocking round-trip to every registered endpoint. Called from a model
Comment on lines +16 to +20
* hook, that ran inside whatever request happened to create the
* notification.
*
* Failures are logged rather than rethrown on the last attempt: a push that
* cannot be delivered must not fail the surrounding work, which is what the
* original swallowed `catch (\Throwable)` was reaching for — just in the
* wrong place.
*/
class SendPushNotificationJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

public int $tries = 3;

/** @var list<int> */
public array $backoff = [10, 60];

/**
* @param array<string, mixed> $extra
*/
public function __construct(
public readonly string $userId,
public readonly string $title,
public readonly string $body,
public readonly array $extra = [],
) {}

public function handle(): void
{
PushNotificationService::sendToUser($this->userId, $this->title, $this->body, $this->extra);
}

public function failed(\Throwable $e): void
{
Log::warning('SendPushNotificationJob: giving up on a push notification', [
'user_id' => $this->userId,
'error' => $e->getMessage(),
]);
}
}
22 changes: 11 additions & 11 deletions app/Models/Notification.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

namespace App\Models;

use App\Services\PushNotificationService;
use App\Jobs\SendPushNotificationJob;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon;
Expand Down Expand Up @@ -36,16 +36,16 @@ protected function casts(): array
protected static function booted(): void
{
static::created(function (Notification $notification) {
// Fire-and-forget push notification (non-blocking)
try {
PushNotificationService::sendToUser(
$notification->user_id,
$notification->title ?? 'ParkHub',
$notification->message ?? '',
);
} catch (\Throwable) {
// Push failure should never break app flow
}
// Queue the push rather than performing it here. This hook runs
// inside whatever request created the notification, and the send
// is a blocking round-trip to every registered endpoint
// (web-push's `flush()` is `yield $promise->wait()`). The comment
// this replaces called it "non-blocking"; it never was.
SendPushNotificationJob::dispatch(
$notification->user_id,
$notification->title ?? 'ParkHub',
$notification->message ?? '',
);
Comment on lines +39 to +48
});
}
}
9 changes: 8 additions & 1 deletion app/Services/PushNotificationService.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@

class PushNotificationService
{
/** Seconds to wait for a single push endpoint before giving up. */
private const int PUSH_TIMEOUT_SECONDS = 5;

public static function sendToUser(string $userId, string $title, string $body, array $extra = []): int
{
$subscriptions = PushSubscription::where('user_id', $userId)->get();
Expand All @@ -32,7 +35,11 @@ public static function sendToUser(string $userId, string $title, string $body, a
],
];

$webPush = new WebPush($auth);
// minishlink/web-push defaults to a 30-second timeout per endpoint
// and `flush()` blocks on each one. Even from a queue worker that is
// far longer than a push is worth waiting for; a browser push service
// that has not answered in five seconds is not going to.
$webPush = new WebPush($auth, [], self::PUSH_TIMEOUT_SECONDS);
$payload = json_encode(array_merge(['title' => $title, 'body' => $body], $extra));
$sent = 0;

Expand Down
12 changes: 11 additions & 1 deletion database/seeders/DatabaseSeeder.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,21 @@ class DatabaseSeeder extends Seeder
/**
* Seed the application's database.
*
* Default: php artisan db:seed → minimal dev seed
* Default: php artisan db:seed → minimal dev seed
* Demo: DEMO_MODE=true db:seed → full production simulation
* (admin user, lots, bookings,
* sufficient data for the full
* E2E / visual-regression suite)
* Full sim: php artisan db:seed --class=ProductionSimulationSeeder
*/
public function run(): void
{
if (config('parkhub.demo_mode') || env('DEMO_MODE') === 'true') {
$this->call(ProductionSimulationSeeder::class);

return;
}

User::factory()->create([
'name' => 'Test User',
'email' => 'test@example.com',
Expand Down
10 changes: 9 additions & 1 deletion database/seeders/ProductionSimulationSeeder.php
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,15 @@ private function seedAdmins(): array
'name' => 'Administrator',
'username' => 'admin',
'email' => 'admin@parkhub.test',
'password' => Hash::make(env('PARKHUB_ADMIN_PASSWORD', 'demo')),
// env() returns '' (not the default) when the var is declared
// but empty. Normalise so an empty PARKHUB_ADMIN_PASSWORD in .env
// still lands on the documented default 'demo' instead of an
// unusable empty password.
'password' => Hash::make(
((string) env('PARKHUB_ADMIN_PASSWORD', 'demo')) !== ''
? (string) env('PARKHUB_ADMIN_PASSWORD', 'demo')
: 'demo'
),
'role' => 'superadmin',
'department' => 'IT',
'is_active' => true,
Expand Down
28 changes: 28 additions & 0 deletions e2e/a11y.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,34 @@ const routes = ['/', '/login', '/register', '/setup'];
for (const route of routes) {
test(`a11y: ${route} has no critical violations`, async ({ page }) => {
await page.goto(route);
// Let the SPA settle: `networkidle` waits for lazy chunks + any session
// probe fetches, and the URL-stability poll gives the React Router a
// chance to commit its final route (some entry paths like / redirect
// to /login client-side — running axe during that redirect throws
// "Execution context was destroyed, most likely because of a navigation").
await page
.waitForLoadState('networkidle', { timeout: 10_000 })
.catch(() => { /* best-effort */ });
let lastUrl = page.url();
for (let i = 0; i < 20; i++) {
await page.waitForTimeout(100);
const cur = page.url();
if (cur === lastUrl) break;
lastUrl = cur;
}
await page
.waitForFunction(
() => {
const txt = document.body?.textContent ?? '';
return txt.length > 50 && !/^\s*Loading ParkHub/i.test(txt);
},
null,
{ timeout: 5_000 },
)
.catch(() => {
/* fall through — axe runs on whatever rendered */
});

const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'])
.disableRules(['color-contrast']) // managed via CSS custom properties
Expand Down
30 changes: 30 additions & 0 deletions e2e/concurrent-users.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,36 @@ test.describe('Concurrent Users — Booking Conflict Detection', () => {
await pageA.waitForLoadState('domcontentloaded');
await pageB.waitForLoadState('domcontentloaded');

// The SPA shows a "Loading ParkHub" splash for a few hundred ms before
// React hydrates and the router decides to either render /book or
// redirect to /login|/welcome. Wait until hydration settles (body text
// length grows past the splash and URL stops changing) so the two
// contexts are compared against their final rendered DOM, not the
// in-flight placeholder.
const waitForSpaReady = async (p: typeof pageA) => {
await p.waitForLoadState('networkidle', { timeout: 10_000 }).catch(() => {});
await p
.waitForFunction(
() => {
const txt = document.body?.textContent ?? '';
return txt.length > 80 && !/^\s*Loading ParkHub/i.test(txt);
},
null,
{ timeout: 10_000 },
)
.catch(() => { /* assertion will produce the failure message */ });
// Let the router commit (redirects happen after the initial paint).
let lastUrl = p.url();
for (let i = 0; i < 20; i++) {
await p.waitForTimeout(100);
const cur = p.url();
if (cur === lastUrl) break;
lastUrl = cur;
}
};
await waitForSpaReady(pageA);
await waitForSpaReady(pageB);

// Both should see the booking page
const bodyA = await pageA.locator('body').textContent();
const bodyB = await pageB.locator('body').textContent();
Expand Down
31 changes: 24 additions & 7 deletions e2e/full-workflow.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { test, expect, type APIRequestContext, type APIResponse } from '@playwright/test';
import { loginViaApi, DEMO_ADMIN } from './helpers';
import { loginViaApi, loginViaUi, DEMO_ADMIN } from './helpers';

const BASE = process.env.E2E_BASE_URL || 'http://localhost:8082';

Expand Down Expand Up @@ -78,12 +78,30 @@ test.describe('Full User Workflow', () => {
// cookie; others return 200/201 with a new token. Either behaviour
// is a valid implementation of "refresh token works".
expect(res.status()).toBeLessThan(500);

// PHP's Sanctum-based refresh revokes the old PAT and returns a new
// one. Capture it so subsequent tests in this describe share a valid
// bearer. Without this, every test that runs after `refresh` inherits
// the revoked token and hits 401 on protected endpoints.
if (res.status() < 400) {
const body = await res.json().catch(() => null);
const next =
body?.data?.tokens?.access_token ??
body?.tokens?.access_token ??
body?.data?.token ??
body?.token;
if (typeof next === 'string' && next.length > 0) {
token = next;
}
}
});

test('get current user profile', async ({ request }) => {
// PHP backend exposes /api/v1/users/me (and /api/v1/me); Rust backend
// exposes /api/v1/auth/me.
const res = await tryEndpoints(
request,
['/api/v1/auth/me', '/api/v1/users/me', '/api/v1/me'],
['/api/v1/users/me', '/api/v1/me', '/api/v1/auth/me'],
{ headers: { Authorization: `Bearer ${token}` } },
);
expect(res.status()).toBe(200);
Expand Down Expand Up @@ -448,11 +466,10 @@ test.describe('Full Admin Workflow', () => {

test.describe('Theme UI Switching (Browser)', () => {
test('theme switcher FAB is visible after login', async ({ page }) => {
await page.goto('/login', { waitUntil: 'domcontentloaded' });
await page.getByLabel(/email/i).first().fill(DEMO_ADMIN.email);
await page.locator('input[type="password"]').first().fill(DEMO_ADMIN.password);
await page.getByRole('button', { name: /sign in|log in|login/i }).click();
await page.waitForURL((url) => !url.pathname.includes('/login'), { timeout: 30_000 });
// Use the shared loginViaUi helper which handles the react-hook-form
// "Required" race (rare, but it happens when register() hasn't wired up
// before the first click) instead of duplicating fragile inline steps.
await loginViaUi(page);
await page.waitForLoadState('domcontentloaded');

// Locating the theme switcher is best-effort — it may be behind a menu
Expand Down
18 changes: 18 additions & 0 deletions e2e/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,24 @@ export async function loginViaUi(page: Page): Promise<void> {
}

await submit.click();

// react-hook-form may race and render a "Required" validation alert if
// the register() listener didn't attach before the first click. When we
// spot it, blur+refill both fields and click again — much cheaper than
// letting the 30s URL wait time out and flake the whole test.
const requiredAlert = page.getByRole('alert').filter({ hasText: /required/i });
if (await requiredAlert.first().isVisible({ timeout: 500 }).catch(() => false)) {
await emailField.click();
await emailField.fill('');
await emailField.fill(DEMO_ADMIN.email);
await emailField.press('Tab');
await passwordField.click();
await passwordField.fill('');
await passwordField.fill(DEMO_ADMIN.password);
await passwordField.press('Tab');
await submit.click();
}

await page.waitForURL((url) => !url.pathname.includes('/login'), { timeout: 30_000 });
// WebKit / mobile-safari commits Set-Cookie noticeably later than Chromium,
// which races with the caller's subsequent page.goto('/protected-route').
Expand Down
12 changes: 12 additions & 0 deletions e2e/offline-reconnect.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,18 @@ test.describe('PWA — Offline Resilience & Reconnection', () => {
// Navigate to a page that fetches data
await page.goto('/');
await page.waitForLoadState('domcontentloaded');
// Wait for React to hydrate past the "Loading ParkHub" splash. Without
// this, the SPA shell body is just the loading string (<50 chars) and
// the test races the hydration.
try {
await page.waitForFunction(
() => (document.body?.textContent ?? '').length > 50,
null,
{ timeout: 10_000 },
);
} catch {
// fall through — assertion below will produce the useful failure message
}

// Page should load successfully now
expect(page.url()).not.toContain('offline');
Expand Down
20 changes: 19 additions & 1 deletion e2e/security-flows.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,25 @@ test.describe('Security — Access Control & Hardening', () => {
await page.goto(route);
await page.waitForLoadState('domcontentloaded');

// Should either redirect to /login or show access denied
// React AuthContext performs a client-side redirect after hydration,
// so the URL may still be the original route for a tick. Wait (up to
// 10s) for either a URL change to /login|/welcome or for an
// access-denied body string before asserting.
try {
await page.waitForFunction(
() => {
const href = window.location.href;
if (href.includes('/login') || href.includes('/welcome')) return true;
const txt = document.body?.textContent ?? '';
return /forbidden|access denied|unauthorized|not authorized/i.test(txt);
},
null,
{ timeout: 10_000 },
);
} catch {
// fall through — assertion below will produce the useful failure message
}

const url = page.url();
const body = await page.locator('body').textContent();
const isBlocked =
Expand Down
16 changes: 16 additions & 0 deletions e2e/visual.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,29 @@ import { loginViaUi } from './helpers';
*/

const SURFACES = [
// Public
{ name: 'login', path: '/login', auth: false },
{ name: 'register', path: '/register', auth: false },
{ name: 'forgot-password', path: '/forgot-password', auth: false },
{ name: 'welcome', path: '/welcome', auth: false },
// Authenticated user
{ name: 'dashboard', path: '/', auth: true },
{ name: 'book', path: '/book', auth: true },
{ name: 'bookings', path: '/bookings', auth: true },
{ name: 'vehicles', path: '/vehicles', auth: true },
{ name: 'credits', path: '/credits', auth: true },
{ name: 'favorites', path: '/favorites', auth: true },
{ name: 'absences', path: '/absences', auth: true },
{ name: 'notifications', path: '/notifications', auth: true },
{ name: 'calendar', path: '/calendar', auth: true },
{ name: 'profile', path: '/profile', auth: true },
// Admin
{ name: 'admin', path: '/admin', auth: true },
{ name: 'admin-modules', path: '/admin/modules', auth: true },
{ name: 'admin-settings', path: '/admin/settings', auth: true },
{ name: 'admin-users', path: '/admin/users', auth: true },
{ name: 'admin-lots', path: '/admin/lots', auth: true },
{ name: 'admin-analytics', path: '/admin/analytics', auth: true },
];

const VIEWPORTS = [
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading