Files
jobtrackingapp/job-tracker-ui/src/kanban-grouped-board.test.tsx
T
cesnimda fb6f17e1f3
CI and Deploy / test (pull_request) Successful in 4m45s
CI and Deploy / deploy (pull_request) Has been skipped
fix(kanban): honor theme and keyboard moves
2026-08-10 11:12:49 +02:00

176 lines
6.9 KiB
TypeScript

import React from 'react';
import '@testing-library/jest-dom';
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { ToastProvider } from './toast';
import { I18nProvider } from './i18n/I18nProvider';
import { api } from './api';
import { JobApplication } from './types';
jest.mock('./api', () => ({
api: {
get: jest.fn(),
patch: jest.fn(() => Promise.resolve({ data: {} })),
},
getApiErrorMessage: jest.fn((_error, fallback) => fallback),
}));
// eslint-disable-next-line import/first
import KanbanBoard from './components/KanbanBoard';
const mockedApi = api as jest.Mocked<typeof api>;
function job(id: number, jobTitle: string, status: string, overrides: Partial<JobApplication> = {}): JobApplication {
return {
id,
jobTitle,
company: { id: 1, name: 'Acme' },
companyId: 1,
status,
// Pre-application stages carry no applied date — that is the whole point of the split.
dateApplied: null,
savedAt: new Date('2026-07-01T00:00:00Z').toISOString(),
daysSince: null,
...overrides,
} as JobApplication;
}
function renderBoard() {
return render(
<ToastProvider>
<I18nProvider>
<KanbanBoard />
</I18nProvider>
</ToastProvider>,
);
}
beforeEach(() => {
jest.clearAllMocks();
});
test('the board collapses ten stages into the three agreed groups', async () => {
mockedApi.get.mockResolvedValue({
data: [
job(1, 'Saved Role', 'Saved'),
job(2, 'Preparing Role', 'Preparing'),
job(3, 'Applied Role', 'Applied', { dateApplied: new Date('2026-07-02T00:00:00Z').toISOString(), daysSince: 15 }),
job(4, 'Offer Role', 'Offer', { dateApplied: new Date('2026-07-02T00:00:00Z').toISOString(), daysSince: 15 }),
job(5, 'Withdrawn Role', 'Withdrawn', { dateApplied: new Date('2026-07-02T00:00:00Z').toISOString(), daysSince: 15 }),
],
} as any);
renderBoard();
await screen.findByText('Not Applied');
expect(screen.getByText('Active')).toBeInTheDocument();
expect(screen.getByText('Closed')).toBeInTheDocument();
// Only the three groups — no "Other" column, because no custom statuses are present.
expect(screen.queryByText('Other')).not.toBeInTheDocument();
// Every job is placed, and grouping does not hide which stage it is actually in.
expect(screen.getByText('Saved Role')).toBeInTheDocument();
expect(screen.getByText('Withdrawn Role')).toBeInTheDocument();
expect(screen.getByText('Withdrawn')).toBeInTheDocument();
expect(screen.getByText('Preparing')).toBeInTheDocument();
// Offer groups under Active even though it is a Success category on the backend.
expect(screen.getByText('Offer')).toBeInTheDocument();
});
test('a custom status gets an Other column that is not a drop target', async () => {
mockedApi.get.mockResolvedValue({
data: [job(1, 'Odd Role', 'Take-home assignment')],
} as any);
renderBoard();
await screen.findByText('Other');
// Custom statuses survive rather than being coerced into a canonical stage.
expect(screen.getByText('Take-home assignment')).toBeInTheDocument();
fireEvent.dragStart(screen.getByText('Odd Role').closest('.MuiCard-root')!);
const other = screen.getByRole('group', { name: 'Other column, not a drop target' });
expect(other).toHaveAttribute('aria-disabled', 'true');
expect(other).toHaveAttribute('data-drop-state', 'invalid');
});
test('loading and retryable error states replace the board', async () => {
let rejectLoad!: (reason: unknown) => void;
mockedApi.get.mockReturnValueOnce(new Promise((_resolve, reject) => { rejectLoad = reject; }) as any);
renderBoard();
expect(screen.getByRole('progressbar')).toBeInTheDocument();
expect(screen.queryByRole('group', { name: 'Not Applied column' })).not.toBeInTheDocument();
rejectLoad(new Error('offline'));
expect(await screen.findByText('Unable to load the kanban board')).toBeInTheDocument();
mockedApi.get.mockResolvedValueOnce({ data: [] } as any);
fireEvent.click(screen.getByRole('button', { name: 'Retry' }));
expect(await screen.findByRole('group', { name: 'Not Applied column' })).toBeInTheDocument();
});
test('dropping onto a group applies that group entry stage', async () => {
mockedApi.get.mockResolvedValue({ data: [job(7, 'Saved Role', 'Saved')] } as any);
renderBoard();
const card = await screen.findByText('Saved Role');
const column = screen.getByText('Active').closest('div')!.parentElement!.parentElement!;
fireEvent.dragStart(card.closest('.MuiCard-root')!);
expect(card.closest('.MuiCard-root')).toHaveAttribute('aria-pressed', 'true');
fireEvent.dragEnter(column);
expect(column).toHaveAttribute('data-drop-state', 'active');
fireEvent.dragOver(column);
fireEvent.drop(column);
// A group is not a status, so a coarse drag applies the group's entry stage.
await waitFor(() => expect(mockedApi.patch).toHaveBeenCalledWith('/jobapplications/7/status', { status: 'Applied' }));
});
test('keyboard pickup and drop moves a card to a valid column', async () => {
mockedApi.get.mockResolvedValue({ data: [job(11, 'Keyboard Role', 'Saved')] } as any);
renderBoard();
const card = (await screen.findByText('Keyboard Role')).closest('.MuiCard-root')!;
fireEvent.keyDown(card, { key: ' ' });
expect(card).toHaveAttribute('aria-pressed', 'true');
const column = screen.getByRole('group', { name: 'Active column' });
fireEvent.focus(column);
fireEvent.keyDown(column, { key: 'Enter' });
await waitFor(() => expect(mockedApi.patch).toHaveBeenCalledWith('/jobapplications/11/status', { status: 'Applied' }));
expect(await screen.findByText('Job moved to Applied.')).toBeInTheDocument();
});
test('a failed move keeps the card and reports a retryable error', async () => {
mockedApi.get.mockResolvedValue({ data: [job(12, 'Failed Move Role', 'Saved')] } as any);
mockedApi.patch.mockRejectedValueOnce(new Error('offline'));
renderBoard();
const card = (await screen.findByText('Failed Move Role')).closest('.MuiCard-root')!;
const column = screen.getByRole('group', { name: 'Active column' });
fireEvent.dragStart(card);
fireEvent.dragOver(column);
fireEvent.drop(column);
expect(await screen.findByText('Unable to move this job right now.')).toBeInTheDocument();
expect(screen.getByText('Failed Move Role')).toBeInTheDocument();
});
test('the card menu offers precise stages grouped by section', async () => {
mockedApi.get.mockResolvedValue({ data: [job(9, 'Saved Role', 'Saved')] } as any);
renderBoard();
await screen.findByText('Saved Role');
fireEvent.click(screen.getByRole('button', { name: 'Change status for Saved Role' }));
const menu = await screen.findByRole('menu');
// Drag is coarse; the menu is where Ghosted and Withdrawn are reachable at all.
expect(within(menu).getByText(/Ghosted/)).toBeInTheDocument();
expect(within(menu).getByText(/Withdrawn/)).toBeInTheDocument();
// The job's own current stage is not offered as a target.
expect(within(menu).queryByText(/Set status: Saved/)).not.toBeInTheDocument();
});