App.tsx refactor + even more unit tests
All checks were successful
Deploy to Web Server flyer-crawler.projectium.com / deploy (push) Successful in 8m28s
All checks were successful
Deploy to Web Server flyer-crawler.projectium.com / deploy (push) Successful in 8m28s
This commit is contained in:
128
src/App.test.tsx
128
src/App.test.tsx
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
586
src/App.tsx
586
src/App.tsx
@@ -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;
|
||||
88
src/components/FlyerCountDisplay.test.tsx
Normal file
88
src/components/FlyerCountDisplay.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
21
src/components/FlyerCountDisplay.tsx
Normal file
21
src/components/FlyerCountDisplay.tsx
Normal 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>;
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
147
src/hooks/useActiveDeals.test.tsx
Normal file
147
src/hooks/useActiveDeals.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
88
src/hooks/useActiveDeals.tsx
Normal file
88
src/hooks/useActiveDeals.tsx
Normal 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
118
src/hooks/useAuth.tsx
Normal 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
110
src/hooks/useData.tsx
Normal 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;
|
||||
};
|
||||
170
src/hooks/useShoppingLists.test.tsx
Normal file
170
src/hooks/useShoppingLists.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
131
src/hooks/useShoppingLists.tsx
Normal file
131
src/hooks/useShoppingLists.tsx
Normal 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,
|
||||
};
|
||||
};
|
||||
61
src/hooks/useWatchedItems.tsx
Normal file
61
src/hooks/useWatchedItems.tsx
Normal 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,
|
||||
};
|
||||
};
|
||||
@@ -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
110
src/layouts/MainLayout.tsx
Normal 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
66
src/pages/HomePage.tsx
Normal 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} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
83
src/routes/admin.system.routes.test.ts
Normal file
83
src/routes/admin.system.routes.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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' });
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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.');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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.');
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
77
src/utils/imageProcessor.test.ts
Normal file
77
src/utils/imageProcessor.test.ts
Normal 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));
|
||||
});
|
||||
});
|
||||
@@ -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' });
|
||||
|
||||
|
||||
37
src/utils/stringUtils.test.ts
Normal file
37
src/utils/stringUtils.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user