PendingUploadsPanelWiring.test.tsx 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199
  1. /**
  2. * Wiring tests for PendingUploadsPanel: pin the requests sent for archive
  3. * (with tags/notes/project), discard, archive-all and discard-all, and that
  4. * cancelling a destructive confirm sends nothing.
  5. */
  6. import { describe, it, expect, beforeEach } from 'vitest';
  7. import { screen, waitFor, within } from '@testing-library/react';
  8. import userEvent from '@testing-library/user-event';
  9. import { http, HttpResponse } from 'msw';
  10. import { render } from '../utils';
  11. import { server } from '../mocks/server';
  12. import { PendingUploadsPanel } from '../../components/PendingUploadsPanel';
  13. const uploads = [
  14. {
  15. id: 11,
  16. filename: 'first.gcode.3mf',
  17. display_name: 'First Upload',
  18. file_size: 1000,
  19. source_ip: null,
  20. status: 'pending',
  21. tags: null,
  22. notes: null,
  23. project_id: null,
  24. uploaded_at: '2026-05-01T10:00:00Z',
  25. },
  26. {
  27. id: 22,
  28. filename: 'second.gcode.3mf',
  29. display_name: 'Second Upload',
  30. file_size: 2000,
  31. source_ip: null,
  32. status: 'pending',
  33. tags: null,
  34. notes: null,
  35. project_id: null,
  36. uploaded_at: '2026-05-01T10:00:00Z',
  37. },
  38. ];
  39. const projects = [
  40. { id: 5, name: 'Gadgets', status: 'active', parent_id: null },
  41. { id: 9, name: 'Archived Proj', status: 'archived', parent_id: null },
  42. ];
  43. interface Captured {
  44. method: string;
  45. path: string;
  46. body?: unknown;
  47. }
  48. let calls: Captured[];
  49. beforeEach(() => {
  50. calls = [];
  51. server.use(
  52. http.get('*/api/v1/pending-uploads/', () => HttpResponse.json(uploads)),
  53. http.get('*/api/v1/projects/', () => HttpResponse.json(projects)),
  54. http.post('*/api/v1/pending-uploads/archive-all', ({ request }) => {
  55. calls.push({ method: 'POST', path: new URL(request.url).pathname });
  56. return HttpResponse.json({ archived: 2, failed: 0 });
  57. }),
  58. http.delete('*/api/v1/pending-uploads/discard-all', ({ request }) => {
  59. calls.push({ method: 'DELETE', path: new URL(request.url).pathname });
  60. return HttpResponse.json({ discarded: 2 });
  61. }),
  62. http.post('*/api/v1/pending-uploads/:id/archive', async ({ request }) => {
  63. calls.push({
  64. method: 'POST',
  65. path: new URL(request.url).pathname,
  66. body: await request.json(),
  67. });
  68. return HttpResponse.json({ id: 100, print_name: 'First Upload', filename: 'first.gcode.3mf' });
  69. }),
  70. http.delete('*/api/v1/pending-uploads/:id', ({ request }) => {
  71. calls.push({ method: 'DELETE', path: new URL(request.url).pathname });
  72. return HttpResponse.json({ success: true });
  73. }),
  74. );
  75. });
  76. /** The row card for an upload, found via its display name. */
  77. async function rowFor(name: string): Promise<HTMLElement> {
  78. const label = await screen.findByText(name);
  79. // name <p> -> div -> div.flex gap-3 -> div.flex justify-between (row header)
  80. const header = label.closest('div.justify-between') as HTMLElement;
  81. expect(header).not.toBeNull();
  82. return header;
  83. }
  84. describe('PendingUploadsPanel wiring', () => {
  85. it('Archive with tags, notes and a project posts them for that upload id only', async () => {
  86. const user = userEvent.setup();
  87. render(<PendingUploadsPanel />);
  88. const row = await rowFor('Second Upload');
  89. // Expand toggle is the first (icon-only) button in the row.
  90. await user.click(within(row).getAllByRole('button')[0]);
  91. const card = row.parentElement as HTMLElement;
  92. await user.type(within(card).getByPlaceholderText('e.g., functional, prototype, gift'), 'gift');
  93. await user.type(within(card).getByPlaceholderText('Add notes about this print...'), 'for bob');
  94. const select = within(card).getByRole('combobox');
  95. await waitFor(() => expect(within(select).getByRole('option', { name: 'Gadgets' })).toBeInTheDocument());
  96. // Archived projects are not offered as a target.
  97. expect(within(select).queryByRole('option', { name: 'Archived Proj' })).toBeNull();
  98. await user.selectOptions(select, '5');
  99. await user.click(within(row).getByRole('button', { name: /Archive/ }));
  100. await waitFor(() => expect(calls).toHaveLength(1));
  101. expect(calls[0]).toEqual({
  102. method: 'POST',
  103. path: '/api/v1/pending-uploads/22/archive',
  104. body: { tags: 'gift', notes: 'for bob', project_id: 5 },
  105. });
  106. });
  107. it('Archive without a project omits project_id', async () => {
  108. const user = userEvent.setup();
  109. render(<PendingUploadsPanel />);
  110. const row = await rowFor('First Upload');
  111. await user.click(within(row).getByRole('button', { name: /Archive/ }));
  112. await waitFor(() => expect(calls).toHaveLength(1));
  113. expect(calls[0].path).toBe('/api/v1/pending-uploads/11/archive');
  114. expect(calls[0].body).toEqual({ tags: '', notes: '' });
  115. });
  116. it('Discard -> confirm sends DELETE for that upload id', async () => {
  117. const user = userEvent.setup();
  118. render(<PendingUploadsPanel />);
  119. const row = await rowFor('Second Upload');
  120. const buttons = within(row).getAllByRole('button');
  121. await user.click(buttons[buttons.length - 1]); // trash icon
  122. expect(await screen.findByText('Discard Upload')).toBeInTheDocument();
  123. await user.click(screen.getByRole('button', { name: 'Discard' }));
  124. await waitFor(() => expect(calls).toHaveLength(1));
  125. expect(calls[0]).toEqual({ method: 'DELETE', path: '/api/v1/pending-uploads/22' });
  126. });
  127. it('Discard -> cancel sends nothing', async () => {
  128. const user = userEvent.setup();
  129. render(<PendingUploadsPanel />);
  130. const row = await rowFor('Second Upload');
  131. const buttons = within(row).getAllByRole('button');
  132. await user.click(buttons[buttons.length - 1]);
  133. await screen.findByText('Discard Upload');
  134. await user.click(screen.getByRole('button', { name: 'Cancel' }));
  135. await waitFor(() => expect(screen.queryByText('Discard Upload')).toBeNull());
  136. expect(calls).toHaveLength(0);
  137. });
  138. it('Archive All -> confirm posts archive-all', async () => {
  139. const user = userEvent.setup();
  140. render(<PendingUploadsPanel />);
  141. await screen.findByText('First Upload');
  142. await user.click(screen.getByRole('button', { name: 'Archive All' }));
  143. const title = await screen.findByText('Archive All Uploads');
  144. const dialog = title.closest('div.flex-1')?.parentElement?.parentElement as HTMLElement;
  145. await user.click(within(dialog).getByRole('button', { name: 'Archive All' }));
  146. await waitFor(() => expect(calls).toHaveLength(1));
  147. expect(calls[0]).toEqual({ method: 'POST', path: '/api/v1/pending-uploads/archive-all' });
  148. });
  149. it('Discard All -> confirm sends DELETE discard-all', async () => {
  150. const user = userEvent.setup();
  151. render(<PendingUploadsPanel />);
  152. await screen.findByText('First Upload');
  153. await user.click(screen.getByRole('button', { name: 'Discard All' }));
  154. const title = await screen.findByText('Discard All Uploads');
  155. const dialog = title.closest('div.flex-1')?.parentElement?.parentElement as HTMLElement;
  156. await user.click(within(dialog).getByRole('button', { name: 'Discard All' }));
  157. await waitFor(() => expect(calls).toHaveLength(1));
  158. expect(calls[0]).toEqual({ method: 'DELETE', path: '/api/v1/pending-uploads/discard-all' });
  159. });
  160. it('Discard All -> cancel sends nothing', async () => {
  161. const user = userEvent.setup();
  162. render(<PendingUploadsPanel />);
  163. await screen.findByText('First Upload');
  164. await user.click(screen.getByRole('button', { name: 'Discard All' }));
  165. await screen.findByText('Discard All Uploads');
  166. await user.click(screen.getByRole('button', { name: 'Cancel' }));
  167. await waitFor(() => expect(screen.queryByText('Discard All Uploads')).toBeNull());
  168. expect(calls).toHaveLength(0);
  169. });
  170. });