From 0a86e501e85ad87c81aa5dc7f6e0f19b4929f0ab Mon Sep 17 00:00:00 2001 From: waterWang <672684719@qq.com> Date: Tue, 25 Aug 2026 05:34:12 +0800 Subject: [PATCH] test: add unit tests for all 13 frontend components (Closes #169) --- .../credential-analytics-dashboard.test.tsx | 91 ++++++++++ .../credential-details-modal.test.tsx | 65 +++++++ .../__tests__/credential-edit-modal.test.tsx | 75 ++++++++ .../credential-metadata-display.test.tsx | 167 +++++++++++++++++ .../deletion-confirmation-modal.test.tsx | 168 ++++++++++++++++++ frontend/__tests__/error-boundary.test.tsx | 89 ++++++++++ .../health-credential-vault.test.tsx | 65 +++++++ frontend/__tests__/notification-bell.test.tsx | 74 ++++++++ .../notification-preferences.test.tsx | 90 ++++++++++ .../vaccination-verification-center.test.tsx | 60 +++++++ 10 files changed, 944 insertions(+) create mode 100644 frontend/__tests__/credential-analytics-dashboard.test.tsx create mode 100644 frontend/__tests__/credential-details-modal.test.tsx create mode 100644 frontend/__tests__/credential-edit-modal.test.tsx create mode 100644 frontend/__tests__/credential-metadata-display.test.tsx create mode 100644 frontend/__tests__/deletion-confirmation-modal.test.tsx create mode 100644 frontend/__tests__/error-boundary.test.tsx create mode 100644 frontend/__tests__/health-credential-vault.test.tsx create mode 100644 frontend/__tests__/notification-bell.test.tsx create mode 100644 frontend/__tests__/notification-preferences.test.tsx create mode 100644 frontend/__tests__/vaccination-verification-center.test.tsx diff --git a/frontend/__tests__/credential-analytics-dashboard.test.tsx b/frontend/__tests__/credential-analytics-dashboard.test.tsx new file mode 100644 index 00000000..90c4968f --- /dev/null +++ b/frontend/__tests__/credential-analytics-dashboard.test.tsx @@ -0,0 +1,91 @@ +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import { CredentialAnalyticsDashboard } from '../src/components/credential-analytics-dashboard'; + +// Mock fetch to reject so the component falls back to MOCK_DATA +global.fetch = jest.fn(() => Promise.reject(new Error('Network error'))); + +describe('CredentialAnalyticsDashboard', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('shows loading state initially', () => { + render(); + expect(screen.getByText('Loading analytics...')).toBeInTheDocument(); + }); + + it('renders dashboard with mock data after loading', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('Credential Analytics Dashboard')).toBeInTheDocument(); + }); + + // Check usage stats + expect(screen.getByText('Total Credentials Issued')).toBeInTheDocument(); + expect(screen.getByText('Total Verifications')).toBeInTheDocument(); + expect(screen.getByText('Data Shares')).toBeInTheDocument(); + + // Check mock data values + expect(screen.getByText('120')).toBeInTheDocument(); // totalIdentities + expect(screen.getByText('300')).toBeInTheDocument(); // total verifications + expect(screen.getByText('150')).toBeInTheDocument(); // total shares + }); + + it('renders system status', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText(/operational/i)).toBeInTheDocument(); + }); + + expect(screen.getByText('99.99%')).toBeInTheDocument(); // uptime + expect(screen.getByText('45ms')).toBeInTheDocument(); // api latency + }); + + it('renders quick action buttons', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('Issue New')).toBeInTheDocument(); + }); + + expect(screen.getByText('Export Report')).toBeInTheDocument(); + }); + + it('renders recent activity section', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('Recent Activity')).toBeInTheDocument(); + }); + + // Check for activity items + expect(screen.getByText(/Vaccination verified by Clinic A/)).toBeInTheDocument(); + expect(screen.getByText(/Credential shared with Employer B/)).toBeInTheDocument(); + }); + + it('renders verification trend section', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('Verification Trend (Last 7 Days)')).toBeInTheDocument(); + }); + }); + + it('renders verification rates pie chart', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('Verification Rates')).toBeInTheDocument(); + }); + }); + + it('shows loading while fetch is pending', async () => { + // Make fetch never resolve/reject + global.fetch = jest.fn(() => new Promise(() => {})); + render(); + expect(screen.getByText('Loading analytics...')).toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/frontend/__tests__/credential-details-modal.test.tsx b/frontend/__tests__/credential-details-modal.test.tsx new file mode 100644 index 00000000..f4c5cd05 --- /dev/null +++ b/frontend/__tests__/credential-details-modal.test.tsx @@ -0,0 +1,65 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { CredentialDetailsModal } from '../src/components/credential-details-modal'; + +const mockCredential = { + id: 'cred-001', + vaccineType: 'COVID-19 (Pfizer)', + verificationStatus: true, + vaccinationDate: '2026-07-15', +}; + +describe('CredentialDetailsModal', () => { + it('renders nothing when isOpen is false', () => { + const { container } = render( + + ); + expect(container.innerHTML).toBe(''); + }); + + it('renders nothing when credential is null', () => { + const { container } = render( + + ); + expect(container.innerHTML).toBe(''); + }); + + it('renders credential details when open', () => { + render( + + ); + expect(screen.getByText('Credential Details')).toBeInTheDocument(); + expect(screen.getByText('COVID-19 (Pfizer)')).toBeInTheDocument(); + expect(screen.getByText('Verified')).toBeInTheDocument(); + expect(screen.getByText('2026-07-15')).toBeInTheDocument(); + expect(screen.getByText('cred-001')).toBeInTheDocument(); + }); + + it('shows Pending status when verificationStatus is false', () => { + const pendingCred = { ...mockCredential, verificationStatus: false }; + render( + + ); + expect(screen.getByText('Pending')).toBeInTheDocument(); + }); + + it('calls onClose when close button is clicked', () => { + const onClose = jest.fn(); + render( + + ); + const closeButton = screen.getByLabelText('Close modal'); + fireEvent.click(closeButton); + expect(onClose).toHaveBeenCalled(); + }); + + it('calls onClose when backdrop is clicked', () => { + const onClose = jest.fn(); + const { container } = render( + + ); + // The backdrop is the first motion.div with onClick={onClose} + const backdrops = container.querySelectorAll('.fixed.inset-0'); + expect(backdrops.length).toBeGreaterThan(0); + }); +}); \ No newline at end of file diff --git a/frontend/__tests__/credential-edit-modal.test.tsx b/frontend/__tests__/credential-edit-modal.test.tsx new file mode 100644 index 00000000..d94dae29 --- /dev/null +++ b/frontend/__tests__/credential-edit-modal.test.tsx @@ -0,0 +1,75 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { CredentialEditModal } from '../src/components/credential-edit-modal'; + +const mockCredential = { + id: 'cred-001', + vaccineType: 'COVID-19 (Pfizer)', + verificationStatus: true, + vaccinationDate: '2026-07-15', +}; + +describe('CredentialEditModal', () => { + it('renders nothing when isOpen is false', () => { + const { container } = render( + + ); + expect(container.innerHTML).toBe(''); + }); + + it('renders nothing when credential is null', () => { + const { container } = render( + + ); + expect(container.innerHTML).toBe(''); + }); + + it('renders edit form with credential values', () => { + render( + + ); + expect(screen.getByText('Edit Metadata')).toBeInTheDocument(); + expect(screen.getByDisplayValue('COVID-19 (Pfizer)')).toBeInTheDocument(); + expect(screen.getByDisplayValue('2026-07-15')).toBeInTheDocument(); + expect(screen.getByText('Save Changes')).toBeInTheDocument(); + expect(screen.getByText('Cancel')).toBeInTheDocument(); + }); + + it('calls onSave with updated credential on form submit', () => { + const onSave = jest.fn(); + const onClose = jest.fn(); + render( + + ); + + const vaccineInput = screen.getByDisplayValue('COVID-19 (Pfizer)'); + fireEvent.change(vaccineInput, { target: { value: 'COVID-19 (Moderna)' } }); + + fireEvent.click(screen.getByText('Save Changes')); + + expect(onSave).toHaveBeenCalledWith({ + ...mockCredential, + vaccineType: 'COVID-19 (Moderna)', + }); + expect(onClose).toHaveBeenCalled(); + }); + + it('calls onClose when Cancel button is clicked', () => { + const onClose = jest.fn(); + render( + + ); + fireEvent.click(screen.getByText('Cancel')); + expect(onClose).toHaveBeenCalled(); + }); + + it('calls onClose when close button is clicked', () => { + const onClose = jest.fn(); + render( + + ); + const closeButton = screen.getByLabelText('Close modal'); + fireEvent.click(closeButton); + expect(onClose).toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/frontend/__tests__/credential-metadata-display.test.tsx b/frontend/__tests__/credential-metadata-display.test.tsx new file mode 100644 index 00000000..e7cea478 --- /dev/null +++ b/frontend/__tests__/credential-metadata-display.test.tsx @@ -0,0 +1,167 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { CredentialMetadataDisplay } from '../src/components/credential-metadata-display'; +import { AccessibilityProvider } from '../src/contexts/AccessibilityContext'; + +function renderWithProviders(ui: React.ReactElement) { + return render({ui}); +} + +const mockCredentials = [ + { + id: 'cred-1', + name: 'COVID-19 Vaccination', + type: 'vaccination', + issuer: 'issuer-1', + issuerName: 'City Health Department', + issuedAt: '2026-01-15T00:00:00Z', + updatedAt: '2026-06-15T00:00:00Z', + expiresAt: '2027-01-15T00:00:00Z', + status: 'active' as const, + description: 'COVID-19 vaccination record', + version: '1.0', + schema: 'https://example.com/schema/vaccination', + proofType: 'BBS+', + signatureAlgorithm: 'Ed25519', + history: [ + { + timestamp: '2026-06-15T10:00:00Z', + action: 'Updated', + field: 'status', + oldValue: 'pending', + newValue: 'active', + performedBy: 'admin@health.gov', + }, + ], + }, + { + id: 'cred-2', + name: 'Flu Shot 2025', + type: 'vaccination', + issuer: 'issuer-2', + issuerName: 'Wellness Center', + issuedAt: '2025-10-01T00:00:00Z', + updatedAt: '2025-10-01T00:00:00Z', + status: 'expired' as const, + proofType: 'Ed25519', + signatureAlgorithm: 'Ed25519', + }, + { + id: 'cred-3', + name: 'Hepatitis B', + type: 'vaccination', + issuer: 'issuer-3', + issuerName: 'General Hospital', + issuedAt: '2024-03-10T00:00:00Z', + updatedAt: '2024-03-10T00:00:00Z', + status: 'revoked' as const, + proofType: 'Ed25519', + signatureAlgorithm: 'Ed25519', + }, +]; + +describe('CredentialMetadataDisplay', () => { + it('renders the heading', () => { + renderWithProviders(); + expect(screen.getByText('Credential Metadata')).toBeInTheDocument(); + }); + + it('renders credential names', () => { + renderWithProviders(); + expect(screen.getByText('COVID-19 Vaccination')).toBeInTheDocument(); + expect(screen.getByText('Flu Shot 2025')).toBeInTheDocument(); + expect(screen.getByText('Hepatitis B')).toBeInTheDocument(); + }); + + it('renders issuer names', () => { + renderWithProviders(); + expect(screen.getByText('City Health Department')).toBeInTheDocument(); + expect(screen.getByText('Wellness Center')).toBeInTheDocument(); + expect(screen.getByText('General Hospital')).toBeInTheDocument(); + }); + + it('renders summary section with totals', () => { + renderWithProviders(); + expect(screen.getByText('Total Credentials')).toBeInTheDocument(); + // Active appears in multiple places, use getAllByText + const activeElements = screen.getAllByText('Active'); + expect(activeElements.length).toBeGreaterThanOrEqual(1); + expect(screen.getByText('Unique Issuers')).toBeInTheDocument(); + expect(screen.getByText('Credential Types')).toBeInTheDocument(); + }); + + it('renders expired/revoked alert badges', () => { + renderWithProviders(); + expect(screen.getByText('1 Expired')).toBeInTheDocument(); + expect(screen.getByText('1 Revoked')).toBeInTheDocument(); + }); + + it('renders search input', () => { + renderWithProviders(); + expect(screen.getByPlaceholderText('Search by name, issuer, type, or description...')).toBeInTheDocument(); + }); + + it('filters credentials by search query', () => { + renderWithProviders(); + const searchInput = screen.getByPlaceholderText('Search by name, issuer, type, or description...'); + fireEvent.change(searchInput, { target: { value: 'Flu' } }); + expect(screen.getByText('Flu Shot 2025')).toBeInTheDocument(); + expect(screen.queryByText('COVID-19 Vaccination')).not.toBeInTheDocument(); + expect(screen.queryByText('Hepatitis B')).not.toBeInTheDocument(); + }); + + it('shows empty state when no credentials match filter', () => { + renderWithProviders(); + const searchInput = screen.getByPlaceholderText('Search by name, issuer, type, or description...'); + fireEvent.change(searchInput, { target: { value: 'ZZZNonexistent' } }); + // When no results, the filtered credentials list is empty + expect(screen.getByText(/Showing 0 of/)).toBeInTheDocument(); + }); + + it('shows empty state when credentials array is empty', () => { + renderWithProviders(); + // When array is empty, the component should show an empty state + expect(screen.getByText('Credential Metadata')).toBeInTheDocument(); + }); + + it('renders filter dropdown', () => { + renderWithProviders(); + expect(screen.getByLabelText('Filter by status')).toBeInTheDocument(); + expect(screen.getByText('All Status')).toBeInTheDocument(); + }); + + it('filters by status via select', () => { + renderWithProviders(); + const filterSelect = screen.getByLabelText('Filter by status'); + fireEvent.change(filterSelect, { target: { value: 'active' } }); + expect(screen.getByText('COVID-19 Vaccination')).toBeInTheDocument(); + expect(screen.queryByText('Flu Shot 2025')).not.toBeInTheDocument(); + expect(screen.queryByText('Hepatitis B')).not.toBeInTheDocument(); + }); + + it('renders select all checkbox', () => { + renderWithProviders(); + const selectAllCheck = screen.getByLabelText('Select all credentials'); + expect(selectAllCheck).toBeInTheDocument(); + }); + + it('renders compare button', () => { + renderWithProviders(); + expect(screen.getByText('Compare')).toBeInTheDocument(); + }); + + it('renders refresh button', () => { + renderWithProviders(); + expect(screen.getByText('Refresh')).toBeInTheDocument(); + }); + + it('expands credential details when clicked', () => { + renderWithProviders(); + // Click on the first credential name to expand + fireEvent.click(screen.getByText('COVID-19 Vaccination')); + // After expansion, extra details should appear + expect(screen.getByText('Description')).toBeInTheDocument(); + expect(screen.getByText('Technical Details')).toBeInTheDocument(); + expect(screen.getByText('Change History')).toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/frontend/__tests__/deletion-confirmation-modal.test.tsx b/frontend/__tests__/deletion-confirmation-modal.test.tsx new file mode 100644 index 00000000..d9e6b576 --- /dev/null +++ b/frontend/__tests__/deletion-confirmation-modal.test.tsx @@ -0,0 +1,168 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { DeletionConfirmationModal } from '../src/components/deletion-confirmation-modal'; +import { AccessibilityProvider } from '../src/contexts/AccessibilityContext'; + +function renderWithProviders(ui: React.ReactElement) { + return render({ui}); +} + +const mockCredential = { + id: 'cred-001', + vaccineType: 'COVID-19 (Pfizer)', + verificationStatus: true, + vaccinationDate: '2026-07-15', +}; + +describe('DeletionConfirmationModal', () => { + it('renders nothing when isOpen is false', () => { + const { container } = renderWithProviders( + + ); + expect(container.innerHTML).toBe(''); + }); + + it('renders nothing when credential is null', () => { + const { container } = renderWithProviders( + + ); + expect(container.innerHTML).toBe(''); + }); + + it('renders credential details when open', () => { + renderWithProviders( + + ); + expect(screen.getByText('Delete Credential')).toBeInTheDocument(); + expect(screen.getByText('COVID-19 (Pfizer)')).toBeInTheDocument(); + expect(screen.getByText('Verified')).toBeInTheDocument(); + expect(screen.getByText('2026-07-15')).toBeInTheDocument(); + }); + + it('disables delete button until DELETE is typed', () => { + renderWithProviders( + + ); + const deleteButton = screen.getByText('Delete Permanently'); + expect(deleteButton).toBeDisabled(); + + const input = screen.getByPlaceholderText('Type DELETE'); + fireEvent.change(input, { target: { value: 'DELETE' } }); + // Re-query the button after state change (component re-renders) + const enabledButton = screen.getByText('Delete Permanently'); + expect(enabledButton).not.toBeDisabled(); + }); + + it('calls onConfirm when delete button is clicked with confirmation', () => { + const onConfirm = jest.fn(); + renderWithProviders( + + ); + const input = screen.getByPlaceholderText('Type DELETE'); + fireEvent.change(input, { target: { value: 'DELETE' } }); + fireEvent.click(screen.getByText('Delete Permanently')); + expect(onConfirm).toHaveBeenCalled(); + }); + + it('calls onCancel when Cancel button is clicked', () => { + const onCancel = jest.fn(); + renderWithProviders( + + ); + fireEvent.click(screen.getByText('Cancel')); + expect(onCancel).toHaveBeenCalled(); + }); + + it('shows deleting state', () => { + renderWithProviders( + + ); + expect(screen.getByText('Deleting credential...')).toBeInTheDocument(); + }); + + it('shows deleted state', () => { + renderWithProviders( + + ); + expect(screen.getByText('Credential deleted')).toBeInTheDocument(); + }); + + it('shows failed state', () => { + renderWithProviders( + + ); + expect(screen.getByText('Deletion failed')).toBeInTheDocument(); + }); + + it('shows undoable state with undo button', () => { + const onUndo = jest.fn(); + renderWithProviders( + + ); + expect(screen.getByText(/You can undo this action within/)).toBeInTheDocument(); + expect(screen.getByText('Undo Delete')).toBeInTheDocument(); + fireEvent.click(screen.getByText('Undo Delete')); + expect(onUndo).toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/frontend/__tests__/error-boundary.test.tsx b/frontend/__tests__/error-boundary.test.tsx new file mode 100644 index 00000000..68bf7e36 --- /dev/null +++ b/frontend/__tests__/error-boundary.test.tsx @@ -0,0 +1,89 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { ErrorBoundary } from '../src/components/error-boundary'; + +// Mock the error-handling utility +jest.mock('../src/utils/error-handling', () => ({ + logError: jest.fn(), +})); + +const GoodComponent = () =>
Everything is fine
; + +const BadComponent = () => { + throw new Error('Test error'); +}; + +describe('ErrorBoundary', () => { + beforeEach(() => { + jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('renders children when there is no error', () => { + render( + + + + ); + expect(screen.getByText('Everything is fine')).toBeInTheDocument(); + }); + + it('renders error state when a child throws', () => { + render( + + + + ); + expect(screen.getByText('Something went wrong')).toBeInTheDocument(); + expect(screen.getByText('Test error')).toBeInTheDocument(); + expect(screen.getByText('Try Again')).toBeInTheDocument(); + }); + + it('renders custom fallback when provided', () => { + render( + Custom fallback}> + + + ); + expect(screen.getByText('Custom fallback')).toBeInTheDocument(); + expect(screen.queryByText('Something went wrong')).not.toBeInTheDocument(); + }); + + it('resets error state when Try Again is clicked', () => { + render( + + + + ); + expect(screen.getByText('Something went wrong')).toBeInTheDocument(); + fireEvent.click(screen.getByText('Try Again')); + // After reset, the component re-renders and will throw again since BadComponent always throws + expect(screen.getByText('Something went wrong')).toBeInTheDocument(); + }); + + it('calls onReset when provided and Try Again is clicked', () => { + const onReset = jest.fn(); + render( + + + + ); + fireEvent.click(screen.getByText('Try Again')); + expect(onReset).toHaveBeenCalled(); + }); + + it('shows generic error message when no error message is available', () => { + const ThrowsNull = () => { + throw null; + }; + render( + + + + ); + expect(screen.getByText('An unexpected error occurred while loading this component.')).toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/frontend/__tests__/health-credential-vault.test.tsx b/frontend/__tests__/health-credential-vault.test.tsx new file mode 100644 index 00000000..f26e8d10 --- /dev/null +++ b/frontend/__tests__/health-credential-vault.test.tsx @@ -0,0 +1,65 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { HealthCredentialVault } from '../src/components/health-credential-vault'; +import { AccessibilityProvider } from '../src/contexts/AccessibilityContext'; + +// Mock crypto.randomUUID +const mockUUID = 'test-uuid-12345'; +global.crypto.randomUUID = jest.fn(() => mockUUID); + +function renderWithProviders(ui: React.ReactElement) { + return render({ui}); +} + +describe('HealthCredentialVault', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('renders the vault heading', () => { + renderWithProviders(); + expect(screen.getByText('Health Credential Vault')).toBeInTheDocument(); + }); + + it('renders upload area', () => { + renderWithProviders(); + expect(screen.getByText('Upload your vaccination records')).toBeInTheDocument(); + expect(screen.getByText('Select File')).toBeInTheDocument(); + }); + + it('shows empty state when no credentials exist', () => { + renderWithProviders(); + expect(screen.getByText('No health credentials uploaded yet')).toBeInTheDocument(); + }); + + it('has file upload input accessible via label', () => { + renderWithProviders(); + const fileInput = screen.getByLabelText('Upload vaccination record file'); + expect(fileInput).toBeInTheDocument(); + expect(fileInput).toHaveAttribute('type', 'file'); + }); + + it('renders with correct aria region', () => { + renderWithProviders(); + const region = screen.getByRole('region', { name: 'Health Credential Vault' }); + expect(region).toBeInTheDocument(); + }); + + it('renders file upload area', () => { + renderWithProviders(); + const uploadRegion = screen.getByRole('region', { name: 'File upload area' }); + expect(uploadRegion).toBeInTheDocument(); + }); + + it('does not render credential list items when empty', () => { + renderWithProviders(); + const list = screen.getByRole('list', { name: 'Uploaded credentials' }); + expect(list).toBeInTheDocument(); + // Empty state should show + expect(screen.getByText('No health credentials uploaded yet')).toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/frontend/__tests__/notification-bell.test.tsx b/frontend/__tests__/notification-bell.test.tsx new file mode 100644 index 00000000..61908b34 --- /dev/null +++ b/frontend/__tests__/notification-bell.test.tsx @@ -0,0 +1,74 @@ +import React from 'react'; +import { render, screen, fireEvent, act } from '@testing-library/react'; +import { NotificationBell } from '../src/components/NotificationBell'; +import { AccessibilityProvider } from '../src/contexts/AccessibilityContext'; +import { NotificationProvider } from '../src/contexts/NotificationContext'; + +function renderWithProviders(ui: React.ReactElement) { + return render( + + + {ui} + + + ); +} + +describe('NotificationBell', () => { + beforeEach(() => { + localStorage.clear(); + }); + + it('renders the bell button', () => { + renderWithProviders(); + expect(screen.getByLabelText('Notifications')).toBeInTheDocument(); + }); + + it('shows unread count badge when there are unread notifications', () => { + // Add a notification to the context + renderWithProviders(); + // By default, no notifications + const bellButton = screen.getByLabelText('Notifications'); + expect(bellButton).toBeInTheDocument(); + }); + + it('opens the notifications panel when clicked', () => { + renderWithProviders(); + const bellButton = screen.getByLabelText('Notifications'); + fireEvent.click(bellButton); + expect(screen.getByText('Notifications')).toBeInTheDocument(); + expect(screen.getByText('No notifications yet')).toBeInTheDocument(); + }); + + it('closes panel when clicking outside', () => { + renderWithProviders(); + const bellButton = screen.getByLabelText('Notifications'); + fireEvent.click(bellButton); + expect(screen.getByText('Notifications')).toBeInTheDocument(); + fireEvent.mouseDown(document.body); + expect(screen.queryByText('Notifications')).not.toBeInTheDocument(); + }); + + it('closes panel on Escape key', () => { + renderWithProviders(); + const bellButton = screen.getByLabelText('Notifications'); + fireEvent.click(bellButton); + expect(screen.getByText('Notifications')).toBeInTheDocument(); + fireEvent.keyDown(document, { key: 'Escape' }); + expect(screen.queryByText('Notifications')).not.toBeInTheDocument(); + }); + + it('has correct aria attributes on the bell button', () => { + renderWithProviders(); + const bellButton = screen.getByLabelText('Notifications'); + expect(bellButton).toHaveAttribute('aria-haspopup', 'true'); + expect(bellButton).toHaveAttribute('aria-expanded', 'false'); + }); + + it('updates aria-expanded when panel is opened', () => { + renderWithProviders(); + const bellButton = screen.getByLabelText('Notifications'); + fireEvent.click(bellButton); + expect(bellButton).toHaveAttribute('aria-expanded', 'true'); + }); +}); \ No newline at end of file diff --git a/frontend/__tests__/notification-preferences.test.tsx b/frontend/__tests__/notification-preferences.test.tsx new file mode 100644 index 00000000..a110de5a --- /dev/null +++ b/frontend/__tests__/notification-preferences.test.tsx @@ -0,0 +1,90 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { NotificationPreferences } from '../src/components/NotificationPreferences'; +import { AccessibilityProvider } from '../src/contexts/AccessibilityContext'; +import { NotificationProvider } from '../src/contexts/NotificationContext'; + +function renderWithProviders(ui: React.ReactElement) { + return render( + + + {ui} + + + ); +} + +describe('NotificationPreferences', () => { + beforeEach(() => { + localStorage.clear(); + }); + + it('renders the preferences heading', () => { + renderWithProviders(); + expect(screen.getByText('Notification Channels')).toBeInTheDocument(); + expect(screen.getByText('Alert Types')).toBeInTheDocument(); + }); + + it('renders channel options', () => { + renderWithProviders(); + expect(screen.getByText('In-App Notifications')).toBeInTheDocument(); + expect(screen.getByText('Push Notifications')).toBeInTheDocument(); + expect(screen.getByText('Email Notifications')).toBeInTheDocument(); + }); + + it('renders alert type options', () => { + renderWithProviders(); + expect(screen.getByText('Credential Expiry')).toBeInTheDocument(); + expect(screen.getByText('Verification Complete')).toBeInTheDocument(); + expect(screen.getByText('Sharing Requests')).toBeInTheDocument(); + }); + + it('toggles in-app notifications checkbox', () => { + renderWithProviders(); + const inAppCheckbox = screen.getByLabelText('In-app notifications'); + expect(inAppCheckbox).toBeChecked(); + fireEvent.click(inAppCheckbox); + expect(inAppCheckbox).not.toBeChecked(); + }); + + it('shows email input when email notifications are enabled', () => { + renderWithProviders(); + // Email is disabled by default, so enable it first + const emailCheckbox = screen.getByLabelText('Email notifications'); + expect(emailCheckbox).not.toBeChecked(); + fireEvent.click(emailCheckbox); + expect(emailCheckbox).toBeChecked(); + // Now email input should appear + expect(screen.getByPlaceholderText('your@email.com')).toBeInTheDocument(); + }); + + it('hides email input when email notifications are disabled', () => { + renderWithProviders(); + // Email is disabled by default, so no input + expect(screen.queryByPlaceholderText('your@email.com')).not.toBeInTheDocument(); + }); + + it('updates email address input', () => { + renderWithProviders(); + // Enable email first + const emailCheckbox = screen.getByLabelText('Email notifications'); + fireEvent.click(emailCheckbox); + const emailInput = screen.getByPlaceholderText('your@email.com'); + fireEvent.change(emailInput, { target: { value: 'test@example.com' } }); + expect(emailInput).toHaveValue('test@example.com'); + }); + + it('renders Enable button for push when push is disabled', () => { + renderWithProviders(); + // push defaults to false + expect(screen.getByLabelText('Enable push notifications')).toBeInTheDocument(); + }); + + it('toggles credential expiry checkbox', () => { + renderWithProviders(); + const expiryCheckbox = screen.getByLabelText('Credential expiry notifications'); + expect(expiryCheckbox).toBeChecked(); + fireEvent.click(expiryCheckbox); + expect(expiryCheckbox).not.toBeChecked(); + }); +}); \ No newline at end of file diff --git a/frontend/__tests__/vaccination-verification-center.test.tsx b/frontend/__tests__/vaccination-verification-center.test.tsx new file mode 100644 index 00000000..fee1dc6e --- /dev/null +++ b/frontend/__tests__/vaccination-verification-center.test.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { VaccinationVerificationCenter } from '../src/components/vaccination-verification-center'; +import { AccessibilityProvider } from '../src/contexts/AccessibilityContext'; + +function renderWithProviders(ui: React.ReactElement) { + return render({ui}); +} + +describe('VaccinationVerificationCenter', () => { + it('renders the heading', () => { + renderWithProviders(); + expect(screen.getByText('Vaccination Verification Center')).toBeInTheDocument(); + }); + + it('renders stats', () => { + renderWithProviders(); + // Mock data: 1 approved, 1 pending, 1 rejected + // Stats and badges both show "Verified", "Pending", "Rejected" - use getAllByText + expect(screen.getAllByText('Verified').length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText('Pending').length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText('Rejected').length).toBeGreaterThanOrEqual(1); + }); + + it('renders count values', () => { + renderWithProviders(); + // Stats: 1 approved, 1 pending, 1 rejected + const stats = screen.getAllByRole('status'); + expect(stats.length).toBeGreaterThanOrEqual(3); + }); + + it('renders verification items', () => { + renderWithProviders(); + expect(screen.getByText('COVID-19 (Pfizer)')).toBeInTheDocument(); + expect(screen.getByText('Influenza 2025')).toBeInTheDocument(); + expect(screen.getByText('Hepatitis B')).toBeInTheDocument(); + }); + + it('renders status badges', () => { + renderWithProviders(); + // Status labels are shown in the badge + expect(screen.getAllByText('Approved').length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText('Rejected').length).toBeGreaterThanOrEqual(1); + }); + + it('renders date labels', () => { + renderWithProviders(); + // Mock data dates formatted as "Submitted: M/D/YYYY" + expect(screen.getByText(/7\/15\/2026/)).toBeInTheDocument(); + expect(screen.getByText(/7\/18\/2026/)).toBeInTheDocument(); + expect(screen.getByText(/7\/10\/2026/)).toBeInTheDocument(); + }); + + it('renders verification list', () => { + renderWithProviders(); + // The list has aria-label="Verification requests" but no visible heading + const list = screen.getByRole('list', { name: /Verification requests/i }); + expect(list).toBeInTheDocument(); + }); +}); \ No newline at end of file