fix(typescript): resolve build errors and improve type safety
- Fix billing components ConnectError type compatibility with exactOptionalPropertyTypes - Update Select component usage to match proper API (options vs children) - Remove unused imports and fix optional property assignments in system components - Resolve duplicate Order/Ticket type definitions and add null safety checks - Handle optional branding properties correctly in organization features - Add window property type declarations for test environment - Fix Playwright API usage (page.setOffline → page.context().setOffline) - Clean up unused imports, variables, and parameters across codebase - Add comprehensive global type declarations for test window extensions Resolves major TypeScript compilation issues and improves type safety throughout the application. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
607
reactrebuild0825/tests/offline-scenarios.spec.ts
Normal file
607
reactrebuild0825/tests/offline-scenarios.spec.ts
Normal file
@@ -0,0 +1,607 @@
|
||||
/**
|
||||
* Offline Scanning Scenarios - Comprehensive Offline Functionality Testing
|
||||
* Tests airplane mode simulation, intermittent connections, optimistic acceptance,
|
||||
* conflict resolution, and queue persistence for real gate operations
|
||||
*/
|
||||
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Airplane Mode Simulation', () => {
|
||||
const testEventId = 'evt-001';
|
||||
const testQRCodes = [
|
||||
'TICKET_OFFLINE_001',
|
||||
'TICKET_OFFLINE_002',
|
||||
'TICKET_OFFLINE_003',
|
||||
'TICKET_OFFLINE_004',
|
||||
'TICKET_OFFLINE_005'
|
||||
];
|
||||
|
||||
test.beforeEach(async ({ page, context }) => {
|
||||
await context.grantPermissions(['camera']);
|
||||
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=${testEventId}`);
|
||||
await expect(page.locator('h1:has-text("Ticket Scanner")')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should handle complete offline mode with queue accumulation', async ({ page }) => {
|
||||
// Enable optimistic accept for offline scanning
|
||||
await page.click('button:has([data-testid="settings-icon"]), button:has(.lucide-settings)');
|
||||
const toggle = page.locator('button.inline-flex').filter({ hasText: '' });
|
||||
|
||||
// Ensure optimistic accept is enabled
|
||||
const isOptimisticEnabled = await toggle.evaluate(el => el.classList.contains('bg-primary-500'));
|
||||
if (!isOptimisticEnabled) {
|
||||
await toggle.click();
|
||||
}
|
||||
|
||||
// Set zone for identification
|
||||
await page.fill('input[placeholder*="Gate"]', 'Gate A - Offline Test');
|
||||
|
||||
// Close settings
|
||||
await page.click('button:has([data-testid="settings-icon"]), button:has(.lucide-settings)');
|
||||
|
||||
// Verify initial online state
|
||||
await expect(page.locator('text=Online')).toBeVisible();
|
||||
|
||||
// Simulate complete network failure (airplane mode)
|
||||
await page.context().setOffline(true);
|
||||
|
||||
// Also simulate navigator.onLine false
|
||||
await page.evaluate(() => {
|
||||
Object.defineProperty(navigator, 'onLine', {
|
||||
writable: true,
|
||||
value: false
|
||||
});
|
||||
window.dispatchEvent(new Event('offline'));
|
||||
});
|
||||
|
||||
// Wait for offline status update
|
||||
await page.waitForTimeout(2000);
|
||||
await expect(page.locator('text=Offline')).toBeVisible();
|
||||
|
||||
// Simulate scanning 5 QR codes while offline
|
||||
for (let i = 0; i < testQRCodes.length; i++) {
|
||||
await page.evaluate((qr) => {
|
||||
// Simulate QR code scan
|
||||
window.dispatchEvent(new CustomEvent('mock-scan', {
|
||||
detail: { qr, timestamp: Date.now() }
|
||||
}));
|
||||
}, testQRCodes[i]);
|
||||
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
// Check pending sync counter increased
|
||||
const pendingText = await page.locator('text=Pending:').locator('..').textContent();
|
||||
expect(pendingText).toContain('5');
|
||||
|
||||
// Simulate network restoration
|
||||
await page.context().setOffline(false);
|
||||
await page.evaluate(() => {
|
||||
Object.defineProperty(navigator, 'onLine', {
|
||||
writable: true,
|
||||
value: true
|
||||
});
|
||||
window.dispatchEvent(new Event('online'));
|
||||
});
|
||||
|
||||
// Wait for reconnection and sync
|
||||
await page.waitForTimeout(3000);
|
||||
await expect(page.locator('text=Online')).toBeVisible();
|
||||
|
||||
// Pending count should decrease as items sync
|
||||
// Note: In a real implementation, this would show the sync progress
|
||||
await page.waitForTimeout(2000);
|
||||
});
|
||||
|
||||
test('should preserve queue across browser refresh while offline', async ({ page }) => {
|
||||
// Go offline first
|
||||
await page.context().setOffline(true);
|
||||
await page.evaluate(() => {
|
||||
Object.defineProperty(navigator, 'onLine', { writable: true, value: false });
|
||||
window.dispatchEvent(new Event('offline'));
|
||||
});
|
||||
|
||||
await expect(page.locator('text=Offline')).toBeVisible();
|
||||
|
||||
// Enable optimistic accept
|
||||
await page.click('button:has([data-testid="settings-icon"]), button:has(.lucide-settings)');
|
||||
const toggle = page.locator('button.inline-flex').filter({ hasText: '' });
|
||||
const isEnabled = await toggle.evaluate(el => el.classList.contains('bg-primary-500'));
|
||||
if (!isEnabled) {await toggle.click();}
|
||||
await page.click('button:has([data-testid="settings-icon"]), button:has(.lucide-settings)');
|
||||
|
||||
// Simulate scanning while offline
|
||||
await page.evaluate((qr) => {
|
||||
window.dispatchEvent(new CustomEvent('mock-scan', {
|
||||
detail: { qr, timestamp: Date.now() }
|
||||
}));
|
||||
}, 'TICKET_PERSIST_001');
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Refresh the page while offline
|
||||
await page.reload();
|
||||
await expect(page.locator('h1:has-text("Ticket Scanner")')).toBeVisible();
|
||||
|
||||
// Should still show offline status
|
||||
await expect(page.locator('text=Offline')).toBeVisible();
|
||||
|
||||
// Queue should be preserved (would check IndexedDB in real implementation)
|
||||
// For now, verify the UI is consistent
|
||||
const pendingElement = page.locator('text=Pending:').locator('..');
|
||||
await expect(pendingElement).toBeVisible();
|
||||
});
|
||||
|
||||
test('should handle optimistic acceptance UI feedback', async ({ page }) => {
|
||||
// Enable optimistic accept
|
||||
await page.click('button:has([data-testid="settings-icon"]), button:has(.lucide-settings)');
|
||||
const toggle = page.locator('button.inline-flex').filter({ hasText: '' });
|
||||
const isEnabled = await toggle.evaluate(el => el.classList.contains('bg-primary-500'));
|
||||
if (!isEnabled) {await toggle.click();}
|
||||
await page.click('button:has([data-testid="settings-icon"]), button:has(.lucide-settings)');
|
||||
|
||||
// Go offline
|
||||
await page.context().setOffline(true);
|
||||
await page.evaluate(() => {
|
||||
Object.defineProperty(navigator, 'onLine', { writable: true, value: false });
|
||||
window.dispatchEvent(new Event('offline'));
|
||||
});
|
||||
|
||||
await expect(page.locator('text=Offline')).toBeVisible();
|
||||
|
||||
// Simulate scan and check for optimistic UI feedback
|
||||
await page.evaluate(() => {
|
||||
window.dispatchEvent(new CustomEvent('mock-scan-result', {
|
||||
detail: {
|
||||
qr: 'TICKET_OPTIMISTIC_001',
|
||||
status: 'offline_success',
|
||||
message: 'Accepted offline - Will sync when online',
|
||||
timestamp: Date.now(),
|
||||
optimistic: true
|
||||
}
|
||||
}));
|
||||
});
|
||||
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Should show optimistic success feedback
|
||||
// (Implementation would show green flash or success indicator)
|
||||
await expect(page.locator('text=Offline')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Intermittent Connectivity', () => {
|
||||
const testEventId = 'evt-001';
|
||||
|
||||
test.beforeEach(async ({ page, context }) => {
|
||||
await context.grantPermissions(['camera']);
|
||||
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');
|
||||
await page.goto(`/scan?eventId=${testEventId}`);
|
||||
});
|
||||
|
||||
test('should handle flaky network with connect/disconnect cycles', async ({ page }) => {
|
||||
// Start online
|
||||
await expect(page.locator('text=Online')).toBeVisible();
|
||||
|
||||
// Simulate flaky network - multiple connect/disconnect cycles
|
||||
for (let cycle = 0; cycle < 3; cycle++) {
|
||||
// Go offline
|
||||
await page.context().setOffline(true);
|
||||
await page.evaluate(() => {
|
||||
Object.defineProperty(navigator, 'onLine', { writable: true, value: false });
|
||||
window.dispatchEvent(new Event('offline'));
|
||||
});
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
await expect(page.locator('text=Offline')).toBeVisible();
|
||||
|
||||
// Simulate scan during offline period
|
||||
await page.evaluate((qr) => {
|
||||
window.dispatchEvent(new CustomEvent('mock-scan', {
|
||||
detail: { qr: `TICKET_FLAKY_${cycle}`, timestamp: Date.now() }
|
||||
}));
|
||||
}, `TICKET_FLAKY_${cycle}`);
|
||||
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Come back online briefly
|
||||
await page.context().setOffline(false);
|
||||
await page.evaluate(() => {
|
||||
Object.defineProperty(navigator, 'onLine', { writable: true, value: true });
|
||||
window.dispatchEvent(new Event('online'));
|
||||
});
|
||||
|
||||
await page.waitForTimeout(1500);
|
||||
// May briefly show online before next disconnect
|
||||
}
|
||||
|
||||
// Should handle the instability gracefully
|
||||
await expect(page.locator('h1:has-text("Ticket Scanner")')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should retry failed requests during poor connectivity', async ({ page }) => {
|
||||
// Simulate poor connectivity with high failure rate
|
||||
let requestCount = 0;
|
||||
await page.route('**/api/scan/**', (route) => {
|
||||
requestCount++;
|
||||
if (requestCount <= 3) {
|
||||
// First 3 requests fail
|
||||
route.abort();
|
||||
} else {
|
||||
// 4th request succeeds
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
valid: true,
|
||||
message: 'Entry allowed',
|
||||
latencyMs: 2500
|
||||
})
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Simulate scan
|
||||
await page.evaluate(() => {
|
||||
window.dispatchEvent(new CustomEvent('mock-scan', {
|
||||
detail: { qr: 'TICKET_RETRY_001', timestamp: Date.now() }
|
||||
}));
|
||||
});
|
||||
|
||||
// Should eventually succeed after retries
|
||||
await page.waitForTimeout(5000);
|
||||
expect(requestCount).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
test('should show connection quality indicators', async ({ page }) => {
|
||||
// Mock slow network responses
|
||||
await page.route('**/*', (route) => {
|
||||
setTimeout(() => route.continue(), Math.random() * 1000 + 500);
|
||||
});
|
||||
|
||||
await page.reload();
|
||||
await expect(page.locator('h1:has-text("Ticket Scanner")')).toBeVisible();
|
||||
|
||||
// Should show online but potentially with latency indicators
|
||||
await expect(page.locator('text=Online')).toBeVisible();
|
||||
|
||||
// Could show latency warnings or connection quality
|
||||
// (Implementation dependent)
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Conflict Resolution', () => {
|
||||
const testEventId = 'evt-001';
|
||||
|
||||
test.beforeEach(async ({ page, context }) => {
|
||||
await context.grantPermissions(['camera']);
|
||||
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');
|
||||
await page.goto(`/scan?eventId=${testEventId}`);
|
||||
});
|
||||
|
||||
test('should handle offline success vs server already_scanned conflict', async ({ page }) => {
|
||||
// Enable optimistic accept
|
||||
await page.click('button:has([data-testid="settings-icon"]), button:has(.lucide-settings)');
|
||||
const toggle = page.locator('button.inline-flex').filter({ hasText: '' });
|
||||
const isEnabled = await toggle.evaluate(el => el.classList.contains('bg-primary-500'));
|
||||
if (!isEnabled) {await toggle.click();}
|
||||
await page.click('button:has([data-testid="settings-icon"]), button:has(.lucide-settings)');
|
||||
|
||||
// Go offline
|
||||
await page.context().setOffline(true);
|
||||
await page.evaluate(() => {
|
||||
Object.defineProperty(navigator, 'onLine', { writable: true, value: false });
|
||||
window.dispatchEvent(new Event('offline'));
|
||||
});
|
||||
|
||||
// Scan ticket offline (optimistically accepted)
|
||||
const conflictQR = 'TICKET_CONFLICT_001';
|
||||
await page.evaluate((qr) => {
|
||||
window.dispatchEvent(new CustomEvent('mock-scan-result', {
|
||||
detail: {
|
||||
qr,
|
||||
status: 'offline_success',
|
||||
message: 'Accepted offline',
|
||||
timestamp: Date.now(),
|
||||
optimistic: true
|
||||
}
|
||||
}));
|
||||
}, conflictQR);
|
||||
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Mock server response indicating already scanned when syncing
|
||||
await page.route('**/api/scan/**', (route) => {
|
||||
route.fulfill({
|
||||
status: 409,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
valid: false,
|
||||
reason: 'already_scanned',
|
||||
scannedAt: '2023-10-15T10:30:00Z',
|
||||
message: 'Ticket already scanned at 10:30 AM'
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
// Come back online
|
||||
await page.context().setOffline(false);
|
||||
await page.evaluate(() => {
|
||||
Object.defineProperty(navigator, 'onLine', { writable: true, value: true });
|
||||
window.dispatchEvent(new Event('online'));
|
||||
});
|
||||
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
// Should handle conflict gracefully
|
||||
await expect(page.locator('text=Online')).toBeVisible();
|
||||
|
||||
// Would show conflict resolution UI in real implementation
|
||||
});
|
||||
|
||||
test('should handle duplicate scan prevention', async ({ page }) => {
|
||||
const duplicateQR = 'TICKET_DUPLICATE_001';
|
||||
|
||||
// First scan - should succeed
|
||||
await page.evaluate((qr) => {
|
||||
window.dispatchEvent(new CustomEvent('mock-scan', {
|
||||
detail: { qr, timestamp: Date.now() }
|
||||
}));
|
||||
}, duplicateQR);
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Second scan of same QR within rate limit window - should be prevented
|
||||
await page.evaluate((qr) => {
|
||||
window.dispatchEvent(new CustomEvent('mock-scan', {
|
||||
detail: { qr, timestamp: Date.now() }
|
||||
}));
|
||||
}, duplicateQR);
|
||||
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Should prevent duplicate scan (implementation would show warning)
|
||||
await expect(page.locator('h1:has-text("Ticket Scanner")')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should resolve conflicts with administrator review', async ({ page }) => {
|
||||
// Simulate conflict scenario requiring admin intervention
|
||||
await page.evaluate(() => {
|
||||
window.dispatchEvent(new CustomEvent('conflict-detected', {
|
||||
detail: {
|
||||
qr: 'TICKET_ADMIN_CONFLICT_001',
|
||||
localResult: 'offline_success',
|
||||
serverResult: {
|
||||
valid: false,
|
||||
reason: 'already_scanned',
|
||||
scannedAt: '2023-10-15T09:45:00Z'
|
||||
},
|
||||
requiresReview: true
|
||||
}
|
||||
}));
|
||||
});
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Should show conflict indication
|
||||
// (Real implementation would show conflict modal or alert)
|
||||
await expect(page.locator('text=Online')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Queue Persistence and Sync', () => {
|
||||
const testEventId = 'evt-001';
|
||||
|
||||
test.beforeEach(async ({ page, context }) => {
|
||||
await context.grantPermissions(['camera']);
|
||||
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');
|
||||
await page.goto(`/scan?eventId=${testEventId}`);
|
||||
});
|
||||
|
||||
test('should persist queue through browser restart', async ({ page, context }) => {
|
||||
// Go offline and scan items
|
||||
await page.context().setOffline(true);
|
||||
await page.evaluate(() => {
|
||||
Object.defineProperty(navigator, 'onLine', { writable: true, value: false });
|
||||
window.dispatchEvent(new Event('offline'));
|
||||
});
|
||||
|
||||
// Enable optimistic scanning
|
||||
await page.click('button:has([data-testid="settings-icon"]), button:has(.lucide-settings)');
|
||||
const toggle = page.locator('button.inline-flex').filter({ hasText: '' });
|
||||
const isEnabled = await toggle.evaluate(el => el.classList.contains('bg-primary-500'));
|
||||
if (!isEnabled) {await toggle.click();}
|
||||
await page.click('button:has([data-testid="settings-icon"]), button:has(.lucide-settings)');
|
||||
|
||||
// Scan multiple items
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
await page.evaluate((qr) => {
|
||||
window.dispatchEvent(new CustomEvent('mock-scan', {
|
||||
detail: { qr, timestamp: Date.now() }
|
||||
}));
|
||||
}, `TICKET_PERSIST_${i}`);
|
||||
await page.waitForTimeout(300);
|
||||
}
|
||||
|
||||
// Close and reopen browser (simulate restart)
|
||||
await page.close();
|
||||
const newPage = await context.newPage();
|
||||
await newPage.goto('/login');
|
||||
await newPage.fill('[name="email"]', 'staff@example.com');
|
||||
await newPage.fill('[name="password"]', 'password');
|
||||
await newPage.click('button[type="submit"]');
|
||||
await newPage.waitForURL('/dashboard');
|
||||
|
||||
await newPage.goto(`/scan?eventId=${testEventId}`);
|
||||
await expect(newPage.locator('h1:has-text("Ticket Scanner")')).toBeVisible();
|
||||
|
||||
// Queue should be restored from IndexedDB
|
||||
// (Implementation would show pending items from storage)
|
||||
const pendingElement = newPage.locator('text=Pending:').locator('..');
|
||||
await expect(pendingElement).toBeVisible();
|
||||
});
|
||||
|
||||
test('should handle sync failure recovery', async ({ page }) => {
|
||||
// Create offline queue
|
||||
await page.context().setOffline(true);
|
||||
await page.evaluate(() => {
|
||||
Object.defineProperty(navigator, 'onLine', { writable: true, value: false });
|
||||
window.dispatchEvent(new Event('offline'));
|
||||
});
|
||||
|
||||
await page.evaluate(() => {
|
||||
window.dispatchEvent(new CustomEvent('mock-scan', {
|
||||
detail: { qr: 'TICKET_SYNC_FAIL_001', timestamp: Date.now() }
|
||||
}));
|
||||
});
|
||||
|
||||
// Mock sync failure
|
||||
await page.route('**/api/scan/**', (route) => {
|
||||
route.fulfill({
|
||||
status: 500,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ error: 'Server error' })
|
||||
});
|
||||
});
|
||||
|
||||
// Come back online (sync should fail)
|
||||
await page.context().setOffline(false);
|
||||
await page.evaluate(() => {
|
||||
Object.defineProperty(navigator, 'onLine', { writable: true, value: true });
|
||||
window.dispatchEvent(new Event('online'));
|
||||
});
|
||||
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
// Should show failed sync status
|
||||
await expect(page.locator('text=Online')).toBeVisible();
|
||||
|
||||
// Items should remain in queue for retry
|
||||
const pendingElement = page.locator('text=Pending:').locator('..');
|
||||
await expect(pendingElement).toBeVisible();
|
||||
|
||||
// Remove route to allow successful retry
|
||||
await page.unroute('**/api/scan/**');
|
||||
|
||||
// Trigger retry (would happen automatically in real implementation)
|
||||
await page.waitForTimeout(2000);
|
||||
});
|
||||
|
||||
test('should batch sync operations for efficiency', async ({ page }) => {
|
||||
// Create multiple offline scans
|
||||
await page.context().setOffline(true);
|
||||
await page.evaluate(() => {
|
||||
Object.defineProperty(navigator, 'onLine', { writable: true, value: false });
|
||||
});
|
||||
|
||||
// Enable optimistic scanning
|
||||
await page.click('button:has([data-testid="settings-icon"]), button:has(.lucide-settings)');
|
||||
const toggle = page.locator('button.inline-flex').filter({ hasText: '' });
|
||||
const isEnabled = await toggle.evaluate(el => el.classList.contains('bg-primary-500'));
|
||||
if (!isEnabled) {await toggle.click();}
|
||||
await page.click('button:has([data-testid="settings-icon"]), button:has(.lucide-settings)');
|
||||
|
||||
// Scan 10 items rapidly
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
await page.evaluate((qr) => {
|
||||
window.dispatchEvent(new CustomEvent('mock-scan', {
|
||||
detail: { qr, timestamp: Date.now() }
|
||||
}));
|
||||
}, `TICKET_BATCH_${i.toString().padStart(3, '0')}`);
|
||||
await page.waitForTimeout(100);
|
||||
}
|
||||
|
||||
let batchRequestCount = 0;
|
||||
await page.route('**/api/scan/batch', (route) => {
|
||||
batchRequestCount++;
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
processed: 10,
|
||||
successful: 10,
|
||||
failed: 0
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
// Come online and sync
|
||||
await page.context().setOffline(false);
|
||||
await page.evaluate(() => {
|
||||
Object.defineProperty(navigator, 'onLine', { writable: true, value: true });
|
||||
window.dispatchEvent(new Event('online'));
|
||||
});
|
||||
|
||||
await page.waitForTimeout(5000);
|
||||
|
||||
// Should use batch API for efficiency
|
||||
expect(batchRequestCount).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('should maintain scan order during sync', async ({ page }) => {
|
||||
// Create timestamped offline scans
|
||||
await page.context().setOffline(true);
|
||||
await page.evaluate(() => {
|
||||
Object.defineProperty(navigator, 'onLine', { writable: true, value: false });
|
||||
});
|
||||
|
||||
const scanTimes = [];
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
const scanTime = Date.now() + (i * 100);
|
||||
scanTimes.push(scanTime);
|
||||
|
||||
await page.evaluate((data) => {
|
||||
window.dispatchEvent(new CustomEvent('mock-scan', {
|
||||
detail: {
|
||||
qr: data.qr,
|
||||
timestamp: data.timestamp
|
||||
}
|
||||
}));
|
||||
}, { qr: `TICKET_ORDER_${i}`, timestamp: scanTime });
|
||||
|
||||
await page.waitForTimeout(150);
|
||||
}
|
||||
|
||||
// Verify sync maintains chronological order
|
||||
await page.route('**/api/scan/**', (route) => {
|
||||
const body = route.request().postData();
|
||||
if (body) {
|
||||
const data = JSON.parse(body);
|
||||
// Verify timestamp order is maintained
|
||||
expect(data.timestamp).toBeDefined();
|
||||
}
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ valid: true })
|
||||
});
|
||||
});
|
||||
|
||||
await page.context().setOffline(false);
|
||||
await page.evaluate(() => {
|
||||
Object.defineProperty(navigator, 'onLine', { writable: true, value: true });
|
||||
window.dispatchEvent(new Event('online'));
|
||||
});
|
||||
|
||||
await page.waitForTimeout(3000);
|
||||
});
|
||||
});
|
||||
933
reactrebuild0825/tests/real-world-gate.spec.ts
Normal file
933
reactrebuild0825/tests/real-world-gate.spec.ts
Normal file
@@ -0,0 +1,933 @@
|
||||
/**
|
||||
* Real-World Gate Scenarios - Advanced Field Testing
|
||||
* Tests network handoff, background/foreground transitions, multi-device racing,
|
||||
* rapid scanning rate limits, and QR code quality scenarios for actual gate operations
|
||||
*/
|
||||
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Network Handoff Scenarios', () => {
|
||||
const testEventId = 'evt-001';
|
||||
|
||||
test.beforeEach(async ({ page, context }) => {
|
||||
await page.setViewportSize({ width: 375, height: 667 }); // Mobile viewport
|
||||
await context.grantPermissions(['camera']);
|
||||
|
||||
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');
|
||||
await page.goto(`/scan?eventId=${testEventId}`);
|
||||
await expect(page.locator('h1:has-text("Ticket Scanner")')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should handle WiFi to cellular network transitions', async ({ page }) => {
|
||||
// Simulate starting on WiFi
|
||||
await expect(page.locator('text=Online')).toBeVisible();
|
||||
|
||||
// Set up network monitoring
|
||||
await page.addInitScript(() => {
|
||||
window.networkTransitionTest = {
|
||||
connectionChanges: 0,
|
||||
networkTypes: [],
|
||||
lastSyncTime: null
|
||||
};
|
||||
|
||||
// Monitor connection type changes
|
||||
if ('connection' in navigator) {
|
||||
const {connection} = navigator;
|
||||
window.networkTransitionTest.networkTypes.push(connection.effectiveType);
|
||||
|
||||
connection.addEventListener('change', () => {
|
||||
window.networkTransitionTest.connectionChanges++;
|
||||
window.networkTransitionTest.networkTypes.push(connection.effectiveType);
|
||||
});
|
||||
}
|
||||
|
||||
// Monitor online/offline events
|
||||
window.addEventListener('online', () => {
|
||||
window.networkTransitionTest.lastSyncTime = Date.now();
|
||||
});
|
||||
});
|
||||
|
||||
// Simulate network quality changes (WiFi -> cellular)
|
||||
await page.route('**/*', (route) => {
|
||||
// Simulate slower cellular connection
|
||||
setTimeout(() => route.continue(), Math.random() * 500 + 100);
|
||||
});
|
||||
|
||||
// Perform scanning during network transition
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await page.evaluate((qr) => {
|
||||
window.dispatchEvent(new CustomEvent('mock-scan', {
|
||||
detail: { qr, timestamp: Date.now() }
|
||||
}));
|
||||
}, `NETWORK_TRANSITION_${i}`);
|
||||
await page.waitForTimeout(300);
|
||||
}
|
||||
|
||||
// Remove network delay
|
||||
await page.unroute('**/*');
|
||||
|
||||
// Should remain online and functional
|
||||
await expect(page.locator('text=Online')).toBeVisible();
|
||||
|
||||
const networkData = await page.evaluate(() => window.networkTransitionTest);
|
||||
console.log('Network transition data:', networkData);
|
||||
|
||||
// Should handle network changes gracefully
|
||||
await expect(page.locator('h1:has-text("Ticket Scanner")')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should maintain sync during poor cellular conditions', async ({ page }) => {
|
||||
// Simulate poor cellular network conditions
|
||||
let requestCount = 0;
|
||||
await page.route('**/api/**', (route) => {
|
||||
requestCount++;
|
||||
|
||||
// Simulate cellular network issues
|
||||
if (Math.random() < 0.3) { // 30% failure rate
|
||||
route.abort();
|
||||
} else {
|
||||
// Slow but successful requests
|
||||
setTimeout(() => {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
valid: true,
|
||||
message: 'Entry allowed',
|
||||
latencyMs: 2500 + Math.random() * 1500
|
||||
})
|
||||
});
|
||||
}, 1000 + Math.random() * 2000);
|
||||
}
|
||||
});
|
||||
|
||||
// Enable optimistic scanning for poor network
|
||||
await page.click('button:has([data-testid="settings-icon"]), button:has(.lucide-settings)');
|
||||
const toggle = page.locator('button.inline-flex').filter({ hasText: '' });
|
||||
const isEnabled = await toggle.evaluate(el => el.classList.contains('bg-primary-500'));
|
||||
if (!isEnabled) {await toggle.click();}
|
||||
await page.click('button:has([data-testid="settings-icon"]), button:has(.lucide-settings)');
|
||||
|
||||
// Perform scanning under poor conditions
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await page.evaluate((qr) => {
|
||||
window.dispatchEvent(new CustomEvent('mock-scan', {
|
||||
detail: { qr, timestamp: Date.now() }
|
||||
}));
|
||||
}, `POOR_CELLULAR_${i}`);
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
await page.waitForTimeout(5000); // Allow retries to complete
|
||||
|
||||
console.log(`Total API requests made: ${requestCount}`);
|
||||
|
||||
// Should handle poor network gracefully
|
||||
await expect(page.locator('h1:has-text("Ticket Scanner")')).toBeVisible();
|
||||
expect(requestCount).toBeGreaterThan(5); // Should have made retry attempts
|
||||
});
|
||||
|
||||
test('should adapt to network quality changes', async ({ page }) => {
|
||||
// Monitor network adaptation behavior
|
||||
await page.addInitScript(() => {
|
||||
window.networkAdaptation = {
|
||||
qualityChanges: [],
|
||||
adaptationMade: false
|
||||
};
|
||||
|
||||
// Simulate network quality detection
|
||||
function detectNetworkQuality() {
|
||||
const startTime = performance.now();
|
||||
|
||||
fetch('/api/ping')
|
||||
.then(() => {
|
||||
const latency = performance.now() - startTime;
|
||||
window.networkAdaptation.qualityChanges.push({
|
||||
latency,
|
||||
timestamp: Date.now(),
|
||||
quality: latency < 100 ? 'fast' : latency < 500 ? 'medium' : 'slow'
|
||||
});
|
||||
|
||||
if (latency > 1000) {
|
||||
window.networkAdaptation.adaptationMade = true;
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
window.networkAdaptation.qualityChanges.push({
|
||||
latency: 999999,
|
||||
timestamp: Date.now(),
|
||||
quality: 'offline'
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Check network quality periodically
|
||||
setInterval(detectNetworkQuality, 2000);
|
||||
});
|
||||
|
||||
// Simulate varying network conditions
|
||||
let responseDelay = 100;
|
||||
await page.route('**/api/ping', (route) => {
|
||||
setTimeout(() => {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ pong: Date.now() })
|
||||
});
|
||||
}, responseDelay);
|
||||
|
||||
// Increase delay to simulate degrading network
|
||||
responseDelay += 200;
|
||||
});
|
||||
|
||||
await page.waitForTimeout(8000); // Let quality checks run
|
||||
|
||||
const adaptationData = await page.evaluate(() => window.networkAdaptation);
|
||||
console.log('Network adaptation data:', adaptationData);
|
||||
|
||||
// Should detect quality changes
|
||||
expect(adaptationData.qualityChanges.length).toBeGreaterThan(2);
|
||||
|
||||
// Scanner should remain functional
|
||||
await expect(page.locator('text=Online')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Background/Foreground Transitions', () => {
|
||||
const testEventId = 'evt-001';
|
||||
|
||||
test.beforeEach(async ({ page, context }) => {
|
||||
await page.setViewportSize({ width: 375, height: 667 });
|
||||
await context.grantPermissions(['camera']);
|
||||
|
||||
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');
|
||||
await page.goto(`/scan?eventId=${testEventId}`);
|
||||
});
|
||||
|
||||
test('should handle app going to background during scanning', async ({ page }) => {
|
||||
await expect(page.locator('video')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Set up app lifecycle monitoring
|
||||
await page.addInitScript(() => {
|
||||
window.appLifecycleTest = {
|
||||
visibilityChanges: 0,
|
||||
backgroundTime: 0,
|
||||
cameraRestored: false,
|
||||
scanningResumed: false
|
||||
};
|
||||
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
window.appLifecycleTest.visibilityChanges++;
|
||||
|
||||
if (document.visibilityState === 'hidden') {
|
||||
window.appLifecycleTest.backgroundTime = Date.now();
|
||||
} else if (document.visibilityState === 'visible') {
|
||||
if (window.appLifecycleTest.backgroundTime > 0) {
|
||||
const backgroundDuration = Date.now() - window.appLifecycleTest.backgroundTime;
|
||||
window.appLifecycleTest.backgroundTime = backgroundDuration;
|
||||
window.appLifecycleTest.cameraRestored = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Simulate scanning activity
|
||||
await page.evaluate(() => {
|
||||
window.dispatchEvent(new CustomEvent('mock-scan', {
|
||||
detail: { qr: 'BACKGROUND_TEST_001', timestamp: Date.now() }
|
||||
}));
|
||||
});
|
||||
|
||||
// Simulate app going to background
|
||||
await page.evaluate(() => {
|
||||
Object.defineProperty(document, 'visibilityState', {
|
||||
writable: true,
|
||||
value: 'hidden'
|
||||
});
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
});
|
||||
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Simulate returning to foreground
|
||||
await page.evaluate(() => {
|
||||
Object.defineProperty(document, 'visibilityState', {
|
||||
writable: true,
|
||||
value: 'visible'
|
||||
});
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
});
|
||||
|
||||
await page.waitForTimeout(3000); // Allow camera to reinitialize
|
||||
|
||||
// Camera should be restored
|
||||
await expect(page.locator('video')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Should be able to scan again
|
||||
await page.evaluate(() => {
|
||||
window.dispatchEvent(new CustomEvent('mock-scan', {
|
||||
detail: { qr: 'BACKGROUND_TEST_002', timestamp: Date.now() }
|
||||
}));
|
||||
});
|
||||
|
||||
const lifecycleData = await page.evaluate(() => window.appLifecycleTest);
|
||||
console.log('App lifecycle data:', lifecycleData);
|
||||
|
||||
expect(lifecycleData.visibilityChanges).toBeGreaterThanOrEqual(2);
|
||||
expect(lifecycleData.cameraRestored).toBe(true);
|
||||
});
|
||||
|
||||
test('should preserve scan queue during background transitions', async ({ page }) => {
|
||||
// Enable offline scanning
|
||||
await page.click('button:has([data-testid="settings-icon"]), button:has(.lucide-settings)');
|
||||
const toggle = page.locator('button.inline-flex').filter({ hasText: '' });
|
||||
const isEnabled = await toggle.evaluate(el => el.classList.contains('bg-primary-500'));
|
||||
if (!isEnabled) {await toggle.click();}
|
||||
await page.click('button:has([data-testid="settings-icon"]), button:has(.lucide-settings)');
|
||||
|
||||
// Go offline and scan items
|
||||
await page.context().setOffline(true);
|
||||
await page.evaluate(() => {
|
||||
Object.defineProperty(navigator, 'onLine', { writable: true, value: false });
|
||||
window.dispatchEvent(new Event('offline'));
|
||||
});
|
||||
|
||||
// Scan while offline
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await page.evaluate((qr) => {
|
||||
window.dispatchEvent(new CustomEvent('mock-scan', {
|
||||
detail: { qr, timestamp: Date.now() }
|
||||
}));
|
||||
}, `QUEUE_PRESERVE_${i}`);
|
||||
await page.waitForTimeout(300);
|
||||
}
|
||||
|
||||
// Go to background
|
||||
await page.evaluate(() => {
|
||||
Object.defineProperty(document, 'visibilityState', {
|
||||
writable: true,
|
||||
value: 'hidden'
|
||||
});
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
});
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Return to foreground
|
||||
await page.evaluate(() => {
|
||||
Object.defineProperty(document, 'visibilityState', {
|
||||
writable: true,
|
||||
value: 'visible'
|
||||
});
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
});
|
||||
|
||||
// Come back online
|
||||
await page.context().setOffline(false);
|
||||
await page.evaluate(() => {
|
||||
Object.defineProperty(navigator, 'onLine', { writable: true, value: true });
|
||||
window.dispatchEvent(new Event('online'));
|
||||
});
|
||||
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
// Queue should be preserved and sync
|
||||
const pendingElement = page.locator('text=Pending:').locator('..');
|
||||
await expect(pendingElement).toBeVisible();
|
||||
|
||||
// Should show online status
|
||||
await expect(page.locator('text=Online')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should handle wake lock across background transitions', async ({ page }) => {
|
||||
const wakeLockTest = await page.evaluate(async () => {
|
||||
if ('wakeLock' in navigator) {
|
||||
try {
|
||||
// Request wake lock
|
||||
const wakeLock = await navigator.wakeLock.request('screen');
|
||||
|
||||
const initialState = !wakeLock.released;
|
||||
|
||||
// Simulate going to background
|
||||
Object.defineProperty(document, 'visibilityState', {
|
||||
writable: true,
|
||||
value: 'hidden'
|
||||
});
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
const backgroundState = !wakeLock.released;
|
||||
|
||||
// Return to foreground
|
||||
Object.defineProperty(document, 'visibilityState', {
|
||||
writable: true,
|
||||
value: 'visible'
|
||||
});
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
// Try to re-request wake lock
|
||||
let newWakeLock = null;
|
||||
try {
|
||||
newWakeLock = await navigator.wakeLock.request('screen');
|
||||
} catch (e) {
|
||||
console.log('Wake lock re-request failed:', e.message);
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
if (!wakeLock.released) {wakeLock.release();}
|
||||
if (newWakeLock && !newWakeLock.released) {newWakeLock.release();}
|
||||
|
||||
return {
|
||||
supported: true,
|
||||
initialState,
|
||||
backgroundState,
|
||||
reRequestSuccessful: !!newWakeLock
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
supported: true,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
return { supported: false };
|
||||
});
|
||||
|
||||
console.log('Wake lock test:', wakeLockTest);
|
||||
|
||||
// Scanner should remain functional regardless
|
||||
await expect(page.locator('h1:has-text("Ticket Scanner")')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Multi-Device Race Conditions', () => {
|
||||
const testEventId = 'evt-001';
|
||||
|
||||
test.beforeEach(async ({ page, context }) => {
|
||||
await page.setViewportSize({ width: 375, height: 667 });
|
||||
await context.grantPermissions(['camera']);
|
||||
|
||||
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');
|
||||
await page.goto(`/scan?eventId=${testEventId}`);
|
||||
});
|
||||
|
||||
test('should handle simultaneous scanning of same ticket', async ({ page }) => {
|
||||
const duplicateTicket = 'RACE_CONDITION_TICKET_001';
|
||||
|
||||
// Mock race condition scenario
|
||||
let scanAttempts = 0;
|
||||
await page.route('**/api/scan/**', (route) => {
|
||||
scanAttempts++;
|
||||
|
||||
if (scanAttempts === 1) {
|
||||
// First scan succeeds
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
valid: true,
|
||||
message: 'Entry allowed',
|
||||
scannedAt: new Date().toISOString(),
|
||||
device: 'device-1'
|
||||
})
|
||||
});
|
||||
} else {
|
||||
// Second scan shows already scanned
|
||||
route.fulfill({
|
||||
status: 409,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
valid: false,
|
||||
reason: 'already_scanned',
|
||||
message: 'Ticket already scanned by another device',
|
||||
originalScanTime: new Date(Date.now() - 1000).toISOString(),
|
||||
originalDevice: 'device-1'
|
||||
})
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// First scan attempt
|
||||
await page.evaluate((qr) => {
|
||||
window.dispatchEvent(new CustomEvent('mock-scan', {
|
||||
detail: { qr, timestamp: Date.now(), deviceId: 'scanner-device-2' }
|
||||
}));
|
||||
}, duplicateTicket);
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Second scan attempt (simulating another device scanned it first)
|
||||
await page.evaluate((qr) => {
|
||||
window.dispatchEvent(new CustomEvent('mock-scan', {
|
||||
detail: { qr, timestamp: Date.now(), deviceId: 'scanner-device-2' }
|
||||
}));
|
||||
}, duplicateTicket);
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
expect(scanAttempts).toBe(2);
|
||||
|
||||
// Scanner should handle race condition gracefully
|
||||
await expect(page.locator('h1:has-text("Ticket Scanner")')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should prevent double-scanning within rate limit window', async ({ page }) => {
|
||||
const testTicket = 'DOUBLE_SCAN_PREVENT_001';
|
||||
|
||||
// Monitor scan attempts
|
||||
await page.addInitScript(() => {
|
||||
window.scanPrevention = {
|
||||
scanAttempts: 0,
|
||||
preventedScans: 0,
|
||||
lastScanTime: 0
|
||||
};
|
||||
});
|
||||
|
||||
// First scan
|
||||
await page.evaluate((data) => {
|
||||
const now = Date.now();
|
||||
window.scanPrevention.scanAttempts++;
|
||||
window.scanPrevention.lastScanTime = now;
|
||||
|
||||
window.dispatchEvent(new CustomEvent('mock-scan', {
|
||||
detail: { qr: data.qr, timestamp: now }
|
||||
}));
|
||||
}, { qr: testTicket });
|
||||
|
||||
await page.waitForTimeout(100);
|
||||
|
||||
// Rapid second scan (should be prevented)
|
||||
await page.evaluate((data) => {
|
||||
const now = Date.now();
|
||||
const timeSinceLastScan = now - window.scanPrevention.lastScanTime;
|
||||
|
||||
if (timeSinceLastScan < 2000) { // Less than 2 seconds
|
||||
window.scanPrevention.preventedScans++;
|
||||
console.log('Preventing duplicate scan within rate limit');
|
||||
return;
|
||||
}
|
||||
|
||||
window.scanPrevention.scanAttempts++;
|
||||
window.scanPrevention.lastScanTime = now;
|
||||
|
||||
window.dispatchEvent(new CustomEvent('mock-scan', {
|
||||
detail: { qr: data.qr, timestamp: now }
|
||||
}));
|
||||
}, { qr: testTicket });
|
||||
|
||||
await page.waitForTimeout(100);
|
||||
|
||||
// Check prevention worked
|
||||
const preventionData = await page.evaluate(() => window.scanPrevention);
|
||||
console.log('Scan prevention data:', preventionData);
|
||||
|
||||
expect(preventionData.preventedScans).toBeGreaterThan(0);
|
||||
expect(preventionData.scanAttempts).toBe(1); // Only one actual scan
|
||||
});
|
||||
|
||||
test('should handle concurrent offline queue sync', async ({ page }) => {
|
||||
// Create offline queue
|
||||
await page.context().setOffline(true);
|
||||
await page.evaluate(() => {
|
||||
Object.defineProperty(navigator, 'onLine', { writable: true, value: false });
|
||||
window.dispatchEvent(new Event('offline'));
|
||||
});
|
||||
|
||||
// Enable optimistic scanning
|
||||
await page.click('button:has([data-testid="settings-icon"]), button:has(.lucide-settings)');
|
||||
const toggle = page.locator('button.inline-flex').filter({ hasText: '' });
|
||||
const isEnabled = await toggle.evaluate(el => el.classList.contains('bg-primary-500'));
|
||||
if (!isEnabled) {await toggle.click();}
|
||||
await page.click('button:has([data-testid="settings-icon"]), button:has(.lucide-settings)');
|
||||
|
||||
// Scan multiple tickets offline
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await page.evaluate((qr) => {
|
||||
window.dispatchEvent(new CustomEvent('mock-scan', {
|
||||
detail: { qr, timestamp: Date.now() }
|
||||
}));
|
||||
}, `CONCURRENT_SYNC_${i}`);
|
||||
await page.waitForTimeout(200);
|
||||
}
|
||||
|
||||
// Mock concurrent sync handling
|
||||
let syncRequestCount = 0;
|
||||
await page.route('**/api/scan/batch', (route) => {
|
||||
syncRequestCount++;
|
||||
|
||||
// Simulate processing delay
|
||||
setTimeout(() => {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
processed: 5,
|
||||
successful: 5,
|
||||
failed: 0,
|
||||
conflicts: []
|
||||
})
|
||||
});
|
||||
}, 1000);
|
||||
});
|
||||
|
||||
// Come back online (trigger sync)
|
||||
await page.context().setOffline(false);
|
||||
await page.evaluate(() => {
|
||||
Object.defineProperty(navigator, 'onLine', { writable: true, value: true });
|
||||
window.dispatchEvent(new Event('online'));
|
||||
});
|
||||
|
||||
await page.waitForTimeout(5000);
|
||||
|
||||
// Should not make multiple concurrent sync requests
|
||||
expect(syncRequestCount).toBeLessThanOrEqual(2);
|
||||
|
||||
await expect(page.locator('text=Online')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Rapid Scanning Rate Limits', () => {
|
||||
const testEventId = 'evt-001';
|
||||
|
||||
test.beforeEach(async ({ page, context }) => {
|
||||
await page.setViewportSize({ width: 375, height: 667 });
|
||||
await context.grantPermissions(['camera']);
|
||||
|
||||
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');
|
||||
await page.goto(`/scan?eventId=${testEventId}`);
|
||||
});
|
||||
|
||||
test('should enforce 8 scans per second rate limit', async ({ page }) => {
|
||||
// Set up rate limit monitoring
|
||||
await page.addInitScript(() => {
|
||||
window.rateLimitMonitor = {
|
||||
scansAttempted: 0,
|
||||
scansProcessed: 0,
|
||||
scansBlocked: 0,
|
||||
rateLimitWarnings: 0,
|
||||
scanTimes: []
|
||||
};
|
||||
});
|
||||
|
||||
// Attempt rapid scanning (faster than 8/second = 125ms interval)
|
||||
const rapidScanCount = 20;
|
||||
const scanInterval = 50; // 50ms = 20 scans/second, well over limit
|
||||
|
||||
for (let i = 0; i < rapidScanCount; i++) {
|
||||
await page.evaluate((data) => {
|
||||
const now = Date.now();
|
||||
window.rateLimitMonitor.scansAttempted++;
|
||||
window.rateLimitMonitor.scanTimes.push(now);
|
||||
|
||||
// Simulate rate limiting logic
|
||||
const recentScans = window.rateLimitMonitor.scanTimes.filter(
|
||||
time => now - time < 1000 // Last 1 second
|
||||
);
|
||||
|
||||
if (recentScans.length > 8) {
|
||||
window.rateLimitMonitor.scansBlocked++;
|
||||
window.rateLimitMonitor.rateLimitWarnings++;
|
||||
console.log('Rate limit exceeded - scan blocked');
|
||||
return;
|
||||
}
|
||||
|
||||
window.rateLimitMonitor.scansProcessed++;
|
||||
window.dispatchEvent(new CustomEvent('mock-scan', {
|
||||
detail: { qr: data.qr, timestamp: now }
|
||||
}));
|
||||
}, { qr: `RATE_LIMIT_${i}` });
|
||||
|
||||
await page.waitForTimeout(scanInterval);
|
||||
}
|
||||
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const rateLimitData = await page.evaluate(() => window.rateLimitMonitor);
|
||||
console.log('Rate limit data:', rateLimitData);
|
||||
|
||||
// Should have blocked excessive scans
|
||||
expect(rateLimitData.scansBlocked).toBeGreaterThan(0);
|
||||
expect(rateLimitData.scansProcessed).toBeLessThan(rateLimitData.scansAttempted);
|
||||
expect(rateLimitData.rateLimitWarnings).toBeGreaterThan(0);
|
||||
|
||||
// Should not process more than ~8 scans per second
|
||||
expect(rateLimitData.scansProcessed).toBeLessThan(15);
|
||||
});
|
||||
|
||||
test('should show "slow down" message for excessive scanning', async ({ page }) => {
|
||||
// Monitor UI feedback for rate limiting
|
||||
await page.addInitScript(() => {
|
||||
window.rateLimitUI = {
|
||||
warningsShown: 0,
|
||||
lastWarningTime: 0
|
||||
};
|
||||
|
||||
// Listen for rate limit events
|
||||
window.addEventListener('rate-limit-warning', () => {
|
||||
window.rateLimitUI.warningsShown++;
|
||||
window.rateLimitUI.lastWarningTime = Date.now();
|
||||
});
|
||||
});
|
||||
|
||||
// Rapidly attempt scans
|
||||
for (let i = 0; i < 15; i++) {
|
||||
await page.evaluate((qr) => {
|
||||
// Trigger rate limit warning
|
||||
if (Math.random() < 0.3) { // 30% chance of warning
|
||||
window.dispatchEvent(new Event('rate-limit-warning'));
|
||||
}
|
||||
|
||||
window.dispatchEvent(new CustomEvent('mock-scan', {
|
||||
detail: { qr, timestamp: Date.now() }
|
||||
}));
|
||||
}, `RAPID_SCAN_${i}`);
|
||||
|
||||
await page.waitForTimeout(30); // Very rapid
|
||||
}
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const uiData = await page.evaluate(() => window.rateLimitUI);
|
||||
console.log('Rate limit UI data:', uiData);
|
||||
|
||||
// Should show warnings for excessive scanning
|
||||
if (uiData.warningsShown > 0) {
|
||||
expect(uiData.warningsShown).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
// Scanner should remain functional
|
||||
await expect(page.locator('h1:has-text("Ticket Scanner")')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should recover from rate limiting', async ({ page }) => {
|
||||
// Trigger rate limiting
|
||||
for (let i = 0; i < 12; i++) {
|
||||
await page.evaluate((qr) => {
|
||||
window.dispatchEvent(new CustomEvent('mock-scan', {
|
||||
detail: { qr, timestamp: Date.now() }
|
||||
}));
|
||||
}, `RATE_LIMIT_TRIGGER_${i}`);
|
||||
await page.waitForTimeout(50);
|
||||
}
|
||||
|
||||
// Wait for rate limit to reset
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
// Should accept scans normally again
|
||||
await page.evaluate(() => {
|
||||
window.dispatchEvent(new CustomEvent('mock-scan', {
|
||||
detail: { qr: 'RATE_LIMIT_RECOVERY_TEST', timestamp: Date.now() }
|
||||
}));
|
||||
});
|
||||
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Scanner should be fully functional
|
||||
await expect(page.locator('h1:has-text("Ticket Scanner")')).toBeVisible();
|
||||
await expect(page.locator('video')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('QR Code Quality and Edge Cases', () => {
|
||||
const testEventId = 'evt-001';
|
||||
|
||||
test.beforeEach(async ({ page, context }) => {
|
||||
await page.setViewportSize({ width: 375, height: 667 });
|
||||
await context.grantPermissions(['camera']);
|
||||
|
||||
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');
|
||||
await page.goto(`/scan?eventId=${testEventId}`);
|
||||
});
|
||||
|
||||
test('should handle various QR code formats and sizes', async ({ page }) => {
|
||||
const qrCodeVariants = [
|
||||
'STANDARD_TICKET_12345678', // Standard format
|
||||
'BCT-EVT001-TKT-987654321', // BCT format with dashes
|
||||
'{"ticket":"123","event":"evt-001"}', // JSON format
|
||||
'https://bct.com/verify/abc123', // URL format
|
||||
'VERY_LONG_TICKET_CODE_WITH_MANY_CHARACTERS_1234567890', // Long format
|
||||
'123', // Very short
|
||||
'TICKET_WITH_SPECIAL_CHARS_@#$%', // Special characters
|
||||
'ticket_lowercase_123', // Lowercase
|
||||
'TICKET_WITH_UNICODE_ñáéíóú_123' // Unicode characters
|
||||
];
|
||||
|
||||
// Test each QR code variant
|
||||
for (const qrCode of qrCodeVariants) {
|
||||
await page.evaluate((qr) => {
|
||||
window.dispatchEvent(new CustomEvent('mock-scan', {
|
||||
detail: {
|
||||
qr,
|
||||
timestamp: Date.now(),
|
||||
format: qr.length > 30 ? 'long' : qr.startsWith('{') ? 'json' : 'standard'
|
||||
}
|
||||
}));
|
||||
}, qrCode);
|
||||
|
||||
await page.waitForTimeout(300);
|
||||
}
|
||||
|
||||
// Scanner should handle all formats gracefully
|
||||
await expect(page.locator('h1:has-text("Ticket Scanner")')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should handle damaged or partial QR code reads', async ({ page }) => {
|
||||
const corruptedQRCodes = [
|
||||
'PARTIAL_SCAN_', // Incomplete scan
|
||||
'CORRUPT_QR_?@#$INVALID', // Contains invalid characters
|
||||
'', // Empty scan
|
||||
' ', // Whitespace only
|
||||
'TICKET_WITH\nNEWLINE', // Contains newline
|
||||
'TICKET_WITH\tTAB', // Contains tab
|
||||
'TICKET_WITH\0NULL', // Contains null character
|
||||
];
|
||||
|
||||
// Set up error handling monitoring
|
||||
await page.addInitScript(() => {
|
||||
window.qrErrorHandling = {
|
||||
invalidScans: 0,
|
||||
emptyScans: 0,
|
||||
handledGracefully: 0
|
||||
};
|
||||
});
|
||||
|
||||
for (const qrCode of corruptedQRCodes) {
|
||||
await page.evaluate((qr) => {
|
||||
// Simulate QR validation logic
|
||||
if (!qr || qr.trim().length === 0) {
|
||||
window.qrErrorHandling.emptyScans++;
|
||||
console.log('Empty QR code detected');
|
||||
return;
|
||||
}
|
||||
|
||||
if (qr.includes('\n') || qr.includes('\t') || qr.includes('\0')) {
|
||||
window.qrErrorHandling.invalidScans++;
|
||||
console.log('Invalid QR code format detected');
|
||||
return;
|
||||
}
|
||||
|
||||
window.qrErrorHandling.handledGracefully++;
|
||||
|
||||
window.dispatchEvent(new CustomEvent('mock-scan', {
|
||||
detail: { qr, timestamp: Date.now(), quality: 'poor' }
|
||||
}));
|
||||
}, qrCode);
|
||||
|
||||
await page.waitForTimeout(200);
|
||||
}
|
||||
|
||||
const errorData = await page.evaluate(() => window.qrErrorHandling);
|
||||
console.log('QR error handling data:', errorData);
|
||||
|
||||
// Should detect and handle invalid QR codes
|
||||
expect(errorData.emptyScans + errorData.invalidScans).toBeGreaterThan(0);
|
||||
|
||||
// Scanner should remain stable despite invalid inputs
|
||||
await expect(page.locator('h1:has-text("Ticket Scanner")')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should adapt to different lighting conditions', async ({ page }) => {
|
||||
// Test torch functionality for low light
|
||||
const torchButton = page.locator('button:has([data-testid="flashlight-icon"]), button:has(.lucide-flashlight)');
|
||||
|
||||
if (await torchButton.isVisible()) {
|
||||
// Simulate low light scanning
|
||||
await page.evaluate(() => {
|
||||
window.dispatchEvent(new CustomEvent('lighting-change', {
|
||||
detail: { condition: 'low-light', brightness: 0.2 }
|
||||
}));
|
||||
});
|
||||
|
||||
// Enable torch for better scanning
|
||||
await torchButton.tap();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Test scanning in low light with torch
|
||||
await page.evaluate(() => {
|
||||
window.dispatchEvent(new CustomEvent('mock-scan', {
|
||||
detail: {
|
||||
qr: 'LOW_LIGHT_SCAN_123',
|
||||
timestamp: Date.now(),
|
||||
conditions: { lighting: 'low', torch: true }
|
||||
}
|
||||
}));
|
||||
});
|
||||
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// Simulate bright light
|
||||
await page.evaluate(() => {
|
||||
window.dispatchEvent(new CustomEvent('lighting-change', {
|
||||
detail: { condition: 'bright-light', brightness: 0.9 }
|
||||
}));
|
||||
});
|
||||
|
||||
// Test scanning in bright light
|
||||
await page.evaluate(() => {
|
||||
window.dispatchEvent(new CustomEvent('mock-scan', {
|
||||
detail: {
|
||||
qr: 'BRIGHT_LIGHT_SCAN_123',
|
||||
timestamp: Date.now(),
|
||||
conditions: { lighting: 'bright', torch: false }
|
||||
}
|
||||
}));
|
||||
});
|
||||
|
||||
// Disable torch in bright conditions
|
||||
await torchButton.tap();
|
||||
}
|
||||
|
||||
// Scanner should adapt to lighting changes
|
||||
await expect(page.locator('h1:has-text("Ticket Scanner")')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should handle different QR code angles and distances', async ({ page }) => {
|
||||
const scanAngles = [
|
||||
{ angle: 0, distance: 'optimal', quality: 'high' },
|
||||
{ angle: 15, distance: 'close', quality: 'medium' },
|
||||
{ angle: 30, distance: 'far', quality: 'low' },
|
||||
{ angle: 45, distance: 'very-close', quality: 'poor' },
|
||||
{ angle: -15, distance: 'medium', quality: 'medium' }
|
||||
];
|
||||
|
||||
for (const scan of scanAngles) {
|
||||
await page.evaluate((scanData) => {
|
||||
window.dispatchEvent(new CustomEvent('mock-scan', {
|
||||
detail: {
|
||||
qr: `ANGLE_TEST_${scanData.angle}_${scanData.distance}`,
|
||||
timestamp: Date.now(),
|
||||
scanConditions: scanData
|
||||
}
|
||||
}));
|
||||
}, scan);
|
||||
|
||||
await page.waitForTimeout(400);
|
||||
}
|
||||
|
||||
// Should handle various scan conditions
|
||||
await expect(page.locator('h1:has-text("Ticket Scanner")')).toBeVisible();
|
||||
});
|
||||
});
|
||||
1102
reactrebuild0825/tests/refunds-disputes.spec.ts
Normal file
1102
reactrebuild0825/tests/refunds-disputes.spec.ts
Normal file
File diff suppressed because it is too large
Load Diff
225
reactrebuild0825/tests/scanner-abuse-prevention.spec.ts
Normal file
225
reactrebuild0825/tests/scanner-abuse-prevention.spec.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* Scanner Abuse Prevention Tests
|
||||
*
|
||||
* Tests for rate limiting, debouncing, device tracking, and ticket status integration
|
||||
*/
|
||||
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
test.describe('Scanner Abuse Prevention', () => {
|
||||
let page: Page;
|
||||
|
||||
test.beforeEach(async ({ page: testPage }) => {
|
||||
page = testPage;
|
||||
|
||||
// Navigate to scanner with test event ID
|
||||
await page.goto('/scan?eventId=test-event-abuse');
|
||||
|
||||
// Wait for scanner to initialize
|
||||
await page.waitForSelector('video', { timeout: 10000 });
|
||||
await page.waitForTimeout(2000); // Allow camera to initialize
|
||||
});
|
||||
|
||||
test('displays scanner with abuse prevention features', async () => {
|
||||
// Check that basic scanner elements are present
|
||||
await expect(page.locator('h1')).toContainText('Ticket Scanner');
|
||||
await expect(page.locator('video')).toBeVisible();
|
||||
|
||||
// Check for abuse prevention configuration
|
||||
await expect(page.locator('text=Maximum 8 scans per second')).toBeVisible();
|
||||
await expect(page.locator('text=Duplicate scans blocked for 2 seconds')).toBeVisible();
|
||||
await expect(page.locator('text=Red locks indicate disputed or refunded tickets')).toBeVisible();
|
||||
});
|
||||
|
||||
test('shows rate limit warning when scanning too fast', async () => {
|
||||
// This test would simulate rapid scanning by directly calling the scanner's handleScan function
|
||||
// In a real implementation, we'd need to mock QR code detection
|
||||
|
||||
// For demo purposes, we'll check that the UI components exist
|
||||
const instructions = page.locator('text=Maximum 8 scans per second');
|
||||
await expect(instructions).toBeVisible();
|
||||
|
||||
// Check that rate limit components are properly imported
|
||||
const scanningFrame = page.locator('.border-dashed');
|
||||
await expect(scanningFrame).toBeVisible();
|
||||
});
|
||||
|
||||
test('displays connection and abuse status indicators', async () => {
|
||||
// Check for online/offline status badge
|
||||
const statusBadge = page.locator('[role="status"]').first();
|
||||
await expect(statusBadge).toBeVisible();
|
||||
|
||||
// Check that the header has space for abuse status indicators
|
||||
const header = page.locator('h1:has-text("Ticket Scanner")').locator('..');
|
||||
await expect(header).toBeVisible();
|
||||
});
|
||||
|
||||
test('shows proper instruction text for abuse prevention', async () => {
|
||||
// Verify all instruction items are present
|
||||
await expect(page.locator('text=Position QR code within the scanning frame')).toBeVisible();
|
||||
await expect(page.locator('text=Maximum 8 scans per second to prevent abuse')).toBeVisible();
|
||||
await expect(page.locator('text=Duplicate scans blocked for 2 seconds')).toBeVisible();
|
||||
await expect(page.locator('text=Red locks indicate disputed or refunded tickets')).toBeVisible();
|
||||
});
|
||||
|
||||
test('can access settings panel with zone configuration', async () => {
|
||||
// Click settings button
|
||||
const settingsButton = page.locator('button').filter({ has: page.locator('svg') }).last();
|
||||
await settingsButton.click();
|
||||
|
||||
// Check settings panel appears
|
||||
await expect(page.locator('text=Gate/Zone')).toBeVisible();
|
||||
await expect(page.locator('text=Optimistic Accept')).toBeVisible();
|
||||
|
||||
// Check zone input field
|
||||
const zoneInput = page.locator('input[placeholder*="Gate"]');
|
||||
await expect(zoneInput).toBeVisible();
|
||||
});
|
||||
|
||||
test('scanner interface maintains glassmorphism design', async () => {
|
||||
// Check for glassmorphism classes
|
||||
const cards = page.locator('.bg-glass-bg');
|
||||
await expect(cards.first()).toBeVisible();
|
||||
|
||||
const backdropBlur = page.locator('.backdrop-blur-lg');
|
||||
await expect(backdropBlur.first()).toBeVisible();
|
||||
|
||||
// Check for proper gradient backgrounds
|
||||
const gradient = page.locator('.bg-gradient-to-br');
|
||||
await expect(gradient.first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('shows proper error state when no event ID provided', async () => {
|
||||
// Navigate to scanner without event ID
|
||||
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')).toBeVisible();
|
||||
});
|
||||
|
||||
test('maintains responsive design on mobile viewport', async () => {
|
||||
// Set mobile viewport
|
||||
await page.setViewportSize({ width: 375, height: 667 });
|
||||
|
||||
// Check that scanner still works
|
||||
await expect(page.locator('h1')).toContainText('Ticket Scanner');
|
||||
await expect(page.locator('video')).toBeVisible();
|
||||
|
||||
// Check that instructions are still visible
|
||||
await expect(page.locator('text=Maximum 8 scans per second')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Scanner Abuse Prevention - Rate Limiting', () => {
|
||||
test('rate limiter utility functions correctly', async ({ page }) => {
|
||||
// Test rate limiter logic by injecting a test script
|
||||
const rateLimiterTest = await page.evaluate(() =>
|
||||
// This would test the RateLimiter class if exposed globally
|
||||
// For now, we'll just verify the page loads with abuse prevention
|
||||
({
|
||||
hasInstructions: document.body.textContent?.includes('Maximum 8 scans per second'),
|
||||
hasDebounceInfo: document.body.textContent?.includes('Duplicate scans blocked'),
|
||||
hasLockInfo: document.body.textContent?.includes('Red locks indicate')
|
||||
})
|
||||
);
|
||||
|
||||
expect(rateLimiterTest.hasInstructions).toBe(true);
|
||||
expect(rateLimiterTest.hasDebounceInfo).toBe(true);
|
||||
expect(rateLimiterTest.hasLockInfo).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Scanner Abuse Prevention - Visual Feedback', () => {
|
||||
test('abuse warning components are properly structured', async ({ page }) => {
|
||||
await page.goto('/scan?eventId=test-event');
|
||||
|
||||
// Check that the page structure supports abuse warnings
|
||||
// Look for the space where warnings would appear
|
||||
// The warnings should appear before the camera view
|
||||
const cameraCard = page.locator('.bg-black').filter({ has: page.locator('video') });
|
||||
await expect(cameraCard).toBeVisible();
|
||||
});
|
||||
|
||||
test('progress bar utility is available', async ({ page }) => {
|
||||
await page.goto('/scan?eventId=test-event');
|
||||
|
||||
// Verify the page loads successfully with all components
|
||||
await expect(page.locator('video')).toBeVisible();
|
||||
|
||||
// Check for glassmorphism styling that would be used in progress bars
|
||||
const glassElements = page.locator('.backdrop-blur-lg');
|
||||
await expect(glassElements.first()).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Scanner Abuse Prevention - Accessibility', () => {
|
||||
test('maintains WCAG AA accessibility with abuse prevention features', async ({ page }) => {
|
||||
await page.goto('/scan?eventId=test-event');
|
||||
|
||||
// Check for proper headings
|
||||
const mainHeading = page.locator('h1');
|
||||
await expect(mainHeading).toBeVisible();
|
||||
|
||||
// Check for proper form labels
|
||||
await page.click('button:has([data-lucide="settings"])');
|
||||
const zoneLabel = page.locator('label:has-text("Gate/Zone")');
|
||||
await expect(zoneLabel).toBeVisible();
|
||||
|
||||
// Check for proper button accessibility
|
||||
const buttons = page.locator('button');
|
||||
for (const button of await buttons.all()) {
|
||||
const hasTextOrAria = await button.evaluate(el =>
|
||||
el.textContent?.trim() || el.getAttribute('aria-label') || el.querySelector('svg')
|
||||
);
|
||||
expect(hasTextOrAria).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
test('provides proper keyboard navigation', async ({ page }) => {
|
||||
await page.goto('/scan?eventId=test-event');
|
||||
|
||||
// Tab through interactive elements
|
||||
await page.keyboard.press('Tab');
|
||||
|
||||
// Settings button should be focusable
|
||||
const settingsButton = page.locator('button:focus');
|
||||
await expect(settingsButton).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Scanner Abuse Prevention - Performance', () => {
|
||||
test('abuse prevention does not significantly impact load time', async ({ page }) => {
|
||||
const startTime = Date.now();
|
||||
|
||||
await page.goto('/scan?eventId=test-event');
|
||||
await page.waitForSelector('video');
|
||||
|
||||
const loadTime = Date.now() - startTime;
|
||||
|
||||
// Should load within 5 seconds even with abuse prevention
|
||||
expect(loadTime).toBeLessThan(5000);
|
||||
|
||||
// Check that all critical elements are present
|
||||
await expect(page.locator('h1')).toContainText('Ticket Scanner');
|
||||
await expect(page.locator('text=Maximum 8 scans per second')).toBeVisible();
|
||||
});
|
||||
|
||||
test('maintains smooth animations with abuse prevention', async ({ page }) => {
|
||||
await page.goto('/scan?eventId=test-event');
|
||||
|
||||
// Open settings panel
|
||||
await page.click('button:has([data-lucide="settings"])');
|
||||
|
||||
// Check that settings panel appears (would have animation)
|
||||
await expect(page.locator('text=Gate/Zone')).toBeVisible({ timeout: 1000 });
|
||||
|
||||
// Close settings panel
|
||||
await page.click('button:has([data-lucide="settings"])');
|
||||
|
||||
// Panel should disappear smoothly
|
||||
await expect(page.locator('text=Gate/Zone')).toBeHidden({ timeout: 1000 });
|
||||
});
|
||||
});
|
||||
265
reactrebuild0825/tests/territory-access.spec.ts
Normal file
265
reactrebuild0825/tests/territory-access.spec.ts
Normal file
@@ -0,0 +1,265 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Territory Access Control', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Navigate to login page
|
||||
await page.goto('/login');
|
||||
});
|
||||
|
||||
test('Territory Manager sees only assigned territories in events', async ({ page }) => {
|
||||
// Login as territory manager
|
||||
await page.fill('[data-testid="login-email"]', 'territory@example.com');
|
||||
await page.fill('[data-testid="login-password"]', 'password123');
|
||||
await page.click('[data-testid="login-submit"]');
|
||||
|
||||
// Wait for redirect to dashboard
|
||||
await page.waitForURL('/dashboard');
|
||||
|
||||
// Navigate to events page
|
||||
await page.click('[data-testid="nav-events"]');
|
||||
await page.waitForURL('/events');
|
||||
|
||||
// Territory manager should only see events from their assigned territories (WNW and SE)
|
||||
// Event 1 is in WNW (territory_001) - should be visible
|
||||
await expect(page.locator('[data-testid="event-card-evt-1"]')).toBeVisible();
|
||||
|
||||
// Event 2 is in SE (territory_002) - should be visible
|
||||
await expect(page.locator('[data-testid="event-card-evt-2"]')).toBeVisible();
|
||||
|
||||
// Event 3 is in NE (territory_003) - should NOT be visible
|
||||
await expect(page.locator('[data-testid="event-card-evt-3"]')).not.toBeVisible();
|
||||
|
||||
// Check that territory filter shows only assigned territories
|
||||
const territoryFilter = page.locator('[data-testid="territory-filter"]');
|
||||
await expect(territoryFilter).toBeVisible();
|
||||
|
||||
// Should show WNW and SE badges (territory manager's assigned territories)
|
||||
await expect(page.locator('[data-testid="territory-badge-WNW"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="territory-badge-SE"]')).toBeVisible();
|
||||
|
||||
// Should not show NE badge
|
||||
await expect(page.locator('[data-testid="territory-badge-NE"]')).not.toBeVisible();
|
||||
|
||||
// Territory filter should be read-only for territory managers
|
||||
const addTerritoryButton = page.locator('[data-testid="add-territory-button"]');
|
||||
await expect(addTerritoryButton).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('OrgAdmin sees all territories and can manage them', async ({ page }) => {
|
||||
// Login as admin (which is mapped to orgAdmin in new system)
|
||||
await page.fill('[data-testid="login-email"]', 'admin@example.com');
|
||||
await page.fill('[data-testid="login-password"]', 'password123');
|
||||
await page.click('[data-testid="login-submit"]');
|
||||
|
||||
await page.waitForURL('/dashboard');
|
||||
|
||||
// Navigate to events page
|
||||
await page.click('[data-testid="nav-events"]');
|
||||
await page.waitForURL('/events');
|
||||
|
||||
// Admin should see all events in their organization
|
||||
await expect(page.locator('[data-testid="event-card-evt-1"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="event-card-evt-2"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="event-card-evt-3"]')).toBeVisible();
|
||||
|
||||
// Territory filter should be editable for admins
|
||||
const territoryFilter = page.locator('[data-testid="territory-filter"]');
|
||||
await expect(territoryFilter).toBeVisible();
|
||||
|
||||
// Should be able to add/remove territories
|
||||
const addTerritorySelect = page.locator('[data-testid="add-territory-select"]');
|
||||
await expect(addTerritorySelect).toBeVisible();
|
||||
});
|
||||
|
||||
test('Territory Manager cannot write to events outside their territory', async ({ page }) => {
|
||||
// This would test Firestore security rules in a real environment
|
||||
// For now, we'll test UI-level restrictions
|
||||
|
||||
await page.fill('[data-testid="login-email"]', 'territory@example.com');
|
||||
await page.fill('[data-testid="login-password"]', 'password123');
|
||||
await page.click('[data-testid="login-submit"]');
|
||||
|
||||
await page.waitForURL('/dashboard');
|
||||
|
||||
// Navigate to create event page
|
||||
await page.click('[data-testid="nav-events"]');
|
||||
await page.click('[data-testid="create-event-button"]');
|
||||
|
||||
// In event creation form, territory dropdown should only show assigned territories
|
||||
const territorySelect = page.locator('[data-testid="event-territory-select"]');
|
||||
await expect(territorySelect).toBeVisible();
|
||||
|
||||
// Should only have options for WNW and SE (territory manager's assigned territories)
|
||||
await territorySelect.click();
|
||||
|
||||
await expect(page.locator('option:has-text("WNW - West Northwest")')).toBeVisible();
|
||||
await expect(page.locator('option:has-text("SE - Southeast")')).toBeVisible();
|
||||
await expect(page.locator('option:has-text("NE - Northeast")')).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('Territory assignment UI is only visible to admins', async ({ page }) => {
|
||||
// Test as territory manager first - should not see admin UI
|
||||
await page.fill('[data-testid="login-email"]', 'territory@example.com');
|
||||
await page.fill('[data-testid="login-password"]', 'password123');
|
||||
await page.click('[data-testid="login-submit"]');
|
||||
|
||||
await page.waitForURL('/dashboard');
|
||||
|
||||
// Navigate to admin page (if it exists in nav)
|
||||
const adminNavLink = page.locator('[data-testid="nav-admin"]');
|
||||
if (await adminNavLink.isVisible()) {
|
||||
await adminNavLink.click();
|
||||
|
||||
// Should show access denied message
|
||||
await expect(page.locator('[data-testid="access-denied-message"]')).toBeVisible();
|
||||
}
|
||||
|
||||
// Now test as admin
|
||||
await page.click('[data-testid="logout-button"]');
|
||||
await page.waitForURL('/login');
|
||||
|
||||
await page.fill('[data-testid="login-email"]', 'admin@example.com');
|
||||
await page.fill('[data-testid="login-password"]', 'password123');
|
||||
await page.click('[data-testid="login-submit"]');
|
||||
|
||||
await page.waitForURL('/dashboard');
|
||||
|
||||
// Admin should see territory management interface
|
||||
if (await page.locator('[data-testid="nav-admin"]').isVisible()) {
|
||||
await page.click('[data-testid="nav-admin"]');
|
||||
|
||||
// Should see user territory manager component
|
||||
await expect(page.locator('[data-testid="user-territory-manager"]')).toBeVisible();
|
||||
|
||||
// Should be able to select users and assign territories
|
||||
await expect(page.locator('[data-testid="user-select"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="role-select"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="territory-checkboxes"]')).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('Event creation requires territory selection', async ({ page }) => {
|
||||
await page.fill('[data-testid="login-email"]', 'admin@example.com');
|
||||
await page.fill('[data-testid="login-password"]', 'password123');
|
||||
await page.click('[data-testid="login-submit"]');
|
||||
|
||||
await page.waitForURL('/dashboard');
|
||||
|
||||
// Navigate to create event
|
||||
await page.click('[data-testid="nav-events"]');
|
||||
await page.click('[data-testid="create-event-button"]');
|
||||
|
||||
// Fill in event details but leave territory empty
|
||||
await page.fill('[data-testid="event-title"]', 'Test Event');
|
||||
await page.fill('[data-testid="event-description"]', 'Test Description');
|
||||
await page.fill('[data-testid="event-venue"]', 'Test Venue');
|
||||
await page.fill('[data-testid="event-date"]', '2024-12-25T18:00');
|
||||
|
||||
// Try to proceed without selecting territory
|
||||
await page.click('[data-testid="next-step-button"]');
|
||||
|
||||
// Should show validation error
|
||||
await expect(page.locator('[data-testid="territory-required-error"]')).toBeVisible();
|
||||
await expect(page.locator('text=Please select a territory for this event')).toBeVisible();
|
||||
|
||||
// Select a territory
|
||||
await page.selectOption('[data-testid="event-territory-select"]', 'territory_001');
|
||||
|
||||
// Now should be able to proceed
|
||||
await page.click('[data-testid="next-step-button"]');
|
||||
|
||||
// Should move to next step (ticket configuration)
|
||||
await expect(page.locator('[data-testid="ticket-configuration-step"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Territory filter persists in URL and localStorage', async ({ page }) => {
|
||||
await page.fill('[data-testid="login-email"]', 'admin@example.com');
|
||||
await page.fill('[data-testid="login-password"]', 'password123');
|
||||
await page.click('[data-testid="login-submit"]');
|
||||
|
||||
await page.waitForURL('/dashboard');
|
||||
|
||||
// Navigate to events page
|
||||
await page.click('[data-testid="nav-events"]');
|
||||
await page.waitForURL('/events');
|
||||
|
||||
// Select specific territories in filter
|
||||
await page.click('[data-testid="add-territory-select"]');
|
||||
await page.selectOption('[data-testid="add-territory-select"]', 'territory_001');
|
||||
|
||||
await page.click('[data-testid="add-territory-select"]');
|
||||
await page.selectOption('[data-testid="add-territory-select"]', 'territory_002');
|
||||
|
||||
// URL should include territories parameter
|
||||
await expect(page).toHaveURL(/territories=territory_001,territory_002/);
|
||||
|
||||
// Refresh page
|
||||
await page.reload();
|
||||
|
||||
// Territory filter should be restored
|
||||
await expect(page.locator('[data-testid="territory-badge-WNW"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="territory-badge-SE"]')).toBeVisible();
|
||||
|
||||
// Navigate away and back
|
||||
await page.click('[data-testid="nav-dashboard"]');
|
||||
await page.click('[data-testid="nav-events"]');
|
||||
|
||||
// Territory filter should still be there (from localStorage)
|
||||
await expect(page.locator('[data-testid="territory-badge-WNW"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="territory-badge-SE"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Claims are properly set in Firebase auth tokens', async ({ page }) => {
|
||||
// This test verifies that custom claims are working correctly
|
||||
// In a real implementation, this would test the actual Firebase auth
|
||||
|
||||
await page.fill('[data-testid="login-email"]', 'territory@example.com');
|
||||
await page.fill('[data-testid="login-password"]', 'password123');
|
||||
await page.click('[data-testid="login-submit"]');
|
||||
|
||||
await page.waitForURL('/dashboard');
|
||||
|
||||
// Check that user info displays correct role and territories
|
||||
await page.click('[data-testid="user-menu"]');
|
||||
|
||||
await expect(page.locator('[data-testid="user-role"]')).toContainText('Territory Manager');
|
||||
await expect(page.locator('[data-testid="user-territories"]')).toContainText('WNW, SE');
|
||||
|
||||
// Check in dev tools console that claims are present
|
||||
const claims = await page.evaluate(async () =>
|
||||
// In real app, this would get claims from Firebase auth
|
||||
// For mock, we'll simulate checking localStorage or context
|
||||
({
|
||||
role: 'territoryManager',
|
||||
territoryIds: ['territory_001', 'territory_002'],
|
||||
orgId: 'org_001'
|
||||
})
|
||||
);
|
||||
|
||||
expect(claims.role).toBe('territoryManager');
|
||||
expect(claims.territoryIds).toEqual(['territory_001', 'territory_002']);
|
||||
expect(claims.orgId).toBe('org_001');
|
||||
});
|
||||
});
|
||||
|
||||
// Helper test for validating Firestore security rules (would run in Firebase emulator)
|
||||
test.describe('Firestore Security Rules (Emulator)', () => {
|
||||
test.skip('Territory Manager cannot read events outside their territory', async () => {
|
||||
// This test would require Firebase emulator setup
|
||||
// Skip for now but document the test pattern
|
||||
|
||||
// 1. Initialize Firebase emulator with test data
|
||||
// 2. Authenticate as territory manager with specific claims
|
||||
// 3. Attempt to read events from other territories
|
||||
// 4. Verify access is denied
|
||||
// 5. Verify write operations are also denied
|
||||
});
|
||||
|
||||
test.skip('OrgAdmin can read all events in their organization', async () => {
|
||||
// Similar pattern for testing orgAdmin permissions
|
||||
});
|
||||
|
||||
test.skip('Cross-organization access is denied', async () => {
|
||||
// Test that users cannot access data from other organizations
|
||||
});
|
||||
});
|
||||
481
reactrebuild0825/tests/whitelabel.spec.ts
Normal file
481
reactrebuild0825/tests/whitelabel.spec.ts
Normal file
@@ -0,0 +1,481 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
// Test configuration
|
||||
const TEST_HOST = 'tickets.acme.test';
|
||||
const MOCK_ORG_DATA = {
|
||||
orgId: 'acme-corp',
|
||||
name: 'ACME Corporation',
|
||||
branding: {
|
||||
logoUrl: 'https://example.com/acme-logo.png',
|
||||
theme: {
|
||||
accent: '#FF6B35',
|
||||
bgCanvas: '#1A1B1E',
|
||||
bgSurface: '#2A2B2E',
|
||||
textPrimary: '#FFFFFF',
|
||||
textSecondary: '#B0B0B0',
|
||||
},
|
||||
},
|
||||
domains: [
|
||||
{
|
||||
host: TEST_HOST,
|
||||
verified: true,
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
verifiedAt: '2024-01-01T01:00:00Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Mock the domain resolution API
|
||||
async function mockDomainResolution(page: Page, orgData = MOCK_ORG_DATA) {
|
||||
await page.route('**/resolveDomain*', async route => {
|
||||
const url = new URL(route.request().url());
|
||||
const host = url.searchParams.get('host');
|
||||
|
||||
if (host === TEST_HOST || host === 'mock.acme.test') {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(orgData),
|
||||
});
|
||||
} else {
|
||||
await route.fulfill({
|
||||
status: 404,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ error: 'Organization not found', host }),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Mock domain verification APIs
|
||||
async function mockDomainAPIs(page: Page) {
|
||||
// Mock request verification
|
||||
await page.route('**/requestDomainVerification', async route => {
|
||||
const body = await route.request().postDataJSON();
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
success: true,
|
||||
host: body.host,
|
||||
verificationToken: 'bct-verify-123456789',
|
||||
instructions: {
|
||||
type: 'TXT',
|
||||
name: '_bct-verification',
|
||||
value: 'bct-verify-123456789',
|
||||
ttl: 300,
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
// Mock verify domain
|
||||
await page.route('**/verifyDomain', async route => {
|
||||
const body = await route.request().postDataJSON();
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
success: true,
|
||||
host: body.host,
|
||||
verified: true,
|
||||
verifiedAt: new Date().toISOString(),
|
||||
message: 'Domain successfully verified',
|
||||
}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test.describe('Whitelabel System', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Mock all domain-related APIs
|
||||
await mockDomainResolution(page);
|
||||
await mockDomainAPIs(page);
|
||||
});
|
||||
|
||||
test('should resolve organization from host and apply theme', async ({ page }) => {
|
||||
// Visit with custom host parameter to simulate domain resolution
|
||||
await page.goto('/?host=mock.acme.test');
|
||||
|
||||
// Wait for organization resolution
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Check that organization theme is applied
|
||||
const rootStyles = await page.evaluate(() => {
|
||||
const root = document.documentElement;
|
||||
return {
|
||||
accent: getComputedStyle(root).getPropertyValue('--color-accent-500'),
|
||||
bgCanvas: getComputedStyle(root).getPropertyValue('--color-bg-canvas'),
|
||||
textPrimary: getComputedStyle(root).getPropertyValue('--color-text-primary'),
|
||||
};
|
||||
});
|
||||
|
||||
// Verify theme colors are applied
|
||||
expect(rootStyles.accent.trim()).toBe(MOCK_ORG_DATA.branding.theme.accent);
|
||||
expect(rootStyles.bgCanvas.trim()).toBe(MOCK_ORG_DATA.branding.theme.bgCanvas);
|
||||
});
|
||||
|
||||
test('should display organization logo and name in header', async ({ page }) => {
|
||||
await page.goto('/?host=mock.acme.test');
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Check for organization name in header
|
||||
const orgName = await page.locator('text="ACME Corporation"').first();
|
||||
await expect(orgName).toBeVisible();
|
||||
|
||||
// Check for logo (if present)
|
||||
const logo = page.locator('img[alt*="ACME Corporation logo"]');
|
||||
if (await logo.count() > 0) {
|
||||
await expect(logo).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('should handle organization not found gracefully', async ({ page }) => {
|
||||
// Mock no organization found
|
||||
await page.route('**/resolveDomain*', async route => {
|
||||
await route.fulfill({
|
||||
status: 404,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ error: 'Organization not found' }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto('/?host=unknown.domain.test');
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Should still render the app with default theme
|
||||
await expect(page.locator('[data-testid="app-layout"]')).toBeVisible();
|
||||
|
||||
// Should apply default theme colors
|
||||
const rootStyles = await page.evaluate(() => {
|
||||
const root = document.documentElement;
|
||||
return getComputedStyle(root).getPropertyValue('--color-accent-500');
|
||||
});
|
||||
|
||||
// Should have default accent color
|
||||
expect(rootStyles.trim()).toBe('#F0C457');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Branding Settings', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await mockDomainResolution(page);
|
||||
await page.goto('/login');
|
||||
|
||||
// Mock authentication
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem('auth-token', 'mock-token');
|
||||
localStorage.setItem('user-data', JSON.stringify({
|
||||
id: 'user-1',
|
||||
name: 'Test User',
|
||||
email: 'test@acme.com',
|
||||
role: 'admin',
|
||||
organization: { id: 'acme-corp', name: 'ACME Corporation' },
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
test('should display branding settings page', async ({ page }) => {
|
||||
await page.goto('/org/acme-corp/branding');
|
||||
|
||||
// Wait for page to load
|
||||
await expect(page.locator('h1')).toContainText('Branding Settings');
|
||||
|
||||
// Check for color inputs
|
||||
await expect(page.locator('text="Accent Color"')).toBeVisible();
|
||||
await expect(page.locator('text="Canvas Background"')).toBeVisible();
|
||||
await expect(page.locator('text="Surface Background"')).toBeVisible();
|
||||
await expect(page.locator('text="Primary Text"')).toBeVisible();
|
||||
await expect(page.locator('text="Secondary Text"')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should enable live preview mode', async ({ page }) => {
|
||||
await page.goto('/org/acme-corp/branding');
|
||||
|
||||
// Click live preview button
|
||||
await page.click('button:has-text("Live Preview")');
|
||||
|
||||
// Verify preview mode is active
|
||||
await expect(page.locator('text="Live preview mode is active"')).toBeVisible();
|
||||
|
||||
// Button should change to "Exit Preview"
|
||||
await expect(page.locator('button:has-text("Exit Preview")')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should update colors and show live preview', async ({ page }) => {
|
||||
await page.goto('/org/acme-corp/branding');
|
||||
|
||||
// Enable live preview
|
||||
await page.click('button:has-text("Live Preview")');
|
||||
|
||||
// Change accent color
|
||||
const newAccentColor = '#00FF88';
|
||||
await page.fill('input[value*="#"]', newAccentColor);
|
||||
|
||||
// Check that the theme variable is updated
|
||||
const appliedColor = await page.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue('--color-accent-500'));
|
||||
|
||||
expect(appliedColor.trim()).toBe(newAccentColor);
|
||||
|
||||
// Check that Save button is enabled
|
||||
await expect(page.locator('button:has-text("Save Changes")')).toBeEnabled();
|
||||
});
|
||||
|
||||
test('should validate color formats', async ({ page }) => {
|
||||
await page.goto('/org/acme-corp/branding');
|
||||
|
||||
// Enter invalid color
|
||||
await page.fill('input[value*="#"]', 'invalid-color');
|
||||
|
||||
// Should show validation error
|
||||
await expect(page.locator('text*="Invalid color"')).toBeVisible();
|
||||
|
||||
// Save button should be disabled
|
||||
await expect(page.locator('button:has-text("Save Changes")')).toBeDisabled();
|
||||
});
|
||||
|
||||
test('should save branding changes', async ({ page }) => {
|
||||
await page.goto('/org/acme-corp/branding');
|
||||
|
||||
// Change a color to make form dirty
|
||||
await page.fill('input[value*="#"]', '#FF0000');
|
||||
|
||||
// Click save
|
||||
await page.click('button:has-text("Save Changes")');
|
||||
|
||||
// Should show success message
|
||||
await expect(page.locator('text*="saved successfully"')).toBeVisible();
|
||||
|
||||
// Save button should be disabled again
|
||||
await expect(page.locator('button:has-text("Save Changes")')).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Domain Settings', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await mockDomainResolution(page);
|
||||
await mockDomainAPIs(page);
|
||||
|
||||
await page.goto('/login');
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem('auth-token', 'mock-token');
|
||||
localStorage.setItem('user-data', JSON.stringify({
|
||||
id: 'user-1',
|
||||
name: 'Test User',
|
||||
email: 'test@acme.com',
|
||||
role: 'admin',
|
||||
organization: { id: 'acme-corp', name: 'ACME Corporation' },
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
test('should display domain settings page', async ({ page }) => {
|
||||
await page.goto('/org/acme-corp/domains');
|
||||
|
||||
await expect(page.locator('h1')).toContainText('Domain Settings');
|
||||
await expect(page.locator('text="Add Custom Domain"')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should show existing verified domain', async ({ page }) => {
|
||||
await page.goto('/org/acme-corp/domains');
|
||||
|
||||
// Should show the verified domain from mock data
|
||||
await expect(page.locator(`text="${TEST_HOST}"`)).toBeVisible();
|
||||
await expect(page.locator('text="Verified"')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should add new domain and show verification instructions', async ({ page }) => {
|
||||
await page.goto('/org/acme-corp/domains');
|
||||
|
||||
// Enter new domain
|
||||
const newDomain = 'tickets.newcorp.com';
|
||||
await page.fill('input[placeholder*="tickets.example.com"]', newDomain);
|
||||
|
||||
// Click add domain
|
||||
await page.click('button:has-text("Add Domain")');
|
||||
|
||||
// Should show success message
|
||||
await expect(page.locator('text*="added successfully"')).toBeVisible();
|
||||
|
||||
// Should show the new domain with unverified status
|
||||
await expect(page.locator(`text="${newDomain}"`)).toBeVisible();
|
||||
await expect(page.locator('text="Unverified"')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should show DNS instructions for unverified domain', async ({ page }) => {
|
||||
await page.goto('/org/acme-corp/domains');
|
||||
|
||||
// Add a domain first
|
||||
await page.fill('input[placeholder*="tickets.example.com"]', 'test.example.com');
|
||||
await page.click('button:has-text("Add Domain")');
|
||||
|
||||
// Click to show DNS instructions
|
||||
await page.click('button:has-text("Show DNS Instructions")');
|
||||
|
||||
// Should show DNS configuration details
|
||||
await expect(page.locator('text="DNS Configuration Required"')).toBeVisible();
|
||||
await expect(page.locator('text="_bct-verification"')).toBeVisible();
|
||||
await expect(page.locator('text="bct-verify-"')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should verify domain successfully', async ({ page }) => {
|
||||
await page.goto('/org/acme-corp/domains');
|
||||
|
||||
// Add a domain first
|
||||
await page.fill('input[placeholder*="tickets.example.com"]', 'verify.example.com');
|
||||
await page.click('button:has-text("Add Domain")');
|
||||
|
||||
// Click verify button
|
||||
await page.click('button:has-text("Check Verification")');
|
||||
|
||||
// Should show success message
|
||||
await expect(page.locator('text*="verified successfully"')).toBeVisible();
|
||||
|
||||
// Status should change to verified
|
||||
await expect(page.locator('text="Verified"')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should validate domain format', async ({ page }) => {
|
||||
await page.goto('/org/acme-corp/domains');
|
||||
|
||||
// Enter invalid domain
|
||||
await page.fill('input[placeholder*="tickets.example.com"]', 'invalid-domain');
|
||||
await page.click('button:has-text("Add Domain")');
|
||||
|
||||
// Should show validation error
|
||||
await expect(page.locator('text*="valid domain name"')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should copy DNS record values', async ({ page }) => {
|
||||
await page.goto('/org/acme-corp/domains');
|
||||
|
||||
// Add domain and show instructions
|
||||
await page.fill('input[placeholder*="tickets.example.com"]', 'copy.example.com');
|
||||
await page.click('button:has-text("Add Domain")');
|
||||
await page.click('button:has-text("Show DNS Instructions")');
|
||||
|
||||
// Mock clipboard API
|
||||
await page.evaluate(() => {
|
||||
Object.assign(navigator, {
|
||||
clipboard: {
|
||||
writeText: () => Promise.resolve(),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Click copy button
|
||||
const copyButton = page.locator('button').filter({ hasText: 'Copy' }).first();
|
||||
await copyButton.click();
|
||||
|
||||
// Copy button should briefly show checkmark
|
||||
await expect(page.locator('svg[data-testid="check-icon"]').first()).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Theme Application', () => {
|
||||
test('should apply theme CSS variables correctly', async ({ page }) => {
|
||||
const customTheme = {
|
||||
orgId: 'custom-org',
|
||||
name: 'Custom Theme Org',
|
||||
branding: {
|
||||
logoUrl: 'https://example.com/logo.png',
|
||||
theme: {
|
||||
accent: '#FF1234',
|
||||
bgCanvas: '#000000',
|
||||
bgSurface: '#111111',
|
||||
textPrimary: '#FFFFFF',
|
||||
textSecondary: '#CCCCCC',
|
||||
},
|
||||
},
|
||||
domains: [],
|
||||
};
|
||||
|
||||
await mockDomainResolution(page, customTheme);
|
||||
await page.goto('/?host=custom.test.com');
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Check all theme variables are applied
|
||||
const themeVars = await page.evaluate(() => {
|
||||
const root = document.documentElement;
|
||||
const computedStyle = getComputedStyle(root);
|
||||
return {
|
||||
accent: computedStyle.getPropertyValue('--color-accent-500').trim(),
|
||||
bgCanvas: computedStyle.getPropertyValue('--color-bg-canvas').trim(),
|
||||
bgSurface: computedStyle.getPropertyValue('--color-bg-surface').trim(),
|
||||
textPrimary: computedStyle.getPropertyValue('--color-text-primary').trim(),
|
||||
textSecondary: computedStyle.getPropertyValue('--color-text-secondary').trim(),
|
||||
};
|
||||
});
|
||||
|
||||
expect(themeVars.accent).toBe('#FF1234');
|
||||
expect(themeVars.bgCanvas).toBe('#000000');
|
||||
expect(themeVars.bgSurface).toBe('#111111');
|
||||
expect(themeVars.textPrimary).toBe('#FFFFFF');
|
||||
expect(themeVars.textSecondary).toBe('#CCCCCC');
|
||||
});
|
||||
|
||||
test('should prevent FOUC with early theme application', async ({ page }) => {
|
||||
// Navigate to page and immediately check if theme is applied
|
||||
const startTime = Date.now();
|
||||
await page.goto('/?host=mock.acme.test');
|
||||
|
||||
// Check theme within first 100ms
|
||||
await page.waitForTimeout(100);
|
||||
|
||||
const hasThemeApplied = await page.evaluate(() => document.documentElement.hasAttribute('data-org-theme'));
|
||||
|
||||
// Theme should be applied very quickly to prevent FOUC
|
||||
const loadTime = Date.now() - startTime;
|
||||
expect(hasThemeApplied).toBe(true);
|
||||
expect(loadTime).toBeLessThan(1000); // Should load within 1 second
|
||||
});
|
||||
|
||||
test('should update theme when organization changes', async ({ page }) => {
|
||||
// Start with one organization
|
||||
await mockDomainResolution(page);
|
||||
await page.goto('/?host=mock.acme.test');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const initialAccent = await page.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue('--color-accent-500').trim());
|
||||
|
||||
// Change to different organization with different theme
|
||||
const newTheme = {
|
||||
orgId: 'new-org',
|
||||
name: 'New Organization',
|
||||
branding: {
|
||||
theme: {
|
||||
accent: '#00FF00',
|
||||
bgCanvas: '#001122',
|
||||
bgSurface: '#112233',
|
||||
textPrimary: '#FFFF00',
|
||||
textSecondary: '#CCCC00',
|
||||
},
|
||||
},
|
||||
domains: [],
|
||||
};
|
||||
|
||||
await page.route('**/resolveDomain*', async route => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(newTheme),
|
||||
});
|
||||
});
|
||||
|
||||
// Simulate organization change (in real app this would happen via URL change)
|
||||
await page.evaluate(() => {
|
||||
// Trigger a manual bootstrap refresh
|
||||
if (window.location.search.includes('refresh=true')) {return;}
|
||||
window.location.search = '?refresh=true&host=new.test.com';
|
||||
});
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const newAccent = await page.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue('--color-accent-500').trim());
|
||||
|
||||
expect(newAccent).toBe('#00FF00');
|
||||
expect(newAccent).not.toBe(initialAccent);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user