GroupEditPage.test.tsx 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283
  1. /**
  2. * Tests for the GroupEditPage component.
  3. *
  4. * Covers create mode, edit mode, permission search/filtering,
  5. * select all / clear all, and category-level toggles.
  6. */
  7. import { describe, it, expect, beforeEach } from 'vitest';
  8. import { screen, waitFor } from '@testing-library/react';
  9. import userEvent from '@testing-library/user-event';
  10. import { render } from '../utils';
  11. import { GroupEditPage } from '../../pages/GroupEditPage';
  12. import { http, HttpResponse } from 'msw';
  13. import { server } from '../mocks/server';
  14. const mockPermissions = {
  15. categories: [
  16. {
  17. name: 'Printers',
  18. permissions: [
  19. { value: 'printers:read', label: 'Read Printers' },
  20. { value: 'printers:control', label: 'Control Printers' },
  21. { value: 'printers:clear_plate', label: 'Clear Plate' },
  22. ],
  23. },
  24. {
  25. name: 'Archives',
  26. permissions: [
  27. { value: 'archives:read', label: 'Read Archives' },
  28. { value: 'archives:create', label: 'Create Archives' },
  29. ],
  30. },
  31. ],
  32. all_permissions: [
  33. 'printers:read',
  34. 'printers:control',
  35. 'printers:clear_plate',
  36. 'archives:read',
  37. 'archives:create',
  38. ],
  39. };
  40. const mockGroup = {
  41. id: 2,
  42. name: 'Operators',
  43. description: 'Control printers and manage content',
  44. permissions: ['printers:read', 'printers:control', 'printers:clear_plate'],
  45. is_system: true,
  46. user_count: 3,
  47. users: [{ id: 1, username: 'admin', is_active: true }],
  48. created_at: '2024-01-01T00:00:00Z',
  49. updated_at: '2024-01-01T00:00:00Z',
  50. };
  51. describe('GroupEditPage', () => {
  52. beforeEach(() => {
  53. server.use(
  54. http.get('/api/v1/groups/permissions', () => {
  55. return HttpResponse.json(mockPermissions);
  56. }),
  57. http.get('/api/v1/groups/:id', () => {
  58. return HttpResponse.json(mockGroup);
  59. }),
  60. http.post('/api/v1/groups/', async ({ request }) => {
  61. const body = (await request.json()) as Record<string, unknown>;
  62. return HttpResponse.json({
  63. id: 10,
  64. ...body,
  65. is_system: false,
  66. user_count: 0,
  67. created_at: '2024-01-01T00:00:00Z',
  68. updated_at: '2024-01-01T00:00:00Z',
  69. });
  70. }),
  71. http.patch('/api/v1/groups/:id', async ({ request }) => {
  72. const body = (await request.json()) as Record<string, unknown>;
  73. return HttpResponse.json({
  74. ...mockGroup,
  75. ...body,
  76. });
  77. })
  78. );
  79. });
  80. describe('create mode', () => {
  81. it('renders create title when no id param', async () => {
  82. render(<GroupEditPage />);
  83. await waitFor(() => {
  84. expect(screen.getByText('Create Group')).toBeInTheDocument();
  85. });
  86. });
  87. it('shows permission categories', async () => {
  88. render(<GroupEditPage />);
  89. await waitFor(() => {
  90. expect(screen.getByText('Printers')).toBeInTheDocument();
  91. });
  92. expect(screen.getByText('Archives')).toBeInTheDocument();
  93. });
  94. it('shows individual permissions', async () => {
  95. render(<GroupEditPage />);
  96. await waitFor(() => {
  97. expect(screen.getByText('Read Printers')).toBeInTheDocument();
  98. });
  99. expect(screen.getByText('Control Printers')).toBeInTheDocument();
  100. expect(screen.getByText('Clear Plate')).toBeInTheDocument();
  101. expect(screen.getByText('Read Archives')).toBeInTheDocument();
  102. expect(screen.getByText('Create Archives')).toBeInTheDocument();
  103. });
  104. it('shows 0 selected initially', async () => {
  105. render(<GroupEditPage />);
  106. await waitFor(() => {
  107. expect(screen.getByText(/0 selected/)).toBeInTheDocument();
  108. });
  109. });
  110. it('shows save and cancel buttons', async () => {
  111. render(<GroupEditPage />);
  112. await waitFor(() => {
  113. expect(screen.getByText('Save')).toBeInTheDocument();
  114. });
  115. expect(screen.getByText('Cancel')).toBeInTheDocument();
  116. });
  117. });
  118. describe('permission interactions', () => {
  119. it('toggles individual permission on click', async () => {
  120. const user = userEvent.setup();
  121. render(<GroupEditPage />);
  122. await waitFor(() => {
  123. expect(screen.getByText('Read Printers')).toBeInTheDocument();
  124. });
  125. const checkbox = screen.getByText('Read Printers').closest('label')!.querySelector('input')!;
  126. await user.click(checkbox);
  127. await waitFor(() => {
  128. expect(screen.getByText(/1 selected/)).toBeInTheDocument();
  129. });
  130. });
  131. it('select all selects all permissions', async () => {
  132. const user = userEvent.setup();
  133. render(<GroupEditPage />);
  134. await waitFor(() => {
  135. expect(screen.getByText('Select All')).toBeInTheDocument();
  136. });
  137. await user.click(screen.getByText('Select All'));
  138. await waitFor(() => {
  139. expect(screen.getByText(/5 selected/)).toBeInTheDocument();
  140. });
  141. });
  142. it('clear all deselects all permissions', async () => {
  143. const user = userEvent.setup();
  144. render(<GroupEditPage />);
  145. await waitFor(() => {
  146. expect(screen.getByText('Select All')).toBeInTheDocument();
  147. });
  148. await user.click(screen.getByText('Select All'));
  149. await waitFor(() => {
  150. expect(screen.getByText(/5 selected/)).toBeInTheDocument();
  151. });
  152. await user.click(screen.getByText('Clear All'));
  153. await waitFor(() => {
  154. expect(screen.getByText(/0 selected/)).toBeInTheDocument();
  155. });
  156. });
  157. it('filters permissions by search', async () => {
  158. const user = userEvent.setup();
  159. render(<GroupEditPage />);
  160. await waitFor(() => {
  161. expect(screen.getByText('Read Printers')).toBeInTheDocument();
  162. });
  163. const searchInput = screen.getByPlaceholderText('Search permissions...');
  164. await user.type(searchInput, 'Clear');
  165. await waitFor(() => {
  166. expect(screen.getByText('Clear Plate')).toBeInTheDocument();
  167. expect(screen.queryByText('Read Printers')).not.toBeInTheDocument();
  168. expect(screen.queryByText('Archives')).not.toBeInTheDocument();
  169. });
  170. });
  171. it('shows no results message for empty search', async () => {
  172. const user = userEvent.setup();
  173. render(<GroupEditPage />);
  174. await waitFor(() => {
  175. expect(screen.getByText('Read Printers')).toBeInTheDocument();
  176. });
  177. const searchInput = screen.getByPlaceholderText('Search permissions...');
  178. await user.type(searchInput, 'zzzznonexistent');
  179. await waitFor(() => {
  180. expect(screen.getByText('No permissions match your search')).toBeInTheDocument();
  181. });
  182. });
  183. });
  184. describe('cache invalidation after save (#1083)', () => {
  185. it('primes the single-group detail cache with the update response body', async () => {
  186. // Regression for #1083: before the fix, onSuccess only invalidated the
  187. // ['groups'] list query. The ['group', id] detail cache stayed stale
  188. // under the global 60s staleTime, so reopening the editor showed the
  189. // pre-update snapshot. The fix invalidates the detail key AND primes the
  190. // cache with the server response so a re-mount sees fresh data.
  191. const { QueryClient, QueryClientProvider } = await import('@tanstack/react-query');
  192. const { MemoryRouter, Routes, Route } = await import('react-router-dom');
  193. const { AuthProvider } = await import('../../contexts/AuthContext');
  194. const { ToastProvider } = await import('../../contexts/ToastContext');
  195. const { ThemeProvider } = await import('../../contexts/ThemeContext');
  196. const { render: rtlRender } = await import('@testing-library/react');
  197. const queryClient = new QueryClient({
  198. defaultOptions: { queries: { staleTime: 60_000, retry: false } },
  199. });
  200. const user = userEvent.setup();
  201. const wrapper = (
  202. <QueryClientProvider client={queryClient}>
  203. <ThemeProvider>
  204. <ToastProvider>
  205. <AuthProvider>
  206. <MemoryRouter initialEntries={['/groups/2/edit']}>
  207. <Routes>
  208. <Route path="/groups/:id/edit" element={<GroupEditPage />} />
  209. <Route path="/settings" element={<div>Settings</div>} />
  210. </Routes>
  211. </MemoryRouter>
  212. </AuthProvider>
  213. </ToastProvider>
  214. </ThemeProvider>
  215. </QueryClientProvider>
  216. );
  217. rtlRender(wrapper);
  218. // Wait for the group to load
  219. await waitFor(() => {
  220. expect(screen.getByDisplayValue('Operators')).toBeInTheDocument();
  221. });
  222. // Change permissions then save
  223. await waitFor(() => {
  224. expect(screen.getByText('Read Archives')).toBeInTheDocument();
  225. });
  226. const archivesCheckbox = screen.getByText('Read Archives').closest('label')!.querySelector('input')!;
  227. await user.click(archivesCheckbox);
  228. await user.click(screen.getByText('Save'));
  229. // Wait for navigation (redirect to /settings)
  230. await waitFor(() => {
  231. expect(screen.getByText('Settings')).toBeInTheDocument();
  232. });
  233. // After save, the detail cache must have been primed with the server
  234. // response (mocked PATCH returns mockGroup + body). The next mount
  235. // should read the cached body, not the stale pre-update payload.
  236. const cached = queryClient.getQueryData(['group', '2']) as { permissions: string[] } | undefined;
  237. expect(cached).toBeDefined();
  238. expect(cached!.permissions).toContain('archives:read');
  239. });
  240. });
  241. });