// src/features/flyer/FlyerUploader.test.tsx import React from 'react'; import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest'; import { FlyerUploader } from './FlyerUploader'; import * as aiApiClientModule from '../../services/aiApiClient'; import * as checksumModule from '../../utils/checksum'; import { useNavigate, MemoryRouter } from 'react-router-dom'; import { QueryClient, QueryClientProvider, onlineManager } from '@tanstack/react-query'; // Mock dependencies vi.mock('../../services/aiApiClient', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, uploadAndProcessFlyer: vi.fn(), getJobStatus: vi.fn(), }; }); vi.mock('../../services/logger.client', () => ({ // Keep the original logger.info/error but also spy on it for test assertions if needed logger: { info: vi.fn((...args) => console.log('[LOGGER.INFO]', ...args)), error: vi.fn((...args) => console.error('[LOGGER.ERROR]', ...args)), warn: vi.fn((...args) => console.warn('[LOGGER.WARN]', ...args)), debug: vi.fn((...args) => console.debug('[LOGGER.DEBUG]', ...args)), }, })); vi.mock('../../utils/checksum', () => ({ generateFileChecksum: vi.fn(), })); // Mock react-router-dom vi.mock('react-router-dom', async () => { const actual = await vi.importActual('react-router-dom'); return { ...actual, useNavigate: vi.fn(), }; }); const mockedAiApiClient = aiApiClientModule as unknown as { uploadAndProcessFlyer: Mock; getJobStatus: Mock; }; const mockedChecksumModule = checksumModule as unknown as { generateFileChecksum: Mock; }; const renderComponent = (onProcessingComplete = vi.fn()) => { console.log('--- [TEST LOG] ---: Rendering component inside MemoryRouter.'); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, }, }, }); return render( , ); }; describe('FlyerUploader', () => { const navigateSpy = vi.fn(); beforeEach(() => { // Disable react-query's online manager to prevent it from interfering with fake timers onlineManager.setEventListener((setOnline) => { return () => {}; }); console.log(`\n--- [TEST LOG] ---: Starting test: "${expect.getState().currentTestName}"`); vi.resetAllMocks(); // Resets mock implementations AND call history. console.log('--- [TEST LOG] ---: Mocks reset.'); mockedChecksumModule.generateFileChecksum.mockResolvedValue('mock-checksum'); (useNavigate as Mock).mockReturnValue(navigateSpy); }); afterEach(() => { console.log(`--- [TEST LOG] ---: Finished test: "${expect.getState().currentTestName}"\n`); }); it('should render the initial state correctly', () => { renderComponent(); expect(screen.getByText('Upload New Flyer')).toBeInTheDocument(); expect(screen.getByText(/click to select a file/i)).toBeInTheDocument(); }); it('should handle file upload and start polling', async () => { console.log('--- [TEST LOG] ---: 1. Setting up mocks for upload and polling.'); mockedAiApiClient.uploadAndProcessFlyer.mockResolvedValue({ jobId: 'job-123' }); mockedAiApiClient.getJobStatus.mockResolvedValue({ state: 'active', progress: { message: 'Checking...' }, }); console.log('--- [TEST LOG] ---: 2. Rendering component and preparing file.'); renderComponent(); const file = new File(['content'], 'flyer.pdf', { type: 'application/pdf' }); const input = screen.getByLabelText(/click to select a file/i); console.log('--- [TEST LOG] ---: 3. Firing file change event.'); fireEvent.change(input, { target: { files: [file] } }); console.log('--- [TEST LOG] ---: 4. File change event fired. Now AWAITING UI update.'); try { console.log('--- [TEST LOG] ---: 5a. Awaiting screen.findByText("Checking...")'); await screen.findByText('Checking...'); console.log('--- [TEST LOG] ---: 5b. SUCCESS: UI updated to polling state.'); } catch (error) { console.error('--- [TEST LOG] ---: 5c. ERROR: findByText("Checking...") timed out.'); console.log('--- [DEBUG] ---: DOM at time of failure:'); screen.debug(); // Print the DOM when the error occurs throw error; // Re-throw the error to fail the test } console.log('--- [TEST LOG] ---: 6. Asserting mock calls after UI update.'); expect(mockedAiApiClient.uploadAndProcessFlyer).toHaveBeenCalledWith(file, 'mock-checksum'); expect(mockedAiApiClient.getJobStatus).toHaveBeenCalledTimes(1); console.log('--- [TEST LOG] ---: 7. Mocks verified. Advancing timers now...'); // With real timers, we now wait for the polling interval to elapse. console.log( `--- [TEST LOG] ---: 9. Act block finished. Now checking if getJobStatus was called again.`, ); try { // The polling interval is 3s, so we wait for a bit longer. await waitFor(() => { const calls = mockedAiApiClient.getJobStatus.mock.calls.length; console.log(`--- [TEST LOG] ---: 10. waitFor check: getJobStatus calls = ${calls}`); expect(mockedAiApiClient.getJobStatus).toHaveBeenCalledTimes(2); }, { timeout: 4000 }); console.log('--- [TEST LOG] ---: 11. SUCCESS: Second poll confirmed.'); } catch (error) { console.error('--- [TEST LOG] ---: 11. ERROR: waitFor for second poll timed out.'); console.log('--- [DEBUG] ---: DOM at time of failure:'); screen.debug(); throw error; } }); it('should handle file upload via drag and drop', async () => { console.log('--- [TEST LOG] ---: 1. Setting up mocks for drag and drop.'); mockedAiApiClient.uploadAndProcessFlyer.mockResolvedValue({ jobId: 'job-dnd' }); mockedAiApiClient.getJobStatus.mockResolvedValue({ state: 'active', progress: { message: 'Dropped...' }, }); console.log('--- [TEST LOG] ---: 2. Rendering component and preparing file for drop.'); renderComponent(); const file = new File(['dnd-content'], 'dnd-flyer.pdf', { type: 'application/pdf' }); // The dropzone is the label element const dropzone = screen.getByText(/click to select a file/i).closest('label')!; console.log('--- [TEST LOG] ---: 3. Firing drop event.'); // Simulate the drop event fireEvent.drop(dropzone, { dataTransfer: { files: [file] }, }); console.log('--- [TEST LOG] ---: 4. Awaiting UI update to "Dropped...".'); await screen.findByText('Dropped...'); console.log('--- [TEST LOG] ---: 5. Asserting upload was called.'); expect(mockedAiApiClient.uploadAndProcessFlyer).toHaveBeenCalledWith(file, 'mock-checksum'); }); it('should poll for status, complete successfully, and redirect', async () => { const onProcessingComplete = vi.fn(); console.log('--- [TEST LOG] ---: 1. Setting up mock sequence for polling.'); mockedAiApiClient.uploadAndProcessFlyer.mockResolvedValue({ jobId: 'job-123' }); mockedAiApiClient.getJobStatus .mockResolvedValueOnce({ state: 'active', progress: { message: 'Analyzing...' } }) .mockResolvedValueOnce({ state: 'completed', returnValue: { flyerId: 42 } }); console.log('--- [TEST LOG] ---: 2. Rendering component and uploading file.'); renderComponent(onProcessingComplete); const file = new File(['content'], 'flyer.pdf', { type: 'application/pdf' }); const input = screen.getByLabelText(/click to select a file/i); fireEvent.change(input, { target: { files: [file] } }); console.log('--- [TEST LOG] ---: 3. Fired event. Now AWAITING UI update to "Analyzing...".'); try { await screen.findByText('Analyzing...'); console.log('--- [TEST LOG] ---: 4. SUCCESS: UI is showing "Analyzing...".'); } catch (error) { console.error('--- [TEST LOG] ---: 4. ERROR: findByText("Analyzing...") timed out.'); console.log('--- [DEBUG] ---: DOM at time of failure:'); screen.debug(); throw error; } expect(mockedAiApiClient.getJobStatus).toHaveBeenCalledTimes(1); console.log('--- [TEST LOG] ---: 5. First poll confirmed. Now AWAITING timer advancement.'); try { console.log( '--- [TEST LOG] ---: 8a. waitFor check: Waiting for completion text and job status count.', ); // Wait for the second poll to occur and the UI to update. await waitFor(() => { console.log( `--- [TEST LOG] ---: 8b. waitFor interval: calls=${ mockedAiApiClient.getJobStatus.mock.calls.length }`, ); expect( screen.getByText('Processing complete! Redirecting to flyer 42...'), ).toBeInTheDocument(); }, { timeout: 4000 }); console.log('--- [TEST LOG] ---: 9. SUCCESS: Completion message found.'); } catch (error) { console.error('--- [TEST LOG] ---: 9. ERROR: waitFor for completion message timed out.'); console.log('--- [DEBUG] ---: DOM at time of failure:'); screen.debug(); throw error; } expect(mockedAiApiClient.getJobStatus).toHaveBeenCalledTimes(2); // Wait for the redirect timer (1.5s in component) to fire. await act(() => new Promise((r) => setTimeout(r, 2000))); console.log(`--- [TEST LOG] ---: 11. Timers advanced. Now asserting navigation.`); expect(onProcessingComplete).toHaveBeenCalled(); expect(navigateSpy).toHaveBeenCalledWith('/flyers/42'); console.log('--- [TEST LOG] ---: 12. Callback and navigation confirmed.'); }); it('should handle a failed job', async () => { console.log('--- [TEST LOG] ---: 1. Setting up mocks for a failed job.'); mockedAiApiClient.uploadAndProcessFlyer.mockResolvedValue({ jobId: 'job-fail' }); // The getJobStatus function throws a specific error when the job fails, // which is then caught by react-query and placed in the `error` state. const jobFailedError = new aiApiClientModule.JobFailedError('AI model exploded', 'UNKNOWN_ERROR'); mockedAiApiClient.getJobStatus.mockRejectedValue(jobFailedError); console.log('--- [TEST LOG] ---: 2. Rendering and uploading.'); renderComponent(); const file = new File(['content'], 'flyer.pdf', { type: 'application/pdf' }); const input = screen.getByLabelText(/click to select a file/i); console.log('--- [TEST LOG] ---: 3. Firing file change event.'); fireEvent.change(input, { target: { files: [file] } }); console.log('--- [TEST LOG] ---: 3. File upload triggered.'); try { console.log('--- [TEST LOG] ---: 4. AWAITING failure message...'); // The UI should now display the error from the `pollError` state, which includes the "Polling failed" prefix. expect(await screen.findByText(/Polling failed: AI model exploded/i)).toBeInTheDocument(); console.log('--- [TEST LOG] ---: 5. SUCCESS: Failure message found.'); } catch (error) { console.error('--- [TEST LOG] ---: 5. ERROR: findByText for failure message timed out.'); console.log('--- [DEBUG] ---: DOM at time of failure:'); screen.debug(); throw error; } expect(screen.getByText('Upload Another Flyer')).toBeInTheDocument(); console.log('--- [TEST LOG] ---: 6. "Upload Another" button confirmed.'); }); it('should clear the polling timeout when a job fails', async () => { console.log('--- [TEST LOG] ---: 1. Setting up mocks for failed job timeout clearance.'); mockedAiApiClient.uploadAndProcessFlyer.mockResolvedValue({ jobId: 'job-fail-timeout' }); // We need at least one 'active' response to establish a timeout loop so we have something to clear // The second call should be a rejection, as this is how getJobStatus signals a failure. mockedAiApiClient.getJobStatus .mockResolvedValueOnce({ state: 'active', progress: { message: 'Working...' }, } as aiApiClientModule.JobStatus) .mockRejectedValueOnce(new aiApiClientModule.JobFailedError('Fatal Error', 'UNKNOWN_ERROR')); renderComponent(); const file = new File(['content'], 'flyer.pdf', { type: 'application/pdf' }); const input = screen.getByLabelText(/click to select a file/i); fireEvent.change(input, { target: { files: [file] } }); // Wait for the first poll to complete and UI to update to "Working..." await screen.findByText('Working...'); // Wait for the failure UI await waitFor(() => expect(screen.getByText(/Polling failed: Fatal Error/i)).toBeInTheDocument(), { timeout: 4000 }); }); it('should stop polling for job status when the component unmounts', async () => { console.log('--- [TEST LOG] ---: 1. Setting up mocks for unmount polling stop.'); mockedAiApiClient.uploadAndProcessFlyer.mockResolvedValue({ jobId: 'job-unmount' }); // Mock getJobStatus to always return 'active' to keep polling mockedAiApiClient.getJobStatus.mockResolvedValue({ state: 'active', progress: { message: 'Polling...' }, }); const { unmount } = renderComponent(); const file = new File(['content'], 'flyer.pdf', { type: 'application/pdf' }); const input = screen.getByLabelText(/click to select a file/i); fireEvent.change(input, { target: { files: [file] } }); // Wait for the first poll to complete and UI to update await screen.findByText('Polling...'); // Wait for exactly one call to be sure polling has started. await waitFor(() => { expect(mockedAiApiClient.getJobStatus).toHaveBeenCalledTimes(1); }); console.log('--- [TEST LOG] ---: 2. First poll confirmed.'); // Record the number of calls before unmounting. const callsBeforeUnmount = mockedAiApiClient.getJobStatus.mock.calls.length; // Now unmount the component, which should stop the polling. console.log('--- [TEST LOG] ---: 3. Unmounting component.'); unmount(); // Wait for a duration longer than the polling interval (3s) to see if more calls are made. console.log('--- [TEST LOG] ---: 4. Waiting for 4 seconds to check for further polling.'); await act(() => new Promise((resolve) => setTimeout(resolve, 4000))); // Verify that getJobStatus was not called again after unmounting. console.log('--- [TEST LOG] ---: 5. Asserting no new polls occurred.'); expect(mockedAiApiClient.getJobStatus).toHaveBeenCalledTimes(callsBeforeUnmount); }); it('should handle a duplicate flyer error (409)', async () => { console.log('--- [TEST LOG] ---: 1. Setting up mock for 409 duplicate error.'); // The API client throws a structured error, which useFlyerUploader now parses // to set both the errorMessage and the duplicateFlyerId. mockedAiApiClient.uploadAndProcessFlyer.mockRejectedValue({ status: 409, body: { flyerId: 99, message: 'This flyer has already been processed.' }, }); console.log('--- [TEST LOG] ---: 2. Rendering and uploading.'); renderComponent(); const file = new File(['content'], 'flyer.pdf', { type: 'application/pdf' }); const input = screen.getByLabelText(/click to select a file/i); console.log('--- [TEST LOG] ---: 3. Firing file change event.'); fireEvent.change(input, { target: { files: [file] } }); console.log('--- [TEST LOG] ---: 3. File upload triggered.'); try { console.log('--- [TEST LOG] ---: 4. AWAITING duplicate flyer message...'); // With the fix, the duplicate error message and the link are combined into a single paragraph. // We now look for this combined message. const errorMessage = await screen.findByText(/This flyer has already been processed. You can view it here:/i); expect(errorMessage).toBeInTheDocument(); console.log('--- [TEST LOG] ---: 5. SUCCESS: Duplicate message found.'); } catch (error) { console.error('--- [TEST LOG] ---: 5. ERROR: findByText for duplicate message timed out.'); console.log('--- [DEBUG] ---: DOM at time of failure:'); screen.debug(); throw error; } const link = screen.getByRole('link', { name: /Flyer #99/i }); expect(link).toHaveAttribute('href', '/flyers/99'); console.log('--- [TEST LOG] ---: 6. Duplicate link confirmed.'); }); it('should allow the user to stop watching progress', async () => { console.log('--- [TEST LOG] ---: 1. Setting up mocks for infinite polling.'); mockedAiApiClient.uploadAndProcessFlyer.mockResolvedValue({ jobId: 'job-stop' }); mockedAiApiClient.getJobStatus.mockResolvedValue({ state: 'active', progress: { message: 'Analyzing...' }, } as any); console.log('--- [TEST LOG] ---: 2. Rendering and uploading.'); renderComponent(); const file = new File(['content'], 'flyer.pdf', { type: 'application/pdf' }); const input = screen.getByLabelText(/click to select a file/i); console.log('--- [TEST LOG] ---: 3. Firing file change event.'); fireEvent.change(input, { target: { files: [file] } }); console.log('--- [TEST LOG] ---: 3. File upload triggered.'); let stopButton; try { console.log('--- [TEST LOG] ---: 4. AWAITING polling UI...'); stopButton = await screen.findByRole('button', { name: 'Stop Watching Progress' }); console.log('--- [TEST LOG] ---: 5. SUCCESS: Polling UI is visible.'); } catch (error) { console.error('--- [TEST LOG] ---: 5. ERROR: findByRole for stop button timed out.'); console.log('--- [DEBUG] ---: DOM at time of failure:'); screen.debug(); throw error; } console.log('--- [TEST LOG] ---: 6. Clicking "Stop Watching Progress" button.'); fireEvent.click(stopButton); console.log('--- [TEST LOG] ---: 7. Click event fired.'); try { console.log('--- [TEST LOG] ---: 8. AWAITING UI reset to idle state...'); expect(await screen.findByText(/click to select a file/i)).toBeInTheDocument(); // Fix typo: queryText -> queryByText expect(screen.queryByText('Analyzing...')).not.toBeInTheDocument(); console.log('--- [TEST LOG] ---: 9. SUCCESS: UI has reset and message removed.'); } catch (error) { console.error('--- [TEST LOG] ---: 9. ERROR: findByText for idle state timed out.'); console.log('--- [DEBUG] ---: DOM at time of failure:'); screen.debug(); throw error; } }); describe('Error Handling and Edge Cases', () => { it('should handle checksum generation failure', async () => { console.log('--- [TEST LOG] ---: 1. Setting up mock for checksum failure.'); mockedChecksumModule.generateFileChecksum.mockRejectedValue( new Error('Checksum generation failed'), ); renderComponent(); const file = new File(['content'], 'flyer.pdf', { type: 'application/pdf' }); const input = screen.getByLabelText(/click to select a file/i); console.log('--- [TEST LOG] ---: 2. Firing file change event.'); fireEvent.change(input, { target: { files: [file] } }); console.log('--- [TEST LOG] ---: 3. Awaiting error message.'); expect(await screen.findByText(/Checksum generation failed/i)).toBeInTheDocument(); expect(mockedAiApiClient.uploadAndProcessFlyer).not.toHaveBeenCalled(); console.log('--- [TEST LOG] ---: 4. Assertions passed.'); }); it('should handle a generic network error during upload', async () => { console.log('--- [TEST LOG] ---: 1. Setting up mock for generic upload error.'); // Simulate a structured error from the API client mockedAiApiClient.uploadAndProcessFlyer.mockRejectedValue({ status: 500, body: { message: 'Network Error During Upload' }, }); renderComponent(); const file = new File(['content'], 'flyer.pdf', { type: 'application/pdf' }); const input = screen.getByLabelText(/click to select a file/i); console.log('--- [TEST LOG] ---: 2. Firing file change event.'); fireEvent.change(input, { target: { files: [file] } }); console.log('--- [TEST LOG] ---: 3. Awaiting error message.'); expect(await screen.findByText(/Network Error During Upload/i)).toBeInTheDocument(); console.log('--- [TEST LOG] ---: 4. Assertions passed.'); }); it('should handle a generic network error during polling', async () => { console.log('--- [TEST LOG] ---: 1. Setting up mock for polling error.'); mockedAiApiClient.uploadAndProcessFlyer.mockResolvedValue({ jobId: 'job-poll-fail' }); mockedAiApiClient.getJobStatus.mockRejectedValue(new Error('Polling Network Error')); renderComponent(); const file = new File(['content'], 'flyer.pdf', { type: 'application/pdf' }); const input = screen.getByLabelText(/click to select a file/i); console.log('--- [TEST LOG] ---: 2. Firing file change event.'); fireEvent.change(input, { target: { files: [file] } }); console.log('--- [TEST LOG] ---: 3. Awaiting error message.'); expect(await screen.findByText(/Polling failed: Polling Network Error/i)).toBeInTheDocument(); console.log('--- [TEST LOG] ---: 4. Assertions passed.'); }); it('should handle a completed job with a missing flyerId', async () => { console.log('--- [TEST LOG] ---: 1. Setting up mock for malformed completion payload.'); mockedAiApiClient.uploadAndProcessFlyer.mockResolvedValue({ jobId: 'job-no-flyerid' }); mockedAiApiClient.getJobStatus.mockResolvedValue( { state: 'completed', returnValue: {} }, // No flyerId ); renderComponent(); const file = new File(['content'], 'flyer.pdf', { type: 'application/pdf' }); const input = screen.getByLabelText(/click to select a file/i); console.log('--- [TEST LOG] ---: 2. Firing file change event.'); fireEvent.change(input, { target: { files: [file] } }); console.log('--- [TEST LOG] ---: 3. Awaiting error message.'); expect( await screen.findByText(/Job completed but did not return a flyer ID/i), ).toBeInTheDocument(); console.log('--- [TEST LOG] ---: 4. Assertions passed.'); }); it('should handle a non-JSON response during polling', async () => { console.log('--- [TEST LOG] ---: 1. Setting up mock for non-JSON response.'); // The actual function would throw, so we mock the rejection. // The new getJobStatus would throw an error like "Failed to parse JSON..." mockedAiApiClient.uploadAndProcessFlyer.mockResolvedValue({ jobId: 'job-bad-json' }); mockedAiApiClient.getJobStatus.mockRejectedValue( new Error('Failed to parse JSON response from server. Body: 502 Bad Gateway'), ); renderComponent(); const file = new File(['content'], 'flyer.pdf', { type: 'application/pdf' }); const input = screen.getByLabelText(/click to select a file/i); console.log('--- [TEST LOG] ---: 2. Firing file change event.'); fireEvent.change(input, { target: { files: [file] } }); console.log('--- [TEST LOG] ---: 3. Awaiting error message.'); expect( await screen.findByText(/Polling failed: Failed to parse JSON response from server/i), ).toBeInTheDocument(); console.log('--- [TEST LOG] ---: 4. Assertions passed.'); }); it('should do nothing if the file input is cancelled', () => { renderComponent(); const input = screen.getByLabelText(/click to select a file/i); fireEvent.change(input, { target: { files: [] } }); // Empty file list expect(mockedAiApiClient.uploadAndProcessFlyer).not.toHaveBeenCalled(); }); }); });