Files
flyer-crawler.projectium.com/src/features/flyer/FlyerUploader.test.tsx
Torben Sorensen 672e4ca597
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 11m56s
flyer upload (anon) issues
2025-12-30 21:53:36 -08:00

522 lines
23 KiB
TypeScript

// 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<typeof import('../../services/aiApiClient')>();
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<typeof import('react-router-dom')>('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(
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<FlyerUploader onProcessingComplete={onProcessingComplete} />
</MemoryRouter>
</QueryClientProvider>,
);
};
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: <html>502 Bad Gateway</html>'),
);
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();
});
});
});