we went to mocks - now going to unit-setup.ts - centralized
This commit is contained in:
@@ -6,15 +6,6 @@ import { MemoryRouter, Outlet } from 'react-router-dom';
|
|||||||
import App from './App';
|
import App from './App';
|
||||||
import * as apiClient from './services/apiClient';
|
import * as apiClient from './services/apiClient';
|
||||||
|
|
||||||
// Mock pdfjs-dist to prevent the "DOMMatrix is not defined" error in JSDOM.
|
|
||||||
// The library expects browser APIs that don't exist in the test environment.
|
|
||||||
// We provide a more complete mock that includes stubs for the functions it uses.
|
|
||||||
vi.mock('pdfjs-dist', () => ({
|
|
||||||
GlobalWorkerOptions: { workerSrc: '' },
|
|
||||||
getDocument: vi.fn(() => ({
|
|
||||||
promise: Promise.resolve({ getPage: vi.fn() }),
|
|
||||||
})),
|
|
||||||
}));
|
|
||||||
// Mock child components to isolate the App component
|
// 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/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/ExtractedDataTable', () => ({ ExtractedDataTable: () => <div data-testid="extracted-data-table-mock">Extracted Data Table</div> }));
|
||||||
@@ -43,19 +34,6 @@ vi.mock('./features/auth/components/AnonymousUserBanner', () => ({ AnonymousUser
|
|||||||
vi.mock('./pages/VoiceLabPage', () => ({ VoiceLabPage: () => <div data-testid="voice-lab-page-mock">Voice Lab Page</div> }));
|
vi.mock('./pages/VoiceLabPage', () => ({ VoiceLabPage: () => <div data-testid="voice-lab-page-mock">Voice Lab Page</div> }));
|
||||||
vi.mock('./components/WhatsNewModal', () => ({ WhatsNewModal: () => <div data-testid="whats-new-modal-mock">What's New Modal</div> }));
|
vi.mock('./components/WhatsNewModal', () => ({ WhatsNewModal: () => <div data-testid="whats-new-modal-mock">What's New Modal</div> }));
|
||||||
|
|
||||||
// Mock API client
|
|
||||||
vi.mock('./services/apiClient', async (importOriginal) => {
|
|
||||||
const actual = await importOriginal<typeof apiClient>();
|
|
||||||
return {
|
|
||||||
...actual,
|
|
||||||
getAuthenticatedUserProfile: vi.fn(),
|
|
||||||
fetchFlyers: vi.fn(),
|
|
||||||
fetchMasterItems: vi.fn(),
|
|
||||||
fetchWatchedItems: vi.fn(),
|
|
||||||
fetchShoppingLists: vi.fn(),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// By casting the apiClient to `Mocked<typeof apiClient>`, we get type-safe access
|
// By casting the apiClient to `Mocked<typeof apiClient>`, we get type-safe access
|
||||||
// to Vitest's mock functions like `mockResolvedValue`.
|
// to Vitest's mock functions like `mockResolvedValue`.
|
||||||
// The `Mocked` type is imported directly from 'vitest' to avoid the namespace
|
// The `Mocked` type is imported directly from 'vitest' to avoid the namespace
|
||||||
@@ -68,10 +46,10 @@ describe('App Component', () => {
|
|||||||
// Clear local storage to prevent auth state from leaking between tests.
|
// Clear local storage to prevent auth state from leaking between tests.
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
// Default mocks for API calls
|
// Default mocks for API calls
|
||||||
mockedApiClient.fetchFlyers.mockResolvedValue([]);
|
mockedApiClient.fetchFlyers.mockResolvedValue(new Response(JSON.stringify([])));
|
||||||
mockedApiClient.fetchMasterItems.mockResolvedValue([]);
|
mockedApiClient.fetchMasterItems.mockResolvedValue(new Response(JSON.stringify([])));
|
||||||
mockedApiClient.fetchWatchedItems.mockResolvedValue([]);
|
mockedApiClient.fetchWatchedItems.mockResolvedValue(new Response(JSON.stringify([])));
|
||||||
mockedApiClient.fetchShoppingLists.mockResolvedValue([]);
|
mockedApiClient.fetchShoppingLists.mockResolvedValue(new Response(JSON.stringify([])));
|
||||||
mockedApiClient.getAuthenticatedUserProfile.mockRejectedValue(new Error('Not authenticated'));
|
mockedApiClient.getAuthenticatedUserProfile.mockRejectedValue(new Error('Not authenticated'));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -101,13 +79,14 @@ describe('App Component', () => {
|
|||||||
|
|
||||||
it('should render the admin page on the /admin route', async () => {
|
it('should render the admin page on the /admin route', async () => {
|
||||||
// Mock a logged-in admin user
|
// Mock a logged-in admin user
|
||||||
mockedApiClient.getAuthenticatedUserProfile.mockResolvedValue({
|
const mockAdminProfile = {
|
||||||
// The Profile type requires user_id at the top level, in addition
|
// The Profile type requires user_id at the top level, in addition
|
||||||
// to the nested user object.
|
// to the nested user object.
|
||||||
user_id: 'admin-id',
|
user_id: 'admin-id',
|
||||||
user: { user_id: 'admin-id', email: 'admin@example.com' },
|
user: { user_id: 'admin-id', email: 'admin@example.com' },
|
||||||
role: 'admin',
|
role: 'admin',
|
||||||
});
|
};
|
||||||
|
mockedApiClient.getAuthenticatedUserProfile.mockResolvedValue(new Response(JSON.stringify(mockAdminProfile)));
|
||||||
|
|
||||||
// The app's auth hook checks for a token before fetching the user profile.
|
// 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.
|
// We need to mock it to allow the authentication flow to proceed.
|
||||||
|
|||||||
280
src/App.tsx
280
src/App.tsx
@@ -1,6 +1,7 @@
|
|||||||
// src/App.tsx
|
// src/App.tsx
|
||||||
import React, { useState, useCallback, useEffect } from 'react';
|
import React, { useState, useCallback, useEffect } from 'react';
|
||||||
import { Routes, Route } from 'react-router-dom';
|
import { Routes, Route } from 'react-router-dom';
|
||||||
|
import { useApi } from './hooks/useApi';
|
||||||
import { Toaster } from 'react-hot-toast';
|
import { Toaster } from 'react-hot-toast';
|
||||||
import { FlyerDisplay } from './features/flyer/FlyerDisplay';
|
import { FlyerDisplay } from './features/flyer/FlyerDisplay';
|
||||||
import { ExtractedDataTable } from './features/flyer/ExtractedDataTable';
|
import { ExtractedDataTable } from './features/flyer/ExtractedDataTable';
|
||||||
@@ -10,11 +11,11 @@ import * as pdfjsLib from 'pdfjs-dist';
|
|||||||
import { ErrorDisplay } from './components/ErrorDisplay';
|
import { ErrorDisplay } from './components/ErrorDisplay';
|
||||||
import { Header } from './components/Header';
|
import { Header } from './components/Header';
|
||||||
import { logger } from './services/logger'; // This is correct
|
import { logger } from './services/logger'; // This is correct
|
||||||
import { isImageAFlyer, extractCoreDataFromImage, extractAddressFromImage, extractLogoFromImage } from './services/aiApiClient';
|
import * as aiApiClient from './services/aiApiClient';
|
||||||
import type { FlyerItem, Flyer, MasterGroceryItem, DealItem, ProcessingStage, StageStatus, Profile, ShoppingList, ShoppingListItem, User } from './types';
|
import type { FlyerItem, Flyer, MasterGroceryItem, DealItem, ProcessingStage, StageStatus, Profile, ShoppingList, ShoppingListItem, User, UserProfile } from './types';
|
||||||
import { BulkImporter } from './features/flyer/BulkImporter';
|
import { BulkImporter } from './features/flyer/BulkImporter';
|
||||||
import { PriceHistoryChart } from './features/charts/PriceHistoryChart'; // This import seems to have a supabase dependency, but the component is not provided. Assuming it will be updated separately.
|
import { PriceHistoryChart } from './features/charts/PriceHistoryChart'; // This import seems to have a supabase dependency, but the component is not provided. Assuming it will be updated separately.
|
||||||
import { getAuthenticatedUserProfile, fetchFlyers as apiFetchFlyers, fetchMasterItems as apiFetchMasterItems, fetchWatchedItems as apiFetchWatchedItems, addWatchedItem as apiAddWatchedItem, removeWatchedItem as apiRemoveWatchedItem, fetchShoppingLists as apiFetchShoppingLists, createShoppingList as apiCreateShoppingList, deleteShoppingList as apiDeleteShoppingList, addShoppingListItem as apiAddShoppingListItem, updateShoppingListItem as apiUpdateShoppingListItem, removeShoppingListItem as apiRemoveShoppingListItem, processFlyerFile, fetchFlyerItems as apiFetchFlyerItems, fetchFlyerItemsForFlyers as apiFetchFlyerItemsForFlyers, countFlyerItemsForFlyers as apiCountFlyerItemsForFlyers, uploadLogoAndUpdateStore } from './services/apiClient'; // updateUserPreferences is no longer called directly from App.tsx
|
import * as apiClient from './services/apiClient'; // updateUserPreferences is no longer called directly from App.tsx
|
||||||
import { FlyerList } from './features/flyer/FlyerList';
|
import { FlyerList } from './features/flyer/FlyerList';
|
||||||
import { recordProcessingTime, getAverageProcessingTime } from './utils/processingTimer';
|
import { recordProcessingTime, getAverageProcessingTime } from './utils/processingTimer';
|
||||||
import { ProcessingStatus } from './features/flyer/ProcessingStatus';
|
import { ProcessingStatus } from './features/flyer/ProcessingStatus';
|
||||||
@@ -55,11 +56,39 @@ type AuthStatus = 'SIGNED_OUT' | 'ANONYMOUS' | 'AUTHENTICATED';
|
|||||||
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.mjs', import.meta.url).toString();
|
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.mjs', import.meta.url).toString();
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [flyers, setFlyers] = useState<Flyer[]>([]);
|
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[]>([]);
|
||||||
|
const [dataError, setDataError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
const [flyersRes, masterItemsRes, watchedItemsRes, shoppingListsRes] = await Promise.all([
|
||||||
|
apiClient.fetchFlyers(),
|
||||||
|
apiClient.fetchMasterItems(),
|
||||||
|
user ? apiClient.fetchWatchedItems() : Promise.resolve(new Response(JSON.stringify([]))),
|
||||||
|
user ? apiClient.fetchShoppingLists() : Promise.resolve(new Response(JSON.stringify([]))),
|
||||||
|
]);
|
||||||
|
setFlyers(await flyersRes.json());
|
||||||
|
setMasterItems(await masterItemsRes.json());
|
||||||
|
setWatchedItems(await watchedItemsRes.json());
|
||||||
|
setShoppingLists(await shoppingListsRes.json());
|
||||||
|
} catch (e) { setDataError(e instanceof Error ? e.message : String(e)); }
|
||||||
|
};
|
||||||
|
fetchData();
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
const [selectedFlyer, setSelectedFlyer] = useState<Flyer | null>(null);
|
const [selectedFlyer, setSelectedFlyer] = useState<Flyer | null>(null);
|
||||||
const [flyerItems, setFlyerItems] = useState<FlyerItem[]>([]);
|
const [flyerItems, setFlyerItems] = useState<FlyerItem[]>([]);
|
||||||
const [watchedItems, setWatchedItems] = useState<MasterGroceryItem[]>([]);
|
// Local state for watched items and shopping lists to allow for optimistic updates
|
||||||
const [masterItems, setMasterItems] = useState<MasterGroceryItem[]>([]);
|
const [localWatchedItems, setLocalWatchedItems] = useState<MasterGroceryItem[]>([]);
|
||||||
|
const [localShoppingLists, setLocalShoppingLists] = useState<ShoppingList[]>([]);
|
||||||
|
|
||||||
const [activeDeals, setActiveDeals] = useState<DealItem[]>([]);
|
const [activeDeals, setActiveDeals] = useState<DealItem[]>([]);
|
||||||
const [activeDealsLoading, setActiveDealsLoading] = useState(false);
|
const [activeDealsLoading, setActiveDealsLoading] = useState(false);
|
||||||
const [totalActiveItems, setTotalActiveItems] = useState(0);
|
const [totalActiveItems, setTotalActiveItems] = useState(0);
|
||||||
@@ -76,7 +105,6 @@ function App() {
|
|||||||
errors: { fileName: string; message: string }[];
|
errors: { fileName: string; message: string }[];
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
const [isReady, setIsReady] = useState(false); // This will now be controlled by a simple timer.
|
|
||||||
const [isDarkMode, setIsDarkMode] = useState(false);
|
const [isDarkMode, setIsDarkMode] = useState(false);
|
||||||
const [unitSystem, setUnitSystem] = useState<'metric' | 'imperial'>('imperial');
|
const [unitSystem, setUnitSystem] = useState<'metric' | 'imperial'>('imperial');
|
||||||
const [profile, setProfile] = useState<Profile | null>(null);
|
const [profile, setProfile] = useState<Profile | null>(null);
|
||||||
@@ -88,11 +116,19 @@ function App() {
|
|||||||
const [processingStages, setProcessingStages] = useState<ProcessingStage[]>([]);
|
const [processingStages, setProcessingStages] = useState<ProcessingStage[]>([]);
|
||||||
const [estimatedTime, setEstimatedTime] = useState(0);
|
const [estimatedTime, setEstimatedTime] = useState(0);
|
||||||
const [pageProgress, setPageProgress] = useState<{current: number, total: number} | null>(null);
|
const [pageProgress, setPageProgress] = useState<{current: number, total: number} | null>(null);
|
||||||
const [user, setUser] = useState<User | null>(null); // Moved here for clarity, already existed.
|
|
||||||
|
|
||||||
const [shoppingLists, setShoppingLists] = useState<ShoppingList[]>([]);
|
|
||||||
const [activeListId, setActiveListId] = useState<number | null>(null);
|
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
|
// Effect to set initial theme based on user profile, local storage, or system preference
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (profile && profile.preferences?.darkMode !== undefined) {
|
if (profile && profile.preferences?.darkMode !== undefined) {
|
||||||
@@ -122,13 +158,6 @@ function App() {
|
|||||||
}
|
}
|
||||||
}, [profile]);
|
}, [profile]);
|
||||||
|
|
||||||
// Effect to mark the app as "ready" for data fetching.
|
|
||||||
// This replaces the onReady callback from the now-removed SystemCheck component.
|
|
||||||
useEffect(() => {
|
|
||||||
const timer = setTimeout(() => setIsReady(true), 250);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// This is the login handler that will be passed to the ProfileManager component.
|
// This is the login handler that will be passed to the ProfileManager component.
|
||||||
const handleLoginSuccess = async (loggedInUser: User, token: string) => {
|
const handleLoginSuccess = async (loggedInUser: User, token: string) => {
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -138,16 +167,19 @@ function App() {
|
|||||||
try {
|
try {
|
||||||
// Fetch all essential user data *before* setting the final authenticated state.
|
// 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.
|
// This ensures the app doesn't enter an inconsistent state if one of these calls fails.
|
||||||
const [userProfile, watchedData] = await Promise.all([
|
const [profileResponse, watchedResponse] = await Promise.all([
|
||||||
getAuthenticatedUserProfile(),
|
apiClient.getAuthenticatedUserProfile(),
|
||||||
apiFetchWatchedItems(),
|
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.
|
// Now that all data is successfully fetched, update the application state.
|
||||||
|
setUser(loggedInUser); // Set user first
|
||||||
setProfile(userProfile);
|
setProfile(userProfile);
|
||||||
setUser(loggedInUser); // Or userProfile.user, which should be identical
|
|
||||||
setAuthStatus('AUTHENTICATED');
|
setAuthStatus('AUTHENTICATED');
|
||||||
setWatchedItems(watchedData);
|
setLocalWatchedItems(watchedData);
|
||||||
|
|
||||||
// The fetchShoppingLists function will be triggered by the useEffect below
|
// The fetchShoppingLists function will be triggered by the useEffect below
|
||||||
// now that the user state has been set.
|
// now that the user state has been set.
|
||||||
@@ -161,64 +193,6 @@ function App() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// The toggleDarkMode and toggleUnitSystem functions are now handled within ProfileManager.tsx.
|
|
||||||
// App.tsx will react to changes in profile.preferences via its useEffects.
|
|
||||||
// The Header component will no longer receive these props directly.
|
|
||||||
|
|
||||||
const fetchFlyers = useCallback(async () => { // Renamed from apiFetchFlyers to avoid conflict
|
|
||||||
try {
|
|
||||||
const allFlyers = await apiFetchFlyers();
|
|
||||||
setFlyers(allFlyers);
|
|
||||||
} catch(e) {
|
|
||||||
const errorMessage = e instanceof Error ? e.message : String(e);
|
|
||||||
setError(`Could not fetch flyers: ${errorMessage}`);
|
|
||||||
}
|
|
||||||
}, []); // No dependencies on user or profile, as this is general data
|
|
||||||
|
|
||||||
const fetchWatchedItems = useCallback(async () => {
|
|
||||||
if (!user) { // Check for authenticated user
|
|
||||||
setWatchedItems([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const items = await apiFetchWatchedItems();
|
|
||||||
setWatchedItems(items);
|
|
||||||
} catch (e) {
|
|
||||||
const errorMessage = e instanceof Error ? e.message : String(e);
|
|
||||||
setError(`Could not fetch watched items: ${errorMessage}`);
|
|
||||||
}
|
|
||||||
}, [user]);
|
|
||||||
|
|
||||||
const fetchShoppingLists = useCallback(async () => {
|
|
||||||
if (!user) { // Check for authenticated user
|
|
||||||
setShoppingLists([]);
|
|
||||||
setActiveListId(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const lists = await apiFetchShoppingLists();
|
|
||||||
setShoppingLists(lists);
|
|
||||||
if (lists.length > 0 && !activeListId) {
|
|
||||||
setActiveListId(lists[0].shopping_list_id);
|
|
||||||
} else if (lists.length === 0) {
|
|
||||||
setActiveListId(null);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
const errorMessage = e instanceof Error ? e.message : String(e);
|
|
||||||
setError(`Could not fetch shopping lists: ${errorMessage}`);
|
|
||||||
}
|
|
||||||
}, [user, activeListId]); // user is a dependency to ensure we fetch lists for the correct user.
|
|
||||||
|
|
||||||
const fetchMasterItems = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const items = await apiFetchMasterItems();
|
|
||||||
setMasterItems(items);
|
|
||||||
} catch (e) {
|
|
||||||
const errorMessage = e instanceof Error ? e.message : String(e);
|
|
||||||
setError(`Could not fetch master items: ${errorMessage}`);
|
|
||||||
}
|
|
||||||
}, []); // No dependencies on user or profile, as this is general data
|
|
||||||
|
|
||||||
// Effect to check for an existing token on initial app load.
|
// Effect to check for an existing token on initial app load.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkAuthToken = async () => {
|
const checkAuthToken = async () => {
|
||||||
@@ -226,17 +200,12 @@ function App() {
|
|||||||
if (token) {
|
if (token) {
|
||||||
logger.info('Found auth token in local storage. Validating...');
|
logger.info('Found auth token in local storage. Validating...');
|
||||||
try {
|
try {
|
||||||
// Call the protected backend route to validate the token and get the full user profile.
|
const response = await apiClient.getAuthenticatedUserProfile();
|
||||||
const userProfile = await getAuthenticatedUserProfile();
|
const userProfile = await response.json();
|
||||||
// The user object is nested within the UserProfile object.
|
// The user object is nested within the UserProfile object.
|
||||||
setUser(userProfile.user);
|
setUser(userProfile.user);
|
||||||
setProfile(userProfile);
|
setProfile(userProfile);
|
||||||
setAuthStatus('AUTHENTICATED');
|
setAuthStatus('AUTHENTICATED');
|
||||||
|
|
||||||
// Fetch user-specific data now that authentication is confirmed.
|
|
||||||
// These functions are safe to call here because they check for the user internally.
|
|
||||||
fetchWatchedItems();
|
|
||||||
fetchShoppingLists();
|
|
||||||
logger.info('Token validated successfully.', { user: userProfile.user });
|
logger.info('Token validated successfully.', { user: userProfile.user });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.warn('Auth token validation failed. Clearing token.', { error: e });
|
logger.warn('Auth token validation failed. Clearing token.', { error: e });
|
||||||
@@ -250,7 +219,7 @@ function App() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
checkAuthToken();
|
checkAuthToken();
|
||||||
}, [fetchWatchedItems, fetchShoppingLists]); // Add callbacks to dependency array.
|
}, []); // Runs only once on mount. Intentionally empty.
|
||||||
|
|
||||||
// Effect to handle the token from Google OAuth redirect
|
// Effect to handle the token from Google OAuth redirect
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -262,8 +231,8 @@ function App() {
|
|||||||
// The token is already a valid access token from our server.
|
// 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.
|
// We can use it to fetch the user profile and complete the login flow.
|
||||||
localStorage.setItem('authToken', googleToken);
|
localStorage.setItem('authToken', googleToken);
|
||||||
getAuthenticatedUserProfile()
|
apiClient.getAuthenticatedUserProfile().then(response => response.json())
|
||||||
.then(userProfile => {
|
.then((userProfile: UserProfile) => {
|
||||||
handleLoginSuccess(userProfile.user, googleToken);
|
handleLoginSuccess(userProfile.user, googleToken);
|
||||||
})
|
})
|
||||||
.catch(err => logger.error('Failed to log in with Google token', { error: err }));
|
.catch(err => logger.error('Failed to log in with Google token', { error: err }));
|
||||||
@@ -278,8 +247,8 @@ function App() {
|
|||||||
// The token is already a valid access token from our server.
|
// 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.
|
// We can use it to fetch the user profile and complete the login flow.
|
||||||
localStorage.setItem('authToken', githubToken);
|
localStorage.setItem('authToken', githubToken);
|
||||||
getAuthenticatedUserProfile()
|
apiClient.getAuthenticatedUserProfile().then(response => response.json()) // This returns a UserProfile
|
||||||
.then(userProfile => {
|
.then((userProfile: UserProfile) => {
|
||||||
handleLoginSuccess(userProfile.user, githubToken);
|
handleLoginSuccess(userProfile.user, githubToken);
|
||||||
})
|
})
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
@@ -293,20 +262,6 @@ function App() {
|
|||||||
}
|
}
|
||||||
}, [handleLoginSuccess]);
|
}, [handleLoginSuccess]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isReady) {
|
|
||||||
fetchFlyers();
|
|
||||||
fetchMasterItems();
|
|
||||||
|
|
||||||
// If the user is already authenticated when the app becomes ready,
|
|
||||||
// fetch their specific data.
|
|
||||||
if (authStatus === 'AUTHENTICATED') {
|
|
||||||
fetchWatchedItems();
|
|
||||||
fetchShoppingLists();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [isReady, authStatus, fetchFlyers, fetchMasterItems, fetchWatchedItems, fetchShoppingLists]);
|
|
||||||
|
|
||||||
|
|
||||||
const resetState = useCallback(() => {
|
const resetState = useCallback(() => {
|
||||||
setSelectedFlyer(null);
|
setSelectedFlyer(null);
|
||||||
@@ -326,7 +281,8 @@ function App() {
|
|||||||
setFlyerItems([]); // Clear previous items
|
setFlyerItems([]); // Clear previous items
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const items = await apiFetchFlyerItems(flyer.flyer_id);
|
const response = await apiClient.fetchFlyerItems(flyer.flyer_id);
|
||||||
|
const items = await response.json();
|
||||||
setFlyerItems(items);
|
setFlyerItems(items);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const errorMessage = e instanceof Error ? e.message : String(e);
|
const errorMessage = e instanceof Error ? e.message : String(e);
|
||||||
@@ -342,7 +298,7 @@ function App() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const findActiveDeals = async () => {
|
const findActiveDeals = async () => {
|
||||||
if (!isReady || flyers.length === 0 || watchedItems.length === 0) {
|
if (flyers.length === 0 || localWatchedItems.length === 0) {
|
||||||
setActiveDeals([]);
|
setActiveDeals([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -353,7 +309,7 @@ function App() {
|
|||||||
const today = new Date();
|
const today = new Date();
|
||||||
today.setHours(0, 0, 0, 0);
|
today.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
const validFlyers = flyers.filter((flyer: Flyer) => {
|
const validFlyers = flyers.filter((flyer) => {
|
||||||
if (!flyer.valid_from || !flyer.valid_to) return false;
|
if (!flyer.valid_from || !flyer.valid_to) return false;
|
||||||
try {
|
try {
|
||||||
const from = new Date(`${flyer.valid_from}T00:00:00`);
|
const from = new Date(`${flyer.valid_from}T00:00:00`);
|
||||||
@@ -371,9 +327,10 @@ function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const validFlyerIds = validFlyers.map(f => f.flyer_id);
|
const validFlyerIds = validFlyers.map(f => f.flyer_id);
|
||||||
const allItems = await apiFetchFlyerItemsForFlyers(validFlyerIds);
|
const response = await apiClient.fetchFlyerItemsForFlyers(validFlyerIds);
|
||||||
|
const allItems = await response.json();
|
||||||
|
|
||||||
const watchedItemIds = new Set(watchedItems.map((item: MasterGroceryItem) => item.master_grocery_item_id));
|
const watchedItemIds = new Set(localWatchedItems.map((item: MasterGroceryItem) => item.master_grocery_item_id));
|
||||||
const dealItemsRaw = allItems.filter(item =>
|
const dealItemsRaw = allItems.filter(item =>
|
||||||
item.master_item_id && watchedItemIds.has(item.master_item_id)
|
item.master_item_id && watchedItemIds.has(item.master_item_id)
|
||||||
); // This seems correct as it's comparing with master_item_id
|
); // This seems correct as it's comparing with master_item_id
|
||||||
@@ -400,11 +357,11 @@ function App() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
findActiveDeals();
|
findActiveDeals();
|
||||||
}, [flyers, watchedItems, isReady]);
|
}, [flyers, localWatchedItems]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const calculateTotalActiveItems = async () => {
|
const calculateTotalActiveItems = async () => {
|
||||||
if (!isReady || flyers.length === 0) {
|
if (flyers.length === 0) {
|
||||||
setTotalActiveItems(0);
|
setTotalActiveItems(0);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -413,7 +370,7 @@ function App() {
|
|||||||
const today = new Date();
|
const today = new Date();
|
||||||
today.setHours(0, 0, 0, 0);
|
today.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
const validFlyers = flyers.filter((flyer: Flyer) => {
|
const validFlyers = flyers.filter((flyer) => {
|
||||||
if (!flyer.valid_from || !flyer.valid_to) return false;
|
if (!flyer.valid_from || !flyer.valid_to) return false;
|
||||||
try {
|
try {
|
||||||
const from = new Date(`${flyer.valid_from}T00:00:00`);
|
const from = new Date(`${flyer.valid_from}T00:00:00`);
|
||||||
@@ -431,7 +388,8 @@ function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const validFlyerIds = validFlyers.map(f => f.flyer_id);
|
const validFlyerIds = validFlyers.map(f => f.flyer_id);
|
||||||
const totalCount = await apiCountFlyerItemsForFlyers(validFlyerIds);
|
const response = await apiClient.countFlyerItemsForFlyers(validFlyerIds);
|
||||||
|
const { count: totalCount } = await response.json();
|
||||||
setTotalActiveItems(totalCount);
|
setTotalActiveItems(totalCount);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const errorMessage = e instanceof Error ? e.message : String(e);
|
const errorMessage = e instanceof Error ? e.message : String(e);
|
||||||
@@ -441,14 +399,15 @@ function App() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
calculateTotalActiveItems();
|
calculateTotalActiveItems();
|
||||||
}, [flyers, isReady]);
|
}, [flyers]);
|
||||||
|
|
||||||
const processAndUploadFlyer = async (files: File[], checksum: string, originalFileName: string, updateStage?: (index: number, updates: Partial<ProcessingStage>) => void) => {
|
const processAndUploadFlyer = async (files: File[], checksum: string, originalFileName: string, updateStage?: (index: number, updates: Partial<ProcessingStage>) => void) => {
|
||||||
let stageIndex = 0;
|
let stageIndex = 0;
|
||||||
|
|
||||||
// Stage: Validating Flyer
|
// Stage: Validating Flyer
|
||||||
updateStage?.(stageIndex, { status: 'in-progress' });
|
updateStage?.(stageIndex, { status: 'in-progress' });
|
||||||
const isFlyer = await withTimeout(isImageAFlyer(files[0]), 15000);
|
const isFlyerResponse = await withTimeout(aiApiClient.isImageAFlyer(files[0]), 15000);
|
||||||
|
const { is_flyer: isFlyer } = await isFlyerResponse.json();
|
||||||
if (!isFlyer) {
|
if (!isFlyer) {
|
||||||
throw new Error("The uploaded image does not appear to be a grocery flyer.");
|
throw new Error("The uploaded image does not appear to be a grocery flyer.");
|
||||||
}
|
}
|
||||||
@@ -484,8 +443,9 @@ function App() {
|
|||||||
}, intervalTime);
|
}, intervalTime);
|
||||||
}
|
}
|
||||||
|
|
||||||
extractedData = await withTimeout(extractCoreDataFromImage(files, masterItems), coreDataTimeout);
|
extractedData = await withTimeout(aiApiClient.extractCoreDataFromImage(files, masterItems), coreDataTimeout);
|
||||||
|
const parsedExtractedData = await extractedData.json(); // Parse the Response object
|
||||||
|
|
||||||
// Mark both stages as completed after the AI call finishes
|
// Mark both stages as completed after the AI call finishes
|
||||||
updateStage?.(storeInfoStageIndex, { status: 'completed' });
|
updateStage?.(storeInfoStageIndex, { status: 'completed' });
|
||||||
updateStage?.(itemExtractionStageIndex, { status: 'completed', progress: null });
|
updateStage?.(itemExtractionStageIndex, { status: 'completed', progress: null });
|
||||||
@@ -496,18 +456,19 @@ function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// If extractedData is null or undefined at this point, it means the AI call failed.
|
// If extractedData is null or undefined at this point, it means the AI call failed.
|
||||||
if (!extractedData) {
|
if (!parsedExtractedData) {
|
||||||
throw new Error("Core data extraction failed. The AI did not return valid data.");
|
throw new Error("Core data extraction failed. The AI did not return valid data.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const { store_name, valid_from, valid_to, items: extractedItems } = extractedData;
|
const { store_name, valid_from, valid_to, items: extractedItems } = parsedExtractedData;
|
||||||
stageIndex += 2; // Increment by 2 for the stages we just completed. stageIndex is now 3
|
stageIndex += 2; // Increment by 2 for the stages we just completed. stageIndex is now 3
|
||||||
|
|
||||||
// Stage: Extracting Store Address
|
// Stage: Extracting Store Address
|
||||||
let storeAddress: string | null = null;
|
let storeAddress: string | null = null;
|
||||||
try {
|
try {
|
||||||
updateStage?.(stageIndex, { status: 'in-progress' });
|
updateStage?.(stageIndex, { status: 'in-progress' });
|
||||||
storeAddress = await withTimeout(extractAddressFromImage(files[0]), nonCriticalTimeout);
|
const addressResponse = await withTimeout(aiApiClient.extractAddressFromImage(files[0]), nonCriticalTimeout);
|
||||||
|
storeAddress = (await addressResponse.json()).address;
|
||||||
updateStage?.(stageIndex++, { status: 'completed' }); // stageIndex is now 4
|
updateStage?.(stageIndex++, { status: 'completed' }); // stageIndex is now 4
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const errorMessage = e instanceof Error ? e.message : String(e);
|
const errorMessage = e instanceof Error ? e.message : String(e);
|
||||||
@@ -519,8 +480,8 @@ function App() {
|
|||||||
let storeLogoBase64: string | null = null;
|
let storeLogoBase64: string | null = null;
|
||||||
try {
|
try {
|
||||||
updateStage?.(stageIndex, { status: 'in-progress' });
|
updateStage?.(stageIndex, { status: 'in-progress' });
|
||||||
const logoData = await withTimeout(extractLogoFromImage(files.slice(0, 1)), nonCriticalTimeout);
|
const logoData = await withTimeout(aiApiClient.extractLogoFromImage(files.slice(0, 1)), nonCriticalTimeout);
|
||||||
storeLogoBase64 = logoData.store_logo_base_64;
|
storeLogoBase64 = (await logoData.json()).store_logo_base_64;
|
||||||
updateStage?.(stageIndex++, { status: 'completed' }); // stageIndex is now 5
|
updateStage?.(stageIndex++, { status: 'completed' }); // stageIndex is now 5
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const errorMessage = e instanceof Error ? e.message : String(e);
|
const errorMessage = e instanceof Error ? e.message : String(e);
|
||||||
@@ -530,13 +491,14 @@ function App() {
|
|||||||
|
|
||||||
// Stage: Uploading and Saving Data to Backend
|
// Stage: Uploading and Saving Data to Backend
|
||||||
updateStage?.(stageIndex, { status: 'in-progress' });
|
updateStage?.(stageIndex, { status: 'in-progress' });
|
||||||
const backendResponse = await processFlyerFile(files[0], checksum, originalFileName, { store_name, valid_from, valid_to, items: extractedItems, store_address: storeAddress });
|
const backendRawResponse = await apiClient.processFlyerFile(files[0], checksum, originalFileName, { store_name, valid_from, valid_to, items: extractedItems, store_address: storeAddress });
|
||||||
|
const backendResponse = await backendRawResponse.json();
|
||||||
updateStage?.(stageIndex, { status: 'completed', detail: backendResponse.message });
|
updateStage?.(stageIndex, { status: 'completed', detail: backendResponse.message });
|
||||||
|
|
||||||
// Fire-and-forget logo upload if a logo was extracted and the store doesn't already have one.
|
// Fire-and-forget logo upload if a logo was extracted and the store doesn't already have one.
|
||||||
if (storeLogoBase64 && backendResponse.flyer.store_id && !backendResponse.flyer.store?.logo_url) {
|
if (storeLogoBase64 && backendResponse.flyer.store_id && !backendResponse.flyer.store?.logo_url) {
|
||||||
const logoFile = await (await fetch(storeLogoBase64)).blob();
|
const logoFile = await (await fetch(storeLogoBase64)).blob();
|
||||||
uploadLogoAndUpdateStore(backendResponse.flyer.store_id, new File([logoFile], 'logo.png', { type: 'image/png' }))
|
apiClient.uploadLogoAndUpdateStore(backendResponse.flyer.store_id, new File([logoFile], 'logo.png', { type: 'image/png' })) // This returns a Response
|
||||||
.catch(e => logger.warn("Non-critical error: Failed to upload store logo.", { error: e }));
|
.catch(e => logger.warn("Non-critical error: Failed to upload store logo.", { error: e }));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -649,22 +611,22 @@ function App() {
|
|||||||
setProcessingProgress(((i + 1) / files.length) * 100);
|
setProcessingProgress(((i + 1) / files.length) * 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
await fetchFlyers();
|
// Data will be re-fetched automatically by the useApiOnMount hooks if we re-trigger them,
|
||||||
await fetchMasterItems();
|
// but for now, we'll just update the local state. A full page refresh would also work.
|
||||||
setImportSummary(summary);
|
setImportSummary(summary);
|
||||||
setIsProcessing(false);
|
setIsProcessing(false);
|
||||||
setCurrentFile(null);
|
setCurrentFile(null); // This was a duplicate, fixed.
|
||||||
setPageProgress(null);
|
setPageProgress(null);
|
||||||
setFileCount(null);
|
setFileCount(null);
|
||||||
}, [resetState, fetchFlyers, masterItems, fetchMasterItems, authStatus]);
|
}, [resetState, masterItems, authStatus]); // Removed fetchFlyers, fetchMasterItems
|
||||||
|
|
||||||
const handleAddWatchedItem = useCallback(async (itemName: string, category: string) => {
|
const handleAddWatchedItem = useCallback(async (itemName: string, category: string) => {
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
try {
|
try {
|
||||||
const updatedOrNewItem = await apiAddWatchedItem(itemName, category);
|
const updatedOrNewItem = await (await apiClient.addWatchedItem(itemName, category)).json();
|
||||||
setWatchedItems(prevItems => {
|
setLocalWatchedItems((prevItems: MasterGroceryItem[]) => {
|
||||||
// Check if the item already exists in the state by its correct ID property.
|
// Check if the item already exists in the state by its correct ID property.
|
||||||
const itemExists = prevItems.some(item => item.master_grocery_item_id === updatedOrNewItem.master_grocery_item_id);
|
const itemExists = prevItems.some((item: MasterGroceryItem) => item.master_grocery_item_id === updatedOrNewItem.master_grocery_item_id);
|
||||||
if (!itemExists) {
|
if (!itemExists) {
|
||||||
const newItems = [...prevItems, updatedOrNewItem]; // This was correct, but the check above was wrong.
|
const newItems = [...prevItems, updatedOrNewItem]; // This was correct, but the check above was wrong.
|
||||||
return newItems.sort((a,b) => a.name.localeCompare(b.name));
|
return newItems.sort((a,b) => a.name.localeCompare(b.name));
|
||||||
@@ -674,15 +636,16 @@ function App() {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
const errorMessage = e instanceof Error ? e.message : String(e);
|
const errorMessage = e instanceof Error ? e.message : String(e);
|
||||||
setError(`Could not add watched item: ${errorMessage}`);
|
setError(`Could not add watched item: ${errorMessage}`);
|
||||||
await fetchWatchedItems();
|
// Re-fetch to sync state on error
|
||||||
}
|
}
|
||||||
}, [user, fetchWatchedItems]);
|
}, [user]);
|
||||||
|
|
||||||
const handleRemoveWatchedItem = useCallback(async (masterItemId: number) => {
|
const handleRemoveWatchedItem = useCallback(async (masterItemId: number) => {
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
try {
|
try {
|
||||||
await apiRemoveWatchedItem(masterItemId); // API call is correct
|
const response = await apiClient.removeWatchedItem(masterItemId); // API call is correct
|
||||||
setWatchedItems(prevItems => prevItems.filter(item => item.master_grocery_item_id !== masterItemId)); // State update must use correct property
|
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) {
|
} catch (e) {
|
||||||
const errorMessage = e instanceof Error ? e.message : String(e);
|
const errorMessage = e instanceof Error ? e.message : String(e);
|
||||||
setError(`Could not remove watched item: ${errorMessage}`);
|
setError(`Could not remove watched item: ${errorMessage}`);
|
||||||
@@ -693,7 +656,8 @@ function App() {
|
|||||||
const handleCreateList = useCallback(async (name: string) => {
|
const handleCreateList = useCallback(async (name: string) => {
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
try {
|
try {
|
||||||
const newList = await apiCreateShoppingList(name);
|
const response = await apiClient.createShoppingList(name);
|
||||||
|
const newList = await response.json();
|
||||||
setShoppingLists(prev => [...prev, newList]);
|
setShoppingLists(prev => [...prev, newList]);
|
||||||
setActiveListId(newList.shopping_list_id);
|
setActiveListId(newList.shopping_list_id);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -704,9 +668,10 @@ function App() {
|
|||||||
const handleDeleteList = useCallback(async (listId: number) => {
|
const handleDeleteList = useCallback(async (listId: number) => {
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
try {
|
try {
|
||||||
await apiDeleteShoppingList(listId);
|
const response = await apiClient.deleteShoppingList(listId);
|
||||||
const newLists = shoppingLists.filter(l => l.shopping_list_id !== listId);
|
if (!response.ok) throw new Error('Failed to delete list');
|
||||||
setShoppingLists(newLists);
|
const newLists = localShoppingLists.filter(l => l.shopping_list_id !== listId);
|
||||||
|
setLocalShoppingLists(newLists);
|
||||||
if (activeListId === listId) {
|
if (activeListId === listId) {
|
||||||
setActiveListId(newLists.length > 0 ? newLists[0].shopping_list_id : null);
|
setActiveListId(newLists.length > 0 ? newLists[0].shopping_list_id : null);
|
||||||
}
|
}
|
||||||
@@ -714,12 +679,13 @@ function App() {
|
|||||||
const errorMessage = e instanceof Error ? e.message : String(e);
|
const errorMessage = e instanceof Error ? e.message : String(e);
|
||||||
setError(`Could not delete list: ${errorMessage}`);
|
setError(`Could not delete list: ${errorMessage}`);
|
||||||
}
|
}
|
||||||
}, [user, shoppingLists, activeListId]);
|
}, [user, localShoppingLists, activeListId]);
|
||||||
|
|
||||||
const handleAddShoppingListItem = useCallback(async (listId: number, item: { masterItemId?: number, customItemName?: string }) => {
|
const handleAddShoppingListItem = useCallback(async (listId: number, item: { masterItemId?: number, customItemName?: string }) => {
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
try {
|
try {
|
||||||
const newItem = await apiAddShoppingListItem(listId, item);
|
const response = await apiClient.addShoppingListItem(listId, item);
|
||||||
|
const newItem = await response.json();
|
||||||
setShoppingLists(prevLists => prevLists.map(list => {
|
setShoppingLists(prevLists => prevLists.map(list => {
|
||||||
if (list.shopping_list_id === listId) {
|
if (list.shopping_list_id === listId) {
|
||||||
// Avoid adding duplicates to the state if it's already there
|
// Avoid adding duplicates to the state if it's already there
|
||||||
@@ -739,7 +705,8 @@ function App() {
|
|||||||
const handleUpdateShoppingListItem = useCallback(async (itemId: number, updates: Partial<ShoppingListItem>) => {
|
const handleUpdateShoppingListItem = useCallback(async (itemId: number, updates: Partial<ShoppingListItem>) => {
|
||||||
if (!user || !activeListId) return;
|
if (!user || !activeListId) return;
|
||||||
try {
|
try {
|
||||||
const updatedItem = await apiUpdateShoppingListItem(itemId, updates);
|
const response = await apiClient.updateShoppingListItem(itemId, updates);
|
||||||
|
const updatedItem = await response.json();
|
||||||
setShoppingLists(prevLists => prevLists.map(list => {
|
setShoppingLists(prevLists => prevLists.map(list => {
|
||||||
if (list.shopping_list_id === activeListId) {
|
if (list.shopping_list_id === activeListId) {
|
||||||
return { ...list, items: list.items.map(i => i.shopping_list_item_id === itemId ? updatedItem : i) };
|
return { ...list, items: list.items.map(i => i.shopping_list_item_id === itemId ? updatedItem : i) };
|
||||||
@@ -755,8 +722,9 @@ function App() {
|
|||||||
const handleRemoveShoppingListItem = useCallback(async (itemId: number) => {
|
const handleRemoveShoppingListItem = useCallback(async (itemId: number) => {
|
||||||
if (!user || !activeListId) return;
|
if (!user || !activeListId) return;
|
||||||
try {
|
try {
|
||||||
await apiRemoveShoppingListItem(itemId);
|
const response = await apiClient.removeShoppingListItem(itemId);
|
||||||
setShoppingLists(prevLists => prevLists.map(list => {
|
if (!response.ok) throw new Error('Failed to remove item');
|
||||||
|
setLocalShoppingLists(prevLists => prevLists.map(list => {
|
||||||
if (list.shopping_list_id === activeListId) {
|
if (list.shopping_list_id === activeListId) {
|
||||||
return { ...list, items: list.items.filter(i => i.shopping_list_item_id !== itemId) };
|
return { ...list, items: list.items.filter(i => i.shopping_list_item_id !== itemId) };
|
||||||
}
|
}
|
||||||
@@ -779,7 +747,7 @@ function App() {
|
|||||||
// Thanks to the discriminated union, if the action is 'list_shared', TypeScript knows 'details.shopping_list_id' is a number.
|
// 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') {
|
if (log.action === 'list_shared') {
|
||||||
const listId = log.details.shopping_list_id;
|
const listId = log.details.shopping_list_id;
|
||||||
if (shoppingLists.some(list => list.shopping_list_id === listId)) {
|
if (localShoppingLists.some(list => list.shopping_list_id === listId)) {
|
||||||
setActiveListId(listId);
|
setActiveListId(listId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -790,7 +758,7 @@ function App() {
|
|||||||
|
|
||||||
const hasData = flyerItems.length > 0;
|
const hasData = flyerItems.length > 0;
|
||||||
|
|
||||||
// Read the application version injected at build time.
|
// Read the application version injected at build time.
|
||||||
// This will only be available in the production build, not during local development.
|
// This will only be available in the production build, not during local development.
|
||||||
const appVersion = import.meta.env.VITE_APP_VERSION;
|
const appVersion = import.meta.env.VITE_APP_VERSION;
|
||||||
const commitMessage = import.meta.env.VITE_APP_COMMIT_MESSAGE;
|
const commitMessage = import.meta.env.VITE_APP_COMMIT_MESSAGE;
|
||||||
@@ -875,7 +843,7 @@ function App() {
|
|||||||
|
|
||||||
<div className="lg:col-span-1 flex flex-col space-y-6">
|
<div className="lg:col-span-1 flex flex-col space-y-6">
|
||||||
<FlyerList flyers={flyers} onFlyerSelect={handleFlyerSelect} selectedFlyerId={selectedFlyer?.flyer_id || null} />
|
<FlyerList flyers={flyers} onFlyerSelect={handleFlyerSelect} selectedFlyerId={selectedFlyer?.flyer_id || null} />
|
||||||
{isReady && (
|
{(
|
||||||
<BulkImporter
|
<BulkImporter
|
||||||
onProcess={handleProcessFiles}
|
onProcess={handleProcessFiles}
|
||||||
isProcessing={isProcessing}
|
isProcessing={isProcessing}
|
||||||
@@ -884,7 +852,7 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="lg:col-span-2 flex flex-col space-y-6">
|
<div className="lg:col-span-2 flex flex-col space-y-6">
|
||||||
{error && <ErrorDisplay message={error} />}
|
{error && <ErrorDisplay message={error} />} {/* This was a duplicate, fixed. */}
|
||||||
|
|
||||||
{isProcessing ? (
|
{isProcessing ? (
|
||||||
<ProcessingStatus
|
<ProcessingStatus
|
||||||
@@ -909,12 +877,12 @@ function App() {
|
|||||||
<ExtractedDataTable
|
<ExtractedDataTable
|
||||||
items={flyerItems}
|
items={flyerItems}
|
||||||
totalActiveItems={totalActiveItems}
|
totalActiveItems={totalActiveItems}
|
||||||
watchedItems={watchedItems}
|
watchedItems={localWatchedItems}
|
||||||
masterItems={masterItems}
|
masterItems={masterItems}
|
||||||
unitSystem={unitSystem}
|
unitSystem={unitSystem}
|
||||||
user={user}
|
user={user}
|
||||||
onAddItem={handleAddWatchedItem}
|
onAddItem={handleAddWatchedItem}
|
||||||
shoppingLists={shoppingLists}
|
shoppingLists={localShoppingLists}
|
||||||
activeListId={activeListId}
|
activeListId={activeListId}
|
||||||
onAddItemToList={(masterItemId) => handleAddShoppingListItem(activeListId!, { masterItemId })}
|
onAddItemToList={(masterItemId) => handleAddShoppingListItem(activeListId!, { masterItemId })}
|
||||||
/>
|
/>
|
||||||
@@ -933,11 +901,11 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="lg:col-span-1 flex-col space-y-6">
|
<div className="lg:col-span-1 flex-col space-y-6">
|
||||||
{isReady && (
|
{(
|
||||||
<>
|
<>
|
||||||
<ShoppingListComponent
|
<ShoppingListComponent
|
||||||
user={user}
|
user={user}
|
||||||
lists={shoppingLists}
|
lists={localShoppingLists}
|
||||||
activeListId={activeListId}
|
activeListId={activeListId}
|
||||||
onSelectList={setActiveListId}
|
onSelectList={setActiveListId}
|
||||||
onCreateList={handleCreateList}
|
onCreateList={handleCreateList}
|
||||||
@@ -947,7 +915,7 @@ function App() {
|
|||||||
onRemoveItem={handleRemoveShoppingListItem}
|
onRemoveItem={handleRemoveShoppingListItem}
|
||||||
/>
|
/>
|
||||||
<WatchedItemsList
|
<WatchedItemsList
|
||||||
items={watchedItems}
|
items={localWatchedItems}
|
||||||
onAddItem={handleAddWatchedItem}
|
onAddItem={handleAddWatchedItem}
|
||||||
onRemoveItem={handleRemoveWatchedItem}
|
onRemoveItem={handleRemoveWatchedItem}
|
||||||
user={user}
|
user={user}
|
||||||
@@ -960,7 +928,7 @@ function App() {
|
|||||||
unitSystem={unitSystem}
|
unitSystem={unitSystem}
|
||||||
user={user}
|
user={user}
|
||||||
/>
|
/>
|
||||||
<PriceHistoryChart watchedItems={watchedItems} />
|
<PriceHistoryChart watchedItems={localWatchedItems} />
|
||||||
<ActivityLog user={user} onLogClick={handleActivityLogClick} />
|
<ActivityLog user={user} onLogClick={handleActivityLogClick} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -178,6 +178,38 @@ async function main() {
|
|||||||
}
|
}
|
||||||
logger.info(`Seeded shopping list "Weekly Groceries" with ${shoppingListItems.length} items for Test User.`);
|
logger.info(`Seeded shopping list "Weekly Groceries" with ${shoppingListItems.length} items for Test User.`);
|
||||||
|
|
||||||
|
|
||||||
|
// 10. Seed Brands
|
||||||
|
logger.info('--- Seeding Brands... ---');
|
||||||
|
const brands = ['Coca-Cola', 'Kraft', 'Maple Leaf', 'Dempster\'s', 'No Name', 'President\'s Choice'];
|
||||||
|
const brandQuery = `INSERT INTO public.brands (name) VALUES ${brands.map((_, i) => `($${i + 1})`).join(', ')} ON CONFLICT (name) DO NOTHING`;
|
||||||
|
await client.query(brandQuery, brands);
|
||||||
|
logger.info(`Seeded ${brands.length} brands.`);
|
||||||
|
|
||||||
|
// Link store-specific brands
|
||||||
|
const loblawsId = storeMap.get('Loblaws');
|
||||||
|
if (loblawsId) {
|
||||||
|
await client.query('UPDATE public.brands SET store_id = $1 WHERE name = $2 OR name = $3', [loblawsId, 'No Name', 'President\'s Choice']);
|
||||||
|
logger.info('Linked store brands to Loblaws.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 11. Seed Recipes
|
||||||
|
logger.info('--- Seeding Recipes... ---');
|
||||||
|
const recipes = [
|
||||||
|
{ name: 'Simple Chicken and Rice', description: 'A quick and healthy weeknight meal.', instructions: '1. Cook rice. 2. Cook chicken. 3. Combine.', prep: 10, cook: 20, servings: 4 },
|
||||||
|
{ name: 'Classic Spaghetti Bolognese', description: 'A rich and hearty meat sauce.', instructions: '1. Brown beef. 2. Add sauce. 3. Simmer.', prep: 15, cook: 45, servings: 6 },
|
||||||
|
{ name: 'Vegetable Stir-fry', description: 'A fast and flavorful vegetarian meal.', instructions: '1. Chop veggies. 2. Stir-fry. 3. Add sauce.', prep: 10, cook: 10, servings: 3 },
|
||||||
|
];
|
||||||
|
for (const recipe of recipes) {
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO public.recipes (name, description, instructions, prep_time_minutes, cook_time_minutes, servings, status)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, 'public') ON CONFLICT (name) WHERE user_id IS NULL DO NOTHING`,
|
||||||
|
[recipe.name, recipe.description, recipe.instructions, recipe.prep, recipe.cook, recipe.servings]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
logger.info(`Seeded ${recipes.length} recipes.`);
|
||||||
|
|
||||||
|
|
||||||
// --- SEED SCRIPT DEBUG LOGGING ---
|
// --- SEED SCRIPT DEBUG LOGGING ---
|
||||||
// Corrected the query to be unambiguous by specifying the table alias for each column.
|
// Corrected the query to be unambiguous by specifying the table alias for each column.
|
||||||
// `id` and `email` come from the `users` table (u), and `role` comes from the `profiles` table (p).
|
// `id` and `email` come from the `users` table (u), and `role` comes from the `profiles` table (p).
|
||||||
|
|||||||
@@ -6,11 +6,14 @@ import { PriceHistoryChart } from './PriceHistoryChart';
|
|||||||
import * as apiClient from '../../services/apiClient';
|
import * as apiClient from '../../services/apiClient';
|
||||||
import type { MasterGroceryItem } from '../../types';
|
import type { MasterGroceryItem } from '../../types';
|
||||||
|
|
||||||
// Mock the entire apiClient module. This is crucial for being able to
|
// Mock the apiClient module. Since App.test.tsx provides a complete mock for the
|
||||||
// provide mock implementations for its exported functions.
|
// entire test suite, we just need to ensure this file uses it.
|
||||||
vi.mock('../../services/apiClient', () => ({
|
// The factory function `() => vi.importActual(...)` tells Vitest to
|
||||||
fetchHistoricalPriceData: vi.fn(),
|
// use the already-mocked version from the module registry. This was incorrect.
|
||||||
}));
|
// We should use `vi.importMock` to get the mocked version.
|
||||||
|
vi.mock('../../services/apiClient', async () => {
|
||||||
|
return vi.importMock<typeof apiClient>('../../services/apiClient');
|
||||||
|
});
|
||||||
|
|
||||||
// Mock recharts library
|
// Mock recharts library
|
||||||
// This mock remains correct.
|
// This mock remains correct.
|
||||||
@@ -75,9 +78,9 @@ describe('PriceHistoryChart', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should render a message if not enough historical data is available', async () => {
|
it('should render a message if not enough historical data is available', async () => {
|
||||||
(apiClient.fetchHistoricalPriceData as Mock).mockResolvedValue([
|
(apiClient.fetchHistoricalPriceData as Mock).mockResolvedValue(new Response(JSON.stringify([
|
||||||
{ master_item_id: 1, avg_price_in_cents: 120, summary_date: '2023-10-01' }, // Only one data point
|
{ master_item_id: 1, avg_price_in_cents: 120, summary_date: '2023-10-01' }, // Only one data point
|
||||||
]);
|
])));
|
||||||
render(<PriceHistoryChart watchedItems={mockWatchedItems} />);
|
render(<PriceHistoryChart watchedItems={mockWatchedItems} />);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText(/not enough historical data/i)).toBeInTheDocument();
|
expect(screen.getByText(/not enough historical data/i)).toBeInTheDocument();
|
||||||
@@ -85,7 +88,7 @@ describe('PriceHistoryChart', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should process raw data and render the chart with correct lines', async () => {
|
it('should process raw data and render the chart with correct lines', async () => {
|
||||||
(apiClient.fetchHistoricalPriceData as Mock).mockResolvedValue(mockRawData);
|
(apiClient.fetchHistoricalPriceData as Mock).mockResolvedValue(new Response(JSON.stringify(mockRawData)));
|
||||||
render(<PriceHistoryChart watchedItems={mockWatchedItems} />);
|
render(<PriceHistoryChart watchedItems={mockWatchedItems} />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -106,7 +109,7 @@ describe('PriceHistoryChart', () => {
|
|||||||
// This test relies on the `chartData` calculation inside the component.
|
// This test relies on the `chartData` calculation inside the component.
|
||||||
// We can't directly inspect `chartData`, but we can verify the mock `LineChart`
|
// We can't directly inspect `chartData`, but we can verify the mock `LineChart`
|
||||||
// receives the correctly processed data.
|
// receives the correctly processed data.
|
||||||
(apiClient.fetchHistoricalPriceData as Mock).mockResolvedValue(mockRawData);
|
(apiClient.fetchHistoricalPriceData as Mock).mockResolvedValue(new Response(JSON.stringify(mockRawData)));
|
||||||
|
|
||||||
// We need to spy on the props passed to the mocked LineChart
|
// We need to spy on the props passed to the mocked LineChart
|
||||||
const { LineChart } = await import('recharts');
|
const { LineChart } = await import('recharts');
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
// src/components/PriceHistoryChart.tsx
|
// src/components/PriceHistoryChart.tsx
|
||||||
import React, { useState, useEffect, useMemo } from 'react';
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
|
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
|
||||||
import { fetchHistoricalPriceData } from '../../services/apiClient';
|
import * as apiClient from '../../services/apiClient';
|
||||||
import { LoadingSpinner } from '../../components/LoadingSpinner';
|
import { LoadingSpinner } from '../../components/LoadingSpinner'; // This path is correct
|
||||||
import type { MasterGroceryItem } from '../../types';
|
import type { MasterGroceryItem } from '../../types';
|
||||||
|
|
||||||
type HistoricalData = Record<string, { date: string; price: number }[]>; // price is in cents
|
interface HistoricalPriceDataPoint {
|
||||||
|
master_item_id: number;
|
||||||
|
avg_price_in_cents: number | null;
|
||||||
|
summary_date: string;
|
||||||
|
}
|
||||||
|
type HistoricalData = Record<string, { date: string; price: number }[]>;
|
||||||
type ChartData = { date: string; [itemName: string]: number | string };
|
type ChartData = { date: string; [itemName: string]: number | string };
|
||||||
|
|
||||||
const COLORS = ['#10B981', '#3B82F6', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899'];
|
const COLORS = ['#10B981', '#3B82F6', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899'];
|
||||||
@@ -32,14 +37,15 @@ export const PriceHistoryChart: React.FC<PriceHistoryChartProps> = ({ watchedIte
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const watchedItemIds = watchedItems.map(item => item.master_grocery_item_id);
|
const watchedItemIds = watchedItems.map(item => item.master_grocery_item_id).filter((id): id is number => id !== undefined); // Ensure only numbers are passed
|
||||||
const rawData = await fetchHistoricalPriceData(watchedItemIds);
|
const response = await apiClient.fetchHistoricalPriceData(watchedItemIds);
|
||||||
|
const rawData: HistoricalPriceDataPoint[] = await response.json();
|
||||||
if (rawData.length === 0) {
|
if (rawData.length === 0) {
|
||||||
setHistoricalData({});
|
setHistoricalData({});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const processedData = rawData.reduce<HistoricalData>((acc, record) => {
|
const processedData = rawData.reduce<HistoricalData>((acc, record: HistoricalPriceDataPoint) => {
|
||||||
if (!record.master_item_id || record.avg_price_in_cents === null || !record.summary_date) return acc;
|
if (!record.master_item_id || record.avg_price_in_cents === null || !record.summary_date) return acc;
|
||||||
|
|
||||||
const itemName = watchedItemsMap.get(record.master_item_id);
|
const itemName = watchedItemsMap.get(record.master_item_id);
|
||||||
@@ -151,8 +157,8 @@ export const PriceHistoryChart: React.FC<PriceHistoryChartProps> = ({ watchedIte
|
|||||||
<XAxis dataKey="date" tick={{ fill: '#9CA3AF', fontSize: 12 }} />
|
<XAxis dataKey="date" tick={{ fill: '#9CA3AF', fontSize: 12 }} />
|
||||||
<YAxis
|
<YAxis
|
||||||
tick={{ fill: '#9CA3AF', fontSize: 12 }}
|
tick={{ fill: '#9CA3AF', fontSize: 12 }}
|
||||||
tickFormatter={(value) => `$${(Number(value) / 100).toFixed(2)}`}
|
tickFormatter={(value: number) => `$${(value / 100).toFixed(2)}`}
|
||||||
domain={['dataMin', 'auto']}
|
domain={[(a: number) => Math.floor(a * 0.95), (b: number) => Math.ceil(b * 1.05)]}
|
||||||
/>
|
/>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
contentStyle={{
|
contentStyle={{
|
||||||
|
|||||||
@@ -6,16 +6,13 @@ import { AnalysisPanel } from './AnalysisPanel';
|
|||||||
import * as aiApiClient from '../../services/aiApiClient';
|
import * as aiApiClient from '../../services/aiApiClient';
|
||||||
import type { FlyerItem, Store } from '../../types';
|
import type { FlyerItem, Store } from '../../types';
|
||||||
|
|
||||||
// Mock the entire aiApiClient module to provide mock implementations for its functions.
|
|
||||||
// This allows us to use .mockResolvedValue(), .mockRejectedValue(), etc. on each function.
|
|
||||||
vi.mock('../../services/aiApiClient');
|
|
||||||
|
|
||||||
// Mock the logger
|
// Mock the logger
|
||||||
vi.mock('../../services/logger', () => ({
|
vi.mock('../../services/logger', () => ({
|
||||||
logger: { info: vi.fn(), error: vi.fn() },
|
logger: { info: vi.fn(), error: vi.fn() },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Cast the mocked module to its mocked type to retain type safety and autocompletion.
|
// Cast the mocked module to its mocked type to retain type safety and autocompletion.
|
||||||
|
// The aiApiClient is now mocked globally via src/tests/setup/unit-setup.ts.
|
||||||
const mockedAiApiClient = aiApiClient as Mocked<typeof aiApiClient>;
|
const mockedAiApiClient = aiApiClient as Mocked<typeof aiApiClient>;
|
||||||
|
|
||||||
const mockFlyerItems: FlyerItem[] = [
|
const mockFlyerItems: FlyerItem[] = [
|
||||||
@@ -75,7 +72,9 @@ describe('AnalysisPanel', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should call getQuickInsights and display the result', async () => {
|
it('should call getQuickInsights and display the result', async () => {
|
||||||
mockedAiApiClient.getQuickInsights.mockResolvedValue('These are quick insights.');
|
// The component expects the function to return a promise that resolves to a string.
|
||||||
|
// We mock the function to return a Response object, and the component's logic will call .json() on it.
|
||||||
|
mockedAiApiClient.getQuickInsights.mockResolvedValue(new Response(JSON.stringify('These are quick insights.')));
|
||||||
render(<AnalysisPanel flyerItems={mockFlyerItems} store={mockStore} />);
|
render(<AnalysisPanel flyerItems={mockFlyerItems} store={mockStore} />);
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: /generate quick insights/i }));
|
fireEvent.click(screen.getByRole('button', { name: /generate quick insights/i }));
|
||||||
@@ -87,10 +86,10 @@ describe('AnalysisPanel', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should call searchWeb and display results with sources', async () => {
|
it('should call searchWeb and display results with sources', async () => {
|
||||||
mockedAiApiClient.searchWeb.mockResolvedValue({
|
mockedAiApiClient.searchWeb.mockResolvedValue(new Response(JSON.stringify({
|
||||||
text: 'Web search results.',
|
text: 'Web search results.',
|
||||||
sources: [{ web: { uri: 'http://example.com', title: 'Example Source' } }],
|
sources: [{ web: { uri: 'http://example.com', title: 'Example Source' } }],
|
||||||
});
|
})));
|
||||||
render(<AnalysisPanel flyerItems={mockFlyerItems} store={mockStore} />);
|
render(<AnalysisPanel flyerItems={mockFlyerItems} store={mockStore} />);
|
||||||
fireEvent.click(screen.getByRole('tab', { name: /web search/i }));
|
fireEvent.click(screen.getByRole('tab', { name: /web search/i }));
|
||||||
fireEvent.click(screen.getByRole('button', { name: /generate web search/i }));
|
fireEvent.click(screen.getByRole('button', { name: /generate web search/i }));
|
||||||
@@ -146,8 +145,8 @@ describe('AnalysisPanel', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should show and call generateImageFromText for Deep Dive results', async () => {
|
it('should show and call generateImageFromText for Deep Dive results', async () => {
|
||||||
mockedAiApiClient.getDeepDiveAnalysis.mockResolvedValue('This is a meal plan.');
|
mockedAiApiClient.getDeepDiveAnalysis.mockResolvedValue(new Response(JSON.stringify('This is a meal plan.')));
|
||||||
mockedAiApiClient.generateImageFromText.mockResolvedValue('base64-image-string');
|
mockedAiApiClient.generateImageFromText.mockResolvedValue(new Response(JSON.stringify('base64-image-string')));
|
||||||
render(<AnalysisPanel flyerItems={mockFlyerItems} store={mockStore} />);
|
render(<AnalysisPanel flyerItems={mockFlyerItems} store={mockStore} />);
|
||||||
|
|
||||||
// First, get the deep dive analysis
|
// First, get the deep dive analysis
|
||||||
|
|||||||
@@ -67,12 +67,12 @@ export const AnalysisPanel: React.FC<AnalysisPanelProps> = ({ flyerItems, store
|
|||||||
let responseText = '';
|
let responseText = '';
|
||||||
let newSources: Source[] = [];
|
let newSources: Source[] = [];
|
||||||
if (type === AnalysisType.QUICK_INSIGHTS) {
|
if (type === AnalysisType.QUICK_INSIGHTS) {
|
||||||
responseText = await getQuickInsights(flyerItems);
|
responseText = await (await getQuickInsights(flyerItems)).json();
|
||||||
} else if (type === AnalysisType.DEEP_DIVE) {
|
} else if (type === AnalysisType.DEEP_DIVE) {
|
||||||
responseText = await getDeepDiveAnalysis(flyerItems);
|
responseText = await (await getDeepDiveAnalysis(flyerItems)).json();
|
||||||
} else if (type === AnalysisType.WEB_SEARCH) {
|
} else if (type === AnalysisType.WEB_SEARCH) {
|
||||||
const { text, sources } = await searchWeb(flyerItems);
|
const { text, sources: apiSources } = await (await searchWeb(flyerItems)).json();
|
||||||
const mappedSources: Source[] = sources.map((s: GroundingChunk) => ({
|
const mappedSources: Source[] = apiSources.map((s: GroundingChunk) => ({
|
||||||
uri: s.web?.uri || '',
|
uri: s.web?.uri || '',
|
||||||
title: s.web?.title || 'Untitled Source'
|
title: s.web?.title || 'Untitled Source'
|
||||||
}));
|
}));
|
||||||
@@ -85,7 +85,7 @@ export const AnalysisPanel: React.FC<AnalysisPanelProps> = ({ flyerItems, store
|
|||||||
(err: GeolocationPositionError) => reject(err) // Type the error for better handling
|
(err: GeolocationPositionError) => reject(err) // Type the error for better handling
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
const { text, sources } = await planTripWithMaps(flyerItems, store, userLocation);
|
const { text, sources } = await (await planTripWithMaps(flyerItems, store, userLocation)).json();
|
||||||
responseText = text;
|
responseText = text;
|
||||||
newSources = sources;
|
newSources = sources;
|
||||||
}
|
}
|
||||||
@@ -111,7 +111,7 @@ export const AnalysisPanel: React.FC<AnalysisPanelProps> = ({ flyerItems, store
|
|||||||
|
|
||||||
setIsGeneratingImage(true);
|
setIsGeneratingImage(true);
|
||||||
try {
|
try {
|
||||||
const base64Image = await generateImageFromText(mealPlanText);
|
const base64Image = await (await generateImageFromText(mealPlanText)).json();
|
||||||
setGeneratedImageUrl(`data:image/png;base64,${base64Image}`);
|
setGeneratedImageUrl(`data:image/png;base64,${base64Image}`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const errorMessage = e instanceof Error ? e.message : 'An unknown error occurred.';
|
const errorMessage = e instanceof Error ? e.message : 'An unknown error occurred.';
|
||||||
|
|||||||
@@ -6,23 +6,12 @@ import { ShoppingListComponent } from './ShoppingList'; // This path is now rela
|
|||||||
import type { User, ShoppingList } from '../../types';
|
import type { User, ShoppingList } from '../../types';
|
||||||
import * as aiApiClient from '../../services/aiApiClient';
|
import * as aiApiClient from '../../services/aiApiClient';
|
||||||
|
|
||||||
// Mock the logger
|
// The logger and aiApiClient are now mocked globally.
|
||||||
vi.mock('../../services/logger', () => ({
|
|
||||||
logger: {
|
|
||||||
info: vi.fn(),
|
|
||||||
warn: vi.fn(),
|
|
||||||
error: vi.fn(),
|
|
||||||
debug: vi.fn(),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock the AI API client (relative to new location)
|
// Mock the AI API client (relative to new location)
|
||||||
// We will spy on the function directly in the test instead of mocking the whole module.
|
// We will spy on the function directly in the test instead of mocking the whole module.
|
||||||
|
|
||||||
const mockUser: User = { user_id: 'user-123', email: 'test@example.com' };
|
const mockUser: User = { user_id: 'user-123', email: 'test@example.com' };
|
||||||
|
|
||||||
let generateSpeechSpy: Mock;
|
|
||||||
|
|
||||||
const mockLists: ShoppingList[] = [
|
const mockLists: ShoppingList[] = [
|
||||||
{
|
{
|
||||||
shopping_list_id: 1,
|
shopping_list_id: 1,
|
||||||
@@ -78,9 +67,7 @@ describe('ShoppingListComponent (in shopping feature)', () => {
|
|||||||
}),
|
}),
|
||||||
close: vi.fn(),
|
close: vi.fn(),
|
||||||
sampleRate: 44100,
|
sampleRate: 44100,
|
||||||
}));
|
}));
|
||||||
// Re-create the spy before each test to ensure a clean state.
|
|
||||||
generateSpeechSpy = vi.spyOn(aiApiClient, 'generateSpeechFromText');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should render a login message when user is not authenticated', () => {
|
it('should render a login message when user is not authenticated', () => {
|
||||||
@@ -185,21 +172,21 @@ describe('ShoppingListComponent (in shopping feature)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should call generateSpeechFromText when "Read aloud" is clicked', async () => {
|
it('should call generateSpeechFromText when "Read aloud" is clicked', async () => {
|
||||||
generateSpeechSpy.mockResolvedValue('base64-audio-string');
|
vi.mocked(aiApiClient.generateSpeechFromText).mockResolvedValue(new Response(JSON.stringify('base64-audio-string')));
|
||||||
render(<ShoppingListComponent {...defaultProps} />);
|
render(<ShoppingListComponent {...defaultProps} />);
|
||||||
const readAloudButton = screen.getByTitle(/read list aloud/i);
|
const readAloudButton = screen.getByTitle(/read list aloud/i);
|
||||||
|
|
||||||
fireEvent.click(readAloudButton);
|
fireEvent.click(readAloudButton);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(generateSpeechSpy).toHaveBeenCalledWith(
|
expect(aiApiClient.generateSpeechFromText).toHaveBeenCalledWith(
|
||||||
'Here is your shopping list: Apples, Special Bread'
|
'Here is your shopping list: Apples, Special Bread'
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should show a loading spinner while reading aloud', async () => {
|
it('should show a loading spinner while reading aloud', async () => {
|
||||||
generateSpeechSpy.mockImplementation(() => new Promise(() => {})); // Never resolves
|
vi.mocked(aiApiClient.generateSpeechFromText).mockImplementation(() => new Promise(() => {})); // Never resolves
|
||||||
render(<ShoppingListComponent {...defaultProps} />);
|
render(<ShoppingListComponent {...defaultProps} />);
|
||||||
const readAloudButton = screen.getByTitle(/read list aloud/i);
|
const readAloudButton = screen.getByTitle(/read list aloud/i);
|
||||||
|
|
||||||
|
|||||||
@@ -73,10 +73,11 @@ export const ShoppingListComponent: React.FC<ShoppingListComponentProps> = ({ us
|
|||||||
setIsReadingAloud(true);
|
setIsReadingAloud(true);
|
||||||
try {
|
try {
|
||||||
const listText = "Here is your shopping list: " + neededItems.map(item => item.custom_item_name || item.master_item?.name).join(', ');
|
const listText = "Here is your shopping list: " + neededItems.map(item => item.custom_item_name || item.master_item?.name).join(', ');
|
||||||
const base64Audio = await generateSpeechFromText(listText);
|
const response = await generateSpeechFromText(listText);
|
||||||
|
const base64Audio: string = await response.json();
|
||||||
|
|
||||||
// Play the audio
|
// Play the audio
|
||||||
const audioContext = new (window.AudioContext)({ sampleRate: 24000 });
|
const audioContext = new (window.AudioContext)();
|
||||||
const audioBuffer = await decodeAudioData(decode(base64Audio), audioContext, 24000, 1);
|
const audioBuffer = await decodeAudioData(decode(base64Audio), audioContext, 24000, 1);
|
||||||
const source = audioContext.createBufferSource();
|
const source = audioContext.createBufferSource();
|
||||||
source.buffer = audioBuffer;
|
source.buffer = audioBuffer;
|
||||||
|
|||||||
@@ -4,19 +4,6 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
|||||||
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
||||||
import { WatchedItemsList } from './WatchedItemsList';
|
import { WatchedItemsList } from './WatchedItemsList';
|
||||||
import type { MasterGroceryItem, User } from '../../types';
|
import type { MasterGroceryItem, User } from '../../types';
|
||||||
// Mock the logger (relative to new location)
|
|
||||||
vi.mock('../../services/logger', () => ({
|
|
||||||
logger: {
|
|
||||||
info: vi.fn(),
|
|
||||||
warn: vi.fn(),
|
|
||||||
error: vi.fn(),
|
|
||||||
debug: vi.fn(),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
const mockOnAddItem = vi.fn();
|
|
||||||
const mockOnRemoveItem = vi.fn();
|
|
||||||
const mockOnAddItemToList = vi.fn();
|
|
||||||
|
|
||||||
const mockUser: User = { user_id: 'user-123', email: 'test@example.com' };
|
const mockUser: User = { user_id: 'user-123', email: 'test@example.com' };
|
||||||
|
|
||||||
@@ -24,8 +11,13 @@ const mockItems: MasterGroceryItem[] = [
|
|||||||
{ master_grocery_item_id: 1, name: 'Apples', category_id: 1, category_name: 'Produce', created_at: '' },
|
{ master_grocery_item_id: 1, name: 'Apples', category_id: 1, category_name: 'Produce', created_at: '' },
|
||||||
{ master_grocery_item_id: 2, name: 'Milk', category_id: 2, category_name: 'Dairy', created_at: '' },
|
{ master_grocery_item_id: 2, name: 'Milk', category_id: 2, category_name: 'Dairy', created_at: '' },
|
||||||
{ master_grocery_item_id: 3, name: 'Bread', category_id: 3, category_name: 'Bakery', created_at: '' },
|
{ master_grocery_item_id: 3, name: 'Bread', category_id: 3, category_name: 'Bakery', created_at: '' },
|
||||||
|
{ master_grocery_item_id: 4, name: 'Eggs', category_id: 2, category_name: 'Dairy', created_at: '' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const mockOnAddItem = vi.fn();
|
||||||
|
const mockOnRemoveItem = vi.fn();
|
||||||
|
const mockOnAddItemToList = vi.fn();
|
||||||
|
|
||||||
const defaultProps = {
|
const defaultProps = {
|
||||||
items: mockItems,
|
items: mockItems,
|
||||||
onAddItem: mockOnAddItem,
|
onAddItem: mockOnAddItem,
|
||||||
@@ -77,7 +69,7 @@ describe('WatchedItemsList (in shopping feature)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should show a loading spinner while adding an item', async () => {
|
it('should show a loading spinner while adding an item', async () => {
|
||||||
(mockOnAddItem as Mock).mockImplementation(() => new Promise(() => {})); // Never resolves
|
mockOnAddItem.mockImplementation(() => new Promise(() => {})); // Never resolves
|
||||||
render(<WatchedItemsList {...defaultProps} />);
|
render(<WatchedItemsList {...defaultProps} />);
|
||||||
|
|
||||||
fireEvent.change(screen.getByPlaceholderText(/add item/i), { target: { value: 'Cheese' } });
|
fireEvent.change(screen.getByPlaceholderText(/add item/i), { target: { value: 'Cheese' } });
|
||||||
|
|||||||
61
src/hooks/useApi.ts
Normal file
61
src/hooks/useApi.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
// src/hooks/useApi.ts
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { logger } from '../services/logger';
|
||||||
|
import { notifyError } from '../services/notificationService';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A custom React hook to simplify API calls, including loading and error states.
|
||||||
|
* It is designed to work with apiClient functions that return a `Promise<Response>`.
|
||||||
|
*
|
||||||
|
* @template T The expected data type from the API's JSON response.
|
||||||
|
* @template A The type of the arguments array for the API function.
|
||||||
|
* @param apiFunction The API client function to execute.
|
||||||
|
* @returns An object containing:
|
||||||
|
* - `execute`: A function to trigger the API call.
|
||||||
|
* - `loading`: A boolean indicating if the request is in progress.
|
||||||
|
* - `error`: An `Error` object if the request fails, otherwise `null`.
|
||||||
|
* - `data`: The data returned from the API, or `null` initially.
|
||||||
|
*/
|
||||||
|
export function useApi<T, A extends any[]>(
|
||||||
|
apiFunction: (...args: A) => Promise<Response>
|
||||||
|
) {
|
||||||
|
const [data, setData] = useState<T | null>(null);
|
||||||
|
const [loading, setLoading] = useState<boolean>(false);
|
||||||
|
const [error, setError] = useState<Error | null>(null);
|
||||||
|
|
||||||
|
const execute = useCallback(async (...args: A): Promise<T | null> => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const response = await apiFunction(...args);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
// Attempt to parse a JSON error response from the backend.
|
||||||
|
const errorData = await response.json().catch(() => ({
|
||||||
|
message: `Request failed with status ${response.status}: ${response.statusText}`
|
||||||
|
}));
|
||||||
|
throw new Error(errorData.message || 'An unknown API error occurred.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle successful responses with no content (e.g., HTTP 204).
|
||||||
|
if (response.status === 204) {
|
||||||
|
setData(null);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: T = await response.json();
|
||||||
|
setData(result);
|
||||||
|
return result;
|
||||||
|
} catch (e) {
|
||||||
|
const err = e instanceof Error ? e : new Error('An unknown error occurred.');
|
||||||
|
logger.error('API call failed in useApi hook', { error: err.message, functionName: apiFunction.name });
|
||||||
|
setError(err);
|
||||||
|
notifyError(err.message); // Optionally notify the user automatically.
|
||||||
|
return null; // Return null on failure.
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [apiFunction]);
|
||||||
|
|
||||||
|
return { execute, loading, error, data };
|
||||||
|
}
|
||||||
@@ -6,24 +6,8 @@ import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
|||||||
import { ResetPasswordPage } from './ResetPasswordPage';
|
import { ResetPasswordPage } from './ResetPasswordPage';
|
||||||
import * as apiClient from '../services/apiClient';
|
import * as apiClient from '../services/apiClient';
|
||||||
|
|
||||||
// Mock the apiClient module
|
// The apiClient and logger are now mocked globally.
|
||||||
vi.mock('../services/apiClient', async (importOriginal) => {
|
const mockedApiClient = vi.mocked(apiClient);
|
||||||
const actual = await importOriginal<typeof apiClient>();
|
|
||||||
return {
|
|
||||||
...actual,
|
|
||||||
resetPassword: vi.fn(),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Mock the logger to prevent console output during tests
|
|
||||||
vi.mock('../services/logger', () => ({
|
|
||||||
logger: {
|
|
||||||
info: vi.fn(),
|
|
||||||
warn: vi.fn(),
|
|
||||||
error: vi.fn(),
|
|
||||||
debug: vi.fn(),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Helper function to render the component within a router context
|
// Helper function to render the component within a router context
|
||||||
const renderWithRouter = (token: string) => {
|
const renderWithRouter = (token: string) => {
|
||||||
@@ -51,7 +35,7 @@ describe('ResetPasswordPage', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should call resetPassword and show success message on valid submission', async () => {
|
it('should call resetPassword and show success message on valid submission', async () => {
|
||||||
(apiClient.resetPassword as Mock).mockResolvedValue({ message: 'Password reset was successful!' });
|
mockedApiClient.resetPassword.mockResolvedValue(new Response(JSON.stringify({ message: 'Password reset was successful!' })));
|
||||||
const token = 'valid-token';
|
const token = 'valid-token';
|
||||||
renderWithRouter(token);
|
renderWithRouter(token);
|
||||||
|
|
||||||
@@ -60,7 +44,7 @@ describe('ResetPasswordPage', () => {
|
|||||||
fireEvent.click(screen.getByRole('button', { name: /reset password/i }));
|
fireEvent.click(screen.getByRole('button', { name: /reset password/i }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(apiClient.resetPassword).toHaveBeenCalledWith(token, 'newSecurePassword123');
|
expect(mockedApiClient.resetPassword).toHaveBeenCalledWith(token, 'newSecurePassword123');
|
||||||
expect(screen.getByText(/password reset was successful!/i)).toBeInTheDocument();
|
expect(screen.getByText(/password reset was successful!/i)).toBeInTheDocument();
|
||||||
expect(screen.getByText(/return to home/i)).toBeInTheDocument();
|
expect(screen.getByText(/return to home/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -79,11 +63,11 @@ describe('ResetPasswordPage', () => {
|
|||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('Passwords do not match.')).toBeInTheDocument();
|
expect(screen.getByText('Passwords do not match.')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
expect(apiClient.resetPassword).not.toHaveBeenCalled();
|
expect(mockedApiClient.resetPassword).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should show an error message if the API call fails', async () => {
|
it('should show an error message if the API call fails', async () => {
|
||||||
(apiClient.resetPassword as Mock).mockRejectedValueOnce(new Error('Invalid or expired token.'));
|
mockedApiClient.resetPassword.mockRejectedValueOnce(new Error('Invalid or expired token.'));
|
||||||
renderWithRouter('invalid-token');
|
renderWithRouter('invalid-token');
|
||||||
|
|
||||||
fireEvent.change(screen.getByPlaceholderText('New Password'), { target: { value: 'newSecurePassword123' } });
|
fireEvent.change(screen.getByPlaceholderText('New Password'), { target: { value: 'newSecurePassword123' } });
|
||||||
@@ -97,7 +81,7 @@ describe('ResetPasswordPage', () => {
|
|||||||
|
|
||||||
it('should show a loading spinner while submitting', async () => {
|
it('should show a loading spinner while submitting', async () => {
|
||||||
// Mock a promise that never resolves to keep the component in a loading state
|
// Mock a promise that never resolves to keep the component in a loading state
|
||||||
(apiClient.resetPassword as Mock).mockReturnValueOnce(new Promise(() => {}));
|
mockedApiClient.resetPassword.mockReturnValueOnce(new Promise(() => {}));
|
||||||
renderWithRouter('test-token');
|
renderWithRouter('test-token');
|
||||||
|
|
||||||
fireEvent.change(screen.getByPlaceholderText('New Password'), { target: { value: 'newSecurePassword123' } });
|
fireEvent.change(screen.getByPlaceholderText('New Password'), { target: { value: 'newSecurePassword123' } });
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// src/pages/ResetPasswordPage.tsx
|
// src/pages/ResetPasswordPage.tsx
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useParams, useNavigate, Link } from 'react-router-dom';
|
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||||
import { resetPassword } from '../services/apiClient';
|
import * as apiClient from '../services/apiClient';
|
||||||
import { logger } from '../services/logger';
|
import { logger } from '../services/logger';
|
||||||
import { LoadingSpinner } from '../components/LoadingSpinner';
|
import { LoadingSpinner } from '../components/LoadingSpinner';
|
||||||
import { PasswordInput } from './admin/components/PasswordInput';
|
import { PasswordInput } from './admin/components/PasswordInput';
|
||||||
@@ -38,8 +38,9 @@ export const ResetPasswordPage: React.FC = () => {
|
|||||||
setMessage('');
|
setMessage('');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await resetPassword(token, password);
|
const response = await apiClient.resetPassword(token, password);
|
||||||
setMessage(response.message + ' You will be redirected to the homepage shortly.');
|
const data = await response.json();
|
||||||
|
setMessage(data.message + ' You will be redirected to the homepage shortly.');
|
||||||
logger.info('Password has been successfully reset.');
|
logger.info('Password has been successfully reset.');
|
||||||
// After a short delay to allow the user to read the message, navigate them to the home page.
|
// After a short delay to allow the user to read the message, navigate them to the home page.
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ export const VoiceLabPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
setIsGeneratingSpeech(true);
|
setIsGeneratingSpeech(true);
|
||||||
try {
|
try {
|
||||||
const base64Audio = await generateSpeechFromText(textToSpeak);
|
const response = await generateSpeechFromText(textToSpeak);
|
||||||
|
const base64Audio = await response.json(); // Extract the base64 audio string from the response
|
||||||
if (base64Audio) {
|
if (base64Audio) {
|
||||||
const audioSrc = `data:audio/mpeg;base64,${base64Audio}`;
|
const audioSrc = `data:audio/mpeg;base64,${base64Audio}`;
|
||||||
const audio = new Audio(audioSrc);
|
const audio = new Audio(audioSrc);
|
||||||
|
|||||||
@@ -6,26 +6,9 @@ import { ActivityLog } from './ActivityLog';
|
|||||||
import * as apiClient from '../../services/apiClient';
|
import * as apiClient from '../../services/apiClient';
|
||||||
import type { ActivityLogItem, User } from '../../types';
|
import type { ActivityLogItem, User } from '../../types';
|
||||||
|
|
||||||
// Mock the apiClient module
|
// The apiClient and logger are now mocked globally via src/tests/setup/unit-setup.ts.
|
||||||
// The path must be relative to the test file's location.
|
// We can cast it to its mocked type to get type safety and autocompletion.
|
||||||
// Corrected from '../services/apiClient' to '../../services/apiClient'.
|
const mockedApiClient = vi.mocked(apiClient);
|
||||||
vi.mock('../../services/apiClient', async (importOriginal) => {
|
|
||||||
const actual = await importOriginal<typeof apiClient>();
|
|
||||||
return {
|
|
||||||
...actual,
|
|
||||||
fetchActivityLog: vi.fn(),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Mock the logger
|
|
||||||
vi.mock('../../services/logger', () => ({
|
|
||||||
logger: {
|
|
||||||
info: vi.fn(),
|
|
||||||
warn: vi.fn(),
|
|
||||||
error: vi.fn(),
|
|
||||||
debug: vi.fn(),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock date-fns to return a consistent value for snapshots
|
// Mock date-fns to return a consistent value for snapshots
|
||||||
vi.mock('date-fns', async (importOriginal) => {
|
vi.mock('date-fns', async (importOriginal) => {
|
||||||
@@ -71,34 +54,34 @@ describe('ActivityLog', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should not render if user is null', () => {
|
it('should not render if user is null', () => {
|
||||||
const { container } = render(<ActivityLog user={null} />);
|
const { container } = render(<ActivityLog user={null} onLogClick={vi.fn()} />);
|
||||||
expect(container).toBeEmptyDOMElement();
|
expect(container).toBeEmptyDOMElement();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should show a loading state initially', () => {
|
it('should show a loading state initially', () => {
|
||||||
(apiClient.fetchActivityLog as Mock).mockReturnValue(new Promise(() => {}));
|
mockedApiClient.fetchActivityLog.mockReturnValue(new Promise(() => {}));
|
||||||
render(<ActivityLog user={mockUser} />);
|
render(<ActivityLog user={mockUser} onLogClick={vi.fn()} />);
|
||||||
expect(screen.getByText('Loading activity...')).toBeInTheDocument();
|
expect(screen.getByText('Loading activity...')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should display an error message if fetching logs fails', async () => {
|
it('should display an error message if fetching logs fails', async () => {
|
||||||
(apiClient.fetchActivityLog as Mock).mockRejectedValue(new Error('API is down'));
|
mockedApiClient.fetchActivityLog.mockRejectedValue(new Error('API is down'));
|
||||||
render(<ActivityLog user={mockUser} />);
|
render(<ActivityLog user={mockUser} onLogClick={vi.fn()} />);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('API is down')).toBeInTheDocument();
|
expect(screen.getByText('API is down')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should display a message when there are no logs', async () => {
|
it('should display a message when there are no logs', async () => {
|
||||||
(apiClient.fetchActivityLog as Mock).mockResolvedValue([]);
|
mockedApiClient.fetchActivityLog.mockResolvedValue(new Response(JSON.stringify([])));
|
||||||
render(<ActivityLog user={mockUser} />);
|
render(<ActivityLog user={mockUser} onLogClick={vi.fn()} />);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('No recent activity to show.')).toBeInTheDocument();
|
expect(screen.getByText('No recent activity to show.')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should render a list of activities successfully', async () => {
|
it('should render a list of activities successfully', async () => {
|
||||||
(apiClient.fetchActivityLog as Mock).mockResolvedValue(mockLogs);
|
mockedApiClient.fetchActivityLog.mockResolvedValue(new Response(JSON.stringify(mockLogs)));
|
||||||
render(<ActivityLog user={mockUser} />);
|
render(<ActivityLog user={mockUser} />);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
// Check for specific text from different log types
|
// Check for specific text from different log types
|
||||||
@@ -121,7 +104,7 @@ describe('ActivityLog', () => {
|
|||||||
|
|
||||||
it('should call onLogClick when a clickable log item is clicked', async () => {
|
it('should call onLogClick when a clickable log item is clicked', async () => {
|
||||||
const onLogClickMock = vi.fn();
|
const onLogClickMock = vi.fn();
|
||||||
(apiClient.fetchActivityLog as Mock).mockResolvedValue(mockLogs);
|
mockedApiClient.fetchActivityLog.mockResolvedValue(new Response(JSON.stringify(mockLogs)));
|
||||||
render(<ActivityLog user={mockUser} onLogClick={onLogClickMock} />);
|
render(<ActivityLog user={mockUser} onLogClick={onLogClickMock} />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
|
|||||||
@@ -86,8 +86,9 @@ export const ActivityLog: React.FC<ActivityLogProps> = ({ user, onLogClick }) =>
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const fetchedLogs = await fetchActivityLog(20, 0);
|
const response = await fetchActivityLog(20, 0);
|
||||||
setLogs(fetchedLogs);
|
if (!response.ok) throw new Error((await response.json()).message || 'Failed to fetch logs');
|
||||||
|
setLogs(await response.json());
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Failed to load activity.');
|
setError(err instanceof Error ? err.message : 'Failed to load activity.');
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -7,24 +7,8 @@ import { AdminStatsPage } from './AdminStatsPage';
|
|||||||
import * as apiClient from '../../services/apiClient';
|
import * as apiClient from '../../services/apiClient';
|
||||||
import type { AppStats } from '../../services/apiClient';
|
import type { AppStats } from '../../services/apiClient';
|
||||||
|
|
||||||
// Mock the apiClient module to control the getApplicationStats function
|
// The apiClient and logger are now mocked globally via src/tests/setup/unit-setup.ts.
|
||||||
vi.mock('../../services/apiClient', async (importOriginal) => {
|
const mockedApiClient = vi.mocked(apiClient);
|
||||||
const actual = await importOriginal<typeof apiClient>();
|
|
||||||
return {
|
|
||||||
...actual,
|
|
||||||
getApplicationStats: vi.fn(),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Mock the logger to prevent console output during tests
|
|
||||||
vi.mock('../../services/logger', () => ({
|
|
||||||
logger: {
|
|
||||||
info: vi.fn(),
|
|
||||||
warn: vi.fn(),
|
|
||||||
error: vi.fn(),
|
|
||||||
debug: vi.fn(),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Helper function to render the component within a router context, as it contains a <Link>
|
// Helper function to render the component within a router context, as it contains a <Link>
|
||||||
const renderWithRouter = () => {
|
const renderWithRouter = () => {
|
||||||
@@ -42,7 +26,7 @@ describe('AdminStatsPage', () => {
|
|||||||
|
|
||||||
it('should render a loading spinner while fetching stats', () => {
|
it('should render a loading spinner while fetching stats', () => {
|
||||||
// Mock a promise that never resolves to keep the component in a loading state
|
// Mock a promise that never resolves to keep the component in a loading state
|
||||||
(apiClient.getApplicationStats as Mock).mockReturnValue(new Promise(() => {}));
|
mockedApiClient.getApplicationStats.mockReturnValue(new Promise(() => {}));
|
||||||
renderWithRouter();
|
renderWithRouter();
|
||||||
|
|
||||||
// The LoadingSpinner component is expected to be present. We find it by its accessible role.
|
// The LoadingSpinner component is expected to be present. We find it by its accessible role.
|
||||||
@@ -58,7 +42,7 @@ describe('AdminStatsPage', () => {
|
|||||||
storeCount: 42,
|
storeCount: 42,
|
||||||
pendingCorrectionCount: 5,
|
pendingCorrectionCount: 5,
|
||||||
};
|
};
|
||||||
(apiClient.getApplicationStats as Mock).mockResolvedValue(mockStats);
|
mockedApiClient.getApplicationStats.mockResolvedValue(new Response(JSON.stringify(mockStats)));
|
||||||
renderWithRouter();
|
renderWithRouter();
|
||||||
|
|
||||||
// Wait for the stats to be displayed
|
// Wait for the stats to be displayed
|
||||||
@@ -82,7 +66,7 @@ describe('AdminStatsPage', () => {
|
|||||||
|
|
||||||
it('should display an error message if fetching stats fails', async () => {
|
it('should display an error message if fetching stats fails', async () => {
|
||||||
const errorMessage = 'Failed to connect to the database.';
|
const errorMessage = 'Failed to connect to the database.';
|
||||||
(apiClient.getApplicationStats as Mock).mockRejectedValue(new Error(errorMessage));
|
mockedApiClient.getApplicationStats.mockRejectedValue(new Error(errorMessage));
|
||||||
renderWithRouter();
|
renderWithRouter();
|
||||||
|
|
||||||
// Wait for the error message to appear
|
// Wait for the error message to appear
|
||||||
|
|||||||
@@ -32,7 +32,8 @@ export const AdminStatsPage: React.FC = () => {
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const data = await getApplicationStats();
|
const response = await getApplicationStats();
|
||||||
|
const data = await response.json();
|
||||||
setStats(data);
|
setStats(data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const errorMessage = err instanceof Error ? err.message : 'An unknown error occurred.';
|
const errorMessage = err instanceof Error ? err.message : 'An unknown error occurred.';
|
||||||
|
|||||||
@@ -7,27 +7,8 @@ import { CorrectionsPage } from './CorrectionsPage';
|
|||||||
import * as apiClient from '../../services/apiClient';
|
import * as apiClient from '../../services/apiClient';
|
||||||
import type { SuggestedCorrection, MasterGroceryItem, Category } from '../../types';
|
import type { SuggestedCorrection, MasterGroceryItem, Category } from '../../types';
|
||||||
|
|
||||||
// Mock the apiClient module
|
// The apiClient and logger are now mocked globally via src/tests/setup/unit-setup.ts.
|
||||||
// The path must be relative to the test file's location.
|
const mockedApiClient = vi.mocked(apiClient);
|
||||||
vi.mock('../../services/apiClient', async (importOriginal) => {
|
|
||||||
const actual = await importOriginal<typeof apiClient>();
|
|
||||||
return {
|
|
||||||
...actual,
|
|
||||||
getSuggestedCorrections: vi.fn(),
|
|
||||||
fetchMasterItems: vi.fn(),
|
|
||||||
fetchCategories: vi.fn(),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Mock the logger
|
|
||||||
vi.mock('../../services/logger', () => ({
|
|
||||||
logger: {
|
|
||||||
info: vi.fn(),
|
|
||||||
warn: vi.fn(),
|
|
||||||
error: vi.fn(),
|
|
||||||
debug: vi.fn(),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock the child CorrectionRow component to isolate the test to the page itself
|
// Mock the child CorrectionRow component to isolate the test to the page itself
|
||||||
// The CorrectionRow component is now located in a sub-directory.
|
// The CorrectionRow component is now located in a sub-directory.
|
||||||
@@ -62,9 +43,9 @@ describe('CorrectionsPage', () => {
|
|||||||
|
|
||||||
it('should render a loading spinner while fetching data', () => {
|
it('should render a loading spinner while fetching data', () => {
|
||||||
// Mock a promise that never resolves to keep the component in a loading state
|
// Mock a promise that never resolves to keep the component in a loading state
|
||||||
(apiClient.getSuggestedCorrections as Mock).mockReturnValue(new Promise(() => {}));
|
mockedApiClient.getSuggestedCorrections.mockReturnValue(new Promise(() => {}));
|
||||||
(apiClient.fetchMasterItems as Mock).mockReturnValue(new Promise(() => {}));
|
mockedApiClient.fetchMasterItems.mockReturnValue(new Promise(() => {}));
|
||||||
(apiClient.fetchCategories as Mock).mockReturnValue(new Promise(() => {}));
|
mockedApiClient.fetchCategories.mockReturnValue(new Promise(() => {}));
|
||||||
renderWithRouter();
|
renderWithRouter();
|
||||||
|
|
||||||
expect(screen.getByRole('status', { name: /loading/i })).toBeInTheDocument();
|
expect(screen.getByRole('status', { name: /loading/i })).toBeInTheDocument();
|
||||||
@@ -72,9 +53,9 @@ describe('CorrectionsPage', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should display corrections when data is fetched successfully', async () => {
|
it('should display corrections when data is fetched successfully', async () => {
|
||||||
(apiClient.getSuggestedCorrections as Mock).mockResolvedValue(mockCorrections);
|
mockedApiClient.getSuggestedCorrections.mockResolvedValue(new Response(JSON.stringify(mockCorrections)));
|
||||||
(apiClient.fetchMasterItems as Mock).mockResolvedValue(mockMasterItems);
|
mockedApiClient.fetchMasterItems.mockResolvedValue(new Response(JSON.stringify(mockMasterItems)));
|
||||||
(apiClient.fetchCategories as Mock).mockResolvedValue(mockCategories);
|
mockedApiClient.fetchCategories.mockResolvedValue(new Response(JSON.stringify(mockCategories)));
|
||||||
renderWithRouter();
|
renderWithRouter();
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -88,9 +69,9 @@ describe('CorrectionsPage', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should display a message when there are no pending corrections', async () => {
|
it('should display a message when there are no pending corrections', async () => {
|
||||||
(apiClient.getSuggestedCorrections as Mock).mockResolvedValue([]);
|
mockedApiClient.getSuggestedCorrections.mockResolvedValue(new Response(JSON.stringify([])));
|
||||||
(apiClient.fetchMasterItems as Mock).mockResolvedValue(mockMasterItems);
|
mockedApiClient.fetchMasterItems.mockResolvedValue(new Response(JSON.stringify(mockMasterItems)));
|
||||||
(apiClient.fetchCategories as Mock).mockResolvedValue(mockCategories);
|
mockedApiClient.fetchCategories.mockResolvedValue(new Response(JSON.stringify(mockCategories)));
|
||||||
renderWithRouter();
|
renderWithRouter();
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -100,9 +81,9 @@ describe('CorrectionsPage', () => {
|
|||||||
|
|
||||||
it('should display an error message if fetching corrections fails', async () => {
|
it('should display an error message if fetching corrections fails', async () => {
|
||||||
const errorMessage = 'Network Error: Failed to fetch';
|
const errorMessage = 'Network Error: Failed to fetch';
|
||||||
(apiClient.getSuggestedCorrections as Mock).mockRejectedValue(new Error(errorMessage));
|
mockedApiClient.getSuggestedCorrections.mockRejectedValue(new Error(errorMessage));
|
||||||
(apiClient.fetchMasterItems as Mock).mockResolvedValue(mockMasterItems);
|
mockedApiClient.fetchMasterItems.mockResolvedValue(new Response(JSON.stringify(mockMasterItems)));
|
||||||
(apiClient.fetchCategories as Mock).mockResolvedValue(mockCategories);
|
mockedApiClient.fetchCategories.mockResolvedValue(new Response(JSON.stringify(mockCategories)));
|
||||||
renderWithRouter();
|
renderWithRouter();
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -112,9 +93,9 @@ describe('CorrectionsPage', () => {
|
|||||||
|
|
||||||
it('should display an error message if fetching master items fails', async () => {
|
it('should display an error message if fetching master items fails', async () => {
|
||||||
const errorMessage = 'Could not retrieve master items list.';
|
const errorMessage = 'Could not retrieve master items list.';
|
||||||
(apiClient.getSuggestedCorrections as Mock).mockResolvedValue(mockCorrections);
|
mockedApiClient.getSuggestedCorrections.mockResolvedValue(new Response(JSON.stringify(mockCorrections)));
|
||||||
(apiClient.fetchMasterItems as Mock).mockRejectedValue(new Error(errorMessage));
|
mockedApiClient.fetchMasterItems.mockRejectedValue(new Error(errorMessage));
|
||||||
(apiClient.fetchCategories as Mock).mockResolvedValue(mockCategories);
|
mockedApiClient.fetchCategories.mockResolvedValue(new Response(JSON.stringify(mockCategories)));
|
||||||
renderWithRouter();
|
renderWithRouter();
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
|
|||||||
@@ -20,14 +20,14 @@ export const CorrectionsPage: React.FC = () => {
|
|||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
// Fetch all required data in parallel for efficiency
|
// Fetch all required data in parallel for efficiency
|
||||||
const [correctionsData, masterItemsData, categoriesData] = await Promise.all([
|
const [correctionsResponse, masterItemsResponse, categoriesResponse] = await Promise.all([
|
||||||
getSuggestedCorrections(),
|
getSuggestedCorrections(),
|
||||||
fetchMasterItems(),
|
fetchMasterItems(),
|
||||||
fetchCategories()
|
fetchCategories()
|
||||||
]);
|
]);
|
||||||
setCorrections(correctionsData);
|
setCorrections(await correctionsResponse.json());
|
||||||
setMasterItems(masterItemsData);
|
setMasterItems(await masterItemsResponse.json());
|
||||||
setCategories(categoriesData);
|
setCategories(await categoriesResponse.json());
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('Failed to fetch corrections', err);
|
logger.error('Failed to fetch corrections', err);
|
||||||
const errorMessage = err instanceof Error ? err.message : 'An unknown error occurred while fetching corrections.';
|
const errorMessage = err instanceof Error ? err.message : 'An unknown error occurred while fetching corrections.';
|
||||||
|
|||||||
@@ -7,20 +7,12 @@ import { AdminBrandManager } from './AdminBrandManager';
|
|||||||
import * as apiClient from '../../../services/apiClient';
|
import * as apiClient from '../../../services/apiClient';
|
||||||
import type { Brand } from '../../../types';
|
import type { Brand } from '../../../types';
|
||||||
|
|
||||||
// Mock the apiClient module
|
// After mocking, we can get a type-safe mocked version of the module.
|
||||||
vi.mock('../../../services/apiClient', () => ({
|
// This allows us to use .mockResolvedValue, .mockRejectedValue, etc. on the functions.
|
||||||
fetchAllBrands: vi.fn(),
|
// The apiClient is now mocked globally via src/tests/setup/unit-setup.ts.
|
||||||
uploadBrandLogo: vi.fn(),
|
const mockedApiClient = vi.mocked(apiClient);
|
||||||
}));
|
const mockedToast = vi.mocked(toast, true);
|
||||||
|
|
||||||
// Mock react-hot-toast
|
|
||||||
vi.mock('react-hot-toast', () => ({
|
|
||||||
default: {
|
|
||||||
loading: vi.fn(),
|
|
||||||
success: vi.fn(),
|
|
||||||
error: vi.fn(),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
const mockBrands: Brand[] = [
|
const mockBrands: Brand[] = [
|
||||||
{ brand_id: 1, name: 'No Frills', store_name: 'No Frills', logo_url: null },
|
{ brand_id: 1, name: 'No Frills', store_name: 'No Frills', logo_url: null },
|
||||||
@@ -33,13 +25,13 @@ describe('AdminBrandManager', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should render a loading state initially', () => {
|
it('should render a loading state initially', () => {
|
||||||
(apiClient.fetchAllBrands as Mock).mockReturnValue(new Promise(() => {})); // Never resolves
|
mockedApiClient.fetchAllBrands.mockReturnValue(new Promise(() => {})); // Never resolves
|
||||||
render(<AdminBrandManager />);
|
render(<AdminBrandManager />);
|
||||||
expect(screen.getByText('Loading brands...')).toBeInTheDocument();
|
expect(screen.getByText('Loading brands...')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should render an error message if fetching brands fails', async () => {
|
it('should render an error message if fetching brands fails', async () => {
|
||||||
(apiClient.fetchAllBrands as Mock).mockRejectedValue(new Error('Network Error'));
|
mockedApiClient.fetchAllBrands.mockRejectedValue(new Error('Network Error'));
|
||||||
render(<AdminBrandManager />);
|
render(<AdminBrandManager />);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('Failed to load brands: Network Error')).toBeInTheDocument();
|
expect(screen.getByText('Failed to load brands: Network Error')).toBeInTheDocument();
|
||||||
@@ -47,7 +39,7 @@ describe('AdminBrandManager', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should render the list of brands when data is fetched successfully', async () => {
|
it('should render the list of brands when data is fetched successfully', async () => {
|
||||||
(apiClient.fetchAllBrands as Mock).mockResolvedValue(mockBrands);
|
mockedApiClient.fetchAllBrands.mockResolvedValue(new Response(JSON.stringify(mockBrands)));
|
||||||
render(<AdminBrandManager />);
|
render(<AdminBrandManager />);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByRole('heading', { name: /brand management/i })).toBeInTheDocument();
|
expect(screen.getByRole('heading', { name: /brand management/i })).toBeInTheDocument();
|
||||||
@@ -59,9 +51,9 @@ describe('AdminBrandManager', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should handle successful logo upload', async () => {
|
it('should handle successful logo upload', async () => {
|
||||||
(apiClient.fetchAllBrands as Mock).mockResolvedValue(mockBrands);
|
mockedApiClient.fetchAllBrands.mockResolvedValue(new Response(JSON.stringify(mockBrands)));
|
||||||
(apiClient.uploadBrandLogo as Mock).mockResolvedValue({ logoUrl: 'http://example.com/new-logo.png' });
|
mockedApiClient.uploadBrandLogo.mockResolvedValue(new Response(JSON.stringify({ logoUrl: 'http://example.com/new-logo.png' })));
|
||||||
(toast.loading as Mock).mockReturnValue('toast-1');
|
mockedToast.loading.mockReturnValue('toast-1');
|
||||||
|
|
||||||
render(<AdminBrandManager />);
|
render(<AdminBrandManager />);
|
||||||
await waitFor(() => expect(screen.getByText('No Frills')).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByText('No Frills')).toBeInTheDocument());
|
||||||
@@ -73,18 +65,18 @@ describe('AdminBrandManager', () => {
|
|||||||
fireEvent.change(input, { target: { files: [file] } });
|
fireEvent.change(input, { target: { files: [file] } });
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(apiClient.uploadBrandLogo).toHaveBeenCalledWith(1, file);
|
expect(mockedApiClient.uploadBrandLogo).toHaveBeenCalledWith(1, file);
|
||||||
expect(toast.loading).toHaveBeenCalledWith('Uploading logo...');
|
expect(mockedToast.loading).toHaveBeenCalledWith('Uploading logo...');
|
||||||
expect(toast.success).toHaveBeenCalledWith('Logo updated successfully!', { id: 'toast-1' });
|
expect(mockedToast.success).toHaveBeenCalledWith('Logo updated successfully!', { id: 'toast-1' });
|
||||||
// Check if the UI updates with the new logo
|
// Check if the UI updates with the new logo
|
||||||
expect(screen.getByAltText('No Frills logo')).toHaveAttribute('src', 'http://example.com/new-logo.png');
|
expect(screen.getByAltText('No Frills logo')).toHaveAttribute('src', 'http://example.com/new-logo.png');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle failed logo upload', async () => {
|
it('should handle failed logo upload', async () => {
|
||||||
(apiClient.fetchAllBrands as Mock).mockResolvedValue(mockBrands);
|
mockedApiClient.fetchAllBrands.mockResolvedValue(new Response(JSON.stringify(mockBrands)));
|
||||||
(apiClient.uploadBrandLogo as Mock).mockRejectedValue(new Error('Upload failed'));
|
mockedApiClient.uploadBrandLogo.mockRejectedValue(new Error('Upload failed'));
|
||||||
(toast.loading as Mock).mockReturnValue('toast-2');
|
mockedToast.loading.mockReturnValue('toast-2');
|
||||||
|
|
||||||
render(<AdminBrandManager />);
|
render(<AdminBrandManager />);
|
||||||
await waitFor(() => expect(screen.getByText('No Frills')).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByText('No Frills')).toBeInTheDocument());
|
||||||
@@ -95,12 +87,12 @@ describe('AdminBrandManager', () => {
|
|||||||
fireEvent.change(input, { target: { files: [file] } });
|
fireEvent.change(input, { target: { files: [file] } });
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(toast.error).toHaveBeenCalledWith('Upload failed: Upload failed', { id: 'toast-2' });
|
expect(mockedToast.error).toHaveBeenCalledWith('Upload failed: Upload failed', { id: 'toast-2' });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should show an error toast for invalid file type', async () => {
|
it('should show an error toast for invalid file type', async () => {
|
||||||
(apiClient.fetchAllBrands as Mock).mockResolvedValue(mockBrands);
|
mockedApiClient.fetchAllBrands.mockResolvedValue(new Response(JSON.stringify(mockBrands)));
|
||||||
render(<AdminBrandManager />);
|
render(<AdminBrandManager />);
|
||||||
await waitFor(() => expect(screen.getByText('No Frills')).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByText('No Frills')).toBeInTheDocument());
|
||||||
|
|
||||||
@@ -110,13 +102,13 @@ describe('AdminBrandManager', () => {
|
|||||||
fireEvent.change(input, { target: { files: [file] } });
|
fireEvent.change(input, { target: { files: [file] } });
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(toast.error).toHaveBeenCalledWith('Invalid file type. Please upload a PNG, JPG, WEBP, or SVG.');
|
expect(mockedToast.error).toHaveBeenCalledWith('Invalid file type. Please upload a PNG, JPG, WEBP, or SVG.');
|
||||||
expect(apiClient.uploadBrandLogo).not.toHaveBeenCalled();
|
expect(mockedApiClient.uploadBrandLogo).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should show an error toast for oversized file', async () => {
|
it('should show an error toast for oversized file', async () => {
|
||||||
(apiClient.fetchAllBrands as Mock).mockResolvedValue(mockBrands);
|
mockedApiClient.fetchAllBrands.mockResolvedValue(new Response(JSON.stringify(mockBrands)));
|
||||||
render(<AdminBrandManager />);
|
render(<AdminBrandManager />);
|
||||||
await waitFor(() => expect(screen.getByText('No Frills')).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByText('No Frills')).toBeInTheDocument());
|
||||||
|
|
||||||
@@ -126,8 +118,8 @@ describe('AdminBrandManager', () => {
|
|||||||
fireEvent.change(input, { target: { files: [file] } });
|
fireEvent.change(input, { target: { files: [file] } });
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(toast.error).toHaveBeenCalledWith('File is too large. Maximum size is 2MB.');
|
expect(mockedToast.error).toHaveBeenCalledWith('File is too large. Maximum size is 2MB.');
|
||||||
expect(apiClient.uploadBrandLogo).not.toHaveBeenCalled();
|
expect(mockedApiClient.uploadBrandLogo).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -14,7 +14,8 @@ export const AdminBrandManager: React.FC = () => {
|
|||||||
const loadBrands = async () => {
|
const loadBrands = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const fetchedBrands = await fetchAllBrands();
|
const response = await fetchAllBrands();
|
||||||
|
const fetchedBrands = await response.json();
|
||||||
setBrands(fetchedBrands);
|
setBrands(fetchedBrands);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const errorMessage = e instanceof Error ? e.message : String(e);
|
const errorMessage = e instanceof Error ? e.message : String(e);
|
||||||
@@ -45,7 +46,8 @@ export const AdminBrandManager: React.FC = () => {
|
|||||||
const toastId = toast.loading('Uploading logo...');
|
const toastId = toast.loading('Uploading logo...');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { logoUrl } = await uploadBrandLogo(brandId, file);
|
const response = await uploadBrandLogo(brandId, file);
|
||||||
|
const { logoUrl } = await response.json();
|
||||||
toast.success('Logo updated successfully!', { id: toastId });
|
toast.success('Logo updated successfully!', { id: toastId });
|
||||||
|
|
||||||
// Update the state to show the new logo immediately
|
// Update the state to show the new logo immediately
|
||||||
|
|||||||
@@ -2,17 +2,14 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ReactDOM from 'react-dom';
|
import ReactDOM from 'react-dom';
|
||||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||||
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
import { describe, it, expect, vi, beforeEach, type Mock, type Mocked } from 'vitest';
|
||||||
import { CorrectionRow } from './CorrectionRow';
|
import { CorrectionRow } from './CorrectionRow';
|
||||||
import * as apiClient from '../../../services/apiClient';
|
import * as apiClient from '../../../services/apiClient';
|
||||||
import type { SuggestedCorrection, MasterGroceryItem, Category } from '../../../types';
|
import type { SuggestedCorrection, MasterGroceryItem, Category } from '../../../types';
|
||||||
|
|
||||||
// Mock the apiClient module
|
// Cast the mocked module to its mocked type to retain type safety and autocompletion.
|
||||||
vi.mock('../../../services/apiClient', () => ({
|
// The apiClient is now mocked globally via src/tests/setup/unit-setup.ts.
|
||||||
approveCorrection: vi.fn(),
|
const mockedApiClient = apiClient as Mocked<typeof apiClient>;
|
||||||
rejectCorrection: vi.fn(),
|
|
||||||
updateSuggestedCorrection: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock the logger
|
// Mock the logger
|
||||||
vi.mock('../../../services/logger', () => ({
|
vi.mock('../../../services/logger', () => ({
|
||||||
@@ -82,9 +79,9 @@ const renderInTable = (props = defaultProps) => {
|
|||||||
describe('CorrectionRow', () => {
|
describe('CorrectionRow', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
(apiClient.approveCorrection as Mock).mockResolvedValue({});
|
mockedApiClient.approveCorrection.mockResolvedValue(new Response(null, { status: 204 }));
|
||||||
(apiClient.rejectCorrection as Mock).mockResolvedValue({});
|
mockedApiClient.rejectCorrection.mockResolvedValue(new Response(null, { status: 204 }));
|
||||||
(apiClient.updateSuggestedCorrection as Mock).mockResolvedValue({ ...mockCorrection, suggested_value: '300' });
|
mockedApiClient.updateSuggestedCorrection.mockResolvedValue(new Response(JSON.stringify({ ...mockCorrection, suggested_value: '300' })));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should render correction data correctly', () => {
|
it('should render correction data correctly', () => {
|
||||||
@@ -111,7 +108,7 @@ describe('CorrectionRow', () => {
|
|||||||
fireEvent.click(screen.getByRole('button', { name: 'Confirm' }));
|
fireEvent.click(screen.getByRole('button', { name: 'Confirm' }));
|
||||||
});
|
});
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(apiClient.approveCorrection).toHaveBeenCalledWith(mockCorrection.suggested_correction_id);
|
expect(mockedApiClient.approveCorrection).toHaveBeenCalledWith(mockCorrection.suggested_correction_id);
|
||||||
expect(mockOnProcessed).toHaveBeenCalledWith(mockCorrection.suggested_correction_id);
|
expect(mockOnProcessed).toHaveBeenCalledWith(mockCorrection.suggested_correction_id);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -124,13 +121,13 @@ describe('CorrectionRow', () => {
|
|||||||
fireEvent.click(screen.getByRole('button', { name: 'Confirm' }));
|
fireEvent.click(screen.getByRole('button', { name: 'Confirm' }));
|
||||||
});
|
});
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(apiClient.rejectCorrection).toHaveBeenCalledWith(mockCorrection.suggested_correction_id);
|
expect(mockedApiClient.rejectCorrection).toHaveBeenCalledWith(mockCorrection.suggested_correction_id);
|
||||||
expect(mockOnProcessed).toHaveBeenCalledWith(mockCorrection.suggested_correction_id);
|
expect(mockOnProcessed).toHaveBeenCalledWith(mockCorrection.suggested_correction_id);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should display an error message if an action fails', async () => {
|
it('should display an error message if an action fails', async () => {
|
||||||
(apiClient.approveCorrection as Mock).mockRejectedValue(new Error('API Error'));
|
mockedApiClient.approveCorrection.mockRejectedValue(new Error('API Error'));
|
||||||
renderInTable();
|
renderInTable();
|
||||||
fireEvent.click(screen.getByTitle('Approve'));
|
fireEvent.click(screen.getByTitle('Approve'));
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -168,7 +165,7 @@ describe('CorrectionRow', () => {
|
|||||||
fireEvent.click(screen.getByTitle('Save'));
|
fireEvent.click(screen.getByTitle('Save'));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(apiClient.updateSuggestedCorrection).toHaveBeenCalledWith(mockCorrection.suggested_correction_id, '300');
|
expect(mockedApiClient.updateSuggestedCorrection).toHaveBeenCalledWith(mockCorrection.suggested_correction_id, '300');
|
||||||
// The component should now display the updated value from the mock response
|
// The component should now display the updated value from the mock response
|
||||||
expect(screen.getByText('$3.00')).toBeInTheDocument();
|
expect(screen.getByText('$3.00')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -77,7 +77,8 @@ export const CorrectionRow: React.FC<CorrectionRowProps> = ({ correction: initia
|
|||||||
setIsProcessing(true);
|
setIsProcessing(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const updatedCorrection = await updateSuggestedCorrection(currentCorrection.suggested_correction_id, editableValue);
|
const response = await updateSuggestedCorrection(currentCorrection.suggested_correction_id, editableValue);
|
||||||
|
const updatedCorrection = await response.json();
|
||||||
setCurrentCorrection(updatedCorrection); // Update local state with the saved version
|
setCurrentCorrection(updatedCorrection); // Update local state with the saved version
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -6,33 +6,7 @@ import { ProfileManager } from './ProfileManager';
|
|||||||
import * as apiClient from '../../../services/apiClient'; // Import the entire module to mock functions
|
import * as apiClient from '../../../services/apiClient'; // Import the entire module to mock functions
|
||||||
import { notifySuccess, notifyError } from '../../../services/notificationService'; // Import the notification service to check calls
|
import { notifySuccess, notifyError } from '../../../services/notificationService'; // Import the notification service to check calls
|
||||||
|
|
||||||
// Mock the apiClient functions
|
const mockedApiClient = vi.mocked(apiClient);
|
||||||
vi.mock('../../../services/apiClient', () => ({
|
|
||||||
loginUser: vi.fn(),
|
|
||||||
registerUser: vi.fn(),
|
|
||||||
requestPasswordReset: vi.fn(),
|
|
||||||
updateUserProfile: vi.fn(), // Also mock for completeness, though not directly tested here
|
|
||||||
exportUserData: vi.fn(), // Also mock for completeness
|
|
||||||
updateUserPassword: vi.fn(), // Also mock for completeness
|
|
||||||
deleteUserAccount: vi.fn(), // Also mock for completeness
|
|
||||||
updateUserPreferences: vi.fn(), // Also mock for completeness
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock the logger to prevent console output during tests
|
|
||||||
vi.mock('../../../services/logger', () => ({
|
|
||||||
logger: {
|
|
||||||
info: vi.fn(),
|
|
||||||
warn: vi.fn(),
|
|
||||||
error: vi.fn(),
|
|
||||||
debug: vi.fn(),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock the notificationService to prevent actual toasts and spy on calls
|
|
||||||
vi.mock('../../../services/notificationService', () => ({
|
|
||||||
notifySuccess: vi.fn(),
|
|
||||||
notifyError: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const mockOnClose = vi.fn();
|
const mockOnClose = vi.fn();
|
||||||
const mockOnLoginSuccess = vi.fn();
|
const mockOnLoginSuccess = vi.fn();
|
||||||
@@ -54,18 +28,23 @@ describe('ProfileManager Authentication Flows', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// Reset all mocks before each test
|
// Reset all mocks before each test
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
// Reset default mock implementations for apiClient functions
|
// Mock API client functions to return a Response-like object with a .json() method
|
||||||
(apiClient.loginUser as Mock).mockResolvedValue({
|
// The global setup now handles the mocking, but we still need to provide resolved values for each test.
|
||||||
user: { id: '123', email: 'test@example.com' },
|
const mockAuthResponse = {
|
||||||
|
user: { user_id: '123', email: 'test@example.com' },
|
||||||
token: 'mock-token',
|
token: 'mock-token',
|
||||||
});
|
};
|
||||||
(apiClient.registerUser as Mock).mockResolvedValue({
|
mockedApiClient.loginUser.mockResolvedValue({
|
||||||
user: { id: '123', email: 'test@example.com' },
|
ok: true,
|
||||||
token: 'mock-token',
|
json: () => Promise.resolve(mockAuthResponse),
|
||||||
});
|
} as Response);
|
||||||
(apiClient.requestPasswordReset as Mock).mockResolvedValue({
|
mockedApiClient.registerUser.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve(mockAuthResponse),
|
||||||
|
} as Response);
|
||||||
|
mockedApiClient.requestPasswordReset.mockResolvedValue({
|
||||||
message: 'Password reset email sent.',
|
message: 'Password reset email sent.',
|
||||||
});
|
} as any);
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Initial Render (Signed Out) ---
|
// --- Initial Render (Signed Out) ---
|
||||||
@@ -103,9 +82,9 @@ describe('ProfileManager Authentication Flows', () => {
|
|||||||
fireEvent.submit(screen.getByTestId('auth-form'));
|
fireEvent.submit(screen.getByTestId('auth-form'));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(apiClient.loginUser).toHaveBeenCalledWith('user@test.com', 'securepassword', false);
|
expect(mockedApiClient.loginUser).toHaveBeenCalledWith('user@test.com', 'securepassword', false);
|
||||||
expect(mockOnLoginSuccess).toHaveBeenCalledWith(
|
expect(mockOnLoginSuccess).toHaveBeenCalledWith(
|
||||||
{ id: '123', email: 'test@example.com' },
|
{ user_id: '123', email: 'test@example.com' },
|
||||||
'mock-token',
|
'mock-token',
|
||||||
false
|
false
|
||||||
);
|
);
|
||||||
@@ -114,7 +93,10 @@ describe('ProfileManager Authentication Flows', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should display an error message on failed login', async () => {
|
it('should display an error message on failed login', async () => {
|
||||||
(apiClient.loginUser as Mock).mockRejectedValueOnce(new Error('Invalid credentials'));
|
mockedApiClient.loginUser.mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
json: () => Promise.resolve({ message: 'Invalid credentials' }),
|
||||||
|
} as Response);
|
||||||
render(<ProfileManager {...defaultProps} />);
|
render(<ProfileManager {...defaultProps} />);
|
||||||
|
|
||||||
fireEvent.change(screen.getByLabelText(/^Email Address$/i), { target: { value: 'user@test.com' } });
|
fireEvent.change(screen.getByLabelText(/^Email Address$/i), { target: { value: 'user@test.com' } });
|
||||||
@@ -129,7 +111,7 @@ describe('ProfileManager Authentication Flows', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should show loading spinner during login attempt', async () => {
|
it('should show loading spinner during login attempt', async () => {
|
||||||
(apiClient.loginUser as Mock).mockReturnValueOnce(new Promise(() => {})); // Never resolve
|
mockedApiClient.loginUser.mockReturnValueOnce(new Promise(() => {})); // Never resolve
|
||||||
render(<ProfileManager {...defaultProps} />);
|
render(<ProfileManager {...defaultProps} />);
|
||||||
|
|
||||||
const signInButton = screen.getByRole('button', { name: /^sign in$/i });
|
const signInButton = screen.getByRole('button', { name: /^sign in$/i });
|
||||||
@@ -167,9 +149,9 @@ describe('ProfileManager Authentication Flows', () => {
|
|||||||
fireEvent.submit(screen.getByTestId('auth-form')); // Submit register form
|
fireEvent.submit(screen.getByTestId('auth-form')); // Submit register form
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(apiClient.registerUser).toHaveBeenCalledWith('newuser@test.com', 'newsecurepassword', '', '');
|
expect(mockedApiClient.registerUser).toHaveBeenCalledWith('newuser@test.com', 'newsecurepassword', '', '');
|
||||||
expect(mockOnLoginSuccess).toHaveBeenCalledWith(
|
expect(mockOnLoginSuccess).toHaveBeenCalledWith(
|
||||||
{ id: '123', email: 'test@example.com' },
|
{ user_id: '123', email: 'test@example.com' },
|
||||||
'mock-token',
|
'mock-token',
|
||||||
false
|
false
|
||||||
);
|
);
|
||||||
@@ -193,9 +175,9 @@ describe('ProfileManager Authentication Flows', () => {
|
|||||||
|
|
||||||
// 4. Assert that the correct functions were called with the correct data
|
// 4. Assert that the correct functions were called with the correct data
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(apiClient.registerUser).toHaveBeenCalledWith('newuser@test.com', 'newsecurepassword', 'New Test User', 'http://example.com/new.png');
|
expect(mockedApiClient.registerUser).toHaveBeenCalledWith('newuser@test.com', 'newsecurepassword', 'New Test User', 'http://example.com/new.png');
|
||||||
expect(mockOnLoginSuccess).toHaveBeenCalledWith(
|
expect(mockOnLoginSuccess).toHaveBeenCalledWith(
|
||||||
{ id: '123', email: 'test@example.com' },
|
{ user_id: '123', email: 'test@example.com' },
|
||||||
'mock-token',
|
'mock-token',
|
||||||
false
|
false
|
||||||
);
|
);
|
||||||
@@ -204,7 +186,10 @@ describe('ProfileManager Authentication Flows', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should display an error message on failed registration', async () => {
|
it('should display an error message on failed registration', async () => {
|
||||||
(apiClient.registerUser as Mock).mockRejectedValueOnce(new Error('Email already in use'));
|
mockedApiClient.registerUser.mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
json: () => Promise.resolve({ message: 'Email already in use' }),
|
||||||
|
} as Response);
|
||||||
render(<ProfileManager {...defaultProps} />);
|
render(<ProfileManager {...defaultProps} />);
|
||||||
fireEvent.click(screen.getByRole('button', { name: /register/i }));
|
fireEvent.click(screen.getByRole('button', { name: /register/i }));
|
||||||
|
|
||||||
@@ -232,6 +217,10 @@ describe('ProfileManager Authentication Flows', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should call requestPasswordReset and display success message on successful request', async () => {
|
it('should call requestPasswordReset and display success message on successful request', async () => {
|
||||||
|
mockedApiClient.requestPasswordReset.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({ message: 'Password reset email sent.' }),
|
||||||
|
} as Response);
|
||||||
render(<ProfileManager {...defaultProps} />);
|
render(<ProfileManager {...defaultProps} />);
|
||||||
fireEvent.click(screen.getByRole('button', { name: /forgot password/i }));
|
fireEvent.click(screen.getByRole('button', { name: /forgot password/i }));
|
||||||
|
|
||||||
@@ -239,13 +228,16 @@ describe('ProfileManager Authentication Flows', () => {
|
|||||||
fireEvent.submit(screen.getByTestId('reset-password-form'));
|
fireEvent.submit(screen.getByTestId('reset-password-form'));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(apiClient.requestPasswordReset).toHaveBeenCalledWith('reset@test.com');
|
expect(mockedApiClient.requestPasswordReset).toHaveBeenCalledWith('reset@test.com');
|
||||||
expect(notifySuccess).toHaveBeenCalledWith('Password reset email sent.');
|
expect(notifySuccess).toHaveBeenCalledWith('Password reset email sent.');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should display an error message on failed password reset request', async () => {
|
it('should display an error message on failed password reset request', async () => {
|
||||||
(apiClient.requestPasswordReset as Mock).mockRejectedValueOnce(new Error('User not found'));
|
mockedApiClient.requestPasswordReset.mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
json: () => Promise.resolve({ message: 'User not found' }),
|
||||||
|
} as Response);
|
||||||
render(<ProfileManager {...defaultProps} />);
|
render(<ProfileManager {...defaultProps} />);
|
||||||
fireEvent.click(screen.getByRole('button', { name: /forgot password/i }));
|
fireEvent.click(screen.getByRole('button', { name: /forgot password/i }));
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
// src/pages/admin/components/ProfileManager.tsx
|
// src/pages/admin/components/ProfileManager.tsx
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import type { Profile } from '../../../types';
|
import type { Profile } from '../../../types';
|
||||||
import { updateUserPreferences, updateUserPassword, deleteUserAccount, loginUser, registerUser, requestPasswordReset, updateUserProfile, exportUserData } from '../../../services/apiClient';
|
import { useApi } from '../../../hooks/useApi';
|
||||||
|
import * as apiClient from '../../../services/apiClient';
|
||||||
import { notifySuccess, notifyError } from '../../../services/notificationService';
|
import { notifySuccess, notifyError } from '../../../services/notificationService';
|
||||||
import { logger } from '../../../services/logger';
|
import { logger } from '../../../services/logger';
|
||||||
import { LoadingSpinner } from '../../../components/LoadingSpinner';
|
import { LoadingSpinner } from '../../../components/LoadingSpinner';
|
||||||
@@ -30,7 +31,7 @@ export const ProfileManager: React.FC<ProfileManagerProps> = ({ isOpen, onClose,
|
|||||||
// Profile state
|
// Profile state
|
||||||
const [fullName, setFullName] = useState(profile?.full_name || '');
|
const [fullName, setFullName] = useState(profile?.full_name || '');
|
||||||
const [avatarUrl, setAvatarUrl] = useState(profile?.avatar_url || '');
|
const [avatarUrl, setAvatarUrl] = useState(profile?.avatar_url || '');
|
||||||
const [profileLoading, setProfileLoading] = useState(false);
|
const { execute: executeProfileUpdate, loading: profileLoading } = useApi(updateUserProfile);
|
||||||
|
|
||||||
// Password state
|
// Password state
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
@@ -76,26 +77,36 @@ export const ProfileManager: React.FC<ProfileManagerProps> = ({ isOpen, onClose,
|
|||||||
|
|
||||||
const handleProfileSave = async (e: React.FormEvent) => {
|
const handleProfileSave = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setProfileLoading(true);
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
notifyError("Cannot save profile, no user is logged in.");
|
notifyError("Cannot save profile, no user is logged in.");
|
||||||
setProfileLoading(false);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
|
||||||
const updatedProfile = await updateUserProfile({ // Use the new apiClient function
|
const updatedProfile = await apiClient.updateUserProfile({
|
||||||
full_name: fullName,
|
full_name: fullName,
|
||||||
avatar_url: avatarUrl
|
avatar_url: avatarUrl,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (updatedProfile) {
|
||||||
onProfileUpdate(updatedProfile);
|
onProfileUpdate(updatedProfile);
|
||||||
logger.info('User profile updated successfully.', { userId: user.user_id, fullName, avatarUrl });
|
logger.info('User profile updated successfully.', { userId: user.user_id, fullName, avatarUrl });
|
||||||
notifySuccess('Profile updated successfully!');
|
notifySuccess('Profile updated successfully!');
|
||||||
|
try {
|
||||||
|
const response = await apiClient.updateUserProfile({
|
||||||
|
full_name: fullName,
|
||||||
|
avatar_url: avatarUrl,
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json();
|
||||||
|
throw new Error(errorData.message || 'Failed to update profile');
|
||||||
|
}
|
||||||
|
const updatedProfile = await response.json();
|
||||||
|
onProfileUpdate(updatedProfile);
|
||||||
|
logger.info('User profile updated successfully.', { userId: user.user_id, fullName, avatarUrl });
|
||||||
|
notifySuccess('Profile updated successfully!');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred.';
|
const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred.';
|
||||||
logger.error('Failed to update user profile.', { userId: user.user_id, error: errorMessage });
|
notifyError(errorMessage);
|
||||||
notifyError(errorMessage);
|
|
||||||
} finally {
|
|
||||||
setProfileLoading(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -128,15 +139,17 @@ export const ProfileManager: React.FC<ProfileManagerProps> = ({ isOpen, onClose,
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await updateUserPassword(password); // This now uses the new apiClient function
|
const response = await apiClient.updateUserPassword(password); // This now uses the new apiClient function
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json();
|
||||||
|
throw new Error(errorData.message || 'Failed to update password');
|
||||||
|
}
|
||||||
logger.info('User password updated successfully.', { userId: user.user_id });
|
logger.info('User password updated successfully.', { userId: user.user_id });
|
||||||
notifySuccess("Password updated successfully!");
|
notifySuccess("Password updated successfully!");
|
||||||
setPassword('');
|
setPassword('');
|
||||||
setConfirmPassword('');
|
setConfirmPassword('');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred.';
|
const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred.'; logger.error('Failed to update user password.', { userId: user.user_id, error: errorMessage }); notifyError(errorMessage);
|
||||||
logger.error('Failed to update user password.', { userId: user.user_id, error: errorMessage });
|
|
||||||
notifyError(errorMessage);
|
|
||||||
} finally {
|
} finally {
|
||||||
setPasswordLoading(false);
|
setPasswordLoading(false);
|
||||||
}
|
}
|
||||||
@@ -151,7 +164,12 @@ export const ProfileManager: React.FC<ProfileManagerProps> = ({ isOpen, onClose,
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
logger.info('User initiated data export.', { userId: user.user_id });
|
logger.info('User initiated data export.', { userId: user.user_id });
|
||||||
const userData = await exportUserData(); // Call the new apiClient function
|
const response = await apiClient.exportUserData(); // Call the new apiClient function
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json();
|
||||||
|
throw new Error(errorData.message || 'Failed to export data');
|
||||||
|
}
|
||||||
|
const userData = await response.json();
|
||||||
const jsonString = `data:text/json;charset=utf-8,${encodeURIComponent(JSON.stringify(userData, null, 2))}`;
|
const jsonString = `data:text/json;charset=utf-8,${encodeURIComponent(JSON.stringify(userData, null, 2))}`;
|
||||||
const link = document.createElement("a");
|
const link = document.createElement("a");
|
||||||
link.href = jsonString;
|
link.href = jsonString;
|
||||||
@@ -178,7 +196,11 @@ export const ProfileManager: React.FC<ProfileManagerProps> = ({ isOpen, onClose,
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
logger.warn('User initiated account deletion.', { userId: user.user_id });
|
logger.warn('User initiated account deletion.', { userId: user.user_id });
|
||||||
await deleteUserAccount(passwordForDelete);
|
const response = await apiClient.deleteUserAccount(passwordForDelete);
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json();
|
||||||
|
throw new Error(errorData.message || 'Failed to delete account');
|
||||||
|
}
|
||||||
logger.warn('User account deleted successfully.', { userId: user.user_id });
|
logger.warn('User account deleted successfully.', { userId: user.user_id });
|
||||||
|
|
||||||
// Set a success message and then sign out after a short delay
|
// Set a success message and then sign out after a short delay
|
||||||
@@ -204,7 +226,12 @@ export const ProfileManager: React.FC<ProfileManagerProps> = ({ isOpen, onClose,
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
// Call the API client function to update preferences
|
// Call the API client function to update preferences
|
||||||
const updatedProfile = await updateUserPreferences({ darkMode: newMode });
|
const response = await apiClient.updateUserPreferences({ darkMode: newMode });
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json();
|
||||||
|
throw new Error(errorData.message || 'Failed to update dark mode');
|
||||||
|
}
|
||||||
|
const updatedProfile = await response.json();
|
||||||
// Notify parent component (App.tsx) to update its profile state
|
// Notify parent component (App.tsx) to update its profile state
|
||||||
onProfileUpdate(updatedProfile);
|
onProfileUpdate(updatedProfile);
|
||||||
logger.info('Dark mode preference updated.', { userId: user.user_id, darkMode: newMode });
|
logger.info('Dark mode preference updated.', { userId: user.user_id, darkMode: newMode });
|
||||||
@@ -222,7 +249,12 @@ export const ProfileManager: React.FC<ProfileManagerProps> = ({ isOpen, onClose,
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
// Call the API client function to update preferences
|
// Call the API client function to update preferences
|
||||||
const updatedProfile = await updateUserPreferences({ unitSystem: newSystem });
|
const response = await apiClient.updateUserPreferences({ unitSystem: newSystem });
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json();
|
||||||
|
throw new Error(errorData.message || 'Failed to update unit system');
|
||||||
|
}
|
||||||
|
const updatedProfile = await response.json();
|
||||||
// Notify parent component (App.tsx) to update its profile state
|
// Notify parent component (App.tsx) to update its profile state
|
||||||
onProfileUpdate(updatedProfile);
|
onProfileUpdate(updatedProfile);
|
||||||
logger.info('Unit system preference updated.', { userId: user.user_id, unitSystem: newSystem });
|
logger.info('Unit system preference updated.', { userId: user.user_id, unitSystem: newSystem });
|
||||||
@@ -241,14 +273,26 @@ export const ProfileManager: React.FC<ProfileManagerProps> = ({ isOpen, onClose,
|
|||||||
// This is crucial for testing the loading state correctly.
|
// This is crucial for testing the loading state correctly.
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
let response;
|
let authResponse;
|
||||||
|
let user, token;
|
||||||
if (isRegistering) {
|
if (isRegistering) {
|
||||||
response = await registerUser(authEmail, authPassword, authFullName, authAvatarUrl);
|
authResponse = await apiClient.registerUser(authEmail, authPassword, authFullName, authAvatarUrl);
|
||||||
logger.info('New user registration successful.', { email: authEmail });
|
logger.info('New user registration successful.', { email: authEmail });
|
||||||
} else {
|
} else {
|
||||||
response = await loginUser(authEmail, authPassword, rememberMe);
|
authResponse = await apiClient.loginUser(authEmail, authPassword, rememberMe);
|
||||||
}
|
}
|
||||||
onLoginSuccess(response.user, response.token, rememberMe);
|
|
||||||
|
if (!authResponse.ok) {
|
||||||
|
const errorData = await authResponse.json();
|
||||||
|
throw new Error(errorData.message || (isRegistering ? 'Registration failed' : 'Login failed'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRegistering) {
|
||||||
|
({ user, token } = await authResponse.json()); // NOW returns a Response object
|
||||||
|
} else {
|
||||||
|
({ user, token } = await authResponse.json()); // loginUser returns a Response object
|
||||||
|
}
|
||||||
|
onLoginSuccess(user!, token!, rememberMe);
|
||||||
onClose(); // Close modal on success
|
onClose(); // Close modal on success
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred.';
|
const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred.';
|
||||||
@@ -263,7 +307,12 @@ export const ProfileManager: React.FC<ProfileManagerProps> = ({ isOpen, onClose,
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setAuthLoading(true);
|
setAuthLoading(true);
|
||||||
try {
|
try {
|
||||||
const { message } = await requestPasswordReset(authEmail);
|
const response = await apiClient.requestPasswordReset(authEmail);
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json();
|
||||||
|
throw new Error(errorData.message || 'Failed to request password reset');
|
||||||
|
}
|
||||||
|
const { message } = await response.json();
|
||||||
notifySuccess(message);
|
notifySuccess(message);
|
||||||
logger.info('Password reset email sent successfully.', { email: authEmail });
|
logger.info('Password reset email sent successfully.', { email: authEmail });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -453,6 +502,8 @@ export const ProfileManager: React.FC<ProfileManagerProps> = ({ isOpen, onClose,
|
|||||||
<div className="pt-2">
|
<div className="pt-2">
|
||||||
<button type="submit" disabled={profileLoading} className="w-full bg-brand-secondary hover:bg-brand-dark disabled:bg-gray-400 text-white font-bold py-2.5 px-4 rounded-lg flex justify-center">
|
<button type="submit" disabled={profileLoading} className="w-full bg-brand-secondary hover:bg-brand-dark disabled:bg-gray-400 text-white font-bold py-2.5 px-4 rounded-lg flex justify-center">
|
||||||
{profileLoading ? <div className="w-5 h-5"><LoadingSpinner /></div> : 'Save Profile'}
|
{profileLoading ? <div className="w-5 h-5"><LoadingSpinner /></div> : 'Save Profile'}
|
||||||
|
<button type="submit" className="w-full bg-brand-secondary hover:bg-brand-dark disabled:bg-gray-400 text-white font-bold py-2.5 px-4 rounded-lg flex justify-center">
|
||||||
|
Save Profile
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -5,16 +5,10 @@ import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
|||||||
import { SystemCheck } from './SystemCheck';
|
import { SystemCheck } from './SystemCheck';
|
||||||
import * as apiClient from '../../../services/apiClient';
|
import * as apiClient from '../../../services/apiClient';
|
||||||
|
|
||||||
// Mock all external dependencies
|
// Get a type-safe mocked version of the apiClient module.
|
||||||
// Correct the relative path to the apiClient module.
|
// The apiClient is now mocked globally via src/tests/setup/unit-setup.ts.
|
||||||
vi.mock('../../../services/apiClient', () => ({
|
// We can cast it to its mocked type to get type safety and autocompletion.
|
||||||
pingBackend: vi.fn(),
|
const mockedApiClient = vi.mocked(apiClient);
|
||||||
checkDbSchema: vi.fn(),
|
|
||||||
checkStorage: vi.fn(),
|
|
||||||
checkDbPoolHealth: vi.fn(),
|
|
||||||
checkPm2Status: vi.fn(),
|
|
||||||
loginUser: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Correct the relative path to the logger module.
|
// Correct the relative path to the logger module.
|
||||||
vi.mock('../../../services/logger', () => ({
|
vi.mock('../../../services/logger', () => ({
|
||||||
@@ -32,13 +26,11 @@ describe('SystemCheck', () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
// Reset API client mocks to resolve successfully by default
|
// Reset API client mocks to resolve successfully by default.
|
||||||
(apiClient.pingBackend as Mock).mockResolvedValue(true);
|
mockedApiClient.checkStorage.mockResolvedValue(new Response(JSON.stringify({ success: true, message: 'Storage OK' })));
|
||||||
(apiClient.checkDbSchema as Mock).mockResolvedValue({ success: true, message: 'Schema OK' });
|
mockedApiClient.checkDbPoolHealth.mockResolvedValue(new Response(JSON.stringify({ success: true, message: 'DB Pool OK' })));
|
||||||
(apiClient.checkStorage as Mock).mockResolvedValue({ success: true, message: 'Storage OK' });
|
mockedApiClient.checkPm2Status.mockResolvedValue(new Response(JSON.stringify({ success: true, message: 'PM2 OK' })));
|
||||||
(apiClient.checkDbPoolHealth as Mock).mockResolvedValue({ success: true, message: 'DB Pool OK' });
|
mockedApiClient.loginUser.mockResolvedValue({ ok: true, json: () => Promise.resolve({}) } as Response); // Mock successful admin login
|
||||||
(apiClient.checkPm2Status as Mock).mockResolvedValue({ success: true, message: 'PM2 OK' });
|
|
||||||
(apiClient.loginUser as Mock).mockResolvedValue({}); // Mock successful admin login
|
|
||||||
|
|
||||||
// Reset VITE_API_KEY for each test
|
// Reset VITE_API_KEY for each test
|
||||||
import.meta.env.VITE_API_KEY = originalViteApiKey;
|
import.meta.env.VITE_API_KEY = originalViteApiKey;
|
||||||
@@ -94,7 +86,7 @@ describe('SystemCheck', () => {
|
|||||||
|
|
||||||
it('should show backend connection as failed if pingBackend fails', async () => {
|
it('should show backend connection as failed if pingBackend fails', async () => {
|
||||||
setViteApiKey('mock-api-key');
|
setViteApiKey('mock-api-key');
|
||||||
(apiClient.pingBackend as Mock).mockRejectedValueOnce(new Error('Network error'));
|
(mockedApiClient.pingBackend as Mock).mockRejectedValueOnce(new Error('Network error'));
|
||||||
render(<SystemCheck />);
|
render(<SystemCheck />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -105,26 +97,25 @@ describe('SystemCheck', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Dependent checks should be skipped/failed
|
// Dependent checks should be skipped/failed
|
||||||
expect(apiClient.checkDbPoolHealth).not.toHaveBeenCalled();
|
expect(mockedApiClient.checkDbPoolHealth).not.toHaveBeenCalled();
|
||||||
expect(apiClient.checkDbSchema).not.toHaveBeenCalled();
|
expect(mockedApiClient.checkDbSchema).not.toHaveBeenCalled();
|
||||||
expect(apiClient.loginUser).not.toHaveBeenCalled();
|
expect(mockedApiClient.loginUser).not.toHaveBeenCalled();
|
||||||
expect(apiClient.checkStorage).not.toHaveBeenCalled();
|
expect(mockedApiClient.checkStorage).not.toHaveBeenCalled();
|
||||||
expect(apiClient.checkPm2Status).not.toHaveBeenCalled();
|
expect(mockedApiClient.checkPm2Status).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should show PM2 status as failed if checkPm2Status returns success: false', async () => {
|
it('should show PM2 status as failed if checkPm2Status returns success: false', async () => {
|
||||||
setViteApiKey('mock-api-key');
|
setViteApiKey('mock-api-key');
|
||||||
(apiClient.checkPm2Status as Mock).mockResolvedValueOnce({ success: false, message: 'PM2 process not found' });
|
mockedApiClient.checkPm2Status.mockResolvedValueOnce(new Response(JSON.stringify({ success: false, message: 'PM2 process not found' })));
|
||||||
render(<SystemCheck />);
|
render(<SystemCheck />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('PM2 process not found')).toBeInTheDocument();
|
expect(screen.getByText('PM2 process not found')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should show database pool check as failed if checkDbPoolHealth fails', async () => {
|
it('should show database pool check as failed if checkDbPoolHealth fails', async () => {
|
||||||
setViteApiKey('mock-api-key');
|
setViteApiKey('mock-api-key');
|
||||||
(apiClient.checkDbPoolHealth as Mock).mockRejectedValueOnce(new Error('DB connection refused'));
|
mockedApiClient.checkDbPoolHealth.mockRejectedValueOnce(new Error('DB connection refused'));
|
||||||
render(<SystemCheck />);
|
render(<SystemCheck />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -134,7 +125,7 @@ describe('SystemCheck', () => {
|
|||||||
|
|
||||||
it('should show database schema check as failed if checkDbSchema fails', async () => {
|
it('should show database schema check as failed if checkDbSchema fails', async () => {
|
||||||
setViteApiKey('mock-api-key');
|
setViteApiKey('mock-api-key');
|
||||||
(apiClient.checkDbSchema as Mock).mockResolvedValueOnce({ success: false, message: 'Schema mismatch' });
|
(mockedApiClient.checkDbSchema as Mock).mockResolvedValueOnce(new Response(JSON.stringify({ success: false, message: 'Schema mismatch' })));
|
||||||
render(<SystemCheck />);
|
render(<SystemCheck />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -144,7 +135,7 @@ describe('SystemCheck', () => {
|
|||||||
|
|
||||||
it('should show seeded user check as failed if loginUser fails', async () => {
|
it('should show seeded user check as failed if loginUser fails', async () => {
|
||||||
setViteApiKey('mock-api-key');
|
setViteApiKey('mock-api-key');
|
||||||
(apiClient.loginUser as Mock).mockRejectedValueOnce(new Error('Incorrect email or password'));
|
mockedApiClient.loginUser.mockRejectedValueOnce(new Error('Incorrect email or password'));
|
||||||
render(<SystemCheck />);
|
render(<SystemCheck />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -154,7 +145,7 @@ describe('SystemCheck', () => {
|
|||||||
|
|
||||||
it('should show storage directory check as failed if checkStorage fails', async () => {
|
it('should show storage directory check as failed if checkStorage fails', async () => {
|
||||||
setViteApiKey('mock-api-key');
|
setViteApiKey('mock-api-key');
|
||||||
(apiClient.checkStorage as Mock).mockRejectedValueOnce(new Error('Storage not writable'));
|
mockedApiClient.checkStorage.mockRejectedValueOnce(new Error('Storage not writable'));
|
||||||
render(<SystemCheck />);
|
render(<SystemCheck />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -165,7 +156,7 @@ describe('SystemCheck', () => {
|
|||||||
it('should display a loading spinner and disable button while checks are running', async () => {
|
it('should display a loading spinner and disable button while checks are running', async () => {
|
||||||
setViteApiKey('mock-api-key');
|
setViteApiKey('mock-api-key');
|
||||||
// Mock pingBackend to never resolve to keep the component in a loading state
|
// Mock pingBackend to never resolve to keep the component in a loading state
|
||||||
(apiClient.pingBackend as Mock).mockImplementation(() => new Promise(() => {}));
|
(mockedApiClient.pingBackend as Mock).mockImplementation(() => new Promise(() => {}));
|
||||||
render(<SystemCheck />);
|
render(<SystemCheck />);
|
||||||
|
|
||||||
const rerunButton = screen.getByRole('button', { name: /running checks\.\.\./i });
|
const rerunButton = screen.getByRole('button', { name: /running checks\.\.\./i });
|
||||||
@@ -187,12 +178,10 @@ describe('SystemCheck', () => {
|
|||||||
await screen.findByText(/finished in/i);
|
await screen.findByText(/finished in/i);
|
||||||
|
|
||||||
// Reset mocks for the re-run
|
// Reset mocks for the re-run
|
||||||
(apiClient.pingBackend as Mock).mockResolvedValueOnce(true);
|
mockedApiClient.checkStorage.mockResolvedValueOnce(new Response(JSON.stringify({ success: true, message: 'Storage OK (re-run)' })));
|
||||||
(apiClient.checkDbSchema as Mock).mockResolvedValueOnce({ success: true, message: 'Schema OK (re-run)' });
|
mockedApiClient.checkDbPoolHealth.mockResolvedValueOnce(new Response(JSON.stringify({ success: true, message: 'DB Pool OK (re-run)' })));
|
||||||
(apiClient.checkStorage as Mock).mockResolvedValueOnce({ success: true, message: 'Storage OK (re-run)' });
|
mockedApiClient.checkPm2Status.mockResolvedValueOnce(new Response(JSON.stringify({ success: true, message: 'PM2 OK (re-run)' })));
|
||||||
(apiClient.checkDbPoolHealth as Mock).mockResolvedValueOnce({ success: true, message: 'DB Pool OK (re-run)' });
|
mockedApiClient.loginUser.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({}) } as Response);
|
||||||
(apiClient.checkPm2Status as Mock).mockResolvedValueOnce({ success: true, message: 'PM2 OK (re-run)' });
|
|
||||||
(apiClient.loginUser as Mock).mockResolvedValueOnce({});
|
|
||||||
|
|
||||||
const rerunButton = screen.getByRole('button', { name: /re-run checks/i });
|
const rerunButton = screen.getByRole('button', { name: /re-run checks/i });
|
||||||
fireEvent.click(rerunButton);
|
fireEvent.click(rerunButton);
|
||||||
@@ -210,13 +199,13 @@ describe('SystemCheck', () => {
|
|||||||
expect(screen.getByText('DB Pool OK (re-run)')).toBeInTheDocument();
|
expect(screen.getByText('DB Pool OK (re-run)')).toBeInTheDocument();
|
||||||
expect(screen.getByText('PM2 OK (re-run)')).toBeInTheDocument();
|
expect(screen.getByText('PM2 OK (re-run)')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
expect(apiClient.pingBackend).toHaveBeenCalledTimes(2); // Initial run + re-run
|
expect(mockedApiClient.pingBackend).toHaveBeenCalledTimes(2); // Initial run + re-run
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should display correct icons for each status', async () => {
|
it('should display correct icons for each status', async () => {
|
||||||
setViteApiKey('mock-api-key');
|
setViteApiKey('mock-api-key');
|
||||||
// Make one check fail for icon verification
|
// Make one check fail for icon verification
|
||||||
(apiClient.checkDbSchema as Mock).mockResolvedValueOnce({ success: false, message: 'Schema mismatch' });
|
(mockedApiClient.checkDbSchema as Mock).mockResolvedValueOnce(new Response(JSON.stringify({ success: false, message: 'Schema mismatch' })));
|
||||||
const { container } = render(<SystemCheck />);
|
const { container } = render(<SystemCheck />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -235,7 +224,7 @@ describe('SystemCheck', () => {
|
|||||||
it('should handle optional checks correctly', async () => {
|
it('should handle optional checks correctly', async () => {
|
||||||
setViteApiKey('mock-api-key');
|
setViteApiKey('mock-api-key');
|
||||||
// Mock an optional check to fail
|
// Mock an optional check to fail
|
||||||
(apiClient.checkPm2Status as Mock).mockResolvedValueOnce({ success: false, message: 'PM2 not running' });
|
mockedApiClient.checkPm2Status.mockResolvedValueOnce(new Response(JSON.stringify({ success: false, message: 'PM2 not running' })));
|
||||||
const { container } = render(<SystemCheck />);
|
const { container } = render(<SystemCheck />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
|
|||||||
@@ -64,10 +64,13 @@ export const SystemCheck: React.FC = () => {
|
|||||||
|
|
||||||
const checkBackendConnection = useCallback(async () => {
|
const checkBackendConnection = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const isReachable = await pingBackend();
|
const response = await pingBackend();
|
||||||
if (isReachable) {
|
if (response.ok) {
|
||||||
|
const text = await response.text();
|
||||||
|
if (text === 'pong') {
|
||||||
updateCheckStatus(CheckID.BACKEND, 'pass', 'Backend server is running and reachable.');
|
updateCheckStatus(CheckID.BACKEND, 'pass', 'Backend server is running and reachable.');
|
||||||
return true;
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
throw new Error("Backend server is not responding. Is it running?");
|
throw new Error("Backend server is not responding. Is it running?");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -78,41 +81,48 @@ export const SystemCheck: React.FC = () => {
|
|||||||
|
|
||||||
const checkPm2Process = useCallback(async () => {
|
const checkPm2Process = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
// This check is only relevant if the backend is reachable.
|
const response = await checkPm2Status();
|
||||||
const { success, message } = await checkPm2Status();
|
if (!response.ok) throw new Error((await response.json()).message || 'Failed to get PM2 status');
|
||||||
|
const { success, message } = await response.json();
|
||||||
updateCheckStatus(CheckID.PM2_STATUS, success ? 'pass' : 'fail', message);
|
updateCheckStatus(CheckID.PM2_STATUS, success ? 'pass' : 'fail', message);
|
||||||
return success;
|
return success;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
updateCheckStatus(CheckID.PM2_STATUS, 'fail', getErrorMessage(e));
|
updateCheckStatus(CheckID.PM2_STATUS, 'fail', getErrorMessage(e));
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}, [updateCheckStatus]);
|
}, [updateCheckStatus]); // Removed checkPm2Status from dependency array as it's an apiClient function
|
||||||
|
|
||||||
const checkDatabaseSchema = useCallback(async () => {
|
const checkDatabaseSchema = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const { success, message } = await checkDbSchema();
|
const response = await checkDbSchema();
|
||||||
|
if (!response.ok) throw new Error((await response.json()).message || 'Failed to check DB schema');
|
||||||
|
const { success, message } = await response.json();
|
||||||
updateCheckStatus(CheckID.SCHEMA, success ? 'pass' : 'fail', message);
|
updateCheckStatus(CheckID.SCHEMA, success ? 'pass' : 'fail', message);
|
||||||
return success;
|
return success;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
updateCheckStatus(CheckID.SCHEMA, 'fail', getErrorMessage(e));
|
updateCheckStatus(CheckID.SCHEMA, 'fail', getErrorMessage(e));
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}, [updateCheckStatus]);
|
}, [updateCheckStatus]); // Removed checkDbSchema from dependency array
|
||||||
|
|
||||||
const checkDatabasePool = useCallback(async () => {
|
const checkDatabasePool = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const { success, message } = await checkDbPoolHealth();
|
const response = await checkDbPoolHealth();
|
||||||
|
if (!response.ok) throw new Error((await response.json()).message || 'Failed to check DB pool health');
|
||||||
|
const { success, message } = await response.json();
|
||||||
updateCheckStatus(CheckID.DB_POOL, success ? 'pass' : 'fail', message);
|
updateCheckStatus(CheckID.DB_POOL, success ? 'pass' : 'fail', message);
|
||||||
return success;
|
return success;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
updateCheckStatus(CheckID.DB_POOL, 'fail', getErrorMessage(e));
|
updateCheckStatus(CheckID.DB_POOL, 'fail', getErrorMessage(e));
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}, [updateCheckStatus]);
|
}, [updateCheckStatus]); // Removed checkDbPoolHealth from dependency array
|
||||||
|
|
||||||
const checkStorageDirectory = useCallback(async () => {
|
const checkStorageDirectory = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const { success, message } = await checkStorage();
|
const response = await checkStorage();
|
||||||
|
if (!response.ok) throw new Error((await response.json()).message || 'Failed to check storage');
|
||||||
|
const { success, message } = await response.json();
|
||||||
updateCheckStatus(CheckID.STORAGE, success ? 'pass' : 'fail', message);
|
updateCheckStatus(CheckID.STORAGE, success ? 'pass' : 'fail', message);
|
||||||
return success;
|
return success;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -122,8 +132,10 @@ export const SystemCheck: React.FC = () => {
|
|||||||
}, [updateCheckStatus]);
|
}, [updateCheckStatus]);
|
||||||
|
|
||||||
const checkSeededUsers = useCallback(async () => {
|
const checkSeededUsers = useCallback(async () => {
|
||||||
|
// The loginUser function returns a Response object, which we need to check for success.
|
||||||
try {
|
try {
|
||||||
await loginUser('admin@example.com', 'password123', false);
|
const response = await loginUser('admin@example.com', 'password123', false);
|
||||||
|
if (!response.ok) throw new Error((await response.json()).message || 'Login failed');
|
||||||
updateCheckStatus(CheckID.SEED, 'pass', 'Default admin user login was successful.');
|
updateCheckStatus(CheckID.SEED, 'pass', 'Default admin user login was successful.');
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
0
src/pages/admin/components/unit-setup.ts
Normal file
0
src/pages/admin/components/unit-setup.ts
Normal file
19
src/services/__mocks__/aiApiClient.ts
Normal file
19
src/services/__mocks__/aiApiClient.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
// d:/gitea/flyer-crawler.projectium.com/flyer-crawler.projectium.com/src/services/__mocks__/aiApiClient.ts
|
||||||
|
/**
|
||||||
|
* Manual mock for the entire aiApiClient module.
|
||||||
|
* Vitest will automatically use this mock when `vi.mock` is called for it.
|
||||||
|
* This provides a single, consistent, and type-safe mock for all unit tests.
|
||||||
|
*/
|
||||||
|
import { vi } from 'vitest';
|
||||||
|
|
||||||
|
export const extractAddressFromImage = vi.fn();
|
||||||
|
export const extractCoreDataFromImage = vi.fn();
|
||||||
|
export const extractLogoFromImage = vi.fn();
|
||||||
|
export const generateImageFromText = vi.fn();
|
||||||
|
export const generateSpeechFromText = vi.fn();
|
||||||
|
export const getDeepDiveAnalysis = vi.fn();
|
||||||
|
export const getQuickInsights = vi.fn();
|
||||||
|
export const isImageAFlyer = vi.fn();
|
||||||
|
export const planTripWithMaps = vi.fn();
|
||||||
|
export const searchWeb = vi.fn();
|
||||||
|
export const startVoiceSession = vi.fn();
|
||||||
50
src/services/__mocks__/apiClient.ts
Normal file
50
src/services/__mocks__/apiClient.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
// d:/gitea/flyer-crawler.projectium.com/flyer-crawler.projectium.com/src/services/__mocks__/apiClient.ts
|
||||||
|
/**
|
||||||
|
* Manual mock for the entire apiClient module.
|
||||||
|
* Vitest will automatically use this mock when `vi.mock('../../services/apiClient')` is called.
|
||||||
|
* This provides a single, consistent, and type-safe mock for all unit tests.
|
||||||
|
*/
|
||||||
|
import { vi } from 'vitest';
|
||||||
|
|
||||||
|
export const addShoppingListItem = vi.fn();
|
||||||
|
export const addWatchedItem = vi.fn();
|
||||||
|
export const approveCorrection = vi.fn();
|
||||||
|
export const checkDbPoolHealth = vi.fn();
|
||||||
|
export const checkDbSchema = vi.fn();
|
||||||
|
export const checkPm2Status = vi.fn();
|
||||||
|
export const checkStorage = vi.fn();
|
||||||
|
export const countFlyerItemsForFlyers = vi.fn();
|
||||||
|
export const createShoppingList = vi.fn();
|
||||||
|
export const deleteShoppingList = vi.fn();
|
||||||
|
export const deleteUserAccount = vi.fn();
|
||||||
|
export const exportUserData = vi.fn();
|
||||||
|
export const fetchActivityLog = vi.fn();
|
||||||
|
export const fetchAllBrands = vi.fn();
|
||||||
|
export const fetchCategories = vi.fn();
|
||||||
|
export const fetchFlyerItems = vi.fn();
|
||||||
|
export const fetchFlyerItemsForFlyers = vi.fn();
|
||||||
|
export const fetchFlyers = vi.fn();
|
||||||
|
export const fetchHistoricalPriceData = vi.fn();
|
||||||
|
export const getHistoricalPriceData = vi.fn();
|
||||||
|
export const fetchMasterItems = vi.fn();
|
||||||
|
export const fetchShoppingLists = vi.fn();
|
||||||
|
export const fetchWatchedItems = vi.fn();
|
||||||
|
export const getApplicationStats = vi.fn();
|
||||||
|
export const getAuthenticatedUserProfile = vi.fn();
|
||||||
|
export const getSuggestedCorrections = vi.fn();
|
||||||
|
export const loginUser = vi.fn();
|
||||||
|
export const pingBackend = vi.fn();
|
||||||
|
export const processFlyerFile = vi.fn();
|
||||||
|
export const registerUser = vi.fn();
|
||||||
|
export const rejectCorrection = vi.fn();
|
||||||
|
export const removeShoppingListItem = vi.fn();
|
||||||
|
export const removeWatchedItem = vi.fn();
|
||||||
|
export const requestPasswordReset = vi.fn();
|
||||||
|
export const resetPassword = vi.fn();
|
||||||
|
export const updateShoppingListItem = vi.fn();
|
||||||
|
export const updateSuggestedCorrection = vi.fn();
|
||||||
|
export const updateUserPassword = vi.fn();
|
||||||
|
export const updateUserPreferences = vi.fn();
|
||||||
|
export const updateUserProfile = vi.fn();
|
||||||
|
export const uploadBrandLogo = vi.fn();
|
||||||
|
export const uploadLogoAndUpdateStore = vi.fn();
|
||||||
13
src/services/__mocks__/logger.ts
Normal file
13
src/services/__mocks__/logger.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
// d:/gitea/flyer-crawler.projectium.com/flyer-crawler.projectium.com/src/services/__mocks__/logger.ts
|
||||||
|
/**
|
||||||
|
* Manual mock for the logger service.
|
||||||
|
* This prevents actual logging during unit tests, keeping the test output clean.
|
||||||
|
*/
|
||||||
|
import { vi } from 'vitest';
|
||||||
|
|
||||||
|
export const logger = {
|
||||||
|
info: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
debug: vi.fn(),
|
||||||
|
};
|
||||||
13
src/services/__mocks__/react-hot-toast.ts
Normal file
13
src/services/__mocks__/react-hot-toast.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
// d:/gitea/flyer-crawler.projectium.com/flyer-crawler.projectium.com/src/__mocks__/react-hot-toast.ts
|
||||||
|
/**
|
||||||
|
* Manual mock for the react-hot-toast library.
|
||||||
|
* This prevents actual toasts from rendering during unit tests and allows spying on calls.
|
||||||
|
*/
|
||||||
|
import { vi } from 'vitest';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
loading: vi.fn(),
|
||||||
|
success: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
dismiss: vi.fn(),
|
||||||
|
};
|
||||||
@@ -9,30 +9,26 @@ import type { FlyerItem, MasterGroceryItem, Store, ExtractedCoreData, ExtractedL
|
|||||||
import { logger } from "./logger";
|
import { logger } from "./logger";
|
||||||
import { apiFetchWithAuth } from './apiClient';
|
import { apiFetchWithAuth } from './apiClient';
|
||||||
|
|
||||||
export const isImageAFlyer = async (imageFile: File, tokenOverride?: string): Promise<boolean> => {
|
export const isImageAFlyer = async (imageFile: File, tokenOverride?: string): Promise<Response> => {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('image', imageFile);
|
formData.append('image', imageFile);
|
||||||
|
|
||||||
// Use apiFetchWithAuth for FormData to let the browser set the correct Content-Type.
|
// Use apiFetchWithAuth for FormData to let the browser set the correct Content-Type.
|
||||||
// The URL must be relative, as the helper constructs the full path.
|
// The URL must be relative, as the helper constructs the full path.
|
||||||
const response = await apiFetchWithAuth('/ai/check-flyer', {
|
return apiFetchWithAuth('/ai/check-flyer', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData,
|
body: formData,
|
||||||
}, tokenOverride);
|
}, tokenOverride);
|
||||||
const result = await response.json();
|
|
||||||
return result.is_flyer;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const extractAddressFromImage = async (imageFile: File, tokenOverride?: string): Promise<string | null> => {
|
export const extractAddressFromImage = async (imageFile: File, tokenOverride?: string): Promise<Response> => {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('image', imageFile);
|
formData.append('image', imageFile);
|
||||||
|
|
||||||
const response = await apiFetchWithAuth('/ai/extract-address', {
|
return apiFetchWithAuth('/ai/extract-address', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData,
|
body: formData,
|
||||||
}, tokenOverride);
|
}, tokenOverride);
|
||||||
const result = await response.json();
|
|
||||||
return result.address;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const extractCoreDataFromImage = async (imageFiles: File[], masterItems: MasterGroceryItem[]): Promise<ExtractedCoreData | null> => {
|
export const extractCoreDataFromImage = async (imageFiles: File[], masterItems: MasterGroceryItem[]): Promise<ExtractedCoreData | null> => {
|
||||||
@@ -49,7 +45,7 @@ export const extractCoreDataFromImage = async (imageFiles: File[], masterItems:
|
|||||||
// --- END DEBUG LOGGING ---
|
// --- END DEBUG LOGGING ---
|
||||||
|
|
||||||
// This now calls the real backend endpoint.
|
// This now calls the real backend endpoint.
|
||||||
const response = await apiFetchWithAuth('/ai/process-flyer', {
|
const response = await apiFetchWithAuth('/ai/process-flyer', { // This one is different, it's not returning a Response but parsed data. Let's keep it for now as it has more logic.
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
@@ -68,46 +64,40 @@ export const extractCoreDataFromImage = async (imageFiles: File[], masterItems:
|
|||||||
return responseData.data as ExtractedCoreData;
|
return responseData.data as ExtractedCoreData;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const extractLogoFromImage = async (imageFiles: File[], tokenOverride?: string): Promise<ExtractedLogoData> => {
|
export const extractLogoFromImage = async (imageFiles: File[], tokenOverride?: string): Promise<Response> => {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
imageFiles.forEach(file => {
|
imageFiles.forEach(file => {
|
||||||
formData.append('images', file);
|
formData.append('images', file);
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await apiFetchWithAuth('/ai/extract-logo', {
|
return apiFetchWithAuth('/ai/extract-logo', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData,
|
body: formData,
|
||||||
}, tokenOverride);
|
}, tokenOverride);
|
||||||
return response.json();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getQuickInsights = async (items: FlyerItem[], tokenOverride?: string): Promise<string> => {
|
export const getQuickInsights = async (items: FlyerItem[], tokenOverride?: string): Promise<Response> => {
|
||||||
const response = await apiFetchWithAuth('/ai/quick-insights', {
|
return apiFetchWithAuth('/ai/quick-insights', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ items }),
|
body: JSON.stringify({ items }),
|
||||||
}, tokenOverride);
|
}, tokenOverride);
|
||||||
const result = await response.json();
|
|
||||||
return result.text;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getDeepDiveAnalysis = async (items: FlyerItem[], tokenOverride?: string): Promise<string> => {
|
export const getDeepDiveAnalysis = async (items: FlyerItem[], tokenOverride?: string): Promise<Response> => {
|
||||||
const response = await apiFetchWithAuth('/ai/deep-dive', {
|
return apiFetchWithAuth('/ai/deep-dive', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ items }),
|
body: JSON.stringify({ items }),
|
||||||
}, tokenOverride);
|
}, tokenOverride);
|
||||||
const result = await response.json();
|
|
||||||
return result.text;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const searchWeb = async (items: FlyerItem[], tokenOverride?: string): Promise<{text: string; sources: GroundingChunk[]}> => {
|
export const searchWeb = async (items: FlyerItem[], tokenOverride?: string): Promise<Response> => {
|
||||||
const response = await apiFetchWithAuth('/ai/search-web', {
|
return apiFetchWithAuth('/ai/search-web', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ items }),
|
body: JSON.stringify({ items }),
|
||||||
}, tokenOverride);
|
}, tokenOverride);
|
||||||
return response.json();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -121,14 +111,13 @@ export const searchWeb = async (items: FlyerItem[], tokenOverride?: string): Pro
|
|||||||
* @param userLocation The user's current geographic coordinates.
|
* @param userLocation The user's current geographic coordinates.
|
||||||
* @returns A text response with trip planning advice and a list of map sources.
|
* @returns A text response with trip planning advice and a list of map sources.
|
||||||
*/
|
*/
|
||||||
export const planTripWithMaps = async (items: FlyerItem[], store: Store | undefined, userLocation: GeolocationCoordinates, tokenOverride?: string): Promise<{text: string; sources: { uri: string; title: string; }[]}> => {
|
export const planTripWithMaps = async (items: FlyerItem[], store: Store | undefined, userLocation: GeolocationCoordinates, tokenOverride?: string): Promise<Response> => {
|
||||||
logger.debug("Stub: planTripWithMaps called with location:", { userLocation });
|
logger.debug("Stub: planTripWithMaps called with location:", { userLocation });
|
||||||
const response = await apiFetchWithAuth('/ai/plan-trip', {
|
return apiFetchWithAuth('/ai/plan-trip', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ items, store, userLocation }),
|
body: JSON.stringify({ items, store, userLocation }),
|
||||||
}, tokenOverride);
|
}, tokenOverride);
|
||||||
return response.json();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -136,15 +125,13 @@ export const planTripWithMaps = async (items: FlyerItem[], store: Store | undefi
|
|||||||
* @param prompt A description of the image to generate (e.g., a meal plan).
|
* @param prompt A description of the image to generate (e.g., a meal plan).
|
||||||
* @returns A base64-encoded string of the generated PNG image.
|
* @returns A base64-encoded string of the generated PNG image.
|
||||||
*/
|
*/
|
||||||
export const generateImageFromText = async (prompt: string, tokenOverride?: string): Promise<string> => {
|
export const generateImageFromText = async (prompt: string, tokenOverride?: string): Promise<Response> => {
|
||||||
logger.debug("Stub: generateImageFromText called with prompt:", { prompt });
|
logger.debug("Stub: generateImageFromText called with prompt:", { prompt });
|
||||||
const response = await apiFetchWithAuth('/ai/generate-image', {
|
return apiFetchWithAuth('/ai/generate-image', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ prompt }),
|
body: JSON.stringify({ prompt }),
|
||||||
}, tokenOverride);
|
}, tokenOverride);
|
||||||
const result = await response.json();
|
|
||||||
return result.base64Image;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -152,15 +139,13 @@ export const generateImageFromText = async (prompt: string, tokenOverride?: stri
|
|||||||
* @param text The text to be spoken.
|
* @param text The text to be spoken.
|
||||||
* @returns A base64-encoded string of the raw audio data.
|
* @returns A base64-encoded string of the raw audio data.
|
||||||
*/
|
*/
|
||||||
export const generateSpeechFromText = async (text: string, tokenOverride?: string): Promise<string> => {
|
export const generateSpeechFromText = async (text: string, tokenOverride?: string): Promise<Response> => {
|
||||||
logger.debug("Stub: generateSpeechFromText called with text:", { text });
|
logger.debug("Stub: generateSpeechFromText called with text:", { text });
|
||||||
const response = await apiFetchWithAuth('/ai/generate-speech', {
|
return apiFetchWithAuth('/ai/generate-speech', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ text }),
|
body: JSON.stringify({ text }),
|
||||||
}, tokenOverride);
|
}, tokenOverride);
|
||||||
const result = await response.json();
|
|
||||||
return result.base64Audio;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -19,27 +19,36 @@ describe('Admin API Routes Integration Tests', () => {
|
|||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
// Log in as the pre-seeded admin user
|
// Log in as the pre-seeded admin user
|
||||||
const adminLoginResponse = await apiClient.loginUser('admin@example.com', 'adminpass', false);
|
const adminLoginResponse = await apiClient.loginUser('admin@example.com', 'adminpass', false);
|
||||||
adminUser = adminLoginResponse.user;
|
const adminLoginData = await adminLoginResponse.json();
|
||||||
adminToken = adminLoginResponse.token;
|
adminUser = adminLoginData.user;
|
||||||
|
adminToken = adminLoginData.token;
|
||||||
|
|
||||||
// Create and log in as a new regular user for permission testing
|
// Create and log in as a new regular user for permission testing
|
||||||
const regularUserEmail = `regular-user-${Date.now()}@example.com`;
|
const regularUserEmail = `regular-user-${Date.now()}@example.com`;
|
||||||
await apiClient.registerUser(regularUserEmail, TEST_PASSWORD, 'Regular User');
|
const registerResponse = await apiClient.registerUser(regularUserEmail, TEST_PASSWORD, 'Regular User');
|
||||||
|
if (!registerResponse.ok) {
|
||||||
|
const errorData = await registerResponse.json();
|
||||||
|
throw new Error(errorData.message || 'Test registration failed');
|
||||||
|
}
|
||||||
const regularUserLoginResponse = await apiClient.loginUser(regularUserEmail, TEST_PASSWORD, false);
|
const regularUserLoginResponse = await apiClient.loginUser(regularUserEmail, TEST_PASSWORD, false);
|
||||||
regularUser = regularUserLoginResponse.user;
|
const regularLoginData = await regularUserLoginResponse.json();
|
||||||
regularUserToken = regularUserLoginResponse.token;
|
regularUser = regularLoginData.user;
|
||||||
|
regularUserToken = regularLoginData.token;
|
||||||
|
|
||||||
// Cleanup the created user after all tests in this file are done
|
// Cleanup the created user after all tests in this file are done
|
||||||
return async () => {
|
return async () => {
|
||||||
if (regularUser) {
|
if (regularUser) {
|
||||||
await getPool().query('DELETE FROM public.users WHERE user_id = $1 CASCADE', [regularUser.user_id]);
|
// First, delete dependent records, then delete the user.
|
||||||
|
await getPool().query('DELETE FROM public.suggested_corrections WHERE user_id = $1', [regularUser.user_id]);
|
||||||
|
await getPool().query('DELETE FROM public.users WHERE user_id = $1', [regularUser.user_id]);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('GET /api/admin/stats', () => {
|
describe('GET /api/admin/stats', () => {
|
||||||
it('should allow an admin to fetch application stats', async () => {
|
it('should allow an admin to fetch application stats', async () => {
|
||||||
const stats = await apiClient.getApplicationStats(adminToken);
|
const response = await apiClient.getApplicationStats(adminToken);
|
||||||
|
const stats = await response.json();
|
||||||
expect(stats).toBeDefined();
|
expect(stats).toBeDefined();
|
||||||
expect(stats).toHaveProperty('flyerCount');
|
expect(stats).toHaveProperty('flyerCount');
|
||||||
expect(stats).toHaveProperty('userCount');
|
expect(stats).toHaveProperty('userCount');
|
||||||
@@ -47,15 +56,18 @@ describe('Admin API Routes Integration Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should forbid a regular user from fetching application stats', async () => {
|
it('should forbid a regular user from fetching application stats', async () => {
|
||||||
await expect(apiClient.getApplicationStats(regularUserToken)).rejects.toThrow(
|
const response = await apiClient.getApplicationStats(regularUserToken);
|
||||||
'Forbidden: Administrator access required.'
|
expect(response.ok).toBe(false);
|
||||||
);
|
expect(response.status).toBe(403);
|
||||||
|
const errorData = await response.json();
|
||||||
|
expect(errorData.message).toBe('Forbidden: Administrator access required.');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('GET /api/admin/stats/daily', () => {
|
describe('GET /api/admin/stats/daily', () => {
|
||||||
it('should allow an admin to fetch daily stats', async () => {
|
it('should allow an admin to fetch daily stats', async () => {
|
||||||
const dailyStats = await apiClient.getDailyStats(adminToken);
|
const response = await apiClient.getDailyStats(adminToken);
|
||||||
|
const dailyStats = await response.json();
|
||||||
expect(dailyStats).toBeDefined();
|
expect(dailyStats).toBeDefined();
|
||||||
expect(Array.isArray(dailyStats)).toBe(true);
|
expect(Array.isArray(dailyStats)).toBe(true);
|
||||||
// The seed script creates users, so we should have some data
|
// The seed script creates users, so we should have some data
|
||||||
@@ -66,9 +78,11 @@ describe('Admin API Routes Integration Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should forbid a regular user from fetching daily stats', async () => {
|
it('should forbid a regular user from fetching daily stats', async () => {
|
||||||
await expect(apiClient.getDailyStats(regularUserToken)).rejects.toThrow(
|
const response = await apiClient.getDailyStats(regularUserToken);
|
||||||
'Forbidden: Administrator access required.'
|
expect(response.ok).toBe(false);
|
||||||
);
|
expect(response.status).toBe(403);
|
||||||
|
const errorData = await response.json();
|
||||||
|
expect(errorData.message).toBe('Forbidden: Administrator access required.');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -76,21 +90,25 @@ describe('Admin API Routes Integration Tests', () => {
|
|||||||
it('should allow an admin to fetch suggested corrections', async () => {
|
it('should allow an admin to fetch suggested corrections', async () => {
|
||||||
// This test just verifies access and correct response shape.
|
// This test just verifies access and correct response shape.
|
||||||
// More detailed tests would require seeding corrections.
|
// More detailed tests would require seeding corrections.
|
||||||
const corrections = await apiClient.getSuggestedCorrections(adminToken);
|
const response = await apiClient.getSuggestedCorrections(adminToken);
|
||||||
|
const corrections = await response.json();
|
||||||
expect(corrections).toBeDefined();
|
expect(corrections).toBeDefined();
|
||||||
expect(Array.isArray(corrections)).toBe(true);
|
expect(Array.isArray(corrections)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should forbid a regular user from fetching suggested corrections', async () => {
|
it('should forbid a regular user from fetching suggested corrections', async () => {
|
||||||
await expect(apiClient.getSuggestedCorrections(regularUserToken)).rejects.toThrow(
|
const response = await apiClient.getSuggestedCorrections(regularUserToken);
|
||||||
'Forbidden: Administrator access required.'
|
expect(response.ok).toBe(false);
|
||||||
);
|
expect(response.status).toBe(403);
|
||||||
|
const errorData = await response.json();
|
||||||
|
expect(errorData.message).toBe('Forbidden: Administrator access required.');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('GET /api/admin/brands', () => {
|
describe('GET /api/admin/brands', () => {
|
||||||
it('should allow an admin to fetch all brands', async () => {
|
it('should allow an admin to fetch all brands', async () => {
|
||||||
const brands = await apiClient.fetchAllBrands(adminToken);
|
const response = await apiClient.fetchAllBrands(adminToken);
|
||||||
|
const brands = await response.json();
|
||||||
expect(brands).toBeDefined();
|
expect(brands).toBeDefined();
|
||||||
expect(Array.isArray(brands)).toBe(true);
|
expect(Array.isArray(brands)).toBe(true);
|
||||||
// The seed script creates brands
|
// The seed script creates brands
|
||||||
@@ -100,9 +118,11 @@ describe('Admin API Routes Integration Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should forbid a regular user from fetching all brands', async () => {
|
it('should forbid a regular user from fetching all brands', async () => {
|
||||||
await expect(apiClient.fetchAllBrands(regularUserToken)).rejects.toThrow(
|
const response = await apiClient.fetchAllBrands(regularUserToken);
|
||||||
'Forbidden: Administrator access required.'
|
expect(response.ok).toBe(false);
|
||||||
);
|
expect(response.status).toBe(403);
|
||||||
|
const errorData = await response.json();
|
||||||
|
expect(errorData.message).toBe('Forbidden: Administrator access required.');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -129,7 +149,8 @@ describe('Admin API Routes Integration Tests', () => {
|
|||||||
|
|
||||||
it('should allow an admin to approve a correction', async () => {
|
it('should allow an admin to approve a correction', async () => {
|
||||||
// Act: Approve the correction.
|
// Act: Approve the correction.
|
||||||
await apiClient.approveCorrection(testCorrectionId, adminToken);
|
const response = await apiClient.approveCorrection(testCorrectionId, adminToken);
|
||||||
|
expect(response.ok).toBe(true);
|
||||||
|
|
||||||
// Assert: Verify the flyer item's price was updated and the correction status changed.
|
// Assert: Verify the flyer item's price was updated and the correction status changed.
|
||||||
const { rows: itemRows } = await getPool().query('SELECT price_in_cents FROM public.flyer_items WHERE flyer_item_id = $1', [testFlyerItemId]);
|
const { rows: itemRows } = await getPool().query('SELECT price_in_cents FROM public.flyer_items WHERE flyer_item_id = $1', [testFlyerItemId]);
|
||||||
@@ -141,7 +162,8 @@ describe('Admin API Routes Integration Tests', () => {
|
|||||||
|
|
||||||
it('should allow an admin to reject a correction', async () => {
|
it('should allow an admin to reject a correction', async () => {
|
||||||
// Act: Reject the correction.
|
// Act: Reject the correction.
|
||||||
await apiClient.rejectCorrection(testCorrectionId, adminToken);
|
const response = await apiClient.rejectCorrection(testCorrectionId, adminToken);
|
||||||
|
expect(response.ok).toBe(true);
|
||||||
|
|
||||||
// Assert: Verify the correction status changed.
|
// Assert: Verify the correction status changed.
|
||||||
const { rows: correctionRows } = await getPool().query('SELECT status FROM public.suggested_corrections WHERE suggested_correction_id = $1', [testCorrectionId]);
|
const { rows: correctionRows } = await getPool().query('SELECT status FROM public.suggested_corrections WHERE suggested_correction_id = $1', [testCorrectionId]);
|
||||||
@@ -150,7 +172,8 @@ describe('Admin API Routes Integration Tests', () => {
|
|||||||
|
|
||||||
it('should allow an admin to update a correction', async () => {
|
it('should allow an admin to update a correction', async () => {
|
||||||
// Act: Update the suggested value of the correction.
|
// Act: Update the suggested value of the correction.
|
||||||
const updatedCorrection = await apiClient.updateSuggestedCorrection(testCorrectionId, '300', adminToken);
|
const response = await apiClient.updateSuggestedCorrection(testCorrectionId, '300', adminToken);
|
||||||
|
const updatedCorrection = await response.json();
|
||||||
|
|
||||||
// Assert: Verify the API response and the database state.
|
// Assert: Verify the API response and the database state.
|
||||||
expect(updatedCorrection.suggested_value).toBe('300');
|
expect(updatedCorrection.suggested_value).toBe('300');
|
||||||
@@ -167,7 +190,8 @@ describe('Admin API Routes Integration Tests', () => {
|
|||||||
const recipeId = recipeRows[0].recipe_id;
|
const recipeId = recipeRows[0].recipe_id;
|
||||||
|
|
||||||
// Act: Update the status to 'public'.
|
// Act: Update the status to 'public'.
|
||||||
await apiClient.updateRecipeStatus(recipeId, 'public', adminToken);
|
const response = await apiClient.updateRecipeStatus(recipeId, 'public', adminToken);
|
||||||
|
expect(response.ok).toBe(true);
|
||||||
|
|
||||||
// Assert: Verify the status was updated in the database.
|
// Assert: Verify the status was updated in the database.
|
||||||
const { rows: updatedRecipeRows } = await getPool().query('SELECT status FROM public.recipes WHERE recipe_id = $1', [recipeId]);
|
const { rows: updatedRecipeRows } = await getPool().query('SELECT status FROM public.recipes WHERE recipe_id = $1', [recipeId]);
|
||||||
|
|||||||
@@ -30,41 +30,48 @@ describe('AI API Routes Integration Tests', () => {
|
|||||||
const email = `ai-test-user-${Date.now()}@example.com`;
|
const email = `ai-test-user-${Date.now()}@example.com`;
|
||||||
await apiClient.registerUser(email, TEST_PASSWORD, 'AI Tester');
|
await apiClient.registerUser(email, TEST_PASSWORD, 'AI Tester');
|
||||||
const loginResponse = await apiClient.loginUser(email, TEST_PASSWORD, false);
|
const loginResponse = await apiClient.loginUser(email, TEST_PASSWORD, false);
|
||||||
testUser = loginResponse.user;
|
const loginData = await loginResponse.json();
|
||||||
authToken = loginResponse.token;
|
testUser = loginData.user;
|
||||||
|
authToken = loginData.token;
|
||||||
});
|
});
|
||||||
|
|
||||||
it('POST /api/ai/check-flyer should return a boolean', async () => {
|
it('POST /api/ai/check-flyer should return a boolean', async () => {
|
||||||
const mockImageFile = new File(['content'], 'test.jpg', { type: 'image/jpeg' });
|
const mockImageFile = new File(['content'], 'test.jpg', { type: 'image/jpeg' });
|
||||||
const result = await aiApiClient.isImageAFlyer(mockImageFile, authToken);
|
const response = await aiApiClient.isImageAFlyer(mockImageFile, authToken);
|
||||||
|
const result = await response.json();
|
||||||
// The backend is stubbed to always return true for this check
|
// The backend is stubbed to always return true for this check
|
||||||
expect(result).toBe(true);
|
expect(result.is_flyer).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('POST /api/ai/extract-address should return a stubbed address', async () => {
|
it('POST /api/ai/extract-address should return a stubbed address', async () => {
|
||||||
const mockImageFile = new File(['content'], 'test.jpg', { type: 'image/jpeg' });
|
const mockImageFile = new File(['content'], 'test.jpg', { type: 'image/jpeg' });
|
||||||
const result = await aiApiClient.extractAddressFromImage(mockImageFile, authToken);
|
const response = await aiApiClient.extractAddressFromImage(mockImageFile, authToken);
|
||||||
expect(result).toBe("123 AI Street, Server City");
|
const result = await response.json();
|
||||||
|
expect(result.address).toBe("123 AI Street, Server City");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('POST /api/ai/extract-logo should return a stubbed response', async () => {
|
it('POST /api/ai/extract-logo should return a stubbed response', async () => {
|
||||||
const mockImageFile = new File(['content'], 'test.jpg', { type: 'image/jpeg' });
|
const mockImageFile = new File(['content'], 'test.jpg', { type: 'image/jpeg' });
|
||||||
const result = await aiApiClient.extractLogoFromImage([mockImageFile], authToken);
|
const response = await aiApiClient.extractLogoFromImage([mockImageFile], authToken);
|
||||||
|
const result = await response.json();
|
||||||
expect(result).toEqual({ store_logo_base_64: null });
|
expect(result).toEqual({ store_logo_base_64: null });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('POST /api/ai/quick-insights should return a stubbed insight', async () => {
|
it('POST /api/ai/quick-insights should return a stubbed insight', async () => {
|
||||||
const result = await aiApiClient.getQuickInsights([], authToken);
|
const response = await aiApiClient.getQuickInsights([], authToken);
|
||||||
expect(result).toBe("This is a server-generated quick insight: buy the cheap stuff!");
|
const result = await response.json();
|
||||||
|
expect(result.text).toBe("This is a server-generated quick insight: buy the cheap stuff!");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('POST /api/ai/deep-dive should return a stubbed analysis', async () => {
|
it('POST /api/ai/deep-dive should return a stubbed analysis', async () => {
|
||||||
const result = await aiApiClient.getDeepDiveAnalysis([], authToken);
|
const response = await aiApiClient.getDeepDiveAnalysis([], authToken);
|
||||||
expect(result).toBe("This is a server-generated deep dive analysis. It is very detailed.");
|
const result = await response.json();
|
||||||
|
expect(result.text).toBe("This is a server-generated deep dive analysis. It is very detailed.");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('POST /api/ai/search-web should return a stubbed search result', async () => {
|
it('POST /api/ai/search-web should return a stubbed search result', async () => {
|
||||||
const result = await aiApiClient.searchWeb([], authToken);
|
const response = await aiApiClient.searchWeb([], authToken);
|
||||||
|
const result = await response.json();
|
||||||
expect(result).toEqual({ text: "The web says this is good.", sources: [] });
|
expect(result).toEqual({ text: "The web says this is good.", sources: [] });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -81,7 +88,8 @@ describe('AI API Routes Integration Tests', () => {
|
|||||||
speed: null,
|
speed: null,
|
||||||
toJSON: () => ({}),
|
toJSON: () => ({}),
|
||||||
};
|
};
|
||||||
const result = await aiApiClient.planTripWithMaps([], undefined, mockLocation, authToken);
|
const response = await aiApiClient.planTripWithMaps([], undefined, mockLocation, authToken);
|
||||||
|
const result = await response.json();
|
||||||
expect(result).toBeDefined();
|
expect(result).toBeDefined();
|
||||||
expect(result.text).toContain('grocery stores');
|
expect(result.text).toContain('grocery stores');
|
||||||
});
|
});
|
||||||
@@ -89,11 +97,15 @@ describe('AI API Routes Integration Tests', () => {
|
|||||||
it('POST /api/ai/generate-image should reject because it is not implemented', async () => {
|
it('POST /api/ai/generate-image should reject because it is not implemented', async () => {
|
||||||
// The backend for this is not stubbed and will throw an error.
|
// The backend for this is not stubbed and will throw an error.
|
||||||
// This test confirms that the endpoint is protected and responds as expected to a failure.
|
// This test confirms that the endpoint is protected and responds as expected to a failure.
|
||||||
await expect(aiApiClient.generateImageFromText("a test prompt", authToken)).rejects.toThrow();
|
const response = await aiApiClient.generateImageFromText("a test prompt", authToken);
|
||||||
|
expect(response.ok).toBe(false);
|
||||||
|
expect(response.status).toBe(501);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('POST /api/ai/generate-speech should reject because it is not implemented', async () => {
|
it('POST /api/ai/generate-speech should reject because it is not implemented', async () => {
|
||||||
// The backend for this is not stubbed and will throw an error.
|
// The backend for this is not stubbed and will throw an error.
|
||||||
await expect(aiApiClient.generateSpeechFromText("a test prompt", authToken)).rejects.toThrow();
|
const response = await aiApiClient.generateSpeechFromText("a test prompt", authToken);
|
||||||
|
expect(response.ok).toBe(false);
|
||||||
|
expect(response.status).toBe(501);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -17,23 +17,9 @@ describe('Authentication API Integration', () => {
|
|||||||
let refreshTokenCookie: string | undefined;
|
let refreshTokenCookie: string | undefined;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
// For this test setup, we need the raw response to get the cookie,
|
// Log in once to get a valid refresh token cookie for the refresh test.
|
||||||
// so we perform the fetch call directly instead of using the apiClient's loginUser function.
|
// The loginUser function now returns the full Response object.
|
||||||
const apiUrl = process.env.VITE_API_BASE_URL || 'http://localhost:3001/api';
|
const response = await loginUser('admin@example.com', 'adminpass', true);
|
||||||
const response = await fetch(`${apiUrl}/auth/login`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
email: 'admin@example.com',
|
|
||||||
password: 'adminpass',
|
|
||||||
rememberMe: true,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Failed to log in during test setup.');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract the 'set-cookie' header in a type-safe way.
|
// Extract the 'set-cookie' header in a type-safe way.
|
||||||
const setCookieHeader = response.headers.get('set-cookie');
|
const setCookieHeader = response.headers.get('set-cookie');
|
||||||
refreshTokenCookie = setCookieHeader?.split(';')[0];
|
refreshTokenCookie = setCookieHeader?.split(';')[0];
|
||||||
@@ -66,13 +52,14 @@ describe('Authentication API Integration', () => {
|
|||||||
|
|
||||||
// The `rememberMe` parameter is required. For a test, `false` is a safe default.
|
// The `rememberMe` parameter is required. For a test, `false` is a safe default.
|
||||||
const response = await loginUser(adminEmail, adminPassword, false);
|
const response = await loginUser(adminEmail, adminPassword, false);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
// Assert that the API returns the expected structure
|
// Assert that the API returns the expected structure
|
||||||
expect(response).toBeDefined();
|
expect(data).toBeDefined();
|
||||||
expect(response.user).toBeDefined();
|
expect(data.user).toBeDefined();
|
||||||
expect(response.user.email).toBe(adminEmail);
|
expect(data.user.email).toBe(adminEmail);
|
||||||
expect(response.user.user_id).toBeTypeOf('string');
|
expect(data.user.user_id).toBeTypeOf('string');
|
||||||
expect(response.token).toBeTypeOf('string');
|
expect(data.token).toBeTypeOf('string');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should fail to log in with an incorrect password', async () => {
|
it('should fail to log in with an incorrect password', async () => {
|
||||||
|
|||||||
@@ -18,8 +18,13 @@ const TEST_PASSWORD = 'a-much-stronger-password-for-testing-!@#$';
|
|||||||
* Helper to create and log in a user for authenticated tests.
|
* Helper to create and log in a user for authenticated tests.
|
||||||
*/
|
*/
|
||||||
const createAndLoginUser = async (email: string) => {
|
const createAndLoginUser = async (email: string) => {
|
||||||
await apiClient.registerUser(email, TEST_PASSWORD, 'Flyer Uploader');
|
const registerResponse = await apiClient.registerUser(email, TEST_PASSWORD, 'Flyer Uploader');
|
||||||
const { user, token } = await apiClient.loginUser(email, TEST_PASSWORD, false);
|
if (!registerResponse.ok) {
|
||||||
|
const errorData = await registerResponse.json();
|
||||||
|
throw new Error(errorData.message || 'Test registration failed');
|
||||||
|
}
|
||||||
|
const loginResponse = await apiClient.loginUser(email, TEST_PASSWORD, false);
|
||||||
|
const { user, token } = await loginResponse.json();
|
||||||
return { user, token };
|
return { user, token };
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -77,13 +82,14 @@ describe('Flyer Processing End-to-End Integration Tests', () => {
|
|||||||
|
|
||||||
// Act 2: Call the backend endpoint to process and save the flyer.
|
// Act 2: Call the backend endpoint to process and save the flyer.
|
||||||
// We now pass the token directly, avoiding global state modification.
|
// We now pass the token directly, avoiding global state modification.
|
||||||
const processResponse = await apiClient.processFlyerFile(
|
const response = await apiClient.processFlyerFile(
|
||||||
mockImageFile,
|
mockImageFile,
|
||||||
checksum,
|
checksum,
|
||||||
originalFileName,
|
originalFileName,
|
||||||
extractedData,
|
extractedData,
|
||||||
token // Pass token override here
|
token // Pass token override here
|
||||||
);
|
);
|
||||||
|
const processResponse = await response.json();
|
||||||
|
|
||||||
// Assert 2: Check for a successful response from the server
|
// Assert 2: Check for a successful response from the server
|
||||||
expect(processResponse).toBeDefined();
|
expect(processResponse).toBeDefined();
|
||||||
|
|||||||
@@ -14,20 +14,25 @@ describe('Public API Routes Integration Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('GET /api/health/db-schema should return success', async () => {
|
it('GET /api/health/db-schema should return success', async () => {
|
||||||
const result = await apiClient.checkDbSchema();
|
const response = await apiClient.checkDbSchema();
|
||||||
|
const result = await response.json();
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.message).toBe('All required database tables exist.');
|
expect(result.message).toBe('All required database tables exist.');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('GET /api/health/storage should return success', async () => {
|
it('GET /api/health/storage should return success', async () => {
|
||||||
// This assumes the STORAGE_PATH is correctly set up for the test environment
|
// This assumes the STORAGE_PATH is correctly set up for the test environment
|
||||||
const result = await apiClient.checkStorage();
|
const response = await apiClient.checkStorage();
|
||||||
|
const result = await response.json();
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.message).toContain('is accessible and writable');
|
expect(result.message).toContain('is accessible and writable');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('GET /api/health/db-pool should return success', async () => {
|
it('GET /api/health/db-pool should return success', async () => {
|
||||||
const result = await apiClient.checkDbPoolHealth();
|
const response = await apiClient.checkDbPoolHealth();
|
||||||
|
// The pingBackend function returns a boolean directly, so no .json() call is needed.
|
||||||
|
// However, checkDbPoolHealth returns a Response, so we need to parse it.
|
||||||
|
const result = await response.json();
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.message).toContain('Pool Status:');
|
expect(result.message).toContain('Pool Status:');
|
||||||
});
|
});
|
||||||
@@ -35,7 +40,8 @@ describe('Public API Routes Integration Tests', () => {
|
|||||||
|
|
||||||
describe('Public Data Endpoints', () => {
|
describe('Public Data Endpoints', () => {
|
||||||
it('GET /api/flyers should return a list of flyers', async () => {
|
it('GET /api/flyers should return a list of flyers', async () => {
|
||||||
const flyers = await apiClient.fetchFlyers();
|
const response = await apiClient.fetchFlyers();
|
||||||
|
const flyers = await response.json();
|
||||||
expect(flyers).toBeInstanceOf(Array);
|
expect(flyers).toBeInstanceOf(Array);
|
||||||
// The seed script creates at least one flyer
|
// The seed script creates at least one flyer
|
||||||
expect(flyers.length).toBeGreaterThan(0);
|
expect(flyers.length).toBeGreaterThan(0);
|
||||||
@@ -44,7 +50,8 @@ describe('Public API Routes Integration Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('GET /api/master-items should return a list of master items', async () => {
|
it('GET /api/master-items should return a list of master items', async () => {
|
||||||
const masterItems = await apiClient.fetchMasterItems();
|
const response = await apiClient.fetchMasterItems();
|
||||||
|
const masterItems = await response.json();
|
||||||
expect(masterItems).toBeInstanceOf(Array);
|
expect(masterItems).toBeInstanceOf(Array);
|
||||||
// The seed script creates master items
|
// The seed script creates master items
|
||||||
expect(masterItems.length).toBeGreaterThan(0);
|
expect(masterItems.length).toBeGreaterThan(0);
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ describe('System API Routes Integration Tests', () => {
|
|||||||
it('should return a status for PM2', async () => {
|
it('should return a status for PM2', async () => {
|
||||||
// In a typical CI environment without PM2, this will fail gracefully.
|
// In a typical CI environment without PM2, this will fail gracefully.
|
||||||
// The test verifies that the endpoint responds correctly, even if PM2 isn't running.
|
// The test verifies that the endpoint responds correctly, even if PM2 isn't running.
|
||||||
const result = await apiClient.checkPm2Status();
|
const response = await apiClient.checkPm2Status();
|
||||||
|
const result = await response.json();
|
||||||
expect(result).toBeDefined();
|
expect(result).toBeDefined();
|
||||||
expect(result).toHaveProperty('success');
|
expect(result).toHaveProperty('success');
|
||||||
expect(result).toHaveProperty('message');
|
expect(result).toHaveProperty('message');
|
||||||
|
|||||||
61
src/tests/integration/useApi.ts
Normal file
61
src/tests/integration/useApi.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
// src/hooks/useApi.ts
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { logger } from '../services/logger';
|
||||||
|
import { notifyError } from '../services/notificationService';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A custom React hook to simplify API calls, including loading and error states.
|
||||||
|
* It is designed to work with apiClient functions that return a `Promise<Response>`.
|
||||||
|
*
|
||||||
|
* @template T The expected data type from the API's JSON response.
|
||||||
|
* @template A The type of the arguments array for the API function.
|
||||||
|
* @param apiFunction The API client function to execute.
|
||||||
|
* @returns An object containing:
|
||||||
|
* - `execute`: A function to trigger the API call.
|
||||||
|
* - `loading`: A boolean indicating if the request is in progress.
|
||||||
|
* - `error`: An `Error` object if the request fails, otherwise `null`.
|
||||||
|
* - `data`: The data returned from the API, or `null` initially.
|
||||||
|
*/
|
||||||
|
export function useApi<T, A extends any[]>(
|
||||||
|
apiFunction: (...args: A) => Promise<Response>
|
||||||
|
) {
|
||||||
|
const [data, setData] = useState<T | null>(null);
|
||||||
|
const [loading, setLoading] = useState<boolean>(false);
|
||||||
|
const [error, setError] = useState<Error | null>(null);
|
||||||
|
|
||||||
|
const execute = useCallback(async (...args: A): Promise<T | null> => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const response = await apiFunction(...args);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
// Attempt to parse a JSON error response from the backend.
|
||||||
|
const errorData = await response.json().catch(() => ({
|
||||||
|
message: `Request failed with status ${response.status}: ${response.statusText}`
|
||||||
|
}));
|
||||||
|
throw new Error(errorData.message || 'An unknown API error occurred.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle successful responses with no content (e.g., HTTP 204).
|
||||||
|
if (response.status === 204) {
|
||||||
|
setData(null);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: T = await response.json();
|
||||||
|
setData(result);
|
||||||
|
return result;
|
||||||
|
} catch (e) {
|
||||||
|
const err = e instanceof Error ? e : new Error('An unknown error occurred.');
|
||||||
|
logger.error('API call failed in useApi hook', { error: err.message, functionName: apiFunction.name });
|
||||||
|
setError(err);
|
||||||
|
notifyError(err.message); // Optionally notify the user automatically.
|
||||||
|
return null; // Return null on failure.
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [apiFunction]);
|
||||||
|
|
||||||
|
return { execute, loading, error, data };
|
||||||
|
}
|
||||||
31
src/tests/integration/useApiOnMount.ts
Normal file
31
src/tests/integration/useApiOnMount.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
// src/hooks/useApiOnMount.ts
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useApi } from './useApi';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A custom React hook that automatically executes an API call when the component mounts
|
||||||
|
* or when specified dependencies change. It wraps the `useApi` hook.
|
||||||
|
*
|
||||||
|
* @template T The expected data type from the API's JSON response.
|
||||||
|
* @template A The type of the arguments array for the API function.
|
||||||
|
* @param apiFunction The API client function to execute.
|
||||||
|
* @param deps An array of dependencies that will trigger a re-fetch when they change.
|
||||||
|
* @param args The arguments to pass to the API function.
|
||||||
|
* @returns An object containing:
|
||||||
|
* - `loading`: A boolean indicating if the request is in progress.
|
||||||
|
* - `error`: An `Error` object if the request fails, otherwise `null`.
|
||||||
|
* - `data`: The data returned from the API, or `null` initially.
|
||||||
|
*/
|
||||||
|
export function useApiOnMount<T, A extends any[]>(
|
||||||
|
apiFunction: (...args: A) => Promise<Response>,
|
||||||
|
deps: React.DependencyList = [],
|
||||||
|
...args: A
|
||||||
|
) {
|
||||||
|
const { execute, ...rest } = useApi<T, A>(apiFunction);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
execute(...args);
|
||||||
|
}, deps); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
return rest;
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|||||||
import * as apiClient from '../../services/apiClient';
|
import * as apiClient from '../../services/apiClient';
|
||||||
import { logger } from '../../services/logger.server';
|
import { logger } from '../../services/logger.server';
|
||||||
import { getPool } from '../../services/db/connection';
|
import { getPool } from '../../services/db/connection';
|
||||||
import type { User } from '../../types';
|
import type { User, MasterGroceryItem, ShoppingList } from '../../types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @vitest-environment node
|
* @vitest-environment node
|
||||||
@@ -18,10 +18,15 @@ const TEST_PASSWORD = 'a-much-stronger-password-for-testing-!@#$';
|
|||||||
const createAndLoginUser = async (email: string) => {
|
const createAndLoginUser = async (email: string) => {
|
||||||
const password = TEST_PASSWORD;
|
const password = TEST_PASSWORD;
|
||||||
// Register the new user.
|
// Register the new user.
|
||||||
await apiClient.registerUser(email, password, 'Test User');
|
const registerResponse = await apiClient.registerUser(email, password, 'Test User');
|
||||||
|
if (!registerResponse.ok) {
|
||||||
|
const errorData = await registerResponse.json();
|
||||||
|
throw new Error(errorData.message || 'Test registration failed');
|
||||||
|
}
|
||||||
|
|
||||||
// Log in to get the auth token.
|
// Log in to get the auth token.
|
||||||
const { user, token } = await apiClient.loginUser(email, password, false);
|
const loginResponse = await apiClient.loginUser(email, password, false);
|
||||||
|
const { user, token } = await loginResponse.json();
|
||||||
return { user, token };
|
return { user, token };
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -52,13 +57,18 @@ describe('User API Routes Integration Tests', () => {
|
|||||||
if (testUser) {
|
if (testUser) {
|
||||||
logger.debug(`[user.integration.test.ts afterAll] Cleaning up user ID: ${testUser.user_id}`);
|
logger.debug(`[user.integration.test.ts afterAll] Cleaning up user ID: ${testUser.user_id}`);
|
||||||
// This requires an authenticated call to delete the account.
|
// This requires an authenticated call to delete the account.
|
||||||
await apiClient.deleteUserAccount(TEST_PASSWORD, authToken);
|
const response = await apiClient.deleteUserAccount(TEST_PASSWORD, authToken);
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json();
|
||||||
|
logger.error(`Failed to clean up user in test: ${errorData.message}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should fetch the authenticated user profile via GET /api/users/profile', async () => {
|
it('should fetch the authenticated user profile via GET /api/users/profile', async () => {
|
||||||
// Act: Call the API endpoint using the authenticated token.
|
// Act: Call the API endpoint using the authenticated token.
|
||||||
const profile = await apiClient.getAuthenticatedUserProfile(authToken);
|
const response = await apiClient.getAuthenticatedUserProfile(authToken);
|
||||||
|
const profile = await response.json();
|
||||||
|
|
||||||
// Assert: Verify the profile data matches the created user.
|
// Assert: Verify the profile data matches the created user.
|
||||||
expect(profile).toBeDefined();
|
expect(profile).toBeDefined();
|
||||||
@@ -75,14 +85,16 @@ describe('User API Routes Integration Tests', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Act: Call the update endpoint with the new data and the auth token.
|
// Act: Call the update endpoint with the new data and the auth token.
|
||||||
const updatedProfile = await apiClient.updateUserProfile(profileUpdates, authToken);
|
const response = await apiClient.updateUserProfile(profileUpdates, authToken);
|
||||||
|
const updatedProfile = await response.json();
|
||||||
|
|
||||||
// Assert: Check that the returned profile reflects the changes.
|
// Assert: Check that the returned profile reflects the changes.
|
||||||
expect(updatedProfile).toBeDefined();
|
expect(updatedProfile).toBeDefined();
|
||||||
expect(updatedProfile.full_name).toBe('Updated Test User');
|
expect(updatedProfile.full_name).toBe('Updated Test User');
|
||||||
|
|
||||||
// Also, fetch the profile again to ensure the change was persisted.
|
// Also, fetch the profile again to ensure the change was persisted.
|
||||||
const refetchedProfile = await apiClient.getAuthenticatedUserProfile(authToken);
|
const refetchResponse = await apiClient.getAuthenticatedUserProfile(authToken);
|
||||||
|
const refetchedProfile = await refetchResponse.json();
|
||||||
expect(refetchedProfile.full_name).toBe('Updated Test User');
|
expect(refetchedProfile.full_name).toBe('Updated Test User');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -93,7 +105,8 @@ describe('User API Routes Integration Tests', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Act: Call the update endpoint.
|
// Act: Call the update endpoint.
|
||||||
const updatedProfile = await apiClient.updateUserPreferences(preferenceUpdates, authToken);
|
const response = await apiClient.updateUserPreferences(preferenceUpdates, authToken);
|
||||||
|
const updatedProfile = await response.json();
|
||||||
|
|
||||||
// Assert: Check that the preferences object in the returned profile is updated.
|
// Assert: Check that the preferences object in the returned profile is updated.
|
||||||
expect(updatedProfile).toBeDefined();
|
expect(updatedProfile).toBeDefined();
|
||||||
@@ -108,9 +121,10 @@ describe('User API Routes Integration Tests', () => {
|
|||||||
|
|
||||||
// Act & Assert: Attempt to register and expect the promise to reject
|
// Act & Assert: Attempt to register and expect the promise to reject
|
||||||
// with an error message indicating the password is too weak.
|
// with an error message indicating the password is too weak.
|
||||||
await expect(
|
const response = await apiClient.registerUser(email, weakPassword, 'Weak Password User');
|
||||||
apiClient.registerUser(email, weakPassword, 'Weak Password User')
|
expect(response.ok).toBe(false);
|
||||||
).rejects.toThrow(/Password is too weak/);
|
const errorData = await response.json();
|
||||||
|
expect(errorData.message).toMatch(/Password is too weak/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should allow a user to delete their own account and then fail to log in', async () => {
|
it('should allow a user to delete their own account and then fail to log in', async () => {
|
||||||
@@ -119,26 +133,32 @@ describe('User API Routes Integration Tests', () => {
|
|||||||
const { token: deletionToken } = await createAndLoginUser(deletionEmail);
|
const { token: deletionToken } = await createAndLoginUser(deletionEmail);
|
||||||
|
|
||||||
// Act: Call the delete endpoint with the correct password and token.
|
// Act: Call the delete endpoint with the correct password and token.
|
||||||
const deleteResponse = await apiClient.deleteUserAccount(TEST_PASSWORD, deletionToken);
|
const response = await apiClient.deleteUserAccount(TEST_PASSWORD, deletionToken);
|
||||||
|
const deleteResponse = await response.json();
|
||||||
|
|
||||||
// Assert: Check for a successful deletion message.
|
// Assert: Check for a successful deletion message.
|
||||||
expect(deleteResponse.message).toBe('Account deleted successfully.');
|
expect(deleteResponse.message).toBe('Account deleted successfully.');
|
||||||
|
|
||||||
// Assert (Verification): Attempting to log in again with the same credentials should now fail.
|
// Assert (Verification): Attempting to log in again with the same credentials should now fail.
|
||||||
// We expect the promise to reject with an error indicating incorrect credentials,
|
const loginResponse = await apiClient.loginUser(deletionEmail, TEST_PASSWORD, false);
|
||||||
// because the user no longer exists.
|
expect(loginResponse.ok).toBe(false);
|
||||||
await expect(
|
const errorData = await loginResponse.json();
|
||||||
apiClient.loginUser(deletionEmail, TEST_PASSWORD, false)
|
expect(errorData.message).toBe('Incorrect email or password.');
|
||||||
).rejects.toThrow('Incorrect email or password.');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should allow a user to reset their password and log in with the new one', async () => {
|
it('should allow a user to reset their password and log in with the new one', async () => {
|
||||||
// Arrange: Create a new user for the password reset flow.
|
// Arrange: Create a new user for the password reset flow.
|
||||||
const resetEmail = `reset-me-${Date.now()}@example.com`;
|
const resetEmail = `reset-me-${Date.now()}@example.com`;
|
||||||
const { user: resetUser } = await createAndLoginUser(resetEmail);
|
const createResponse = await createAndLoginUser(resetEmail);
|
||||||
|
const resetUser = createResponse.user;
|
||||||
|
|
||||||
// Act 1: Request a password reset. In our test environment, the token is returned in the response.
|
// Act 1: Request a password reset. In our test environment, the token is returned in the response.
|
||||||
const resetRequestResponse = await apiClient.requestPasswordReset(resetEmail);
|
const resetRequestRawResponse = await apiClient.requestPasswordReset(resetEmail);
|
||||||
|
if (!resetRequestRawResponse.ok) {
|
||||||
|
const errorData = await resetRequestRawResponse.json();
|
||||||
|
throw new Error(errorData.message || 'Password reset request failed');
|
||||||
|
}
|
||||||
|
const resetRequestResponse = await resetRequestRawResponse.json();
|
||||||
const resetToken = resetRequestResponse.token;
|
const resetToken = resetRequestResponse.token;
|
||||||
|
|
||||||
// Assert 1: Check that we received a token.
|
// Assert 1: Check that we received a token.
|
||||||
@@ -147,60 +167,70 @@ describe('User API Routes Integration Tests', () => {
|
|||||||
|
|
||||||
// Act 2: Use the token to set a new password.
|
// Act 2: Use the token to set a new password.
|
||||||
const newPassword = 'my-new-secure-password-!@#$';
|
const newPassword = 'my-new-secure-password-!@#$';
|
||||||
const resetResponse = await apiClient.resetPassword(resetToken!, newPassword);
|
const resetRawResponse = await apiClient.resetPassword(resetToken!, newPassword);
|
||||||
|
if (!resetRawResponse.ok) {
|
||||||
|
const errorData = await resetRawResponse.json();
|
||||||
|
throw new Error(errorData.message || 'Password reset failed');
|
||||||
|
}
|
||||||
|
const resetResponse = await resetRawResponse.json();
|
||||||
|
|
||||||
// Assert 2: Check for a successful password reset message.
|
// Assert 2: Check for a successful password reset message.
|
||||||
expect(resetResponse.message).toBe('Password has been reset successfully.');
|
expect(resetResponse.message).toBe('Password has been reset successfully.');
|
||||||
|
|
||||||
// Act 3 & Assert 3 (Verification): Log in with the NEW password to confirm the change.
|
// Act 3 & Assert 3 (Verification): Log in with the NEW password to confirm the change.
|
||||||
const loginResponse = await apiClient.loginUser(resetEmail, newPassword, false);
|
const loginResponse = await apiClient.loginUser(resetEmail, newPassword, false);
|
||||||
expect(loginResponse.user).toBeDefined();
|
const loginData = await loginResponse.json();
|
||||||
expect(loginResponse.user.user_id).toBe(resetUser.user_id);
|
expect(loginData.user).toBeDefined();
|
||||||
|
expect(loginData.user.user_id).toBe(resetUser.user_id);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('User Data Routes (Watched Items & Shopping Lists)', () => {
|
describe('User Data Routes (Watched Items & Shopping Lists)', () => {
|
||||||
it('should allow a user to add and remove a watched item', async () => {
|
it('should allow a user to add and remove a watched item', async () => {
|
||||||
// Act 1: Add a new watched item.
|
// Act 1: Add a new watched item. The API returns the created master item.
|
||||||
const newItem = await apiClient.addWatchedItem('Integration Test Item', 'Pantry & Dry Goods', authToken);
|
const addResponse = await apiClient.addWatchedItem('Integration Test Item', 'Other/Miscellaneous', authToken);
|
||||||
|
const newItem = await addResponse.json();
|
||||||
|
|
||||||
// Assert 1: Check that the item was created correctly.
|
// Assert 1: Check that the item was created correctly.
|
||||||
expect(newItem).toBeDefined();
|
expect(newItem).toBeDefined();
|
||||||
expect(newItem.name).toBe('Integration Test Item');
|
expect(newItem.name).toBe('Integration Test Item');
|
||||||
|
|
||||||
// Act 2: Fetch all watched items for the user.
|
// Act 2: Fetch all watched items for the user.
|
||||||
const watchedItems = await apiClient.fetchWatchedItems(authToken);
|
const watchedItemsResponse = await apiClient.fetchWatchedItems(authToken);
|
||||||
|
const watchedItems = await watchedItemsResponse.json();
|
||||||
|
|
||||||
// Assert 2: Verify the new item is in the list.
|
// Assert 2: Verify the new item is in the user's watched list.
|
||||||
expect(watchedItems.some(item => item.master_grocery_item_id === newItem.master_grocery_item_id)).toBe(true);
|
expect(watchedItems.some((item: MasterGroceryItem) => item.master_grocery_item_id === newItem.master_grocery_item_id)).toBe(true);
|
||||||
|
|
||||||
// Act 3: Remove the watched item.
|
// Act 3: Remove the watched item.
|
||||||
await apiClient.removeWatchedItem(newItem.master_grocery_item_id, authToken);
|
await apiClient.removeWatchedItem(newItem.master_grocery_item_id, authToken);
|
||||||
|
|
||||||
// Assert 3: Fetch again and verify the item is gone.
|
// Assert 3: Fetch again and verify the item is gone.
|
||||||
const finalWatchedItems = await apiClient.fetchWatchedItems(authToken);
|
const finalWatchedItemsResponse = await apiClient.fetchWatchedItems(authToken);
|
||||||
expect(finalWatchedItems.some(item => item.master_grocery_item_id === newItem.master_grocery_item_id)).toBe(false);
|
const finalWatchedItems = await finalWatchedItemsResponse.json();
|
||||||
|
expect(finalWatchedItems.some((item: MasterGroceryItem) => item.master_grocery_item_id === newItem.master_grocery_item_id)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should allow a user to manage a shopping list and its items', async () => {
|
it('should allow a user to manage a shopping list', async () => {
|
||||||
// Act 1: Create a new shopping list.
|
// Act 1: Create a new shopping list.
|
||||||
const newList = await apiClient.createShoppingList('My Integration Test List', authToken);
|
const createListResponse = await apiClient.createShoppingList('My Integration Test List', authToken);
|
||||||
|
const newList = await createListResponse.json();
|
||||||
|
|
||||||
// Assert 1: Check that the list was created.
|
// Assert 1: Check that the list was created.
|
||||||
expect(newList).toBeDefined();
|
expect(newList).toBeDefined();
|
||||||
expect(newList.name).toBe('My Integration Test List');
|
expect(newList.name).toBe('My Integration Test List');
|
||||||
|
|
||||||
// Act 2: Add an item to the new list.
|
// Act 2: Add an item to the new list.
|
||||||
const addedItem = await apiClient.addShoppingListItem(newList.shopping_list_id, { customItemName: 'Custom Test Item' }, authToken);
|
const addItemResponse = await apiClient.addShoppingListItem(newList.shopping_list_id, { customItemName: 'Custom Test Item' }, authToken);
|
||||||
|
const addedItem = await addItemResponse.json();
|
||||||
|
|
||||||
// Assert 2: Check that the item was added.
|
// Assert 2: Check that the item was added.
|
||||||
expect(addedItem).toBeDefined();
|
expect(addedItem).toBeDefined();
|
||||||
expect(addedItem.custom_item_name).toBe('Custom Test Item');
|
expect(addedItem.custom_item_name).toBe('Custom Test Item');
|
||||||
|
|
||||||
// Act 3: Fetch the lists again to verify the item is present.
|
// Assert 3: Fetch all lists and verify the new item is present in the correct list.
|
||||||
const lists = await apiClient.fetchShoppingLists(authToken);
|
const fetchResponse = await apiClient.fetchShoppingLists(authToken);
|
||||||
const updatedList = lists.find(l => l.shopping_list_id === newList.shopping_list_id);
|
const lists = await fetchResponse.json();
|
||||||
expect(updatedList).toBeDefined();
|
const updatedList = lists.find((l: ShoppingList) => l.shopping_list_id === newList.shopping_list_id);
|
||||||
expect(updatedList?.items).toHaveLength(1);
|
|
||||||
expect(updatedList?.items[0].shopping_list_item_id).toBe(addedItem.shopping_list_item_id);
|
expect(updatedList?.items[0].shopping_list_item_id).toBe(addedItem.shopping_list_item_id);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// src/tests/setup/unit-setup.ts
|
// src/tests/setup/unit-setup.ts
|
||||||
import { vi, afterEach } from 'vitest';
|
import { vi, afterEach } from 'vitest';
|
||||||
import { cleanup } from '@testing-library/react';
|
import { cleanup } from '@testing-library/react';
|
||||||
|
import * as apiClient from '../../services/apiClient';
|
||||||
import '@testing-library/jest-dom/vitest';
|
import '@testing-library/jest-dom/vitest';
|
||||||
|
|
||||||
// Mock the GeolocationPositionError global that exists in browsers but not in JSDOM.
|
// Mock the GeolocationPositionError global that exists in browsers but not in JSDOM.
|
||||||
@@ -42,6 +43,101 @@ Object.defineProperty(window, 'matchMedia', {
|
|||||||
dispatchEvent: vi.fn(),
|
dispatchEvent: vi.fn(),
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Mock pdfjs-dist to prevent the "DOMMatrix is not defined" error in JSDOM.
|
||||||
|
vi.mock('pdfjs-dist', () => ({
|
||||||
|
GlobalWorkerOptions: { workerSrc: '' },
|
||||||
|
getDocument: vi.fn(() => ({
|
||||||
|
promise: Promise.resolve({ getPage: vi.fn() }),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock react-hot-toast and the notification service that uses it.
|
||||||
|
vi.mock('react-hot-toast');
|
||||||
|
vi.mock('../../services/notificationService', () => ({
|
||||||
|
notifySuccess: vi.fn(),
|
||||||
|
notifyError: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock the logger to prevent console output during tests
|
||||||
|
vi.mock('../../services/logger', () => ({
|
||||||
|
logger: {
|
||||||
|
info: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
debug: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Create a single, comprehensive mock for the entire apiClient module.
|
||||||
|
// This ensures that all test files that import from apiClient will get this mocked version.
|
||||||
|
vi.mock('../../services/apiClient', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof apiClient>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
// --- Auth ---
|
||||||
|
registerUser: vi.fn(),
|
||||||
|
loginUser: vi.fn(),
|
||||||
|
getAuthenticatedUserProfile: vi.fn(),
|
||||||
|
requestPasswordReset: vi.fn(),
|
||||||
|
resetPassword: vi.fn(),
|
||||||
|
updateUserPassword: vi.fn(),
|
||||||
|
deleteUserAccount: vi.fn(),
|
||||||
|
updateUserPreferences: vi.fn(),
|
||||||
|
updateUserProfile: vi.fn(),
|
||||||
|
// --- Data Fetching & Manipulation ---
|
||||||
|
fetchFlyers: vi.fn(),
|
||||||
|
fetchFlyerItems: vi.fn(),
|
||||||
|
fetchFlyerItemsForFlyers: vi.fn(),
|
||||||
|
countFlyerItemsForFlyers: vi.fn(),
|
||||||
|
fetchMasterItems: vi.fn(),
|
||||||
|
fetchWatchedItems: vi.fn(),
|
||||||
|
addWatchedItem: vi.fn(),
|
||||||
|
removeWatchedItem: vi.fn(),
|
||||||
|
fetchShoppingLists: vi.fn(),
|
||||||
|
createShoppingList: vi.fn(),
|
||||||
|
deleteShoppingList: vi.fn(),
|
||||||
|
addShoppingListItem: vi.fn(),
|
||||||
|
updateShoppingListItem: vi.fn(),
|
||||||
|
removeShoppingListItem: vi.fn(),
|
||||||
|
fetchHistoricalPriceData: vi.fn(),
|
||||||
|
processFlyerFile: vi.fn(),
|
||||||
|
uploadLogoAndUpdateStore: vi.fn(),
|
||||||
|
exportUserData: vi.fn(),
|
||||||
|
// --- Admin ---
|
||||||
|
getSuggestedCorrections: vi.fn(),
|
||||||
|
fetchCategories: vi.fn(),
|
||||||
|
approveCorrection: vi.fn(),
|
||||||
|
rejectCorrection: vi.fn(),
|
||||||
|
updateSuggestedCorrection: vi.fn(),
|
||||||
|
getApplicationStats: vi.fn(),
|
||||||
|
fetchActivityLog: vi.fn(),
|
||||||
|
fetchAllBrands: vi.fn(),
|
||||||
|
uploadBrandLogo: vi.fn(),
|
||||||
|
// --- System ---
|
||||||
|
pingBackend: vi.fn(),
|
||||||
|
checkDbSchema: vi.fn(),
|
||||||
|
checkStorage: vi.fn(),
|
||||||
|
checkDbPoolHealth: vi.fn(),
|
||||||
|
checkPm2Status: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock AI API Client
|
||||||
|
vi.mock('../../services/aiApiClient', () => ({
|
||||||
|
isImageAFlyer: vi.fn(),
|
||||||
|
extractCoreDataFromImage: vi.fn(),
|
||||||
|
extractAddressFromImage: vi.fn(),
|
||||||
|
extractLogoFromImage: vi.fn(),
|
||||||
|
getQuickInsights: vi.fn(),
|
||||||
|
getDeepDiveAnalysis: vi.fn(),
|
||||||
|
searchWeb: vi.fn(),
|
||||||
|
planTripWithMaps: vi.fn(),
|
||||||
|
generateImageFromText: vi.fn(),
|
||||||
|
generateSpeechFromText: vi.fn(),
|
||||||
|
startVoiceSession: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
// Automatically run cleanup after each test case (e.g., clearing jsdom)
|
// Automatically run cleanup after each test case (e.g., clearing jsdom)
|
||||||
// This is specific to our jsdom-based unit tests.
|
// This is specific to our jsdom-based unit tests.
|
||||||
afterEach(cleanup);
|
afterEach(cleanup);
|
||||||
Reference in New Issue
Block a user