diff --git a/resources/js/admin/mobile/components/__tests__/MobileShell.test.tsx b/resources/js/admin/mobile/components/__tests__/MobileShell.test.tsx
new file mode 100644
index 0000000..e651285
--- /dev/null
+++ b/resources/js/admin/mobile/components/__tests__/MobileShell.test.tsx
@@ -0,0 +1,143 @@
+import React from 'react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { act, render, screen } from '@testing-library/react';
+import { MemoryRouter } from 'react-router-dom';
+
+vi.mock('react-i18next', () => ({
+ useTranslation: () => ({ t: (key: string, fallback?: string) => fallback ?? key, i18n: { language: 'en-GB' } }),
+}));
+
+vi.mock('@tamagui/core', () => ({
+ useTheme: () => ({
+ background: { val: '#FFF8F5' },
+ surface: { val: '#ffffff' },
+ borderColor: { val: '#e5e7eb' },
+ color: { val: '#1f2937' },
+ gray: { val: '#6b7280' },
+ red10: { val: '#b91c1c' },
+ shadowColor: { val: 'rgba(0,0,0,0.12)' },
+ primary: { val: '#FF5A5F' },
+ }),
+}));
+
+vi.mock('@tamagui/stacks', () => ({
+ YStack: ({ children, ...props }: { children: React.ReactNode }) =>
{children}
,
+ XStack: ({ children, ...props }: { children: React.ReactNode }) => {children}
,
+}));
+
+vi.mock('@tamagui/text', () => ({
+ SizableText: ({ children, ...props }: { children: React.ReactNode }) => {children},
+}));
+
+vi.mock('@tamagui/react-native-web-lite', () => ({
+ Pressable: ({ children, onPress, ...props }: { children: React.ReactNode; onPress?: () => void }) => (
+
+ ),
+}));
+
+vi.mock('../BottomNav', () => ({
+ BottomNav: () => ,
+ NavKey: {},
+}));
+
+vi.mock('../../../context/EventContext', () => ({
+ useEventContext: () => ({
+ events: [],
+ activeEvent: { slug: 'event-1', name: 'Test Event', event_date: '2024-01-01', status: 'active', settings: {} },
+ hasMultipleEvents: false,
+ hasEvents: true,
+ selectEvent: vi.fn(),
+ }),
+}));
+
+vi.mock('../../hooks/useMobileNav', () => ({
+ useMobileNav: () => ({ go: vi.fn(), slug: 'event-1' }),
+}));
+
+vi.mock('../../hooks/useNotificationsBadge', () => ({
+ useNotificationsBadge: () => ({ count: 0 }),
+}));
+
+vi.mock('../../hooks/useOnlineStatus', () => ({
+ useOnlineStatus: () => true,
+}));
+
+vi.mock('../../../api', () => ({
+ getEvents: vi.fn().mockResolvedValue([]),
+}));
+
+vi.mock('../../lib/tabHistory', () => ({
+ setTabHistory: vi.fn(),
+}));
+
+vi.mock('../../lib/photoModerationQueue', () => ({
+ loadPhotoQueue: vi.fn(() => []),
+}));
+
+vi.mock('../../lib/queueStatus', () => ({
+ countQueuedPhotoActions: vi.fn(() => 0),
+}));
+
+vi.mock('../../theme', () => ({
+ useAdminTheme: () => ({
+ background: '#FFF8F5',
+ surface: '#ffffff',
+ border: '#e5e7eb',
+ text: '#1f2937',
+ muted: '#6b7280',
+ warningBg: '#fff7ed',
+ warningText: '#92400e',
+ primary: '#FF5A5F',
+ danger: '#b91c1c',
+ shadow: 'rgba(0,0,0,0.12)',
+ }),
+}));
+
+import { MobileShell } from '../MobileShell';
+
+describe('MobileShell', () => {
+ beforeEach(() => {
+ window.matchMedia = vi.fn().mockReturnValue({
+ matches: false,
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ });
+ });
+
+ it('renders quick QR as icon-only button', async () => {
+ await act(async () => {
+ render(
+
+
+ Body
+
+
+ );
+ });
+
+ expect(screen.getByLabelText('Quick QR')).toBeInTheDocument();
+ expect(screen.queryByText('Quick QR')).not.toBeInTheDocument();
+ });
+
+ it('hides the event context on compact headers', async () => {
+ window.matchMedia = vi.fn().mockReturnValue({
+ matches: true,
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ });
+
+ await act(async () => {
+ render(
+
+
+ Body
+
+
+ );
+ });
+
+ expect(screen.queryByText('Test Event')).not.toBeInTheDocument();
+ });
+});
diff --git a/tests/Unit/RateLimitConfigTest.php b/tests/Unit/RateLimitConfigTest.php
new file mode 100644
index 0000000..5f4a14d
--- /dev/null
+++ b/tests/Unit/RateLimitConfigTest.php
@@ -0,0 +1,67 @@
+ '10.0.0.1',
+ ]);
+ $request->attributes->set('tenant_id', 42);
+
+ $limiter = RateLimiter::limiter('tenant-api');
+
+ $this->assertNotNull($limiter);
+
+ $limit = $limiter($request);
+
+ $this->assertInstanceOf(Limit::class, $limit);
+ $this->assertSame(600, $limit->maxAttempts);
+ }
+
+ public function test_guest_api_rate_limiter_allows_higher_throughput(): void
+ {
+ $request = Request::create('/api/v1/events/sample', 'GET', [], [], [], [
+ 'REMOTE_ADDR' => '10.0.0.2',
+ ]);
+
+ $limiter = RateLimiter::limiter('guest-api');
+
+ $this->assertNotNull($limiter);
+
+ $limit = $limiter($request);
+
+ $this->assertInstanceOf(Limit::class, $limit);
+ $this->assertSame(300, $limit->maxAttempts);
+ }
+
+ public function test_guest_policy_defaults_follow_join_token_limits(): void
+ {
+ $accessLimit = 300;
+ $downloadLimit = 120;
+
+ config([
+ 'join_tokens.access_limit' => $accessLimit,
+ 'join_tokens.download_limit' => $downloadLimit,
+ ]);
+
+ GuestPolicySetting::query()->delete();
+ GuestPolicySetting::flushCache();
+
+ $settings = GuestPolicySetting::current();
+
+ $this->assertSame($accessLimit, $settings->join_token_access_limit);
+ $this->assertSame($downloadLimit, $settings->join_token_download_limit);
+ }
+}