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: {} })), }, })); // eslint-disable-next-line import/first import KanbanBoard from './components/KanbanBoard'; const mockedApi = api as jest.Mocked; function job(id: number, jobTitle: string, status: string, overrides: Partial = {}): 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( , ); } 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(); }); 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')!); 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('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: '' }) ?? screen.getAllByRole('button')[0]); 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(); });