LibraryTrashPage.test.tsx 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150
  1. /**
  2. * Tests for the Library Trash page (#1008).
  3. *
  4. * Covers: empty-trash view, populated table with file/folder/size columns,
  5. * restore round-trip (invalidates list), purge-now with confirmation dialog,
  6. * empty-trash bulk action, and the admin-only retention setting control.
  7. */
  8. import { describe, it, expect, afterEach } from 'vitest';
  9. import { screen, waitFor } from '@testing-library/react';
  10. import userEvent from '@testing-library/user-event';
  11. import { http, HttpResponse } from 'msw';
  12. import { render } from '../utils';
  13. import { server } from '../mocks/server';
  14. import { LibraryTrashPage } from '../../pages/LibraryTrashPage';
  15. function trashItem(overrides: Partial<Record<string, unknown>> = {}) {
  16. return {
  17. id: 1,
  18. filename: 'old_benchy.3mf',
  19. file_size: 204800,
  20. thumbnail_path: null,
  21. folder_id: null,
  22. folder_name: 'Calibration',
  23. created_by_id: 1,
  24. created_by_username: 'alice',
  25. deleted_at: '2026-04-10T12:00:00Z',
  26. auto_purge_at: '2026-05-10T12:00:00Z',
  27. ...overrides,
  28. };
  29. }
  30. afterEach(() => server.resetHandlers());
  31. describe('LibraryTrashPage', () => {
  32. it('shows the empty state when there are no trashed files', async () => {
  33. server.use(
  34. http.get('*/library/trash', () =>
  35. HttpResponse.json({ items: [], total: 0, retention_days: 30 }),
  36. ),
  37. http.get('*/library/trash/settings', () =>
  38. HttpResponse.json({ retention_days: 30 }),
  39. ),
  40. );
  41. render(<LibraryTrashPage />);
  42. expect(await screen.findByText(/trash is empty/i)).toBeInTheDocument();
  43. });
  44. it('renders trashed files and the retention control for admins', async () => {
  45. server.use(
  46. http.get('*/library/trash', () =>
  47. HttpResponse.json({
  48. items: [trashItem(), trashItem({ id: 2, filename: 'old_calibration.3mf', file_size: 102400 })],
  49. total: 2,
  50. retention_days: 30,
  51. }),
  52. ),
  53. http.get('*/library/trash/settings', () =>
  54. HttpResponse.json({ retention_days: 30 }),
  55. ),
  56. );
  57. render(<LibraryTrashPage />);
  58. expect(await screen.findByText('old_benchy.3mf')).toBeInTheDocument();
  59. expect(screen.getByText('old_calibration.3mf')).toBeInTheDocument();
  60. // Retention input is visible when auth is off (isAdmin=true in tests)
  61. expect(screen.getByLabelText(/auto-delete after/i)).toBeInTheDocument();
  62. });
  63. it('restores a file when Restore is clicked', async () => {
  64. let restoreCalledFor: number | null = null;
  65. server.use(
  66. http.get('*/library/trash', () =>
  67. HttpResponse.json({ items: [trashItem()], total: 1, retention_days: 30 }),
  68. ),
  69. http.get('*/library/trash/settings', () =>
  70. HttpResponse.json({ retention_days: 30 }),
  71. ),
  72. http.post('*/library/trash/:id/restore', ({ params }) => {
  73. restoreCalledFor = Number(params.id);
  74. return HttpResponse.json({ status: 'success', id: Number(params.id) });
  75. }),
  76. );
  77. render(<LibraryTrashPage />);
  78. await screen.findByText('old_benchy.3mf');
  79. await userEvent.click(screen.getByRole('button', { name: /restore/i }));
  80. await waitFor(() => expect(restoreCalledFor).toBe(1));
  81. });
  82. it('prompts before permanently deleting a single file', async () => {
  83. let deleteCalledFor: number | null = null;
  84. server.use(
  85. http.get('*/library/trash', () =>
  86. HttpResponse.json({ items: [trashItem()], total: 1, retention_days: 30 }),
  87. ),
  88. http.get('*/library/trash/settings', () =>
  89. HttpResponse.json({ retention_days: 30 }),
  90. ),
  91. http.delete('*/library/trash/:id', ({ params }) => {
  92. deleteCalledFor = Number(params.id);
  93. return HttpResponse.json({ status: 'success' });
  94. }),
  95. );
  96. render(<LibraryTrashPage />);
  97. await screen.findByText('old_benchy.3mf');
  98. await userEvent.click(screen.getByRole('button', { name: /delete now/i }));
  99. // ConfirmModal opens. The modal's title is unique; the body repeats the filename.
  100. expect(await screen.findByRole('heading', { name: /delete permanently/i })).toBeInTheDocument();
  101. await userEvent.click(screen.getByRole('button', { name: /delete permanently/i }));
  102. await waitFor(() => expect(deleteCalledFor).toBe(1));
  103. });
  104. it('empties the trash via the Empty Trash action', async () => {
  105. let emptyCalled = false;
  106. server.use(
  107. http.get('*/library/trash', () =>
  108. HttpResponse.json({
  109. items: [trashItem(), trashItem({ id: 2, filename: 'two.3mf' })],
  110. total: 2,
  111. retention_days: 30,
  112. }),
  113. ),
  114. http.get('*/library/trash/settings', () =>
  115. HttpResponse.json({ retention_days: 30 }),
  116. ),
  117. http.delete('*/library/trash', () => {
  118. emptyCalled = true;
  119. return HttpResponse.json({ deleted: 2 });
  120. }),
  121. );
  122. render(<LibraryTrashPage />);
  123. await screen.findByText('old_benchy.3mf');
  124. await userEvent.click(screen.getByRole('button', { name: /empty trash/i }));
  125. await userEvent.click(screen.getByRole('button', { name: /delete permanently/i }));
  126. await waitFor(() => expect(emptyCalled).toBe(true));
  127. });
  128. });