/** * Comprehensive Scanner Tests * Tests offline functionality, background sync, and conflict resolution */ import { test, expect } from '@playwright/test'; test.describe('Scanner Offline Functionality', () => { const testEventId = 'evt-1'; const testQRCode = 'TICKET_123456'; test.beforeEach(async ({ page, context }) => { // Grant camera permissions for testing await context.grantPermissions(['camera']); // Login as staff user who has scan:tickets permission await page.goto('/login'); await page.fill('[name="email"]', 'staff@example.com'); await page.fill('[name="password"]', 'password'); await page.click('button[type="submit"]'); await page.waitForURL('/dashboard'); }); test('should display scanner page with event ID', async ({ page }) => { // Navigate to scanner with event ID await page.goto(`/scan?eventId=${testEventId}`); // Should show scanner interface await expect(page.locator('h1:has-text("Ticket Scanner")')).toBeVisible(); await expect(page.locator('text=Device ยท No Zone Set')).toBeVisible(); // Should show camera view await expect(page.locator('video')).toBeVisible(); // Should show scanner frame overlay await expect(page.locator('.border-primary-500')).toBeVisible(); }); test('should reject access without event ID', async ({ page }) => { await page.goto('/scan'); // Should show error message await expect(page.locator('text=Event ID Required')).toBeVisible(); await expect(page.locator('text=Please access the scanner with a valid event ID parameter')).toBeVisible(); }); test('should show online status and stats', async ({ page }) => { await page.goto(`/scan?eventId=${testEventId}`); // Should show online badge await expect(page.locator('text=Online')).toBeVisible(); // Should show stats await expect(page.locator('text=Total:')).toBeVisible(); await expect(page.locator('text=Pending:')).toBeVisible(); }); test('should open and close settings panel', async ({ page }) => { await page.goto(`/scan?eventId=${testEventId}`); // Click settings button await page.click('button:has([data-testid="settings-icon"]), button:has(.lucide-settings)'); // Should show settings panel await expect(page.locator('text=Gate/Zone')).toBeVisible(); await expect(page.locator('text=Optimistic Accept')).toBeVisible(); // Test zone setting await page.fill('input[placeholder*="Gate"]', 'Gate A'); await expect(page.locator('input[placeholder*="Gate"]')).toHaveValue('Gate A'); // Close settings panel await page.click('button:has([data-testid="settings-icon"]), button:has(.lucide-settings)'); await expect(page.locator('text=Gate/Zone')).not.toBeVisible(); }); test('should toggle optimistic accept setting', async ({ page }) => { await page.goto(`/scan?eventId=${testEventId}`); // Open settings await page.click('button:has([data-testid="settings-icon"]), button:has(.lucide-settings)'); // Find the toggle button (it's a button with inline-flex class) const toggle = page.locator('button.inline-flex').filter({ hasText: '' }); // Get initial state by checking if bg-primary-500 class is present const isInitiallyOn = await toggle.evaluate(el => el.classList.contains('bg-primary-500')); // Click toggle await toggle.click(); // Verify state changed const isAfterClickOn = await toggle.evaluate(el => el.classList.contains('bg-primary-500')); expect(isAfterClickOn).toBe(!isInitiallyOn); }); test('should handle torch toggle when supported', async ({ page }) => { await page.goto(`/scan?eventId=${testEventId}`); // Look for torch button (only visible if torch is supported) const torchButton = page.locator('button:has([data-testid="flashlight-icon"]), button:has(.lucide-flashlight)'); // If torch is supported, test the toggle if (await torchButton.isVisible()) { await torchButton.click(); // Note: Actual torch functionality can't be tested in headless mode // but we can verify the button click doesn't cause errors } }); test('should simulate offline mode and queueing', async ({ page }) => { await page.goto(`/scan?eventId=${testEventId}`); // Simulate going offline await page.evaluate(() => { // Override navigator.onLine Object.defineProperty(navigator, 'onLine', { writable: true, value: false }); // Dispatch offline event window.dispatchEvent(new Event('offline')); }); // Wait for offline status to update await page.waitForTimeout(1000); // Should show offline badge await expect(page.locator('text=Offline')).toBeVisible(); // Simulate a scan by calling the scan handler directly await page.evaluate((qr) => { // Trigger scan event window.dispatchEvent(new CustomEvent('mock-scan', { detail: { qr } })); }, testQRCode); await page.waitForTimeout(500); // Should show pending sync count increased // (This test is limited since we can't actually scan QR codes in automated tests) }); test('should show scan result with success status', async ({ page }) => { await page.goto(`/scan?eventId=${testEventId}`); // Mock a successful scan result by triggering the UI update directly await page.evaluate(() => { // Simulate a successful scan result const event = new CustomEvent('mock-scan-result', { detail: { qr: 'TICKET_123456', status: 'success', message: 'Valid ticket - Entry allowed', timestamp: Date.now(), ticketInfo: { eventTitle: 'Test Event', ticketTypeName: 'General Admission', customerEmail: 'test@example.com' } } }); window.dispatchEvent(event); }); await page.waitForTimeout(500); // Verify we don't see error states (since this is a mock test environment) await expect(page.locator('text=Ticket Scanner')).toBeVisible(); }); test('should handle camera permission denied', async ({ page, context }) => { // Revoke camera permission await context.clearPermissions(); await page.goto(`/scan?eventId=${testEventId}`); await page.waitForTimeout(2000); // Should show permission denied message or retry button // Note: In a real test environment, you might see different behaviors // depending on how the camera permission is handled // Verify the page still loads without crashing await expect(page.locator('h1:has-text("Ticket Scanner")')).toBeVisible(); }); test('should display instructions', async ({ page }) => { await page.goto(`/scan?eventId=${testEventId}`); // Should show instructions card await expect(page.locator('text=Instructions')).toBeVisible(); await expect(page.locator('text=Position QR code within the scanning frame')).toBeVisible(); await expect(page.locator('text=Scans work offline and sync automatically')).toBeVisible(); }); test('should handle navigation away and back', async ({ page }) => { await page.goto(`/scan?eventId=${testEventId}`); // Verify scanner loads await expect(page.locator('h1:has-text("Ticket Scanner")')).toBeVisible(); // Navigate away await page.goto('/dashboard'); await expect(page.locator('h1:has-text("Dashboard")')).toBeVisible(); // Navigate back to scanner await page.goto(`/scan?eventId=${testEventId}`); await expect(page.locator('h1:has-text("Ticket Scanner")')).toBeVisible(); // Should reinitialize properly await expect(page.locator('video')).toBeVisible(); }); test('should be responsive on mobile viewport', async ({ page }) => { // Set mobile viewport await page.setViewportSize({ width: 375, height: 667 }); await page.goto(`/scan?eventId=${testEventId}`); // Should still show all essential elements await expect(page.locator('h1:has-text("Ticket Scanner")')).toBeVisible(); await expect(page.locator('video')).toBeVisible(); // Settings should still be accessible await page.click('button:has([data-testid="settings-icon"]), button:has(.lucide-settings)'); await expect(page.locator('text=Gate/Zone')).toBeVisible(); }); test('should maintain zone setting across page reloads', async ({ page }) => { await page.goto(`/scan?eventId=${testEventId}`); // Set zone await page.click('button:has([data-testid="settings-icon"]), button:has(.lucide-settings)'); await page.fill('input[placeholder*="Gate"]', 'Gate A'); // Reload page await page.reload(); // Zone should be preserved await page.click('button:has([data-testid="settings-icon"]), button:has(.lucide-settings)'); await expect(page.locator('input[placeholder*="Gate"]')).toHaveValue('Gate A'); }); test('should handle service worker registration', async ({ page }) => { await page.goto(`/scan?eventId=${testEventId}`); // Check that service worker is being registered const swRegistration = await page.evaluate(() => 'serviceWorker' in navigator); expect(swRegistration).toBe(true); // Verify PWA manifest is linked const manifestLink = await page.locator('link[rel="manifest"]').getAttribute('href'); expect(manifestLink).toBe('/manifest.json'); }); }); test.describe('Scanner Access Control', () => { test('should require authentication', async ({ page }) => { // Try to access scanner without login await page.goto('/scan?eventId=evt-1'); // Should redirect to login await page.waitForURL('/login'); await expect(page.locator('h1:has-text("Login")')).toBeVisible(); }); test('should require scan:tickets permission', async ({ page }) => { // Login as a user without scan permissions (simulate by modifying role) await page.goto('/login'); await page.fill('[name="email"]', 'customer@example.com'); // Non-existent user await page.fill('[name="password"]', 'password'); // This should either fail login or redirect to unauthorized await page.click('button[type="submit"]'); // Expect to either stay on login or go to error page const url = page.url(); expect(url).toMatch(/(login|unauthorized|error)/); }); test('should allow access for staff role', async ({ page }) => { await page.goto('/login'); await page.fill('[name="email"]', 'staff@example.com'); await page.fill('[name="password"]', 'password'); await page.click('button[type="submit"]'); await page.waitForURL('/dashboard'); // Navigate to scanner await page.goto('/scan?eventId=evt-1'); // Should have access await expect(page.locator('h1:has-text("Ticket Scanner")')).toBeVisible(); }); test('should allow access for admin role', async ({ page }) => { await page.goto('/login'); await page.fill('[name="email"]', 'admin@example.com'); await page.fill('[name="password"]', 'password'); await page.click('button[type="submit"]'); await page.waitForURL('/dashboard'); // Navigate to scanner await page.goto('/scan?eventId=evt-1'); // Should have access await expect(page.locator('h1:has-text("Ticket Scanner")')).toBeVisible(); }); });