SuppliersModal.test.tsx 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163
  1. /**
  2. * Tests for the SuppliersModal (#2988) — the supplier master list opened from the Inventory toolbar.
  3. */
  4. import { describe, it, expect, vi, beforeEach } from 'vitest';
  5. import { render, screen, waitFor } from '@testing-library/react';
  6. import userEvent from '@testing-library/user-event';
  7. import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
  8. import { SuppliersModal } from '../../components/SuppliersModal';
  9. import { api, ApiError } from '../../api/client';
  10. import { inventorySuppliersQueryKey } from '../../utils/inventoryQueries';
  11. const mockShowToast = vi.fn();
  12. vi.mock('../../api/client', async (importOriginal) => {
  13. const original = await importOriginal<typeof import('../../api/client')>();
  14. return {
  15. ApiError: original.ApiError,
  16. api: {
  17. getSuppliers: vi.fn(),
  18. createSupplier: vi.fn(),
  19. updateSupplier: vi.fn(),
  20. deleteSupplier: vi.fn(),
  21. },
  22. };
  23. });
  24. vi.mock('../../contexts/ToastContext', () => ({
  25. useToast: () => ({ showToast: mockShowToast }),
  26. }));
  27. const suppliers = [
  28. {
  29. id: 1,
  30. name: 'Filament24',
  31. website: 'https://filament24.example',
  32. customer_number: 'C-1042',
  33. note: null,
  34. spool_count: 3,
  35. created_at: '2026-01-01',
  36. updated_at: '2026-01-01',
  37. },
  38. {
  39. id: 2,
  40. name: 'PrintStore',
  41. website: null,
  42. customer_number: null,
  43. note: 'B2B only',
  44. spool_count: 0,
  45. created_at: '2026-01-01',
  46. updated_at: '2026-01-01',
  47. },
  48. ];
  49. // The list lives in react-query under the shared inventory-suppliers key, so
  50. // the modal needs a client the same way LocationsModal's tests give it one.
  51. function renderModal(open = true) {
  52. const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
  53. const result = render(
  54. <QueryClientProvider client={client}>
  55. <SuppliersModal open={open} onClose={() => {}} />
  56. </QueryClientProvider>,
  57. );
  58. return { ...result, client };
  59. }
  60. describe('SuppliersModal', () => {
  61. beforeEach(() => {
  62. vi.clearAllMocks();
  63. (api.getSuppliers as ReturnType<typeof vi.fn>).mockResolvedValue(suppliers);
  64. });
  65. it('lists suppliers with their spool usage counts', async () => {
  66. renderModal();
  67. expect(await screen.findByText('Filament24')).toBeInTheDocument();
  68. expect(screen.getByText('PrintStore')).toBeInTheDocument();
  69. expect(screen.getByText('3')).toBeInTheDocument();
  70. expect(screen.getByText('C-1042')).toBeInTheDocument();
  71. });
  72. it('creates a supplier through the add form and re-reads the list', async () => {
  73. const newShop = {
  74. id: 3,
  75. name: 'NewShop',
  76. website: null,
  77. customer_number: null,
  78. note: null,
  79. spool_count: 0,
  80. created_at: '2026-01-01',
  81. updated_at: '2026-01-01',
  82. };
  83. (api.createSupplier as ReturnType<typeof vi.fn>).mockResolvedValue(newShop);
  84. // The row appears because the shared query is invalidated and refetched,
  85. // not because the component pushed it into a private copy.
  86. (api.getSuppliers as ReturnType<typeof vi.fn>)
  87. .mockResolvedValueOnce(suppliers)
  88. .mockResolvedValue([...suppliers, newShop]);
  89. const user = userEvent.setup();
  90. renderModal();
  91. await screen.findByText('Filament24');
  92. await user.click(screen.getByRole('button', { name: /Add/i }));
  93. await user.type(screen.getByPlaceholderText(/Name \(e\.g\./), 'NewShop');
  94. const addButtons = screen.getAllByRole('button', { name: /Add/i });
  95. await user.click(addButtons[addButtons.length - 1]);
  96. await waitFor(() => {
  97. expect(api.createSupplier).toHaveBeenCalledWith({
  98. name: 'NewShop',
  99. website: null,
  100. customer_number: null,
  101. note: null,
  102. });
  103. });
  104. expect(await screen.findByText('NewShop')).toBeInTheDocument();
  105. });
  106. it('picks up a supplier created elsewhere when the shared key is invalidated', async () => {
  107. // What the inventory_changed broadcast does: useWebSocket invalidates
  108. // inventory-suppliers, and every consumer of that key re-reads (#2988).
  109. const { client } = renderModal();
  110. await screen.findByText('Filament24');
  111. expect(screen.queryByText('Elsewhere')).not.toBeInTheDocument();
  112. (api.getSuppliers as ReturnType<typeof vi.fn>).mockResolvedValue([
  113. ...suppliers,
  114. { ...suppliers[1], id: 9, name: 'Elsewhere' },
  115. ]);
  116. await client.invalidateQueries({ queryKey: inventorySuppliersQueryKey });
  117. expect(await screen.findByText('Elsewhere')).toBeInTheDocument();
  118. });
  119. it('says the load failed instead of showing an empty list', async () => {
  120. // An empty list reads as "no suppliers yet" and invites the user to
  121. // create a duplicate of one they cannot see.
  122. (api.getSuppliers as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('boom'));
  123. renderModal();
  124. expect(await screen.findByText(/Failed to load suppliers/i)).toBeInTheDocument();
  125. });
  126. it('surfaces the delete guard when the supplier is still referenced', async () => {
  127. (api.deleteSupplier as ReturnType<typeof vi.fn>).mockRejectedValue(
  128. new ApiError('Supplier is assigned to 3 spool(s)', 409),
  129. );
  130. const user = userEvent.setup();
  131. renderModal();
  132. await screen.findByText('Filament24');
  133. // Open the confirm for the referenced supplier (first row).
  134. const deleteButtons = document.querySelectorAll('button.text-red-500');
  135. await user.click(deleteButtons[0] as HTMLElement);
  136. // The confirm message warns about the existing assignments.
  137. expect(await screen.findByText(/assigned to 3 spool/)).toBeInTheDocument();
  138. await user.click(screen.getByRole('button', { name: /Delete/i }));
  139. await waitFor(() => {
  140. expect(mockShowToast).toHaveBeenCalledWith(expect.stringContaining('3'), 'error');
  141. });
  142. // Row stays — nothing was deleted.
  143. expect(screen.getByText('Filament24')).toBeInTheDocument();
  144. });
  145. });