PrinterLocationsPage.test.tsx 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191
  1. /**
  2. * Printer Locations page (#2962).
  3. *
  4. * Locations, with their icon and colour, come from the server: a location with
  5. * no printers used to live only in one browser's localStorage, invisible to
  6. * other users and devices. Every change is one request, so a failure cannot
  7. * leave a location split across printers, and English counts never render as
  8. * "5 printer".
  9. */
  10. import { describe, it, expect, vi, beforeEach } from 'vitest';
  11. import { screen, waitFor, within } from '@testing-library/react';
  12. import userEvent from '@testing-library/user-event';
  13. import { http, HttpResponse } from 'msw';
  14. import { server } from '../mocks/server';
  15. import { render } from '../utils';
  16. import i18n from '../../i18n';
  17. const permissions = { granted: ['printers:read', 'printers:update'] as string[] };
  18. const mockUseAuth = {
  19. user: { id: 1, username: 'operator', permissions: [] as string[] },
  20. authEnabled: true,
  21. loading: false,
  22. isAdmin: false,
  23. login: vi.fn(),
  24. loginWithToken: vi.fn(),
  25. logout: vi.fn(),
  26. refreshAuth: vi.fn(),
  27. hasPermission: vi.fn((permission: string) => permissions.granted.includes(permission)),
  28. hasAnyPermission: vi.fn(() => true),
  29. canModify: vi.fn(() => true),
  30. };
  31. vi.mock('../../contexts/AuthContext', async (importOriginal) => {
  32. const actual = await importOriginal<typeof import('../../contexts/AuthContext')>();
  33. return { ...actual, useAuth: () => mockUseAuth };
  34. });
  35. import { PrinterLocationsPage } from '../../pages/PrinterLocationsPage';
  36. const printer = (id: number, name: string, location: string | null) => ({
  37. id,
  38. name,
  39. location,
  40. model: 'X1C',
  41. serial_number: `S${id}`,
  42. ip_address: `10.0.0.${id}`,
  43. is_active: true,
  44. });
  45. const PRINTERS = [
  46. printer(1, 'Alpha', 'Workshop'),
  47. printer(2, 'Bravo', 'Workshop'),
  48. printer(3, 'Charlie', null),
  49. printer(4, 'Delta', ''),
  50. ];
  51. const LOCATIONS = [
  52. { id: 1, name: 'Future rack', icon: 'home', color: '#3b82f6', printer_count: 0 },
  53. { id: null, name: 'Workshop', icon: null, color: null, printer_count: 5 },
  54. ];
  55. let requests: { method: string; path: string; body: unknown }[];
  56. beforeEach(() => {
  57. permissions.granted = ['printers:read', 'printers:update'];
  58. requests = [];
  59. const record = async (request: Request) => {
  60. requests.push({ method: request.method, path: new URL(request.url).pathname, body: await request.json() });
  61. };
  62. server.use(
  63. http.get('/api/v1/printers/', () => HttpResponse.json(PRINTERS)),
  64. http.get('/api/v1/printers/:id/status', () => HttpResponse.json({ connected: true, state: 'IDLE' })),
  65. http.get('/api/v1/printer-locations/', () => HttpResponse.json(LOCATIONS)),
  66. http.post('/api/v1/printer-locations/', async ({ request }) => {
  67. await record(request);
  68. return HttpResponse.json({ id: 9, name: 'Basement', icon: null, color: null, printer_count: 0 }, { status: 201 });
  69. }),
  70. http.post('/api/v1/printer-locations/assign', async ({ request }) => {
  71. await record(request);
  72. return HttpResponse.json({ moved: 2 });
  73. }),
  74. http.post('/api/v1/printer-locations/delete', async ({ request }) => {
  75. await record(request);
  76. return HttpResponse.json({ deleted: 2, printers_ungrouped: 5 });
  77. }),
  78. );
  79. });
  80. describe('PrinterLocationsPage', () => {
  81. it('shows server locations, including an empty one, with readable English counts', async () => {
  82. render(<PrinterLocationsPage />);
  83. expect(await screen.findByText('Future rack')).toBeInTheDocument();
  84. expect(screen.getByText('Workshop')).toBeInTheDocument();
  85. expect(screen.getByText('Printers: 0')).toBeInTheDocument();
  86. expect(screen.getByText('Printers: 5')).toBeInTheDocument();
  87. // NULL and "" are both "no location".
  88. expect(screen.getByText('Printers without a location: 2')).toBeInTheDocument();
  89. expect(screen.getByText('Charlie')).toBeInTheDocument();
  90. expect(screen.getByText('Delta')).toBeInTheDocument();
  91. });
  92. it('creates a location on the server, with a name capped at the column width', async () => {
  93. const user = userEvent.setup();
  94. render(<PrinterLocationsPage />);
  95. await user.click(await screen.findByRole('button', { name: /new location/i }));
  96. const input = screen.getByRole('textbox', { name: /location name/i });
  97. expect(input).toHaveAttribute('maxLength', '100');
  98. await user.type(input, 'Basement');
  99. await user.click(screen.getByRole('button', { name: '#ef4444' }));
  100. const dialog = screen.getByRole('heading', { name: 'Create Location' }).closest('div')!;
  101. await user.click(within(dialog).getByRole('button', { name: /new location/i }));
  102. await waitFor(() => expect(requests).toHaveLength(1));
  103. expect(requests[0]).toEqual({
  104. method: 'POST',
  105. path: '/api/v1/printer-locations/',
  106. body: { name: 'Basement', icon: null, color: '#ef4444' },
  107. });
  108. });
  109. it('moves selected printers in one request', async () => {
  110. const user = userEvent.setup();
  111. render(<PrinterLocationsPage />);
  112. await user.click(await screen.findByRole('button', { name: /select all/i }));
  113. expect(screen.getByText('Selected: 2')).toBeInTheDocument();
  114. await user.click(screen.getByRole('button', { name: /^move$/i }));
  115. const dialog = screen.getByRole('heading', { name: 'Move printers (2)' }).closest('div')!;
  116. await user.selectOptions(within(dialog).getByRole('combobox'), 'Future rack');
  117. await user.click(within(dialog).getByRole('button', { name: /^move$/i }));
  118. await waitFor(() => expect(requests).toHaveLength(1));
  119. expect(requests[0]).toEqual({
  120. method: 'POST',
  121. path: '/api/v1/printer-locations/assign',
  122. body: { printer_ids: [3, 4], location: 'Future rack' },
  123. });
  124. expect(await screen.findByText('Printers moved: 2')).toBeInTheDocument();
  125. });
  126. it('deletes selected locations in one request and names what it affects', async () => {
  127. const user = userEvent.setup();
  128. render(<PrinterLocationsPage />);
  129. await user.click(await screen.findByRole('button', { name: /^select$/i }));
  130. await user.click(screen.getByText('Future rack'));
  131. await user.click(screen.getByText('Workshop'));
  132. await user.click(screen.getByRole('button', { name: /delete selected/i }));
  133. expect(screen.getByText('Delete locations (2)')).toBeInTheDocument();
  134. expect(
  135. screen.getByText('Delete Future rack, Workshop? Printers left without a location: 5.'),
  136. ).toBeInTheDocument();
  137. await user.click(screen.getByRole('button', { name: /^delete$/i }));
  138. await waitFor(() => expect(requests).toHaveLength(1));
  139. expect(requests[0]).toEqual({
  140. method: 'POST',
  141. path: '/api/v1/printer-locations/delete',
  142. body: { names: ['Future rack', 'Workshop'] },
  143. });
  144. });
  145. it('is read-only without printers:update', async () => {
  146. permissions.granted = ['printers:read'];
  147. render(<PrinterLocationsPage />);
  148. expect(await screen.findByText('Workshop')).toBeInTheDocument();
  149. expect(screen.queryByRole('button', { name: /new location/i })).not.toBeInTheDocument();
  150. expect(screen.queryByRole('button', { name: /^select$/i })).not.toBeInTheDocument();
  151. expect(screen.queryByRole('button', { name: /edit/i })).not.toBeInTheDocument();
  152. expect(screen.queryByRole('button', { name: /select all/i })).not.toBeInTheDocument();
  153. });
  154. it('links back to the printers page', async () => {
  155. render(<PrinterLocationsPage />);
  156. const back = await screen.findByRole('link', { name: /back to printers/i });
  157. expect(back).toHaveAttribute('href', '/');
  158. });
  159. });
  160. describe('printer locations strings', () => {
  161. it('keeps printers.dropToQueue, which the printer card still uses', () => {
  162. expect(i18n.t('printers.dropToQueue', { lng: 'en' })).toBe('Drop to queue');
  163. expect(i18n.t('printers.dropToQueue', { lng: 'ru' })).not.toBe('printers.dropToQueue');
  164. });
  165. });