diff --git a/app/Jobs/SendPushNotificationJob.php b/app/Jobs/SendPushNotificationJob.php new file mode 100644 index 00000000..92ea5a0f --- /dev/null +++ b/app/Jobs/SendPushNotificationJob.php @@ -0,0 +1,60 @@ +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 + * 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 */ + public array $backoff = [10, 60]; + + /** + * @param array $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(), + ]); + } +} diff --git a/app/Models/Notification.php b/app/Models/Notification.php index b3a31103..834f416c 100644 --- a/app/Models/Notification.php +++ b/app/Models/Notification.php @@ -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; @@ -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 ?? '', + ); }); } } diff --git a/app/Services/PushNotificationService.php b/app/Services/PushNotificationService.php index 33422a7d..e484f999 100644 --- a/app/Services/PushNotificationService.php +++ b/app/Services/PushNotificationService.php @@ -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(); @@ -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; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index e5c8a5cc..6d9a9840 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -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', diff --git a/database/seeders/ProductionSimulationSeeder.php b/database/seeders/ProductionSimulationSeeder.php index 09510897..9fa062e5 100644 --- a/database/seeders/ProductionSimulationSeeder.php +++ b/database/seeders/ProductionSimulationSeeder.php @@ -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, diff --git a/e2e/a11y.spec.ts b/e2e/a11y.spec.ts index bc6a6dd9..62589e62 100644 --- a/e2e/a11y.spec.ts +++ b/e2e/a11y.spec.ts @@ -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 diff --git a/e2e/concurrent-users.spec.ts b/e2e/concurrent-users.spec.ts index c7d9e8bc..d87d7b15 100644 --- a/e2e/concurrent-users.spec.ts +++ b/e2e/concurrent-users.spec.ts @@ -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(); diff --git a/e2e/full-workflow.spec.ts b/e2e/full-workflow.spec.ts index d3efb648..572e3f23 100644 --- a/e2e/full-workflow.spec.ts +++ b/e2e/full-workflow.spec.ts @@ -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'; @@ -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); @@ -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 diff --git a/e2e/helpers.ts b/e2e/helpers.ts index 5d569ba0..b62b47d0 100644 --- a/e2e/helpers.ts +++ b/e2e/helpers.ts @@ -55,6 +55,24 @@ export async function loginViaUi(page: Page): Promise { } 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'). diff --git a/e2e/offline-reconnect.spec.ts b/e2e/offline-reconnect.spec.ts index b3790ebe..4cefdb8c 100644 --- a/e2e/offline-reconnect.spec.ts +++ b/e2e/offline-reconnect.spec.ts @@ -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'); diff --git a/e2e/security-flows.spec.ts b/e2e/security-flows.spec.ts index 44f8a439..8f816355 100644 --- a/e2e/security-flows.spec.ts +++ b/e2e/security-flows.spec.ts @@ -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 = diff --git a/e2e/visual.spec.ts b/e2e/visual.spec.ts index a58f8692..abd29e11 100644 --- a/e2e/visual.spec.ts +++ b/e2e/visual.spec.ts @@ -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 = [ diff --git a/e2e/visual.spec.ts-snapshots/absences-desktop-dark-chromium-linux.png b/e2e/visual.spec.ts-snapshots/absences-desktop-dark-chromium-linux.png new file mode 100644 index 00000000..01d7e5ae Binary files /dev/null and b/e2e/visual.spec.ts-snapshots/absences-desktop-dark-chromium-linux.png differ diff --git a/e2e/visual.spec.ts-snapshots/absences-desktop-light-chromium-linux.png b/e2e/visual.spec.ts-snapshots/absences-desktop-light-chromium-linux.png new file mode 100644 index 00000000..86026094 Binary files /dev/null and b/e2e/visual.spec.ts-snapshots/absences-desktop-light-chromium-linux.png differ diff --git a/e2e/visual.spec.ts-snapshots/absences-mobile-dark-chromium-linux.png b/e2e/visual.spec.ts-snapshots/absences-mobile-dark-chromium-linux.png new file mode 100644 index 00000000..486d1f96 Binary files /dev/null and b/e2e/visual.spec.ts-snapshots/absences-mobile-dark-chromium-linux.png differ diff --git a/e2e/visual.spec.ts-snapshots/absences-mobile-light-chromium-linux.png b/e2e/visual.spec.ts-snapshots/absences-mobile-light-chromium-linux.png new file mode 100644 index 00000000..8e3e9569 Binary files /dev/null and b/e2e/visual.spec.ts-snapshots/absences-mobile-light-chromium-linux.png differ diff --git a/e2e/visual.spec.ts-snapshots/admin-analytics-desktop-dark-chromium-linux.png b/e2e/visual.spec.ts-snapshots/admin-analytics-desktop-dark-chromium-linux.png new file mode 100644 index 00000000..3df23849 Binary files /dev/null and b/e2e/visual.spec.ts-snapshots/admin-analytics-desktop-dark-chromium-linux.png differ diff --git a/e2e/visual.spec.ts-snapshots/admin-analytics-desktop-light-chromium-linux.png b/e2e/visual.spec.ts-snapshots/admin-analytics-desktop-light-chromium-linux.png new file mode 100644 index 00000000..a7de4706 Binary files /dev/null and b/e2e/visual.spec.ts-snapshots/admin-analytics-desktop-light-chromium-linux.png differ diff --git a/e2e/visual.spec.ts-snapshots/admin-analytics-mobile-dark-chromium-linux.png b/e2e/visual.spec.ts-snapshots/admin-analytics-mobile-dark-chromium-linux.png new file mode 100644 index 00000000..7eeb8a79 Binary files /dev/null and b/e2e/visual.spec.ts-snapshots/admin-analytics-mobile-dark-chromium-linux.png differ diff --git a/e2e/visual.spec.ts-snapshots/admin-analytics-mobile-light-chromium-linux.png b/e2e/visual.spec.ts-snapshots/admin-analytics-mobile-light-chromium-linux.png new file mode 100644 index 00000000..b76b3c37 Binary files /dev/null and b/e2e/visual.spec.ts-snapshots/admin-analytics-mobile-light-chromium-linux.png differ diff --git a/e2e/visual.spec.ts-snapshots/admin-desktop-dark-chromium-linux.png b/e2e/visual.spec.ts-snapshots/admin-desktop-dark-chromium-linux.png index 5a990343..75ab07e6 100644 Binary files a/e2e/visual.spec.ts-snapshots/admin-desktop-dark-chromium-linux.png and b/e2e/visual.spec.ts-snapshots/admin-desktop-dark-chromium-linux.png differ diff --git a/e2e/visual.spec.ts-snapshots/admin-desktop-light-chromium-linux.png b/e2e/visual.spec.ts-snapshots/admin-desktop-light-chromium-linux.png index 0ebf7e45..d9c9f971 100644 Binary files a/e2e/visual.spec.ts-snapshots/admin-desktop-light-chromium-linux.png and b/e2e/visual.spec.ts-snapshots/admin-desktop-light-chromium-linux.png differ diff --git a/e2e/visual.spec.ts-snapshots/admin-lots-desktop-dark-chromium-linux.png b/e2e/visual.spec.ts-snapshots/admin-lots-desktop-dark-chromium-linux.png new file mode 100644 index 00000000..19a34ecf Binary files /dev/null and b/e2e/visual.spec.ts-snapshots/admin-lots-desktop-dark-chromium-linux.png differ diff --git a/e2e/visual.spec.ts-snapshots/admin-lots-desktop-light-chromium-linux.png b/e2e/visual.spec.ts-snapshots/admin-lots-desktop-light-chromium-linux.png new file mode 100644 index 00000000..010a3b73 Binary files /dev/null and b/e2e/visual.spec.ts-snapshots/admin-lots-desktop-light-chromium-linux.png differ diff --git a/e2e/visual.spec.ts-snapshots/admin-lots-mobile-dark-chromium-linux.png b/e2e/visual.spec.ts-snapshots/admin-lots-mobile-dark-chromium-linux.png new file mode 100644 index 00000000..a0eef59a Binary files /dev/null and b/e2e/visual.spec.ts-snapshots/admin-lots-mobile-dark-chromium-linux.png differ diff --git a/e2e/visual.spec.ts-snapshots/admin-lots-mobile-light-chromium-linux.png b/e2e/visual.spec.ts-snapshots/admin-lots-mobile-light-chromium-linux.png new file mode 100644 index 00000000..41cd917a Binary files /dev/null and b/e2e/visual.spec.ts-snapshots/admin-lots-mobile-light-chromium-linux.png differ diff --git a/e2e/visual.spec.ts-snapshots/admin-mobile-dark-chromium-linux.png b/e2e/visual.spec.ts-snapshots/admin-mobile-dark-chromium-linux.png index 25e960af..fb31e52d 100644 Binary files a/e2e/visual.spec.ts-snapshots/admin-mobile-dark-chromium-linux.png and b/e2e/visual.spec.ts-snapshots/admin-mobile-dark-chromium-linux.png differ diff --git a/e2e/visual.spec.ts-snapshots/admin-mobile-light-chromium-linux.png b/e2e/visual.spec.ts-snapshots/admin-mobile-light-chromium-linux.png index 95ecfacc..cdfc2fea 100644 Binary files a/e2e/visual.spec.ts-snapshots/admin-mobile-light-chromium-linux.png and b/e2e/visual.spec.ts-snapshots/admin-mobile-light-chromium-linux.png differ diff --git a/e2e/visual.spec.ts-snapshots/admin-modules-desktop-dark-chromium-linux.png b/e2e/visual.spec.ts-snapshots/admin-modules-desktop-dark-chromium-linux.png index f947e080..2ce5d162 100644 Binary files a/e2e/visual.spec.ts-snapshots/admin-modules-desktop-dark-chromium-linux.png and b/e2e/visual.spec.ts-snapshots/admin-modules-desktop-dark-chromium-linux.png differ diff --git a/e2e/visual.spec.ts-snapshots/admin-modules-desktop-light-chromium-linux.png b/e2e/visual.spec.ts-snapshots/admin-modules-desktop-light-chromium-linux.png index 8df4d7ad..b6ef8c58 100644 Binary files a/e2e/visual.spec.ts-snapshots/admin-modules-desktop-light-chromium-linux.png and b/e2e/visual.spec.ts-snapshots/admin-modules-desktop-light-chromium-linux.png differ diff --git a/e2e/visual.spec.ts-snapshots/admin-modules-mobile-dark-chromium-linux.png b/e2e/visual.spec.ts-snapshots/admin-modules-mobile-dark-chromium-linux.png index a069d5e2..b41be79c 100644 Binary files a/e2e/visual.spec.ts-snapshots/admin-modules-mobile-dark-chromium-linux.png and b/e2e/visual.spec.ts-snapshots/admin-modules-mobile-dark-chromium-linux.png differ diff --git a/e2e/visual.spec.ts-snapshots/admin-modules-mobile-light-chromium-linux.png b/e2e/visual.spec.ts-snapshots/admin-modules-mobile-light-chromium-linux.png index 104e3cd0..724b63bb 100644 Binary files a/e2e/visual.spec.ts-snapshots/admin-modules-mobile-light-chromium-linux.png and b/e2e/visual.spec.ts-snapshots/admin-modules-mobile-light-chromium-linux.png differ diff --git a/e2e/visual.spec.ts-snapshots/admin-settings-desktop-dark-chromium-linux.png b/e2e/visual.spec.ts-snapshots/admin-settings-desktop-dark-chromium-linux.png new file mode 100644 index 00000000..e43ef185 Binary files /dev/null and b/e2e/visual.spec.ts-snapshots/admin-settings-desktop-dark-chromium-linux.png differ diff --git a/e2e/visual.spec.ts-snapshots/admin-settings-desktop-light-chromium-linux.png b/e2e/visual.spec.ts-snapshots/admin-settings-desktop-light-chromium-linux.png new file mode 100644 index 00000000..dbb493de Binary files /dev/null and b/e2e/visual.spec.ts-snapshots/admin-settings-desktop-light-chromium-linux.png differ diff --git a/e2e/visual.spec.ts-snapshots/admin-settings-mobile-dark-chromium-linux.png b/e2e/visual.spec.ts-snapshots/admin-settings-mobile-dark-chromium-linux.png new file mode 100644 index 00000000..be88d5b1 Binary files /dev/null and b/e2e/visual.spec.ts-snapshots/admin-settings-mobile-dark-chromium-linux.png differ diff --git a/e2e/visual.spec.ts-snapshots/admin-settings-mobile-light-chromium-linux.png b/e2e/visual.spec.ts-snapshots/admin-settings-mobile-light-chromium-linux.png new file mode 100644 index 00000000..255a1d31 Binary files /dev/null and b/e2e/visual.spec.ts-snapshots/admin-settings-mobile-light-chromium-linux.png differ diff --git a/e2e/visual.spec.ts-snapshots/admin-users-desktop-dark-chromium-linux.png b/e2e/visual.spec.ts-snapshots/admin-users-desktop-dark-chromium-linux.png new file mode 100644 index 00000000..2a849f21 Binary files /dev/null and b/e2e/visual.spec.ts-snapshots/admin-users-desktop-dark-chromium-linux.png differ diff --git a/e2e/visual.spec.ts-snapshots/admin-users-desktop-light-chromium-linux.png b/e2e/visual.spec.ts-snapshots/admin-users-desktop-light-chromium-linux.png new file mode 100644 index 00000000..81d561a4 Binary files /dev/null and b/e2e/visual.spec.ts-snapshots/admin-users-desktop-light-chromium-linux.png differ diff --git a/e2e/visual.spec.ts-snapshots/admin-users-mobile-dark-chromium-linux.png b/e2e/visual.spec.ts-snapshots/admin-users-mobile-dark-chromium-linux.png new file mode 100644 index 00000000..1f2f7afd Binary files /dev/null and b/e2e/visual.spec.ts-snapshots/admin-users-mobile-dark-chromium-linux.png differ diff --git a/e2e/visual.spec.ts-snapshots/admin-users-mobile-light-chromium-linux.png b/e2e/visual.spec.ts-snapshots/admin-users-mobile-light-chromium-linux.png new file mode 100644 index 00000000..8a9a64c1 Binary files /dev/null and b/e2e/visual.spec.ts-snapshots/admin-users-mobile-light-chromium-linux.png differ diff --git a/e2e/visual.spec.ts-snapshots/book-desktop-dark-chromium-linux.png b/e2e/visual.spec.ts-snapshots/book-desktop-dark-chromium-linux.png index 8e861e2b..69e882af 100644 Binary files a/e2e/visual.spec.ts-snapshots/book-desktop-dark-chromium-linux.png and b/e2e/visual.spec.ts-snapshots/book-desktop-dark-chromium-linux.png differ diff --git a/e2e/visual.spec.ts-snapshots/book-desktop-light-chromium-linux.png b/e2e/visual.spec.ts-snapshots/book-desktop-light-chromium-linux.png index 075bbe23..ddc0491a 100644 Binary files a/e2e/visual.spec.ts-snapshots/book-desktop-light-chromium-linux.png and b/e2e/visual.spec.ts-snapshots/book-desktop-light-chromium-linux.png differ diff --git a/e2e/visual.spec.ts-snapshots/book-mobile-dark-chromium-linux.png b/e2e/visual.spec.ts-snapshots/book-mobile-dark-chromium-linux.png index 20114cb1..08176e32 100644 Binary files a/e2e/visual.spec.ts-snapshots/book-mobile-dark-chromium-linux.png and b/e2e/visual.spec.ts-snapshots/book-mobile-dark-chromium-linux.png differ diff --git a/e2e/visual.spec.ts-snapshots/book-mobile-light-chromium-linux.png b/e2e/visual.spec.ts-snapshots/book-mobile-light-chromium-linux.png index 7a2995b7..a4d2564a 100644 Binary files a/e2e/visual.spec.ts-snapshots/book-mobile-light-chromium-linux.png and b/e2e/visual.spec.ts-snapshots/book-mobile-light-chromium-linux.png differ diff --git a/e2e/visual.spec.ts-snapshots/bookings-desktop-dark-chromium-linux.png b/e2e/visual.spec.ts-snapshots/bookings-desktop-dark-chromium-linux.png index 34f018ee..02043202 100644 Binary files a/e2e/visual.spec.ts-snapshots/bookings-desktop-dark-chromium-linux.png and b/e2e/visual.spec.ts-snapshots/bookings-desktop-dark-chromium-linux.png differ diff --git a/e2e/visual.spec.ts-snapshots/bookings-desktop-light-chromium-linux.png b/e2e/visual.spec.ts-snapshots/bookings-desktop-light-chromium-linux.png index bc0beb77..ed30ee02 100644 Binary files a/e2e/visual.spec.ts-snapshots/bookings-desktop-light-chromium-linux.png and b/e2e/visual.spec.ts-snapshots/bookings-desktop-light-chromium-linux.png differ diff --git a/e2e/visual.spec.ts-snapshots/bookings-mobile-dark-chromium-linux.png b/e2e/visual.spec.ts-snapshots/bookings-mobile-dark-chromium-linux.png index 1956eda6..942409b8 100644 Binary files a/e2e/visual.spec.ts-snapshots/bookings-mobile-dark-chromium-linux.png and b/e2e/visual.spec.ts-snapshots/bookings-mobile-dark-chromium-linux.png differ diff --git a/e2e/visual.spec.ts-snapshots/bookings-mobile-light-chromium-linux.png b/e2e/visual.spec.ts-snapshots/bookings-mobile-light-chromium-linux.png index 159324df..c61039d7 100644 Binary files a/e2e/visual.spec.ts-snapshots/bookings-mobile-light-chromium-linux.png and b/e2e/visual.spec.ts-snapshots/bookings-mobile-light-chromium-linux.png differ diff --git a/e2e/visual.spec.ts-snapshots/calendar-desktop-dark-chromium-linux.png b/e2e/visual.spec.ts-snapshots/calendar-desktop-dark-chromium-linux.png new file mode 100644 index 00000000..18d2b874 Binary files /dev/null and b/e2e/visual.spec.ts-snapshots/calendar-desktop-dark-chromium-linux.png differ diff --git a/e2e/visual.spec.ts-snapshots/calendar-desktop-light-chromium-linux.png b/e2e/visual.spec.ts-snapshots/calendar-desktop-light-chromium-linux.png new file mode 100644 index 00000000..aa0ab41a Binary files /dev/null and b/e2e/visual.spec.ts-snapshots/calendar-desktop-light-chromium-linux.png differ diff --git a/e2e/visual.spec.ts-snapshots/calendar-mobile-dark-chromium-linux.png b/e2e/visual.spec.ts-snapshots/calendar-mobile-dark-chromium-linux.png new file mode 100644 index 00000000..5d58d543 Binary files /dev/null and b/e2e/visual.spec.ts-snapshots/calendar-mobile-dark-chromium-linux.png differ diff --git a/e2e/visual.spec.ts-snapshots/calendar-mobile-light-chromium-linux.png b/e2e/visual.spec.ts-snapshots/calendar-mobile-light-chromium-linux.png new file mode 100644 index 00000000..1efd0aaa Binary files /dev/null and b/e2e/visual.spec.ts-snapshots/calendar-mobile-light-chromium-linux.png differ diff --git a/e2e/visual.spec.ts-snapshots/credits-desktop-dark-chromium-linux.png b/e2e/visual.spec.ts-snapshots/credits-desktop-dark-chromium-linux.png new file mode 100644 index 00000000..c14a5f53 Binary files /dev/null and b/e2e/visual.spec.ts-snapshots/credits-desktop-dark-chromium-linux.png differ diff --git a/e2e/visual.spec.ts-snapshots/credits-desktop-light-chromium-linux.png b/e2e/visual.spec.ts-snapshots/credits-desktop-light-chromium-linux.png new file mode 100644 index 00000000..e94efbc8 Binary files /dev/null and b/e2e/visual.spec.ts-snapshots/credits-desktop-light-chromium-linux.png differ diff --git a/e2e/visual.spec.ts-snapshots/credits-mobile-dark-chromium-linux.png b/e2e/visual.spec.ts-snapshots/credits-mobile-dark-chromium-linux.png new file mode 100644 index 00000000..04e6878a Binary files /dev/null and b/e2e/visual.spec.ts-snapshots/credits-mobile-dark-chromium-linux.png differ diff --git a/e2e/visual.spec.ts-snapshots/credits-mobile-light-chromium-linux.png b/e2e/visual.spec.ts-snapshots/credits-mobile-light-chromium-linux.png new file mode 100644 index 00000000..08d48db4 Binary files /dev/null and b/e2e/visual.spec.ts-snapshots/credits-mobile-light-chromium-linux.png differ diff --git a/e2e/visual.spec.ts-snapshots/dashboard-desktop-dark-chromium-linux.png b/e2e/visual.spec.ts-snapshots/dashboard-desktop-dark-chromium-linux.png index 53a36025..01f06315 100644 Binary files a/e2e/visual.spec.ts-snapshots/dashboard-desktop-dark-chromium-linux.png and b/e2e/visual.spec.ts-snapshots/dashboard-desktop-dark-chromium-linux.png differ diff --git a/e2e/visual.spec.ts-snapshots/dashboard-desktop-light-chromium-linux.png b/e2e/visual.spec.ts-snapshots/dashboard-desktop-light-chromium-linux.png index b43d4a9d..f5dac22b 100644 Binary files a/e2e/visual.spec.ts-snapshots/dashboard-desktop-light-chromium-linux.png and b/e2e/visual.spec.ts-snapshots/dashboard-desktop-light-chromium-linux.png differ diff --git a/e2e/visual.spec.ts-snapshots/dashboard-mobile-dark-chromium-linux.png b/e2e/visual.spec.ts-snapshots/dashboard-mobile-dark-chromium-linux.png index d76967bc..b175326c 100644 Binary files a/e2e/visual.spec.ts-snapshots/dashboard-mobile-dark-chromium-linux.png and b/e2e/visual.spec.ts-snapshots/dashboard-mobile-dark-chromium-linux.png differ diff --git a/e2e/visual.spec.ts-snapshots/dashboard-mobile-light-chromium-linux.png b/e2e/visual.spec.ts-snapshots/dashboard-mobile-light-chromium-linux.png index 1b5a3b79..da049868 100644 Binary files a/e2e/visual.spec.ts-snapshots/dashboard-mobile-light-chromium-linux.png and b/e2e/visual.spec.ts-snapshots/dashboard-mobile-light-chromium-linux.png differ diff --git a/e2e/visual.spec.ts-snapshots/favorites-desktop-dark-chromium-linux.png b/e2e/visual.spec.ts-snapshots/favorites-desktop-dark-chromium-linux.png new file mode 100644 index 00000000..2b57d16f Binary files /dev/null and b/e2e/visual.spec.ts-snapshots/favorites-desktop-dark-chromium-linux.png differ diff --git a/e2e/visual.spec.ts-snapshots/favorites-desktop-light-chromium-linux.png b/e2e/visual.spec.ts-snapshots/favorites-desktop-light-chromium-linux.png new file mode 100644 index 00000000..99a37c59 Binary files /dev/null and b/e2e/visual.spec.ts-snapshots/favorites-desktop-light-chromium-linux.png differ diff --git a/e2e/visual.spec.ts-snapshots/favorites-mobile-dark-chromium-linux.png b/e2e/visual.spec.ts-snapshots/favorites-mobile-dark-chromium-linux.png new file mode 100644 index 00000000..863a7085 Binary files /dev/null and b/e2e/visual.spec.ts-snapshots/favorites-mobile-dark-chromium-linux.png differ diff --git a/e2e/visual.spec.ts-snapshots/favorites-mobile-light-chromium-linux.png b/e2e/visual.spec.ts-snapshots/favorites-mobile-light-chromium-linux.png new file mode 100644 index 00000000..6c737f08 Binary files /dev/null and b/e2e/visual.spec.ts-snapshots/favorites-mobile-light-chromium-linux.png differ diff --git a/e2e/visual.spec.ts-snapshots/forgot-password-desktop-dark-chromium-linux.png b/e2e/visual.spec.ts-snapshots/forgot-password-desktop-dark-chromium-linux.png new file mode 100644 index 00000000..5950b5a6 Binary files /dev/null and b/e2e/visual.spec.ts-snapshots/forgot-password-desktop-dark-chromium-linux.png differ diff --git a/e2e/visual.spec.ts-snapshots/forgot-password-desktop-light-chromium-linux.png b/e2e/visual.spec.ts-snapshots/forgot-password-desktop-light-chromium-linux.png new file mode 100644 index 00000000..3150f5a9 Binary files /dev/null and b/e2e/visual.spec.ts-snapshots/forgot-password-desktop-light-chromium-linux.png differ diff --git a/e2e/visual.spec.ts-snapshots/forgot-password-mobile-dark-chromium-linux.png b/e2e/visual.spec.ts-snapshots/forgot-password-mobile-dark-chromium-linux.png new file mode 100644 index 00000000..d07526c8 Binary files /dev/null and b/e2e/visual.spec.ts-snapshots/forgot-password-mobile-dark-chromium-linux.png differ diff --git a/e2e/visual.spec.ts-snapshots/forgot-password-mobile-light-chromium-linux.png b/e2e/visual.spec.ts-snapshots/forgot-password-mobile-light-chromium-linux.png new file mode 100644 index 00000000..7ab066fa Binary files /dev/null and b/e2e/visual.spec.ts-snapshots/forgot-password-mobile-light-chromium-linux.png differ diff --git a/e2e/visual.spec.ts-snapshots/login-desktop-dark-chromium-linux.png b/e2e/visual.spec.ts-snapshots/login-desktop-dark-chromium-linux.png index c64ba998..3fc5e70b 100644 Binary files a/e2e/visual.spec.ts-snapshots/login-desktop-dark-chromium-linux.png and b/e2e/visual.spec.ts-snapshots/login-desktop-dark-chromium-linux.png differ diff --git a/e2e/visual.spec.ts-snapshots/login-desktop-light-chromium-linux.png b/e2e/visual.spec.ts-snapshots/login-desktop-light-chromium-linux.png index 0ca444bf..3a04dd54 100644 Binary files a/e2e/visual.spec.ts-snapshots/login-desktop-light-chromium-linux.png and b/e2e/visual.spec.ts-snapshots/login-desktop-light-chromium-linux.png differ diff --git a/e2e/visual.spec.ts-snapshots/login-mobile-dark-chromium-linux.png b/e2e/visual.spec.ts-snapshots/login-mobile-dark-chromium-linux.png index c1918c13..f4405a07 100644 Binary files a/e2e/visual.spec.ts-snapshots/login-mobile-dark-chromium-linux.png and b/e2e/visual.spec.ts-snapshots/login-mobile-dark-chromium-linux.png differ diff --git a/e2e/visual.spec.ts-snapshots/login-mobile-light-chromium-linux.png b/e2e/visual.spec.ts-snapshots/login-mobile-light-chromium-linux.png index 8ffc9bbd..3e9e2072 100644 Binary files a/e2e/visual.spec.ts-snapshots/login-mobile-light-chromium-linux.png and b/e2e/visual.spec.ts-snapshots/login-mobile-light-chromium-linux.png differ diff --git a/e2e/visual.spec.ts-snapshots/notifications-desktop-dark-chromium-linux.png b/e2e/visual.spec.ts-snapshots/notifications-desktop-dark-chromium-linux.png new file mode 100644 index 00000000..796cf3ad Binary files /dev/null and b/e2e/visual.spec.ts-snapshots/notifications-desktop-dark-chromium-linux.png differ diff --git a/e2e/visual.spec.ts-snapshots/notifications-desktop-light-chromium-linux.png b/e2e/visual.spec.ts-snapshots/notifications-desktop-light-chromium-linux.png new file mode 100644 index 00000000..31acc15a Binary files /dev/null and b/e2e/visual.spec.ts-snapshots/notifications-desktop-light-chromium-linux.png differ diff --git a/e2e/visual.spec.ts-snapshots/notifications-mobile-dark-chromium-linux.png b/e2e/visual.spec.ts-snapshots/notifications-mobile-dark-chromium-linux.png new file mode 100644 index 00000000..59593de9 Binary files /dev/null and b/e2e/visual.spec.ts-snapshots/notifications-mobile-dark-chromium-linux.png differ diff --git a/e2e/visual.spec.ts-snapshots/notifications-mobile-light-chromium-linux.png b/e2e/visual.spec.ts-snapshots/notifications-mobile-light-chromium-linux.png new file mode 100644 index 00000000..0e3b7a27 Binary files /dev/null and b/e2e/visual.spec.ts-snapshots/notifications-mobile-light-chromium-linux.png differ diff --git a/e2e/visual.spec.ts-snapshots/profile-desktop-dark-chromium-linux.png b/e2e/visual.spec.ts-snapshots/profile-desktop-dark-chromium-linux.png new file mode 100644 index 00000000..f58e5e7b Binary files /dev/null and b/e2e/visual.spec.ts-snapshots/profile-desktop-dark-chromium-linux.png differ diff --git a/e2e/visual.spec.ts-snapshots/profile-desktop-light-chromium-linux.png b/e2e/visual.spec.ts-snapshots/profile-desktop-light-chromium-linux.png new file mode 100644 index 00000000..62769b3e Binary files /dev/null and b/e2e/visual.spec.ts-snapshots/profile-desktop-light-chromium-linux.png differ diff --git a/e2e/visual.spec.ts-snapshots/profile-mobile-dark-chromium-linux.png b/e2e/visual.spec.ts-snapshots/profile-mobile-dark-chromium-linux.png new file mode 100644 index 00000000..4bd64caf Binary files /dev/null and b/e2e/visual.spec.ts-snapshots/profile-mobile-dark-chromium-linux.png differ diff --git a/e2e/visual.spec.ts-snapshots/profile-mobile-light-chromium-linux.png b/e2e/visual.spec.ts-snapshots/profile-mobile-light-chromium-linux.png new file mode 100644 index 00000000..983cf59d Binary files /dev/null and b/e2e/visual.spec.ts-snapshots/profile-mobile-light-chromium-linux.png differ diff --git a/e2e/visual.spec.ts-snapshots/register-desktop-dark-chromium-linux.png b/e2e/visual.spec.ts-snapshots/register-desktop-dark-chromium-linux.png new file mode 100644 index 00000000..d911515c Binary files /dev/null and b/e2e/visual.spec.ts-snapshots/register-desktop-dark-chromium-linux.png differ diff --git a/e2e/visual.spec.ts-snapshots/register-desktop-light-chromium-linux.png b/e2e/visual.spec.ts-snapshots/register-desktop-light-chromium-linux.png new file mode 100644 index 00000000..7af3f5a1 Binary files /dev/null and b/e2e/visual.spec.ts-snapshots/register-desktop-light-chromium-linux.png differ diff --git a/e2e/visual.spec.ts-snapshots/register-mobile-dark-chromium-linux.png b/e2e/visual.spec.ts-snapshots/register-mobile-dark-chromium-linux.png new file mode 100644 index 00000000..fa6def13 Binary files /dev/null and b/e2e/visual.spec.ts-snapshots/register-mobile-dark-chromium-linux.png differ diff --git a/e2e/visual.spec.ts-snapshots/register-mobile-light-chromium-linux.png b/e2e/visual.spec.ts-snapshots/register-mobile-light-chromium-linux.png new file mode 100644 index 00000000..feb7d948 Binary files /dev/null and b/e2e/visual.spec.ts-snapshots/register-mobile-light-chromium-linux.png differ diff --git a/e2e/visual.spec.ts-snapshots/vehicles-desktop-dark-chromium-linux.png b/e2e/visual.spec.ts-snapshots/vehicles-desktop-dark-chromium-linux.png index f4ce00ad..42eb6345 100644 Binary files a/e2e/visual.spec.ts-snapshots/vehicles-desktop-dark-chromium-linux.png and b/e2e/visual.spec.ts-snapshots/vehicles-desktop-dark-chromium-linux.png differ diff --git a/e2e/visual.spec.ts-snapshots/vehicles-desktop-light-chromium-linux.png b/e2e/visual.spec.ts-snapshots/vehicles-desktop-light-chromium-linux.png index c8186b3f..bbd51120 100644 Binary files a/e2e/visual.spec.ts-snapshots/vehicles-desktop-light-chromium-linux.png and b/e2e/visual.spec.ts-snapshots/vehicles-desktop-light-chromium-linux.png differ diff --git a/e2e/visual.spec.ts-snapshots/vehicles-mobile-dark-chromium-linux.png b/e2e/visual.spec.ts-snapshots/vehicles-mobile-dark-chromium-linux.png index 31561977..0981ff17 100644 Binary files a/e2e/visual.spec.ts-snapshots/vehicles-mobile-dark-chromium-linux.png and b/e2e/visual.spec.ts-snapshots/vehicles-mobile-dark-chromium-linux.png differ diff --git a/e2e/visual.spec.ts-snapshots/vehicles-mobile-light-chromium-linux.png b/e2e/visual.spec.ts-snapshots/vehicles-mobile-light-chromium-linux.png index b7c34d99..d6f25a35 100644 Binary files a/e2e/visual.spec.ts-snapshots/vehicles-mobile-light-chromium-linux.png and b/e2e/visual.spec.ts-snapshots/vehicles-mobile-light-chromium-linux.png differ diff --git a/e2e/visual.spec.ts-snapshots/welcome-desktop-dark-chromium-linux.png b/e2e/visual.spec.ts-snapshots/welcome-desktop-dark-chromium-linux.png new file mode 100644 index 00000000..372ee8ce Binary files /dev/null and b/e2e/visual.spec.ts-snapshots/welcome-desktop-dark-chromium-linux.png differ diff --git a/e2e/visual.spec.ts-snapshots/welcome-desktop-light-chromium-linux.png b/e2e/visual.spec.ts-snapshots/welcome-desktop-light-chromium-linux.png new file mode 100644 index 00000000..1fcbb35b Binary files /dev/null and b/e2e/visual.spec.ts-snapshots/welcome-desktop-light-chromium-linux.png differ diff --git a/e2e/visual.spec.ts-snapshots/welcome-mobile-dark-chromium-linux.png b/e2e/visual.spec.ts-snapshots/welcome-mobile-dark-chromium-linux.png new file mode 100644 index 00000000..090b155c Binary files /dev/null and b/e2e/visual.spec.ts-snapshots/welcome-mobile-dark-chromium-linux.png differ diff --git a/e2e/visual.spec.ts-snapshots/welcome-mobile-light-chromium-linux.png b/e2e/visual.spec.ts-snapshots/welcome-mobile-light-chromium-linux.png new file mode 100644 index 00000000..8b12c178 Binary files /dev/null and b/e2e/visual.spec.ts-snapshots/welcome-mobile-light-chromium-linux.png differ diff --git a/parkhub-web/src/views/SetupWizard.tsx b/parkhub-web/src/views/SetupWizard.tsx index abefdb94..28c04646 100644 --- a/parkhub-web/src/views/SetupWizard.tsx +++ b/parkhub-web/src/views/SetupWizard.tsx @@ -184,28 +184,38 @@ export function SetupWizardPage() { />
-
-
diff --git a/tests/Feature/PushNotificationDispatchTest.php b/tests/Feature/PushNotificationDispatchTest.php new file mode 100644 index 00000000..26f07653 --- /dev/null +++ b/tests/Feature/PushNotificationDispatchTest.php @@ -0,0 +1,90 @@ +flush() as $report)`, and minishlink/web-push's + * `flush()` is `yield $promise->wait()` with a 30-second default timeout + * per endpoint. + * + * There are ten `Notification::create` call sites in request handlers. + * `BookingController::destroy` alone creates up to three in a loop, so one + * cancellation could pin a PHP-FPM worker for 3 x subscriptions x 30s. The + * project ships `deploy-shared-hosting.sh`; on a shared-hosting worker pool + * that is a self-inflicted denial of service. + */ +class PushNotificationDispatchTest extends TestCase +{ + use RefreshDatabase; + + public function test_creating_a_notification_queues_the_push_instead_of_sending_it(): void + { + Queue::fake(); + $user = User::factory()->create(); + + Notification::create([ + 'user_id' => $user->id, + 'title' => 'Booking cancelled', + 'message' => 'Your slot was released.', + 'type' => 'booking', + ]); + + Queue::assertPushed(SendPushNotificationJob::class, 1); + } + + public function test_the_queued_job_carries_what_the_push_needs(): void + { + Queue::fake(); + $user = User::factory()->create(); + + Notification::create([ + 'user_id' => $user->id, + 'title' => 'Slot free', + 'message' => 'A slot opened up.', + 'type' => 'waitlist', + ]); + + Queue::assertPushed( + SendPushNotificationJob::class, + fn (SendPushNotificationJob $job) => $job->userId === $user->id + && $job->title === 'Slot free' + && $job->body === 'A slot opened up.', + ); + } + + /** + * Several notifications in one request must not become several blocking + * calls — this is the shape that pins a worker. + */ + public function test_a_burst_of_notifications_queues_one_job_each(): void + { + Queue::fake(); + $users = User::factory()->count(3)->create(); + + foreach ($users as $user) { + Notification::create([ + 'user_id' => $user->id, + 'title' => 'Slot free', + 'message' => 'A slot opened up.', + 'type' => 'waitlist', + ]); + } + + Queue::assertPushed(SendPushNotificationJob::class, 3); + } +}