moar unit test !
Some checks failed
Deploy to Web Server flyer-crawler.projectium.com / deploy (push) Failing after 6m6s

This commit is contained in:
2025-12-07 00:30:40 -08:00
parent 0d04d74228
commit 08e73c4ca1
7 changed files with 377 additions and 47 deletions

View File

@@ -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();
});
});
});

View File

@@ -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>

View File

@@ -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', () => {

View File

@@ -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);
});
});
});

View File

@@ -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();
});
});
});

View 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();
});
});
});

View File

@@ -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;