LocationsModal.test.tsx 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243
  1. import { describe, it, expect, vi, beforeEach } from 'vitest';
  2. import { render, screen, waitFor, within } from '@testing-library/react';
  3. import userEvent from '@testing-library/user-event';
  4. import { MemoryRouter } from 'react-router-dom';
  5. import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
  6. import { LocationsModal } from '../../components/LocationsModal';
  7. import { api, ApiError } from '../../api/client';
  8. const mockShowToast = vi.fn();
  9. const mockOnClose = vi.fn();
  10. const mockOnPickLocation = vi.fn();
  11. vi.mock('../../api/client', () => ({
  12. api: {
  13. getLocations: vi.fn(),
  14. createLocation: vi.fn(),
  15. updateLocation: vi.fn(),
  16. deleteLocation: vi.fn(),
  17. },
  18. ApiError: class ApiError extends Error {
  19. status: number;
  20. constructor(message: string, status: number) {
  21. super(message);
  22. this.status = status;
  23. }
  24. },
  25. }));
  26. vi.mock('../../contexts/ToastContext', () => ({
  27. useToast: () => ({ showToast: mockShowToast }),
  28. }));
  29. const locations = [
  30. { id: 1, name: 'Shelf A', identifier: null, spool_count: 2, created_at: '2026-01-01', updated_at: '2026-01-01' },
  31. { id: 2, name: 'Drawer 1', identifier: null, spool_count: 0, created_at: '2026-01-01', updated_at: '2026-01-01' },
  32. ];
  33. function renderModal(open = true) {
  34. const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
  35. return render(
  36. <QueryClientProvider client={client}>
  37. <MemoryRouter>
  38. <LocationsModal open={open} onClose={mockOnClose} onPickLocation={mockOnPickLocation} />
  39. </MemoryRouter>
  40. </QueryClientProvider>,
  41. );
  42. }
  43. describe('LocationsModal', () => {
  44. beforeEach(() => {
  45. vi.clearAllMocks();
  46. vi.mocked(api.getLocations).mockResolvedValue(locations);
  47. });
  48. it('renders nothing when open=false', () => {
  49. const { container } = renderModal(false);
  50. expect(container.firstChild).toBeNull();
  51. expect(api.getLocations).not.toHaveBeenCalled();
  52. });
  53. it('renders locations from API when open', async () => {
  54. renderModal();
  55. expect(await screen.findByText('Shelf A')).toBeInTheDocument();
  56. expect(screen.getByText('Drawer 1')).toBeInTheDocument();
  57. expect(screen.getByText('2')).toBeInTheDocument();
  58. });
  59. it('renders empty state when API returns no locations', async () => {
  60. vi.mocked(api.getLocations).mockResolvedValue([]);
  61. renderModal();
  62. expect(await screen.findByText(/locations\.empty|no storage locations/i)).toBeInTheDocument();
  63. });
  64. it('opens create editor and calls createLocation on submit', async () => {
  65. vi.mocked(api.createLocation).mockResolvedValue({
  66. id: 3,
  67. name: 'Garage',
  68. identifier: null,
  69. spool_count: 0,
  70. created_at: '2026-01-01',
  71. updated_at: '2026-01-01',
  72. });
  73. const user = userEvent.setup();
  74. renderModal();
  75. await screen.findByText('Shelf A');
  76. await user.click(screen.getByRole('button', { name: /add location|locations\.add/i }));
  77. const input = screen.getByLabelText(/name|locations\.name/i);
  78. await user.type(input, 'Garage');
  79. await user.click(screen.getByRole('button', { name: /save|common\.save/i }));
  80. await waitFor(() => {
  81. expect(api.createLocation).toHaveBeenCalledWith({ name: 'Garage' });
  82. });
  83. expect(mockShowToast).toHaveBeenCalledWith(expect.stringMatching(/created|locations\.created/i), 'success');
  84. });
  85. it('submits create form on Enter key', async () => {
  86. vi.mocked(api.createLocation).mockResolvedValue({
  87. id: 3,
  88. name: 'Garage',
  89. identifier: null,
  90. spool_count: 0,
  91. created_at: '2026-01-01',
  92. updated_at: '2026-01-01',
  93. });
  94. const user = userEvent.setup();
  95. renderModal();
  96. await screen.findByText('Shelf A');
  97. await user.click(screen.getByRole('button', { name: /add location|locations\.add/i }));
  98. const input = screen.getByLabelText(/name|locations\.name/i);
  99. await user.type(input, 'Garage{Enter}');
  100. await waitFor(() => {
  101. expect(api.createLocation).toHaveBeenCalledWith({ name: 'Garage' });
  102. });
  103. });
  104. it('Escape closes the inner editor first, then the outer modal', async () => {
  105. const user = userEvent.setup();
  106. renderModal();
  107. await screen.findByText('Shelf A');
  108. // Open the inner editor; both dialogs are now in the DOM.
  109. await user.click(screen.getByRole('button', { name: /add location|locations\.add/i }));
  110. expect(screen.getAllByRole('dialog')).toHaveLength(2);
  111. // First Escape closes the editor only.
  112. await user.keyboard('{Escape}');
  113. await waitFor(() => {
  114. expect(screen.getAllByRole('dialog')).toHaveLength(1);
  115. });
  116. expect(mockOnClose).not.toHaveBeenCalled();
  117. // Second Escape closes the outer modal.
  118. await user.keyboard('{Escape}');
  119. await waitFor(() => {
  120. expect(mockOnClose).toHaveBeenCalledTimes(1);
  121. });
  122. });
  123. it('edits a location and calls updateLocation', async () => {
  124. vi.mocked(api.updateLocation).mockResolvedValue({
  125. id: 2,
  126. name: 'Drawer 2',
  127. identifier: null,
  128. spool_count: 0,
  129. created_at: '2026-01-01',
  130. updated_at: '2026-01-01',
  131. });
  132. const user = userEvent.setup();
  133. renderModal();
  134. await screen.findByText('Drawer 1');
  135. const editButtons = screen.getAllByTitle(/edit|common\.edit/i);
  136. await user.click(editButtons[1]);
  137. const input = screen.getByLabelText(/name|locations\.name/i);
  138. await user.clear(input);
  139. await user.type(input, 'Drawer 2');
  140. await user.click(screen.getByRole('button', { name: /save|common\.save/i }));
  141. await waitFor(() => {
  142. expect(api.updateLocation).toHaveBeenCalledWith(2, { name: 'Drawer 2' });
  143. });
  144. expect(mockShowToast).toHaveBeenCalledWith(expect.stringMatching(/updated|locations\.updated/i), 'success');
  145. });
  146. it('deletes an empty location after confirmation', async () => {
  147. vi.mocked(api.deleteLocation).mockResolvedValue({ status: 'deleted' });
  148. const user = userEvent.setup();
  149. renderModal();
  150. await screen.findByText('Drawer 1');
  151. const row = screen.getByText('Drawer 1').closest('tr');
  152. expect(row).not.toBeNull();
  153. await user.click(within(row!).getByTitle(/^Delete$/i));
  154. await user.click(screen.getAllByRole('button', { name: /^Delete$/i }).pop()!);
  155. await waitFor(() => {
  156. expect(api.deleteLocation).toHaveBeenCalledWith(2);
  157. });
  158. expect(mockShowToast).toHaveBeenCalledWith(expect.stringMatching(/deleted|locations\.deleted/i), 'success');
  159. });
  160. it('blocks delete when spool_count > 0', async () => {
  161. renderModal();
  162. await screen.findByText('Shelf A');
  163. const blockedDelete = screen.getByTitle(/Remove all spools from this location before deleting/i);
  164. expect(blockedDelete).toBeDisabled();
  165. });
  166. it('shows error toast when create returns 409 duplicate name', async () => {
  167. vi.mocked(api.createLocation).mockRejectedValue(
  168. new ApiError('A location with this name already exists', 409),
  169. );
  170. const user = userEvent.setup();
  171. renderModal();
  172. await screen.findByText('Shelf A');
  173. await user.click(screen.getByRole('button', { name: /add location|locations\.add/i }));
  174. await user.type(screen.getByLabelText(/name|locations\.name/i), 'Shelf A');
  175. await user.click(screen.getByRole('button', { name: /save|common\.save/i }));
  176. await waitFor(() => {
  177. expect(mockShowToast).toHaveBeenCalledWith('A location with this name already exists', 'error');
  178. });
  179. });
  180. it('shows error toast when delete fails', async () => {
  181. vi.mocked(api.deleteLocation).mockRejectedValue(new Error('Delete failed'));
  182. const user = userEvent.setup();
  183. renderModal();
  184. await screen.findByText('Drawer 1');
  185. const row = screen.getByText('Drawer 1').closest('tr');
  186. expect(row).not.toBeNull();
  187. await user.click(within(row!).getByTitle(/^Delete$/i));
  188. await user.click(screen.getAllByRole('button', { name: /^Delete$/i }).pop()!);
  189. await waitFor(() => {
  190. expect(mockShowToast).toHaveBeenCalledWith('Delete failed', 'error');
  191. });
  192. });
  193. it('row click calls onPickLocation and onClose', async () => {
  194. const user = userEvent.setup();
  195. renderModal();
  196. await screen.findByText('Shelf A');
  197. const row = screen.getByText('Shelf A').closest('tr')!;
  198. await user.click(row);
  199. expect(mockOnPickLocation).toHaveBeenCalledWith(1);
  200. expect(mockOnClose).toHaveBeenCalledTimes(1);
  201. });
  202. it('shows error toast when rename returns 409 collision', async () => {
  203. vi.mocked(api.updateLocation).mockRejectedValue(
  204. new ApiError('A location with this name already exists', 409),
  205. );
  206. const user = userEvent.setup();
  207. renderModal();
  208. await screen.findByText('Drawer 1');
  209. const editButtons = screen.getAllByTitle(/edit|common\.edit/i);
  210. await user.click(editButtons[1]);
  211. const input = screen.getByLabelText(/name|locations\.name/i);
  212. await user.clear(input);
  213. await user.type(input, 'Shelf A');
  214. await user.click(screen.getByRole('button', { name: /save|common\.save/i }));
  215. await waitFor(() => {
  216. expect(mockShowToast).toHaveBeenCalledWith(
  217. 'A location with this name already exists',
  218. 'error',
  219. );
  220. });
  221. });
  222. });