diff --git a/apps/web/src/components/ErrorBoundary.test.tsx b/apps/web/src/components/ErrorBoundary.test.tsx new file mode 100644 index 0000000..394f572 --- /dev/null +++ b/apps/web/src/components/ErrorBoundary.test.tsx @@ -0,0 +1,93 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { createMemoryRouter, RouterProvider } from 'react-router'; +import { RootErrorBoundary } from './ErrorBoundary.tsx'; + +vi.mock('@/utils/logger.ts', () => ({ + createLogger: () => ({ + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + trace: vi.fn(), + fatal: vi.fn(), + }), +})); + +vi.mock('../contexts/I18nContext.tsx', () => ({ + useTranslation: () => ({ + t: (k: string) => k, + locale: 'en', + setLocale: vi.fn(), + availableLocales: ['en', 'tr'], + }), +})); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +function renderWithLoader(loader: () => never) { + const router = createMemoryRouter( + [ + { + path: '/', + element:
Page
, + loader, + errorElement: , + }, + ], + { initialEntries: ['/'] }, + ); + return render(); +} + +describe('RootErrorBoundary', () => { + it('renders NotFoundPage for 404 route errors', async () => { + renderWithLoader(() => { + throw new Response(null, { status: 404 }); + }); + + expect(await screen.findByText('404')).toBeInTheDocument(); + expect(screen.getByText('error.404')).toBeInTheDocument(); + }); + + it('renders ServerErrorPage for 500 route errors', async () => { + renderWithLoader(() => { + throw new Response(null, { status: 500 }); + }); + + expect(await screen.findByText('500')).toBeInTheDocument(); + expect(screen.getByText('error.500')).toBeInTheDocument(); + }); + + it('renders ServerErrorPage for 503 route errors', async () => { + renderWithLoader(() => { + throw new Response(null, { status: 503 }); + }); + + expect(await screen.findByText('500')).toBeInTheDocument(); + }); + + it('renders generic fallback with t() strings for non-route errors', async () => { + const router = createMemoryRouter( + [ + { + path: '/', + element: , + errorElement: , + }, + ], + { initialEntries: ['/'] }, + ); + render(); + + expect(await screen.findByText('error.boundary.oops')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'common.goHome' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'error.boundary.reload' })).toBeInTheDocument(); + }); +}); + +function ThrowError() { + throw new Error('boom'); +} diff --git a/apps/web/src/components/ErrorBoundary.tsx b/apps/web/src/components/ErrorBoundary.tsx index c0aa8ad..4ba1af8 100644 --- a/apps/web/src/components/ErrorBoundary.tsx +++ b/apps/web/src/components/ErrorBoundary.tsx @@ -1,15 +1,19 @@ import { isRouteErrorResponse, Link, useRouteError } from 'react-router'; +import { NotFoundPage, ServerErrorPage } from '../pages/ErrorPage.tsx'; +import { useTranslation } from '../contexts/I18nContext.tsx'; import { createLogger } from '@/utils/logger.ts'; const log = createLogger('ErrorBoundary'); /** * Router-level error boundary: logs the caught error and renders a - * full-page fallback (status-aware for route error responses, stack - * trace in dev) with home/reload actions. + * full-page fallback — delegating 404 to `NotFoundPage` and 5xx to + * `ServerErrorPage` from the canonical error-page module, with a + * generic fallback (Go Home + Reload Page) for non-route errors. */ export function RootErrorBoundary() { const error = useRouteError(); + const { t } = useTranslation(); log.error( { err: error, componentStack: error instanceof Error ? error.stack : undefined }, @@ -22,33 +26,8 @@ export function RootErrorBoundary() { } if (isRouteErrorResponse(error)) { - return ( -
-

- {error.status} -

-

- {error.status === 404 - ? "Looks like this cup is empty. The page you're looking for doesn't exist." - : error.statusText || 'Something went wrong.'} -

-
- - Go Home - - -
-
- ); + if (error.status === 404) return ; + if (error.status >= 500) return ; } const message = error instanceof Error ? error.message : 'An unexpected error occurred.'; @@ -59,7 +38,7 @@ export function RootErrorBoundary() { style={{ backgroundColor: 'var(--bg-primary)', color: 'var(--text-primary)' }} >

- Oops + {t('error.boundary.oops')}

{message} @@ -74,14 +53,14 @@ export function RootErrorBoundary() { )}

- Go Home + {t('common.goHome')}
diff --git a/apps/web/src/components/admin/BanDialog.test.tsx b/apps/web/src/components/admin/BanDialog.test.tsx new file mode 100644 index 0000000..016467f --- /dev/null +++ b/apps/web/src/components/admin/BanDialog.test.tsx @@ -0,0 +1,92 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { BanDialog } from './BanDialog.tsx'; + +vi.mock('@/utils/logger.ts', () => ({ + createLogger: () => ({ + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + trace: vi.fn(), + fatal: vi.fn(), + }), +})); + +vi.mock('../../contexts/I18nContext.tsx', () => ({ + useTranslation: () => ({ + t: (k: string) => k, + locale: 'en', + setLocale: vi.fn(), + availableLocales: ['en', 'tr'], + }), +})); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +const defaultProps = { + user: { id: 'u1', username: 'alice', displayName: 'Alice A' }, + open: true, + onClose: vi.fn(), + onConfirm: vi.fn(), + processing: false, +}; + +describe('BanDialog', () => { + it('renders user name (displayName) in title when open', () => { + render(); + expect(screen.getByText(/admin\.users\.banDialogTitle: Alice A/)).toBeInTheDocument(); + const textarea = screen.getByPlaceholderText('admin.users.banReasonPlaceholder'); + expect(textarea).toHaveValue(''); + }); + + it('renders username in title when displayName is null', () => { + render( + , + ); + expect(screen.getByText(/admin\.users\.banDialogTitle: bob/)).toBeInTheDocument(); + }); + + it('confirm button is disabled when reason is empty', () => { + render(); + const confirmButton = screen.getByRole('button', { name: 'admin.users.confirmBan' }); + expect(confirmButton).toBeDisabled(); + }); + + it('typing a reason and clicking confirm calls onConfirm with the reason', async () => { + const user = userEvent.setup(); + render(); + const textarea = screen.getByPlaceholderText('admin.users.banReasonPlaceholder'); + await user.type(textarea, 'Spam'); + const confirmButton = screen.getByRole('button', { name: 'admin.users.confirmBan' }); + await user.click(confirmButton); + expect(defaultProps.onConfirm).toHaveBeenCalledWith('Spam'); + }); + + it('clicking cancel calls onClose', async () => { + const user = userEvent.setup(); + render(); + const cancelButton = screen.getByRole('button', { name: 'common.cancel' }); + await user.click(cancelButton); + expect(defaultProps.onClose).toHaveBeenCalled(); + }); + + it('processing true disables buttons and shows banning label', () => { + render(); + const confirmButton = screen.getByRole('button', { name: 'admin.users.banning' }); + expect(confirmButton).toBeDisabled(); + const cancelButton = screen.getByRole('button', { name: 'common.cancel' }); + expect(cancelButton).toBeDisabled(); + }); + + it('renders nothing when open is false', () => { + render(); + expect(screen.queryByText(/admin\.users\.banDialogTitle/)).not.toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/components/admin/BanDialog.tsx b/apps/web/src/components/admin/BanDialog.tsx new file mode 100644 index 0000000..2f6a453 --- /dev/null +++ b/apps/web/src/components/admin/BanDialog.tsx @@ -0,0 +1,83 @@ +import { useEffect, useState } from 'react'; +import { useTranslation } from '../../contexts/I18nContext.tsx'; +import { createLogger } from '@/utils/logger.ts'; + +const log = createLogger('BanDialog'); + +/** + * Props for the {@link BanDialog} component. + */ +interface BanDialogProps { + /** The user targeted by the ban action. */ + user: { id: string; username: string; displayName: string | null }; + /** Whether the dialog is currently visible. */ + open: boolean; + /** Called when the user requests to close the dialog without confirming. */ + onClose: () => void; + /** Called with the entered reason when the user confirms the ban. */ + onConfirm: (reason: string) => void; + /** When true, both buttons are disabled and the confirm button shows a "banning" label. */ + processing: boolean; +} + +/** + * Controlled modal dialog for confirming a ban action against a user. Owns the + * reason textarea state internally and reports the entered reason via + * `onConfirm`. The parent controls visibility (`open`) and the processing state. + */ +export function BanDialog({ user, open, onClose, onConfirm, processing }: BanDialogProps) { + const { t } = useTranslation(); + const [reason, setReason] = useState(''); + + useEffect(() => { + log.debug({ userId: user.id, open }, 'BanDialog render'); + }, [open]); + + if (!open) return null; + + return ( +
+
+

+ {`${t('admin.users.banDialogTitle')}: ${user.displayName || user.username}`} +

+