wrolpi/app/src/components/collections/CollectionReorganizeModal.test.js
Roland 8ba73e64cd
build: uninstall Semantic UI
`semantic-ui-react`, `react-semantic-toasts-2` and `semantic-ui-offline` are
gone from package.json, the lockfile and node_modules -- 19 MB of dependencies
that nothing had imported since the migration finished.

Four things still referenced them and would have broken on uninstall.  None was
caught by a build or by jest:

  Four test files still ran `jest.mock('react-semantic-toasts-2', ...)` for
  components that no longer use it.  A jest.mock of a module that does not exist
  throws, so the packages could not have come out without this.

  `src/components/Download.cy.js` and `cypress/support/component.js` both
  imported semantic.min.css.  The support file now loads what the app loads --
  index.css, the font and token stylesheets, and Mantine's.

The ratchet changes job rather than being deleted.  An import of a missing module
fails loudly now, but three quieter mistakes would not: a `className="ui button"`
that styles nothing, a file reaching back through Theme.tsx, and someone
reinstalling a package to "just use one Semantic component".  It asserts all
three, plus that package.json names nothing matching /semantic/i.

Two pre-existing problems in the cypress component harness surfaced while
checking the uninstall had not broken it, both fixed because that file had to be
edited anyway:

  It imported `react-router-dom`, which is not installed -- this app is on
  react-router v7.  Dates to 5621d473, so the component suite has been
  un-runnable since well before this migration.

  Its mount helper supplied no MantineProvider, so every library component threw
  -- the same gap test-utils.js had for jest.

The component specs now load and four pass; eleven assertions are still written
against markup from before they stopped running.  Repairing those is its own
piece of work, deliberately not bundled in here.  The e2e suite is unaffected:
43/43.
2026-08-08 18:04:03 -06:00

461 lines
16 KiB
JavaScript

import React from 'react';
import {screen, waitFor, fireEvent} from '@testing-library/react';
import {renderWithProviders} from '../../test-utils';
import {CollectionReorganizeModal} from './CollectionReorganizeModal';
import * as api from '../../api';
// Mock the API functions
jest.mock('../../api', () => ({
previewCollectionReorganization: jest.fn(),
executeCollectionReorganization: jest.fn(),
getReorganizationStatus: jest.fn(),
}));
// Mock the FileWorkerStatusContext - default mock returns inactive state
jest.mock('../../contexts/FileWorkerStatusContext', () => ({
useReorganizationStatus: () => ({
isReorganizing: false,
taskType: null,
collectionId: null,
collectionKind: null,
batchStatus: null,
workerStatus: null,
refresh: jest.fn(),
}),
useFileWorkerStatus: () => ({
status: null,
error: null,
refresh: jest.fn(),
setFastPolling: jest.fn(),
}),
}));
describe('CollectionReorganizeModal', () => {
const defaultProps = {
open: true,
onClose: jest.fn(),
collectionId: 1,
collectionName: 'Test Channel',
onComplete: jest.fn(),
needsReorganization: true,
};
beforeEach(() => {
jest.clearAllMocks();
});
it('loads preview when opened', async () => {
const mockPreview = {
files_needing_move: 45,
total_files: 50,
current_file_format: '%(title)s.%(ext)s',
new_file_format: '%(upload_date)s/%(title)s.%(ext)s',
sample_moves: [
{old_path: 'video.mp4', new_path: '2024-01-15/video.mp4'},
],
};
api.previewCollectionReorganization.mockResolvedValue(mockPreview);
renderWithProviders(<CollectionReorganizeModal {...defaultProps} />);
// Should show loading initially
expect(screen.getByText(/Analyzing files/i)).toBeInTheDocument();
// Wait for preview to load
await waitFor(() => {
expect(screen.getByText(/45/)).toBeInTheDocument();
expect(screen.getByText(/50/)).toBeInTheDocument();
});
expect(api.previewCollectionReorganization).toHaveBeenCalledWith(1);
});
it('shows sample moves in preview', async () => {
const mockPreview = {
files_needing_move: 10,
total_files: 20,
current_file_format: '%(title)s.%(ext)s',
new_file_format: '%(upload_date)s/%(title)s.%(ext)s',
sample_moves: [
{old_path: 'old/video1.mp4', new_path: 'new/video1.mp4'},
{old_path: 'old/video2.mp4', new_path: 'new/video2.mp4'},
],
};
api.previewCollectionReorganization.mockResolvedValue(mockPreview);
renderWithProviders(<CollectionReorganizeModal {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText('old/video1.mp4')).toBeInTheDocument();
expect(screen.getByText('new/video1.mp4')).toBeInTheDocument();
});
});
it('shows "No Files Need Reorganization" message when no files need moving', async () => {
const mockPreview = {
files_needing_move: 0,
total_files: 50,
current_file_format: '%(upload_date)s/%(title)s.%(ext)s',
new_file_format: '%(upload_date)s/%(title)s.%(ext)s',
sample_moves: [],
};
api.previewCollectionReorganization.mockResolvedValue(mockPreview);
renderWithProviders(<CollectionReorganizeModal {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText(/No Files Need Reorganization/i)).toBeInTheDocument();
});
});
it('shows warning when collection appears organized but has files to move', async () => {
const mockPreview = {
files_needing_move: 5,
total_files: 50,
current_file_format: '%(upload_date)s/%(title)s.%(ext)s',
new_file_format: '%(upload_date)s/%(title)s.%(ext)s',
sample_moves: [{old_path: 'a.mp4', new_path: 'b.mp4'}],
};
api.previewCollectionReorganization.mockResolvedValue(mockPreview);
// needsReorganization is false, but there are files to move
renderWithProviders(
<CollectionReorganizeModal {...defaultProps} needsReorganization={false} />
);
await waitFor(() => {
expect(screen.getByText(/Collection Appears Organized/i)).toBeInTheDocument();
});
});
it('executes reorganization on button click', async () => {
const mockPreview = {
files_needing_move: 10,
total_files: 20,
new_file_format: '%(upload_date)s/%(title)s.%(ext)s',
sample_moves: [],
};
api.previewCollectionReorganization.mockResolvedValue(mockPreview);
api.executeCollectionReorganization.mockResolvedValue({
job_id: 'reorganize-abc123',
message: 'Reorganization started',
});
api.getReorganizationStatus.mockResolvedValue({
status: 'complete',
total: 10,
completed: 10,
percent: 100,
});
renderWithProviders(<CollectionReorganizeModal {...defaultProps} />);
// Wait for preview to load
await waitFor(() => {
expect(screen.getByRole('button', {name: /Reorganize Files/i})).toBeInTheDocument();
});
// Click the reorganize button
fireEvent.click(screen.getByRole('button', {name: /Reorganize Files/i}));
await waitFor(() => {
expect(api.executeCollectionReorganization).toHaveBeenCalledWith(1);
});
});
it('shows progress bar during execution', async () => {
const mockPreview = {
files_needing_move: 10,
total_files: 20,
new_file_format: '%(upload_date)s/%(title)s.%(ext)s',
sample_moves: [],
};
api.previewCollectionReorganization.mockResolvedValue(mockPreview);
api.executeCollectionReorganization.mockResolvedValue({
job_id: 'reorganize-abc123',
message: 'Reorganization started',
});
api.getReorganizationStatus.mockResolvedValue({
status: 'running',
total: 10,
completed: 5,
percent: 50,
});
renderWithProviders(<CollectionReorganizeModal {...defaultProps} />);
await waitFor(() => {
expect(screen.getByRole('button', {name: /Reorganize Files/i})).toBeInTheDocument();
});
fireEvent.click(screen.getByRole('button', {name: /Reorganize Files/i}));
await waitFor(() => {
expect(screen.getByText(/5 of 10 files processed/i)).toBeInTheDocument();
});
});
it('calls onComplete when reorganization finishes', async () => {
const mockOnComplete = jest.fn();
const mockPreview = {
files_needing_move: 10,
total_files: 20,
new_file_format: '%(upload_date)s/%(title)s.%(ext)s',
sample_moves: [],
};
api.previewCollectionReorganization.mockResolvedValue(mockPreview);
api.executeCollectionReorganization.mockResolvedValue({
job_id: 'reorganize-abc123',
message: 'Reorganization started',
});
api.getReorganizationStatus.mockResolvedValue({
status: 'complete',
total: 10,
completed: 10,
percent: 100,
});
renderWithProviders(
<CollectionReorganizeModal {...defaultProps} onComplete={mockOnComplete} />
);
await waitFor(() => {
expect(screen.getByRole('button', {name: /Reorganize Files/i})).toBeInTheDocument();
});
fireEvent.click(screen.getByRole('button', {name: /Reorganize Files/i}));
await waitFor(() => {
expect(mockOnComplete).toHaveBeenCalled();
});
});
it('displays error message on API failure', async () => {
api.previewCollectionReorganization.mockRejectedValue(new Error('Network error'));
renderWithProviders(<CollectionReorganizeModal {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText(/Network error/i)).toBeInTheDocument();
});
});
it('calls onClose when cancel button is clicked', async () => {
const mockOnClose = jest.fn();
const mockPreview = {
files_needing_move: 10,
total_files: 20,
new_file_format: '%(upload_date)s/%(title)s.%(ext)s',
sample_moves: [],
};
api.previewCollectionReorganization.mockResolvedValue(mockPreview);
renderWithProviders(
<CollectionReorganizeModal {...defaultProps} onClose={mockOnClose} />
);
await waitFor(() => {
expect(screen.getByRole('button', {name: /Cancel/i})).toBeInTheDocument();
});
fireEvent.click(screen.getByRole('button', {name: /Cancel/i}));
expect(mockOnClose).toHaveBeenCalled();
});
it('shows completion message when reorganization completes', async () => {
const mockPreview = {
files_needing_move: 10,
total_files: 20,
new_file_format: '%(upload_date)s/%(title)s.%(ext)s',
sample_moves: [],
};
api.previewCollectionReorganization.mockResolvedValue(mockPreview);
api.executeCollectionReorganization.mockResolvedValue({
job_id: 'reorganize-abc123',
message: 'Reorganization started',
});
api.getReorganizationStatus.mockResolvedValue({
status: 'complete',
total: 10,
completed: 10,
percent: 100,
});
renderWithProviders(<CollectionReorganizeModal {...defaultProps} />);
await waitFor(() => {
expect(screen.getByRole('button', {name: /Reorganize Files/i})).toBeInTheDocument();
});
fireEvent.click(screen.getByRole('button', {name: /Reorganize Files/i}));
await waitFor(() => {
expect(screen.getByText(/Reorganization Complete/i)).toBeInTheDocument();
});
});
it('handles execution error gracefully', async () => {
const mockPreview = {
files_needing_move: 10,
total_files: 20,
new_file_format: '%(upload_date)s/%(title)s.%(ext)s',
sample_moves: [],
};
api.previewCollectionReorganization.mockResolvedValue(mockPreview);
api.executeCollectionReorganization.mockRejectedValue(new Error('Permission denied'));
renderWithProviders(<CollectionReorganizeModal {...defaultProps} />);
await waitFor(() => {
expect(screen.getByRole('button', {name: /Reorganize Files/i})).toBeInTheDocument();
});
fireEvent.click(screen.getByRole('button', {name: /Reorganize Files/i}));
await waitFor(() => {
expect(screen.getByText(/Permission denied/i)).toBeInTheDocument();
});
});
it('does not fetch preview when modal is closed', () => {
renderWithProviders(<CollectionReorganizeModal {...defaultProps} open={false} />);
expect(api.previewCollectionReorganization).not.toHaveBeenCalled();
});
it('changes button text based on state', async () => {
const mockPreview = {
files_needing_move: 10,
total_files: 20,
new_file_format: '%(upload_date)s/%(title)s.%(ext)s',
sample_moves: [],
};
api.previewCollectionReorganization.mockResolvedValue(mockPreview);
api.executeCollectionReorganization.mockResolvedValue({
job_id: 'reorganize-abc123',
message: 'Reorganization started',
});
api.getReorganizationStatus.mockResolvedValue({
status: 'complete',
total: 10,
completed: 10,
percent: 100,
});
renderWithProviders(<CollectionReorganizeModal {...defaultProps} />);
// Wait for preview to load and button to appear
await waitFor(() => {
expect(screen.getByRole('button', {name: /Reorganize Files/i})).toBeInTheDocument();
});
// Should show "Cancel" initially (when not in progress)
expect(screen.getByRole('button', {name: /Cancel/i})).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', {name: /Reorganize Files/i}));
// Should show "Close" after completion
await waitFor(() => {
expect(screen.getByRole('button', {name: /Close/i})).toBeInTheDocument();
});
});
it('polls for status with real job ID, not with "active"', async () => {
// This test verifies the bug fix: the component should poll using
// the real job_id returned from executeCollectionReorganization,
// not a hardcoded string like 'active'
const mockPreview = {
files_needing_move: 10,
total_files: 20,
new_file_format: '%(upload_date)s/%(title)s.%(ext)s',
sample_moves: [],
};
api.previewCollectionReorganization.mockResolvedValue(mockPreview);
api.executeCollectionReorganization.mockResolvedValue({
job_id: 'reorganize-real-job-id-123',
message: 'Reorganization started',
});
api.getReorganizationStatus.mockResolvedValue({
status: 'running',
total: 10,
completed: 3,
percent: 30,
});
renderWithProviders(<CollectionReorganizeModal {...defaultProps} />);
await waitFor(() => {
expect(screen.getByRole('button', {name: /Reorganize Files/i})).toBeInTheDocument();
});
fireEvent.click(screen.getByRole('button', {name: /Reorganize Files/i}));
// Wait for status polling to start
await waitFor(() => {
expect(api.getReorganizationStatus).toHaveBeenCalled();
});
// CRITICAL: Verify that getReorganizationStatus was called with the real job ID
// NOT with 'active' which was the bug
expect(api.getReorganizationStatus).toHaveBeenCalledWith(1, 'reorganize-real-job-id-123');
expect(api.getReorganizationStatus).not.toHaveBeenCalledWith(1, 'active');
});
it('handles status polling error and retries', async () => {
const mockPreview = {
files_needing_move: 10,
total_files: 20,
new_file_format: '%(upload_date)s/%(title)s.%(ext)s',
sample_moves: [],
};
api.previewCollectionReorganization.mockResolvedValue(mockPreview);
api.executeCollectionReorganization.mockResolvedValue({
job_id: 'reorganize-abc123',
message: 'Reorganization started',
});
// First call fails, second call succeeds
api.getReorganizationStatus
.mockRejectedValueOnce(new Error('Network error'))
.mockResolvedValue({
status: 'complete',
total: 10,
completed: 10,
percent: 100,
});
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
renderWithProviders(<CollectionReorganizeModal {...defaultProps} />);
await waitFor(() => {
expect(screen.getByRole('button', {name: /Reorganize Files/i})).toBeInTheDocument();
});
fireEvent.click(screen.getByRole('button', {name: /Reorganize Files/i}));
// Wait for retry and completion
await waitFor(() => {
expect(screen.getByText(/Reorganization Complete/i)).toBeInTheDocument();
}, {timeout: 3000});
// Should have retried after initial error
expect(api.getReorganizationStatus.mock.calls.length).toBeGreaterThanOrEqual(2);
consoleSpy.mockRestore();
});
});