moar unit test !
Some checks failed
Deploy to Web Server flyer-crawler.projectium.com / deploy (push) Failing after 6m6s
Some checks failed
Deploy to Web Server flyer-crawler.projectium.com / deploy (push) Failing after 6m6s
This commit is contained in:
@@ -133,28 +133,58 @@ describe('AnalysisPanel', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// it('should display a specific error for geolocation permission denial', async () => {
|
||||
// // Provide explicit types for the success and error callbacks to satisfy TypeScript
|
||||
// (navigator.geolocation.getCurrentPosition as Mock).mockImplementation(
|
||||
// (
|
||||
// success: (position: GeolocationPosition) => void,
|
||||
// error: (error: GeolocationPositionError) => void
|
||||
// ) => {
|
||||
// error({ code: 1, message: 'User denied Geolocation', PERMISSION_DENIED: 1, POSITION_UNAVAILABLE: 2, TIMEOUT: 3 });
|
||||
// }
|
||||
// );
|
||||
// render(<AnalysisPanel flyerItems={mockFlyerItems} store={mockStore} />);
|
||||
// fireEvent.click(screen.getByRole('tab', { name: /plan trip/i }));
|
||||
// fireEvent.click(screen.getByRole('button', { name: /generate plan trip/i }));
|
||||
it('should display a specific error for geolocation permission denial', async () => {
|
||||
// Mock getCurrentPosition to reject the promise, which is how the component's logic handles errors.
|
||||
(navigator.geolocation.getCurrentPosition as Mocked<any>).mockImplementation(
|
||||
(
|
||||
success: (position: GeolocationPosition) => void,
|
||||
error: (error: GeolocationPositionError) => void
|
||||
) => {
|
||||
// The component wraps this in a Promise, so we call the error callback which causes the promise to reject.
|
||||
const geolocationError = new GeolocationPositionError();
|
||||
Object.assign(geolocationError, { code: 1, message: 'User denied Geolocation' });
|
||||
error(geolocationError);
|
||||
}
|
||||
);
|
||||
render(<AnalysisPanel flyerItems={mockFlyerItems} store={mockStore} />);
|
||||
fireEvent.click(screen.getByRole('tab', { name: /plan trip/i }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /generate plan trip/i }));
|
||||
|
||||
// // When geolocation fails, the component logs an error and sets the result to an empty string.
|
||||
// // It does not display a specific error message in the UI in this case.
|
||||
// // The test should verify that no result is displayed and no API call is made.
|
||||
// await waitFor(() => {
|
||||
// expect(mockedAiApiClient.planTripWithMaps).not.toHaveBeenCalled();
|
||||
// expect(screen.queryByText(/Please allow location access/i)).not.toBeInTheDocument();
|
||||
// });
|
||||
// });
|
||||
// The component should catch the GeolocationPositionError and display a user-friendly message.
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Please allow location access to use this feature.')).toBeInTheDocument();
|
||||
expect(mockedAiApiClient.planTripWithMaps).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should call planTripWithMaps and display the result with sources', async () => {
|
||||
const mockTripData = {
|
||||
text: 'Here is your optimized shopping trip.',
|
||||
sources: [{ uri: 'https://maps.google.com/123', title: 'View on Google Maps' }],
|
||||
};
|
||||
mockedAiApiClient.planTripWithMaps.mockResolvedValue(new Response(JSON.stringify(mockTripData)));
|
||||
|
||||
render(<AnalysisPanel flyerItems={mockFlyerItems} store={mockStore} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: /plan trip/i }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /generate plan trip/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
// Verify the API was called with the correct data
|
||||
expect(mockedAiApiClient.planTripWithMaps).toHaveBeenCalledWith(
|
||||
mockFlyerItems,
|
||||
mockStore,
|
||||
// This matches the coordinates from the mocked geolocation in beforeEach
|
||||
expect.objectContaining({ latitude: 51.1, longitude: 45.3 })
|
||||
);
|
||||
|
||||
// Verify the results are displayed
|
||||
expect(screen.getByText('Here is your optimized shopping trip.')).toBeInTheDocument();
|
||||
const sourceLink = screen.getByRole('link', { name: 'View on Google Maps' });
|
||||
expect(sourceLink).toBeInTheDocument();
|
||||
expect(sourceLink).toHaveAttribute('href', 'https://maps.google.com/123');
|
||||
});
|
||||
});
|
||||
|
||||
it('should show and call generateImageFromText for Deep Dive results', async () => {
|
||||
mockedAiApiClient.getDeepDiveAnalysis.mockResolvedValue(new Response(JSON.stringify('This is a meal plan.')));
|
||||
@@ -179,4 +209,25 @@ describe('AnalysisPanel', () => {
|
||||
expect(image).toHaveAttribute('src', 'data:image/png;base64,base64-image-string');
|
||||
});
|
||||
});
|
||||
|
||||
it('should display an error if image generation fails', async () => {
|
||||
mockedAiApiClient.getDeepDiveAnalysis.mockResolvedValue(new Response(JSON.stringify('This is a meal plan.')));
|
||||
mockedAiApiClient.generateImageFromText.mockRejectedValue(new Error('AI model for images is offline'));
|
||||
render(<AnalysisPanel flyerItems={mockFlyerItems} store={mockStore} />);
|
||||
|
||||
// First, get the deep dive analysis to show the button
|
||||
fireEvent.click(screen.getByRole('tab', { name: /deep dive/i }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /generate deep dive/i }));
|
||||
|
||||
// Wait for the button to appear
|
||||
const generateImageButton = await screen.findByRole('button', { name: /generate an image for this meal plan/i });
|
||||
|
||||
// Click the button to trigger the failing API call
|
||||
fireEvent.click(generateImageButton);
|
||||
|
||||
// Assert that the error message is displayed
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Failed to generate image: AI model for images is offline')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,13 +2,13 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { AnalysisType, FlyerItem, Store } from '../../types';
|
||||
import type { GroundingChunk } from '@google/genai';
|
||||
import { getQuickInsights, getDeepDiveAnalysis, searchWeb, generateImageFromText } from '../../services/aiApiClient';
|
||||
import { getQuickInsights, getDeepDiveAnalysis, searchWeb, generateImageFromText, planTripWithMaps } from '../../services/aiApiClient';
|
||||
import { LoadingSpinner } from '../../components/LoadingSpinner';
|
||||
import { LightbulbIcon } from '../../components/icons/LightbulbIcon';
|
||||
import { BrainIcon } from '../../components/icons/BrainIcon';
|
||||
import { SearchIcon } from '../../components/icons/SearchIcon';
|
||||
import { MapPinIcon } from '../../components/icons/MapPinIcon';
|
||||
import { PhotoIcon } from '../../components/icons/PhotoIcon';
|
||||
import { PhotoIcon as ImageIcon } from '../../components/icons/PhotoIcon';
|
||||
import { logger } from '../../services/logger';
|
||||
|
||||
interface AnalysisPanelProps {
|
||||
@@ -78,16 +78,16 @@ export const AnalysisPanel: React.FC<AnalysisPanelProps> = ({ flyerItems, store
|
||||
}));
|
||||
responseText = text;
|
||||
newSources = mappedSources;
|
||||
// } else if (type === AnalysisType.PLAN_TRIP) {
|
||||
// const userLocation = await new Promise<GeolocationCoordinates>((resolve, reject) => {
|
||||
// navigator.geolocation.getCurrentPosition(
|
||||
// (position) => resolve(position.coords),
|
||||
// (err: GeolocationPositionError) => reject(err) // Type the error for better handling
|
||||
// );
|
||||
// });
|
||||
// const { text, sources } = await (await planTripWithMaps(flyerItems, store, userLocation)).json();
|
||||
// responseText = text;
|
||||
// newSources = sources;
|
||||
} else if (type === AnalysisType.PLAN_TRIP) {
|
||||
const userLocation = await new Promise<GeolocationCoordinates>((resolve, reject) => {
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(position) => resolve(position.coords),
|
||||
(err: GeolocationPositionError) => reject(err) // Type the error for better handling
|
||||
);
|
||||
});
|
||||
const { text, sources } = await (await planTripWithMaps(flyerItems, store, userLocation)).json();
|
||||
responseText = text;
|
||||
newSources = sources;
|
||||
}
|
||||
setResults(prev => ({ ...prev, [type]: responseText }));
|
||||
setSources(newSources); // Update sources once after all logic
|
||||
@@ -164,7 +164,7 @@ export const AnalysisPanel: React.FC<AnalysisPanelProps> = ({ flyerItems, store
|
||||
disabled={isGeneratingImage}
|
||||
className="inline-flex items-center justify-center bg-indigo-500 hover:bg-indigo-600 disabled:bg-indigo-300 text-white font-bold py-2 px-4 rounded-lg"
|
||||
>
|
||||
{isGeneratingImage ? <><LoadingSpinner /> <span className="ml-2">Generating...</span></> : <><PhotoIcon className="w-4 h-4 mr-2"/> Generate an image for this meal plan</>}
|
||||
{isGeneratingImage ? <><LoadingSpinner /> <span className="ml-2">Generating...</span></> : <><ImageIcon className="w-4 h-4 mr-2"/> Generate an image for this meal plan</>}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -134,14 +134,18 @@ describe('ExtractedDataTable', () => {
|
||||
|
||||
describe('Sorting and Filtering', () => {
|
||||
it('should sort watched items to the top', () => {
|
||||
// Watch 'Chicken Breast' (last item) and 'Apples' (first item)
|
||||
// Watch 'Chicken Breast' (normally 3rd) and 'Apples' (normally 1st)
|
||||
render(<ExtractedDataTable {...defaultProps} watchedItems={[mockMasterItems[2], mockMasterItems[0]]} />);
|
||||
const rows = screen.getAllByRole('row'); // includes header if it exists, but tbody rows here
|
||||
// Expected order: Gala Apples, Boneless Chicken, 2% Milk, Mystery Soda
|
||||
expect(rows[0]).toHaveTextContent('Gala Apples'); // Watched
|
||||
expect(rows[1]).toHaveTextContent('Boneless Chicken'); // Watched
|
||||
expect(rows[2]).toHaveTextContent('2% Milk'); // Not watched
|
||||
expect(rows[3]).toHaveTextContent('Mystery Soda'); // Not watched
|
||||
|
||||
// Get all rows from the table body
|
||||
const rows = screen.getAllByRole('row');
|
||||
// Extract the primary item name from each row to check the sort order
|
||||
const itemNamesInOrder = rows.map(row => row.querySelector('div.font-semibold, div.font-bold')?.textContent);
|
||||
|
||||
// Assert the order is correct: watched items first, then others.
|
||||
// Note: The component doesn't specify a sub-sort, so the order among watched items is based on their original order.
|
||||
// 'Gala Apples' comes before 'Boneless Chicken' in the original `mockFlyerItems` array.
|
||||
expect(itemNamesInOrder).toEqual(['Gala Apples', 'Boneless Chicken', '2% Milk', 'Mystery Soda']);
|
||||
});
|
||||
|
||||
it('should filter items by category', () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// src/features/flyer/FlyerDisplay.test.tsx
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { FlyerDisplay } from './FlyerDisplay';
|
||||
import type { Store } from '../../types';
|
||||
|
||||
@@ -12,15 +12,22 @@ const mockStore: Store = {
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const mockOnOpenCorrectionTool = vi.fn();
|
||||
|
||||
const defaultProps = {
|
||||
imageUrl: 'http://example.com/flyer.jpg',
|
||||
store: mockStore,
|
||||
validFrom: '2023-10-26',
|
||||
validTo: '2023-11-01',
|
||||
storeAddress: '123 Main St, Anytown',
|
||||
onOpenCorrectionTool: mockOnOpenCorrectionTool,
|
||||
};
|
||||
|
||||
describe('FlyerDisplay', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render all elements when all props are provided', () => {
|
||||
render(<FlyerDisplay {...defaultProps} />);
|
||||
|
||||
@@ -42,7 +49,7 @@ describe('FlyerDisplay', () => {
|
||||
});
|
||||
|
||||
it('should not render the header if store and date info are missing', () => {
|
||||
render(<FlyerDisplay imageUrl={defaultProps.imageUrl} />);
|
||||
render(<FlyerDisplay imageUrl={defaultProps.imageUrl} onOpenCorrectionTool={mockOnOpenCorrectionTool} />);
|
||||
expect(screen.queryByRole('heading')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -73,4 +80,22 @@ describe('FlyerDisplay', () => {
|
||||
expect(image).toHaveClass('dark:invert');
|
||||
expect(image).toHaveClass('dark:hue-rotate-180');
|
||||
});
|
||||
|
||||
describe('"Correct Data" Button', () => {
|
||||
it('should be visible when the header is rendered', () => {
|
||||
render(<FlyerDisplay {...defaultProps} />);
|
||||
expect(screen.getByRole('button', { name: /correct data/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not be visible when the header is not rendered', () => {
|
||||
render(<FlyerDisplay imageUrl={defaultProps.imageUrl} onOpenCorrectionTool={mockOnOpenCorrectionTool} />);
|
||||
expect(screen.queryByRole('button', { name: /correct data/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call onOpenCorrectionTool when clicked', () => {
|
||||
render(<FlyerDisplay {...defaultProps} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: /correct data/i }));
|
||||
expect(mockOnOpenCorrectionTool).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,15 @@
|
||||
// src/features/flyer/FlyerList.test.tsx
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach, type Mocked } from 'vitest';
|
||||
import { FlyerList } from './FlyerList';
|
||||
import type { Flyer, UserProfile } from '../../types';
|
||||
import { createMockUserProfile } from '../../tests/utils/mockFactories';
|
||||
import * as apiClient from '../../services/apiClient';
|
||||
|
||||
// Mock the apiClient and toast notifications
|
||||
vi.mock('../../services/apiClient');
|
||||
vi.mock('react-hot-toast', () => ({ default: { success: vi.fn(), error: vi.fn() } }));
|
||||
|
||||
const mockFlyers: Flyer[] = [
|
||||
{
|
||||
@@ -37,10 +42,16 @@ const mockFlyers: Flyer[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const mockedApiClient = apiClient as Mocked<typeof apiClient>;
|
||||
|
||||
describe('FlyerList', () => {
|
||||
const mockOnFlyerSelect = vi.fn();
|
||||
const mockProfile: UserProfile = createMockUserProfile({ user_id: '1', role: 'user' });
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render the heading', () => {
|
||||
render(<FlyerList flyers={[]} onFlyerSelect={mockOnFlyerSelect} selectedFlyerId={null} profile={null} />);
|
||||
expect(screen.getByRole('heading', { name: /processed flyers/i })).toBeInTheDocument();
|
||||
@@ -80,4 +91,46 @@ describe('FlyerList', () => {
|
||||
const selectedItem = screen.getByText('Metro').closest('li');
|
||||
expect(selectedItem).toHaveClass('bg-brand-light', 'dark:bg-brand-dark/30');
|
||||
});
|
||||
|
||||
describe('Admin Functionality', () => {
|
||||
const adminProfile: UserProfile = createMockUserProfile({ user_id: 'admin-1', role: 'admin' });
|
||||
|
||||
it('should not show the cleanup button for non-admin users', () => {
|
||||
render(<FlyerList flyers={mockFlyers} onFlyerSelect={mockOnFlyerSelect} selectedFlyerId={null} profile={mockProfile} />);
|
||||
expect(screen.queryByTitle(/clean up files/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show the cleanup button for admin users', () => {
|
||||
render(<FlyerList flyers={mockFlyers} onFlyerSelect={mockOnFlyerSelect} selectedFlyerId={null} profile={adminProfile} />);
|
||||
expect(screen.getByTitle('Clean up files for flyer ID 1')).toBeInTheDocument();
|
||||
expect(screen.getByTitle('Clean up files for flyer ID 2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call cleanupFlyerFiles when admin clicks and confirms', async () => {
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
mockedApiClient.cleanupFlyerFiles.mockResolvedValue(new Response(null, { status: 200 }));
|
||||
|
||||
render(<FlyerList flyers={mockFlyers} onFlyerSelect={mockOnFlyerSelect} selectedFlyerId={null} profile={adminProfile} />);
|
||||
|
||||
const cleanupButton = screen.getByTitle('Clean up files for flyer ID 1');
|
||||
fireEvent.click(cleanupButton);
|
||||
|
||||
expect(confirmSpy).toHaveBeenCalledWith('Are you sure you want to clean up the files for flyer ID 1? This action cannot be undone.');
|
||||
await waitFor(() => {
|
||||
expect(mockedApiClient.cleanupFlyerFiles).toHaveBeenCalledWith(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('should not call cleanupFlyerFiles when admin clicks and cancels', () => {
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false);
|
||||
|
||||
render(<FlyerList flyers={mockFlyers} onFlyerSelect={mockOnFlyerSelect} selectedFlyerId={null} profile={adminProfile} />);
|
||||
|
||||
const cleanupButton = screen.getByTitle('Clean up files for flyer ID 1');
|
||||
fireEvent.click(cleanupButton);
|
||||
|
||||
expect(confirmSpy).toHaveBeenCalled();
|
||||
expect(mockedApiClient.cleanupFlyerFiles).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
193
src/features/flyer/FlyerUploader.test.tsx
Normal file
193
src/features/flyer/FlyerUploader.test.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
// 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, type Mocked, type Mock } from 'vitest';
|
||||
import { FlyerUploader } from './FlyerUploader';
|
||||
import * as aiApiClientModule from '../../services/aiApiClient';
|
||||
import * as checksumModule from '../../utils/checksum';
|
||||
import * as routerDomModule from 'react-router-dom';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('../../services/aiApiClient');
|
||||
vi.mock('../../services/logger.client', () => ({
|
||||
logger: {
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock('../../utils/checksum', () => ({
|
||||
generateFileChecksum: vi.fn(),
|
||||
}));
|
||||
// Mock react-router-dom to spy on the navigate function
|
||||
vi.mock('react-router-dom', async () => {
|
||||
const actual = await vi.importActual<typeof import('react-router-dom')>('react-router-dom');
|
||||
return {
|
||||
...actual,
|
||||
useNavigate: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
// Get the mocked versions of the modules/functions
|
||||
const mockedAiApiClient = aiApiClientModule as Mocked<typeof aiApiClientModule>;
|
||||
const mockedChecksumModule = checksumModule as Mocked<typeof checksumModule>;
|
||||
const mockedRouterDom = routerDomModule as Mocked<typeof routerDomModule>;
|
||||
const navigateSpy = vi.fn();
|
||||
|
||||
const renderComponent = (onProcessingComplete = vi.fn()) => {
|
||||
return render(
|
||||
// We still use MemoryRouter to provide routing context for components like <Link>
|
||||
<routerDomModule.MemoryRouter>
|
||||
<FlyerUploader onProcessingComplete={onProcessingComplete} />
|
||||
</routerDomModule.MemoryRouter>
|
||||
);
|
||||
};
|
||||
|
||||
describe('FlyerUploader', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
// Access the mock implementation directly from the mocked module.
|
||||
// This is the most robust way and avoids TypeScript confusion.
|
||||
mockedChecksumModule.generateFileChecksum.mockResolvedValue('mock-checksum');
|
||||
// Correctly type the mock for `useNavigate`.
|
||||
// Since we've mocked `react-router-dom`, `useNavigate` is a `vi.fn()`. We just need to
|
||||
// cast it to the imported `Mock` type so TypeScript knows it has methods like `mockReturnValue`.
|
||||
(mockedRouterDom.useNavigate as Mock).mockReturnValue(navigateSpy);
|
||||
});
|
||||
|
||||
it('should render the initial state correctly', () => {
|
||||
renderComponent();
|
||||
expect(screen.getByText('Upload New Flyer')).toBeInTheDocument();
|
||||
expect(screen.getByText('Click to select a file')).toBeInTheDocument();
|
||||
expect(screen.getByText('Select a flyer (PDF or image) to begin.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle file upload and start polling', async () => {
|
||||
mockedAiApiClient.uploadAndProcessFlyer.mockResolvedValue(
|
||||
new Response(JSON.stringify({ jobId: 'job-123' }), { status: 200 })
|
||||
);
|
||||
renderComponent();
|
||||
const file = new File(['content'], 'flyer.pdf', { type: 'application/pdf' });
|
||||
const input = screen.getByLabelText(/click to select a file/i);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.change(input, { target: { files: [file] } });
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedChecksumModule.generateFileChecksum).toHaveBeenCalledWith(file);
|
||||
expect(mockedAiApiClient.uploadAndProcessFlyer).toHaveBeenCalledWith(file, 'mock-checksum');
|
||||
expect(screen.getByText('Processing...')).toBeInTheDocument();
|
||||
expect(screen.getByText('File accepted. Waiting for processing to start...')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should poll for status, complete successfully, and redirect', async () => {
|
||||
const onProcessingComplete = vi.fn();
|
||||
mockedAiApiClient.uploadAndProcessFlyer.mockResolvedValue(
|
||||
new Response(JSON.stringify({ jobId: 'job-123' }), { status: 200 })
|
||||
);
|
||||
mockedAiApiClient.getJobStatus
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ state: 'active', progress: { message: 'Analyzing...' } })))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ state: 'completed', returnValue: { flyerId: 42 } })));
|
||||
|
||||
renderComponent(onProcessingComplete);
|
||||
const file = new File(['content'], 'flyer.pdf', { type: 'application/pdf' });
|
||||
const input = screen.getByLabelText(/click to select a file/i);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.change(input, { target: { files: [file] } });
|
||||
});
|
||||
|
||||
await waitFor(() => expect(mockedAiApiClient.getJobStatus).toHaveBeenCalledWith('job-123'));
|
||||
expect(screen.getByText('Analyzing...')).toBeInTheDocument();
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(3000); // Advance past the polling interval
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Processing complete! Redirecting to flyer 42...')).toBeInTheDocument();
|
||||
expect(onProcessingComplete).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// Now, test the redirection by advancing the timer past the 1500ms timeout
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1500);
|
||||
});
|
||||
expect(navigateSpy).toHaveBeenCalledWith('/flyers/42');
|
||||
});
|
||||
|
||||
it('should handle a failed job', async () => {
|
||||
mockedAiApiClient.uploadAndProcessFlyer.mockResolvedValue(
|
||||
new Response(JSON.stringify({ jobId: 'job-123' }), { status: 200 })
|
||||
);
|
||||
mockedAiApiClient.getJobStatus.mockResolvedValue(
|
||||
new Response(JSON.stringify({ state: 'failed', failedReason: 'AI model exploded' }))
|
||||
);
|
||||
|
||||
renderComponent();
|
||||
const file = new File(['content'], 'flyer.pdf', { type: 'application/pdf' });
|
||||
const input = screen.getByLabelText(/click to select a file/i);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.change(input, { target: { files: [file] } });
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Processing failed: AI model exploded')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle a duplicate flyer error (409)', async () => {
|
||||
mockedAiApiClient.uploadAndProcessFlyer.mockResolvedValue(
|
||||
new Response(JSON.stringify({ flyerId: 99, message: 'Duplicate' }), { status: 409 })
|
||||
);
|
||||
|
||||
renderComponent();
|
||||
const file = new File(['content'], 'flyer.pdf', { type: 'application/pdf' });
|
||||
const input = screen.getByLabelText(/click to select a file/i);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.change(input, { target: { files: [file] } });
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('This flyer has already been processed. You can view it here:')).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: 'Flyer #99' })).toHaveAttribute('href', '/flyers/99');
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow the user to stop watching progress', async () => {
|
||||
mockedAiApiClient.uploadAndProcessFlyer.mockResolvedValue(
|
||||
new Response(JSON.stringify({ jobId: 'job-123' }), { status: 200 })
|
||||
);
|
||||
// Mock getJobStatus to keep the component in a polling state
|
||||
mockedAiApiClient.getJobStatus.mockResolvedValue(
|
||||
new Response(JSON.stringify({ state: 'active', progress: { message: 'Analyzing...' } }))
|
||||
);
|
||||
|
||||
renderComponent();
|
||||
const file = new File(['content'], 'flyer.pdf', { type: 'application/pdf' });
|
||||
const input = screen.getByLabelText(/click to select a file/i);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.change(input, { target: { files: [file] } });
|
||||
});
|
||||
|
||||
// Wait for the component to enter the polling state and for the button to appear
|
||||
const stopButton = await screen.findByRole('button', { name: 'Stop Watching Progress' });
|
||||
expect(stopButton).toBeInTheDocument();
|
||||
expect(mockedAiApiClient.getJobStatus).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Click the button to cancel polling
|
||||
fireEvent.click(stopButton);
|
||||
|
||||
// Assert that the UI has returned to its initial idle state
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Click to select a file')).toBeInTheDocument();
|
||||
expect(screen.getByText('Select a flyer (PDF or image) to begin.')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Stop Watching Progress' })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -55,7 +55,11 @@ router.post(
|
||||
|
||||
// Manually invoke the multer middleware.
|
||||
upload(req, res, async (err: unknown) => {
|
||||
if (err) return next(err);
|
||||
if (err instanceof multer.MulterError) {
|
||||
return res.status(400).json({ message: err.message });
|
||||
} else if (err) {
|
||||
return res.status(400).json({ message: (err as Error).message });
|
||||
}
|
||||
if (!req.file) return res.status(400).json({ message: 'No avatar file uploaded.' });
|
||||
|
||||
const user = req.user as User;
|
||||
|
||||
Reference in New Issue
Block a user