import React from 'react'; import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import App from './App'; import { api } from './services/api'; import { Task, User } from './types'; jest.mock('./services/api', () => ({ ...jest.requireActual('./services/api'), api: { getCurrentUser: jest.fn(), getUsers: jest.fn(), getTasks: jest.fn(), createTask: jest.fn(), updateTask: jest.fn(), deleteTask: jest.fn(), }, })); const mockApi = api as jest.Mocked; const sarah: User = { id: 1, username: 'sarah.chen', full_name: 'Sarah Chen' }; const alex: User = { id: 2, username: 'alex.rivera', full_name: 'Alex Rivera' }; const myTask: Task = { id: 1, title: 'Q4 Marketing Strategy', description: 'Outline key campaigns and budget', assigned_to: sarah, status: 'open', priority: 'medium', due_date: '2026-10-20', created_at: '2026-09-01T10:00:00Z', updated_at: '2026-09-01T10:00:00Z', }; const blockedTask: Task = { id: 2, title: 'API Integration Debugging', description: 'Fixing errors in payment gateway', assigned_to: alex, status: 'blocked', priority: 'high', due_date: '2026-10-18', created_at: '2026-09-02T10:00:00Z', updated_at: '2026-09-02T10:00:00Z', }; /** The desktop table and the mobile cards are both in the DOM (CSS picks one), * so row-level assertions are scoped to the table. */ const table = () => within(screen.getByRole('table')); const renderApp = async (): Promise => { render(); await waitFor(() => expect(screen.getByRole('table')).toBeInTheDocument()); }; beforeEach(() => { jest.clearAllMocks(); mockApi.getCurrentUser.mockResolvedValue(sarah); mockApi.getUsers.mockResolvedValue([sarah, alex]); mockApi.getTasks.mockResolvedValue([myTask, blockedTask]); }); describe('loading the dashboard', () => { it('shows a loading state, then the team task table', async () => { render(); expect(screen.getByRole('status')).toHaveTextContent(/loading/i); await waitFor(() => expect(screen.getByRole('table')).toBeInTheDocument()); expect(table().getByText('Q4 Marketing Strategy')).toBeInTheDocument(); expect(table().getByText('API Integration Debugging')).toBeInTheDocument(); }); it('offers a retry when the request fails, and recovers on retry', async () => { mockApi.getTasks.mockRejectedValueOnce(new Error('Network request failed')); render(); const alert = await screen.findByRole('alert'); expect(alert).toHaveTextContent('Network request failed'); await userEvent.click(screen.getByRole('button', { name: /try again/i })); await waitFor(() => expect(screen.getByRole('table')).toBeInTheDocument()); expect(table().getByText('Q4 Marketing Strategy')).toBeInTheDocument(); }); }); describe('filtering', () => { it('limits My Tasks to the signed-in user', async () => { await renderApp(); await userEvent.click(screen.getByRole('tab', { name: /my tasks/i })); expect(table().getByText('Q4 Marketing Strategy')).toBeInTheDocument(); expect(table().queryByText('API Integration Debugging')).not.toBeInTheDocument(); }); it('explains why work is blocked in the Blocked view', async () => { await renderApp(); await userEvent.click(screen.getByRole('tab', { name: /blocked/i })); expect(screen.getByRole('heading', { name: /these tasks are blocked/i })).toBeInTheDocument(); expect(table().getByText('API Integration Debugging')).toBeInTheDocument(); expect(table().queryByText('Q4 Marketing Strategy')).not.toBeInTheDocument(); }); it('reports an empty filter without pretending there are no tasks at all', async () => { mockApi.getTasks.mockResolvedValue([myTask]); await renderApp(); await userEvent.click(screen.getByRole('tab', { name: /blocked/i })); expect(screen.getByRole('heading', { name: /no blocked tasks/i })).toBeInTheDocument(); }); }); describe('creating a task', () => { it('creates and lists a task in three steps', async () => { const created: Task = { ...myTask, id: 3, title: 'Update client onboarding documentation', description: '', assigned_to: alex, priority: 'high', due_date: '2026-11-01', }; mockApi.createTask.mockResolvedValue(created); await renderApp(); // 1. open the form await userEvent.click(screen.getByRole('button', { name: 'Create task' })); expect(screen.getByRole('dialog', { name: /create new task/i })).toBeInTheDocument(); // 2. fill in the required fields fireEvent.change(screen.getByLabelText(/task title/i), { target: { value: 'Update client onboarding documentation' }, }); fireEvent.change(screen.getByLabelText(/assignee/i), { target: { value: '2' } }); fireEvent.change(screen.getByLabelText(/due date/i), { target: { value: '2026-11-01' } }); fireEvent.change(screen.getByLabelText(/priority/i), { target: { value: 'high' } }); // 3. save await userEvent.click(screen.getByRole('button', { name: /save task/i })); await waitFor(() => expect(mockApi.createTask).toHaveBeenCalledWith({ title: 'Update client onboarding documentation', description: '', assignee_id: 2, status: 'open', priority: 'high', due_date: '2026-11-01', }) ); await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); expect(table().getByText('Update client onboarding documentation')).toBeInTheDocument(); }); it('explains what to fix instead of submitting an incomplete task', async () => { await renderApp(); await userEvent.click(screen.getByRole('button', { name: 'Create task' })); await userEvent.click(screen.getByRole('button', { name: /save task/i })); expect(await screen.findByText(/enter a task title/i)).toBeInTheDocument(); expect(screen.getByText(/choose the team member who owns this task/i)).toBeInTheDocument(); expect(screen.getByText(/pick a due date/i)).toBeInTheDocument(); expect(screen.getByLabelText(/task title/i)).toHaveAttribute('aria-invalid', 'true'); expect(mockApi.createTask).not.toHaveBeenCalled(); expect(screen.getByRole('dialog')).toBeInTheDocument(); }); it('keeps the form and its input on screen when saving fails', async () => { mockApi.createTask.mockRejectedValue(new Error('Connection lost before the task was saved.')); await renderApp(); await userEvent.click(screen.getByRole('button', { name: 'Create task' })); fireEvent.change(screen.getByLabelText(/task title/i), { target: { value: 'Draft that must survive' } }); fireEvent.change(screen.getByLabelText(/assignee/i), { target: { value: '2' } }); fireEvent.change(screen.getByLabelText(/due date/i), { target: { value: '2026-11-01' } }); await userEvent.click(screen.getByRole('button', { name: /save task/i })); expect( await screen.findByText('Connection lost before the task was saved.') ).toBeInTheDocument(); expect(screen.getByRole('dialog')).toBeInTheDocument(); expect(screen.getByLabelText(/task title/i)).toHaveValue('Draft that must survive'); }); }); describe('updating and deleting', () => { it('changes status straight from the list', async () => { mockApi.updateTask.mockResolvedValue({ ...myTask, status: 'in_progress' }); await renderApp(); fireEvent.change(table().getByLabelText('Status for Q4 Marketing Strategy'), { target: { value: 'in_progress' }, }); await waitFor(() => expect(mockApi.updateTask).toHaveBeenCalledWith(1, { status: 'in_progress' })); expect(await screen.findByText(/is now in progress/i)).toBeInTheDocument(); }); it('leaves the task untouched and says so when an update fails', async () => { mockApi.updateTask.mockRejectedValue(new Error('Could not reach the server.')); await renderApp(); fireEvent.change(table().getByLabelText('Status for Q4 Marketing Strategy'), { target: { value: 'blocked' }, }); expect(await screen.findByText('Could not reach the server.')).toBeInTheDocument(); expect(table().getByLabelText('Status for Q4 Marketing Strategy')).toHaveValue('open'); }); it('opens the edit dialog prefilled from the row', async () => { await renderApp(); await userEvent.click(table().getByRole('button', { name: 'Edit Q4 Marketing Strategy' })); expect(screen.getByRole('dialog', { name: /edit task/i })).toBeInTheDocument(); expect(screen.getByLabelText(/task title/i)).toHaveValue('Q4 Marketing Strategy'); expect(screen.getByLabelText(/assignee/i)).toHaveValue('1'); }); it('asks before deleting and does nothing if the user backs out', async () => { const confirmSpy = jest.spyOn(window, 'confirm').mockReturnValue(false); await renderApp(); await userEvent.click(table().getByRole('button', { name: 'Delete Q4 Marketing Strategy' })); expect(confirmSpy).toHaveBeenCalled(); expect(mockApi.deleteTask).not.toHaveBeenCalled(); expect(table().getByText('Q4 Marketing Strategy')).toBeInTheDocument(); confirmSpy.mockRestore(); }); it('deletes once confirmed', async () => { const confirmSpy = jest.spyOn(window, 'confirm').mockReturnValue(true); mockApi.deleteTask.mockResolvedValue(undefined); await renderApp(); await userEvent.click(table().getByRole('button', { name: 'Delete Q4 Marketing Strategy' })); await waitFor(() => expect(mockApi.deleteTask).toHaveBeenCalledWith(1)); await waitFor(() => expect(table().queryByText('Q4 Marketing Strategy')).not.toBeInTheDocument()); confirmSpy.mockRestore(); }); }); describe('keyboard and screen reader support', () => { it('labels the filter tabs as a tablist with counts', async () => { await renderApp(); const tabs = screen.getAllByRole('tab'); expect(tabs).toHaveLength(3); expect(screen.getByRole('tab', { name: /all tasks, 2 tasks/i })).toHaveAttribute( 'aria-selected', 'true' ); }); it('moves between filter tabs with the arrow keys', async () => { await renderApp(); const allTab = screen.getByRole('tab', { name: /all tasks/i }); allTab.focus(); fireEvent.keyDown(allTab, { key: 'ArrowRight' }); await waitFor(() => expect(screen.getByRole('tab', { name: /blocked/i })).toHaveAttribute('aria-selected', 'true') ); }); it('closes the dialog on Escape without saving', async () => { await renderApp(); await userEvent.click(screen.getByRole('button', { name: 'Create task' })); fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' }); await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); expect(mockApi.createTask).not.toHaveBeenCalled(); }); it('exposes every column header and a skip link', async () => { await renderApp(); ['Priority', 'Task Name', 'Assignee', 'Status', 'Due Date', 'Actions'].forEach((header) => { expect(table().getByRole('columnheader', { name: header })).toBeInTheDocument(); }); expect(screen.getByRole('link', { name: /skip to task list/i })).toBeInTheDocument(); }); });