diff --git a/src/components/__tests__/ErrorLayout.test.tsx b/src/components/__tests__/ErrorLayout.test.tsx new file mode 100644 index 0000000..7fd2bf9 --- /dev/null +++ b/src/components/__tests__/ErrorLayout.test.tsx @@ -0,0 +1,85 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' +import React from 'react' +import type { ReactNode } from 'react' +import { fireEvent, render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import ErrorButton from '@/components/ErrorButton' +import ErrorLayout from '@/components/ErrorLayout' + +vi.mock('next/link', () => ({ + default: ({ + href, + children, + className, + }: { + href: string + children: ReactNode + className?: string + }) => React.createElement('a', { href, className }, children), +})) + +describe('ErrorLayout', () => { + it('renders error page content inside the shared layout shell', () => { + render( + React.createElement( + ErrorLayout, + null, + React.createElement('h1', null, 'Recoverable error'), + ), + ) + + expect(screen.getByRole('heading', { level: 1, name: 'Recoverable error' })).toBeInTheDocument() + }) +}) + +describe('ErrorButton', () => { + it('renders an internal recovery link', () => { + render(React.createElement(ErrorButton, { href: '/dashboard' }, 'Dashboard')) + + expect(screen.getByRole('link', { name: 'Dashboard' })).toHaveAttribute('href', '/dashboard') + }) + + it('renders an external support link safely', () => { + render( + React.createElement( + ErrorButton, + { + href: 'https://stellar.org/contact', + isExternal: true, + variant: 'secondary', + }, + 'Report Issue', + ), + ) + + const link = screen.getByRole('link', { name: 'Report Issue' }) + expect(link).toHaveAttribute('href', 'https://stellar.org/contact') + expect(link).toHaveAttribute('target', '_blank') + expect(link).toHaveAttribute('rel', 'noopener noreferrer') + }) + + it('calls click handlers for button recovery actions', () => { + const onClick = vi.fn() + + render(React.createElement(ErrorButton, { onClick }, 'Try Again')) + + fireEvent.click(screen.getByRole('button', { name: 'Try Again' })) + + expect(onClick).toHaveBeenCalledTimes(1) + }) + + it('supports disabled recovery buttons', () => { + const onClick = vi.fn() + + render(React.createElement(ErrorButton, { onClick, disabled: true }, 'Retrying...')) + + const button = screen.getByRole('button', { name: 'Retrying...' }) + expect(button).toBeDisabled() + + fireEvent.click(button) + + expect(onClick).not.toHaveBeenCalled() + }) +}) diff --git a/tests/error-page.test.tsx b/tests/error-page.test.tsx new file mode 100644 index 0000000..3777434 --- /dev/null +++ b/tests/error-page.test.tsx @@ -0,0 +1,54 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' +import React from 'react' +import type { ReactNode } from 'react' +import { fireEvent, render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import ErrorPage from '@/app/error' + +vi.mock('next/link', () => ({ + default: ({ + href, + children, + className, + }: { + href: string + children: ReactNode + className?: string + }) => React.createElement('a', { href, className }, children), +})) + +describe('app error page', () => { + it('renders server error details and recovery actions', () => { + const reset = vi.fn() + const error = Object.assign(new Error('Database connection failed'), { + digest: 'ERR-123', + }) + + render(React.createElement(ErrorPage, { error, reset })) + + expect(screen.getByText('500')).toBeInTheDocument() + expect(screen.getByRole('heading', { level: 1, name: 'Something Went Wrong' })).toBeInTheDocument() + expect(screen.getByText('Database connection failed')).toBeInTheDocument() + expect(screen.getByText('Error ID: ERR-123')).toBeInTheDocument() + expect(screen.getByRole('link', { name: 'Go Home' })).toHaveAttribute('href', '/') + expect(screen.getByRole('link', { name: 'Report Issue' })).toHaveAttribute( + 'href', + 'https://stellar.org/contact', + ) + + fireEvent.click(screen.getByRole('button', { name: 'Try Again' })) + + expect(reset).toHaveBeenCalledTimes(1) + }) + + it('falls back when an error has no message', () => { + render(React.createElement(ErrorPage, { + error: Object.assign(new Error(''), { message: '' }), + reset: vi.fn(), + })) + + expect(screen.getByText('An unexpected error occurred')).toBeInTheDocument() + }) +}) diff --git a/tests/network-error-page.test.tsx b/tests/network-error-page.test.tsx new file mode 100644 index 0000000..68ed02a --- /dev/null +++ b/tests/network-error-page.test.tsx @@ -0,0 +1,61 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' +import React from 'react' +import type { ReactNode } from 'react' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import NetworkError from '@/app/network-error/page' + +vi.mock('next/link', () => ({ + default: ({ + href, + children, + className, + }: { + href: string + children: ReactNode + className?: string + }) => React.createElement('a', { href, className }, children), +})) + +describe('network error page', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('renders troubleshooting guidance and recovery actions', () => { + render(React.createElement(NetworkError)) + + expect(screen.getByRole('heading', { level: 1, name: 'Connection Error' })).toBeInTheDocument() + expect(screen.getByRole('heading', { level: 2, name: 'What you can do:' })).toBeInTheDocument() + expect(screen.getByText("Check that you're connected to the internet")).toBeInTheDocument() + expect(screen.getByText('No internet connection detected')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Retry' })).toBeEnabled() + expect(screen.getByRole('link', { name: 'Go Home' })).toHaveAttribute('href', '/') + }) + + it('shows retrying status and disables retry while connectivity is being checked', () => { + vi.stubGlobal('fetch', vi.fn(() => new Promise(() => undefined))) + + render(React.createElement(NetworkError)) + + fireEvent.click(screen.getByRole('button', { name: 'Retry' })) + + expect(screen.getByText('Checking connection...')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Retrying...' })).toBeDisabled() + }) + + it('restores the idle status when the connectivity check fails', async () => { + vi.stubGlobal('fetch', vi.fn(() => Promise.reject(new Error('offline')))) + + render(React.createElement(NetworkError)) + + fireEvent.click(screen.getByRole('button', { name: 'Retry' })) + + await waitFor(() => { + expect(screen.getByText('No internet connection detected')).toBeInTheDocument() + }) + expect(screen.getByRole('button', { name: 'Retry' })).toBeEnabled() + }) +}) diff --git a/tests/not-found-page.test.tsx b/tests/not-found-page.test.tsx new file mode 100644 index 0000000..875967e --- /dev/null +++ b/tests/not-found-page.test.tsx @@ -0,0 +1,60 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' +import React from 'react' +import type { ReactNode } from 'react' +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import NotFound from '@/app/not-found' + +const routerBack = vi.fn() + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ + back: routerBack, + }), +})) + +vi.mock('next/link', () => ({ + default: ({ + href, + children, + className, + }: { + href: string + children: ReactNode + className?: string + }) => React.createElement('a', { href, className }, children), +})) + +describe('not found page', () => { + beforeEach(() => { + routerBack.mockClear() + }) + + it('renders the 404 message, search input, and recovery links', () => { + render(React.createElement(NotFound)) + + expect(screen.getByText('404')).toBeInTheDocument() + expect(screen.getByRole('heading', { level: 1, name: 'Page Not Found' })).toBeInTheDocument() + expect(screen.getByPlaceholderText('Search the site...')).toBeInTheDocument() + expect(screen.getByRole('link', { name: 'Go Home' })).toHaveAttribute('href', '/') + }) + + it('routes back from the secondary recovery button', () => { + render(React.createElement(NotFound)) + + fireEvent.click(screen.getByRole('button', { name: 'Go Back' })) + + expect(routerBack).toHaveBeenCalledTimes(1) + }) + + it('accepts search text in the site search box', () => { + render(React.createElement(NotFound)) + + const search = screen.getByPlaceholderText('Search the site...') + fireEvent.change(search, { target: { value: 'escrow status' } }) + + expect(search).toHaveValue('escrow status') + }) +})