diff --git a/apps/web/src/__tests__/components/DocumentPreviewModal.test.tsx b/apps/web/src/__tests__/components/DocumentPreviewModal.test.tsx new file mode 100644 index 00000000..829dc922 --- /dev/null +++ b/apps/web/src/__tests__/components/DocumentPreviewModal.test.tsx @@ -0,0 +1,119 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { + DocumentPreviewModal, + isPreviewableMimeType, +} from '#/routes/_authenticated/applications/$applicationId/-components/DocumentPreviewModal'; + +describe('isPreviewableMimeType', () => { + it('returns true for PDF', () => { + expect(isPreviewableMimeType('application/pdf')).toBe(true); + }); + + it('returns true for PNG', () => { + expect(isPreviewableMimeType('image/png')).toBe(true); + }); + + it('returns true for JPEG', () => { + expect(isPreviewableMimeType('image/jpeg')).toBe(true); + }); + + it('returns false for DOCX', () => { + expect( + isPreviewableMimeType( + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + ), + ).toBe(false); + }); + + it('returns false for plain text', () => { + expect(isPreviewableMimeType('text/plain')).toBe(false); + }); +}); + +describe('DocumentPreviewModal', () => { + const pdfDoc = { + name: 'resume.pdf', + mimeType: 'application/pdf', + url: 'https://example.com/resume.pdf', + }; + const imageDoc = { + name: 'photo.png', + mimeType: 'image/png', + url: 'https://example.com/photo.png', + }; + const unsupportedDoc = { + name: 'report.docx', + mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + url: 'https://example.com/report.docx', + }; + + it('renders nothing when document is null', () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it('renders the document name in the header', () => { + render(); + expect(screen.getByText('resume.pdf')).toBeInTheDocument(); + }); + + it('renders an iframe for PDF documents', () => { + render(); + const iframe = screen.getByTitle('resume.pdf'); + expect(iframe).toHaveAttribute('src', 'https://example.com/resume.pdf'); + }); + + it('renders an img for PNG documents', () => { + render(); + const img = screen.getByAltText('photo.png'); + expect(img).toHaveAttribute('src', 'https://example.com/photo.png'); + }); + + it('renders an img for JPEG documents', () => { + const jpegDoc = { ...imageDoc, name: 'photo.jpg', mimeType: 'image/jpeg' }; + render(); + const img = screen.getByAltText('photo.jpg'); + expect(img).toHaveAttribute('src', 'https://example.com/photo.png'); + }); + + it('renders fallback message for unsupported file types', () => { + render(); + expect(screen.getByText(/preview isn't available/i)).toBeInTheDocument(); + }); + + it('renders a download link for unsupported file types', () => { + render(); + const link = screen.getByRole('link', { name: /open report\.docx/i }); + expect(link).toHaveAttribute('href', 'https://example.com/report.docx'); + expect(link).toHaveAttribute('target', '_blank'); + }); + + it('calls onClose when the close button is clicked', () => { + const onClose = vi.fn(); + render(); + // The X button + const buttons = screen.getAllByRole('button'); + const closeButton = buttons.find((b) => b.querySelector('svg')); + expect(closeButton).toBeDefined(); + fireEvent.click(closeButton!); + expect(onClose).toHaveBeenCalledOnce(); + }); + + it('calls onClose when the backdrop is clicked', () => { + const onClose = vi.fn(); + const { container } = render(); + // The backdrop is the fixed inset-0 div with bg-black/50 + const backdrop = container.querySelector('.bg-black\\/50'); + expect(backdrop).toBeInTheDocument(); + fireEvent.click(backdrop!); + expect(onClose).toHaveBeenCalledOnce(); + }); + + it('renders the "Open in new tab" link', () => { + render(); + const link = screen.getByTitle('Open in new tab'); + expect(link).toHaveAttribute('href', 'https://example.com/resume.pdf'); + expect(link).toHaveAttribute('target', '_blank'); + }); +}); diff --git a/apps/web/src/__tests__/components/ErrorState.test.tsx b/apps/web/src/__tests__/components/ErrorState.test.tsx new file mode 100644 index 00000000..9bd3e16c --- /dev/null +++ b/apps/web/src/__tests__/components/ErrorState.test.tsx @@ -0,0 +1,73 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { ErrorState } from '#/components/ErrorState'; + +describe('ErrorState', () => { + it('renders the generic error message for a plain string error', () => { + render(); + expect(screen.getByText('Something went wrong. Please try again.')).toBeInTheDocument(); + }); + + it('renders the generic error message for an Error instance', () => { + render(); + expect(screen.getByText('Something went wrong. Please try again.')).toBeInTheDocument(); + }); + + it('renders the network error message for a TypeError', () => { + render(); + expect( + screen.getByText("Can't reach the server. Check your connection and try again."), + ).toBeInTheDocument(); + }); + + it('renders the error message from a GraphQL error with a known code', () => { + const gqlError = { + response: { + errors: [{ message: 'Rate limited', extensions: { code: 'RATE_LIMITED' } }], + }, + }; + render(); + expect(screen.getByText('Rate limited')).toBeInTheDocument(); + }); + + it('shows a NOT_FOUND override message', () => { + const gqlError = { + response: { + errors: [{ message: 'Application not found', extensions: { code: 'NOT_FOUND' } }], + }, + }; + render(); + expect( + screen.getByText("That item couldn't be found — it may have been deleted."), + ).toBeInTheDocument(); + }); + + it('shows a FORBIDDEN override message', () => { + const gqlError = { + response: { + errors: [{ message: 'Forbidden', extensions: { code: 'FORBIDDEN' } }], + }, + }; + render(); + expect(screen.getByText("You don't have permission to do that.")).toBeInTheDocument(); + }); + + it('renders the retry button when onRetry is provided', () => { + const onRetry = vi.fn(); + render(); + const btn = screen.getByRole('button', { name: /try again/i }); + expect(btn).toBeInTheDocument(); + fireEvent.click(btn); + expect(onRetry).toHaveBeenCalledOnce(); + }); + + it('does not render the retry button when onRetry is not provided', () => { + render(); + expect(screen.queryByRole('button', { name: /try again/i })).not.toBeInTheDocument(); + }); + + it('renders the alert triangle icon', () => { + const { container } = render(); + expect(container.querySelector('svg')).toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/__tests__/components/JdImportPanel.test.tsx b/apps/web/src/__tests__/components/JdImportPanel.test.tsx new file mode 100644 index 00000000..2619f540 --- /dev/null +++ b/apps/web/src/__tests__/components/JdImportPanel.test.tsx @@ -0,0 +1,181 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { JdImportPanel } from '#/routes/_authenticated/applications/-components/JdImportPanel'; + +const { mockGqlRequest } = vi.hoisted(() => ({ + mockGqlRequest: vi.fn(), +})); + +vi.mock('#/graphql/client', () => ({ + gqlClient: { request: mockGqlRequest }, +})); + +vi.mock('@tanstack/react-router', () => ({ + Link: ({ children, to }: { children: React.ReactNode; to: string }) => ( + {children} + ), +})); + +describe('JdImportPanel', () => { + const onFill = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders the collapsed toggle button', () => { + render(); + expect(screen.getByText('Auto-fill from job posting')).toBeInTheDocument(); + }); + + it('expands the panel when toggle is clicked', () => { + render(); + fireEvent.click(screen.getByText('Auto-fill from job posting')); + expect(screen.getByText('Paste text')).toBeInTheDocument(); + expect(screen.getByText('From URL')).toBeInTheDocument(); + }); + + it('shows the textarea in text mode by default', () => { + render(); + fireEvent.click(screen.getByText('Auto-fill from job posting')); + expect(screen.getByPlaceholderText(/paste the job description/i)).toBeInTheDocument(); + }); + + it('switches to URL mode when "From URL" is clicked', () => { + render(); + fireEvent.click(screen.getByText('Auto-fill from job posting')); + fireEvent.click(screen.getByText('From URL')); + expect(screen.getByPlaceholderText(/https:\/\/company\.com/i)).toBeInTheDocument(); + }); + + it('disables the auto-fill button when input is empty', () => { + render(); + fireEvent.click(screen.getByText('Auto-fill from job posting')); + const btn = screen.getByRole('button', { name: /auto-fill fields/i }); + expect(btn).toBeDisabled(); + }); + + it('enables the auto-fill button when text is entered', () => { + render(); + fireEvent.click(screen.getByText('Auto-fill from job posting')); + fireEvent.change(screen.getByPlaceholderText(/paste the job description/i), { + target: { value: 'Software Engineer at Acme' }, + }); + const btn = screen.getByRole('button', { name: /auto-fill fields/i }); + expect(btn).toBeEnabled(); + }); + + it('calls onFill with parsed data on successful auto-fill', async () => { + mockGqlRequest.mockResolvedValue({ + parseJobDescription: { + company: 'Acme', + role: 'Engineer', + location: 'Remote', + salary: '$100k', + description: 'Build things', + }, + }); + + render(); + fireEvent.click(screen.getByText('Auto-fill from job posting')); + fireEvent.change(screen.getByPlaceholderText(/paste the job description/i), { + target: { value: 'Software Engineer at Acme' }, + }); + fireEvent.click(screen.getByRole('button', { name: /auto-fill fields/i })); + + await waitFor(() => { + expect(onFill).toHaveBeenCalledWith({ + company: 'Acme', + role: 'Engineer', + location: 'Remote', + salary: '$100k', + description: 'Build things', + }); + }); + }); + + it('shows error message on mutation failure', async () => { + mockGqlRequest.mockRejectedValue(new Error('Network error')); + + render(); + fireEvent.click(screen.getByText('Auto-fill from job posting')); + fireEvent.change(screen.getByPlaceholderText(/paste the job description/i), { + target: { value: 'Some job description' }, + }); + fireEvent.click(screen.getByRole('button', { name: /auto-fill fields/i })); + + await waitFor(() => { + expect(screen.getByText('Network error')).toBeInTheDocument(); + }); + }); + + it('shows "Parsing..." while loading', async () => { + let resolvePromise: (value: unknown) => void; + mockGqlRequest.mockReturnValue( + new Promise((resolve) => { + resolvePromise = resolve; + }), + ); + + render(); + fireEvent.click(screen.getByText('Auto-fill from job posting')); + fireEvent.change(screen.getByPlaceholderText(/paste the job description/i), { + target: { value: 'Job description' }, + }); + fireEvent.click(screen.getByRole('button', { name: /auto-fill fields/i })); + + await waitFor(() => { + expect(screen.getByText('Parsing…')).toBeInTheDocument(); + }); + + resolvePromise!({ + parseJobDescription: { + company: null, + role: null, + location: null, + salary: null, + description: null, + }, + }); + }); + + it('shows "Filled!" after successful auto-fill', async () => { + mockGqlRequest.mockResolvedValue({ + parseJobDescription: { + company: null, + role: null, + location: null, + salary: null, + description: null, + }, + }); + + render(); + fireEvent.click(screen.getByText('Auto-fill from job posting')); + fireEvent.change(screen.getByPlaceholderText(/paste the job description/i), { + target: { value: 'Job description' }, + }); + fireEvent.click(screen.getByRole('button', { name: /auto-fill fields/i })); + + await waitFor(() => { + expect(screen.getByText('Filled!')).toBeInTheDocument(); + }); + }); + + it('shows AI not configured message when error code matches', async () => { + mockGqlRequest.mockRejectedValue({ + response: { errors: [{ extensions: { code: 'AI_NOT_CONFIGURED' } }] }, + }); + + render(); + fireEvent.click(screen.getByText('Auto-fill from job posting')); + fireEvent.change(screen.getByPlaceholderText(/paste the job description/i), { + target: { value: 'Job description' }, + }); + fireEvent.click(screen.getByRole('button', { name: /auto-fill fields/i })); + + await waitFor(() => { + expect(screen.getByText(/add your ai api key/i)).toBeInTheDocument(); + }); + }); +}); diff --git a/apps/web/src/__tests__/components/LogoMark.test.tsx b/apps/web/src/__tests__/components/LogoMark.test.tsx new file mode 100644 index 00000000..aea37365 --- /dev/null +++ b/apps/web/src/__tests__/components/LogoMark.test.tsx @@ -0,0 +1,45 @@ +import { describe, it, expect } from 'vitest'; +import { render } from '@testing-library/react'; +import { LogoMark } from '#/components/LogoMark'; + +describe('LogoMark', () => { + it('renders an SVG element', () => { + const { container } = render(); + const svg = container.querySelector('svg'); + expect(svg).toBeInTheDocument(); + }); + + it('uses default size of 24', () => { + const { container } = render(); + const svg = container.querySelector('svg'); + expect(svg).toHaveAttribute('width', '24'); + expect(svg).toHaveAttribute('height', '24'); + }); + + it('accepts a custom size prop', () => { + const { container } = render(); + const svg = container.querySelector('svg'); + expect(svg).toHaveAttribute('width', '48'); + expect(svg).toHaveAttribute('height', '48'); + }); + + it('applies a custom className', () => { + const { container } = render(); + const svg = container.querySelector('svg'); + expect(svg).toHaveClass('my-class'); + }); + + it('has aria-hidden for decorative use', () => { + const { container } = render(); + const svg = container.querySelector('svg'); + expect(svg).toHaveAttribute('aria-hidden', 'true'); + }); + + it('contains the brand blue rectangles and checkmark path', () => { + const { container } = render(); + const rects = container.querySelectorAll('rect'); + expect(rects.length).toBe(3); + const path = container.querySelector('path'); + expect(path).toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/__tests__/components/NavigationProgressBar.test.tsx b/apps/web/src/__tests__/components/NavigationProgressBar.test.tsx new file mode 100644 index 00000000..8304ce32 --- /dev/null +++ b/apps/web/src/__tests__/components/NavigationProgressBar.test.tsx @@ -0,0 +1,58 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, act } from '@testing-library/react'; + +const { mockStart, mockDone } = vi.hoisted(() => ({ + mockStart: vi.fn(), + mockDone: vi.fn(), +})); + +vi.mock('nprogress', () => ({ + default: { + configure: vi.fn(), + start: mockStart, + done: mockDone, + }, +})); + +let mockStatus: string; + +vi.mock('@tanstack/react-router', () => ({ + useRouterState: ({ select }: { select: (s: { status: string }) => unknown }) => + select({ status: mockStatus }), +})); + +const NavigationProgressBar = (await import('#/components/NavigationProgressBar')) + .NavigationProgressBar; + +describe('NavigationProgressBar', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockStatus = 'idle'; + }); + + it('renders nothing (null)', async () => { + let result: ReturnType; + await act(async () => { + result = render(); + }); + expect(result!.container.firstChild).toBeNull(); + }); + + it('does not call NProgress.start when status is idle', async () => { + await act(async () => { + render(); + }); + expect(mockStart).not.toHaveBeenCalled(); + }); + + it('calls NProgress.done on unmount', async () => { + let unmount: () => void; + await act(async () => { + ({ unmount } = render()); + }); + await act(async () => { + unmount!(); + }); + expect(mockDone).toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/__tests__/components/OAuthButtons.test.tsx b/apps/web/src/__tests__/components/OAuthButtons.test.tsx new file mode 100644 index 00000000..dd5ead97 --- /dev/null +++ b/apps/web/src/__tests__/components/OAuthButtons.test.tsx @@ -0,0 +1,28 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { OAuthButtons } from '#/components/OAuthButtons'; + +describe('OAuthButtons', () => { + it('renders the "or" divider', () => { + render(); + expect(screen.getByText('or')).toBeInTheDocument(); + }); + + it('renders Google OAuth link with the label', () => { + render(); + const link = screen.getByRole('link', { name: /sign up with google/i }); + expect(link).toHaveAttribute('href', '/auth/oauth/google/start'); + }); + + it('renders GitHub OAuth link with the label', () => { + render(); + const link = screen.getByRole('link', { name: /log in with github/i }); + expect(link).toHaveAttribute('href', '/auth/oauth/github/start'); + }); + + it('renders both OAuth buttons', () => { + render(); + expect(screen.getByRole('link', { name: /continue with google/i })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: /continue with github/i })).toBeInTheDocument(); + }); +});