MaintenancePageWiring.test.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307
  1. /**
  2. * MaintenancePage wiring: which item / printer / type each control sends,
  3. * the confirm on deleting maintenance types, and the permission gates.
  4. */
  5. import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
  6. import { screen, waitFor, within } from '@testing-library/react';
  7. import userEvent from '@testing-library/user-event';
  8. import { http, HttpResponse } from 'msw';
  9. import { render } from '../utils';
  10. import { server } from '../mocks/server';
  11. import { MaintenancePage } from '../../pages/MaintenancePage';
  12. import { setAuthToken } from '../../api/client';
  13. type Sent = { method: string; path: string; search: string; body: unknown };
  14. function item(id: number, printerId: number, typeId: number, name: string, overrides: Record<string, unknown> = {}) {
  15. return {
  16. id,
  17. printer_id: printerId,
  18. printer_name: printerId === 1 ? 'Alpha Printer' : 'Beta Printer',
  19. printer_model: 'X1C',
  20. maintenance_type_id: typeId,
  21. maintenance_type_name: name,
  22. maintenance_type_icon: null,
  23. maintenance_type_wiki_url: 'https://wiki.example/doc',
  24. enabled: true,
  25. interval_hours: 100,
  26. interval_type: 'hours',
  27. current_hours: 50,
  28. hours_since_maintenance: 50,
  29. hours_until_due: 50,
  30. days_since_maintenance: null,
  31. days_until_due: null,
  32. is_due: false,
  33. is_warning: false,
  34. last_performed_at: null,
  35. ...overrides,
  36. };
  37. }
  38. const OVERVIEW = [
  39. {
  40. printer_id: 1,
  41. printer_name: 'Alpha Printer',
  42. printer_model: 'X1C',
  43. total_print_hours: 10.4,
  44. due_count: 0,
  45. warning_count: 0,
  46. maintenance_items: [item(11, 1, 1, 'Nozzle Task')],
  47. },
  48. {
  49. printer_id: 2,
  50. printer_name: 'Beta Printer',
  51. printer_model: 'X1C',
  52. total_print_hours: 250.2,
  53. due_count: 0,
  54. warning_count: 0,
  55. maintenance_items: [item(21, 2, 1, 'Nozzle Task')],
  56. },
  57. ];
  58. const TYPES = [
  59. { id: 1, name: 'Clean Nozzle', description: null, default_interval_hours: 100, interval_type: 'hours', icon: null, wiki_url: null, is_system: true, created_at: '2026-01-01T00:00:00Z' },
  60. { id: 7, name: 'Custom Belt Check', description: null, default_interval_hours: 300, interval_type: 'hours', icon: null, wiki_url: null, is_system: false, created_at: '2026-01-01T00:00:00Z' },
  61. ];
  62. let sent: Sent[];
  63. async function record(request: Request) {
  64. const url = new URL(request.url);
  65. let body: unknown = null;
  66. try {
  67. body = await request.json();
  68. } catch {
  69. body = null;
  70. }
  71. sent.push({ method: request.method, path: url.pathname, search: url.search, body });
  72. }
  73. const writes = () => sent.filter((s) => s.method !== 'GET');
  74. beforeEach(() => {
  75. sent = [];
  76. server.use(
  77. http.get('*/api/v1/maintenance/overview', () => HttpResponse.json(OVERVIEW)),
  78. http.get('*/api/v1/maintenance/types', () => HttpResponse.json(TYPES)),
  79. http.post('*/api/v1/maintenance/items/:id/perform', async ({ request }) => {
  80. await record(request);
  81. return HttpResponse.json(item(21, 2, 1, 'Clean Nozzle'));
  82. }),
  83. http.patch('*/api/v1/maintenance/items/:id', async ({ request }) => {
  84. await record(request);
  85. return HttpResponse.json(item(21, 2, 1, 'Clean Nozzle'));
  86. }),
  87. http.patch('*/api/v1/maintenance/printers/:id/hours', async ({ request }) => {
  88. await record(request);
  89. return HttpResponse.json({ printer_id: 2, total_hours: 123, archive_hours: 0, offset_hours: 123 });
  90. }),
  91. http.delete('*/api/v1/maintenance/types/:id', async ({ request }) => {
  92. await record(request);
  93. return HttpResponse.json({ status: 'deleted' });
  94. }),
  95. http.post('*/api/v1/maintenance/types/restore-defaults', async ({ request }) => {
  96. await record(request);
  97. return HttpResponse.json({ restored: 1 });
  98. }),
  99. );
  100. });
  101. afterEach(() => {
  102. setAuthToken(null);
  103. vi.restoreAllMocks();
  104. });
  105. /** The printer card whose heading is `name`. */
  106. async function printerCard(name: string): Promise<HTMLElement> {
  107. const heading = await screen.findByRole('heading', { name });
  108. let el: HTMLElement | null = heading;
  109. while (el && !within(el).queryByText('Total Print Time')) el = el.parentElement;
  110. // `el` is the card header; its parent is the card, which also holds the expanded items.
  111. return el!.parentElement!;
  112. }
  113. async function openSettings(user: ReturnType<typeof userEvent.setup>) {
  114. await user.click(await screen.findByRole('button', { name: 'Settings' }));
  115. }
  116. function typeTile(name: string): HTMLElement {
  117. return screen.getByText(name).closest('.rounded-xl') as HTMLElement;
  118. }
  119. describe('MaintenancePage wiring: status tab', () => {
  120. it('Reset on a task sends performMaintenance for that printer\'s item only', async () => {
  121. const user = userEvent.setup();
  122. render(<MaintenancePage />);
  123. const beta = await printerCard('Beta Printer');
  124. await user.click(within(beta).getByRole('button', { name: /expand/i }));
  125. await user.click(await within(beta).findByRole('button', { name: 'Reset' }));
  126. await waitFor(() => expect(writes()).toHaveLength(1));
  127. expect(writes()[0]).toMatchObject({ method: 'POST', path: '/api/v1/maintenance/items/21/perform' });
  128. expect(await screen.findByText('Maintenance marked as complete')).toBeInTheDocument();
  129. });
  130. it('the item toggle PATCHes { enabled } for that item', async () => {
  131. const user = userEvent.setup();
  132. render(<MaintenancePage />);
  133. const alpha = await printerCard('Alpha Printer');
  134. await user.click(within(alpha).getByRole('button', { name: /expand/i }));
  135. await user.click(await within(alpha).findByRole('switch'));
  136. await waitFor(() => expect(writes()).toHaveLength(1));
  137. expect(writes()[0]).toEqual({ method: 'PATCH', path: '/api/v1/maintenance/items/11', search: '', body: { enabled: false } });
  138. });
  139. it('editing print hours sends setPrinterHours for that printer', async () => {
  140. const user = userEvent.setup();
  141. render(<MaintenancePage />);
  142. const beta = await printerCard('Beta Printer');
  143. await user.click(within(beta).getByText('Total Print Time'));
  144. const input = within(beta).getByRole('spinbutton');
  145. expect(input).toHaveValue(250);
  146. await user.clear(input);
  147. await user.type(input, '123');
  148. await user.click(within(beta).getByRole('button', { name: 'Save' }));
  149. await waitFor(() => expect(writes()).toHaveLength(1));
  150. expect(writes()[0]).toMatchObject({
  151. method: 'PATCH',
  152. path: '/api/v1/maintenance/printers/2/hours',
  153. search: '?total_hours=123',
  154. });
  155. });
  156. it('cancelling the hours edit sends nothing', async () => {
  157. const user = userEvent.setup();
  158. render(<MaintenancePage />);
  159. const beta = await printerCard('Beta Printer');
  160. await user.click(within(beta).getByText('Total Print Time'));
  161. await user.type(within(beta).getByRole('spinbutton'), '9');
  162. await user.click(within(beta).getByRole('button', { name: 'Cancel' }));
  163. expect(within(beta).queryByRole('spinbutton')).not.toBeInTheDocument();
  164. expect(writes()).toEqual([]);
  165. });
  166. });
  167. describe('MaintenancePage wiring: settings tab', () => {
  168. it('deleting a default type asks first; confirming sends DELETE for that type', async () => {
  169. const user = userEvent.setup();
  170. render(<MaintenancePage />);
  171. await openSettings(user);
  172. const tile = await waitFor(() => typeTile('Clean Nozzle'));
  173. await user.click(within(tile).getByRole('button'));
  174. expect(await screen.findByText('Delete default maintenance task?')).toBeInTheDocument();
  175. expect(writes()).toEqual([]);
  176. await user.click(screen.getByRole('button', { name: 'Delete' }));
  177. await waitFor(() => expect(writes()).toHaveLength(1));
  178. expect(writes()[0]).toMatchObject({ method: 'DELETE', path: '/api/v1/maintenance/types/1' });
  179. });
  180. it('cancelling the default-type delete sends nothing', async () => {
  181. const user = userEvent.setup();
  182. render(<MaintenancePage />);
  183. await openSettings(user);
  184. const tile = await waitFor(() => typeTile('Clean Nozzle'));
  185. await user.click(within(tile).getByRole('button'));
  186. await screen.findByText('Delete default maintenance task?');
  187. await user.click(screen.getByRole('button', { name: 'Cancel' }));
  188. await waitFor(() => expect(screen.queryByText('Delete default maintenance task?')).not.toBeInTheDocument());
  189. expect(writes()).toEqual([]);
  190. });
  191. it('a custom type is deleted only after the browser confirm says yes', async () => {
  192. const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false);
  193. const user = userEvent.setup();
  194. render(<MaintenancePage />);
  195. await openSettings(user);
  196. const tile = await waitFor(() => typeTile('Custom Belt Check'));
  197. const trash = within(tile).getAllByRole('button').at(-1)!;
  198. await user.click(trash);
  199. expect(confirmSpy).toHaveBeenCalledTimes(1);
  200. expect(writes()).toEqual([]);
  201. confirmSpy.mockReturnValue(true);
  202. await user.click(trash);
  203. await waitFor(() => expect(writes()).toHaveLength(1));
  204. expect(writes()[0]).toMatchObject({ method: 'DELETE', path: '/api/v1/maintenance/types/7' });
  205. });
  206. it('Restore Default Tasks sends the restore request', async () => {
  207. const user = userEvent.setup();
  208. render(<MaintenancePage />);
  209. await openSettings(user);
  210. await user.click(await screen.findByRole('button', { name: 'Restore Default Tasks' }));
  211. await waitFor(() => expect(writes()).toHaveLength(1));
  212. expect(writes()[0]).toMatchObject({ method: 'POST', path: '/api/v1/maintenance/types/restore-defaults' });
  213. });
  214. });
  215. describe('MaintenancePage wiring: permissions', () => {
  216. function signInWith(permissions: string[]) {
  217. setAuthToken('test-token-1', 'session');
  218. server.use(
  219. http.get('*/api/v1/auth/status', () => HttpResponse.json({ auth_enabled: true, requires_setup: false })),
  220. http.get('*/api/v1/auth/me', () =>
  221. HttpResponse.json({
  222. id: 9,
  223. username: 'viewer',
  224. is_active: true,
  225. is_admin: false,
  226. groups: [],
  227. permissions,
  228. created_at: '2026-01-01T00:00:00Z',
  229. }),
  230. ),
  231. );
  232. }
  233. it('a read-only user gets disabled controls and nothing is sent', async () => {
  234. signInWith(['maintenance:read']);
  235. const user = userEvent.setup();
  236. render(<MaintenancePage />);
  237. const beta = await printerCard('Beta Printer');
  238. await waitFor(() =>
  239. expect(within(beta).getByTitle('You do not have permission to edit print hours')).toBeInTheDocument(),
  240. );
  241. await user.click(within(beta).getByText('Total Print Time'));
  242. expect(within(beta).queryByRole('spinbutton')).not.toBeInTheDocument();
  243. await user.click(within(beta).getByRole('button', { name: /expand/i }));
  244. expect(await within(beta).findByRole('button', { name: 'Reset' })).toBeDisabled();
  245. expect(within(beta).getByRole('switch')).toBeDisabled();
  246. await openSettings(user);
  247. expect(await screen.findByRole('button', { name: 'Restore Default Tasks' })).toBeDisabled();
  248. const tile = typeTile('Clean Nozzle');
  249. expect(within(tile).getByRole('button')).toBeDisabled();
  250. expect(writes()).toEqual([]);
  251. });
  252. it('maintenance:update enables Reset but not the type delete', async () => {
  253. signInWith(['maintenance:read', 'maintenance:update']);
  254. const user = userEvent.setup();
  255. render(<MaintenancePage />);
  256. const beta = await printerCard('Beta Printer');
  257. await user.click(within(beta).getByRole('button', { name: /expand/i }));
  258. await waitFor(() => expect(within(beta).getByRole('button', { name: 'Reset' })).not.toBeDisabled());
  259. await openSettings(user);
  260. const tile = await waitFor(() => typeTile('Clean Nozzle'));
  261. expect(within(tile).getByRole('button')).toBeDisabled();
  262. });
  263. });