unit testing for interface
Some checks failed
Deploy to Web Server flyer-crawler.projectium.com / deploy (push) Failing after 56s
Some checks failed
Deploy to Web Server flyer-crawler.projectium.com / deploy (push) Failing after 56s
This commit is contained in:
@@ -120,7 +120,7 @@ app.use('/api/users', userRouter);
|
||||
// --- Error Handling and Server Startup ---
|
||||
|
||||
// Basic error handling middleware
|
||||
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
|
||||
app.use((err: Error, req: Request, res: Response, _next: NextFunction) => {
|
||||
// Check if the error is from the timeout middleware
|
||||
if (req.timedout) {
|
||||
// The timeout event is already logged by the requestLogger, but we can add more detail here if needed.
|
||||
@@ -128,9 +128,8 @@ app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
|
||||
return;
|
||||
}
|
||||
logger.error('Unhandled application error:', { error: err.stack, path: req.originalUrl });
|
||||
// The 'next' parameter is required for Express to identify this as an error-handling middleware.
|
||||
// We log it here to satisfy the 'no-unused-vars' lint rule, as it's not called in this terminal handler.
|
||||
// logger.info('Terminal error handler invoked. The "next" function is part of the required signature.', { next: String(next) });
|
||||
// The 4-argument signature is required for Express to identify this as an error-handling middleware.
|
||||
// We prefix `next` with an underscore to indicate it's intentionally unused, satisfying the linter.
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ message: 'Something broke!' });
|
||||
}
|
||||
|
||||
@@ -16,8 +16,8 @@
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
# Define file paths relative to the script's location
|
||||
$PSScriptRoot = Split-Path -Parent -Path $MyInvocation.MyCommand.Definition
|
||||
$ProjectRoot = Resolve-Path -Path (Join-Path $PSScriptRoot "..")
|
||||
$scriptDirectory = Split-Path -Parent -Path $MyInvocation.MyCommand.Definition
|
||||
$ProjectRoot = Resolve-Path -Path (Join-Path $scriptDirectory "..")
|
||||
$MasterFile = Join-Path $ProjectRoot "sql\master_schema_rollup.sql"
|
||||
|
||||
# The individual files to concatenate, IN ORDER.
|
||||
|
||||
129
src/components/AdminBrandManager.test.tsx
Normal file
129
src/components/AdminBrandManager.test.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
// src/components/AdminBrandManager.test.tsx
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
||||
import toast from 'react-hot-toast';
|
||||
import { AdminBrandManager } from './AdminBrandManager';
|
||||
import * as apiClient from '../services/apiClient';
|
||||
import type { Brand } from '../types';
|
||||
|
||||
// Mock the apiClient module
|
||||
vi.mock('../services/apiClient');
|
||||
|
||||
// Mock react-hot-toast
|
||||
vi.mock('react-hot-toast', () => ({
|
||||
default: {
|
||||
loading: vi.fn(),
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockBrands: Brand[] = [
|
||||
{ brand_id: 1, name: 'No Frills', store_name: 'No Frills', logo_url: null },
|
||||
{ brand_id: 2, name: 'Compliments', store_name: 'Sobeys', logo_url: 'http://example.com/compliments.png' },
|
||||
];
|
||||
|
||||
describe('AdminBrandManager', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render a loading state initially', () => {
|
||||
(apiClient.fetchAllBrands as Mock).mockReturnValue(new Promise(() => {})); // Never resolves
|
||||
render(<AdminBrandManager />);
|
||||
expect(screen.getByText('Loading brands...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render an error message if fetching brands fails', async () => {
|
||||
(apiClient.fetchAllBrands as Mock).mockRejectedValue(new Error('Network Error'));
|
||||
render(<AdminBrandManager />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Failed to load brands: Network Error')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should render the list of brands when data is fetched successfully', async () => {
|
||||
(apiClient.fetchAllBrands as Mock).mockResolvedValue(mockBrands);
|
||||
render(<AdminBrandManager />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { name: /brand management/i })).toBeInTheDocument();
|
||||
expect(screen.getByText('No Frills')).toBeInTheDocument();
|
||||
expect(screen.getByText('(Sobeys)')).toBeInTheDocument();
|
||||
expect(screen.getByAltText('Compliments logo')).toBeInTheDocument();
|
||||
expect(screen.getByText('No Logo')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle successful logo upload', async () => {
|
||||
(apiClient.fetchAllBrands as Mock).mockResolvedValue(mockBrands);
|
||||
(apiClient.uploadBrandLogo as Mock).mockResolvedValue({ logoUrl: 'http://example.com/new-logo.png' });
|
||||
(toast.loading as Mock).mockReturnValue('toast-1');
|
||||
|
||||
render(<AdminBrandManager />);
|
||||
await waitFor(() => expect(screen.getByText('No Frills')).toBeInTheDocument());
|
||||
|
||||
const file = new File(['logo'], 'logo.png', { type: 'image/png' });
|
||||
const input = screen.getAllByRole('textbox', { hidden: true })[0]; // Find the first file input
|
||||
|
||||
fireEvent.change(input, { target: { files: [file] } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(apiClient.uploadBrandLogo).toHaveBeenCalledWith(1, file);
|
||||
expect(toast.loading).toHaveBeenCalledWith('Uploading logo...');
|
||||
expect(toast.success).toHaveBeenCalledWith('Logo updated successfully!', { id: 'toast-1' });
|
||||
// Check if the UI updates with the new logo
|
||||
expect(screen.getByAltText('No Frills logo')).toHaveAttribute('src', 'http://example.com/new-logo.png');
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle failed logo upload', async () => {
|
||||
(apiClient.fetchAllBrands as Mock).mockResolvedValue(mockBrands);
|
||||
(apiClient.uploadBrandLogo as Mock).mockRejectedValue(new Error('Upload failed'));
|
||||
(toast.loading as Mock).mockReturnValue('toast-2');
|
||||
|
||||
render(<AdminBrandManager />);
|
||||
await waitFor(() => expect(screen.getByText('No Frills')).toBeInTheDocument());
|
||||
|
||||
const file = new File(['logo'], 'logo.png', { type: 'image/png' });
|
||||
const input = screen.getAllByRole('textbox', { hidden: true })[0];
|
||||
|
||||
fireEvent.change(input, { target: { files: [file] } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith('Upload failed: Upload failed', { id: 'toast-2' });
|
||||
});
|
||||
});
|
||||
|
||||
it('should show an error toast for invalid file type', async () => {
|
||||
(apiClient.fetchAllBrands as Mock).mockResolvedValue(mockBrands);
|
||||
render(<AdminBrandManager />);
|
||||
await waitFor(() => expect(screen.getByText('No Frills')).toBeInTheDocument());
|
||||
|
||||
const file = new File(['text'], 'document.txt', { type: 'text/plain' });
|
||||
const input = screen.getAllByRole('textbox', { hidden: true })[0];
|
||||
|
||||
fireEvent.change(input, { target: { files: [file] } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith('Invalid file type. Please upload a PNG, JPG, WEBP, or SVG.');
|
||||
expect(apiClient.uploadBrandLogo).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should show an error toast for oversized file', async () => {
|
||||
(apiClient.fetchAllBrands as Mock).mockResolvedValue(mockBrands);
|
||||
render(<AdminBrandManager />);
|
||||
await waitFor(() => expect(screen.getByText('No Frills')).toBeInTheDocument());
|
||||
|
||||
const file = new File(['a'.repeat(3 * 1024 * 1024)], 'large.png', { type: 'image/png' });
|
||||
const input = screen.getAllByRole('textbox', { hidden: true })[0];
|
||||
|
||||
fireEvent.change(input, { target: { files: [file] } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith('File is too large. Maximum size is 2MB.');
|
||||
expect(apiClient.uploadBrandLogo).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
44
src/components/AdminPage.test.tsx
Normal file
44
src/components/AdminPage.test.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
// src/components/AdminPage.test.tsx
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { AdminPage } from './AdminPage';
|
||||
|
||||
// Mock child components to isolate the AdminPage component
|
||||
vi.mock('./SystemCheck', () => ({
|
||||
SystemCheck: () => <div data-testid="system-check">SystemCheck Component</div>,
|
||||
}));
|
||||
|
||||
vi.mock('./AdminBrandManager', () => ({
|
||||
AdminBrandManager: () => <div data-testid="brand-manager">AdminBrandManager Component</div>,
|
||||
}));
|
||||
|
||||
describe('AdminPage', () => {
|
||||
const renderWithRouter = () => {
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<AdminPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
};
|
||||
|
||||
it('should render the main heading and description', () => {
|
||||
renderWithRouter();
|
||||
expect(screen.getByRole('heading', { name: /admin dashboard/i })).toBeInTheDocument();
|
||||
expect(screen.getByText('Tools and system health checks.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render a link back to the main application', () => {
|
||||
renderWithRouter();
|
||||
const backLink = screen.getByRole('link', { name: /back to main app/i });
|
||||
expect(backLink).toBeInTheDocument();
|
||||
expect(backLink).toHaveAttribute('href', '/');
|
||||
});
|
||||
|
||||
it('should render the SystemCheck and AdminBrandManager components', () => {
|
||||
renderWithRouter();
|
||||
expect(screen.getByTestId('system-check')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('brand-manager')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
50
src/components/AdminRoute.test.tsx
Normal file
50
src/components/AdminRoute.test.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
// src/components/AdminRoute.test.tsx
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { MemoryRouter, Routes, Route } from 'react-router-dom';
|
||||
import { AdminRoute } from './AdminRoute';
|
||||
import type { Profile } from '../types';
|
||||
|
||||
const AdminContent = () => <div>Admin Page Content</div>;
|
||||
const HomePage = () => <div>Home Page</div>;
|
||||
|
||||
const renderWithRouter = (profile: Profile | null, initialPath: string) => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={[initialPath]}>
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/admin" element={<AdminRoute profile={profile} />}>
|
||||
<Route index element={<AdminContent />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
};
|
||||
|
||||
describe('AdminRoute', () => {
|
||||
it('should render the admin content when user has admin role', () => {
|
||||
const adminProfile: Profile = { user_id: '1', role: 'admin' };
|
||||
renderWithRouter(adminProfile, '/admin');
|
||||
|
||||
expect(screen.getByText('Admin Page Content')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Home Page')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should redirect to home page when user does not have admin role', () => {
|
||||
const userProfile: Profile = { user_id: '2', role: 'user' };
|
||||
renderWithRouter(userProfile, '/admin');
|
||||
|
||||
// The user is redirected, so we should see the home page content
|
||||
expect(screen.getByText('Home Page')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Admin Page Content')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should redirect to home page when profile is null', () => {
|
||||
renderWithRouter(null, '/admin');
|
||||
|
||||
// The user is redirected, so we should see the home page content
|
||||
expect(screen.getByText('Home Page')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Admin Page Content')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
148
src/components/AnalysisPanel.test.tsx
Normal file
148
src/components/AnalysisPanel.test.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
// src/components/AnalysisPanel.test.tsx
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
||||
import { AnalysisPanel } from './AnalysisPanel';
|
||||
import * as aiApiClient from '../services/aiApiClient';
|
||||
import type { FlyerItem, Store } from '../types';
|
||||
|
||||
// Mock the AI API client
|
||||
vi.mock('../services/aiApiClient');
|
||||
|
||||
// Mock the logger
|
||||
vi.mock('../services/logger', () => ({
|
||||
logger: { info: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
|
||||
const mockFlyerItems: FlyerItem[] = [
|
||||
{ flyer_item_id: 1, item: 'Apples', price_display: '$1.99', price_in_cents: 199, quantity: '1lb', flyer_id: 1, created_at: '', view_count: 0, click_count: 0, updated_at: '' },
|
||||
];
|
||||
const mockStore: Store = { store_id: 1, name: 'SuperMart', created_at: '' };
|
||||
|
||||
describe('AnalysisPanel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Mock Geolocation API
|
||||
Object.defineProperty(navigator, 'geolocation', {
|
||||
writable: true,
|
||||
value: {
|
||||
getCurrentPosition: vi.fn().mockImplementation((success) =>
|
||||
Promise.resolve(
|
||||
success({
|
||||
coords: {
|
||||
latitude: 51.1,
|
||||
longitude: 45.3,
|
||||
accuracy: 1,
|
||||
altitude: null,
|
||||
altitudeAccuracy: null,
|
||||
heading: null,
|
||||
speed: null,
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
)
|
||||
),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should render tabs and an initial "Generate" button', () => {
|
||||
render(<AnalysisPanel flyerItems={mockFlyerItems} store={mockStore} />);
|
||||
expect(screen.getByRole('button', { name: /quick insights/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /deep dive/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /web search/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /plan trip/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /generate quick insights/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should switch tabs and update the generate button text', () => {
|
||||
render(<AnalysisPanel flyerItems={mockFlyerItems} store={mockStore} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: /deep dive/i }));
|
||||
expect(screen.getByRole('button', { name: /generate deep dive/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call getQuickInsights and display the result', async () => {
|
||||
(aiApiClient.getQuickInsights as Mock).mockResolvedValue('These are quick insights.');
|
||||
render(<AnalysisPanel flyerItems={mockFlyerItems} store={mockStore} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /generate quick insights/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(aiApiClient.getQuickInsights).toHaveBeenCalledWith(mockFlyerItems);
|
||||
expect(screen.getByText('These are quick insights.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should call searchWeb and display results with sources', async () => {
|
||||
(aiApiClient.searchWeb as Mock).mockResolvedValue({
|
||||
text: 'Web search results.',
|
||||
sources: [{ web: { uri: 'http://example.com', title: 'Example Source' } }],
|
||||
});
|
||||
render(<AnalysisPanel flyerItems={mockFlyerItems} store={mockStore} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: /web search/i }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /generate web search/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(aiApiClient.searchWeb).toHaveBeenCalledWith(mockFlyerItems);
|
||||
expect(screen.getByText('Web search results.')).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: 'Example Source' })).toHaveAttribute('href', 'http://example.com');
|
||||
});
|
||||
});
|
||||
|
||||
it('should show a loading spinner during analysis', async () => {
|
||||
(aiApiClient.getQuickInsights as Mock).mockImplementation(() => new Promise(() => {})); // Never resolves
|
||||
render(<AnalysisPanel flyerItems={mockFlyerItems} store={mockStore} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: /generate quick insights/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('status')).toBeInTheDocument(); // LoadingSpinner
|
||||
});
|
||||
});
|
||||
|
||||
it('should display an error message if analysis fails', async () => {
|
||||
(aiApiClient.getQuickInsights as Mock).mockRejectedValue(new Error('AI API is down'));
|
||||
render(<AnalysisPanel flyerItems={mockFlyerItems} store={mockStore} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: /generate quick insights/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('AI API is down')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should display a specific error for geolocation permission denial', async () => {
|
||||
(navigator.geolocation.getCurrentPosition as Mock).mockImplementation((success, error) =>
|
||||
error({ code: 1, message: 'User denied Geolocation' })
|
||||
);
|
||||
render(<AnalysisPanel flyerItems={mockFlyerItems} store={mockStore} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: /plan trip/i }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /generate plan trip/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Please allow location access to use this feature.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should show and call generateImageFromText for Deep Dive results', async () => {
|
||||
(aiApiClient.getDeepDiveAnalysis as Mock).mockResolvedValue('This is a meal plan.');
|
||||
(aiApiClient.generateImageFromText as Mock).mockResolvedValue('base64-image-string');
|
||||
render(<AnalysisPanel flyerItems={mockFlyerItems} store={mockStore} />);
|
||||
|
||||
// First, get the deep dive analysis
|
||||
fireEvent.click(screen.getByRole('button', { name: /deep dive/i }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /generate deep dive/i }));
|
||||
|
||||
// Wait for the result and the "Generate Image" button to appear
|
||||
const generateImageButton = await screen.findByRole('button', { name: /generate an image for this meal plan/i });
|
||||
expect(generateImageButton).toBeInTheDocument();
|
||||
|
||||
// Click the button to generate the image
|
||||
fireEvent.click(generateImageButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(aiApiClient.generateImageFromText).toHaveBeenCalledWith('This is a meal plan.');
|
||||
const image = screen.getByAltText('AI generated meal plan');
|
||||
expect(image).toBeInTheDocument();
|
||||
expect(image).toHaveAttribute('src', 'data:image/png;base64,base64-image-string');
|
||||
});
|
||||
});
|
||||
});
|
||||
26
src/components/AnonymousUserBanner.test.tsx
Normal file
26
src/components/AnonymousUserBanner.test.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
// src/components/AnonymousUserBanner.test.tsx
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { AnonymousUserBanner } from './AnonymousUserBanner';
|
||||
|
||||
describe('AnonymousUserBanner', () => {
|
||||
it('should render the banner with the correct text content', () => {
|
||||
const mockOnOpenProfile = vi.fn();
|
||||
render(<AnonymousUserBanner onOpenProfile={mockOnOpenProfile} />);
|
||||
|
||||
expect(screen.getByText(/you're viewing as a guest/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/to save your flyers, create a watchlist, and access more features/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /sign up or log in/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call onOpenProfile when the "sign up or log in" button is clicked', () => {
|
||||
const mockOnOpenProfile = vi.fn();
|
||||
render(<AnonymousUserBanner onOpenProfile={mockOnOpenProfile} />);
|
||||
|
||||
const loginButton = screen.getByRole('button', { name: /sign up or log in/i });
|
||||
fireEvent.click(loginButton);
|
||||
|
||||
expect(mockOnOpenProfile).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
72
src/components/BulkImportSummary.test.tsx
Normal file
72
src/components/BulkImportSummary.test.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
// src/components/BulkImportSummary.test.tsx
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { BulkImportSummary } from './BulkImportSummary';
|
||||
|
||||
describe('BulkImportSummary', () => {
|
||||
const mockOnDismiss = vi.fn();
|
||||
|
||||
const mockSummary = {
|
||||
processed: ['flyer1.pdf', 'flyer2.jpg'],
|
||||
skipped: ['flyer1.pdf'],
|
||||
errors: [{ fileName: 'flyer3.png', message: 'Invalid format' }],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render all sections when data is provided for each', () => {
|
||||
render(<BulkImportSummary summary={mockSummary} onDismiss={mockOnDismiss} />);
|
||||
|
||||
// Check titles and counts
|
||||
expect(screen.getByRole('heading', { name: /bulk import report/i })).toBeInTheDocument();
|
||||
expect(screen.getByText('Processed: 2, Skipped: 1, Errors: 1')).toBeInTheDocument();
|
||||
|
||||
// Check section headers
|
||||
expect(screen.getByText(/successfully processed \(2\)/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/skipped duplicates \(1\)/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/errors \(1\)/i)).toBeInTheDocument();
|
||||
|
||||
// Check content
|
||||
expect(screen.getByText('flyer1.pdf')).toBeInTheDocument();
|
||||
expect(screen.getByText('flyer2.jpg')).toBeInTheDocument();
|
||||
expect(screen.getByText('flyer3.png:')).toBeInTheDocument();
|
||||
expect(screen.getByText('Invalid format')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render sections with no data', () => {
|
||||
const partialSummary = {
|
||||
processed: ['flyer1.pdf'],
|
||||
skipped: [],
|
||||
errors: [],
|
||||
};
|
||||
render(<BulkImportSummary summary={partialSummary} onDismiss={mockOnDismiss} />);
|
||||
|
||||
expect(screen.getByText(/successfully processed \(1\)/i)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/skipped duplicates/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/errors/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render an empty state message when no files were processed', () => {
|
||||
const emptySummary = {
|
||||
processed: [],
|
||||
skipped: [],
|
||||
errors: [],
|
||||
};
|
||||
render(<BulkImportSummary summary={emptySummary} onDismiss={mockOnDismiss} />);
|
||||
|
||||
expect(screen.getByText(/no new files were found to process/i)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/successfully processed/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call onDismiss when the close button is clicked', () => {
|
||||
render(<BulkImportSummary summary={mockSummary} onDismiss={mockOnDismiss} />);
|
||||
|
||||
const closeButton = screen.getByRole('button', { name: /dismiss summary/i });
|
||||
fireEvent.click(closeButton);
|
||||
|
||||
expect(mockOnDismiss).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
92
src/components/BulkImporter.test.tsx
Normal file
92
src/components/BulkImporter.test.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
// src/components/BulkImporter.test.tsx
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { BulkImporter } from './BulkImporter';
|
||||
|
||||
describe('BulkImporter', () => {
|
||||
const mockOnProcess = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render the initial state correctly', () => {
|
||||
render(<BulkImporter onProcess={mockOnProcess} isProcessing={false} />);
|
||||
expect(screen.getByText(/click to upload/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/drag and drop/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/png, jpg, webp, or pdf/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render the processing state', () => {
|
||||
render(<BulkImporter onProcess={mockOnProcess} isProcessing={true} />);
|
||||
expect(screen.getByText(/processing, please wait.../i)).toBeInTheDocument();
|
||||
const label = screen.getByText(/processing, please wait.../i).closest('label');
|
||||
expect(label).toHaveClass('cursor-not-allowed');
|
||||
expect(label).toHaveClass('opacity-60');
|
||||
});
|
||||
|
||||
it('should call onProcess when files are selected via input', async () => {
|
||||
render(<BulkImporter onProcess={mockOnProcess} isProcessing={false} />);
|
||||
const file = new File(['content'], 'flyer.pdf', { type: 'application/pdf' });
|
||||
const input = screen.getByLabelText(/click to upload/i);
|
||||
|
||||
await waitFor(() =>
|
||||
fireEvent.change(input, {
|
||||
target: { files: [file] },
|
||||
})
|
||||
);
|
||||
|
||||
expect(mockOnProcess).toHaveBeenCalledTimes(1);
|
||||
expect(mockOnProcess.mock.calls[0][0][0]).toBe(file);
|
||||
});
|
||||
|
||||
it('should handle drag and drop events', () => {
|
||||
render(<BulkImporter onProcess={mockOnProcess} isProcessing={false} />);
|
||||
const dropzone = screen.getByLabelText(/click to upload/i);
|
||||
|
||||
// Test drag enter
|
||||
fireEvent.dragEnter(dropzone, { dataTransfer: { files: [] } });
|
||||
expect(dropzone).toHaveClass('border-brand-primary');
|
||||
|
||||
// Test drag leave
|
||||
fireEvent.dragLeave(dropzone);
|
||||
expect(dropzone).not.toHaveClass('border-brand-primary');
|
||||
});
|
||||
|
||||
it('should call onProcess when files are dropped', () => {
|
||||
render(<BulkImporter onProcess={mockOnProcess} isProcessing={false} />);
|
||||
const dropzone = screen.getByLabelText(/click to upload/i);
|
||||
const file = new File(['content'], 'flyer.pdf', { type: 'application/pdf' });
|
||||
|
||||
fireEvent.drop(dropzone, {
|
||||
dataTransfer: {
|
||||
files: [file],
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockOnProcess).toHaveBeenCalledTimes(1);
|
||||
expect(mockOnProcess.mock.calls[0][0][0]).toBe(file);
|
||||
});
|
||||
|
||||
it('should not call onProcess if no files are dropped', () => {
|
||||
render(<BulkImporter onProcess={mockOnProcess} isProcessing={false} />);
|
||||
const dropzone = screen.getByLabelText(/click to upload/i);
|
||||
|
||||
fireEvent.drop(dropzone, {
|
||||
dataTransfer: {
|
||||
files: [],
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockOnProcess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not respond to interactions when isProcessing is true', () => {
|
||||
render(<BulkImporter onProcess={mockOnProcess} isProcessing={true} />);
|
||||
const dropzone = screen.getByLabelText(/processing, please wait.../i);
|
||||
|
||||
fireEvent.dragEnter(dropzone, { dataTransfer: { files: [] } });
|
||||
expect(dropzone).not.toHaveClass('border-brand-primary');
|
||||
});
|
||||
});
|
||||
81
src/components/ConfirmationModal.test.tsx
Normal file
81
src/components/ConfirmationModal.test.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
// src/components/ConfirmationModal.test.tsx
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { ConfirmationModal } from './ConfirmationModal';
|
||||
|
||||
describe('ConfirmationModal', () => {
|
||||
const mockOnClose = vi.fn();
|
||||
const mockOnConfirm = vi.fn();
|
||||
|
||||
const defaultProps = {
|
||||
isOpen: true,
|
||||
onClose: mockOnClose,
|
||||
onConfirm: mockOnConfirm,
|
||||
title: 'Confirm Action',
|
||||
message: 'Are you sure you want to do this?',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should not render when isOpen is false', () => {
|
||||
const { container } = render(<ConfirmationModal {...defaultProps} isOpen={false} />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('should render correctly when isOpen is true', () => {
|
||||
render(<ConfirmationModal {...defaultProps} />);
|
||||
expect(screen.getByRole('heading', { name: 'Confirm Action' })).toBeInTheDocument();
|
||||
expect(screen.getByText('Are you sure you want to do this?')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Confirm' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call onConfirm when the confirm button is clicked', () => {
|
||||
render(<ConfirmationModal {...defaultProps} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Confirm' }));
|
||||
expect(mockOnConfirm).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should call onClose when the cancel button is clicked', () => {
|
||||
render(<ConfirmationModal {...defaultProps} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||
expect(mockOnClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should call onClose when the close icon is clicked', () => {
|
||||
render(<ConfirmationModal {...defaultProps} />);
|
||||
fireEvent.click(screen.getByLabelText('Close confirmation modal'));
|
||||
expect(mockOnClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should call onClose when the overlay is clicked', () => {
|
||||
render(<ConfirmationModal {...defaultProps} />);
|
||||
// The overlay is the parent of the modal content div
|
||||
fireEvent.click(screen.getByRole('dialog'));
|
||||
expect(mockOnClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should not call onClose when clicking inside the modal content', () => {
|
||||
render(<ConfirmationModal {...defaultProps} />);
|
||||
fireEvent.click(screen.getByText('Are you sure you want to do this?'));
|
||||
expect(mockOnClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should render custom button text and classes', () => {
|
||||
render(
|
||||
<ConfirmationModal
|
||||
{...defaultProps}
|
||||
confirmButtonText="Yes, Delete"
|
||||
cancelButtonText="No, Keep"
|
||||
confirmButtonClass="bg-blue-500"
|
||||
/>
|
||||
);
|
||||
const confirmButton = screen.getByRole('button', { name: 'Yes, Delete' });
|
||||
expect(confirmButton).toBeInTheDocument();
|
||||
expect(confirmButton).toHaveClass('bg-blue-500');
|
||||
expect(screen.getByRole('button', { name: 'No, Keep' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
167
src/components/CorrectionRow.test.tsx
Normal file
167
src/components/CorrectionRow.test.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
// src/components/CorrectionRow.test.tsx
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
||||
import { CorrectionRow } from './CorrectionRow';
|
||||
import * as apiClient from '../services/apiClient';
|
||||
import type { SuggestedCorrection, MasterGroceryItem, Category } from '../types';
|
||||
|
||||
// Mock the apiClient module
|
||||
vi.mock('../services/apiClient');
|
||||
|
||||
// Mock the logger
|
||||
vi.mock('../services/logger', () => ({
|
||||
logger: { info: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
|
||||
// Mock the ConfirmationModal to test its props and interactions
|
||||
vi.mock('./ConfirmationModal', () => ({
|
||||
ConfirmationModal: vi.fn(({ isOpen, onConfirm, onClose }) =>
|
||||
isOpen ? (
|
||||
<div data-testid="confirmation-modal">
|
||||
<button onClick={onConfirm}>Confirm</button>
|
||||
<button onClick={onClose}>Cancel</button>
|
||||
</div>
|
||||
) : null
|
||||
),
|
||||
}));
|
||||
|
||||
const mockCorrection: SuggestedCorrection = {
|
||||
suggested_correction_id: 1,
|
||||
flyer_item_id: 101,
|
||||
user_id: 'user-1',
|
||||
correction_type: 'WRONG_PRICE',
|
||||
suggested_value: '250', // $2.50
|
||||
status: 'pending',
|
||||
created_at: new Date().toISOString(),
|
||||
flyer_item_name: 'Bananas',
|
||||
flyer_item_price_display: '$1.99',
|
||||
user_email: 'test@example.com',
|
||||
};
|
||||
|
||||
const mockMasterItems: MasterGroceryItem[] = [
|
||||
{ master_grocery_item_id: 1, name: 'Bananas', created_at: '', category_id: 1, category_name: 'Produce' },
|
||||
];
|
||||
|
||||
const mockCategories: Category[] = [
|
||||
{ category_id: 1, name: 'Produce' },
|
||||
];
|
||||
|
||||
const mockOnProcessed = vi.fn();
|
||||
|
||||
const defaultProps = {
|
||||
correction: mockCorrection,
|
||||
masterItems: mockMasterItems,
|
||||
categories: mockCategories,
|
||||
onProcessed: mockOnProcessed,
|
||||
};
|
||||
|
||||
// Helper to render the component inside a table structure
|
||||
const renderInTable = (props = defaultProps) => {
|
||||
return render(
|
||||
<table>
|
||||
<tbody>
|
||||
<CorrectionRow {...props} />
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
};
|
||||
|
||||
describe('CorrectionRow', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
(apiClient.approveCorrection as Mock).mockResolvedValue({});
|
||||
(apiClient.rejectCorrection as Mock).mockResolvedValue({});
|
||||
(apiClient.updateSuggestedCorrection as Mock).mockResolvedValue({ ...mockCorrection, suggested_value: '300' });
|
||||
});
|
||||
|
||||
it('should render correction data correctly', () => {
|
||||
renderInTable();
|
||||
expect(screen.getByText('Bananas')).toBeInTheDocument();
|
||||
expect(screen.getByText('Original Price: $1.99')).toBeInTheDocument();
|
||||
expect(screen.getByText('WRONG_PRICE')).toBeInTheDocument();
|
||||
expect(screen.getByText('$2.50')).toBeInTheDocument(); // Formatted price
|
||||
expect(screen.getByText('test@example.com')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should open the confirmation modal on approve click', async () => {
|
||||
renderInTable();
|
||||
fireEvent.click(screen.getByTitle('Approve'));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('confirmation-modal')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should call approveCorrection when approval is confirmed', async () => {
|
||||
renderInTable();
|
||||
fireEvent.click(screen.getByTitle('Approve'));
|
||||
await waitFor(() => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Confirm' }));
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(apiClient.approveCorrection).toHaveBeenCalledWith(mockCorrection.suggested_correction_id);
|
||||
expect(mockOnProcessed).toHaveBeenCalledWith(mockCorrection.suggested_correction_id);
|
||||
});
|
||||
});
|
||||
|
||||
it('should call rejectCorrection when rejection is confirmed', async () => {
|
||||
renderInTable();
|
||||
fireEvent.click(screen.getByTitle('Reject'));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('confirmation-modal')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Confirm' }));
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(apiClient.rejectCorrection).toHaveBeenCalledWith(mockCorrection.suggested_correction_id);
|
||||
expect(mockOnProcessed).toHaveBeenCalledWith(mockCorrection.suggested_correction_id);
|
||||
});
|
||||
});
|
||||
|
||||
it('should display an error message if an action fails', async () => {
|
||||
(apiClient.approveCorrection as Mock).mockRejectedValue(new Error('API Error'));
|
||||
renderInTable();
|
||||
fireEvent.click(screen.getByTitle('Approve'));
|
||||
await waitFor(() => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Confirm' }));
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('API Error')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should enter and exit editing mode', async () => {
|
||||
renderInTable();
|
||||
// Enter editing mode
|
||||
fireEvent.click(screen.getByTitle('Edit'));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('spinbutton')).toBeInTheDocument(); // For input type=number
|
||||
expect(screen.getByTitle('Save')).toBeInTheDocument();
|
||||
expect(screen.getByTitle('Cancel')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Exit editing mode
|
||||
fireEvent.click(screen.getByTitle('Cancel'));
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('spinbutton')).not.toBeInTheDocument();
|
||||
expect(screen.getByTitle('Approve')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should save an edited value', async () => {
|
||||
renderInTable();
|
||||
fireEvent.click(screen.getByTitle('Edit'));
|
||||
|
||||
const input = await screen.findByRole('spinbutton');
|
||||
fireEvent.change(input, { target: { value: '300' } });
|
||||
fireEvent.click(screen.getByTitle('Save'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(apiClient.updateSuggestedCorrection).toHaveBeenCalledWith(mockCorrection.suggested_correction_id, '300');
|
||||
// The component should now display the updated value from the mock response
|
||||
expect(screen.getByText('$3.00')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Check that it exited editing mode
|
||||
expect(screen.queryByRole('spinbutton')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
49
src/components/DarkModeToggle.test.tsx
Normal file
49
src/components/DarkModeToggle.test.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
// src/components/DarkModeToggle.test.tsx
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { DarkModeToggle } from './DarkModeToggle';
|
||||
|
||||
// Mock the icon components to isolate the toggle's logic
|
||||
vi.mock('./icons/SunIcon', () => ({
|
||||
SunIcon: () => <div data-testid="sun-icon" />,
|
||||
}));
|
||||
vi.mock('./icons/MoonIcon', () => ({
|
||||
MoonIcon: () => <div data-testid="moon-icon" />,
|
||||
}));
|
||||
|
||||
describe('DarkModeToggle', () => {
|
||||
const mockOnToggle = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render in light mode state', () => {
|
||||
render(<DarkModeToggle isDarkMode={false} onToggle={mockOnToggle} />);
|
||||
|
||||
const checkbox = screen.getByRole('checkbox');
|
||||
expect(checkbox).not.toBeChecked();
|
||||
expect(screen.getByTestId('sun-icon')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('moon-icon')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render in dark mode state', () => {
|
||||
render(<DarkModeToggle isDarkMode={true} onToggle={mockOnToggle} />);
|
||||
|
||||
const checkbox = screen.getByRole('checkbox');
|
||||
expect(checkbox).toBeChecked();
|
||||
expect(screen.getByTestId('moon-icon')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('sun-icon')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call onToggle when the label is clicked', () => {
|
||||
render(<DarkModeToggle isDarkMode={false} onToggle={mockOnToggle} />);
|
||||
|
||||
// Clicking the label triggers the checkbox change
|
||||
const label = screen.getByTitle('Switch to Dark Mode');
|
||||
fireEvent.click(label);
|
||||
|
||||
expect(mockOnToggle).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
29
src/components/ErrorDisplay.test.tsx
Normal file
29
src/components/ErrorDisplay.test.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
// src/components/ErrorDisplay.test.tsx
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { ErrorDisplay } from './ErrorDisplay';
|
||||
|
||||
describe('ErrorDisplay', () => {
|
||||
it('should not render when the message is empty', () => {
|
||||
const { container } = render(<ErrorDisplay message="" />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('should not render when the message is null', () => {
|
||||
// The component expects a string, but we test for nullish values as a safeguard.
|
||||
const { container } = render(<ErrorDisplay message={null as unknown as string} />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('should render the error message when provided', () => {
|
||||
const errorMessage = 'Something went terribly wrong.';
|
||||
render(<ErrorDisplay message={errorMessage} />);
|
||||
|
||||
const alert = screen.getByRole('alert');
|
||||
expect(alert).toBeInTheDocument();
|
||||
expect(screen.getByText('Error:')).toBeInTheDocument();
|
||||
expect(screen.getByText(errorMessage)).toBeInTheDocument();
|
||||
expect(alert).toHaveClass('bg-red-100');
|
||||
});
|
||||
});
|
||||
158
src/components/ExtractedDataTable.test.tsx
Normal file
158
src/components/ExtractedDataTable.test.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
// src/components/ExtractedDataTable.test.tsx
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { ExtractedDataTable } from './ExtractedDataTable';
|
||||
import type { FlyerItem, MasterGroceryItem, ShoppingList, User } from '../types';
|
||||
|
||||
const mockUser: User = { user_id: 'user-123', email: 'test@example.com' };
|
||||
|
||||
const mockMasterItems: MasterGroceryItem[] = [
|
||||
{ master_grocery_item_id: 1, name: 'Apples', category_id: 1, category_name: 'Produce', created_at: '' },
|
||||
{ master_grocery_item_id: 2, name: 'Milk', category_id: 2, category_name: 'Dairy', created_at: '' },
|
||||
{ master_grocery_item_id: 3, name: 'Chicken Breast', category_id: 3, category_name: 'Meat', created_at: '' },
|
||||
];
|
||||
|
||||
const mockFlyerItems: FlyerItem[] = [
|
||||
{ flyer_item_id: 101, item: 'Gala Apples', price_display: '$1.99/lb', price_in_cents: 199, quantity: 'per lb', master_item_id: 1, category_name: 'Produce', flyer_id: 1, created_at: '', view_count: 0, click_count: 0, updated_at: '' },
|
||||
{ flyer_item_id: 102, item: '2% Milk', price_display: '$4.50', price_in_cents: 450, quantity: '4L', master_item_id: 2, category_name: 'Dairy', flyer_id: 1, created_at: '', view_count: 0, click_count: 0, updated_at: '' },
|
||||
{ flyer_item_id: 103, item: 'Boneless Chicken', price_display: '$8.00/kg', price_in_cents: 800, quantity: 'per kg', master_item_id: 3, category_name: 'Meat', flyer_id: 1, created_at: '', view_count: 0, click_count: 0, updated_at: '' },
|
||||
{ flyer_item_id: 104, item: 'Mystery Soda', price_display: '$1.00', price_in_cents: 100, quantity: '1 can', master_item_id: undefined, category_name: 'Beverages', flyer_id: 1, created_at: '', view_count: 0, click_count: 0, updated_at: '' }, // Unmatched item
|
||||
];
|
||||
|
||||
const mockShoppingLists: ShoppingList[] = [
|
||||
{
|
||||
shopping_list_id: 1, name: 'My List', user_id: 'user-123', created_at: '',
|
||||
items: [{ shopping_list_item_id: 1, shopping_list_id: 1, master_item_id: 2, quantity: 1, is_purchased: false, added_at: '' }], // Contains Milk
|
||||
},
|
||||
];
|
||||
|
||||
const mockOnAddItem = vi.fn();
|
||||
const mockOnAddItemToList = vi.fn();
|
||||
|
||||
const defaultProps = {
|
||||
items: mockFlyerItems,
|
||||
masterItems: mockMasterItems,
|
||||
unitSystem: 'imperial' as const,
|
||||
user: mockUser,
|
||||
onAddItem: mockOnAddItem,
|
||||
shoppingLists: mockShoppingLists,
|
||||
activeListId: 1,
|
||||
onAddItemToList: mockOnAddItemToList,
|
||||
};
|
||||
|
||||
describe('ExtractedDataTable', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render an empty state message if no items are provided', () => {
|
||||
render(<ExtractedDataTable {...defaultProps} items={[]} />);
|
||||
expect(screen.getByText('No items extracted yet.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render the table with items', () => {
|
||||
render(<ExtractedDataTable {...defaultProps} />);
|
||||
expect(screen.getByRole('heading', { name: /item list/i })).toBeInTheDocument();
|
||||
expect(screen.getByText('Gala Apples')).toBeInTheDocument();
|
||||
expect(screen.getByText('2% Milk')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not show watch/add to list buttons for anonymous users', () => {
|
||||
render(<ExtractedDataTable {...defaultProps} user={null} />);
|
||||
expect(screen.queryByRole('button', { name: /watch/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /add to list/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('Item States and Interactions', () => {
|
||||
it('should highlight watched items and hide the watch button', () => {
|
||||
// 'Apples' (master_item_id: 1) is watched
|
||||
render(<ExtractedDataTable {...defaultProps} watchedItems={[mockMasterItems[0]]} />);
|
||||
const appleItemRow = screen.getByText('Gala Apples').closest('tr');
|
||||
expect(appleItemRow).toHaveTextContent('Gala Apples');
|
||||
expect(appleItemRow!.querySelector('.font-bold')).toBeInTheDocument(); // Watched items are bold
|
||||
expect(appleItemRow!.querySelector('button[title*="Watch"]')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show the watch button for unwatched, matched items', () => {
|
||||
// 'Chicken Breast' is not in the default watchedItems
|
||||
render(<ExtractedDataTable {...defaultProps} watchedItems={[]} />);
|
||||
const chickenItemRow = screen.getByText('Boneless Chicken').closest('tr');
|
||||
const watchButton = screen.getByTitle("Add 'Chicken Breast' to your watchlist");
|
||||
expect(watchButton).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(watchButton);
|
||||
expect(mockOnAddItem).toHaveBeenCalledWith('Chicken Breast', 'Meat');
|
||||
});
|
||||
|
||||
it('should not show watch or add to list buttons for unmatched items', () => {
|
||||
render(<ExtractedDataTable {...defaultProps} />);
|
||||
const sodaItemRow = screen.getByText('Mystery Soda').closest('tr');
|
||||
expect(sodaItemRow!.querySelector('button[title*="Watch"]')).not.toBeInTheDocument();
|
||||
expect(sodaItemRow!.querySelector('button[title*="list"]')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should hide the add to list button for items already in the active list', () => {
|
||||
// 'Milk' (master_item_id: 2) is in the active shopping list
|
||||
render(<ExtractedDataTable {...defaultProps} />);
|
||||
const milkItemRow = screen.getByText('2% Milk').closest('tr');
|
||||
expect(milkItemRow!.querySelector('button[title*="list"]')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show the add to list button for items not in the list', () => {
|
||||
render(<ExtractedDataTable {...defaultProps} />);
|
||||
const appleItemRow = screen.getByText('Gala Apples').closest('tr');
|
||||
const addToListButton = appleItemRow!.querySelector('button[title*="list"]');
|
||||
expect(addToListButton).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(addToListButton!);
|
||||
expect(mockOnAddItemToList).toHaveBeenCalledWith(1); // master_item_id for Apples
|
||||
});
|
||||
|
||||
it('should disable the add to list button if no list is active', () => {
|
||||
render(<ExtractedDataTable {...defaultProps} activeListId={null} />);
|
||||
const addToListButton = screen.getByTitle('Select a shopping list first');
|
||||
expect(addToListButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it('should display the canonical name when it differs from the item name', () => {
|
||||
render(<ExtractedDataTable {...defaultProps} />);
|
||||
// For 'Gala Apples', canonical is 'Apples'
|
||||
expect(screen.getByText('(Canonical: Apples)')).toBeInTheDocument();
|
||||
// For '2% Milk', canonical is 'Milk'
|
||||
expect(screen.getByText('(Canonical: Milk)')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Sorting and Filtering', () => {
|
||||
it('should sort watched items to the top', () => {
|
||||
// Watch 'Chicken Breast' (last item) and 'Apples' (first item)
|
||||
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
|
||||
});
|
||||
|
||||
it('should filter items by category', () => {
|
||||
render(<ExtractedDataTable {...defaultProps} />);
|
||||
const categoryFilter = screen.getByLabelText('Filter by category');
|
||||
|
||||
// Initial state
|
||||
expect(screen.getByText('Gala Apples')).toBeInTheDocument();
|
||||
expect(screen.getByText('2% Milk')).toBeInTheDocument();
|
||||
|
||||
// Filter by Dairy
|
||||
fireEvent.change(categoryFilter, { target: { value: 'Dairy' } });
|
||||
expect(screen.queryByText('Gala Apples')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('2% Milk')).toBeInTheDocument();
|
||||
|
||||
// Check empty state for filter
|
||||
fireEvent.change(categoryFilter, { target: { value: 'Produce' } });
|
||||
fireEvent.change(categoryFilter, { target: { value: 'Snacks' } }); // A category with no items
|
||||
expect(screen.getByText('No items found for the selected category.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
76
src/components/FlyerDisplay.test.tsx
Normal file
76
src/components/FlyerDisplay.test.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
// src/components/FlyerDisplay.test.tsx
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { FlyerDisplay } from './FlyerDisplay';
|
||||
import type { Store } from '../types';
|
||||
|
||||
const mockStore: Store = {
|
||||
store_id: 1,
|
||||
name: 'SuperMart',
|
||||
logo_url: 'http://example.com/logo.png',
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
imageUrl: 'http://example.com/flyer.jpg',
|
||||
store: mockStore,
|
||||
validFrom: '2023-10-26',
|
||||
validTo: '2023-11-01',
|
||||
storeAddress: '123 Main St, Anytown',
|
||||
};
|
||||
|
||||
describe('FlyerDisplay', () => {
|
||||
it('should render all elements when all props are provided', () => {
|
||||
render(<FlyerDisplay {...defaultProps} />);
|
||||
|
||||
// Check for store info
|
||||
expect(screen.getByRole('heading', { name: 'SuperMart' })).toBeInTheDocument();
|
||||
expect(screen.getByAltText('SuperMart Logo')).toHaveAttribute('src', mockStore.logo_url);
|
||||
expect(screen.getByText('123 Main St, Anytown')).toBeInTheDocument();
|
||||
|
||||
// Check for date range
|
||||
expect(screen.getByText('Deals valid from October 26, 2023 to November 1, 2023')).toBeInTheDocument();
|
||||
|
||||
// Check for flyer image
|
||||
expect(screen.getByAltText('Grocery Flyer')).toHaveAttribute('src', defaultProps.imageUrl);
|
||||
});
|
||||
|
||||
it('should render a placeholder when imageUrl is null', () => {
|
||||
render(<FlyerDisplay {...defaultProps} imageUrl={null} />);
|
||||
expect(screen.getByText('Flyer image will be displayed here')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render the header if store and date info are missing', () => {
|
||||
render(<FlyerDisplay imageUrl={defaultProps.imageUrl} />);
|
||||
expect(screen.queryByRole('heading')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render without a logo if store.logo_url is not provided', () => {
|
||||
render(<FlyerDisplay {...defaultProps} store={{ ...mockStore, logo_url: null }} />);
|
||||
expect(screen.getByRole('heading', { name: 'SuperMart' })).toBeInTheDocument();
|
||||
expect(screen.queryByAltText('SuperMart Logo')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should format a single day validity correctly', () => {
|
||||
render(<FlyerDisplay {...defaultProps} validFrom="2023-10-26" validTo="2023-10-26" />);
|
||||
expect(screen.getByText('Valid on October 26, 2023')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should format correctly with only a start date', () => {
|
||||
render(<FlyerDisplay {...defaultProps} validTo={null} />);
|
||||
expect(screen.getByText('Deals start October 26, 2023')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should format correctly with only an end date', () => {
|
||||
render(<FlyerDisplay {...defaultProps} validFrom={null} />);
|
||||
expect(screen.getByText('Deals end November 1, 2023')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should apply dark mode image styles', () => {
|
||||
render(<FlyerDisplay {...defaultProps} />);
|
||||
const image = screen.getByAltText('Grocery Flyer');
|
||||
expect(image).toHaveClass('dark:invert');
|
||||
expect(image).toHaveClass('dark:hue-rotate-180');
|
||||
});
|
||||
});
|
||||
80
src/components/FlyerList.test.tsx
Normal file
80
src/components/FlyerList.test.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
// src/components/FlyerList.test.tsx
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { FlyerList } from './FlyerList';
|
||||
import type { Flyer } from '../types';
|
||||
|
||||
const mockFlyers: Flyer[] = [
|
||||
{
|
||||
flyer_id: 1,
|
||||
created_at: '2023-10-01T10:00:00Z',
|
||||
file_name: 'metro_flyer_oct_1.pdf',
|
||||
image_url: 'http://example.com/flyer1.jpg',
|
||||
store: {
|
||||
store_id: 101,
|
||||
name: 'Metro',
|
||||
created_at: '2023-01-01T00:00:00Z',
|
||||
},
|
||||
valid_from: '2023-10-05',
|
||||
valid_to: '2023-10-11',
|
||||
},
|
||||
{
|
||||
flyer_id: 2,
|
||||
created_at: '2023-10-02T11:00:00Z',
|
||||
file_name: 'walmart_flyer.pdf',
|
||||
image_url: 'http://example.com/flyer2.jpg',
|
||||
store: {
|
||||
store_id: 102,
|
||||
name: 'Walmart',
|
||||
created_at: '2023-01-01T00:00:00Z',
|
||||
},
|
||||
valid_from: '2023-10-06',
|
||||
valid_to: '2023-10-06', // Same day
|
||||
},
|
||||
];
|
||||
|
||||
describe('FlyerList', () => {
|
||||
const mockOnFlyerSelect = vi.fn();
|
||||
|
||||
it('should render the heading', () => {
|
||||
render(<FlyerList flyers={[]} onFlyerSelect={mockOnFlyerSelect} selectedFlyerId={null} />);
|
||||
expect(screen.getByRole('heading', { name: /processed flyers/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display a message when there are no flyers', () => {
|
||||
render(<FlyerList flyers={[]} onFlyerSelect={mockOnFlyerSelect} selectedFlyerId={null} />);
|
||||
expect(screen.getByText(/no flyers have been processed yet/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render a list of flyers with correct details', () => {
|
||||
render(<FlyerList flyers={mockFlyers} onFlyerSelect={mockOnFlyerSelect} selectedFlyerId={null} />);
|
||||
|
||||
// Check first flyer
|
||||
expect(screen.getByText('Metro')).toBeInTheDocument();
|
||||
expect(screen.getByText('metro_flyer_oct_1.pdf')).toBeInTheDocument();
|
||||
expect(screen.getByText(/processed: 10\/1\/2023/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/valid: Oct 5 - Oct 11/i)).toBeInTheDocument();
|
||||
|
||||
// Check second flyer
|
||||
expect(screen.getByText('Walmart')).toBeInTheDocument();
|
||||
expect(screen.getByText(/valid: Oct 6/i)).toBeInTheDocument(); // Single day validity
|
||||
});
|
||||
|
||||
it('should call onFlyerSelect with the correct flyer when an item is clicked', () => {
|
||||
render(<FlyerList flyers={mockFlyers} onFlyerSelect={mockOnFlyerSelect} selectedFlyerId={null} />);
|
||||
|
||||
const firstFlyerItem = screen.getByText('Metro').closest('li');
|
||||
fireEvent.click(firstFlyerItem!);
|
||||
|
||||
expect(mockOnFlyerSelect).toHaveBeenCalledTimes(1);
|
||||
expect(mockOnFlyerSelect).toHaveBeenCalledWith(mockFlyers[0]);
|
||||
});
|
||||
|
||||
it('should apply a selected style to the currently selected flyer', () => {
|
||||
render(<FlyerList flyers={mockFlyers} onFlyerSelect={mockOnFlyerSelect} selectedFlyerId={1} />);
|
||||
|
||||
const selectedItem = screen.getByText('Metro').closest('li');
|
||||
expect(selectedItem).toHaveClass('bg-brand-light');
|
||||
});
|
||||
});
|
||||
116
src/components/Header.test.tsx
Normal file
116
src/components/Header.test.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
// src/components/Header.test.tsx
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { Header } from './Header';
|
||||
import type { User, Profile } from '../types';
|
||||
|
||||
const mockUser: User = { user_id: 'user-123', email: 'test@example.com' };
|
||||
const mockProfile: Profile = { user_id: 'user-123', role: 'user' };
|
||||
const mockAdminProfile: Profile = { user_id: 'user-123', role: 'admin' };
|
||||
|
||||
const mockOnOpenProfile = vi.fn();
|
||||
const mockOnOpenVoiceAssistant = vi.fn();
|
||||
const mockOnSignOut = vi.fn();
|
||||
|
||||
const defaultProps = {
|
||||
isDarkMode: false,
|
||||
unitSystem: 'imperial' as const,
|
||||
user: null,
|
||||
authStatus: 'SIGNED_OUT' as const,
|
||||
profile: null,
|
||||
onOpenProfile: mockOnOpenProfile,
|
||||
onOpenVoiceAssistant: mockOnOpenVoiceAssistant,
|
||||
onSignOut: mockOnSignOut,
|
||||
};
|
||||
|
||||
// Helper to render with router context
|
||||
const renderWithRouter = (props: Partial<React.ComponentProps<typeof Header>>) => {
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<Header {...defaultProps} {...props} />
|
||||
</MemoryRouter>
|
||||
);
|
||||
};
|
||||
|
||||
describe('Header', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render the application title', () => {
|
||||
renderWithRouter({});
|
||||
expect(screen.getByRole('heading', { name: /flyer crawler/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display unit system and theme mode', () => {
|
||||
renderWithRouter({ isDarkMode: true, unitSystem: 'metric' });
|
||||
expect(screen.getByText('Metric')).toBeInTheDocument();
|
||||
expect(screen.getByText('Dark Mode')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('When user is logged out', () => {
|
||||
it('should show a Login button', () => {
|
||||
renderWithRouter({ user: null, authStatus: 'SIGNED_OUT' });
|
||||
expect(screen.getByRole('button', { name: /login/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call onOpenProfile when Login button is clicked', () => {
|
||||
renderWithRouter({ user: null, authStatus: 'SIGNED_OUT' });
|
||||
fireEvent.click(screen.getByRole('button', { name: /login/i }));
|
||||
expect(mockOnOpenProfile).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should not show user-specific buttons', () => {
|
||||
renderWithRouter({ user: null, authStatus: 'SIGNED_OUT' });
|
||||
expect(screen.queryByLabelText(/open voice assistant/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText(/open my account settings/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /logout/i })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('When user is authenticated', () => {
|
||||
it('should display the user email', () => {
|
||||
renderWithRouter({ user: mockUser, authStatus: 'AUTHENTICATED' });
|
||||
expect(screen.getByText(mockUser.email)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display "Guest" for anonymous users', () => {
|
||||
renderWithRouter({ user: mockUser, authStatus: 'ANONYMOUS' });
|
||||
expect(screen.getByText(/guest/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call onOpenVoiceAssistant when microphone icon is clicked', () => {
|
||||
renderWithRouter({ user: mockUser, authStatus: 'AUTHENTICATED' });
|
||||
fireEvent.click(screen.getByLabelText(/open voice assistant/i));
|
||||
expect(mockOnOpenVoiceAssistant).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should call onOpenProfile when cog icon is clicked', () => {
|
||||
renderWithRouter({ user: mockUser, authStatus: 'AUTHENTICATED' });
|
||||
fireEvent.click(screen.getByLabelText(/open my account settings/i));
|
||||
expect(mockOnOpenProfile).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should call onSignOut when Logout button is clicked', () => {
|
||||
renderWithRouter({ user: mockUser, authStatus: 'AUTHENTICATED' });
|
||||
fireEvent.click(screen.getByRole('button', { name: /logout/i }));
|
||||
expect(mockOnSignOut).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Admin user', () => {
|
||||
it('should show the Admin Area link for admin users', () => {
|
||||
renderWithRouter({ user: mockUser, authStatus: 'AUTHENTICATED', profile: mockAdminProfile });
|
||||
const adminLink = screen.getByTitle(/admin area/i);
|
||||
expect(adminLink).toBeInTheDocument();
|
||||
expect(adminLink.closest('a')).toHaveAttribute('href', '/admin');
|
||||
});
|
||||
|
||||
it('should not show the Admin Area link for non-admin users', () => {
|
||||
renderWithRouter({ user: mockUser, authStatus: 'AUTHENTICATED', profile: mockProfile });
|
||||
expect(screen.queryByTitle(/admin area/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
22
src/components/LoadingSpinner.test.tsx
Normal file
22
src/components/LoadingSpinner.test.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
// src/components/LoadingSpinner.test.tsx
|
||||
import React from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { LoadingSpinner } from './LoadingSpinner';
|
||||
|
||||
describe('LoadingSpinner', () => {
|
||||
it('should render the SVG with animation classes', () => {
|
||||
const { container } = render(<LoadingSpinner />);
|
||||
const svgElement = container.querySelector('svg');
|
||||
expect(svgElement).toBeInTheDocument();
|
||||
expect(svgElement).toHaveClass('animate-spin');
|
||||
});
|
||||
|
||||
it('should contain the correct SVG paths for the spinner graphic', () => {
|
||||
const { container } = render(<LoadingSpinner />);
|
||||
const circle = container.querySelector('circle');
|
||||
const path = container.querySelector('path');
|
||||
expect(circle).toBeInTheDocument();
|
||||
expect(path).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
75
src/components/PasswordInput.test.tsx
Normal file
75
src/components/PasswordInput.test.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
// src/components/PasswordInput.test.tsx
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { PasswordInput } from './PasswordInput';
|
||||
|
||||
// Mock the child PasswordStrengthIndicator component to isolate the test
|
||||
vi.mock('./PasswordStrengthIndicator', () => ({
|
||||
PasswordStrengthIndicator: ({ password }: { password?: string }) => (
|
||||
<div data-testid="strength-indicator">{`Strength for: ${password}`}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
describe('PasswordInput', () => {
|
||||
it('should render as a password input by default', () => {
|
||||
render(<PasswordInput />);
|
||||
const input = screen.getByRole('textbox'); // It's a textbox role even with type password
|
||||
expect(input).toHaveAttribute('type', 'password');
|
||||
});
|
||||
|
||||
it('should toggle input type between password and text when the eye icon is clicked', () => {
|
||||
render(<PasswordInput />);
|
||||
const input = screen.getByRole('textbox');
|
||||
const toggleButton = screen.getByRole('button', { name: /show password/i });
|
||||
|
||||
// Initial state
|
||||
expect(input).toHaveAttribute('type', 'password');
|
||||
|
||||
// Click to show
|
||||
fireEvent.click(toggleButton);
|
||||
expect(input).toHaveAttribute('type', 'text');
|
||||
expect(toggleButton).toHaveAttribute('aria-label', 'Hide password');
|
||||
|
||||
// Click to hide again
|
||||
fireEvent.click(toggleButton);
|
||||
expect(input).toHaveAttribute('type', 'password');
|
||||
expect(toggleButton).toHaveAttribute('aria-label', 'Show password');
|
||||
});
|
||||
|
||||
it('should pass through standard input attributes', () => {
|
||||
const handleChange = vi.fn();
|
||||
render(
|
||||
<PasswordInput
|
||||
value="test"
|
||||
onChange={handleChange}
|
||||
placeholder="Enter password"
|
||||
className="extra-class"
|
||||
/>
|
||||
);
|
||||
|
||||
const input = screen.getByPlaceholderText('Enter password');
|
||||
expect(input).toHaveValue('test');
|
||||
expect(input).toHaveClass('extra-class');
|
||||
|
||||
fireEvent.change(input, { target: { value: 'new value' } });
|
||||
expect(handleChange).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should not show strength indicator by default', () => {
|
||||
render(<PasswordInput value="some-password" />);
|
||||
expect(screen.queryByTestId('strength-indicator')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show strength indicator when showStrength is true and there is a value', () => {
|
||||
render(<PasswordInput value="some-password" showStrength />);
|
||||
const indicator = screen.getByTestId('strength-indicator');
|
||||
expect(indicator).toBeInTheDocument();
|
||||
expect(indicator).toHaveTextContent('Strength for: some-password');
|
||||
});
|
||||
|
||||
it('should not show strength indicator when showStrength is true but value is empty', () => {
|
||||
render(<PasswordInput value="" showStrength />);
|
||||
expect(screen.queryByTestId('strength-indicator')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
82
src/components/PasswordStrengthIndicator.test.tsx
Normal file
82
src/components/PasswordStrengthIndicator.test.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
// src/components/PasswordStrengthIndicator.test.tsx
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, type Mock } from 'vitest';
|
||||
import { PasswordStrengthIndicator } from './PasswordStrengthIndicator';
|
||||
import zxcvbn from 'zxcvbn';
|
||||
|
||||
// Mock the zxcvbn library to control its output for testing
|
||||
vi.mock('zxcvbn');
|
||||
|
||||
describe('PasswordStrengthIndicator', () => {
|
||||
it('should render 5 gray bars when no password is provided', () => {
|
||||
(zxcvbn as Mock).mockReturnValue({ score: -1, feedback: { warning: '', suggestions: [] } });
|
||||
const { container } = render(<PasswordStrengthIndicator password="" />);
|
||||
const bars = container.querySelectorAll('.h-1\\.5');
|
||||
expect(bars).toHaveLength(5);
|
||||
bars.forEach(bar => {
|
||||
expect(bar).toHaveClass('bg-gray-200');
|
||||
});
|
||||
expect(screen.queryByText(/Very Weak/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ score: 0, label: 'Very Weak', color: 'bg-red-500', bars: 1 },
|
||||
{ score: 1, label: 'Weak', color: 'bg-red-500', bars: 2 },
|
||||
{ score: 2, label: 'Fair', color: 'bg-orange-500', bars: 3 },
|
||||
{ score: 3, label: 'Good', color: 'bg-yellow-500', bars: 4 },
|
||||
{ score: 4, label: 'Strong', color: 'bg-green-500', bars: 5 },
|
||||
])('should render correctly for score $score ($label)', ({ score, label, color, bars }) => {
|
||||
(zxcvbn as Mock).mockReturnValue({ score, feedback: { warning: '', suggestions: [] } });
|
||||
const { container } = render(<PasswordStrengthIndicator password="some-password" />);
|
||||
|
||||
// Check the label
|
||||
expect(screen.getByText(label)).toBeInTheDocument();
|
||||
|
||||
// Check the bar colors
|
||||
const barElements = container.querySelectorAll('.h-1\\.5');
|
||||
expect(barElements).toHaveLength(5);
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
if (i < bars) {
|
||||
expect(barElements[i]).toHaveClass(color);
|
||||
} else {
|
||||
expect(barElements[i]).toHaveClass('bg-gray-200');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should display a warning from zxcvbn', () => {
|
||||
(zxcvbn as Mock).mockReturnValue({
|
||||
score: 1,
|
||||
feedback: {
|
||||
warning: 'This is a very common password',
|
||||
suggestions: [],
|
||||
},
|
||||
});
|
||||
render(<PasswordStrengthIndicator password="password" />);
|
||||
expect(screen.getByText(/this is a very common password/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display a suggestion from zxcvbn', () => {
|
||||
(zxcvbn as Mock).mockReturnValue({
|
||||
score: 1,
|
||||
feedback: {
|
||||
warning: '',
|
||||
suggestions: ['Add another word or two'],
|
||||
},
|
||||
});
|
||||
render(<PasswordStrengthIndicator password="pass" />);
|
||||
expect(screen.getByText(/add another word or two/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should prioritize warning over suggestions', () => {
|
||||
(zxcvbn as Mock).mockReturnValue({
|
||||
score: 1,
|
||||
feedback: { warning: 'A warning here', suggestions: ['A suggestion here'] },
|
||||
});
|
||||
render(<PasswordStrengthIndicator password="password" />);
|
||||
expect(screen.getByText(/a warning here/i)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/a suggestion here/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
93
src/components/PriceChart.test.tsx
Normal file
93
src/components/PriceChart.test.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
// src/components/PriceChart.test.tsx
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { PriceChart } from './PriceChart';
|
||||
import type { DealItem, User } from '../types';
|
||||
|
||||
const mockUser: User = { user_id: 'user-123', email: 'test@example.com' };
|
||||
|
||||
const mockDeals: DealItem[] = [
|
||||
{
|
||||
item: 'Organic Bananas',
|
||||
master_item_name: 'Bananas',
|
||||
price_display: '$1.99',
|
||||
price_in_cents: 199,
|
||||
quantity: 'per lb',
|
||||
storeName: 'Metro',
|
||||
unit_price: { value: 199, unit: 'lb' },
|
||||
},
|
||||
{
|
||||
item: 'Milk 2%',
|
||||
master_item_name: null, // No different master name
|
||||
price_display: '$4.50',
|
||||
price_in_cents: 450,
|
||||
quantity: '4L',
|
||||
storeName: 'Walmart',
|
||||
unit_price: { value: 112.5, unit: 'L' },
|
||||
},
|
||||
];
|
||||
|
||||
const defaultProps = {
|
||||
deals: mockDeals,
|
||||
isLoading: false,
|
||||
unitSystem: 'imperial' as const,
|
||||
user: mockUser,
|
||||
};
|
||||
|
||||
describe('PriceChart', () => {
|
||||
it('should render a login prompt when user is not authenticated', () => {
|
||||
render(<PriceChart {...defaultProps} user={null} />);
|
||||
expect(screen.getByRole('heading', { name: /personalized deals/i })).toBeInTheDocument();
|
||||
expect(screen.getByText(/log in to see active deals/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render a loading spinner when isLoading is true', () => {
|
||||
render(<PriceChart {...defaultProps} isLoading={true} />);
|
||||
expect(screen.getByText(/finding active deals/i)).toBeInTheDocument();
|
||||
// The LoadingSpinner component has a role of 'status'
|
||||
expect(screen.getByRole('status')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render a message when there are no deals', () => {
|
||||
render(<PriceChart {...defaultProps} deals={[]} />);
|
||||
expect(screen.getByText(/no deals for your watched items/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render the table with deal items when data is provided', () => {
|
||||
render(<PriceChart {...defaultProps} />);
|
||||
|
||||
// Check for table headers
|
||||
expect(screen.getByRole('columnheader', { name: 'Item' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('columnheader', { name: 'Store' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('columnheader', { name: 'Price' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('columnheader', { name: 'Unit Price' })).toBeInTheDocument();
|
||||
|
||||
// Check for content of the first deal
|
||||
expect(screen.getByText('Organic Bananas')).toBeInTheDocument();
|
||||
expect(screen.getByText('(Bananas)')).toBeInTheDocument(); // Master item name
|
||||
expect(screen.getByText('Metro')).toBeInTheDocument();
|
||||
expect(screen.getByText('$1.99')).toBeInTheDocument();
|
||||
expect(screen.getByText('per lb')).toBeInTheDocument();
|
||||
expect(screen.getByText('$1.99/lb')).toBeInTheDocument(); // Formatted unit price
|
||||
|
||||
// Check for content of the second deal
|
||||
expect(screen.getByText('Milk 2%')).toBeInTheDocument();
|
||||
expect(screen.queryByText('(Milk)')).not.toBeInTheDocument(); // No master item name to show
|
||||
expect(screen.getByText('Walmart')).toBeInTheDocument();
|
||||
expect(screen.getByText('$4.50')).toBeInTheDocument();
|
||||
expect(screen.getByText('4L')).toBeInTheDocument();
|
||||
expect(screen.getByText('$1.13/L')).toBeInTheDocument(); // Formatted unit price
|
||||
});
|
||||
|
||||
it('should format unit price correctly for metric system', () => {
|
||||
// Override default props for this specific test
|
||||
render(<PriceChart {...defaultProps} unitSystem="metric" />);
|
||||
|
||||
// Bananas: 1.99/lb -> 1.99 / 0.453592 kg -> $4.39/kg
|
||||
expect(screen.getByText('$4.39/kg')).toBeInTheDocument();
|
||||
|
||||
// Milk: $1.13/L (already metric)
|
||||
expect(screen.getByText('$1.13/L')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
119
src/components/PriceHistoryChart.test.tsx
Normal file
119
src/components/PriceHistoryChart.test.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
// src/components/PriceHistoryChart.test.tsx
|
||||
import React from 'react';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
||||
import { PriceHistoryChart } from './PriceHistoryChart';
|
||||
import * as apiClient from '../services/apiClient';
|
||||
import type { MasterGroceryItem } from '../types';
|
||||
|
||||
// Mock the apiClient
|
||||
vi.mock('../services/apiClient');
|
||||
|
||||
// Mock recharts library
|
||||
vi.mock('recharts', async () => {
|
||||
const OriginalModule = await vi.importActual('recharts');
|
||||
return {
|
||||
...OriginalModule,
|
||||
ResponsiveContainer: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="responsive-container">{children}</div>
|
||||
),
|
||||
LineChart: ({ children }: { children: React.ReactNode }) => <div data-testid="line-chart">{children}</div>,
|
||||
Line: ({ dataKey }: { dataKey: string }) => <div data-testid={`line-${dataKey}`}></div>,
|
||||
};
|
||||
});
|
||||
|
||||
const mockWatchedItems: MasterGroceryItem[] = [
|
||||
{ master_grocery_item_id: 1, name: 'Apples', category_id: 1, category_name: 'Produce', created_at: '' },
|
||||
{ master_grocery_item_id: 2, name: 'Milk', category_id: 2, category_name: 'Dairy', created_at: '' },
|
||||
{ master_grocery_item_id: 3, name: 'Bread', category_id: 3, category_name: 'Bakery', created_at: '' }, // Will be filtered out (1 data point)
|
||||
];
|
||||
|
||||
const mockRawData = [
|
||||
// Apples data
|
||||
{ master_item_id: 1, avg_price_in_cents: 120, summary_date: '2023-10-01' },
|
||||
{ master_item_id: 1, avg_price_in_cents: 110, summary_date: '2023-10-08' },
|
||||
{ master_item_id: 1, avg_price_in_cents: 130, summary_date: '2023-10-08' }, // Higher price, should be ignored
|
||||
// Milk data
|
||||
{ master_item_id: 2, avg_price_in_cents: 250, summary_date: '2023-10-01' },
|
||||
{ master_item_id: 2, avg_price_in_cents: 240, summary_date: '2023-10-15' },
|
||||
// Bread data (only one point)
|
||||
{ master_item_id: 3, avg_price_in_cents: 200, summary_date: '2023-10-01' },
|
||||
// Data with nulls to be ignored
|
||||
{ master_item_id: 4, avg_price_in_cents: null, summary_date: '2023-10-01' },
|
||||
];
|
||||
|
||||
describe('PriceHistoryChart', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render a loading spinner while fetching data', () => {
|
||||
(apiClient.fetchHistoricalPriceData as Mock).mockReturnValue(new Promise(() => {}));
|
||||
render(<PriceHistoryChart watchedItems={mockWatchedItems} />);
|
||||
expect(screen.getByText(/loading price history/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole('status')).toBeInTheDocument(); // LoadingSpinner
|
||||
});
|
||||
|
||||
it('should render an error message if fetching fails', async () => {
|
||||
(apiClient.fetchHistoricalPriceData as Mock).mockRejectedValue(new Error('API is down'));
|
||||
render(<PriceHistoryChart watchedItems={mockWatchedItems} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Error:')).toBeInTheDocument();
|
||||
expect(screen.getByText('API is down')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should render a message if no watched items are provided', () => {
|
||||
render(<PriceHistoryChart watchedItems={[]} />);
|
||||
expect(screen.getByText(/add items to your watchlist/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render a message if not enough historical data is available', async () => {
|
||||
(apiClient.fetchHistoricalPriceData as Mock).mockResolvedValue([
|
||||
{ master_item_id: 1, avg_price_in_cents: 120, summary_date: '2023-10-01' }, // Only one data point
|
||||
]);
|
||||
render(<PriceHistoryChart watchedItems={mockWatchedItems} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/not enough historical data/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should process raw data and render the chart with correct lines', async () => {
|
||||
(apiClient.fetchHistoricalPriceData as Mock).mockResolvedValue(mockRawData);
|
||||
render(<PriceHistoryChart watchedItems={mockWatchedItems} />);
|
||||
|
||||
await waitFor(() => {
|
||||
// Check that the chart components are rendered
|
||||
expect(screen.getByTestId('responsive-container')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('line-chart')).toBeInTheDocument();
|
||||
|
||||
// Check that lines are created for items with more than one data point
|
||||
expect(screen.getByTestId('line-Apples')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('line-Milk')).toBeInTheDocument();
|
||||
|
||||
// Check that 'Bread' is filtered out because it only has one data point
|
||||
expect(screen.queryByTestId('line-Bread')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should correctly process data, keeping only the lowest price per day', async () => {
|
||||
// This test relies on the `chartData` calculation inside the component.
|
||||
// We can't directly inspect `chartData`, but we can verify the mock `LineChart`
|
||||
// receives the correctly processed data.
|
||||
(apiClient.fetchHistoricalPriceData as Mock).mockResolvedValue(mockRawData);
|
||||
|
||||
// We need to spy on the props passed to the mocked LineChart
|
||||
const { LineChart } = await import('recharts');
|
||||
render(<PriceHistoryChart watchedItems={mockWatchedItems} />);
|
||||
|
||||
await waitFor(() => {
|
||||
const lineChartProps = vi.mocked(LineChart).mock.calls[0][0];
|
||||
const chartData = lineChartProps.data as { date: string; Apples?: number; Milk?: number }[];
|
||||
|
||||
// Find the entry for Oct 8
|
||||
const oct8Entry = chartData.find(d => d.date.includes('Oct') && d.date.includes('8'));
|
||||
// The price for Apples on Oct 8 should be 110, not 130.
|
||||
expect(oct8Entry?.Apples).toBe(110);
|
||||
});
|
||||
});
|
||||
});
|
||||
140
src/components/ProcessingStatus.test.tsx
Normal file
140
src/components/ProcessingStatus.test.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
// src/components/ProcessingStatus.test.tsx
|
||||
import React from 'react';
|
||||
import { render, screen, act } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { ProcessingStatus } from './ProcessingStatus';
|
||||
import type { ProcessingStage } from '../types';
|
||||
|
||||
describe('ProcessingStatus', () => {
|
||||
const mockStages: ProcessingStage[] = [
|
||||
{ name: 'Uploading File', status: 'completed', detail: 'Done' },
|
||||
{ name: 'Converting to Image', status: 'in-progress', detail: 'Page 2 of 5...' },
|
||||
{ name: 'Extracting Text', status: 'pending', detail: '' },
|
||||
{ name: 'Analyzing with AI', status: 'error', detail: 'AI model timeout', critical: false },
|
||||
{ name: 'Saving to Database', status: 'error', detail: 'Connection failed', critical: true },
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('Single File Layout', () => {
|
||||
it('should render the title and initial time remaining', () => {
|
||||
render(<ProcessingStatus stages={[]} estimatedTime={125} />);
|
||||
expect(screen.getByRole('heading', { name: /processing your flyer/i })).toBeInTheDocument();
|
||||
expect(screen.getByText(/estimated time remaining: 2m 5s/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should count down the time remaining', () => {
|
||||
render(<ProcessingStatus stages={[]} estimatedTime={125} />);
|
||||
expect(screen.getByText(/estimated time remaining: 2m 5s/i)).toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(3000); // Advance time by 3 seconds
|
||||
});
|
||||
|
||||
expect(screen.getByText(/estimated time remaining: 2m 2s/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render all stages with correct statuses and icons', () => {
|
||||
render(<ProcessingStatus stages={mockStages} estimatedTime={120} />);
|
||||
|
||||
// Completed stage
|
||||
const completedStage = screen.getByText('Uploading File').parentElement;
|
||||
expect(completedStage).toHaveClass('text-gray-700');
|
||||
expect(completedStage?.querySelector('svg')).toHaveClass('text-green-500'); // CheckCircleIcon
|
||||
|
||||
// In-progress stage
|
||||
const inProgressStage = screen.getByText('Converting to Image').parentElement;
|
||||
expect(inProgressStage).toHaveClass('text-brand-primary');
|
||||
expect(inProgressStage?.querySelector('svg')).toHaveClass('animate-spin'); // LoadingSpinner
|
||||
|
||||
// Pending stage
|
||||
const pendingStage = screen.getByText('Extracting Text').parentElement;
|
||||
expect(pendingStage).toHaveClass('text-gray-400');
|
||||
expect(pendingStage?.querySelector('div > div')).toHaveClass('border-gray-400'); // Pending circle
|
||||
|
||||
// Non-critical error stage
|
||||
const nonCriticalErrorStage = screen.getByText(/analyzing with ai/i).parentElement;
|
||||
expect(nonCriticalErrorStage).toHaveClass('text-yellow-600');
|
||||
expect(nonCriticalErrorStage?.querySelector('svg')).toHaveClass('text-yellow-500'); // ExclamationTriangleIcon
|
||||
expect(screen.getByText(/optional/i)).toBeInTheDocument();
|
||||
|
||||
// Critical error stage
|
||||
const criticalErrorStage = screen.getByText('Saving to Database').parentElement;
|
||||
expect(criticalErrorStage).toHaveClass('text-red-500');
|
||||
expect(criticalErrorStage?.querySelector('svg')).toHaveClass('text-red-500'); // Red XCircleIcon
|
||||
});
|
||||
|
||||
it('should render PDF conversion progress bar', () => {
|
||||
render(<ProcessingStatus stages={[]} estimatedTime={60} pageProgress={{ current: 3, total: 10 }} />);
|
||||
const progressBar = screen.getByText(/converting pdf: page 3 of 10/i).nextElementSibling?.firstChild;
|
||||
expect(progressBar).toBeInTheDocument();
|
||||
expect(progressBar).toHaveStyle('width: 30%');
|
||||
});
|
||||
|
||||
it('should render item extraction progress bar for a stage', () => {
|
||||
const stagesWithProgress: ProcessingStage[] = [
|
||||
{ name: 'Extracting Items', status: 'in-progress', progress: { current: 4, total: 8 } },
|
||||
];
|
||||
render(<ProcessingStatus stages={stagesWithProgress} estimatedTime={60} />);
|
||||
const progressBar = screen.getByText(/analyzing page 4 of 8/i).nextElementSibling?.firstChild;
|
||||
expect(progressBar).toBeInTheDocument();
|
||||
expect(progressBar).toHaveStyle('width: 50%');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Bulk Processing Layout', () => {
|
||||
const bulkProps = {
|
||||
stages: mockStages,
|
||||
estimatedTime: 300,
|
||||
currentFile: 'flyer_batch_01.pdf',
|
||||
bulkProgress: 25,
|
||||
bulkFileCount: { current: 2, total: 8 },
|
||||
};
|
||||
|
||||
it('should render the bulk processing layout with current file name', () => {
|
||||
render(<ProcessingStatus {...bulkProps} />);
|
||||
expect(screen.getByRole('heading', { name: /processing steps for:/i })).toBeInTheDocument();
|
||||
expect(screen.getByText('flyer_batch_01.pdf')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render the overall bulk progress bar', () => {
|
||||
render(<ProcessingStatus {...bulkProps} />);
|
||||
const progressBar = screen.getByText(/file 2 of 8/i).nextElementSibling?.firstChild;
|
||||
expect(progressBar).toBeInTheDocument();
|
||||
expect(progressBar).toHaveStyle('width: 25%');
|
||||
});
|
||||
|
||||
it('should render the PDF conversion progress bar in bulk mode', () => {
|
||||
render(<ProcessingStatus {...bulkProps} pageProgress={{ current: 1, total: 5 }} />);
|
||||
const progressBar = screen.getByText(/converting pdf: page 1 of 5/i).nextElementSibling?.firstChild;
|
||||
expect(progressBar).toBeInTheDocument();
|
||||
expect(progressBar).toHaveStyle('width: 20%');
|
||||
});
|
||||
|
||||
it('should render the item extraction progress bar from the correct stage in bulk mode', () => {
|
||||
const stagesWithProgress: ProcessingStage[] = [
|
||||
{ name: 'Some Other Step', status: 'completed' },
|
||||
{ name: 'Extracting All Items from Flyer', status: 'in-progress', progress: { current: 3, total: 10 } },
|
||||
];
|
||||
render(<ProcessingStatus {...bulkProps} stages={stagesWithProgress} />);
|
||||
const progressBar = screen.getByText(/analyzing page 3 of 10/i).nextElementSibling?.firstChild;
|
||||
expect(progressBar).toBeInTheDocument();
|
||||
expect(progressBar).toHaveStyle('width: 30%');
|
||||
});
|
||||
|
||||
it('should render the checklist of stages on the right', () => {
|
||||
render(<ProcessingStatus {...bulkProps} />);
|
||||
// The list is inside the second column of the grid
|
||||
const rightColumn = screen.getByText('Uploading File').closest('.md\\:grid-cols-2 > div:last-child');
|
||||
expect(rightColumn).toBeInTheDocument();
|
||||
expect(rightColumn).toHaveTextContent('Uploading File');
|
||||
expect(rightColumn).toHaveTextContent('Converting to Image');
|
||||
});
|
||||
});
|
||||
});
|
||||
25
src/components/SampleDataButton.test.tsx
Normal file
25
src/components/SampleDataButton.test.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
// src/components/SampleDataButton.test.tsx
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { SampleDataButton } from './SampleDataButton';
|
||||
|
||||
describe('SampleDataButton', () => {
|
||||
it('should render the button with the correct text', () => {
|
||||
const mockOnClick = vi.fn();
|
||||
render(<SampleDataButton onClick={mockOnClick} />);
|
||||
|
||||
const button = screen.getByRole('button', { name: /no flyer\? try with sample data\./i });
|
||||
expect(button).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call the onClick handler when clicked', () => {
|
||||
const mockOnClick = vi.fn();
|
||||
render(<SampleDataButton onClick={mockOnClick} />);
|
||||
|
||||
const button = screen.getByRole('button', { name: /no flyer\? try with sample data\./i });
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(mockOnClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
205
src/components/ShoppingList.test.tsx
Normal file
205
src/components/ShoppingList.test.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
// src/components/ShoppingList.test.tsx
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
||||
import { ShoppingListComponent } from './ShoppingList';
|
||||
import type { User, ShoppingList } from '../types';
|
||||
import * as aiApiClient from '../services/aiApiClient';
|
||||
|
||||
// Mock the logger
|
||||
vi.mock('../services/logger', () => ({
|
||||
logger: {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock the AI API client
|
||||
vi.mock('../services/aiApiClient');
|
||||
|
||||
const mockUser: User = { user_id: 'user-123', email: 'test@example.com' };
|
||||
|
||||
const mockLists: ShoppingList[] = [
|
||||
{
|
||||
shopping_list_id: 1,
|
||||
name: 'Weekly Groceries',
|
||||
user_id: 'user-123',
|
||||
created_at: new Date().toISOString(),
|
||||
items: [
|
||||
{ shopping_list_item_id: 101, shopping_list_id: 1, master_item_id: 1, custom_item_name: null, is_purchased: false, quantity: 1, added_at: new Date().toISOString(), master_item: { name: 'Apples' } },
|
||||
{ shopping_list_item_id: 102, shopping_list_id: 1, master_item_id: null, custom_item_name: 'Special Bread', is_purchased: false, quantity: 1, added_at: new Date().toISOString(), master_item: null },
|
||||
{ shopping_list_item_id: 103, shopping_list_id: 1, master_item_id: 2, custom_item_name: null, is_purchased: true, quantity: 1, added_at: new Date().toISOString(), master_item: { name: 'Milk' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
shopping_list_id: 2,
|
||||
name: 'Party Supplies',
|
||||
user_id: 'user-123',
|
||||
created_at: new Date().toISOString(),
|
||||
items: [],
|
||||
},
|
||||
];
|
||||
|
||||
describe('ShoppingListComponent', () => {
|
||||
const mockOnSelectList = vi.fn();
|
||||
const mockOnCreateList = vi.fn();
|
||||
const mockOnDeleteList = vi.fn();
|
||||
const mockOnAddItem = vi.fn();
|
||||
const mockOnUpdateItem = vi.fn();
|
||||
const mockOnRemoveItem = vi.fn();
|
||||
|
||||
const defaultProps = {
|
||||
user: mockUser,
|
||||
lists: mockLists,
|
||||
activeListId: 1,
|
||||
onSelectList: mockOnSelectList,
|
||||
onCreateList: mockOnCreateList,
|
||||
onDeleteList: mockOnDeleteList,
|
||||
onAddItem: mockOnAddItem,
|
||||
onUpdateItem: mockOnUpdateItem,
|
||||
onRemoveItem: mockOnRemoveItem,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Mock browser APIs
|
||||
window.prompt = vi.fn();
|
||||
window.confirm = vi.fn();
|
||||
window.AudioContext = vi.fn().mockImplementation(() => ({
|
||||
decodeAudioData: vi.fn().mockResolvedValue({}),
|
||||
createBufferSource: vi.fn().mockReturnValue({
|
||||
connect: vi.fn(),
|
||||
start: vi.fn(),
|
||||
buffer: {},
|
||||
}),
|
||||
close: vi.fn(),
|
||||
sampleRate: 44100,
|
||||
}));
|
||||
});
|
||||
|
||||
it('should render a login message when user is not authenticated', () => {
|
||||
render(<ShoppingListComponent {...defaultProps} user={null} />);
|
||||
expect(screen.getByText(/please log in to manage your shopping lists/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render correctly when authenticated with an active list', () => {
|
||||
render(<ShoppingListComponent {...defaultProps} />);
|
||||
expect(screen.getByRole('heading', { name: /shopping list/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('combobox')).toHaveValue('1');
|
||||
expect(screen.getByText('Apples')).toBeInTheDocument();
|
||||
expect(screen.getByText('Special Bread')).toBeInTheDocument();
|
||||
expect(screen.getByText('Milk')).toBeInTheDocument(); // Purchased item
|
||||
expect(screen.getByRole('heading', { name: /purchased/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display a message if there are no lists', () => {
|
||||
render(<ShoppingListComponent {...defaultProps} lists={[]} activeListId={null} />);
|
||||
expect(screen.getByText(/no shopping lists found/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call onSelectList when changing the list in the dropdown', () => {
|
||||
render(<ShoppingListComponent {...defaultProps} />);
|
||||
fireEvent.change(screen.getByRole('combobox'), { target: { value: '2' } });
|
||||
expect(mockOnSelectList).toHaveBeenCalledWith(2);
|
||||
});
|
||||
|
||||
it('should call onCreateList when creating a new list', async () => {
|
||||
(window.prompt as Mock).mockReturnValue('New List Name');
|
||||
render(<ShoppingListComponent {...defaultProps} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: /new list/i }));
|
||||
await waitFor(() => {
|
||||
expect(mockOnCreateList).toHaveBeenCalledWith('New List Name');
|
||||
});
|
||||
});
|
||||
|
||||
it('should not call onCreateList if prompt is cancelled', async () => {
|
||||
(window.prompt as Mock).mockReturnValue(null);
|
||||
render(<ShoppingListComponent {...defaultProps} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: /new list/i }));
|
||||
await waitFor(() => {
|
||||
expect(mockOnCreateList).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should call onDeleteList when deleting a list after confirmation', async () => {
|
||||
(window.confirm as Mock).mockReturnValue(true);
|
||||
render(<ShoppingListComponent {...defaultProps} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: /delete list/i }));
|
||||
await waitFor(() => {
|
||||
expect(window.confirm).toHaveBeenCalled();
|
||||
expect(mockOnDeleteList).toHaveBeenCalledWith(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('should not call onDeleteList if deletion is not confirmed', async () => {
|
||||
(window.confirm as Mock).mockReturnValue(false);
|
||||
render(<ShoppingListComponent {...defaultProps} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: /delete list/i }));
|
||||
await waitFor(() => {
|
||||
expect(window.confirm).toHaveBeenCalled();
|
||||
expect(mockOnDeleteList).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should call onAddItem when adding a custom item', async () => {
|
||||
render(<ShoppingListComponent {...defaultProps} />);
|
||||
const input = screen.getByPlaceholderText(/add a custom item/i);
|
||||
const addButton = screen.getByRole('button', { name: 'Add' });
|
||||
|
||||
fireEvent.change(input, { target: { value: 'New Custom Item' } });
|
||||
fireEvent.click(addButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnAddItem).toHaveBeenCalledWith({ customItemName: 'New Custom Item' });
|
||||
});
|
||||
expect(input).toHaveValue('');
|
||||
});
|
||||
|
||||
it('should call onUpdateItem when toggling an item checkbox', () => {
|
||||
render(<ShoppingListComponent {...defaultProps} />);
|
||||
const checkboxes = screen.getAllByRole('checkbox');
|
||||
const appleCheckbox = checkboxes[0]; // 'Apples' is the first item
|
||||
|
||||
fireEvent.click(appleCheckbox);
|
||||
expect(mockOnUpdateItem).toHaveBeenCalledWith(101, { is_purchased: true });
|
||||
});
|
||||
|
||||
it('should call onRemoveItem when clicking the remove button', () => {
|
||||
render(<ShoppingListComponent {...defaultProps} />);
|
||||
// Items are in a div, not listitem. We find the button near the item text.
|
||||
const appleItem = screen.getByText('Apples').closest('div');
|
||||
const removeButton = appleItem!.querySelector('button')!;
|
||||
|
||||
fireEvent.click(removeButton);
|
||||
expect(mockOnRemoveItem).toHaveBeenCalledWith(101);
|
||||
});
|
||||
|
||||
it('should call generateSpeechFromText when "Read aloud" is clicked', async () => {
|
||||
(aiApiClient.generateSpeechFromText as Mock).mockResolvedValue('base64-audio-string');
|
||||
render(<ShoppingListComponent {...defaultProps} />);
|
||||
const readAloudButton = screen.getByTitle(/read list aloud/i);
|
||||
|
||||
fireEvent.click(readAloudButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(aiApiClient.generateSpeechFromText).toHaveBeenCalledWith(
|
||||
'Here is your shopping list: Apples, Special Bread'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should show a loading spinner while reading aloud', async () => {
|
||||
(aiApiClient.generateSpeechFromText as Mock).mockImplementation(() => new Promise(() => {})); // Never resolves
|
||||
render(<ShoppingListComponent {...defaultProps} />);
|
||||
const readAloudButton = screen.getByTitle(/read list aloud/i);
|
||||
|
||||
fireEvent.click(readAloudButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(readAloudButton.querySelector('svg.animate-spin')).toBeInTheDocument();
|
||||
expect(readAloudButton).toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
245
src/components/SystemCheck.test.tsx
Normal file
245
src/components/SystemCheck.test.tsx
Normal file
@@ -0,0 +1,245 @@
|
||||
// src/components/SystemCheck.test.tsx
|
||||
import React from 'react';
|
||||
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
||||
import { SystemCheck } from './SystemCheck';
|
||||
import * as apiClient from '../services/apiClient';
|
||||
|
||||
// Mock all external dependencies
|
||||
vi.mock('../services/apiClient', () => ({
|
||||
pingBackend: vi.fn(),
|
||||
checkDbSchema: vi.fn(),
|
||||
checkStorage: vi.fn(),
|
||||
checkDbPoolHealth: vi.fn(),
|
||||
checkPm2Status: vi.fn(),
|
||||
loginUser: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../services/logger', () => ({
|
||||
logger: {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('SystemCheck', () => {
|
||||
// Store original env variable
|
||||
const originalViteApiKey = import.meta.env.VITE_API_KEY;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Reset API client mocks to resolve successfully by default
|
||||
(apiClient.pingBackend as Mock).mockResolvedValue(true);
|
||||
(apiClient.checkDbSchema as Mock).mockResolvedValue({ success: true, message: 'Schema OK' });
|
||||
(apiClient.checkStorage as Mock).mockResolvedValue({ success: true, message: 'Storage OK' });
|
||||
(apiClient.checkDbPoolHealth as Mock).mockResolvedValue({ success: true, message: 'DB Pool OK' });
|
||||
(apiClient.checkPm2Status as Mock).mockResolvedValue({ success: true, message: 'PM2 OK' });
|
||||
(apiClient.loginUser as Mock).mockResolvedValue({}); // Mock successful admin login
|
||||
|
||||
// Reset VITE_API_KEY for each test
|
||||
import.meta.env.VITE_API_KEY = originalViteApiKey;
|
||||
});
|
||||
|
||||
// Helper to set VITE_API_KEY
|
||||
const setViteApiKey = (value: string | undefined) => {
|
||||
import.meta.env.VITE_API_KEY = value;
|
||||
};
|
||||
|
||||
it('should render initial idle state and then run checks automatically on mount', async () => {
|
||||
setViteApiKey('mock-api-key');
|
||||
render(<SystemCheck />);
|
||||
|
||||
// Initially, all checks should be in 'running' state due to auto-run
|
||||
expect(screen.getByText('Gemini API Key')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Checking...')).toHaveLength(7); // All 7 checks
|
||||
|
||||
// Wait for all checks to complete
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('VITE_API_KEY is set.')).toBeInTheDocument();
|
||||
expect(screen.getByText('Backend server is running and reachable.')).toBeInTheDocument();
|
||||
expect(screen.getByText('PM2 OK')).toBeInTheDocument();
|
||||
expect(screen.getByText('DB Pool OK')).toBeInTheDocument();
|
||||
expect(screen.getByText('Schema OK')).toBeInTheDocument();
|
||||
expect(screen.getByText('Default admin user login was successful.')).toBeInTheDocument();
|
||||
expect(screen.getByText('Storage OK')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Check that the re-run button is enabled and elapsed time is shown
|
||||
expect(screen.getByRole('button', { name: /re-run checks/i })).toBeEnabled();
|
||||
expect(screen.getByText(/finished in \d+\.\d{2} seconds\./i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show API key as failed if VITE_API_KEY is not set', async () => {
|
||||
setViteApiKey(undefined);
|
||||
render(<SystemCheck />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('VITE_API_KEY is missing. Please add it to your .env file.')).toBeInTheDocument();
|
||||
});
|
||||
// Other checks should not run if API key check fails early
|
||||
expect(apiClient.pingBackend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should show backend connection as failed if pingBackend fails', async () => {
|
||||
setViteApiKey('mock-api-key');
|
||||
(apiClient.pingBackend as Mock).mockRejectedValueOnce(new Error('Network error'));
|
||||
render(<SystemCheck />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Network error')).toBeInTheDocument();
|
||||
expect(screen.getByText('Skipped: Backend server is not reachable.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Dependent checks should be skipped/failed
|
||||
expect(apiClient.checkDbPoolHealth).not.toHaveBeenCalled();
|
||||
expect(apiClient.checkDbSchema).not.toHaveBeenCalled();
|
||||
expect(apiClient.loginUser).not.toHaveBeenCalled();
|
||||
expect(apiClient.checkStorage).not.toHaveBeenCalled();
|
||||
expect(apiClient.checkPm2Status).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should show PM2 status as failed if checkPm2Status returns success: false', async () => {
|
||||
setViteApiKey('mock-api-key');
|
||||
(apiClient.checkPm2Status as Mock).mockResolvedValueOnce({ success: false, message: 'PM2 process not found' });
|
||||
render(<SystemCheck />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('PM2 process not found')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should show database pool check as failed if checkDbPoolHealth fails', async () => {
|
||||
setViteApiKey('mock-api-key');
|
||||
(apiClient.checkDbPoolHealth as Mock).mockRejectedValueOnce(new Error('DB connection refused'));
|
||||
render(<SystemCheck />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('DB connection refused')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should show database schema check as failed if checkDbSchema fails', async () => {
|
||||
setViteApiKey('mock-api-key');
|
||||
(apiClient.checkDbSchema as Mock).mockResolvedValueOnce({ success: false, message: 'Schema mismatch' });
|
||||
render(<SystemCheck />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Schema mismatch')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should show seeded user check as failed if loginUser fails', async () => {
|
||||
setViteApiKey('mock-api-key');
|
||||
(apiClient.loginUser as Mock).mockRejectedValueOnce(new Error('Incorrect email or password'));
|
||||
render(<SystemCheck />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Login failed. Ensure the default admin user is seeded in your database.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should show storage directory check as failed if checkStorage fails', async () => {
|
||||
setViteApiKey('mock-api-key');
|
||||
(apiClient.checkStorage as Mock).mockRejectedValueOnce(new Error('Storage not writable'));
|
||||
render(<SystemCheck />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Storage not writable')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should display a loading spinner and disable button while checks are running', async () => {
|
||||
setViteApiKey('mock-api-key');
|
||||
// Mock pingBackend to never resolve to keep the component in a loading state
|
||||
(apiClient.pingBackend as Mock).mockImplementation(() => new Promise(() => {}));
|
||||
render(<SystemCheck />);
|
||||
|
||||
const rerunButton = screen.getByRole('button', { name: /running checks\.\.\./i });
|
||||
expect(rerunButton).toBeDisabled();
|
||||
expect(rerunButton.querySelector('svg')).toBeInTheDocument(); // Check for spinner inside button
|
||||
|
||||
// All checks should show 'Checking...'
|
||||
expect(screen.getAllByText('Checking...')).toHaveLength(7);
|
||||
});
|
||||
|
||||
it('should re-run checks when the "Re-run Checks" button is clicked', async () => {
|
||||
setViteApiKey('mock-api-key');
|
||||
render(<SystemCheck />);
|
||||
|
||||
// Wait for initial auto-run to complete
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('VITE_API_KEY is set.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Reset mocks for the re-run
|
||||
(apiClient.pingBackend as Mock).mockResolvedValueOnce(true);
|
||||
(apiClient.checkDbSchema as Mock).mockResolvedValueOnce({ success: true, message: 'Schema OK (re-run)' });
|
||||
(apiClient.checkStorage as Mock).mockResolvedValueOnce({ success: true, message: 'Storage OK (re-run)' });
|
||||
(apiClient.checkDbPoolHealth as Mock).mockResolvedValueOnce({ success: true, message: 'DB Pool OK (re-run)' });
|
||||
(apiClient.checkPm2Status as Mock).mockResolvedValueOnce({ success: true, message: 'PM2 OK (re-run)' });
|
||||
(apiClient.loginUser as Mock).mockResolvedValueOnce({});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /re-run checks/i }));
|
||||
|
||||
// Expect checks to go back to 'Checking...' state
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText('Checking...')).toHaveLength(7);
|
||||
});
|
||||
|
||||
// Wait for re-run to complete
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Schema OK (re-run)')).toBeInTheDocument();
|
||||
expect(screen.getByText('Storage OK (re-run)')).toBeInTheDocument();
|
||||
expect(screen.getByText('DB Pool OK (re-run)')).toBeInTheDocument();
|
||||
expect(screen.getByText('PM2 OK (re-run)')).toBeInTheDocument();
|
||||
});
|
||||
expect(apiClient.pingBackend).toHaveBeenCalledTimes(2); // Initial run + re-run
|
||||
});
|
||||
|
||||
it('should display correct icons for each status', async () => {
|
||||
setViteApiKey('mock-api-key');
|
||||
// Make one check fail for icon verification
|
||||
(apiClient.checkDbSchema as Mock).mockResolvedValueOnce({ success: false, message: 'Schema mismatch' });
|
||||
render(<SystemCheck />);
|
||||
|
||||
await waitFor(() => {
|
||||
// Check for pass icons (green checkmark)
|
||||
const passIcons = screen.getAllByTestId('check-circle-icon');
|
||||
// 6 checks should pass (API key, backend, PM2, DB Pool, Seed, Storage)
|
||||
expect(passIcons.length).toBeGreaterThanOrEqual(5); // At least 5, as PM2, DB Pool, Seed, Storage are dependent on backend
|
||||
|
||||
// Check for fail icon (red X)
|
||||
expect(screen.getByTestId('x-circle-icon')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle optional checks correctly', async () => {
|
||||
setViteApiKey('mock-api-key');
|
||||
// Mock an optional check to fail
|
||||
(apiClient.checkPm2Status as Mock).mockResolvedValueOnce({ success: false, message: 'PM2 not running (optional)' });
|
||||
render(<SystemCheck />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('PM2 not running (optional)')).toBeInTheDocument();
|
||||
// Ensure the '(optional)' text is present
|
||||
expect(screen.getByText('(optional)')).toBeInTheDocument();
|
||||
// The error icon for an optional check should be ExclamationTriangleIcon
|
||||
expect(screen.getByTestId('exclamation-triangle-icon')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should display elapsed time after checks complete', async () => {
|
||||
setViteApiKey('mock-api-key');
|
||||
render(<SystemCheck />);
|
||||
|
||||
await waitFor(() => {
|
||||
const elapsedTimeText = screen.getByText(/finished in \d+\.\d{2} seconds\./i);
|
||||
expect(elapsedTimeText).toBeInTheDocument();
|
||||
const match = elapsedTimeText.textContent?.match(/(\d+\.\d{2})/);
|
||||
expect(match).not.toBeNull();
|
||||
expect(parseFloat(match![1])).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
90
src/components/TopDeals.test.tsx
Normal file
90
src/components/TopDeals.test.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
// src/components/TopDeals.test.tsx
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { TopDeals } from './TopDeals';
|
||||
import type { FlyerItem } from '../types';
|
||||
|
||||
describe('TopDeals', () => {
|
||||
const mockFlyerItems: FlyerItem[] = [
|
||||
...[
|
||||
{ flyer_item_id: 1, item: 'Apples', price_display: '$1.00', price_in_cents: 100, quantity: '1 lb', category_name: 'Produce', flyer_id: 1, master_item_id: 1, unit_price: { value: 100, unit: 'lb' } },
|
||||
{ flyer_item_id: 2, item: 'Milk', price_display: '$2.50', price_in_cents: 250, quantity: '1L', category_name: 'Dairy', flyer_id: 1, master_item_id: 2, unit_price: { value: 250, unit: 'L' } },
|
||||
{ flyer_item_id: 3, item: 'Bread', price_display: '$2.00', price_in_cents: 200, quantity: '1 loaf', category_name: 'Bakery', flyer_id: 1, master_item_id: 3, unit_price: { value: 200, unit: 'count' } },
|
||||
{ flyer_item_id: 4, item: 'Eggs', price_display: '$3.00', price_in_cents: 300, quantity: '1 dozen', category_name: 'Dairy', flyer_id: 1, master_item_id: 4, unit_price: { value: 25, unit: 'count' } },
|
||||
{ flyer_item_id: 5, item: 'Cheese', price_display: '$4.00', price_in_cents: 400, quantity: '200g', category_name: 'Dairy', flyer_id: 1, master_item_id: 5, unit_price: { value: 200, unit: '100g' } },
|
||||
{ flyer_item_id: 6, item: 'Yogurt', price_display: '$1.50', price_in_cents: 150, quantity: '500g', category_name: 'Dairy', flyer_id: 1, master_item_id: 6, unit_price: { value: 30, unit: '100g' } },
|
||||
{ flyer_item_id: 7, item: 'Oranges', price_display: '$1.20', price_in_cents: 120, quantity: '1 lb', category_name: 'Produce', flyer_id: 1, master_item_id: 7, unit_price: { value: 120, unit: 'lb' } },
|
||||
{ flyer_item_id: 8, item: 'Cereal', price_display: '$3.50', price_in_cents: 350, quantity: '300g', category_name: 'Breakfast', flyer_id: 1, master_item_id: 8, unit_price: { value: 117, unit: '100g' } },
|
||||
{ flyer_item_id: 9, item: 'Coffee', price_display: '$5.00', price_in_cents: 500, quantity: '250g', category_name: 'Beverages', flyer_id: 1, master_item_id: 9, unit_price: { value: 200, unit: '100g' } },
|
||||
{ flyer_item_id: 10, item: 'Tea', price_display: '$2.20', price_in_cents: 220, quantity: '20 bags', category_name: 'Beverages', flyer_id: 1, master_item_id: 10, unit_price: { value: 11, unit: 'count' } },
|
||||
{ flyer_item_id: 11, item: 'Pasta', price_display: '$1.80', price_in_cents: 180, quantity: '500g', category_name: 'Pantry', flyer_id: 1, master_item_id: 11, unit_price: { value: 36, unit: '100g' } },
|
||||
{ flyer_item_id: 12, item: 'Water', price_display: '$0.99', price_in_cents: 99, quantity: '1L', category_name: 'Beverages', flyer_id: 1, master_item_id: 12, unit_price: { value: 99, unit: 'L' } },
|
||||
{ flyer_item_id: 13, item: 'Soda', price_display: '$0.75', price_in_cents: 75, quantity: '355ml', category_name: 'Beverages', flyer_id: 1, master_item_id: 13, unit_price: { value: 21, unit: '100ml' } },
|
||||
{ flyer_item_id: 14, item: 'Chips', price_display: '$2.10', price_in_cents: 210, quantity: '150g', category_name: 'Snacks', flyer_id: 1, master_item_id: 14, unit_price: { value: 140, unit: '100g' } },
|
||||
{ flyer_item_id: 15, item: 'Candy', price_display: '$0.50', price_in_cents: 50, quantity: '50g', category_name: 'Snacks', flyer_id: 1, master_item_id: 15, unit_price: { value: 100, unit: '100g' } },
|
||||
].map(item => ({
|
||||
...item,
|
||||
created_at: new Date().toISOString(),
|
||||
view_count: 0,
|
||||
click_count: 0,
|
||||
updated_at: new Date().toISOString(),
|
||||
}))
|
||||
];
|
||||
|
||||
it('should not render if the items array is empty', () => {
|
||||
const { container } = render(<TopDeals items={[]} />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('should not render if no items have price_in_cents', () => {
|
||||
const itemsWithoutPrices: FlyerItem[] = [
|
||||
{ flyer_item_id: 1, item: 'Free Sample', price_display: 'FREE', price_in_cents: null, quantity: '1', category_name: 'Other', flyer_id: 1, master_item_id: undefined, unit_price: null, created_at: '', view_count: 0, click_count: 0, updated_at: '' },
|
||||
{ flyer_item_id: 2, item: 'Info Brochure', price_display: '', price_in_cents: null, quantity: '', category_name: 'Other', flyer_id: 1, master_item_id: undefined, unit_price: null, created_at: '', view_count: 0, click_count: 0, updated_at: '' },
|
||||
];
|
||||
const { container } = render(<TopDeals items={itemsWithoutPrices} />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('should render the correct heading', () => {
|
||||
render(<TopDeals items={mockFlyerItems.slice(0, 5)} />);
|
||||
expect(screen.getByRole('heading', { name: /top 10 deals across all flyers/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display up to 10 items, sorted by price_in_cents ascending', () => {
|
||||
render(<TopDeals items={mockFlyerItems} />);
|
||||
|
||||
const listItems = screen.getAllByRole('listitem');
|
||||
expect(listItems).toHaveLength(10); // Should only show top 10
|
||||
|
||||
// Expected order of items based on price_in_cents:
|
||||
// Candy (50), Soda (75), Water (99), Apples (100), Oranges (120), Yogurt (150), Pasta (180), Bread (200), Tea (220), Milk (250)
|
||||
expect(listItems[0]).toHaveTextContent('Candy');
|
||||
expect(listItems[0]).toHaveTextContent('$0.50');
|
||||
expect(listItems[1]).toHaveTextContent('Soda');
|
||||
expect(listItems[1]).toHaveTextContent('$0.75');
|
||||
expect(listItems[2]).toHaveTextContent('Water');
|
||||
expect(listItems[2]).toHaveTextContent('$0.99');
|
||||
expect(listItems[3]).toHaveTextContent('Apples');
|
||||
expect(listItems[3]).toHaveTextContent('$1.00');
|
||||
expect(listItems[4]).toHaveTextContent('Oranges');
|
||||
expect(listItems[4]).toHaveTextContent('$1.20');
|
||||
expect(listItems[5]).toHaveTextContent('Yogurt');
|
||||
expect(listItems[5]).toHaveTextContent('$1.50');
|
||||
expect(listItems[6]).toHaveTextContent('Pasta');
|
||||
expect(listItems[6]).toHaveTextContent('$1.80');
|
||||
expect(listItems[7]).toHaveTextContent('Bread');
|
||||
expect(listItems[7]).toHaveTextContent('$2.00');
|
||||
expect(listItems[8]).toHaveTextContent('Tea');
|
||||
expect(listItems[8]).toHaveTextContent('$2.20');
|
||||
expect(listItems[9]).toHaveTextContent('Milk');
|
||||
expect(listItems[9]).toHaveTextContent('$2.50');
|
||||
});
|
||||
|
||||
it('should display item name, price_display, and quantity for each deal', () => {
|
||||
render(<TopDeals items={mockFlyerItems.slice(0, 1)} />); // Render just one item for simplicity
|
||||
expect(screen.getByText('Apples')).toBeInTheDocument();
|
||||
expect(screen.getByText('$1.00')).toBeInTheDocument();
|
||||
expect(screen.getByText('(Qty: 1 lb)')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
41
src/components/UnitSystemToggle.test.tsx
Normal file
41
src/components/UnitSystemToggle.test.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
// src/components/UnitSystemToggle.test.tsx
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { UnitSystemToggle } from './UnitSystemToggle';
|
||||
|
||||
describe('UnitSystemToggle', () => {
|
||||
const mockOnToggle = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render correctly for imperial system', () => {
|
||||
render(<UnitSystemToggle currentSystem="imperial" onToggle={mockOnToggle} />);
|
||||
|
||||
const checkbox = screen.getByRole('checkbox');
|
||||
expect(checkbox).toBeChecked();
|
||||
|
||||
// Check which label is visually prominent
|
||||
expect(screen.getByText('Imperial')).not.toHaveClass('text-gray-400');
|
||||
expect(screen.getByText('Metric')).toHaveClass('text-gray-400');
|
||||
});
|
||||
|
||||
it('should render correctly for metric system', () => {
|
||||
render(<UnitSystemToggle currentSystem="metric" onToggle={mockOnToggle} />);
|
||||
|
||||
const checkbox = screen.getByRole('checkbox');
|
||||
expect(checkbox).not.toBeChecked();
|
||||
|
||||
// Check which label is visually prominent
|
||||
expect(screen.getByText('Metric')).not.toHaveClass('text-gray-400');
|
||||
expect(screen.getByText('Imperial')).toHaveClass('text-gray-400');
|
||||
});
|
||||
|
||||
it('should call onToggle when the toggle is clicked', () => {
|
||||
render(<UnitSystemToggle currentSystem="metric" onToggle={mockOnToggle} />);
|
||||
fireEvent.click(screen.getByRole('checkbox'));
|
||||
expect(mockOnToggle).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
149
src/components/WatchedItemsList.test.tsx
Normal file
149
src/components/WatchedItemsList.test.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
// src/components/WatchedItemsList.test.tsx
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
||||
import { WatchedItemsList } from './WatchedItemsList';
|
||||
import type { MasterGroceryItem, User } from '../types';
|
||||
|
||||
// Mock the logger
|
||||
vi.mock('../services/logger', () => ({
|
||||
logger: {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockOnAddItem = vi.fn();
|
||||
const mockOnRemoveItem = vi.fn();
|
||||
const mockOnAddItemToList = vi.fn();
|
||||
|
||||
const mockUser: User = { user_id: 'user-123', email: 'test@example.com' };
|
||||
|
||||
const mockItems: MasterGroceryItem[] = [
|
||||
{ master_grocery_item_id: 1, name: 'Apples', category_id: 1, category_name: 'Produce', created_at: '' },
|
||||
{ master_grocery_item_id: 2, name: 'Milk', category_id: 2, category_name: 'Dairy', created_at: '' },
|
||||
{ master_grocery_item_id: 3, name: 'Bread', category_id: 3, category_name: 'Bakery', created_at: '' },
|
||||
];
|
||||
|
||||
const defaultProps = {
|
||||
items: mockItems,
|
||||
onAddItem: mockOnAddItem,
|
||||
onRemoveItem: mockOnRemoveItem,
|
||||
user: mockUser,
|
||||
activeListId: 1,
|
||||
onAddItemToList: mockOnAddItemToList,
|
||||
};
|
||||
|
||||
describe('WatchedItemsList', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockOnAddItem.mockResolvedValue(undefined);
|
||||
mockOnRemoveItem.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('should render a login message when user is not authenticated', () => {
|
||||
render(<WatchedItemsList {...defaultProps} user={null} />);
|
||||
expect(screen.getByText(/please log in to create and manage your personal watchlist/i)).toBeInTheDocument();
|
||||
expect(screen.queryByRole('form')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render the form and item list when user is authenticated', () => {
|
||||
render(<WatchedItemsList {...defaultProps} />);
|
||||
expect(screen.getByPlaceholderText(/add item/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole('combobox', { name: /filter by category/i })).toBeInTheDocument();
|
||||
expect(screen.getByText('Apples')).toBeInTheDocument();
|
||||
expect(screen.getByText('Milk')).toBeInTheDocument();
|
||||
expect(screen.getByText('Bread')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should allow adding a new item', async () => {
|
||||
render(<WatchedItemsList {...defaultProps} />);
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText(/add item/i), { target: { value: 'Cheese' } });
|
||||
fireEvent.change(screen.getByRole('combobox', { name: '' }), { target: { value: 'Dairy' } }); // Category select
|
||||
|
||||
fireEvent.submit(screen.getByRole('button', { name: 'Add' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnAddItem).toHaveBeenCalledWith('Cheese', 'Dairy');
|
||||
});
|
||||
|
||||
// Check if form resets
|
||||
expect(screen.getByPlaceholderText(/add item/i)).toHaveValue('');
|
||||
});
|
||||
|
||||
it('should show a loading spinner while adding an item', async () => {
|
||||
(mockOnAddItem as Mock).mockImplementation(() => new Promise(() => {})); // Never resolves
|
||||
render(<WatchedItemsList {...defaultProps} />);
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText(/add item/i), { target: { value: 'Cheese' } });
|
||||
fireEvent.change(screen.getByRole('combobox', { name: '' }), { target: { value: 'Dairy' } });
|
||||
|
||||
const addButton = screen.getByRole('button', { name: 'Add' });
|
||||
fireEvent.click(addButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(addButton.querySelector('svg.animate-spin')).toBeInTheDocument();
|
||||
expect(addButton).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow removing an item', async () => {
|
||||
render(<WatchedItemsList {...defaultProps} />);
|
||||
const removeButton = screen.getByRole('button', { name: /remove apples/i });
|
||||
fireEvent.click(removeButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnRemoveItem).toHaveBeenCalledWith(1); // ID for Apples is 1
|
||||
});
|
||||
});
|
||||
|
||||
it('should filter items by category', () => {
|
||||
render(<WatchedItemsList {...defaultProps} />);
|
||||
const categoryFilter = screen.getByRole('combobox', { name: /filter by category/i });
|
||||
|
||||
fireEvent.change(categoryFilter, { target: { value: 'Dairy' } });
|
||||
|
||||
expect(screen.getByText('Milk')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Apples')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Bread')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should sort items ascending and descending', () => {
|
||||
render(<WatchedItemsList {...defaultProps} />);
|
||||
const sortButton = screen.getByRole('button', { name: /sort items descending/i });
|
||||
|
||||
const itemsAsc = screen.getAllByRole('listitem');
|
||||
expect(itemsAsc[0]).toHaveTextContent('Apples');
|
||||
expect(itemsAsc[1]).toHaveTextContent('Bread');
|
||||
expect(itemsAsc[2]).toHaveTextContent('Milk');
|
||||
|
||||
// Click to sort descending
|
||||
fireEvent.click(sortButton);
|
||||
|
||||
const itemsDesc = screen.getAllByRole('listitem');
|
||||
expect(itemsDesc[0]).toHaveTextContent('Milk');
|
||||
expect(itemsDesc[1]).toHaveTextContent('Bread');
|
||||
expect(itemsDesc[2]).toHaveTextContent('Apples');
|
||||
});
|
||||
|
||||
it('should call onAddItemToList when plus icon is clicked', () => {
|
||||
render(<WatchedItemsList {...defaultProps} />);
|
||||
const addToListButton = screen.getByTitle('Add Apples to list');
|
||||
fireEvent.click(addToListButton);
|
||||
expect(mockOnAddItemToList).toHaveBeenCalledWith(1); // ID for Apples
|
||||
});
|
||||
|
||||
it('should disable the add to list button if activeListId is null', () => {
|
||||
render(<WatchedItemsList {...defaultProps} activeListId={null} />);
|
||||
const addToListButton = screen.getByTitle('Select a shopping list first');
|
||||
expect(addToListButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it('should display a message when the list is empty', () => {
|
||||
render(<WatchedItemsList {...defaultProps} items={[]} />);
|
||||
expect(screen.getByText(/your watchlist is empty/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
64
src/components/WhatsNewModal.test.tsx
Normal file
64
src/components/WhatsNewModal.test.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
// src/components/WhatsNewModal.test.tsx
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { WhatsNewModal } from './WhatsNewModal';
|
||||
|
||||
describe('WhatsNewModal', () => {
|
||||
const mockOnClose = vi.fn();
|
||||
const defaultProps = {
|
||||
isOpen: true,
|
||||
onClose: mockOnClose,
|
||||
version: '20240101-abcd',
|
||||
commitMessage: 'feat: Add exciting new feature',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should not render when isOpen is false', () => {
|
||||
const { container } = render(<WhatsNewModal {...defaultProps} isOpen={false} />);
|
||||
// The component returns null, so the container should be empty.
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('should render correctly when isOpen is true', () => {
|
||||
render(<WhatsNewModal {...defaultProps} />);
|
||||
|
||||
expect(screen.getByRole('heading', { name: /what's new/i })).toBeInTheDocument();
|
||||
expect(screen.getByText(`Version: ${defaultProps.version}`)).toBeInTheDocument();
|
||||
expect(screen.getByText(defaultProps.commitMessage)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /got it/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call onClose when the "Got it!" button is clicked', () => {
|
||||
render(<WhatsNewModal {...defaultProps} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: /got it/i }));
|
||||
expect(mockOnClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should call onClose when the close icon button is clicked', () => {
|
||||
render(<WhatsNewModal {...defaultProps} />);
|
||||
// The close button is an SVG icon inside a button, best queried by its aria-label.
|
||||
// Assuming the XCircleIcon doesn't have a specific label, we can find the button containing it.
|
||||
// Let's assume the button has a label for accessibility.
|
||||
const closeButton = screen.getByRole('button', { name: '' }); // Adjust if it has a label
|
||||
fireEvent.click(closeButton);
|
||||
expect(mockOnClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should call onClose when clicking on the overlay', () => {
|
||||
render(<WhatsNewModal {...defaultProps} />);
|
||||
// The overlay is the root div with the background color.
|
||||
const overlay = screen.getByRole('dialog').parentElement;
|
||||
fireEvent.click(overlay!);
|
||||
expect(mockOnClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should not call onClose when clicking inside the modal content', () => {
|
||||
render(<WhatsNewModal {...defaultProps} />);
|
||||
fireEvent.click(screen.getByText(defaultProps.commitMessage));
|
||||
expect(mockOnClose).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -45,8 +45,8 @@ describe('AdminStatsPage', () => {
|
||||
(apiClient.getApplicationStats as Mock).mockReturnValue(new Promise(() => {}));
|
||||
renderWithRouter();
|
||||
|
||||
// The LoadingSpinner component is expected to be present
|
||||
expect(screen.getByTestId('loading-spinner')).toBeInTheDocument();
|
||||
// The LoadingSpinner component is expected to be present. We find it by its accessible role.
|
||||
expect(screen.getByRole('status', { name: /loading/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: /application statistics/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@ describe('CorrectionsPage', () => {
|
||||
(apiClient.fetchCategories as Mock).mockReturnValue(new Promise(() => {}));
|
||||
renderWithRouter();
|
||||
|
||||
expect(screen.getByTestId('loading-spinner')).toBeInTheDocument();
|
||||
expect(screen.getByRole('status', { name: /loading/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: /user-submitted corrections/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
@@ -83,6 +83,19 @@ export const extractItemsFromReceiptImage = async (
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Defines the shape of a single flyer item as returned by the AI.
|
||||
* This type is intentionally loose to accommodate potential null/undefined values
|
||||
* from the AI before they are cleaned and normalized.
|
||||
*/
|
||||
type RawFlyerItem = {
|
||||
item: string;
|
||||
price_display: string | null | undefined;
|
||||
price_in_cents: number | null;
|
||||
quantity: string | null | undefined;
|
||||
category_name: string | null | undefined;
|
||||
master_item_id: number | null;
|
||||
};
|
||||
/**
|
||||
* SERVER-SIDE FUNCTION
|
||||
* This is the complete, secure implementation for extracting core data from flyer images.
|
||||
@@ -165,11 +178,11 @@ export const extractCoreDataFromFlyerImage = async (
|
||||
// Post-process items to ensure 'price_display', 'quantity', and 'category_name' are always strings
|
||||
// This prevents database 'not-null' constraint violations if the AI returns null for these fields.
|
||||
if (extractedData && Array.isArray(extractedData.items)) {
|
||||
extractedData.items = extractedData.items.map((item: any) => ({
|
||||
extractedData.items = extractedData.items.map((item: RawFlyerItem) => ({
|
||||
...item,
|
||||
price_display: item.price_display === null || item.price_display === undefined ? "" : String(item.price_display),
|
||||
quantity: item.quantity === null || item.quantity === undefined ? "" : String(item.quantity),
|
||||
category_name: item.category_name === null || item.category_name === undefined ? "Other/Miscellaneous" : String(item.category_name),
|
||||
category_name: item.category_name === null || item.category_name === undefined ? "Other/Miscellaneous" : String(item.category_name)
|
||||
}));
|
||||
}
|
||||
return extractedData;
|
||||
|
||||
@@ -298,6 +298,12 @@ export async function getActivityLog(limit: number, offset: number): Promise<Act
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines a type for JSON-compatible data structures, allowing for nested objects and arrays.
|
||||
* This provides a safer alternative to `any` for objects intended for JSON serialization.
|
||||
*/
|
||||
type JsonData = string | number | boolean | null | { [key: string]: JsonData } | JsonData[];
|
||||
|
||||
/**
|
||||
* Inserts a new entry into the activity log. This is the standardized function
|
||||
* to be used across the application for logging significant events.
|
||||
@@ -308,7 +314,7 @@ export async function logActivity(logData: {
|
||||
action: string;
|
||||
displayText: string;
|
||||
icon?: string | null;
|
||||
details?: Record<string, any> | null;
|
||||
details?: Record<string, JsonData> | null;
|
||||
}): Promise<void> {
|
||||
const { userId, action, displayText, icon, details } = logData;
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user