| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243 |
- import { describe, it, expect, vi, beforeEach } from 'vitest';
- import { render, screen, waitFor, within } from '@testing-library/react';
- import userEvent from '@testing-library/user-event';
- import { MemoryRouter } from 'react-router-dom';
- import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
- import { LocationsModal } from '../../components/LocationsModal';
- import { api, ApiError } from '../../api/client';
- const mockShowToast = vi.fn();
- const mockOnClose = vi.fn();
- const mockOnPickLocation = vi.fn();
- vi.mock('../../api/client', () => ({
- api: {
- getLocations: vi.fn(),
- createLocation: vi.fn(),
- updateLocation: vi.fn(),
- deleteLocation: vi.fn(),
- },
- ApiError: class ApiError extends Error {
- status: number;
- constructor(message: string, status: number) {
- super(message);
- this.status = status;
- }
- },
- }));
- vi.mock('../../contexts/ToastContext', () => ({
- useToast: () => ({ showToast: mockShowToast }),
- }));
- const locations = [
- { id: 1, name: 'Shelf A', identifier: null, spool_count: 2, created_at: '2026-01-01', updated_at: '2026-01-01' },
- { id: 2, name: 'Drawer 1', identifier: null, spool_count: 0, created_at: '2026-01-01', updated_at: '2026-01-01' },
- ];
- function renderModal(open = true) {
- const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
- return render(
- <QueryClientProvider client={client}>
- <MemoryRouter>
- <LocationsModal open={open} onClose={mockOnClose} onPickLocation={mockOnPickLocation} />
- </MemoryRouter>
- </QueryClientProvider>,
- );
- }
- describe('LocationsModal', () => {
- beforeEach(() => {
- vi.clearAllMocks();
- vi.mocked(api.getLocations).mockResolvedValue(locations);
- });
- it('renders nothing when open=false', () => {
- const { container } = renderModal(false);
- expect(container.firstChild).toBeNull();
- expect(api.getLocations).not.toHaveBeenCalled();
- });
- it('renders locations from API when open', async () => {
- renderModal();
- expect(await screen.findByText('Shelf A')).toBeInTheDocument();
- expect(screen.getByText('Drawer 1')).toBeInTheDocument();
- expect(screen.getByText('2')).toBeInTheDocument();
- });
- it('renders empty state when API returns no locations', async () => {
- vi.mocked(api.getLocations).mockResolvedValue([]);
- renderModal();
- expect(await screen.findByText(/locations\.empty|no storage locations/i)).toBeInTheDocument();
- });
- it('opens create editor and calls createLocation on submit', async () => {
- vi.mocked(api.createLocation).mockResolvedValue({
- id: 3,
- name: 'Garage',
- identifier: null,
- spool_count: 0,
- created_at: '2026-01-01',
- updated_at: '2026-01-01',
- });
- const user = userEvent.setup();
- renderModal();
- await screen.findByText('Shelf A');
- await user.click(screen.getByRole('button', { name: /add location|locations\.add/i }));
- const input = screen.getByLabelText(/name|locations\.name/i);
- await user.type(input, 'Garage');
- await user.click(screen.getByRole('button', { name: /save|common\.save/i }));
- await waitFor(() => {
- expect(api.createLocation).toHaveBeenCalledWith({ name: 'Garage' });
- });
- expect(mockShowToast).toHaveBeenCalledWith(expect.stringMatching(/created|locations\.created/i), 'success');
- });
- it('submits create form on Enter key', async () => {
- vi.mocked(api.createLocation).mockResolvedValue({
- id: 3,
- name: 'Garage',
- identifier: null,
- spool_count: 0,
- created_at: '2026-01-01',
- updated_at: '2026-01-01',
- });
- const user = userEvent.setup();
- renderModal();
- await screen.findByText('Shelf A');
- await user.click(screen.getByRole('button', { name: /add location|locations\.add/i }));
- const input = screen.getByLabelText(/name|locations\.name/i);
- await user.type(input, 'Garage{Enter}');
- await waitFor(() => {
- expect(api.createLocation).toHaveBeenCalledWith({ name: 'Garage' });
- });
- });
- it('Escape closes the inner editor first, then the outer modal', async () => {
- const user = userEvent.setup();
- renderModal();
- await screen.findByText('Shelf A');
- // Open the inner editor; both dialogs are now in the DOM.
- await user.click(screen.getByRole('button', { name: /add location|locations\.add/i }));
- expect(screen.getAllByRole('dialog')).toHaveLength(2);
- // First Escape closes the editor only.
- await user.keyboard('{Escape}');
- await waitFor(() => {
- expect(screen.getAllByRole('dialog')).toHaveLength(1);
- });
- expect(mockOnClose).not.toHaveBeenCalled();
- // Second Escape closes the outer modal.
- await user.keyboard('{Escape}');
- await waitFor(() => {
- expect(mockOnClose).toHaveBeenCalledTimes(1);
- });
- });
- it('edits a location and calls updateLocation', async () => {
- vi.mocked(api.updateLocation).mockResolvedValue({
- id: 2,
- name: 'Drawer 2',
- identifier: null,
- spool_count: 0,
- created_at: '2026-01-01',
- updated_at: '2026-01-01',
- });
- const user = userEvent.setup();
- renderModal();
- await screen.findByText('Drawer 1');
- const editButtons = screen.getAllByTitle(/edit|common\.edit/i);
- await user.click(editButtons[1]);
- const input = screen.getByLabelText(/name|locations\.name/i);
- await user.clear(input);
- await user.type(input, 'Drawer 2');
- await user.click(screen.getByRole('button', { name: /save|common\.save/i }));
- await waitFor(() => {
- expect(api.updateLocation).toHaveBeenCalledWith(2, { name: 'Drawer 2' });
- });
- expect(mockShowToast).toHaveBeenCalledWith(expect.stringMatching(/updated|locations\.updated/i), 'success');
- });
- it('deletes an empty location after confirmation', async () => {
- vi.mocked(api.deleteLocation).mockResolvedValue({ status: 'deleted' });
- const user = userEvent.setup();
- renderModal();
- await screen.findByText('Drawer 1');
- const row = screen.getByText('Drawer 1').closest('tr');
- expect(row).not.toBeNull();
- await user.click(within(row!).getByTitle(/^Delete$/i));
- await user.click(screen.getAllByRole('button', { name: /^Delete$/i }).pop()!);
- await waitFor(() => {
- expect(api.deleteLocation).toHaveBeenCalledWith(2);
- });
- expect(mockShowToast).toHaveBeenCalledWith(expect.stringMatching(/deleted|locations\.deleted/i), 'success');
- });
- it('blocks delete when spool_count > 0', async () => {
- renderModal();
- await screen.findByText('Shelf A');
- const blockedDelete = screen.getByTitle(/Remove all spools from this location before deleting/i);
- expect(blockedDelete).toBeDisabled();
- });
- it('shows error toast when create returns 409 duplicate name', async () => {
- vi.mocked(api.createLocation).mockRejectedValue(
- new ApiError('A location with this name already exists', 409),
- );
- const user = userEvent.setup();
- renderModal();
- await screen.findByText('Shelf A');
- await user.click(screen.getByRole('button', { name: /add location|locations\.add/i }));
- await user.type(screen.getByLabelText(/name|locations\.name/i), 'Shelf A');
- await user.click(screen.getByRole('button', { name: /save|common\.save/i }));
- await waitFor(() => {
- expect(mockShowToast).toHaveBeenCalledWith('A location with this name already exists', 'error');
- });
- });
- it('shows error toast when delete fails', async () => {
- vi.mocked(api.deleteLocation).mockRejectedValue(new Error('Delete failed'));
- const user = userEvent.setup();
- renderModal();
- await screen.findByText('Drawer 1');
- const row = screen.getByText('Drawer 1').closest('tr');
- expect(row).not.toBeNull();
- await user.click(within(row!).getByTitle(/^Delete$/i));
- await user.click(screen.getAllByRole('button', { name: /^Delete$/i }).pop()!);
- await waitFor(() => {
- expect(mockShowToast).toHaveBeenCalledWith('Delete failed', 'error');
- });
- });
- it('row click calls onPickLocation and onClose', async () => {
- const user = userEvent.setup();
- renderModal();
- await screen.findByText('Shelf A');
- const row = screen.getByText('Shelf A').closest('tr')!;
- await user.click(row);
- expect(mockOnPickLocation).toHaveBeenCalledWith(1);
- expect(mockOnClose).toHaveBeenCalledTimes(1);
- });
- it('shows error toast when rename returns 409 collision', async () => {
- vi.mocked(api.updateLocation).mockRejectedValue(
- new ApiError('A location with this name already exists', 409),
- );
- const user = userEvent.setup();
- renderModal();
- await screen.findByText('Drawer 1');
- const editButtons = screen.getAllByTitle(/edit|common\.edit/i);
- await user.click(editButtons[1]);
- const input = screen.getByLabelText(/name|locations\.name/i);
- await user.clear(input);
- await user.type(input, 'Shelf A');
- await user.click(screen.getByRole('button', { name: /save|common\.save/i }));
- await waitFor(() => {
- expect(mockShowToast).toHaveBeenCalledWith(
- 'A location with this name already exists',
- 'error',
- );
- });
- });
- });
|