diff --git a/frontend/src/components/BulkRetryModal.tsx b/frontend/src/components/BulkRetryModal.tsx index fdc0d52..7958d58 100644 --- a/frontend/src/components/BulkRetryModal.tsx +++ b/frontend/src/components/BulkRetryModal.tsx @@ -365,13 +365,13 @@ export const BulkRetryModal: React.FC = ({ {formatDuration(previewResult.estimated_total_time_minutes)} - {previewResult.documents.length > 0 && ( + {previewResult.documents && previewResult.documents.length > 0 && ( Sample Documents: - {previewResult.documents.slice(0, 10).map((doc) => ( + {(previewResult.documents || []).slice(0, 10).map((doc) => ( {doc.filename} ({formatFileSize(doc.file_size)}) @@ -385,7 +385,7 @@ export const BulkRetryModal: React.FC = ({ ))} - {previewResult.documents.length > 10 && ( + {previewResult.documents && previewResult.documents.length > 10 && ( ... and {previewResult.documents.length - 10} more documents diff --git a/frontend/src/components/__tests__/BulkRetryModal.test.tsx b/frontend/src/components/__tests__/BulkRetryModal.test.tsx new file mode 100644 index 0000000..d0e9a72 --- /dev/null +++ b/frontend/src/components/__tests__/BulkRetryModal.test.tsx @@ -0,0 +1,254 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { BulkRetryModal } from '../BulkRetryModal'; + +// Mock the API +const mockBulkRetryOcr = vi.fn(); +const mockDocumentService = { + bulkRetryOcr: mockBulkRetryOcr, +}; +const mockApi = { + bulkRetryOcr: mockBulkRetryOcr, +}; + +vi.mock('../../services/api', () => ({ + default: mockApi, + documentService: mockDocumentService, +})); + +describe('BulkRetryModal', () => { + const mockProps = { + open: true, + onClose: vi.fn(), + onSuccess: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + mockBulkRetryOcr.mockResolvedValue({ + data: { + success: true, + queued_count: 5, + matched_count: 5, + documents: [], + estimated_total_time_minutes: 2.5, + message: 'Operation completed successfully', + }, + }); + }); + + test('renders modal with title and form elements', () => { + render(); + + expect(screen.getByText('Bulk OCR Retry')).toBeInTheDocument(); + expect(screen.getByText('Retry Mode')).toBeInTheDocument(); + expect(screen.getByText('Retry all failed OCR documents')).toBeInTheDocument(); + expect(screen.getByText('Retry documents matching criteria')).toBeInTheDocument(); + }); + + test('closes modal when close button is clicked', async () => { + const user = userEvent.setup(); + render(); + + const closeButton = screen.getByText('Cancel'); + await user.click(closeButton); + + expect(mockProps.onClose).toHaveBeenCalled(); + }); + + test('shows preview by default', () => { + render(); + + const previewButton = screen.getByText('Preview'); + expect(previewButton).toBeInTheDocument(); + }); + + test('allows switching to filter mode', async () => { + const user = userEvent.setup(); + render(); + + const filterRadio = screen.getByLabelText('Retry documents matching criteria'); + await user.click(filterRadio); + + // Should show the accordion with filter criteria + expect(screen.getByText('Filter Criteria')).toBeInTheDocument(); + + // Expand the accordion to see filter options + const filterAccordion = screen.getByText('Filter Criteria'); + await user.click(filterAccordion); + + expect(screen.getByText('File Types')).toBeInTheDocument(); + expect(screen.getByText('Failure Reasons')).toBeInTheDocument(); + expect(screen.getByText('Maximum File Size')).toBeInTheDocument(); + }); + + test('can select MIME types in filter mode', async () => { + const user = userEvent.setup(); + render(); + + // Switch to filter mode + const filterRadio = screen.getByLabelText('Retry documents matching criteria'); + await user.click(filterRadio); + + // Expand the accordion to see filter options + const filterAccordion = screen.getByText('Filter Criteria'); + await user.click(filterAccordion); + + // Should show MIME type chips + const pdfChip = screen.getByText('PDF'); + expect(pdfChip).toBeInTheDocument(); + + // Click on the PDF chip to select it + await user.click(pdfChip); + + // The chip should now be selected (filled variant) + expect(pdfChip.closest('[data-testid], .MuiChip-root')).toBeInTheDocument(); + }); + + test('can set priority override', async () => { + const user = userEvent.setup(); + render(); + + // Expand the Advanced Options accordion + const advancedAccordion = screen.getByText('Advanced Options'); + await user.click(advancedAccordion); + + // Enable priority override + const priorityCheckbox = screen.getByLabelText('Override processing priority'); + await user.click(priorityCheckbox); + + // Now the slider should be visible + const prioritySlider = screen.getByRole('slider'); + fireEvent.change(prioritySlider, { target: { value: 15 } }); + + expect(prioritySlider).toHaveValue('15'); + }); + + test('executes preview request successfully', async () => { + const user = userEvent.setup(); + mockBulkRetryOcr.mockResolvedValue({ + data: { + success: true, + queued_count: 0, + matched_count: 3, + documents: [ + { id: '1', filename: 'doc1.pdf', file_size: 1024, mime_type: 'application/pdf' }, + { id: '2', filename: 'doc2.pdf', file_size: 2048, mime_type: 'application/pdf' }, + ], + estimated_total_time_minutes: 1.5, + }, + }); + + render(); + + const previewButton = screen.getByText('Preview'); + await user.click(previewButton); + + await waitFor(() => { + expect(screen.getByText('Preview Results')).toBeInTheDocument(); + }); + + expect(screen.getByText('Documents matched:')).toBeInTheDocument(); + expect(screen.getByText('Estimated processing time:')).toBeInTheDocument(); + }); + + test('executes actual retry request successfully', async () => { + const user = userEvent.setup(); + render(); + + // First do a preview + const previewButton = screen.getByText('Preview'); + await user.click(previewButton); + + await waitFor(() => { + expect(screen.getByText(/Retry \d+ Documents/)).toBeInTheDocument(); + }); + + // Now execute the retry + const executeButton = screen.getByText(/Retry \d+ Documents/); + await user.click(executeButton); + + await waitFor(() => { + expect(mockBulkRetryOcr).toHaveBeenCalledWith({ + mode: 'all', + preview_only: false, + }); + }); + + expect(mockProps.onSuccess).toHaveBeenCalled(); + expect(mockProps.onClose).toHaveBeenCalled(); + }); + + test('handles API errors gracefully', async () => { + const user = userEvent.setup(); + mockBulkRetryOcr.mockRejectedValue(new Error('API Error')); + + render(); + + const previewButton = screen.getByText('Preview'); + await user.click(previewButton); + + await waitFor(() => { + expect(screen.getByText(/Failed to preview retry/)).toBeInTheDocument(); + }); + }); + + test('can set document limit in filter mode', async () => { + const user = userEvent.setup(); + render(); + + // Switch to filter mode + const filterRadio = screen.getByLabelText('Retry documents matching criteria'); + await user.click(filterRadio); + + // Expand the accordion to see filter options + const filterAccordion = screen.getByText('Filter Criteria'); + await user.click(filterAccordion); + + // Find and set the document limit + const limitInput = screen.getByLabelText('Maximum Documents to Retry'); + await user.clear(limitInput); + await user.type(limitInput, '100'); + + expect(limitInput).toHaveValue(100); + }); + + test('shows loading state during API calls', async () => { + const user = userEvent.setup(); + + // Make the API call take time + mockBulkRetryOcr.mockImplementation(() => new Promise(resolve => + setTimeout(() => resolve({ + data: { success: true, queued_count: 0, matched_count: 0, documents: [] } + }), 100) + )); + + render(); + + const previewButton = screen.getByText('Preview'); + await user.click(previewButton); + + // Should show loading state + expect(screen.getByRole('progressbar')).toBeInTheDocument(); + // The button should remain as "Preview" during loading, not change text + expect(screen.getByText('Preview')).toBeInTheDocument(); + }); + + test('resets form when modal is closed and reopened', () => { + const { rerender } = render(); + + // Reopen the modal + rerender(); + + // Should be back to default state + expect(screen.getByLabelText('Retry all failed OCR documents')).toBeChecked(); + // Note: slider is not visible by default as it's in an accordion + }); + + test('does not render when modal is closed', () => { + render(); + + expect(screen.queryByText('Bulk OCR Retry')).not.toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/frontend/src/components/__tests__/RetryHistoryModal.test.tsx b/frontend/src/components/__tests__/RetryHistoryModal.test.tsx new file mode 100644 index 0000000..47812ac --- /dev/null +++ b/frontend/src/components/__tests__/RetryHistoryModal.test.tsx @@ -0,0 +1,296 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { RetryHistoryModal } from '../RetryHistoryModal'; + +// Mock the API +const mockGetDocumentRetryHistory = vi.fn(); + +const mockDocumentService = { + getDocumentRetryHistory: mockGetDocumentRetryHistory, +}; + +vi.mock('../../services/api', () => ({ + documentService: mockDocumentService, +})); + +describe('RetryHistoryModal', () => { + const mockProps = { + open: true, + onClose: vi.fn(), + documentId: 'test-doc-123', + documentName: 'test-document.pdf', + }; + + const sampleRetryHistory = [ + { + id: 'retry-1', + retry_reason: 'bulk_retry_all', + previous_status: 'failed', + previous_failure_reason: 'low_confidence', + previous_error: 'OCR confidence too low: 45%', + priority: 15, + queue_id: 'queue-1', + created_at: '2024-01-15T10:30:00Z', + }, + { + id: 'retry-2', + retry_reason: 'manual_retry', + previous_status: 'failed', + previous_failure_reason: 'image_quality', + previous_error: 'Image resolution too low', + priority: 12, + queue_id: 'queue-2', + created_at: '2024-01-14T14:20:00Z', + }, + ]; + + beforeEach(() => { + vi.clearAllMocks(); + mockGetDocumentRetryHistory.mockResolvedValue({ + data: { + document_id: 'test-doc-123', + retry_history: sampleRetryHistory, + total_retries: 2, + }, + }); + }); + + test('renders modal with title and document name', () => { + render(); + + expect(screen.getByText('OCR Retry History')).toBeInTheDocument(); + expect(screen.getByText('test-document.pdf')).toBeInTheDocument(); + }); + + test('does not render when modal is closed', () => { + render(); + + expect(screen.queryByText('OCR Retry History')).not.toBeInTheDocument(); + }); + + test('loads and displays retry history on mount', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('Bulk Retry (All Documents)')).toBeInTheDocument(); + }); + + expect(screen.getByText('Manual Retry')).toBeInTheDocument(); + expect(screen.getByText('Low Confidence')).toBeInTheDocument(); + expect(screen.getByText('Image Quality')).toBeInTheDocument(); + expect(screen.getByText('High')).toBeInTheDocument(); // Priority 15 + expect(screen.getByText('Medium')).toBeInTheDocument(); // Priority 12 + }); + + test('shows loading state initially', () => { + mockGetDocumentRetryHistory.mockImplementation(() => new Promise(() => {})); // Never resolves + render(); + + expect(screen.getByRole('progressbar')).toBeInTheDocument(); + expect(screen.getByText('Loading retry history...')).toBeInTheDocument(); + }); + + test('handles API errors gracefully', async () => { + mockGetDocumentRetryHistory.mockRejectedValue(new Error('API Error')); + render(); + + await waitFor(() => { + expect(screen.getByText(/Failed to load retry history/)).toBeInTheDocument(); + }); + }); + + test('shows empty state when no retry history exists', async () => { + mockGetDocumentRetryHistory.mockResolvedValue({ + data: { + document_id: 'test-doc-123', + retry_history: [], + total_retries: 0, + }, + }); + + render(); + + await waitFor(() => { + expect(screen.getByText('No retry history found for this document.')).toBeInTheDocument(); + }); + }); + + test('closes modal when close button is clicked', async () => { + const user = userEvent.setup(); + render(); + + const closeButton = screen.getByText('Close'); + await user.click(closeButton); + + expect(mockProps.onClose).toHaveBeenCalled(); + }); + + test('formats retry reasons correctly', async () => { + const customHistory = [ + { ...sampleRetryHistory[0], retry_reason: 'bulk_retry_all' }, + { ...sampleRetryHistory[0], retry_reason: 'bulk_retry_specific' }, + { ...sampleRetryHistory[0], retry_reason: 'bulk_retry_filtered' }, + { ...sampleRetryHistory[0], retry_reason: 'manual_retry' }, + { ...sampleRetryHistory[0], retry_reason: 'unknown_reason' }, + ]; + + mockGetDocumentRetryHistory.mockResolvedValue({ + data: { + document_id: 'test-doc-123', + retry_history: customHistory, + total_retries: customHistory.length, + }, + }); + + render(); + + await waitFor(() => { + expect(screen.getByText('Bulk Retry (All Documents)')).toBeInTheDocument(); + expect(screen.getByText('Bulk Retry (Specific Documents)')).toBeInTheDocument(); + expect(screen.getByText('Bulk Retry (Filtered)')).toBeInTheDocument(); + expect(screen.getByText('Manual Retry')).toBeInTheDocument(); + expect(screen.getByText('unknown_reason')).toBeInTheDocument(); // Unknown reasons show as-is + }); + }); + + test('formats priority levels correctly', async () => { + const customHistory = [ + { ...sampleRetryHistory[0], priority: 20 }, + { ...sampleRetryHistory[0], priority: 15 }, + { ...sampleRetryHistory[0], priority: 10 }, + { ...sampleRetryHistory[0], priority: 5 }, + { ...sampleRetryHistory[0], priority: 1 }, + ]; + + mockGetDocumentRetryHistory.mockResolvedValue({ + data: { + document_id: 'test-doc-123', + retry_history: customHistory, + total_retries: customHistory.length, + }, + }); + + render(); + + await waitFor(() => { + const highPriorities = screen.getAllByText('High'); + const mediumPriorities = screen.getAllByText('Medium'); + const lowPriorities = screen.getAllByText('Low'); + + expect(highPriorities).toHaveLength(2); // Priority 20 and 15 + expect(mediumPriorities).toHaveLength(1); // Priority 10 + expect(lowPriorities).toHaveLength(2); // Priority 5 and 1 + }); + }); + + test('formats failure reasons correctly', async () => { + const customHistory = [ + { ...sampleRetryHistory[0], previous_failure_reason: 'low_confidence' }, + { ...sampleRetryHistory[0], previous_failure_reason: 'image_quality' }, + { ...sampleRetryHistory[0], previous_failure_reason: 'processing_timeout' }, + { ...sampleRetryHistory[0], previous_failure_reason: 'unknown_error' }, + { ...sampleRetryHistory[0], previous_failure_reason: null }, + ]; + + mockGetDocumentRetryHistory.mockResolvedValue({ + data: { + document_id: 'test-doc-123', + retry_history: customHistory, + total_retries: customHistory.length, + }, + }); + + render(); + + await waitFor(() => { + expect(screen.getByText('Low Confidence')).toBeInTheDocument(); + expect(screen.getByText('Image Quality')).toBeInTheDocument(); + expect(screen.getByText('Processing Timeout')).toBeInTheDocument(); + expect(screen.getByText('Unknown Error')).toBeInTheDocument(); + expect(screen.getByText('N/A')).toBeInTheDocument(); // null reason + }); + }); + + test('displays previous error messages', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('OCR confidence too low: 45%')).toBeInTheDocument(); + expect(screen.getByText('Image resolution too low')).toBeInTheDocument(); + }); + }); + + test('formats dates correctly', async () => { + render(); + + await waitFor(() => { + // Check that dates are formatted (exact format may vary by locale) + expect(screen.getByText(/Jan/)).toBeInTheDocument(); + expect(screen.getByText(/2024/)).toBeInTheDocument(); + }); + }); + + test('shows total retry count', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('Total retries: 2')).toBeInTheDocument(); + }); + }); + + test('handles missing documentName gracefully', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('test-doc-123')).toBeInTheDocument(); // Falls back to documentId + }); + }); + + test('handles history entries with missing fields', async () => { + const incompleteHistory = [ + { + id: 'retry-1', + retry_reason: null, + previous_status: null, + previous_failure_reason: null, + previous_error: null, + priority: null, + queue_id: null, + created_at: '2024-01-15T10:30:00Z', + }, + ]; + + mockGetDocumentRetryHistory.mockResolvedValue({ + data: { + document_id: 'test-doc-123', + retry_history: incompleteHistory, + total_retries: 1, + }, + }); + + render(); + + await waitFor(() => { + // Should not crash and should show N/A for missing fields + expect(screen.getAllByText('N/A')).toHaveLength(4); // reason, failure reason, previous error, priority + }); + }); + + test('loads fresh data when documentId changes', async () => { + const { rerender } = render(); + + await waitFor(() => { + expect(mockGetDocumentRetryHistory).toHaveBeenCalledWith('test-doc-123'); + }); + + // Change document ID + rerender(); + + await waitFor(() => { + expect(mockGetDocumentRetryHistory).toHaveBeenCalledWith('different-doc-456'); + }); + + expect(mockGetDocumentRetryHistory).toHaveBeenCalledTimes(2); + }); +}); \ No newline at end of file diff --git a/frontend/src/components/__tests__/RetryRecommendations.test.tsx b/frontend/src/components/__tests__/RetryRecommendations.test.tsx new file mode 100644 index 0000000..bcff794 --- /dev/null +++ b/frontend/src/components/__tests__/RetryRecommendations.test.tsx @@ -0,0 +1,307 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { RetryRecommendations } from '../RetryRecommendations'; + +// Mock the API +const mockGetRetryRecommendations = vi.fn(); +const mockBulkRetryOcr = vi.fn(); + +const mockDocumentService = { + getRetryRecommendations: mockGetRetryRecommendations, +}; + +const mockApi = { + bulkRetryOcr: mockBulkRetryOcr, +}; + +vi.mock('../../services/api', () => ({ + documentService: mockDocumentService, + default: mockApi, +})); + +describe('RetryRecommendations', () => { + const mockProps = { + onRetrySuccess: vi.fn(), + onRetryClick: vi.fn(), + }; + + const sampleRecommendations = [ + { + reason: 'low_confidence', + title: 'Low Confidence Results', + description: 'Documents with OCR confidence below 70%', + estimated_success_rate: 0.8, + document_count: 15, + filter: { + failure_reasons: ['low_confidence'], + min_confidence: 0, + max_confidence: 70, + }, + }, + { + reason: 'image_quality', + title: 'Image Quality Issues', + description: 'Documents that failed due to poor image quality', + estimated_success_rate: 0.6, + document_count: 8, + filter: { + failure_reasons: ['image_quality', 'resolution_too_low'], + }, + }, + ]; + + beforeEach(() => { + vi.clearAllMocks(); + mockGetRetryRecommendations.mockResolvedValue({ + data: { + recommendations: sampleRecommendations, + total_recommendations: 2, + }, + }); + mockBulkRetryOcr.mockResolvedValue({ + data: { + success: true, + queued_count: 10, + matched_count: 15, + documents: [], + }, + }); + }); + + test('renders loading state initially', () => { + mockGetRetryRecommendations.mockImplementation(() => new Promise(() => {})); // Never resolves + render(); + + expect(screen.getByRole('progressbar')).toBeInTheDocument(); + expect(screen.getByText('Loading retry recommendations...')).toBeInTheDocument(); + }); + + test('loads and displays recommendations on mount', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('OCR Retry Recommendations')).toBeInTheDocument(); + }); + + expect(screen.getByText('Low Confidence Results')).toBeInTheDocument(); + expect(screen.getByText('Image Quality Issues')).toBeInTheDocument(); + expect(screen.getByText('15 documents')).toBeInTheDocument(); + expect(screen.getByText('8 documents')).toBeInTheDocument(); + }); + + test('displays success rate badges with correct colors', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('80% (High)')).toBeInTheDocument(); + expect(screen.getByText('60% (Medium)')).toBeInTheDocument(); + }); + + // Check that the badges have the correct colors + const highBadge = screen.getByText('80% (High)').closest('.MuiChip-root'); + const mediumBadge = screen.getByText('60% (Medium)').closest('.MuiChip-root'); + + expect(highBadge).toHaveClass('MuiChip-colorSuccess'); + expect(mediumBadge).toHaveClass('MuiChip-colorWarning'); + }); + + test('handles retry click with onRetryClick callback', async () => { + const user = userEvent.setup(); + render(); + + await waitFor(() => { + expect(screen.getByText('Low Confidence Results')).toBeInTheDocument(); + }); + + const retryButton = screen.getAllByText('Retry Now')[0]; + await user.click(retryButton); + + expect(mockProps.onRetryClick).toHaveBeenCalledWith(sampleRecommendations[0]); + }); + + test('executes retry directly when onRetryClick is not provided', async () => { + const user = userEvent.setup(); + render(); + + await waitFor(() => { + expect(screen.getByText('Low Confidence Results')).toBeInTheDocument(); + }); + + const retryButton = screen.getAllByText('Retry Now')[0]; + await user.click(retryButton); + + await waitFor(() => { + expect(mockBulkRetryOcr).toHaveBeenCalledWith({ + mode: 'filter', + filter: sampleRecommendations[0].filter, + priority_override: 12, + }); + }); + + expect(mockProps.onRetrySuccess).toHaveBeenCalled(); + }); + + test('shows loading state during retry execution', async () => { + const user = userEvent.setup(); + mockBulkRetryOcr.mockImplementation(() => new Promise(resolve => + setTimeout(() => resolve({ + data: { success: true, queued_count: 10, matched_count: 10, documents: [] } + }), 100) + )); + + render(); + + await waitFor(() => { + expect(screen.getByText('Low Confidence Results')).toBeInTheDocument(); + }); + + const retryButton = screen.getAllByText('Retry Now')[0]; + await user.click(retryButton); + + // Should show loading state + expect(screen.getByRole('progressbar')).toBeInTheDocument(); + expect(retryButton).toBeDisabled(); + }); + + test('handles API errors gracefully', async () => { + mockGetRetryRecommendations.mockRejectedValue(new Error('API Error')); + render(); + + await waitFor(() => { + expect(screen.getByText(/Failed to load retry recommendations/)).toBeInTheDocument(); + }); + }); + + test('handles retry API errors gracefully', async () => { + const user = userEvent.setup(); + mockBulkRetryOcr.mockRejectedValue({ + response: { data: { message: 'Retry failed' } } + }); + + render(); + + await waitFor(() => { + expect(screen.getByText('Low Confidence Results')).toBeInTheDocument(); + }); + + const retryButton = screen.getAllByText('Retry Now')[0]; + await user.click(retryButton); + + await waitFor(() => { + expect(screen.getByText('Retry failed')).toBeInTheDocument(); + }); + }); + + test('shows empty state when no recommendations are available', async () => { + mockGetRetryRecommendations.mockResolvedValue({ + data: { + recommendations: [], + total_recommendations: 0, + }, + }); + + render(); + + await waitFor(() => { + expect(screen.getByText('No retry recommendations available')).toBeInTheDocument(); + }); + + expect(screen.getByText('All documents have been processed successfully')).toBeInTheDocument(); + expect(screen.getByText('No failed documents found')).toBeInTheDocument(); + }); + + test('shows correct success rate labels', () => { + const { rerender } = render(
); + + // Test high success rate (>= 70%) + mockGetRetryRecommendations.mockResolvedValue({ + data: { + recommendations: [{ + ...sampleRecommendations[0], + estimated_success_rate: 0.85, + }], + total_recommendations: 1, + }, + }); + + rerender(); + + waitFor(() => { + expect(screen.getByText('85% (High)')).toBeInTheDocument(); + }); + + // Test medium success rate (40-69%) + mockGetRetryRecommendations.mockResolvedValue({ + data: { + recommendations: [{ + ...sampleRecommendations[0], + estimated_success_rate: 0.55, + }], + total_recommendations: 1, + }, + }); + + rerender(); + + waitFor(() => { + expect(screen.getByText('55% (Medium)')).toBeInTheDocument(); + }); + + // Test low success rate (< 40%) + mockGetRetryRecommendations.mockResolvedValue({ + data: { + recommendations: [{ + ...sampleRecommendations[0], + estimated_success_rate: 0.25, + }], + total_recommendations: 1, + }, + }); + + rerender(); + + waitFor(() => { + expect(screen.getByText('25% (Low)')).toBeInTheDocument(); + }); + }); + + test('refreshes recommendations after successful retry', async () => { + const user = userEvent.setup(); + render(); + + await waitFor(() => { + expect(screen.getByText('Low Confidence Results')).toBeInTheDocument(); + }); + + expect(mockGetRetryRecommendations).toHaveBeenCalledTimes(1); + + const retryButton = screen.getAllByText('Retry Now')[0]; + await user.click(retryButton); + + await waitFor(() => { + expect(mockBulkRetryOcr).toHaveBeenCalled(); + }); + + // Should reload recommendations after successful retry + expect(mockGetRetryRecommendations).toHaveBeenCalledTimes(2); + }); + + test('handles null/undefined recommendations safely', async () => { + mockGetRetryRecommendations.mockResolvedValue({ + data: { + recommendations: null, + total_recommendations: 0, + }, + }); + + render(); + + await waitFor(() => { + expect(screen.getByText('No retry recommendations available')).toBeInTheDocument(); + }); + + // Should not crash + expect(screen.getByText('OCR Retry Recommendations')).toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/frontend/src/pages/__tests__/DocumentDetailsPage.retry.test.tsx b/frontend/src/pages/__tests__/DocumentDetailsPage.retry.test.tsx new file mode 100644 index 0000000..53575f6 --- /dev/null +++ b/frontend/src/pages/__tests__/DocumentDetailsPage.retry.test.tsx @@ -0,0 +1,367 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter, Routes, Route } from 'react-router-dom'; +import DocumentDetailsPage from '../DocumentDetailsPage'; + +// Mock the entire API module +const mockBulkRetryOcr = vi.fn(); +const mockGetById = vi.fn(); +const mockGetOcrText = vi.fn(); +const mockGetThumbnail = vi.fn(); +const mockGetDocumentRetryHistory = vi.fn(); + +const mockDocumentService = { + getById: mockGetById, + getOcrText: mockGetOcrText, + getThumbnail: mockGetThumbnail, + bulkRetryOcr: mockBulkRetryOcr, + getDocumentRetryHistory: mockGetDocumentRetryHistory, + download: vi.fn(), + getProcessedImage: vi.fn(), +}; + +const mockApi = { + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), +}; + +vi.mock('../../services/api', () => ({ + documentService: mockDocumentService, + default: mockApi, +})); + +// Mock the RetryHistoryModal component +vi.mock('../../components/RetryHistoryModal', () => ({ + RetryHistoryModal: ({ open, onClose, documentId, documentName }: any) => ( + open ? ( +
+
Retry History for {documentName}
+
Document ID: {documentId}
+ +
+ ) : null + ), +})); + +// Mock other components +vi.mock('../../components/DocumentViewer', () => ({ + default: ({ documentId, filename }: any) => ( +
+ Viewing {filename} (ID: {documentId}) +
+ ), +})); + +vi.mock('../../components/Labels/LabelSelector', () => ({ + default: ({ selectedLabels, onLabelsChange }: any) => ( +
+
Selected: {selectedLabels.length} labels
+ +
+ ), +})); + +vi.mock('../../components/MetadataDisplay', () => ({ + default: ({ metadata, title }: any) => ( +
+

{title}

+
{JSON.stringify(metadata, null, 2)}
+
+ ), +})); + +describe('DocumentDetailsPage - Retry Functionality', () => { + const mockDocument = { + id: 'test-doc-1', + original_filename: 'test-document.pdf', + filename: 'test-document.pdf', + file_size: 1024000, + mime_type: 'application/pdf', + created_at: '2023-01-01T00:00:00Z', + has_ocr_text: true, + tags: ['important'], + }; + + const mockOcrData = { + document_id: 'test-doc-1', + filename: 'test-document.pdf', + has_ocr_text: true, + ocr_text: 'Sample OCR text content', + ocr_confidence: 95, + ocr_word_count: 100, + ocr_processing_time_ms: 5000, + ocr_status: 'completed', + ocr_completed_at: '2023-01-01T00:05:00Z', + }; + + beforeEach(() => { + vi.clearAllMocks(); + + mockGetById.mockResolvedValue({ + data: mockDocument, + }); + + mockGetOcrText.mockResolvedValue({ + data: mockOcrData, + }); + + mockGetThumbnail.mockRejectedValue(new Error('Thumbnail not available')); + + mockBulkRetryOcr.mockResolvedValue({ + data: { + success: true, + queued_count: 1, + matched_count: 1, + documents: [mockDocument], + estimated_total_time_minutes: 2.0, + message: 'OCR retry queued successfully', + }, + }); + + mockGetDocumentRetryHistory.mockResolvedValue({ + data: { + document_id: 'test-doc-1', + retry_history: [], + total_retries: 0, + }, + }); + + mockApi.get.mockResolvedValue({ data: [] }); + }); + + const renderDocumentDetailsPage = () => { + return render( + + + } /> + + + ); + }; + + test('renders retry OCR button', async () => { + renderDocumentDetailsPage(); + + await waitFor(() => { + expect(screen.getByText('Document Details')).toBeInTheDocument(); + }); + + expect(screen.getByText('Retry OCR')).toBeInTheDocument(); + }); + + test('can retry OCR for document', async () => { + const user = userEvent.setup(); + renderDocumentDetailsPage(); + + await waitFor(() => { + expect(screen.getByText('Document Details')).toBeInTheDocument(); + }); + + const retryButton = screen.getByText('Retry OCR'); + expect(retryButton).toBeInTheDocument(); + + // Clear previous calls to track only the retry call + mockBulkRetryOcr.mockClear(); + + await user.click(retryButton); + + await waitFor(() => { + expect(mockBulkRetryOcr).toHaveBeenCalledWith({ + mode: 'specific', + document_ids: ['test-doc-1'], + priority_override: 15, + }); + }); + }); + + test('shows loading state during retry', async () => { + const user = userEvent.setup(); + + // Make the retry take some time + mockBulkRetryOcr.mockImplementation(() => + new Promise(resolve => + setTimeout(() => resolve({ + data: { + success: true, + queued_count: 1, + matched_count: 1, + documents: [mockDocument], + estimated_total_time_minutes: 2.0, + message: 'OCR retry queued successfully', + }, + }), 100) + ) + ); + + renderDocumentDetailsPage(); + + await waitFor(() => { + expect(screen.getByText('Document Details')).toBeInTheDocument(); + }); + + const retryButton = screen.getByText('Retry OCR'); + await user.click(retryButton); + + // Should show loading state + expect(screen.getByText('Retrying...')).toBeInTheDocument(); + + // Wait for retry to complete + await waitFor(() => { + expect(screen.getByText('Retry OCR')).toBeInTheDocument(); + }); + }); + + test('handles retry OCR error gracefully', async () => { + const user = userEvent.setup(); + + // Mock retry to fail + mockBulkRetryOcr.mockRejectedValue(new Error('Retry failed')); + + renderDocumentDetailsPage(); + + await waitFor(() => { + expect(screen.getByText('Document Details')).toBeInTheDocument(); + }); + + const retryButton = screen.getByText('Retry OCR'); + await user.click(retryButton); + + // Should still show the retry button (not stuck in loading state) + await waitFor(() => { + expect(screen.getByText('Retry OCR')).toBeInTheDocument(); + }); + + expect(mockBulkRetryOcr).toHaveBeenCalled(); + }); + + test('renders retry history button', async () => { + renderDocumentDetailsPage(); + + await waitFor(() => { + expect(screen.getByText('Document Details')).toBeInTheDocument(); + }); + + expect(screen.getByText('Retry History')).toBeInTheDocument(); + }); + + test('can open retry history modal', async () => { + const user = userEvent.setup(); + renderDocumentDetailsPage(); + + await waitFor(() => { + expect(screen.getByText('Document Details')).toBeInTheDocument(); + }); + + const historyButton = screen.getByText('Retry History'); + await user.click(historyButton); + + // Should open the retry history modal + expect(screen.getByTestId('retry-history-modal')).toBeInTheDocument(); + expect(screen.getByText('Retry History for test-document.pdf')).toBeInTheDocument(); + expect(screen.getByText('Document ID: test-doc-1')).toBeInTheDocument(); + }); + + test('can close retry history modal', async () => { + const user = userEvent.setup(); + renderDocumentDetailsPage(); + + await waitFor(() => { + expect(screen.getByText('Document Details')).toBeInTheDocument(); + }); + + // Open modal + const historyButton = screen.getByText('Retry History'); + await user.click(historyButton); + + expect(screen.getByTestId('retry-history-modal')).toBeInTheDocument(); + + // Close modal + const closeButton = screen.getByText('Close'); + await user.click(closeButton); + + expect(screen.queryByTestId('retry-history-modal')).not.toBeInTheDocument(); + }); + + test('refreshes document details after successful retry', async () => { + const user = userEvent.setup(); + + // Mock successful retry + mockBulkRetryOcr.mockResolvedValue({ + data: { + success: true, + queued_count: 1, + matched_count: 1, + documents: [mockDocument], + estimated_total_time_minutes: 2.0, + message: 'OCR retry queued successfully', + }, + }); + + renderDocumentDetailsPage(); + + await waitFor(() => { + expect(screen.getByText('Document Details')).toBeInTheDocument(); + }); + + // Clear previous calls + mockGetById.mockClear(); + + const retryButton = screen.getByText('Retry OCR'); + await user.click(retryButton); + + // Should call getById again to refresh document details after delay + await waitFor(() => { + expect(mockGetById).toHaveBeenCalledWith('test-doc-1'); + }, { timeout: 2000 }); + }); + + test('retry functionality works with documents without OCR text', async () => { + const user = userEvent.setup(); + + // Mock document without OCR text + mockGetById.mockResolvedValue({ + data: { + ...mockDocument, + has_ocr_text: false, + }, + }); + + renderDocumentDetailsPage(); + + await waitFor(() => { + expect(screen.getByText('Document Details')).toBeInTheDocument(); + }); + + // Retry button should still be available + const retryButton = screen.getByText('Retry OCR'); + expect(retryButton).toBeInTheDocument(); + + await user.click(retryButton); + + await waitFor(() => { + expect(mockBulkRetryOcr).toHaveBeenCalledWith({ + mode: 'specific', + document_ids: ['test-doc-1'], + priority_override: 15, + }); + }); + }); + + test('retry history modal receives correct props', async () => { + const user = userEvent.setup(); + renderDocumentDetailsPage(); + + await waitFor(() => { + expect(screen.getByText('Document Details')).toBeInTheDocument(); + }); + + const historyButton = screen.getByText('Retry History'); + await user.click(historyButton); + + // Verify modal props are passed correctly + expect(screen.getByText('Document ID: test-doc-1')).toBeInTheDocument(); + expect(screen.getByText('Retry History for test-document.pdf')).toBeInTheDocument(); + }); +}); \ No newline at end of file