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(
,
);
}
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',
);
});
});
});
describe('LocationsModal — startCreating', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(api.getLocations).mockResolvedValue(locations);
vi.mocked(api.createLocation).mockResolvedValue({
id: 3,
name: 'Garage',
identifier: null,
spool_count: 0,
created_at: '2026-01-01',
updated_at: '2026-01-01',
});
});
function renderCreatingModal(withPickLocation: boolean) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
,
);
}
it('calls onPickLocation and onClose on a successful create', async () => {
const user = userEvent.setup();
renderCreatingModal(true);
const input = await screen.findByLabelText(/name|locations\.name/i);
await user.type(input, 'Garage');
await user.click(screen.getByRole('button', { name: /save|common\.save/i }));
await waitFor(() => expect(mockOnPickLocation).toHaveBeenCalledWith(3));
expect(mockOnClose).toHaveBeenCalledTimes(1);
});
it('still closes on a successful create when onPickLocation is not provided', async () => {
// Regression: gating the close on onPickLocation being set left editorOpen
// false with open still true — nothing left to render (startCreating has
// no location-list view to fall back to), but the caller never told to
// close, so the modal effectively vanished stuck "open".
const user = userEvent.setup();
renderCreatingModal(false);
const input = await screen.findByLabelText(/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' }));
await waitFor(() => expect(mockOnClose).toHaveBeenCalledTimes(1));
expect(mockOnPickLocation).not.toHaveBeenCalled();
});
});