App.tsx refactor + even more unit tests
All checks were successful
Deploy to Web Server flyer-crawler.projectium.com / deploy (push) Successful in 8m28s

This commit is contained in:
2025-12-08 12:18:46 -08:00
parent e022a4a2cc
commit 0eda796fad
34 changed files with 1999 additions and 611 deletions

View File

@@ -1,44 +1,34 @@
// src/App.test.tsx
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, type Mocked } from 'vitest';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { MemoryRouter, Outlet } from 'react-router-dom';
import App from './App';
import * as apiClient from './services/apiClient';
import type { UserProfile } from './types';
// Mock useAuth to allow overriding the user state in tests
const mockUseAuth = vi.fn();
vi.mock('./hooks/useAuth', () => ({
useAuth: () => mockUseAuth(),
}));
// Mock child components to isolate the App component
vi.mock('./features/flyer/FlyerDisplay', () => ({ FlyerDisplay: () => <div data-testid="flyer-display-mock">Flyer Display</div> }));
vi.mock('./features/flyer/ExtractedDataTable', () => ({ ExtractedDataTable: () => <div data-testid="extracted-data-table-mock">Extracted Data Table</div> }));
vi.mock('./features/flyer/AnalysisPanel', () => ({ AnalysisPanel: () => <div data-testid="analysis-panel-mock">Analysis Panel</div> }));
vi.mock('./features/charts/PriceChart', () => ({ PriceChart: () => <div data-testid="price-chart-mock">Price Chart</div> }));
// Mock top-level components rendered by App's routes
vi.mock('./components/Header', () => ({ Header: () => <header data-testid="header-mock">Header</header> }));
vi.mock('./features/flyer/BulkImporter', () => ({ BulkImporter: () => <div data-testid="bulk-importer-mock">Bulk Importer</div> }));
vi.mock('./features/charts/PriceHistoryChart', () => ({ PriceHistoryChart: () => <div data-testid="price-history-chart-mock">Price History Chart</div> }));
vi.mock('./features/flyer/FlyerList', () => ({ FlyerList: () => <div data-testid="flyer-list-mock">Flyer List</div> }));
vi.mock('./features/flyer/ProcessingStatus', () => ({ ProcessingStatus: () => <div data-testid="processing-status-mock">Processing Status</div> }));
vi.mock('./features/flyer/BulkImportSummary', () => ({ BulkImportSummary: () => <div data-testid="bulk-import-summary-mock">Bulk Import Summary</div> }));
vi.mock('./pages/admin/components/ProfileManager', () => ({ ProfileManager: () => <div data-testid="profile-manager-mock">Profile Manager</div> }));
vi.mock('./features/shopping/ShoppingList', () => ({ ShoppingListComponent: () => <div data-testid="shopping-list-mock">Shopping List</div> }));
vi.mock('./features/voice-assistant/VoiceAssistant', () => ({ VoiceAssistant: () => <div data-testid="voice-assistant-mock">Voice Assistant</div> }));
vi.mock('./pages/admin/AdminPage', () => ({ AdminPage: () => <div data-testid="admin-page-mock">Admin Page</div> }));
// In react-router v6, wrapper routes must render an <Outlet /> for nested routes to appear.
// Our previous mock was trying to render `{children}`, which is incorrect for this pattern.
// This new mock correctly simulates the behavior of the actual AdminRoute component.
vi.mock('./components/AdminRoute', () => ({ AdminRoute: ({ children }: { children: React.ReactNode }) => <div data-testid="admin-route-mock">{children || <Outlet />}</div> }));
vi.mock('./pages/admin/CorrectionsPage', () => ({ CorrectionsPage: () => <div data-testid="corrections-page-mock">Corrections Page</div> }));
vi.mock('./pages/admin/ActivityLog', () => ({ ActivityLog: () => <div data-testid="activity-log-mock">Activity Log</div> }));
vi.mock('./features/shopping/WatchedItemsList', () => ({ WatchedItemsList: () => <div data-testid="watched-items-list-mock">Watched Items List</div> }));
vi.mock('./pages/admin/AdminStatsPage', () => ({ AdminStatsPage: () => <div data-testid="admin-stats-page-mock">Admin Stats Page</div> }));
vi.mock('./pages/ResetPasswordPage', () => ({ ResetPasswordPage: () => <div data-testid="reset-password-page-mock">Reset Password Page</div> }));
vi.mock('./pages/admin/components/AnonymousUserBanner', () => ({ AnonymousUserBanner: () => <div data-testid="anonymous-user-banner-mock">Anonymous User Banner</div> }));
vi.mock('./pages/VoiceLabPage', () => ({ VoiceLabPage: () => <div data-testid="voice-lab-page-mock">Voice Lab Page</div> }));
vi.mock('./pages/admin/VoiceLabPage', () => ({ VoiceLabPage: () => <div data-testid="voice-lab-page-mock">Voice Lab Page</div> })); // Corrected path
vi.mock('./components/WhatsNewModal', () => ({ WhatsNewModal: () => <div data-testid="whats-new-modal-mock">What's New Modal</div> }));
// Mock the new layout and page components
vi.mock('./layouts/MainLayout', () => ({ MainLayout: () => <div data-testid="main-layout-mock"><Outlet /></div> }));
vi.mock('./pages/HomePage', () => ({ HomePage: () => <div data-testid="home-page-mock">Home Page</div> }));
// Mock pdfjs-dist to prevent the "DOMMatrix is not defined" error in JSDOM.
// This must be done in any test file that imports App.tsx.
vi.mock('pdfjs-dist', () => ({
@@ -48,11 +38,18 @@ vi.mock('pdfjs-dist', () => ({
})),
}));
// By casting the apiClient to `Mocked<typeof apiClient>`, we get type-safe access
// to Vitest's mock functions like `mockResolvedValue`.
// The `Mocked` type is imported directly from 'vitest' to avoid the namespace
// collision that occurs when using `vi.Mocked` with an imported `vi` object.
const mockedApiClient = apiClient as Mocked<typeof apiClient>;
const mockedApiClient = vi.mocked(apiClient);
// Mock the useData hook as it's a dependency of App.tsx
const mockUseData = vi.fn();
vi.mock('./hooks/useData', () => ({
useData: () => mockUseData(),
}));
// Mock the useShoppingLists hook
vi.mock('./hooks/useShoppingLists', () => ({ useShoppingLists: vi.fn() }));
// Mock the useWatchedItems hook
vi.mock('./hooks/useWatchedItems', () => ({ useWatchedItems: vi.fn() }));
describe('App Component', () => {
beforeEach(() => {
@@ -61,11 +58,26 @@ describe('App Component', () => {
mockUseAuth.mockReturnValue({
user: null,
profile: null,
authStatus: 'Determining...',
authStatus: 'SIGNED_OUT',
isLoading: true,
login: vi.fn(),
logout: vi.fn(),
updateProfile: vi.fn(),
});
// Clear local storage to prevent auth state from leaking between tests.
// Default data state
mockUseData.mockReturnValue({
flyers: [],
masterItems: [],
watchedItems: [],
shoppingLists: [],
setWatchedItems: vi.fn(),
setShoppingLists: vi.fn(),
refetchFlyers: vi.fn(),
error: null,
isLoading: false,
});
// Clear local storage to prevent state from leaking between tests.
localStorage.clear();
// Default mocks for API calls
// Use mockImplementation to create a new Response object for each call,
@@ -76,7 +88,6 @@ describe('App Component', () => {
mockedApiClient.fetchMasterItems.mockImplementation(() => Promise.resolve(new Response(JSON.stringify([]))));
mockedApiClient.fetchWatchedItems.mockImplementation(() => Promise.resolve(new Response(JSON.stringify([]))));
mockedApiClient.fetchShoppingLists.mockImplementation(() => Promise.resolve(new Response(JSON.stringify([]))));
mockedApiClient.getAuthenticatedUserProfile.mockRejectedValue(new Error('Not authenticated'));
});
const renderApp = (initialEntries = ['/']) => {
@@ -88,42 +99,42 @@ describe('App Component', () => {
};
it('should render the main layout and header', async () => {
// Simulate the auth hook finishing its initial check
mockUseAuth.mockReturnValue({
user: null,
profile: null,
authStatus: 'SIGNED_OUT',
isLoading: false,
login: vi.fn(), logout: vi.fn(), updateProfile: vi.fn(),
});
renderApp();
await waitFor(() => {
expect(screen.getByTestId('header-mock')).toBeInTheDocument();
expect(screen.getByTestId('flyer-list-mock')).toBeInTheDocument();
// The BulkImporter is not rendered for anonymous users.
expect(screen.queryByTestId('bulk-importer-mock')).not.toBeInTheDocument();
// Check that the main layout and home page are rendered for the root path
expect(screen.getByTestId('main-layout-mock')).toBeInTheDocument();
expect(screen.getByTestId('home-page-mock')).toBeInTheDocument();
});
});
it('should render the BulkImporter for an admin user', async () => {
// FIX 4: Update the BulkImporter test case to simulate an admin user via useAuth mock
const mockAdminProfile = {
const mockAdminProfile: UserProfile = {
user_id: 'admin-id',
user: { user_id: 'admin-id', email: 'admin@example.com' },
role: 'admin',
full_name: 'Admin',
avatar_url: '',
points: 0,
};
// Force the auth hook to return an authenticated admin user
mockUseAuth.mockReturnValue({
user: mockAdminProfile.user,
profile: mockAdminProfile,
authStatus: 'AUTHENTICATED',
authStatus: 'AUTHENTICATED',
isLoading: false,
login: vi.fn(), logout: vi.fn(), updateProfile: vi.fn(),
});
// Also mock the API call just in case App uses it directly on mount
mockedApiClient.getAuthenticatedUserProfile.mockResolvedValue(new Response(JSON.stringify(mockAdminProfile)));
// --- FIX LEDGER ---
// 1. Expect `bulk-importer-mock`. Failed (Element not found).
// Reason: `AdminPage` is mocked, so its children (including BulkImporter) are not rendered.
// 2. Expect `admin-page-mock` but render default route `/`. Failed (Element not found).
// Reason: `renderApp()` defaults to `/`, so `AdminPage` at `/admin` is never rendered.
// 3. Current Strategy: Explicitly render `['/admin']`.
renderApp(['/admin']);
@@ -134,33 +145,44 @@ describe('App Component', () => {
});
it('should show a welcome message when no flyer is selected', async () => {
// Simulate the auth hook finishing its initial check
mockUseAuth.mockReturnValue({
user: null,
profile: null,
authStatus: 'SIGNED_OUT',
isLoading: false,
login: vi.fn(), logout: vi.fn(), updateProfile: vi.fn(),
});
renderApp();
await waitFor(() => {
expect(screen.getByText(/welcome to flyer crawler/i)).toBeInTheDocument();
// The welcome message is inside HomePage, which is now mocked. We check for the mock instead.
expect(screen.getByTestId('home-page-mock')).toBeInTheDocument();
});
});
it('should render the admin page on the /admin route', async () => {
// Mock a logged-in admin user
const mockAdminProfile = {
// The Profile type requires user_id at the top level, in addition
// to the nested user object.
const mockAdminProfile: UserProfile = {
user_id: 'admin-id',
user: { user_id: 'admin-id', email: 'admin@example.com' },
role: 'admin',
full_name: 'Admin',
avatar_url: '',
points: 0,
};
mockedApiClient.getAuthenticatedUserProfile.mockResolvedValue(new Response(JSON.stringify(mockAdminProfile)));
// The app's auth hook checks for a token before fetching the user profile.
// We need to mock it to allow the authentication flow to proceed.
localStorage.setItem('authToken', 'fake-admin-token');
// Directly set the auth state via the mocked hook
mockUseAuth.mockReturnValue({
user: mockAdminProfile.user,
profile: mockAdminProfile,
authStatus: 'AUTHENTICATED',
isLoading: false,
login: vi.fn(), logout: vi.fn(), updateProfile: vi.fn(),
});
renderApp(['/admin']);
// Use findByTestId to handle the asynchronous rendering of the page
// after the user profile has been fetched and the auth state has been updated.
expect(await screen.findByTestId('admin-page-mock')).toBeInTheDocument();
});

View File

@@ -1,50 +1,27 @@
// src/App.tsx
import React, { useState, useCallback, useEffect } from 'react';
import { Routes, Route, useParams, useNavigate } from 'react-router-dom';
import { Routes, Route, useParams, useLocation, Outlet } from 'react-router-dom';
import { Toaster } from 'react-hot-toast';
import { FlyerDisplay } from './features/flyer/FlyerDisplay';
import { ExtractedDataTable } from './features/flyer/ExtractedDataTable';
import { AnalysisPanel } from './features/flyer/AnalysisPanel';
import { PriceChart } from './features/charts/PriceChart';
import * as pdfjsLib from 'pdfjs-dist';
import { ErrorDisplay } from './components/ErrorDisplay';
import { Header } from './components/Header';
import { logger } from './services/logger';
import * as aiApiClient from './services/aiApiClient';
import type { FlyerItem, Flyer, MasterGroceryItem, DealItem, ProcessingStage, StageStatus, Profile, ShoppingList, ShoppingListItem, User, UserProfile } from './types';
import { BulkImporter } from './features/flyer/BulkImporter';
import { PriceHistoryChart } from './features/charts/PriceHistoryChart';
import type { Flyer, Profile, User, UserProfile } from './types';
import * as apiClient from './services/apiClient';
import { FlyerList } from './features/flyer/FlyerList';
import { recordProcessingTime, getAverageProcessingTime } from './utils/processingTimer';
import { ProfileManager } from './pages/admin/components/ProfileManager';
import { ShoppingListComponent } from './features/shopping/ShoppingList';
import { FlyerUploader } from './features/flyer/FlyerUploader';
import { VoiceAssistant } from './features/voice-assistant/VoiceAssistant';
import { AdminPage } from './pages/admin/AdminPage';
import { AdminRoute } from './components/AdminRoute';
import { CorrectionsPage } from './pages/admin/CorrectionsPage';
import { ActivityLog, ActivityLogClickHandler } from './pages/admin/ActivityLog';
import { WatchedItemsList } from './features/shopping/WatchedItemsList';
import { AdminStatsPage } from './pages/admin/AdminStatsPage';
import { ResetPasswordPage } from './pages/ResetPasswordPage';
import { AnonymousUserBanner } from './pages/admin/components/AnonymousUserBanner';
import { VoiceLabPage } from './pages/VoiceLabPage';
import { WhatsNewModal } from './components/WhatsNewModal';
import { FlyerCorrectionTool } from './components/FlyerCorrectionTool';
import { QuestionMarkCircleIcon } from './components/icons/QuestionMarkCircleIcon';
import Leaderboard from './components/Leaderboard';
/**
* Defines the possible authentication states for a user session.
* - `SIGNED_OUT`: No user is active. The session is fresh or the user has explicitly signed out.
* - `ANONYMOUS`: The user has started interacting with the app (e.g., by uploading a flyer)
* but has not logged in or created an account. This state allows for temporary,
* session-based data access without full authentication. This is a planned feature.
* - `AUTHENTICATED`: The user has successfully logged in, and their identity is confirmed
* via a valid JWT.
*/
type AuthStatus = 'SIGNED_OUT' | 'ANONYMOUS' | 'AUTHENTICATED';
import { useAuth } from './hooks/useAuth';
import { useData } from './hooks/useData';
import { MainLayout } from './layouts/MainLayout';
import { HomePage } from './pages/HomePage';
// pdf.js worker configuration
// This is crucial for allowing pdf.js to process PDFs in a separate thread, preventing the UI from freezing.
@@ -53,59 +30,18 @@ type AuthStatus = 'SIGNED_OUT' | 'ANONYMOUS' | 'AUTHENTICATED';
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.mjs', import.meta.url).toString();
function App() {
const [user, setUser] = useState<User | null>(null); // Moved user state to the top
// --- Data Fetching ---
const [flyers, setFlyers] = useState<Flyer[]>([]);
const [masterItems, setMasterItems] = useState<MasterGroceryItem[]>([]);
const [watchedItems, setWatchedItems] = useState<MasterGroceryItem[]>([]);
const [shoppingLists, setShoppingLists] = useState<ShoppingList[]>([]); // This was a duplicate, fixed.
const refetchFlyers = useCallback(async () => {
try {
const flyersRes = await apiClient.fetchFlyers();
setFlyers(await flyersRes.json());
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
}
}, []);
useEffect(() => {
const fetchData = async () => {
const [masterItemsRes, watchedItemsRes, shoppingListsRes] = await Promise.all([
apiClient.fetchMasterItems(),
user ? apiClient.fetchWatchedItems() : Promise.resolve(new Response(JSON.stringify([]))),
user ? apiClient.fetchShoppingLists() : Promise.resolve(new Response(JSON.stringify([]))),
]);
setMasterItems(await masterItemsRes.json());
setWatchedItems(await watchedItemsRes.json());
setShoppingLists(await shoppingListsRes.json());
};
refetchFlyers(); // Initial fetch
fetchData();
}, [user, refetchFlyers]);
const { user, profile, authStatus, login, logout, updateProfile } = useAuth();
const {
flyers,
} = useData();
const location = useLocation();
const params = useParams<{ flyerId?: string }>();
const [selectedFlyer, setSelectedFlyer] = useState<Flyer | null>(null);
const [flyerItems, setFlyerItems] = useState<FlyerItem[]>([]);
// Local state for watched items and shopping lists to allow for optimistic updates
const [localWatchedItems, setLocalWatchedItems] = useState<MasterGroceryItem[]>([]);
const [localShoppingLists, setLocalShoppingLists] = useState<ShoppingList[]>([]);
const [activeDeals, setActiveDeals] = useState<DealItem[]>([]);
const [activeDealsLoading, setActiveDealsLoading] = useState(false);
const [totalActiveItems, setTotalActiveItems] = useState(0);
const [error, setError] = useState<string | null>(null);
const [importSummary, setImportSummary] = useState<{
processed: string[];
skipped: string[];
errors: { fileName: string; message: string }[];
} | null>(null);
const [isDarkMode, setIsDarkMode] = useState(false);
const [unitSystem, setUnitSystem] = useState<'metric' | 'imperial'>('imperial');
const [profile, setProfile] = useState<UserProfile | null>(null);
const [authStatus, setAuthStatus] = useState<AuthStatus>('SIGNED_OUT');
const [isProfileManagerOpen, setIsProfileManagerOpen] = useState(false); // This will now control the login modal as well
const [isWhatsNewOpen, setIsWhatsNewOpen] = useState(false);
const [isVoiceAssistantOpen, setIsVoiceAssistantOpen] = useState(false);
@@ -130,26 +66,11 @@ function App() {
// When the profile is updated, the API returns a `Profile` object.
// We need to merge it with the existing `user` object to maintain
// the `UserProfile` type in our state.
if (user) {
const updatedUserProfile: UserProfile = { ...updatedProfileData, user };
setProfile(updatedUserProfile);
}
updateProfile(updatedProfileData);
};
const [estimatedTime, setEstimatedTime] = useState(0);
const [activeListId, setActiveListId] = useState<number | null>(null);
// --- State Synchronization and Error Handling ---
useEffect(() => {
setLocalWatchedItems(watchedItems);
}, [watchedItems]);
useEffect(() => {
setLocalShoppingLists(shoppingLists);
}, [shoppingLists]);
// Effect to set initial theme based on user profile, local storage, or system preference
useEffect(() => {
if (profile && profile.preferences?.darkMode !== undefined) {
@@ -181,83 +102,29 @@ function App() {
// This is the login handler that will be passed to the ProfileManager component.
const handleLoginSuccess = async (loggedInUser: User, token: string) => {
setError(null);
// Immediately store the token so subsequent API calls in this function are authenticated.
localStorage.setItem('authToken', token);
try {
// Fetch all essential user data *before* setting the final authenticated state.
// This ensures the app doesn't enter an inconsistent state if one of these calls fails.
const [profileResponse, watchedResponse] = await Promise.all([
apiClient.getAuthenticatedUserProfile(),
apiClient.fetchWatchedItems(), // We still fetch here to get immediate data after login
]);
const userProfile = await profileResponse.json();
const watchedData = await watchedResponse.json();
// Now that all data is successfully fetched, update the application state.
setUser(loggedInUser); // Set user first
setProfile(userProfile);
setAuthStatus('AUTHENTICATED');
setLocalWatchedItems(watchedData);
// The fetchShoppingLists function will be triggered by the useEffect below
// now that the user state has been set.
logger.info('Login and data fetch successful', { user: loggedInUser });
await login(loggedInUser, token);
// After successful login, fetch user-specific data
// The useData hook will automatically refetch user data when `user` changes.
// We can remove the explicit fetch here.
} catch (e) {
const errorMessage = e instanceof Error ? e.message : String(e);
logger.error('Failed to fetch user data after login. Rolling back.', { error: errorMessage });
setError(`Login succeeded, but failed to fetch your data: ${errorMessage}`);
handleSignOut(); // Log the user out to prevent an inconsistent state.
setError(errorMessage);
}
};
// Effect to check for an existing token on initial app load.
useEffect(() => {
const checkAuthToken = async () => {
const token = localStorage.getItem('authToken');
if (token) {
logger.info('Found auth token in local storage. Validating...');
try {
const response = await apiClient.getAuthenticatedUserProfile();
const userProfile = await response.json();
// The user object is nested within the UserProfile object.
setUser(userProfile.user);
setProfile(userProfile);
setAuthStatus('AUTHENTICATED');
logger.info('Token validated successfully.', { user: userProfile.user });
} catch (e) {
logger.warn('Auth token validation failed. Clearing token.', { error: e });
localStorage.removeItem('authToken');
setUser(null);
setAuthStatus('SIGNED_OUT');
}
} else {
logger.info('No auth token found. User is signed out.');
setAuthStatus('SIGNED_OUT');
}
};
checkAuthToken();
}, []); // Runs only once on mount. Intentionally empty.
// Effect to handle the token from Google OAuth redirect
useEffect(() => {
const urlParams = new URLSearchParams(window.location.search);
const urlParams = new URLSearchParams(location.search);
const googleToken = urlParams.get('googleAuthToken');
if (googleToken) {
logger.info('Received Google Auth token from URL. Authenticating...');
// The token is already a valid access token from our server.
// We can use it to fetch the user profile and complete the login flow.
localStorage.setItem('authToken', googleToken);
apiClient.getAuthenticatedUserProfile().then(response => response.json())
.then((userProfile: UserProfile) => {
handleLoginSuccess(userProfile.user, googleToken);
})
// The login flow is now handled by the useAuth hook. We just need to trigger it.
// We can't know the user object yet, so we'll let the login function fetch it.
// A dummy user object is passed, the real one will be set inside login().
login({ user_id: '', email: '' }, googleToken)
.catch(err => logger.error('Failed to log in with Google token', { error: err }));
// Clean the token from the URL
window.history.replaceState({}, document.title, "/");
}
@@ -265,13 +132,8 @@ function App() {
const githubToken = urlParams.get('githubAuthToken');
if (githubToken) {
logger.info('Received GitHub Auth token from URL. Authenticating...');
// The token is already a valid access token from our server.
// We can use it to fetch the user profile and complete the login flow.
localStorage.setItem('authToken', githubToken);
apiClient.getAuthenticatedUserProfile().then(response => response.json()) // This returns a UserProfile
.then((userProfile: UserProfile) => {
handleLoginSuccess(userProfile.user, githubToken);
})
// Same logic as for Google
login({ user_id: '', email: '' }, githubToken)
.catch(err => {
logger.error('Failed to log in with GitHub token', { error: err });
// Optionally, redirect to a page with an error message
@@ -281,27 +143,12 @@ function App() {
// Clean the token from the URL
window.history.replaceState({}, document.title, "/");
}
}, [handleLoginSuccess]);
}, [login, location.search]);
const resetState = useCallback(() => {
setSelectedFlyer(null);
setFlyerItems([]);
}, []);
const handleFlyerSelect = useCallback(async (flyer: Flyer) => {
setSelectedFlyer(flyer);
setError(null);
setFlyerItems([]); // Clear previous items
try {
const response = await apiClient.fetchFlyerItems(flyer.flyer_id);
const items = await response.json();
setFlyerItems(items);
} catch (e) {
const errorMessage = e instanceof Error ? e.message : String(e);
setError(errorMessage);
}
}, []);
useEffect(() => {
@@ -312,8 +159,7 @@ function App() {
// New effect to handle routing to a specific flyer ID from the URL
useEffect(() => {
const urlParams = new URLSearchParams(window.location.search);
const flyerIdFromUrl = urlParams.get('flyerId'); // Or parse from path if using /flyers/:id
const flyerIdFromUrl = params.flyerId;
if (flyerIdFromUrl && flyers.length > 0) {
const flyerId = parseInt(flyerIdFromUrl, 10);
@@ -321,252 +167,8 @@ function App() {
if (flyerToSelect && flyerToSelect.flyer_id !== selectedFlyer?.flyer_id) {
handleFlyerSelect(flyerToSelect);
}
}
}, [flyers, handleFlyerSelect, selectedFlyer]);
useEffect(() => {
const findActiveDeals = async () => {
if (flyers.length === 0 || localWatchedItems.length === 0) {
setActiveDeals([]);
return;
}
setActiveDealsLoading(true);
try {
const today = new Date();
today.setHours(0, 0, 0, 0);
const validFlyers = flyers.filter((flyer) => {
if (!flyer.valid_from || !flyer.valid_to) return false;
try {
const from = new Date(`${flyer.valid_from}T00:00:00`);
const to = new Date(`${flyer.valid_to}T00:00:00`);
return today >= from && today <= to;
} catch (e) {
logger.error("Error parsing flyer date", { error: e });
return false;
}
});
if (validFlyers.length === 0) {
setActiveDeals([]);
return;
}
const validFlyerIds = validFlyers.map(f => f.flyer_id);
const response = await apiClient.fetchFlyerItemsForFlyers(validFlyerIds);
const allItems: FlyerItem[] = await response.json();
const watchedItemIds = new Set(localWatchedItems.map((item: MasterGroceryItem) => item.master_grocery_item_id));
const dealItemsRaw = allItems.filter(item =>
item.master_item_id && watchedItemIds.has(item.master_item_id)
); // This seems correct as it's comparing with master_item_id
const flyerIdToStoreName = new Map(validFlyers.map((f: Flyer) => [f.flyer_id, f.store?.name || 'Unknown Store']));
const deals: DealItem[] = dealItemsRaw.map(item => ({
item: item.item,
price_display: item.price_display,
price_in_cents: item.price_in_cents,
quantity: item.quantity,
storeName: (flyerIdToStoreName.get(item.flyer_id!) || 'Unknown Store') as string,
master_item_name: item.master_item_name,
unit_price: item.unit_price,
}));
setActiveDeals(deals);
} catch (e) {
const errorMessage = e instanceof Error ? e.message : String(e);
setError(`Could not fetch active deals: ${errorMessage}`);
} finally {
setActiveDealsLoading(false);
}
};
findActiveDeals();
}, [flyers, localWatchedItems]);
useEffect(() => {
const calculateTotalActiveItems = async () => {
if (flyers.length === 0) {
setTotalActiveItems(0);
return;
}
try {
const today = new Date();
today.setHours(0, 0, 0, 0);
const validFlyers = flyers.filter((flyer) => {
if (!flyer.valid_from || !flyer.valid_to) return false;
try {
const from = new Date(`${flyer.valid_from}T00:00:00`);
const to = new Date(`${flyer.valid_to}T00:00:00`);
return today >= from && today <= to;
} catch (e) {
logger.error("Error parsing flyer date", { error: e });
return false;
}
});
if (validFlyers.length === 0) {
setTotalActiveItems(0);
return;
}
const validFlyerIds = validFlyers.map(f => f.flyer_id);
const response = await apiClient.countFlyerItemsForFlyers(validFlyerIds);
const { count: totalCount } = await response.json();
setTotalActiveItems(totalCount);
} catch (e) {
const errorMessage = e instanceof Error ? e.message : String(e);
logger.error("Failed to calculate total active items:", { error: errorMessage });
setTotalActiveItems(0);
}
};
calculateTotalActiveItems();
}, [flyers]);
const handleAddWatchedItem = useCallback(async (itemName: string, category: string) => {
if (!user) return;
try {
const updatedOrNewItem = await (await apiClient.addWatchedItem(itemName, category)).json();
setLocalWatchedItems((prevItems: MasterGroceryItem[]) => {
// Check if the item already exists in the state by its correct ID property.
const itemExists = prevItems.some((item: MasterGroceryItem) => item.master_grocery_item_id === updatedOrNewItem.master_grocery_item_id);
if (!itemExists) {
const newItems = [...prevItems, updatedOrNewItem]; // This was correct, but the check above was wrong.
return newItems.sort((a,b) => a.name.localeCompare(b.name));
}
return prevItems; // Item already existed in list
});
} catch (e) {
const errorMessage = e instanceof Error ? e.message : String(e);
setError(`Could not add watched item: ${errorMessage}`);
// Re-fetch to sync state on error
}
}, [user]);
const handleRemoveWatchedItem = useCallback(async (masterItemId: number) => {
if (!user) return;
try {
const response = await apiClient.removeWatchedItem(masterItemId); // API call is correct
if (!response.ok) throw new Error('Failed to remove item');
setLocalWatchedItems(prevItems => prevItems.filter((item: MasterGroceryItem) => item.master_grocery_item_id !== masterItemId)); // State update must use correct property
} catch (e) {
const errorMessage = e instanceof Error ? e.message : String(e);
setError(`Could not remove watched item: ${errorMessage}`);
}
}, [user]);
// --- Shopping List Handlers ---
const handleCreateList = useCallback(async (name: string) => {
if (!user) return;
try {
const response = await apiClient.createShoppingList(name);
const newList = await response.json();
setShoppingLists(prev => [...prev, newList]);
setActiveListId(newList.shopping_list_id);
} catch (e) {
const errorMessage = e instanceof Error ? e.message : String(e);
setError(`Could not create list: ${errorMessage}`);
}
}, [user]); // Changed dependency from `session` to `user`
const handleDeleteList = useCallback(async (listId: number) => {
if (!user) return;
try {
const response = await apiClient.deleteShoppingList(listId);
if (!response.ok) throw new Error('Failed to delete list');
const newLists = localShoppingLists.filter(l => l.shopping_list_id !== listId);
setLocalShoppingLists(newLists);
if (activeListId === listId) {
setActiveListId(newLists.length > 0 ? newLists[0].shopping_list_id : null);
}
} catch (e) {
const errorMessage = e instanceof Error ? e.message : String(e);
setError(`Could not delete list: ${errorMessage}`);
}
}, [user, localShoppingLists, activeListId]);
const handleAddShoppingListItem = useCallback(async (listId: number, item: { masterItemId?: number, customItemName?: string }) => {
if (!user) return;
try {
const response = await apiClient.addShoppingListItem(listId, item);
const newItem = await response.json();
setShoppingLists(prevLists => prevLists.map(list => {
if (list.shopping_list_id === listId) {
// Avoid adding duplicates to the state if it's already there
// Check if the item already exists in the list by its correct ID property.
const itemExists = list.items.some(i => i.shopping_list_item_id === newItem.shopping_list_item_id);
if (itemExists) return list;
return { ...list, items: [...list.items, newItem] };
}
return list;
}));
} catch (e) {
const errorMessage = e instanceof Error ? e.message : String(e);
setError(`Could not add item to list: ${errorMessage}`);
}
}, [user, activeListId]); // Added activeListId to dependencies
const handleUpdateShoppingListItem = useCallback(async (itemId: number, updates: Partial<ShoppingListItem>) => {
if (!user || !activeListId) return;
try {
const response = await apiClient.updateShoppingListItem(itemId, updates);
const updatedItem = await response.json();
setShoppingLists(prevLists => prevLists.map(list => {
if (list.shopping_list_id === activeListId) {
return { ...list, items: list.items.map(i => i.shopping_list_item_id === itemId ? updatedItem : i) };
}
return list;
}));
} catch (e) {
const errorMessage = e instanceof Error ? e.message : String(e);
setError(`Could not update list item: ${errorMessage}`);
}
}, [user, activeListId]); // Changed dependency from `session` to `user`
const handleRemoveShoppingListItem = useCallback(async (itemId: number) => {
if (!user || !activeListId) return;
try {
const response = await apiClient.removeShoppingListItem(itemId);
if (!response.ok) throw new Error('Failed to remove item');
setLocalShoppingLists(prevLists => prevLists.map(list => {
if (list.shopping_list_id === activeListId) {
return { ...list, items: list.items.filter(i => i.shopping_list_item_id !== itemId) };
}
return list;
}));
} catch (e) {
const errorMessage = e instanceof Error ? e.message : String(e);
setError(`Could not remove list item: ${errorMessage}`);
}
}, [user, activeListId]); // Changed dependency from `session` to `user`
const handleSignOut = () => {
localStorage.removeItem('authToken'); // Remove the JWT token
setUser(null); // Clear the user state
setProfile(null); // Clear the profile state
setAuthStatus('SIGNED_OUT'); // Update auth status
};
const handleActivityLogClick: ActivityLogClickHandler = (log) => {
// Thanks to the discriminated union, if the action is 'list_shared', TypeScript knows 'details.shopping_list_id' is a number.
if (log.action === 'list_shared') {
const listId = log.details.shopping_list_id;
if (localShoppingLists.some(list => list.shopping_list_id === listId)) {
setActiveListId(listId);
}
}
// Future functionality for other clickable log types can be added here.
// For example, clicking a recipe could open a recipe detail modal.
};
const hasData = flyerItems.length > 0;
}, [flyers, handleFlyerSelect, selectedFlyer, params.flyerId]);
// Read the application version injected at build time.
// This will only be available in the production build, not during local development.
@@ -607,8 +209,8 @@ function App() {
authStatus={authStatus}
user={user}
onOpenProfile={() => setIsProfileManagerOpen(true)}
onOpenVoiceAssistant={() => setIsVoiceAssistantOpen(true)}
onSignOut={handleSignOut}
onOpenVoiceAssistant={() => setIsVoiceAssistantOpen(true)}
onSignOut={logout}
/>
{/* The ProfileManager is now always available to be opened, handling both login and profile management. */}
@@ -621,7 +223,7 @@ function App() {
profile={profile}
onProfileUpdate={handleProfileUpdate}
onLoginSuccess={handleLoginSuccess}
onSignOut={handleSignOut} // Pass the signOut handler
onSignOut={logout} // Pass the signOut handler
/>
)}
{user && (
@@ -651,97 +253,13 @@ function App() {
)}
<Routes>
<Route path="/flyers/:flyerId" element={<HomePage />} />
<Route path="/" element={
<main className="max-w-screen-2xl mx-auto py-4 px-2.5 sm:py-6 lg:py-8">
{/* This banner will only appear for users who have interacted with the app but are not logged in. */}
{authStatus === 'ANONYMOUS' && (
<div className="max-w-5xl mx-auto mb-6 px-4 lg:px-0">
<AnonymousUserBanner onOpenProfile={() => setIsProfileManagerOpen(true)} />
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8 items-start">
<div className="lg:col-span-1 flex flex-col space-y-6">
<FlyerList flyers={flyers} onFlyerSelect={handleFlyerSelect} selectedFlyerId={selectedFlyer?.flyer_id || null} profile={profile} />
<FlyerUploader onProcessingComplete={refetchFlyers} />
</div>
<div className="lg:col-span-2 flex flex-col space-y-6">
{error && <ErrorDisplay message={error} />} {/* This was a duplicate, fixed. */}
{selectedFlyer ? (
<>
<FlyerDisplay
imageUrl={selectedFlyer.image_url}
store={selectedFlyer.store}
validFrom={selectedFlyer.valid_from}
validTo={selectedFlyer.valid_to}
storeAddress={selectedFlyer.store_address}
onOpenCorrectionTool={() => setIsCorrectionToolOpen(true)}
/>
{hasData && (
<>
<ExtractedDataTable
items={flyerItems}
totalActiveItems={totalActiveItems}
watchedItems={localWatchedItems}
masterItems={masterItems}
unitSystem={unitSystem}
user={user}
onAddItem={handleAddWatchedItem}
shoppingLists={localShoppingLists}
activeListId={activeListId}
onAddItemToList={(masterItemId: number) => handleAddShoppingListItem(activeListId!, { masterItemId })} />
<AnalysisPanel flyerItems={flyerItems} store={selectedFlyer.store} />
</>
)}
</>
) : (
<div className="text-center p-8 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 h-full flex flex-col justify-center min-h-[400px]">
<h2 className="text-xl font-semibold text-gray-700 dark:text-gray-200">Welcome to Flyer Crawler!</h2>
<p className="mt-2 text-gray-500 dark:text-gray-400">Upload a new grocery flyer to begin, or select a previously processed flyer from the list on the left.</p>
</div>
)}
</div>
<div className="lg:col-span-1 flex-col space-y-6">
{(
<>
<ShoppingListComponent
user={user}
lists={localShoppingLists}
activeListId={activeListId}
onSelectList={setActiveListId}
onCreateList={handleCreateList}
onDeleteList={handleDeleteList} // This was a duplicate, fixed.
onAddItem={(item: { masterItemId?: number; customItemName?: string }) => handleAddShoppingListItem(activeListId!, item)}
onUpdateItem={handleUpdateShoppingListItem}
onRemoveItem={handleRemoveShoppingListItem}
/>
<WatchedItemsList
items={localWatchedItems}
onAddItem={handleAddWatchedItem}
onRemoveItem={handleRemoveWatchedItem}
user={user}
activeListId={activeListId}
onAddItemToList={(masterItemId) => handleAddShoppingListItem(activeListId!, { masterItemId })}
/>
<PriceChart
deals={activeDeals}
isLoading={activeDealsLoading}
unitSystem={unitSystem}
user={user}
/>
<PriceHistoryChart watchedItems={localWatchedItems} />
<Leaderboard />
<ActivityLog user={user} onLogClick={handleActivityLogClick} />
</>
)}
</div>
</div>
</main>
} />
{/* Layout Route for main application view */}
<Route element={<MainLayout onFlyerSelect={handleFlyerSelect} selectedFlyerId={selectedFlyer?.flyer_id || null} onOpenProfile={() => setIsProfileManagerOpen(true)} />}>
<Route index element={<HomePage selectedFlyer={selectedFlyer} flyerItems={[]} onOpenCorrectionTool={() => setIsCorrectionToolOpen(true)} />} />
<Route path="/flyers/:flyerId" element={<HomePage selectedFlyer={selectedFlyer} flyerItems={[]} onOpenCorrectionTool={() => setIsCorrectionToolOpen(true)} />} />
</Route>
{/* Admin Routes */}
<Route element={<AdminRoute profile={profile} />}>
<Route path="/admin" element={<AdminPage />} />
<Route path="/admin/corrections" element={<CorrectionsPage />} />
@@ -749,6 +267,8 @@ function App() {
<Route path="/admin/voice-lab" element={<VoiceLabPage />} />
</Route>
<Route path="/reset-password/:token" element={<ResetPasswordPage />} />
{/* Add other top-level routes here if needed */}
</Routes>
{/* Display the build version number at the bottom-left of the screen */}
@@ -772,30 +292,4 @@ function App() {
);
}
/**
* A wrapper component to handle the logic for the /flyers/:flyerId route.
* It extracts the flyerId from the URL and triggers the selection in the parent App component.
*/
const HomePage: React.FC = () => {
const { flyerId } = useParams<{ flyerId: string }>();
const navigate = useNavigate();
useEffect(() => {
// This component's purpose is to set the selected flyer based on the URL.
// The actual rendering is handled by the main App component's state.
// After mounting, we can navigate back to the root path, as the selection
// will have been triggered by the main App's useEffect hook that watches the URL.
// This is a common pattern for using URL params to drive state in a parent component.
if (flyerId) {
// The main App component will see this URL and select the flyer.
// We can then navigate to the root to clean up the URL, while the selection remains.
// A small timeout can ensure the parent component has time to react.
setTimeout(() => navigate('/', { replace: true }), 100);
}
}, [flyerId, navigate]);
// This component doesn't render anything itself; it's just a controller.
return null;
};
export default App;

View File

@@ -0,0 +1,88 @@
// src/components/FlyerCountDisplay.test.tsx
import React from 'react';
import { render, screen } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { FlyerCountDisplay } from './FlyerCountDisplay';
import { useData } from '../hooks/useData';
import type { DataContextType } from '../hooks/useData';
import type { Flyer } from '../types';
// Mock the useData hook. This is the key to testing components that consume it.
// We tell Vitest that any time a component calls `useData`, it should call our mock function instead.
vi.mock('../hooks/useData');
// We cast the mock to the correct type to get full type-safety and autocompletion in our tests.
const mockedUseData = vi.mocked(useData);
describe('FlyerCountDisplay', () => {
// Define a base state for the mock. This represents the default return value of our hook.
// We can override this in specific tests.
const baseMockData: DataContextType = {
flyers: [],
masterItems: [],
watchedItems: [],
shoppingLists: [],
setWatchedItems: vi.fn(),
setShoppingLists: vi.fn(),
refetchFlyers: vi.fn().mockResolvedValue(undefined),
isLoading: false,
error: null,
};
beforeEach(() => {
// Reset mocks before each test to ensure they are isolated.
vi.clearAllMocks();
// Set a default return value for the hook.
mockedUseData.mockReturnValue(baseMockData);
});
it('should render a loading state when isLoading is true', () => {
// Arrange: For this specific test, override the mock to return a loading state.
mockedUseData.mockReturnValue({
...baseMockData,
isLoading: true,
});
// Act: Render the component.
render(<FlyerCountDisplay />);
// Assert: Check that the loading spinner is visible.
expect(screen.getByTestId('loading-spinner')).toBeInTheDocument();
expect(screen.getByText('Loading...')).toBeInTheDocument();
});
it('should render an error message when an error is present', () => {
// Arrange: Override the mock to return an error state.
const errorMessage = 'Failed to fetch data';
mockedUseData.mockReturnValue({
...baseMockData,
error: errorMessage,
});
// Act
render(<FlyerCountDisplay />);
// Assert: Check that the error message is displayed.
expect(screen.getByRole('alert')).toHaveTextContent(`Error: ${errorMessage}`);
});
it('should render the flyer count when data is successfully loaded', () => {
// Arrange: Override the mock to return a successful state with some data.
const mockFlyers: Flyer[] = [
{ flyer_id: 1, file_name: 'flyer1.pdf', image_url: '', item_count: 10, created_at: '' },
{ flyer_id: 2, file_name: 'flyer2.pdf', image_url: '', item_count: 20, created_at: '' },
];
mockedUseData.mockReturnValue({
...baseMockData,
flyers: mockFlyers,
});
// Act
render(<FlyerCountDisplay />);
// Assert: Check that the correct count is displayed.
const countDisplay = screen.getByTestId('flyer-count');
expect(countDisplay).toBeInTheDocument();
expect(countDisplay).toHaveTextContent('Number of flyers: 2');
});
});

View File

@@ -0,0 +1,21 @@
// src/components/FlyerCountDisplay.tsx
import React from 'react';
import { useData } from '../hooks/useData';
/**
* A simple component that displays the number of flyers available.
* It demonstrates consuming the useData hook for its state.
*/
export const FlyerCountDisplay: React.FC = () => {
const { flyers, isLoading, error } = useData();
if (isLoading) {
return <div data-testid="loading-spinner">Loading...</div>;
}
if (error) {
return <div data-testid="error-message" role="alert">Error: {error}</div>;
}
return <div data-testid="flyer-count">Number of flyers: {flyers.length}</div>;
};

View File

@@ -6,9 +6,9 @@ import { Cog8ToothIcon } from './icons/Cog8ToothIcon';
import { MicrophoneIcon } from './icons/MicrophoneIcon';
import { Link } from 'react-router-dom';
import { ShieldCheckIcon } from './icons/ShieldCheckIcon';
import { Profile, User } from '../types';
import type { Profile, User } from '../types';
import type { AuthStatus } from '../hooks/useAuth';
type AuthStatus = 'SIGNED_OUT' | 'ANONYMOUS' | 'AUTHENTICATED';
interface HeaderProps {
isDarkMode: boolean;
unitSystem: 'metric' | 'imperial';

View File

@@ -0,0 +1,147 @@
// src/hooks/useActiveDeals.test.tsx
import { renderHook, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { useActiveDeals } from './useActiveDeals';
import * as apiClient from '../services/apiClient';
import type { Flyer, MasterGroceryItem, FlyerItem, DealItem } from '../types';
// The apiClient is globally mocked in our test setup, so we just need to cast it
const mockedApiClient = vi.mocked(apiClient);
// Mock the logger to prevent console noise
vi.mock('../services/logger', () => ({
logger: {
error: vi.fn(),
},
}));
// Set a consistent "today" for testing flyer validity to make tests deterministic
const TODAY = new Date('2024-01-15T12:00:00.000Z');
describe('useActiveDeals Hook', () => {
// Use fake timers to control the current date in tests
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(TODAY);
vi.clearAllMocks();
});
afterEach(() => {
vi.useRealTimers();
});
const mockFlyers: Flyer[] = [
// A currently valid flyer
{ flyer_id: 1, file_name: 'valid.pdf', image_url: '', item_count: 10, created_at: '', valid_from: '2024-01-10', valid_to: '2024-01-20', store: { store_id: 1, name: 'Valid Store', created_at: '', logo_url: '' } },
// An expired flyer
{ flyer_id: 2, file_name: 'expired.pdf', image_url: '', item_count: 5, created_at: '', valid_from: '2024-01-01', valid_to: '2024-01-05', store: { store_id: 2, name: 'Expired Store', created_at: '', logo_url: '' } },
// A future flyer
{ flyer_id: 3, file_name: 'future.pdf', image_url: '', item_count: 8, created_at: '', valid_from: '2024-02-01', valid_to: '2024-02-10', store: { store_id: 3, name: 'Future Store', created_at: '', logo_url: '' } },
];
const mockWatchedItems: MasterGroceryItem[] = [
{ master_grocery_item_id: 101, name: 'Apples', created_at: '' },
{ master_grocery_item_id: 102, name: 'Milk', created_at: '' },
];
const mockFlyerItems: FlyerItem[] = [
// A deal for a watched item in a valid flyer
{ flyer_item_id: 1, flyer_id: 1, item: 'Red Apples', price_display: '$1.99', price_in_cents: 199, quantity: 'lb', master_item_id: 101, master_item_name: 'Apples', created_at: '', view_count: 0, click_count: 0, updated_at: '' },
// An item that is not a deal
{ flyer_item_id: 2, flyer_id: 1, item: 'Oranges', price_display: '$2.49', price_in_cents: 249, quantity: 'lb', master_item_id: 201, created_at: '', view_count: 0, click_count: 0, updated_at: '' },
];
it('should return loading state initially and then calculated data', async () => {
mockedApiClient.countFlyerItemsForFlyers.mockResolvedValue(new Response(JSON.stringify({ count: 10 })));
mockedApiClient.fetchFlyerItemsForFlyers.mockResolvedValue(new Response(JSON.stringify(mockFlyerItems)));
const { result } = renderHook(() => useActiveDeals(mockFlyers, mockWatchedItems));
// Check initial state
expect(result.current.isLoading).toBe(false); // It's false until the effect runs
expect(result.current.activeDeals).toEqual([]);
expect(result.current.totalActiveItems).toBe(0);
// Wait for the hook's useEffect to run and complete
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
expect(result.current.totalActiveItems).toBe(10);
expect(result.current.activeDeals).toHaveLength(1);
expect(result.current.activeDeals[0].item).toBe('Red Apples');
});
});
it('should correctly filter for valid flyers and make API calls with their IDs', async () => {
mockedApiClient.countFlyerItemsForFlyers.mockResolvedValue(new Response(JSON.stringify({ count: 0 })));
mockedApiClient.fetchFlyerItemsForFlyers.mockResolvedValue(new Response(JSON.stringify([])));
const { result } = renderHook(() => useActiveDeals(mockFlyers, mockWatchedItems));
await waitFor(() => {
// Only the valid flyer (id: 1) should be used in the API calls
expect(mockedApiClient.countFlyerItemsForFlyers).toHaveBeenCalledWith([1]);
expect(mockedApiClient.fetchFlyerItemsForFlyers).toHaveBeenCalledWith([1]);
});
});
it('should not fetch flyer items if there are no watched items', async () => {
mockedApiClient.countFlyerItemsForFlyers.mockResolvedValue(new Response(JSON.stringify({ count: 10 })));
const { result } = renderHook(() => useActiveDeals(mockFlyers, [])); // Pass empty watched items
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
expect(result.current.totalActiveItems).toBe(10);
expect(result.current.activeDeals).toEqual([]);
// The key assertion: fetchFlyerItemsForFlyers should not be called
expect(mockedApiClient.fetchFlyerItemsForFlyers).not.toHaveBeenCalled();
});
});
it('should handle the case where there are no valid flyers', async () => {
const noValidFlyers = [mockFlyers[1], mockFlyers[2]]; // Only expired and future
const { result } = renderHook(() => useActiveDeals(noValidFlyers, mockWatchedItems));
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
expect(result.current.totalActiveItems).toBe(0);
expect(result.current.activeDeals).toEqual([]);
// No API calls should be made if there are no valid flyers
expect(mockedApiClient.countFlyerItemsForFlyers).not.toHaveBeenCalled();
expect(mockedApiClient.fetchFlyerItemsForFlyers).not.toHaveBeenCalled();
});
});
it('should set an error state if an API call fails', async () => {
const apiError = new Error('Network Failure');
mockedApiClient.countFlyerItemsForFlyers.mockRejectedValue(apiError);
const { result } = renderHook(() => useActiveDeals(mockFlyers, mockWatchedItems));
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
expect(result.current.error).toBe('Could not fetch active deals or totals: Network Failure');
});
});
it('should correctly map flyer items to DealItem format', async () => {
mockedApiClient.countFlyerItemsForFlyers.mockResolvedValue(new Response(JSON.stringify({ count: 10 })));
mockedApiClient.fetchFlyerItemsForFlyers.mockResolvedValue(new Response(JSON.stringify(mockFlyerItems)));
const { result } = renderHook(() => useActiveDeals(mockFlyers, mockWatchedItems));
await waitFor(() => {
const deal = result.current.activeDeals[0];
const expectedDeal: DealItem = {
item: 'Red Apples',
price_display: '$1.99',
price_in_cents: 199,
quantity: 'lb',
storeName: 'Valid Store',
master_item_name: 'Apples',
unit_price: undefined, // or mock a value if needed
};
expect(deal).toEqual(expectedDeal);
});
});
});

View File

@@ -0,0 +1,88 @@
// src/hooks/useActiveDeals.tsx
import { useState, useEffect } from 'react';
import type { Flyer, FlyerItem, MasterGroceryItem, DealItem } from '../types';
import * as apiClient from '../services/apiClient';
import { logger } from '../services/logger';
/**
* A custom hook to calculate currently active deals and total active items
* based on flyer validity dates and a user's watched items.
* @param flyers - The list of all available flyers.
* @param watchedItems - The list of the user's watched items.
* @returns An object containing active deals, total active items, loading state, and any errors.
*/
export const useActiveDeals = (flyers: Flyer[], watchedItems: MasterGroceryItem[]) => {
const [activeDeals, setActiveDeals] = useState<DealItem[]>([]);
const [totalActiveItems, setTotalActiveItems] = useState(0);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const calculateActiveData = async () => {
if (flyers.length === 0) {
setActiveDeals([]);
setTotalActiveItems(0);
return;
}
setIsLoading(true);
setError(null);
try {
const today = new Date();
today.setHours(0, 0, 0, 0);
const validFlyers = flyers.filter((flyer) => {
if (!flyer.valid_from || !flyer.valid_to) return false;
try {
const from = new Date(`${flyer.valid_from}T00:00:00`);
const to = new Date(`${flyer.valid_to}T00:00:00`);
return today >= from && today <= to;
} catch (e) {
logger.error("Error parsing flyer date", { error: e });
return false;
}
});
if (validFlyers.length === 0) {
setActiveDeals([]);
setTotalActiveItems(0);
return;
}
const validFlyerIds = validFlyers.map(f => f.flyer_id);
const [countResponse, itemsResponse] = await Promise.all([
apiClient.countFlyerItemsForFlyers(validFlyerIds),
watchedItems.length > 0
? apiClient.fetchFlyerItemsForFlyers(validFlyerIds)
: Promise.resolve(new Response(JSON.stringify([])))
]);
const { count: totalCount } = await countResponse.json();
setTotalActiveItems(totalCount);
if (watchedItems.length > 0) {
const allItems: FlyerItem[] = await itemsResponse.json();
const watchedItemIds = new Set(watchedItems.map(item => item.master_grocery_item_id));
const dealItemsRaw = allItems.filter(item => item.master_item_id && watchedItemIds.has(item.master_item_id));
const flyerIdToStoreName = new Map(validFlyers.map(f => [f.flyer_id, f.store?.name || 'Unknown Store']));
const deals: DealItem[] = dealItemsRaw.map(item => ({ item: item.item, price_display: item.price_display, price_in_cents: item.price_in_cents, quantity: item.quantity, storeName: flyerIdToStoreName.get(item.flyer_id!) || 'Unknown Store', master_item_name: item.master_item_name, unit_price: item.unit_price }));
setActiveDeals(deals);
} else {
setActiveDeals([]);
}
} catch (e) {
const errorMessage = e instanceof Error ? e.message : String(e);
setError(`Could not fetch active deals or totals: ${errorMessage}`);
} finally {
setIsLoading(false);
}
};
calculateActiveData();
}, [flyers, watchedItems]);
return { activeDeals, totalActiveItems, isLoading, error };
};

118
src/hooks/useAuth.tsx Normal file
View File

@@ -0,0 +1,118 @@
// src/hooks/useAuth.tsx
import React, { createContext, useState, useContext, useEffect, useCallback, ReactNode } from 'react';
import type { User, UserProfile } from '../types';
import * as apiClient from '../services/apiClient';
import { logger } from '../services/logger';
/**
* Defines the possible authentication states for a user session.
* - `Determining...`: The initial state while checking for a token.
* - `SIGNED_OUT`: No user is active.
* - `AUTHENTICATED`: The user has successfully logged in.
*/
export type AuthStatus = 'Determining...' | 'SIGNED_OUT' | 'AUTHENTICATED';
interface AuthContextType {
user: User | null;
profile: UserProfile | null;
authStatus: AuthStatus;
isLoading: boolean;
login: (user: User, token: string) => Promise<void>;
logout: () => void;
updateProfile: (updatedProfileData: Partial<UserProfile>) => void;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [user, setUser] = useState<User | null>(null);
const [profile, setProfile] = useState<UserProfile | null>(null);
const [authStatus, setAuthStatus] = useState<AuthStatus>('Determining...');
const [isLoading, setIsLoading] = useState(true);
const checkAuthToken = useCallback(async () => {
const token = localStorage.getItem('authToken');
if (token) {
logger.info('Found auth token in local storage. Validating...');
try {
const response = await apiClient.getAuthenticatedUserProfile();
const userProfile: UserProfile = await response.json();
setUser(userProfile.user);
setProfile(userProfile);
setAuthStatus('AUTHENTICATED');
logger.info('Token validated successfully.', { user: userProfile.user });
} catch (e) {
logger.warn('Auth token validation failed. Clearing token.', { error: e });
localStorage.removeItem('authToken');
setUser(null);
setProfile(null);
setAuthStatus('SIGNED_OUT');
}
} else {
logger.info('No auth token found. User is signed out.');
setAuthStatus('SIGNED_OUT');
}
setIsLoading(false);
}, []);
useEffect(() => {
checkAuthToken();
}, [checkAuthToken]);
const login = useCallback(async (loggedInUser: User, token: string) => {
localStorage.setItem('authToken', token);
try {
const profileResponse = await apiClient.getAuthenticatedUserProfile();
const userProfile = await profileResponse.json();
setUser(loggedInUser);
setProfile(userProfile);
setAuthStatus('AUTHENTICATED');
logger.info('Login and data fetch successful', { user: loggedInUser });
} catch (e) {
const errorMessage = e instanceof Error ? e.message : String(e);
logger.error('Failed to fetch user data after login. Rolling back.', { error: errorMessage });
logout(); // Log the user out to prevent an inconsistent state.
// Re-throw the error so the calling component can handle it (e.g., show a notification)
throw new Error(`Login succeeded, but failed to fetch your data: ${errorMessage}`);
}
}, []);
const logout = useCallback(() => {
localStorage.removeItem('authToken');
setUser(null);
setProfile(null);
setAuthStatus('SIGNED_OUT');
}, []);
const updateProfile = useCallback((updatedProfileData: Partial<UserProfile>) => {
setProfile(prevProfile => {
if (!prevProfile) return null;
return { ...prevProfile, ...updatedProfileData };
});
}, []);
const value = {
user,
profile,
authStatus,
isLoading,
login,
logout,
updateProfile,
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
};
/**
* Custom hook to access the authentication context.
* This is what components will use to get auth state and methods.
* It also ensures that it's used within an AuthProvider.
*/
export const useAuth = (): AuthContextType => {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};

110
src/hooks/useData.tsx Normal file
View File

@@ -0,0 +1,110 @@
// src/hooks/useData.tsx
import React, { createContext, useState, useContext, useEffect, useCallback, ReactNode } from 'react';
import type { Flyer, MasterGroceryItem, ShoppingList } from '../types';
import * as apiClient from '../services/apiClient';
import { useAuth } from './useAuth';
import { logger } from '../services/logger';
export interface DataContextType {
flyers: Flyer[];
masterItems: MasterGroceryItem[];
watchedItems: MasterGroceryItem[];
shoppingLists: ShoppingList[];
setWatchedItems: React.Dispatch<React.SetStateAction<MasterGroceryItem[]>>;
setShoppingLists: React.Dispatch<React.SetStateAction<ShoppingList[]>>;
refetchFlyers: () => Promise<void>;
isLoading: boolean;
error: string | null;
}
const DataContext = createContext<DataContextType | undefined>(undefined);
export const DataProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const { user } = useAuth();
const [flyers, setFlyers] = useState<Flyer[]>([]);
const [masterItems, setMasterItems] = useState<MasterGroceryItem[]>([]);
const [watchedItems, setWatchedItems] = useState<MasterGroceryItem[]>([]);
const [shoppingLists, setShoppingLists] = useState<ShoppingList[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const refetchFlyers = useCallback(async () => {
try {
setError(null);
const flyersRes = await apiClient.fetchFlyers();
setFlyers(await flyersRes.json());
} catch (e) {
const errorMessage = e instanceof Error ? e.message : String(e);
logger.error('Failed to refetch flyers', { error: errorMessage });
setError(errorMessage);
}
}, []);
useEffect(() => {
const fetchData = async () => {
setIsLoading(true);
setError(null);
try {
// Fetch data that doesn't depend on authentication
const [flyersRes, masterItemsRes] = await Promise.all([
apiClient.fetchFlyers(),
apiClient.fetchMasterItems(),
]);
setFlyers(await flyersRes.json());
setMasterItems(await masterItemsRes.json());
// Fetch data that *does* depend on authentication
if (user) {
logger.info('User is authenticated, fetching user-specific data.');
const [watchedItemsRes, shoppingListsRes] = await Promise.all([
apiClient.fetchWatchedItems(),
apiClient.fetchShoppingLists(),
]);
setWatchedItems(await watchedItemsRes.json());
setShoppingLists(await shoppingListsRes.json());
} else {
// If user is not logged in, clear user-specific data
logger.info('User is not authenticated, clearing user-specific data.');
setWatchedItems([]);
setShoppingLists([]);
}
} catch (e) {
const errorMessage = e instanceof Error ? e.message : String(e);
logger.error('Failed to fetch initial app data', { error: errorMessage });
setError(errorMessage);
} finally {
setIsLoading(false);
}
};
fetchData();
}, [user]); // This effect now correctly re-runs only when the user's auth state changes.
const value = {
flyers,
masterItems,
watchedItems,
shoppingLists,
setWatchedItems,
setShoppingLists,
refetchFlyers,
isLoading,
error,
};
return <DataContext.Provider value={value}>{children}</DataContext.Provider>;
};
/**
* Custom hook to access the global application data.
* This is what components will use to get flyers, master items, etc.
* It also ensures that it's used within a DataProvider.
*/
export const useData = (): DataContextType => {
const context = useContext(DataContext);
if (context === undefined) {
throw new Error('useData must be used within a DataProvider');
}
return context;
};

View File

@@ -0,0 +1,170 @@
// src/hooks/useShoppingLists.test.tsx
import { renderHook, act, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { useShoppingLists } from './useShoppingLists';
import { useAuth } from './useAuth';
import { useData } from './useData';
import * as apiClient from '../services/apiClient';
import type { ShoppingList, User } from '../types';
// Mock the hooks that useShoppingLists depends on
vi.mock('./useAuth');
vi.mock('./useData');
// The apiClient is globally mocked in our test setup, so we just need to cast it
const mockedUseAuth = vi.mocked(useAuth);
const mockedUseData = vi.mocked(useData);
const mockedApiClient = vi.mocked(apiClient);
const mockUser: User = { user_id: 'user-123', email: 'test@example.com' };
describe('useShoppingLists Hook', () => {
// Create a mock setter function that we can spy on
const mockSetShoppingLists = vi.fn();
beforeEach(() => {
// Reset all mocks before each test to ensure isolation
vi.clearAllMocks();
// Provide a default implementation for the mocked hooks
mockedUseAuth.mockReturnValue({
user: mockUser,
// other useAuth properties are not needed for this test
} as any);
mockedUseData.mockReturnValue({
shoppingLists: [],
setShoppingLists: mockSetShoppingLists,
// other useData properties
} as any);
});
it('should initialize with no active list when there are no lists', () => {
const { result } = renderHook(() => useShoppingLists());
expect(result.current.shoppingLists).toEqual([]);
expect(result.current.activeListId).toBeNull();
});
it('should set the first list as active on initial load if lists exist', () => {
const mockLists: ShoppingList[] = [
{ shopping_list_id: 1, name: 'Groceries', user_id: 'user-123', created_at: '', items: [] },
{ shopping_list_id: 2, name: 'Hardware Store', user_id: 'user-123', created_at: '', items: [] },
];
mockedUseData.mockReturnValue({
shoppingLists: mockLists,
setShoppingLists: mockSetShoppingLists,
} as any);
const { result } = renderHook(() => useShoppingLists());
expect(result.current.activeListId).toBe(1);
});
it('should not set an active list if the user is not authenticated', () => {
mockedUseAuth.mockReturnValue({ user: null } as any);
const mockLists: ShoppingList[] = [
{ shopping_list_id: 1, name: 'Groceries', user_id: 'user-123', created_at: '', items: [] },
];
mockedUseData.mockReturnValue({ shoppingLists: mockLists, setShoppingLists: mockSetShoppingLists } as any);
const { result } = renderHook(() => useShoppingLists());
expect(result.current.activeListId).toBeNull();
});
describe('createList', () => {
it('should call the API and update state on successful creation', async () => {
const newList: ShoppingList = { shopping_list_id: 99, name: 'New List', user_id: 'user-123', created_at: '', items: [] };
mockedApiClient.createShoppingList.mockResolvedValue(new Response(JSON.stringify(newList)));
const { result } = renderHook(() => useShoppingLists());
// `act` ensures that all state updates from the hook are processed before assertions are made
await act(async () => {
await result.current.createList('New List');
});
expect(mockedApiClient.createShoppingList).toHaveBeenCalledWith('New List');
// Check that the global state setter was called with a function that adds the new list
expect(mockSetShoppingLists).toHaveBeenCalledWith(expect.any(Function));
// To test the update function, we can call it with a previous state
const updater = mockSetShoppingLists.mock.calls[0][0] as (prev: ShoppingList[]) => ShoppingList[];
expect(updater([])).toEqual([newList]);
// Check that the new list becomes the active one
expect(result.current.activeListId).toBe(99);
});
it('should set an error message if API call fails', async () => {
mockedApiClient.createShoppingList.mockRejectedValue(new Error('API Failed'));
const { result } = renderHook(() => useShoppingLists());
await act(async () => {
await result.current.createList('New List');
});
expect(result.current.error).toBe('Could not create list: API Failed');
});
});
describe('deleteList', () => {
it('should call the API and update state on successful deletion', async () => {
const mockLists: ShoppingList[] = [
{ shopping_list_id: 1, name: 'Groceries', user_id: 'user-123', created_at: '', items: [] },
{ shopping_list_id: 2, name: 'Hardware Store', user_id: 'user-123', created_at: '', items: [] },
];
mockedUseData.mockReturnValue({ shoppingLists: mockLists, setShoppingLists: mockSetShoppingLists } as any);
mockedApiClient.deleteShoppingList.mockResolvedValue(new Response(null, { status: 204 }));
const { result } = renderHook(() => useShoppingLists());
await act(async () => {
await result.current.deleteList(1);
});
expect(mockedApiClient.deleteShoppingList).toHaveBeenCalledWith(1);
// Check that the global state setter was called with the correctly filtered list
expect(mockSetShoppingLists).toHaveBeenCalledWith([mockLists[1]]);
});
it('should update activeListId if the active list is deleted', async () => {
const mockLists: ShoppingList[] = [
{ shopping_list_id: 1, name: 'Groceries', user_id: 'user-123', created_at: '', items: [] },
{ shopping_list_id: 2, name: 'Hardware Store', user_id: 'user-123', created_at: '', items: [] },
];
mockedUseData.mockReturnValue({ shoppingLists: mockLists, setShoppingLists: mockSetShoppingLists } as any);
mockedApiClient.deleteShoppingList.mockResolvedValue(new Response(null, { status: 204 }));
// Render the hook and wait for the initial effect to set activeListId
const { result, rerender } = renderHook(() => useShoppingLists());
await waitFor(() => expect(result.current.activeListId).toBe(1));
await act(async () => {
await result.current.deleteList(1);
});
// After deletion, the hook should select the next available list as active
expect(result.current.activeListId).toBe(2);
});
});
// You can follow a similar pattern to test the other functions:
// - addItemToList
// - updateItemInList
// - removeItemFromList
it('should not perform actions if user is not authenticated', async () => {
mockedUseAuth.mockReturnValue({ user: null } as any);
const { result } = renderHook(() => useShoppingLists());
await act(async () => {
await result.current.createList('Should not work');
});
expect(mockedApiClient.createShoppingList).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,131 @@
// src/hooks/useShoppingLists.tsx
import { useState, useCallback, useEffect } from 'react';
import { useAuth } from './useAuth';
import { useData } from './useData';
import * as apiClient from '../services/apiClient';
import type { ShoppingList, ShoppingListItem } from '../types';
import { logger } from '../services/logger';
/**
* A custom hook to manage all state and logic related to shopping lists.
* It encapsulates API calls and state updates for creating, deleting, and modifying lists and their items.
*/
export const useShoppingLists = () => {
const { user } = useAuth();
// We get the lists and the global setter from the DataContext.
const { shoppingLists, setShoppingLists } = useData();
const [activeListId, setActiveListId] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
// Effect to select the first list as active when lists are loaded or the user changes.
useEffect(() => {
if (user && shoppingLists.length > 0 && !shoppingLists.some(l => l.shopping_list_id === activeListId)) {
setActiveListId(shoppingLists[0].shopping_list_id);
} else if (!user || shoppingLists.length === 0) {
setActiveListId(null);
}
}, [shoppingLists, user, activeListId]);
const createList = useCallback(async (name: string) => {
if (!user) return;
try {
setError(null);
const response = await apiClient.createShoppingList(name);
const newList: ShoppingList = await response.json();
setShoppingLists(prev => [...prev, newList]);
setActiveListId(newList.shopping_list_id);
} catch (e) {
const errorMessage = e instanceof Error ? e.message : String(e);
logger.error('Could not create list', { error: errorMessage });
setError(`Could not create list: ${errorMessage}`);
}
}, [user, setShoppingLists]);
const deleteList = useCallback(async (listId: number) => {
if (!user) return;
try {
setError(null);
await apiClient.deleteShoppingList(listId);
const newLists = shoppingLists.filter(l => l.shopping_list_id !== listId);
setShoppingLists(newLists);
if (activeListId === listId) {
setActiveListId(newLists.length > 0 ? newLists[0].shopping_list_id : null);
}
} catch (e) {
const errorMessage = e instanceof Error ? e.message : String(e);
logger.error('Could not delete list', { error: errorMessage });
setError(`Could not delete list: ${errorMessage}`);
}
}, [user, shoppingLists, activeListId, setShoppingLists]);
const addItemToList = useCallback(async (listId: number, item: { masterItemId?: number, customItemName?: string }) => {
if (!user) return;
try {
setError(null);
const response = await apiClient.addShoppingListItem(listId, item);
const newItem: ShoppingListItem = await response.json();
setShoppingLists(prevLists => prevLists.map(list => {
if (list.shopping_list_id === listId) {
const itemExists = list.items.some(i => i.shopping_list_item_id === newItem.shopping_list_item_id);
if (itemExists) return list;
return { ...list, items: [...list.items, newItem] };
}
return list;
}));
} catch (e) {
const errorMessage = e instanceof Error ? e.message : String(e);
logger.error('Could not add item to list', { error: errorMessage });
setError(`Could not add item to list: ${errorMessage}`);
}
}, [user, setShoppingLists]);
const updateItemInList = useCallback(async (itemId: number, updates: Partial<ShoppingListItem>) => {
if (!user || !activeListId) return;
try {
setError(null);
const response = await apiClient.updateShoppingListItem(itemId, updates);
const updatedItem: ShoppingListItem = await response.json();
setShoppingLists(prevLists => prevLists.map(list => {
if (list.shopping_list_id === activeListId) {
return { ...list, items: list.items.map(i => i.shopping_list_item_id === itemId ? updatedItem : i) };
}
return list;
}));
} catch (e) {
const errorMessage = e instanceof Error ? e.message : String(e);
logger.error('Could not update list item', { error: errorMessage });
setError(`Could not update list item: ${errorMessage}`);
}
}, [user, activeListId, setShoppingLists]);
const removeItemFromList = useCallback(async (itemId: number) => {
if (!user || !activeListId) return;
try {
setError(null);
await apiClient.removeShoppingListItem(itemId);
setShoppingLists(prevLists => prevLists.map(list => {
if (list.shopping_list_id === activeListId) {
return { ...list, items: list.items.filter(i => i.shopping_list_item_id !== itemId) };
}
return list;
}));
} catch (e) {
const errorMessage = e instanceof Error ? e.message : String(e);
logger.error('Could not remove list item', { error: errorMessage });
setError(`Could not remove list item: ${errorMessage}`);
}
}, [user, activeListId, setShoppingLists]);
return {
shoppingLists,
activeListId,
setActiveListId,
createList,
deleteList,
addItemToList,
updateItemInList,
removeItemFromList,
error,
};
};

View File

@@ -0,0 +1,61 @@
// src/hooks/useWatchedItems.tsx
import { useState, useCallback } from 'react';
import { useAuth } from './useAuth';
import { useData } from './useData';
import * as apiClient from '../services/apiClient';
import type { MasterGroceryItem } from '../types';
import { logger } from '../services/logger';
/**
* A custom hook to manage all state and logic related to a user's watched items.
* It encapsulates API calls and state updates for adding and removing items.
*/
export const useWatchedItems = () => {
const { user } = useAuth();
// Get the watched items and the global setter from the DataContext.
const { watchedItems, setWatchedItems } = useData();
const [error, setError] = useState<string | null>(null);
const addWatchedItem = useCallback(async (itemName: string, category: string) => {
if (!user) return;
try {
setError(null);
const updatedOrNewItem: MasterGroceryItem = await (await apiClient.addWatchedItem(itemName, category)).json();
// Update the global state in the DataContext.
setWatchedItems(currentItems => {
const itemExists = currentItems.some(item => item.master_grocery_item_id === updatedOrNewItem.master_grocery_item_id);
if (!itemExists) {
return [...currentItems, updatedOrNewItem].sort((a, b) => a.name.localeCompare(b.name));
}
return currentItems;
});
} catch (e) {
const errorMessage = e instanceof Error ? e.message : String(e);
logger.error('Could not add watched item', { error: errorMessage });
setError(`Could not add watched item: ${errorMessage}`);
}
}, [user, setWatchedItems]);
const removeWatchedItem = useCallback(async (masterItemId: number) => {
if (!user) return;
try {
setError(null);
await apiClient.removeWatchedItem(masterItemId);
// Update the global state in the DataContext.
setWatchedItems(currentItems => currentItems.filter(item => item.master_grocery_item_id !== masterItemId));
} catch (e) {
const errorMessage = e instanceof Error ? e.message : String(e);
logger.error('Could not remove watched item', { error: errorMessage });
setError(`Could not remove watched item: ${errorMessage}`);
}
}, [user, setWatchedItems]);
return {
watchedItems,
addWatchedItem,
removeWatchedItem,
error,
};
};

View File

@@ -2,6 +2,8 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import { DataProvider } from './hooks/useData';
import { AuthProvider } from './hooks/useAuth';
import { BrowserRouter } from 'react-router-dom';
import './index.css';
@@ -13,9 +15,13 @@ if (!rootElement) {
const root = ReactDOM.createRoot(rootElement);
root.render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
<BrowserRouter>
<AuthProvider>
<DataProvider>
<App />
</DataProvider>
</AuthProvider>
</BrowserRouter>
</React.StrictMode>
);

110
src/layouts/MainLayout.tsx Normal file
View File

@@ -0,0 +1,110 @@
// src/layouts/MainLayout.tsx
import React from 'react';
import { Outlet } from 'react-router-dom';
import { useAuth } from '../hooks/useAuth';
import { useData } from '../hooks/useData';
import { useShoppingLists } from '../hooks/useShoppingLists';
import { useWatchedItems } from '../hooks/useWatchedItems';
import { useActiveDeals } from '../hooks/useActiveDeals';
import { FlyerList } from '../features/flyer/FlyerList';
import { FlyerUploader } from '../features/flyer/FlyerUploader';
import { ShoppingListComponent } from '../features/shopping/ShoppingList';
import { WatchedItemsList } from '../features/shopping/WatchedItemsList';
import { PriceChart } from '../features/charts/PriceChart';
import { PriceHistoryChart } from '../features/charts/PriceHistoryChart';
import Leaderboard from '../components/Leaderboard';
import { ActivityLog, ActivityLogClickHandler } from '../pages/admin/ActivityLog';
import { AnonymousUserBanner } from '../pages/admin/components/AnonymousUserBanner';
import { ErrorDisplay } from '../components/ErrorDisplay';
interface MainLayoutProps {
onFlyerSelect: (flyer: import('../types').Flyer) => void;
selectedFlyerId: number | null;
onOpenProfile: () => void;
}
export const MainLayout: React.FC<MainLayoutProps> = ({ onFlyerSelect, selectedFlyerId, onOpenProfile }) => {
const { user, authStatus } = useAuth();
const { flyers, masterItems, refetchFlyers, error: dataError } = useData();
const {
shoppingLists, activeListId, setActiveListId,
createList, deleteList, addItemToList, updateItemInList, removeItemFromList,
error: shoppingListError,
} = useShoppingLists();
const {
watchedItems, addWatchedItem, removeWatchedItem,
error: watchedItemsError,
} = useWatchedItems();
const { activeDeals, totalActiveItems, isLoading: activeDealsLoading, error: activeDealsError } = useActiveDeals(flyers, watchedItems);
const handleActivityLogClick: ActivityLogClickHandler = (log) => {
if (log.action === 'list_shared') {
const listId = log.details.shopping_list_id;
if (shoppingLists.some(list => list.shopping_list_id === listId)) {
setActiveListId(listId);
}
}
};
return (
<main className="max-w-screen-2xl mx-auto py-4 px-2.5 sm:py-6 lg:py-8">
{authStatus === 'SIGNED_OUT' && flyers.length > 0 && (
<div className="max-w-5xl mx-auto mb-6 px-4 lg:px-0">
<AnonymousUserBanner onOpenProfile={onOpenProfile} />
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8 items-start">
<div className="lg:col-span-1 flex flex-col space-y-6">
<FlyerList flyers={flyers} onFlyerSelect={onFlyerSelect} selectedFlyerId={selectedFlyerId} profile={null} />
<FlyerUploader onProcessingComplete={refetchFlyers} />
</div>
<div className="lg:col-span-2 flex flex-col space-y-6">
{(dataError || shoppingListError || watchedItemsError || activeDealsError) && (
<ErrorDisplay message={dataError || shoppingListError || watchedItemsError || activeDealsError || 'An unknown error occurred.'} />
)}
{/* The Outlet will render the specific page content (e.g., FlyerDisplay or Welcome message) */}
<Outlet context={{ totalActiveItems, masterItems, addWatchedItem, shoppingLists, activeListId, addItemToList }} />
</div>
<div className="lg:col-span-1 flex-col space-y-6">
<>
<ShoppingListComponent
user={user}
lists={shoppingLists}
activeListId={activeListId}
onSelectList={setActiveListId}
onCreateList={createList}
onDeleteList={deleteList}
onAddItem={async (item) => {
if (activeListId) {
await addItemToList(activeListId, item);
}
}}
onUpdateItem={updateItemInList}
onRemoveItem={removeItemFromList}
/>
<WatchedItemsList
items={watchedItems}
onAddItem={addWatchedItem}
onRemoveItem={removeWatchedItem}
user={user}
activeListId={activeListId}
onAddItemToList={(masterItemId) => activeListId && addItemToList(activeListId, { masterItemId })}
/>
<PriceChart
deals={activeDeals}
isLoading={activeDealsLoading}
unitSystem={'imperial'} // This can be passed down or sourced from a context
user={user}
/>
<PriceHistoryChart watchedItems={watchedItems} />
<Leaderboard />
<ActivityLog user={user} onLogClick={handleActivityLogClick} />
</>
</div>
</div>
</main>
);
};

66
src/pages/HomePage.tsx Normal file
View File

@@ -0,0 +1,66 @@
// src/pages/HomePage.tsx
import React from 'react';
import { useOutletContext } from 'react-router-dom';
import { FlyerDisplay } from '../features/flyer/FlyerDisplay';
import { ExtractedDataTable } from '../features/flyer/ExtractedDataTable';
import { AnalysisPanel } from '../features/flyer/AnalysisPanel';
import type { Flyer, FlyerItem, MasterGroceryItem, ShoppingList } from '../types';
interface HomePageContext {
totalActiveItems: number;
masterItems: MasterGroceryItem[];
addWatchedItem: (itemName: string, category: string) => Promise<void>;
shoppingLists: ShoppingList[];
activeListId: number | null;
addItemToList: (listId: number, item: { masterItemId?: number; customItemName?: string; }) => Promise<void>;
}
interface HomePageProps {
selectedFlyer: Flyer | null;
flyerItems: FlyerItem[];
onOpenCorrectionTool: () => void;
}
export const HomePage: React.FC<HomePageProps> = ({ selectedFlyer, flyerItems, onOpenCorrectionTool }) => {
const { totalActiveItems, masterItems, addWatchedItem, shoppingLists, activeListId, addItemToList } = useOutletContext<HomePageContext>();
const hasData = flyerItems.length > 0;
if (!selectedFlyer) {
return (
<div className="text-center p-8 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 h-full flex flex-col justify-center min-h-[400px]">
<h2 className="text-xl font-semibold text-gray-700 dark:text-gray-200">Welcome to Flyer Crawler!</h2>
<p className="mt-2 text-gray-500 dark:text-gray-400">Upload a new grocery flyer to begin, or select a previously processed flyer from the list on the left.</p>
</div>
);
}
return (
<>
<FlyerDisplay
imageUrl={selectedFlyer.image_url}
store={selectedFlyer.store}
validFrom={selectedFlyer.valid_from}
validTo={selectedFlyer.valid_to}
storeAddress={selectedFlyer.store_address}
onOpenCorrectionTool={onOpenCorrectionTool}
/>
{hasData && (
<>
<ExtractedDataTable
items={flyerItems}
totalActiveItems={totalActiveItems}
watchedItems={[]} // Sourced from useWatchedItems in layout
masterItems={masterItems}
unitSystem={'imperial'} // Sourced from context/props
user={null} // Sourced from useAuth in layout
onAddItem={addWatchedItem}
shoppingLists={shoppingLists}
activeListId={activeListId}
onAddItemToList={(masterItemId: number) => activeListId && addItemToList(activeListId, { masterItemId })}
/>
<AnalysisPanel flyerItems={flyerItems} store={selectedFlyer.store} />
</>
)}
</>
);
};

View File

@@ -15,8 +15,8 @@ import { PasswordInput } from './PasswordInput';
import { AddressForm } from './AddressForm';
import { MapView } from '../../../components/MapView';
import { useDebounce } from '../../../hooks/useDebounce';
import type { AuthStatus } from '../../../hooks/useAuth';
type AuthStatus = 'SIGNED_OUT' | 'ANONYMOUS' | 'AUTHENTICATED';
interface ProfileManagerProps {
isOpen: boolean;
onClose: () => void;

View File

@@ -110,6 +110,12 @@ describe('Admin Content Management Routes (/api/admin)', () => {
expect(response.body).toEqual(mockCorrections);
});
it('should return 500 if the database call fails', async () => {
mockedDb.getSuggestedCorrections.mockRejectedValue(new Error('DB Error'));
const response = await supertest(app).get('/api/admin/corrections');
expect(response.status).toBe(500);
});
it('POST /corrections/:id/approve should approve a correction', async () => {
const correctionId = 123;
mockedDb.approveCorrection.mockResolvedValue(undefined);
@@ -119,6 +125,12 @@ describe('Admin Content Management Routes (/api/admin)', () => {
expect(mockedDb.approveCorrection).toHaveBeenCalledWith(correctionId);
});
it('POST /corrections/:id/approve should return 400 for an invalid ID', async () => {
const response = await supertest(app).post('/api/admin/corrections/abc/approve');
expect(response.status).toBe(400);
expect(response.body.message).toBe('Invalid correction ID provided.');
});
it('POST /corrections/:id/reject should reject a correction', async () => {
const correctionId = 789;
mockedDb.rejectCorrection.mockResolvedValue(undefined);
@@ -127,6 +139,12 @@ describe('Admin Content Management Routes (/api/admin)', () => {
expect(response.body).toEqual({ message: 'Correction rejected successfully.' });
});
it('PUT /corrections/:id should return 400 if suggested_value is missing', async () => {
const response = await supertest(app).put('/api/admin/corrections/101').send({});
expect(response.status).toBe(400);
expect(response.body.message).toBe('A new suggested_value is required.');
});
it('PUT /corrections/:id should update a correction', async () => {
const correctionId = 101;
const requestBody = { suggested_value: 'A new corrected value' };
@@ -136,6 +154,12 @@ describe('Admin Content Management Routes (/api/admin)', () => {
expect(response.status).toBe(200);
expect(response.body).toEqual(mockUpdatedCorrection);
});
it('PUT /corrections/:id should return 404 if correction not found', async () => {
mockedDb.updateSuggestedCorrection.mockRejectedValue(new Error('Correction with ID 999 not found'));
const response = await supertest(app).put('/api/admin/corrections/999').send({ suggested_value: 'new value' });
expect(response.status).toBe(404);
});
});
describe('Brand Routes', () => {
@@ -157,6 +181,12 @@ describe('Admin Content Management Routes (/api/admin)', () => {
expect(response.body.message).toBe('Brand logo updated successfully.');
expect(mockedDb.updateBrandLogo).toHaveBeenCalledWith(brandId, expect.stringContaining('/assets/'));
});
it('POST /brands/:id/logo should return 400 if no file is uploaded', async () => {
const response = await supertest(app).post('/api/admin/brands/55/logo');
expect(response.status).toBe(400);
expect(response.body.message).toBe('Logo image file is required.');
});
});
describe('Recipe and Comment Routes', () => {
@@ -170,6 +200,12 @@ describe('Admin Content Management Routes (/api/admin)', () => {
expect(response.body).toEqual(mockUpdatedRecipe);
});
it('PUT /recipes/:id/status should return 400 for an invalid status', async () => {
const response = await supertest(app).put('/api/admin/recipes/201').send({ status: 'invalid-status' });
expect(response.status).toBe(400);
expect(response.body.message).toContain('A valid status');
});
it('PUT /comments/:id/status should update a comment status', async () => {
const commentId = 301;
const requestBody = { status: 'hidden' as const };
@@ -179,6 +215,12 @@ describe('Admin Content Management Routes (/api/admin)', () => {
expect(response.status).toBe(200);
expect(response.body).toEqual(mockUpdatedComment);
});
it('PUT /comments/:id/status should return 400 for an invalid status', async () => {
const response = await supertest(app).put('/api/admin/comments/301').send({ status: 'invalid-status' });
expect(response.status).toBe(400);
expect(response.body.message).toContain('A valid status');
});
});
describe('Unmatched Items Route', () => {

View File

@@ -115,6 +115,13 @@ describe('Admin Job Trigger Routes (/api/admin/trigger)', () => {
expect(response.body.message).toContain('Daily deal check job has been triggered');
expect(backgroundJobService.runDailyDealCheck).toHaveBeenCalledTimes(1);
});
it('should return 500 if triggering the job fails', async () => {
vi.mocked(backgroundJobService.runDailyDealCheck).mockImplementation(() => { throw new Error('Job runner failed'); });
const response = await supertest(app).post('/api/admin/trigger/daily-deal-check');
expect(response.status).toBe(500);
expect(response.body.message).toContain('Job runner failed');
});
});
describe('POST /trigger/failing-job', () => {
@@ -126,6 +133,12 @@ describe('Admin Job Trigger Routes (/api/admin/trigger)', () => {
expect(response.body.message).toContain('Failing test job has been enqueued');
expect(analyticsQueue.add).toHaveBeenCalledWith('generate-daily-report', { reportDate: 'FAIL' });
});
it('should return 500 if enqueuing the job fails', async () => {
vi.mocked(analyticsQueue.add).mockRejectedValue(new Error('Queue is down'));
const response = await supertest(app).post('/api/admin/trigger/failing-job');
expect(response.status).toBe(500);
});
});
describe('POST /flyers/:flyerId/cleanup', () => {
@@ -144,6 +157,13 @@ describe('Admin Job Trigger Routes (/api/admin/trigger)', () => {
expect(response.status).toBe(400);
expect(response.body.message).toBe('A valid flyer ID is required.');
});
it('should return 500 if enqueuing the cleanup job fails', async () => {
const flyerId = 789;
vi.mocked(cleanupQueue.add).mockRejectedValue(new Error('Queue is down'));
const response = await supertest(app).post(`/api/admin/flyers/${flyerId}/cleanup`);
expect(response.status).toBe(500);
});
});
describe('POST /jobs/:queueName/:jobId/retry', () => {
@@ -195,5 +215,19 @@ describe('Admin Job Trigger Routes (/api/admin/trigger)', () => {
expect(response.body.message).toBe("Job is not in a 'failed' state. Current state: completed.");
expect(mockJob.retry).not.toHaveBeenCalled();
});
it('should return 500 if job.retry() throws an error', async () => {
const mockJob = {
id: jobId,
getState: vi.fn().mockResolvedValue('failed'),
retry: vi.fn().mockRejectedValue(new Error('Cannot retry job')),
};
vi.mocked(flyerQueue.getJob).mockResolvedValue(mockJob as any);
const response = await supertest(app).post(`/api/admin/jobs/${queueName}/${jobId}/retry`);
expect(response.status).toBe(500);
expect(response.body.message).toBe('Cannot retry job');
});
});
});

View File

@@ -173,5 +173,12 @@ describe('Admin Monitoring Routes (/api/admin)', () => {
{ name: 'file-cleanup', counts: { waiting: 2, active: 0, completed: 25, failed: 0, delayed: 0, paused: 0 } },
]);
});
it('should return 500 if fetching queue counts fails', async () => {
vi.mocked(mockedQueueService.flyerQueue.getJobCounts).mockRejectedValue(new Error('Redis is down'));
const response = await supertest(app).get('/api/admin/queues/status');
expect(response.status).toBe(500);
});
});
});

View File

@@ -0,0 +1,83 @@
// src/routes/admin.system.routes.test.ts
import { describe, it, expect, vi, beforeEach, type Mocked } from 'vitest';
import supertest from 'supertest';
import express, { Request, Response, NextFunction } from 'express';
import adminRouter from './admin.routes';
import { createMockUserProfile } from '../tests/utils/mockFactories';
import { UserProfile } from '../types';
// Mock dependencies
vi.mock('../services/geocodingService.server', () => ({
clearGeocodeCache: vi.fn(),
}));
// Mock other dependencies that are part of the adminRouter setup but not directly tested here
vi.mock('../services/db/admin.db');
vi.mock('../services/db/flyer.db');
vi.mock('../services/db/recipe.db');
vi.mock('../services/db/user.db');
vi.mock('node:fs/promises');
vi.mock('../services/backgroundJobService');
vi.mock('../services/queueService.server');
vi.mock('@bull-board/api');
vi.mock('@bull-board/api/bullMQAdapter');
vi.mock('@bull-board/express', () => ({
ExpressAdapter: class {
setBasePath = vi.fn();
getRouter = vi.fn().mockReturnValue((req: Request, res: Response, next: NextFunction) => next());
},
}));
// Import the mocked modules to control them
import { clearGeocodeCache } from '../services/geocodingService.server';
// Mock the logger
vi.mock('../services/logger.server', () => ({
logger: { info: vi.fn(), debug: vi.fn(), error: vi.fn(), warn: vi.fn() },
}));
// Mock the passport middleware
vi.mock('./passport.routes', () => ({
default: {
authenticate: vi.fn(() => (req: Request, res: Response, next: NextFunction) => {
req.user = createMockUserProfile({ role: 'admin' });
next();
}),
},
isAdmin: (req: Request, res: Response, next: NextFunction) => next(),
}));
// Helper function to create a test app instance.
const createApp = () => {
const app = express();
app.use(express.json());
app.use('/api/admin', adminRouter);
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
res.status(500).json({ message: err.message || 'Internal Server Error' });
});
return app;
};
describe('Admin System Routes (/api/admin/system)', () => {
const app = createApp();
beforeEach(() => {
vi.clearAllMocks();
});
describe('POST /system/clear-geocode-cache', () => {
it('should return 200 on successful cache clear', async () => {
vi.mocked(clearGeocodeCache).mockResolvedValue(10);
const response = await supertest(app).post('/api/admin/system/clear-geocode-cache');
expect(response.status).toBe(200);
expect(response.body.message).toContain('10 keys were removed');
});
it('should return 500 if clearing the cache fails', async () => {
vi.mocked(clearGeocodeCache).mockRejectedValue(new Error('Redis is down'));
const response = await supertest(app).post('/api/admin/system/clear-geocode-cache');
expect(response.status).toBe(500);
expect(response.body.message).toBe('Redis is down');
});
});
});

View File

@@ -208,6 +208,13 @@ describe('AI Routes (/api/ai)', () => {
expect(flyerDb.createFlyerAndItems).toHaveBeenCalledTimes(1);
});
it('should return 400 if no flyer image is provided', async () => {
const response = await supertest(app)
.post('/api/ai/flyers/process')
.field('data', JSON.stringify(mockDataPayload));
expect(response.status).toBe(400);
});
it('should return 409 Conflict if flyer checksum already exists', async () => {
// Arrange
const mockExistingFlyer = createMockFlyer({ flyer_id: 99 });
@@ -280,6 +287,25 @@ describe('AI Routes (/api/ai)', () => {
const flyerDataArg = vi.mocked(flyerDb.createFlyerAndItems).mock.calls[0][0];
expect(flyerDataArg.store_name).toContain('Unknown Store');
});
it('should handle a generic error during flyer creation', async () => {
vi.mocked(flyerDb.findFlyerByChecksum).mockResolvedValue(undefined);
vi.mocked(flyerDb.createFlyerAndItems).mockRejectedValue(new Error('DB transaction failed'));
const response = await supertest(app)
.post('/api/ai/flyers/process')
.field('data', JSON.stringify(mockDataPayload))
.attach('flyerImage', imagePath);
expect(response.status).toBe(500);
});
});
describe('POST /check-flyer', () => {
it('should return 400 if no image is provided', async () => {
const response = await supertest(app).post('/api/ai/check-flyer');
expect(response.status).toBe(400);
});
});
describe('POST /rescan-area', () => {
@@ -292,6 +318,14 @@ describe('AI Routes (/api/ai)', () => {
});
});
describe('POST /extract-address', () => {
it('should return 400 if no image is provided', async () => {
const response = await supertest(app).post('/api/ai/extract-address');
expect(response.status).toBe(400);
});
});
describe('POST /rescan-area (authenticated)', () => {
const imagePath = path.resolve(__dirname, '../tests/assets/test-flyer-image.jpg');
const mockUser = createMockUserProfile({ user_id: 'user-123' });

View File

@@ -69,6 +69,14 @@ describe('Gamification Routes (/api/achievements)', () => {
expect(response.body).toEqual(mockAchievements);
expect(mockedDb.getAllAchievements).toHaveBeenCalledTimes(1);
});
it('should return 500 if the database call fails', async () => {
const dbError = new Error('DB Connection Failed');
mockedDb.getAllAchievements.mockRejectedValue(dbError);
const response = await supertest(app).get('/api/achievements');
expect(response.status).toBe(500);
});
});
describe('GET /me', () => {
@@ -93,6 +101,18 @@ describe('Gamification Routes (/api/achievements)', () => {
expect(response.body).toEqual(mockUserAchievements);
expect(mockedDb.getUserAchievements).toHaveBeenCalledWith('user-123');
});
it('should return 500 if the database call fails', async () => {
// Mock an authenticated user
mockedAuthMiddleware.mockImplementation((req: Request, res: Response, next: NextFunction) => {
req.user = mockUserProfile;
next();
});
const dbError = new Error('DB Error');
mockedDb.getUserAchievements.mockRejectedValue(dbError);
const response = await supertest(app).get('/api/achievements/me');
expect(response.status).toBe(500);
});
});
describe('POST /award', () => {
@@ -131,5 +151,48 @@ describe('Gamification Routes (/api/achievements)', () => {
expect(mockedDb.awardAchievement).toHaveBeenCalledTimes(1);
expect(mockedDb.awardAchievement).toHaveBeenCalledWith(awardPayload.userId, awardPayload.achievementName);
});
it('should return 400 if userId is missing', async () => {
mockedAuthMiddleware.mockImplementation((req: Request, res: Response, next: NextFunction) => {
req.user = mockAdminProfile;
next();
});
mockedIsAdmin.mockImplementation((req: Request, res: Response, next: NextFunction) => next());
const response = await supertest(app).post('/api/achievements/award').send({ achievementName: 'Test Award' });
expect(response.status).toBe(400);
expect(response.body.message).toBe('Both userId and achievementName are required.');
});
it('should return 500 if the database call fails', async () => {
mockedAuthMiddleware.mockImplementation((req: Request, res: Response, next: NextFunction) => {
req.user = mockAdminProfile;
next();
});
mockedIsAdmin.mockImplementation((req: Request, res: Response, next: NextFunction) => next());
mockedDb.awardAchievement.mockRejectedValue(new Error('DB Error'));
const response = await supertest(app).post('/api/achievements/award').send(awardPayload);
expect(response.status).toBe(500);
});
});
describe('GET /leaderboard', () => {
it('should return a list of top users (public endpoint)', async () => {
const mockLeaderboard = [{ user_id: 'user-1', full_name: 'Leader', points: 1000, rank: '1' }];
mockedDb.getLeaderboard.mockResolvedValue(mockLeaderboard as any);
const response = await supertest(app).get('/api/achievements/leaderboard?limit=5');
expect(response.status).toBe(200);
expect(response.body).toEqual(mockLeaderboard);
expect(mockedDb.getLeaderboard).toHaveBeenCalledWith(5);
});
it('should return 500 if the database call fails', async () => {
mockedDb.getLeaderboard.mockRejectedValue(new Error('DB Error'));
const response = await supertest(app).get('/api/achievements/leaderboard');
expect(response.status).toBe(500);
});
});
});

View File

@@ -1,5 +1,6 @@
// src/routes/passport.test.ts
import { describe, it, expect, vi, beforeEach, type Mocked } from 'vitest';
import * as bcrypt from 'bcrypt';
import { Request, Response, NextFunction } from 'express';
// Define a type for the JWT verify callback function for type safety.
@@ -27,6 +28,20 @@ vi.mock('passport-jwt', () => ({
ExtractJwt: { fromAuthHeaderAsBearerToken: vi.fn() },
}));
// FIX: Add a similar mock for 'passport-local' to capture its verify callback.
const { localStrategyCallbackWrapper } = vi.hoisted(() => {
type LocalVerifyCallback = (req: Request, email: string, pass: string, done: (error: Error | null, user?: object | false, options?: { message: string }) => void) => Promise<void>;
return {
localStrategyCallbackWrapper: { callback: null as LocalVerifyCallback | null }
};
});
vi.mock('passport-local', () => ({
Strategy: vi.fn(function(options, verify) {
localStrategyCallbackWrapper.callback = verify;
}),
}));
import * as db from '../services/db/index.db';
import { UserProfile } from '../types';
@@ -47,6 +62,11 @@ vi.mock('../services/logger.server', () => ({
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
}));
// Mock bcrypt for password comparisons
vi.mock('bcrypt', () => ({
compare: vi.fn(),
}));
// Mock the passport library
vi.mock('passport', () => {
const mAuthenticate = vi.fn(() => (req: Request, res: Response, next: NextFunction) => next());
@@ -71,6 +91,112 @@ describe('Passport Configuration', () => {
vi.resetModules();
});
describe('LocalStrategy (Isolated Callback Logic)', () => {
const mockReq = { ip: '127.0.0.1' } as Request;
const done = vi.fn();
it('should call done(null, user) on successful authentication', async () => {
// Arrange
const mockUser = { user_id: 'user-123', email: 'test@test.com', password_hash: 'hashed_password', failed_login_attempts: 0, last_failed_login: null };
vi.mocked(mockedDb.findUserByEmail).mockResolvedValue(mockUser);
vi.mocked(bcrypt.compare).mockResolvedValue(true as never);
// Act
if (localStrategyCallbackWrapper.callback) {
await localStrategyCallbackWrapper.callback(mockReq, 'test@test.com', 'password', done);
}
// Assert
expect(mockedDb.findUserByEmail).toHaveBeenCalledWith('test@test.com');
expect(bcrypt.compare).toHaveBeenCalledWith('password', 'hashed_password');
expect(mockedDb.resetFailedLoginAttempts).toHaveBeenCalledWith('user-123', '127.0.0.1');
expect(done).toHaveBeenCalledWith(null, { user_id: 'user-123', email: 'test@test.com', failed_login_attempts: 0, last_failed_login: null });
});
it('should call done(null, false) if user is not found', async () => {
vi.mocked(mockedDb.findUserByEmail).mockResolvedValue(undefined);
if (localStrategyCallbackWrapper.callback) {
await localStrategyCallbackWrapper.callback(mockReq, 'notfound@test.com', 'password', done);
}
expect(done).toHaveBeenCalledWith(null, false, { message: 'Incorrect email or password.' });
});
it('should call done(null, false) and increment failed attempts on password mismatch', async () => {
const mockUser = { user_id: 'user-123', email: 'test@test.com', password_hash: 'hashed_password', failed_login_attempts: 1, last_failed_login: null };
vi.mocked(mockedDb.findUserByEmail).mockResolvedValue(mockUser);
vi.mocked(bcrypt.compare).mockResolvedValue(false as never);
if (localStrategyCallbackWrapper.callback) {
await localStrategyCallbackWrapper.callback(mockReq, 'test@test.com', 'wrong_password', done);
}
expect(mockedDb.incrementFailedLoginAttempts).toHaveBeenCalledWith('user-123');
expect(mockedDb.logActivity).toHaveBeenCalledWith(expect.objectContaining({ action: 'login_failed_password' }));
expect(done).toHaveBeenCalledWith(null, false, { message: 'Incorrect email or password.' });
});
it('should call done(null, false) for an OAuth user (no password hash)', async () => {
const mockUser = { user_id: 'oauth-user', email: 'oauth@test.com', password_hash: null, failed_login_attempts: 0, last_failed_login: null };
vi.mocked(mockedDb.findUserByEmail).mockResolvedValue(mockUser);
if (localStrategyCallbackWrapper.callback) {
await localStrategyCallbackWrapper.callback(mockReq, 'oauth@test.com', 'any_password', done);
}
expect(done).toHaveBeenCalledWith(null, false, { message: 'This account was created using a social login. Please use Google or GitHub to sign in.' });
});
it('should call done(null, false) if account is locked', async () => {
const mockUser = {
user_id: 'locked-user',
email: 'locked@test.com',
password_hash: 'hashed_password',
failed_login_attempts: 5,
last_failed_login: new Date().toISOString(), // Recently locked
};
vi.mocked(mockedDb.findUserByEmail).mockResolvedValue(mockUser);
if (localStrategyCallbackWrapper.callback) {
await localStrategyCallbackWrapper.callback(mockReq, 'locked@test.com', 'any_password', done);
}
expect(done).toHaveBeenCalledWith(null, false, { message: 'Account is temporarily locked. Please try again in 15 minutes.' });
});
it('should allow login if lockout period has expired', async () => {
const mockUser = {
user_id: 'expired-lock-user',
email: 'expired@test.com',
password_hash: 'hashed_password',
failed_login_attempts: 5,
last_failed_login: new Date(Date.now() - 20 * 60 * 1000).toISOString(), // Locked 20 mins ago
};
vi.mocked(mockedDb.findUserByEmail).mockResolvedValue(mockUser);
vi.mocked(bcrypt.compare).mockResolvedValue(true as never); // Correct password
if (localStrategyCallbackWrapper.callback) {
await localStrategyCallbackWrapper.callback(mockReq, 'expired@test.com', 'correct_password', done);
}
// Should proceed to successful login
expect(mockedDb.resetFailedLoginAttempts).toHaveBeenCalled();
expect(done).toHaveBeenCalledWith(null, expect.any(Object));
});
it('should call done(err) if the database lookup fails', async () => {
const dbError = new Error('DB connection failed');
vi.mocked(mockedDb.findUserByEmail).mockRejectedValue(dbError);
if (localStrategyCallbackWrapper.callback) {
await localStrategyCallbackWrapper.callback(mockReq, 'any@test.com', 'any_password', done);
}
expect(done).toHaveBeenCalledWith(dbError);
});
});
describe('JwtStrategy (Isolated Callback Logic)', () => {
it('should call done(null, userProfile) on successful authentication', async () => {
// Arrange

View File

@@ -39,6 +39,12 @@ describe('Budget DB Service', () => {
expect(mockPoolInstance.query).toHaveBeenCalledWith('SELECT * FROM public.budgets WHERE user_id = $1 ORDER BY start_date DESC', ['user-123']);
expect(result).toEqual(mockBudgets);
});
it('should throw an error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(getBudgetsForUser('user-123')).rejects.toThrow('Failed to retrieve budgets.');
});
});
describe('createBudget', () => {
@@ -73,6 +79,15 @@ describe('Budget DB Service', () => {
await expect(createBudget('non-existent-user', budgetData)).rejects.toThrow('The specified user does not exist.');
});
it('should throw a generic error if the database query fails', async () => {
const budgetData = { name: 'Groceries', amount_cents: 50000, period: 'monthly' as const, start_date: '2024-01-01' };
const dbError = new Error('DB Error');
// Mock BEGIN to succeed, but the INSERT to fail
mockPoolInstance.query.mockResolvedValueOnce({ rows: [] }).mockRejectedValueOnce(dbError);
await expect(createBudget('user-123', budgetData)).rejects.toThrow('Failed to create budget.');
});
});
describe('updateBudget', () => {
@@ -97,6 +112,13 @@ describe('Budget DB Service', () => {
await expect(updateBudget(999, 'user-123', { name: 'Fail' }))
.rejects.toThrow('Budget not found or user does not have permission to update.');
});
it('should throw an error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(updateBudget(1, 'user-123', { name: 'Fail' }))
.rejects.toThrow('Failed to update budget.');
});
});
describe('deleteBudget', () => {
@@ -113,6 +135,12 @@ describe('Budget DB Service', () => {
await expect(deleteBudget(999, 'user-123'))
.rejects.toThrow('Budget not found or user does not have permission to delete.');
});
it('should throw an error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(deleteBudget(1, 'user-123')).rejects.toThrow('Failed to delete budget.');
});
});
describe('getSpendingByCategory', () => {
@@ -125,5 +153,11 @@ describe('Budget DB Service', () => {
expect(mockPoolInstance.query).toHaveBeenCalledWith('SELECT * FROM public.get_spending_by_category($1, $2, $3)', ['user-123', '2024-01-01', '2024-01-31']);
expect(result).toEqual(mockSpendingData);
});
it('should throw an error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(getSpendingByCategory('user-123', '2024-01-01', '2024-01-31')).rejects.toThrow('Failed to get spending analysis.');
});
});
});

View File

@@ -158,6 +158,12 @@ describe('Personalization DB Service', () => {
await findRecipesFromPantry('user-123');
expect(mockQuery).toHaveBeenCalledWith('SELECT * FROM public.find_recipes_from_pantry($1)', ['user-123']);
});
it('should throw an error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockQuery.mockRejectedValue(dbError);
await expect(findRecipesFromPantry('user-123')).rejects.toThrow('Failed to find recipes from pantry.');
});
});
describe('recommendRecipesForUser', () => {
@@ -166,6 +172,12 @@ describe('Personalization DB Service', () => {
await recommendRecipesForUser('user-123', 5);
expect(mockQuery).toHaveBeenCalledWith('SELECT * FROM public.recommend_recipes_for_user($1, $2)', ['user-123', 5]);
});
it('should throw an error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockQuery.mockRejectedValue(dbError);
await expect(recommendRecipesForUser('user-123', 5)).rejects.toThrow('Failed to recommend recipes.');
});
});
describe('getBestSalePricesForUser', () => {
@@ -174,6 +186,12 @@ describe('Personalization DB Service', () => {
await getBestSalePricesForUser('user-123');
expect(mockQuery).toHaveBeenCalledWith('SELECT * FROM public.get_best_sale_prices_for_user($1)', ['user-123']);
});
it('should throw an error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockQuery.mockRejectedValue(dbError);
await expect(getBestSalePricesForUser('user-123')).rejects.toThrow('Failed to get best sale prices.');
});
});
describe('getBestSalePricesForAllUsers', () => {
@@ -182,6 +200,12 @@ describe('Personalization DB Service', () => {
await getBestSalePricesForAllUsers();
expect(mockQuery).toHaveBeenCalledWith('SELECT * FROM public.get_best_sale_prices_for_all_users()');
});
it('should throw an error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockQuery.mockRejectedValue(dbError);
await expect(getBestSalePricesForAllUsers()).rejects.toThrow('Failed to get best sale prices for all users.');
});
});
describe('suggestPantryItemConversions', () => {
@@ -190,6 +214,12 @@ describe('Personalization DB Service', () => {
await suggestPantryItemConversions(1);
expect(mockQuery).toHaveBeenCalledWith('SELECT * FROM public.suggest_pantry_item_conversions($1)', [1]);
});
it('should throw an error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockQuery.mockRejectedValue(dbError);
await expect(suggestPantryItemConversions(1)).rejects.toThrow('Failed to suggest pantry item conversions.');
});
});
describe('findPantryItemOwner', () => {
@@ -199,6 +229,12 @@ describe('Personalization DB Service', () => {
expect(mockQuery).toHaveBeenCalledWith('SELECT user_id FROM public.pantry_items WHERE pantry_item_id = $1', [1]);
expect(result?.user_id).toBe('user-123');
});
it('should throw an error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockQuery.mockRejectedValue(dbError);
await expect(findPantryItemOwner(1)).rejects.toThrow('Failed to retrieve pantry item owner from database.');
});
});
describe('getDietaryRestrictions', () => {
@@ -207,6 +243,12 @@ describe('Personalization DB Service', () => {
await getDietaryRestrictions();
expect(mockQuery).toHaveBeenCalledWith('SELECT * FROM public.dietary_restrictions ORDER BY type, name');
});
it('should throw an error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockQuery.mockRejectedValue(dbError);
await expect(getDietaryRestrictions()).rejects.toThrow('Failed to get dietary restrictions.');
});
});
describe('getUserDietaryRestrictions', () => {
@@ -215,6 +257,12 @@ describe('Personalization DB Service', () => {
await getUserDietaryRestrictions('user-123');
expect(mockQuery).toHaveBeenCalledWith(expect.stringContaining('FROM public.dietary_restrictions dr'), ['user-123']);
});
it('should throw an error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockQuery.mockRejectedValue(dbError);
await expect(getUserDietaryRestrictions('user-123')).rejects.toThrow('Failed to get user dietary restrictions.');
});
});
describe('setUserDietaryRestrictions', () => {
@@ -243,6 +291,20 @@ describe('Personalization DB Service', () => {
await expect(setUserDietaryRestrictions('user-123', [999])).rejects.toThrow('One or more of the specified restriction IDs are invalid.');
});
it('should handle an empty array of restriction IDs', async () => {
mockQuery.mockResolvedValue({ rows: [] });
await setUserDietaryRestrictions('user-123', []);
expect(mockConnect).toHaveBeenCalled();
expect(mockQuery).toHaveBeenCalledWith('DELETE FROM public.user_dietary_restrictions WHERE user_id = $1', ['user-123']);
expect(mockQuery).not.toHaveBeenCalledWith(expect.stringContaining('INSERT INTO'));
});
it('should throw a generic error if the database query fails', async () => {
mockQuery.mockRejectedValueOnce(new Error('DB Error')); // Mock the DELETE to fail
await expect(setUserDietaryRestrictions('user-123', [1])).rejects.toThrow('Failed to set user dietary restrictions.');
expect(mockQuery).toHaveBeenCalledWith('ROLLBACK');
});
});
describe('getAppliances', () => {
@@ -251,6 +313,12 @@ describe('Personalization DB Service', () => {
await getAppliances();
expect(mockQuery).toHaveBeenCalledWith('SELECT * FROM public.appliances ORDER BY name');
});
it('should throw an error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockQuery.mockRejectedValue(dbError);
await expect(getAppliances()).rejects.toThrow('Failed to get appliances.');
});
});
describe('getUserAppliances', () => {
@@ -259,6 +327,12 @@ describe('Personalization DB Service', () => {
await getUserAppliances('user-123');
expect(mockQuery).toHaveBeenCalledWith(expect.stringContaining('FROM public.appliances a'), ['user-123']);
});
it('should throw an error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockQuery.mockRejectedValue(dbError);
await expect(getUserAppliances('user-123')).rejects.toThrow('Failed to get user appliances.');
});
});
describe('setUserAppliances', () => {
@@ -313,5 +387,11 @@ describe('Personalization DB Service', () => {
await getRecipesForUserDiets('user-123');
expect(mockQuery).toHaveBeenCalledWith('SELECT * FROM public.get_recipes_for_user_diets($1)', ['user-123']);
});
it('should throw an error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockQuery.mockRejectedValue(dbError);
await expect(getRecipesForUserDiets('user-123')).rejects.toThrow('Failed to get recipes compatible with user diet.');
});
});
});

View File

@@ -130,6 +130,12 @@ describe('Recipe DB Service', () => {
expect(mockQuery).toHaveBeenCalledWith('DELETE FROM public.favorite_recipes WHERE user_id = $1 AND recipe_id = $2', ['user-123', 1]);
});
it('should throw an error if the favorite recipe is not found', async () => {
// Simulate the DB returning 0 rows affected
mockQuery.mockResolvedValue({ rowCount: 0 });
await expect(removeFavoriteRecipe('user-123', 999)).rejects.toThrow('Favorite recipe not found for this user.');
});
it('should throw a generic error if the database query fails', async () => {
const dbError = new Error('DB Connection Error');
mockQuery.mockRejectedValue(dbError);

View File

@@ -94,7 +94,11 @@ export async function addFavoriteRecipe(userId: string, recipeId: number): Promi
*/
export async function removeFavoriteRecipe(userId: string, recipeId: number): Promise<void> {
try {
await getPool().query('DELETE FROM public.favorite_recipes WHERE user_id = $1 AND recipe_id = $2', [userId, recipeId]);
const res = await getPool().query('DELETE FROM public.favorite_recipes WHERE user_id = $1 AND recipe_id = $2', [userId, recipeId]);
if (res.rowCount === 0) {
// This indicates the favorite relationship did not exist.
throw new Error('Favorite recipe not found for this user.');
}
} catch (error) {
logger.error('Database error in removeFavoriteRecipe:', { error, userId, recipeId });
throw new Error('Failed to remove favorite recipe.');

View File

@@ -21,6 +21,8 @@ import {
getShoppingTripHistory,
createReceipt,
findReceiptOwner,
processReceiptItems,
findDealsForReceipt,
} from './shopping.db';
// Mock the logger to prevent console output during tests
@@ -157,6 +159,11 @@ describe('Shopping DB Service', () => {
await expect(updateShoppingListItem(999, { quantity: 5 })).rejects.toThrow('Shopping list item not found.');
});
it('should throw an error if no valid fields are provided to update', async () => {
// The function should throw before even querying the database.
await expect(updateShoppingListItem(1, {})).rejects.toThrow('No valid fields to update.');
});
it('should throw a generic error if the database query fails', async () => {
const dbError = new Error('DB Connection Error');
mockPoolInstance.query.mockRejectedValue(dbError);
@@ -317,5 +324,51 @@ describe('Shopping DB Service', () => {
expect(mockPoolInstance.query).toHaveBeenCalledWith('SELECT user_id FROM public.receipts WHERE receipt_id = $1', [1]);
expect(result).toEqual(mockOwner);
});
it('should throw a generic error if the database query fails', async () => {
const dbError = new Error('DB Error');
mockPoolInstance.query.mockRejectedValue(dbError);
await expect(findReceiptOwner(1)).rejects.toThrow('Failed to retrieve receipt owner from database.');
});
});
describe('processReceiptItems', () => {
it('should call the process_receipt_items database function with correct parameters', async () => {
mockPoolInstance.query.mockResolvedValue({ rows: [] });
const items = [{ raw_item_description: 'Milk', price_paid_cents: 399 }];
await processReceiptItems(1, items);
const expectedItemsWithQuantity = [{ raw_item_description: 'Milk', price_paid_cents: 399, quantity: 1 }];
expect(mockPoolInstance.query).toHaveBeenCalledWith(
'SELECT public.process_receipt_items($1, $2, $3)',
[1, JSON.stringify(expectedItemsWithQuantity), JSON.stringify(expectedItemsWithQuantity)]
);
});
it('should update receipt status to "failed" on error', async () => {
const dbError = new Error('Function error');
// The first query (process_receipt_items) fails
mockPoolInstance.query.mockRejectedValueOnce(dbError);
// The second query (UPDATE status to 'failed') succeeds
mockPoolInstance.query.mockResolvedValueOnce({ rows: [] });
const items = [{ raw_item_description: 'Milk', price_paid_cents: 399 }];
await expect(processReceiptItems(1, items)).rejects.toThrow('Failed to process and save receipt items.');
// Verify that the status was updated to 'failed' in the catch block
expect(mockPoolInstance.query).toHaveBeenCalledWith("UPDATE public.receipts SET status = 'failed' WHERE id = $1", [1]);
});
});
describe('findDealsForReceipt', () => {
it('should call the find_deals_for_receipt_items database function', async () => {
const mockDeals = [{ receipt_item_id: 1, master_item_id: 10, item_name: 'Milk', price_paid_cents: 399, current_best_price_in_cents: 350, potential_savings_cents: 49, deal_store_name: 'Grocer', flyer_id: 101 }];
mockPoolInstance.query.mockResolvedValue({ rows: mockDeals });
const result = await findDealsForReceipt(1);
expect(mockPoolInstance.query).toHaveBeenCalledWith('SELECT * FROM public.find_deals_for_receipt_items($1)', [1]);
expect(result).toEqual(mockDeals);
});
});
});

View File

@@ -357,7 +357,7 @@ export async function createReceipt(userId: string, receiptImageUrl: string): Pr
*/
export async function processReceiptItems(
receiptId: number,
items: Omit<ReceiptItem, 'id' | 'receipt_id' | 'status' | 'master_item_id' | 'product_id' | 'quantity'>[]
items: Omit<ReceiptItem, 'receipt_item_id' | 'receipt_id' | 'status' | 'master_item_id' | 'product_id' | 'quantity'>[]
): Promise<void> {
try {
const itemsWithQuantity = items.map(item => ({ ...item, quantity: 1 }));

View File

@@ -12,8 +12,8 @@ import { getWatchedItems } from './personalization.db';
interface DbUser {
user_id: string; // UUID
email: string;
// The User type from types.ts is what is returned to the client, not the full DbUser.
password_hash: string;
// The password_hash can be null for users who signed up via OAuth.
password_hash: string | null;
refresh_token?: string | null;
failed_login_attempts: number;
last_failed_login: string | null; // This will be a date string from the DB

View File

@@ -1,5 +1,5 @@
// src/utils/checksum.test.ts
import { describe, it, expect } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { generateFileChecksum } from './checksum';
describe('generateFileChecksum', () => {
@@ -43,4 +43,56 @@ describe('generateFileChecksum', () => {
// Assert
expect(checksum).toBe(expectedChecksum);
});
describe('with fallback mechanisms', () => {
const originalCrypto = global.crypto;
beforeEach(() => {
// Mock console.warn to prevent logs from appearing in test output
vi.spyOn(console, 'warn').mockImplementation(() => {});
});
afterEach(() => {
// Restore any mocked globals
vi.restoreAllMocks();
global.crypto = originalCrypto;
});
it('should use FileReader fallback if file.arrayBuffer is not a function', async () => {
const fileContent = 'fallback test';
const file = new File([fileContent], 'test.txt', { type: 'text/plain' });
// Simulate an environment where file.arrayBuffer does not exist
Object.defineProperty(file, 'arrayBuffer', { value: undefined });
const checksum = await generateFileChecksum(file);
// SHA-256 for "fallback test"
expect(checksum).toBe('33d303e4c33c5a1e695575069916f7533383a9552887393d796c3a688b438a45');
expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('file.arrayBuffer is not a function'));
});
it('should use FileReader fallback if file.arrayBuffer throws an error', async () => {
const fileContent = 'error fallback';
const file = new File([fileContent], 'test.txt', { type: 'text/plain' });
// Mock the function to throw an error
vi.spyOn(file, 'arrayBuffer').mockRejectedValue(new Error('Simulated error'));
const checksum = await generateFileChecksum(file);
// SHA-256 for "error fallback"
expect(checksum).toBe('a876a745353597a893f0a997b834155375399547535083925239618a1233525b');
expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('file.arrayBuffer() threw an error'));
});
it('should throw an error if crypto.subtle is not available', async () => {
// Simulate an environment where crypto.subtle is missing
Object.defineProperty(global, 'crypto', {
value: { subtle: undefined },
configurable: true,
});
const file = new File(['test'], 'test.txt', { type: 'text/plain' });
// The function will throw because crypto.subtle.digest is not a function
await expect(generateFileChecksum(file)).rejects.toThrow();
});
});
});

View File

@@ -0,0 +1,77 @@
// src/utils/imageProcessor.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
// --- Hoisted Mocks ---
const mocks = vi.hoisted(() => {
// Create a chainable mock for the sharp library
const toFile = vi.fn().mockResolvedValue({ info: 'mocked' });
const webp = vi.fn(() => ({ toFile }));
const resize = vi.fn(() => ({ webp }));
const sharpInstance = { resize };
return {
sharp: vi.fn(() => sharpInstance),
resize,
webp,
toFile,
mkdir: vi.fn().mockResolvedValue(undefined),
};
});
// --- Mock Modules ---
vi.mock('sharp', () => ({
__esModule: true,
default: mocks.sharp,
}));
vi.mock('node:fs/promises', () => ({
default: {
mkdir: mocks.mkdir,
},
}));
vi.mock('../services/logger.server', () => ({
logger: {
info: vi.fn(),
error: vi.fn(),
},
}));
// --- Import the function to be tested ---
import { generateFlyerIcon } from './imageProcessor';
import { logger } from '../services/logger.server';
describe('generateFlyerIcon', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should process the image and return the correct icon filename', async () => {
const sourceImagePath = '/path/to/flyer image (1).jpg';
const iconsDirectory = '/path/to/icons';
const result = await generateFlyerIcon(sourceImagePath, iconsDirectory);
// Check that the icons directory was created
expect(mocks.mkdir).toHaveBeenCalledWith(iconsDirectory, { recursive: true });
// Check that sharp was called with the correct source
expect(mocks.sharp).toHaveBeenCalledWith(sourceImagePath);
// Check the processing chain
expect(mocks.resize).toHaveBeenCalledWith(64, 64, { fit: 'cover' });
expect(mocks.webp).toHaveBeenCalledWith({ quality: 80 });
expect(mocks.toFile).toHaveBeenCalledWith('/path/to/icons/icon-flyer-image-1.webp');
// Check the returned filename
expect(result).toBe('icon-flyer-image-1.webp');
});
it('should throw an error if sharp fails to process the image', async () => {
const sharpError = new Error('Invalid image buffer');
mocks.toFile.mockRejectedValue(sharpError);
await expect(generateFlyerIcon('/path/to/bad-image.jpg', '/path/to/icons')).rejects.toThrow('Icon generation failed.');
expect(logger.error).toHaveBeenCalledWith('Failed to generate flyer icon:', expect.any(Object));
});
});

View File

@@ -23,7 +23,10 @@ const mockToBlob = vi.fn((callback) => {
callback(blob);
});
const mockGetContext = vi.fn(() => ({}));
// Define the mock with a more accurate type signature to allow returning null.
// This satisfies TypeScript when we later use `mockReturnValueOnce(null)`.
const mockGetContext = vi.fn<(contextId: '2d') => CanvasRenderingContext2D | null>(() => ({} as CanvasRenderingContext2D));
// We need to mock document.createElement to return our mock canvas
const originalCreateElement = document.createElement;
@@ -110,6 +113,27 @@ describe('pdfConverter', () => {
expect(onProgress).toHaveBeenCalledWith(3, 3);
});
it('should throw an error if getContext returns null', async () => {
const pdfFile = new File(['pdf-content'], 'flyer.pdf', { type: 'application/pdf' });
// Mock getContext to fail for the first page
mockGetContext.mockReturnValueOnce(null);
await expect(convertPdfToImageFiles(pdfFile)).rejects.toThrow('Could not get canvas context');
});
it('should use FileReader fallback if file.arrayBuffer fails', async () => {
const pdfFile = new File(['pdf-content'], 'flyer.pdf', { type: 'application/pdf' });
// Temporarily break the arrayBuffer method on the mock File instance
vi.spyOn(pdfFile, 'arrayBuffer').mockRejectedValueOnce(new Error('arrayBuffer not available'));
// The test should still pass by using the FileReader fallback
await expect(convertPdfToImageFiles(pdfFile)).resolves.toBeDefined();
// Verify that getDocument was still called, indicating the fallback worked
const { getDocument } = await import('pdfjs-dist');
expect(getDocument).toHaveBeenCalled();
});
it('should throw an error if conversion results in zero images for a non-empty PDF', async () => {
const pdfFile = new File(['pdf-content'], 'flyer.pdf', { type: 'application/pdf' });

View File

@@ -0,0 +1,37 @@
// src/utils/stringUtils.test.ts
import { describe, it, expect } from 'vitest';
import { sanitizeFilename } from './stringUtils';
describe('sanitizeFilename', () => {
it('should replace single spaces with a hyphen', () => {
expect(sanitizeFilename('my test file.jpg')).toBe('my-test-file.jpg');
});
it('should replace multiple consecutive spaces with a single hyphen', () => {
expect(sanitizeFilename('my test file.jpg')).toBe('my-test-file.jpg');
});
it('should remove most special characters', () => {
expect(sanitizeFilename('file!@#$%^&*()+=[]{}\\|;:\'",<>?~`.png')).toBe('file.png');
});
it('should replace multiple consecutive hyphens with a single one', () => {
expect(sanitizeFilename('a---b--c.gif')).toBe('a-b-c.gif');
});
it('should not convert the filename to lowercase', () => {
expect(sanitizeFilename('MyFileWithCaps.JPEG')).toBe('MyFileWithCaps.JPEG');
});
it('should handle a mix of issues correctly', () => {
expect(sanitizeFilename(' A (very) messy -- file name!.pdf')).toBe('A-very-messy-file-name.pdf');
});
it('should leave an already clean filename unchanged', () => {
expect(sanitizeFilename('clean-filename_123.txt')).toBe('clean-filename_123.txt');
});
it('should preserve underscores and periods', () => {
expect(sanitizeFilename('archive_2024.01.01.zip')).toBe('archive_2024.01.01.zip');
});
});