Refactor: Enhance logging and error handling in useAiAnalysis and AdminBrandManager for improved clarity and maintainability
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 28m42s
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 28m42s
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
// src/hooks/useAiAnalysis.test.ts
|
||||
import React, { ReactNode } from 'react';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { renderHook, act, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { useAiAnalysis } from './useAiAnalysis';
|
||||
import { useApi } from './useApi';
|
||||
@@ -287,7 +287,8 @@ describe('useAiAnalysis Hook', () => {
|
||||
|
||||
logger.info('Assert: Checking that the logger was warned and the API was not called.');
|
||||
expect(mockGenerateImage.execute).not.toHaveBeenCalled();
|
||||
expect(logger.warn).toHaveBeenCalledWith('generateImage called but no meal plan text available in results');
|
||||
expect(logger.warn).toHaveBeenCalledWith(`[generateImage Callback] Aborting: required DEEP_DIVE text is missing. Value was: 'undefined'`);
|
||||
logger.info('Assertion passed for no-op generateImage call.');
|
||||
});
|
||||
|
||||
it('should call the API and set the image URL on success', async () => {
|
||||
@@ -299,33 +300,41 @@ describe('useAiAnalysis Hook', () => {
|
||||
mockedUseApi.mockReturnValue({ execute: vi.fn(), data: null, loading: false, error: null, isRefetching: false, reset: vi.fn() });
|
||||
mockedUseApi
|
||||
.mockReturnValueOnce(mockGetQuickInsights)
|
||||
.mockReturnValueOnce({ ...mockGetDeepDive, data: 'A great meal plan' }) // Deep dive data
|
||||
.mockReturnValueOnce({ ...mockGetDeepDive, data: 'A great meal plan', reset: vi.fn() })
|
||||
.mockReturnValueOnce(mockSearchWeb)
|
||||
.mockReturnValueOnce(mockPlanTrip)
|
||||
.mockReturnValueOnce(mockComparePrices)
|
||||
.mockReturnValueOnce(mockGenerateImage);
|
||||
rerender();
|
||||
|
||||
logger.info('Step 2 (Act): Calling `generateImage`.');
|
||||
// THIS IS THE CRITICAL FIX:
|
||||
// We must wait for the hook's internal useEffect to process the new data and update its state.
|
||||
logger.info("Step 2 (Sync): Waiting for the hook's internal state to update after receiving new data...");
|
||||
await waitFor(() => {
|
||||
expect(result.current.results[AnalysisType.DEEP_DIVE]).toBe('A great meal plan');
|
||||
});
|
||||
logger.info('Step 2 (Sync): State successfully updated.');
|
||||
|
||||
logger.info('Step 3 (Act): Calling `generateImage`.');
|
||||
await act(async () => {
|
||||
await result.current.generateImage();
|
||||
});
|
||||
|
||||
logger.info('Step 3 (Assert): Verifying the image generation API was called.');
|
||||
logger.info('Step 4 (Assert): Verifying the image generation API was called with the correct text.');
|
||||
expect(mockGenerateImage.execute).toHaveBeenCalledWith('A great meal plan');
|
||||
|
||||
logger.info('Step 4 (Arrange): Simulating `useApi` for image generation returning a successful result via rerender.');
|
||||
logger.info('Step 5 (Arrange): Simulating `useApi` for image generation returning a successful result via rerender.');
|
||||
mockedUseApi.mockReset();
|
||||
mockedUseApi
|
||||
.mockReturnValueOnce(mockGetQuickInsights)
|
||||
.mockReturnValueOnce({ ...mockGetDeepDive, data: 'A great meal plan' })
|
||||
.mockReturnValueOnce({ ...mockGetDeepDive, data: 'A great meal plan', reset: vi.fn() })
|
||||
.mockReturnValueOnce(mockSearchWeb)
|
||||
.mockReturnValueOnce(mockPlanTrip)
|
||||
.mockReturnValueOnce(mockComparePrices)
|
||||
.mockReturnValueOnce({ ...mockGenerateImage, data: 'base64string' }); // Now has data
|
||||
.mockReturnValueOnce({ ...mockGenerateImage, data: 'base64string', reset: vi.fn() });
|
||||
rerender();
|
||||
|
||||
logger.info('Step 5 (Assert): Checking for correctly formatted image URL.');
|
||||
logger.info('Step 6 (Assert): Checking for correctly formatted image URL from the final state.');
|
||||
expect(result.current.generatedImageUrl).toBe('data:image/png;base64,base64string');
|
||||
logger.info('Image URL assertion passed.');
|
||||
});
|
||||
@@ -339,32 +348,39 @@ describe('useAiAnalysis Hook', () => {
|
||||
mockedUseApi.mockReturnValue({ execute: vi.fn(), data: null, loading: false, error: null, isRefetching: false, reset: vi.fn() });
|
||||
mockedUseApi
|
||||
.mockReturnValueOnce(mockGetQuickInsights)
|
||||
.mockReturnValueOnce({ ...mockGetDeepDive, data: 'A great meal plan' }) // Deep dive data
|
||||
.mockReturnValueOnce({ ...mockGetDeepDive, data: 'A great meal plan', reset: vi.fn() })
|
||||
.mockReturnValueOnce(mockSearchWeb)
|
||||
.mockReturnValueOnce(mockPlanTrip)
|
||||
.mockReturnValueOnce(mockComparePrices)
|
||||
.mockReturnValueOnce(mockGenerateImage);
|
||||
rerender();
|
||||
|
||||
logger.info('Step 2 (Act): Call generateImage. The execute function is called.');
|
||||
// THIS IS THE CRITICAL FIX (AGAIN): Wait for state to be ready.
|
||||
logger.info("Step 2 (Sync): Waiting for the hook's internal state to update before calling generateImage...");
|
||||
await waitFor(() => {
|
||||
expect(result.current.results[AnalysisType.DEEP_DIVE]).toBe('A great meal plan');
|
||||
});
|
||||
logger.info('Step 2 (Sync): State successfully updated.');
|
||||
|
||||
logger.info('Step 3 (Act): Call generateImage. The execute function will be called.');
|
||||
await act(async () => {
|
||||
await result.current.generateImage();
|
||||
});
|
||||
|
||||
logger.info('Step 3 (Arrange): Simulate the useApi hook re-rendering our component with an error state.');
|
||||
logger.info('Step 4 (Arrange): Simulate the useApi hook re-rendering our component with an error state.');
|
||||
const apiError = new Error('Image model failed');
|
||||
mockedUseApi.mockReset();
|
||||
mockedUseApi.mockReturnValue({ execute: vi.fn(), data: null, loading: false, error: null, isRefetching: false, reset: vi.fn() });
|
||||
mockedUseApi
|
||||
.mockReturnValueOnce(mockGetQuickInsights)
|
||||
.mockReturnValueOnce({ ...mockGetDeepDive, data: 'A great meal plan' })
|
||||
.mockReturnValueOnce({ ...mockGetDeepDive, data: 'A great meal plan', reset: vi.fn() })
|
||||
.mockReturnValueOnce(mockSearchWeb)
|
||||
.mockReturnValueOnce(mockPlanTrip)
|
||||
.mockReturnValueOnce(mockComparePrices)
|
||||
.mockReturnValueOnce({ ...mockGenerateImage, error: apiError, reset: vi.fn() }); // Image gen now has an error
|
||||
rerender();
|
||||
|
||||
logger.info("Step 4 (Assert): The error from the useApi hook is now exposed as the hook's primary error state.");
|
||||
logger.info("Step 5 (Assert): The error from the useApi hook is now exposed as the hook's primary error state.");
|
||||
expect(result.current.error).toBe('Image model failed');
|
||||
logger.info('Error state assertion passed.');
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// src/hooks/useAiAnalysis.ts
|
||||
import { useState, useCallback, useMemo, useEffect } from 'react';
|
||||
import { useState, useCallback, useMemo, useEffect, useRef } from 'react';
|
||||
import { Flyer, FlyerItem, MasterGroceryItem, AnalysisType } from '../types';
|
||||
import type { GroundingChunk } from '@google/genai';
|
||||
import * as aiApiClient from '../services/aiApiClient';
|
||||
@@ -23,6 +23,13 @@ interface UseAiAnalysisParams {
|
||||
}
|
||||
|
||||
export const useAiAnalysis = ({ flyerItems, selectedFlyer, watchedItems }: UseAiAnalysisParams) => {
|
||||
// --- Add a render counter for debugging ---
|
||||
const renderCount = useRef(0);
|
||||
renderCount.current += 1;
|
||||
// This log helps trace how many times the hook re-renders during a test or in the browser.
|
||||
logger.info(`[useAiAnalysis] Render #${renderCount.current}`);
|
||||
|
||||
|
||||
// --- State for results ---
|
||||
const [results, setResults] = useState<{ [key in AnalysisType]?: string }>({});
|
||||
const [sources, setSources] = useState<{ [key in AnalysisType]?: Source[] }>({});
|
||||
@@ -53,26 +60,33 @@ export const useAiAnalysis = ({ flyerItems, selectedFlyer, watchedItems }: UseAi
|
||||
|
||||
// --- Effects to update state when API data changes ---
|
||||
useEffect(() => {
|
||||
logger.info(`[useAiAnalysis Effect] Start: Processing data from useApi hooks. Render #${renderCount.current}`);
|
||||
if (quickInsightsData) {
|
||||
logger.info(`[useAiAnalysis Effect] quickInsightsData detected. Updating results state.`);
|
||||
setResults(prev => ({ ...prev, [AnalysisType.QUICK_INSIGHTS]: quickInsightsData }));
|
||||
}
|
||||
if (deepDiveData) {
|
||||
logger.info(`[useAiAnalysis Effect] deepDiveData detected ('${deepDiveData}'). Updating results state.`);
|
||||
setResults(prev => ({ ...prev, [AnalysisType.DEEP_DIVE]: deepDiveData }));
|
||||
}
|
||||
if (webSearchData) {
|
||||
logger.info(`[useAiAnalysis Effect] webSearchData detected. Updating results and sources.`);
|
||||
setResults(prev => ({ ...prev, [AnalysisType.WEB_SEARCH]: webSearchData.text }));
|
||||
const mappedSources = (webSearchData.sources || []).map(s => ('web' in s ? { uri: s.web?.uri || '', title: s.web?.title || 'Untitled' } : s) as Source);
|
||||
setSources(prev => ({ ...prev, [AnalysisType.WEB_SEARCH]: mappedSources }));
|
||||
}
|
||||
if (tripPlanData) {
|
||||
logger.info(`[useAiAnalysis Effect] tripPlanData detected. Updating results and sources.`);
|
||||
setResults(prev => ({ ...prev, [AnalysisType.PLAN_TRIP]: tripPlanData.text }));
|
||||
setSources(prev => ({ ...prev, [AnalysisType.PLAN_TRIP]: tripPlanData.sources as Source[] }));
|
||||
}
|
||||
if (priceComparisonData) {
|
||||
logger.info(`[useAiAnalysis Effect] priceComparisonData detected. Updating results and sources.`);
|
||||
setResults(prev => ({ ...prev, [AnalysisType.COMPARE_PRICES]: priceComparisonData.text }));
|
||||
const mappedSources = (priceComparisonData.sources || []).map(s => ('web' in s ? { uri: s.web?.uri || '', title: s.web?.title || 'Untitled' } : s) as Source);
|
||||
setSources(prev => ({ ...prev, [AnalysisType.COMPARE_PRICES]: mappedSources }));
|
||||
}
|
||||
logger.info(`[useAiAnalysis Effect] Finish: Data processing complete.`);
|
||||
}, [quickInsightsData, deepDiveData, webSearchData, tripPlanData, priceComparisonData]);
|
||||
|
||||
const generatedImageUrl = useMemo(() => generatedImageData ? `data:image/png;base64,${generatedImageData}` : null, [generatedImageData]);
|
||||
@@ -127,14 +141,17 @@ export const useAiAnalysis = ({ flyerItems, selectedFlyer, watchedItems }: UseAi
|
||||
]);
|
||||
|
||||
const generateImage = useCallback(async () => {
|
||||
// This log is crucial to see what `results` the function is seeing when it's called.
|
||||
logger.info(`[generateImage Callback] Executing. Captured results state:`, results);
|
||||
const mealPlanText = results[AnalysisType.DEEP_DIVE];
|
||||
if (!mealPlanText) {
|
||||
logger.warn('generateImage called but no meal plan text available in results');
|
||||
logger.warn(`[generateImage Callback] Aborting: required DEEP_DIVE text is missing. Value was: '${mealPlanText}'`);
|
||||
return;
|
||||
}
|
||||
// Clear any previous internal errors. The useApi hook will manage the loading/error state
|
||||
// for this specific API call, and its state is already wired up to this hook's `error` value.
|
||||
setInternalError(null);
|
||||
logger.info(`[generateImage Callback] Proceeding to call generateImageApi with text snippet: "${mealPlanText.substring(0, 50)}..."`);
|
||||
// No try/catch is needed here because the useApi hook handles promise rejections
|
||||
// and exposes the error through its `error` return value.
|
||||
await generateImageApi(mealPlanText);
|
||||
|
||||
@@ -53,7 +53,7 @@ describe('AdminBrandManager', () => {
|
||||
});
|
||||
|
||||
it('should render the list of brands when data is fetched successfully', async () => {
|
||||
mockedApiClient.fetchAllBrands.mockImplementation(async () => new Response(JSON.stringify(mockBrands)));
|
||||
mockedApiClient.fetchAllBrands.mockResolvedValue(new Response(JSON.stringify(mockBrands), { status: 200, statusText: 'OK' }));
|
||||
render(<AdminBrandManager />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { name: /brand management/i })).toBeInTheDocument();
|
||||
@@ -65,8 +65,8 @@ describe('AdminBrandManager', () => {
|
||||
});
|
||||
|
||||
it('should handle successful logo upload', async () => {
|
||||
mockedApiClient.fetchAllBrands.mockImplementation(async () => new Response(JSON.stringify(mockBrands)));
|
||||
mockedApiClient.uploadBrandLogo.mockImplementation(async () => new Response(JSON.stringify({ logoUrl: 'http://example.com/new-logo.png' })));
|
||||
mockedApiClient.fetchAllBrands.mockResolvedValue(new Response(JSON.stringify(mockBrands), { status: 200, statusText: 'OK' }));
|
||||
mockedApiClient.uploadBrandLogo.mockResolvedValue(new Response(JSON.stringify({ logoUrl: 'http://example.com/new-logo.png' }), { status: 200, statusText: 'OK' }));
|
||||
mockedToast.loading.mockReturnValue('toast-1');
|
||||
|
||||
render(<AdminBrandManager />);
|
||||
@@ -88,7 +88,7 @@ describe('AdminBrandManager', () => {
|
||||
});
|
||||
|
||||
it('should handle failed logo upload', async () => {
|
||||
mockedApiClient.fetchAllBrands.mockImplementation(async () => new Response(JSON.stringify(mockBrands)));
|
||||
mockedApiClient.fetchAllBrands.mockResolvedValue(new Response(JSON.stringify(mockBrands), { status: 200, statusText: 'OK' }));
|
||||
mockedApiClient.uploadBrandLogo.mockRejectedValue(new Error('Upload failed'));
|
||||
mockedToast.loading.mockReturnValue('toast-2');
|
||||
|
||||
@@ -106,7 +106,7 @@ describe('AdminBrandManager', () => {
|
||||
});
|
||||
|
||||
it('should show an error toast for invalid file type', async () => {
|
||||
mockedApiClient.fetchAllBrands.mockImplementation(async () => new Response(JSON.stringify(mockBrands)));
|
||||
mockedApiClient.fetchAllBrands.mockResolvedValue(new Response(JSON.stringify(mockBrands), { status: 200, statusText: 'OK' }));
|
||||
render(<AdminBrandManager />);
|
||||
await waitFor(() => expect(screen.getByText('No Frills')).toBeInTheDocument());
|
||||
|
||||
@@ -122,7 +122,7 @@ describe('AdminBrandManager', () => {
|
||||
});
|
||||
|
||||
it('should show an error toast for oversized file', async () => {
|
||||
mockedApiClient.fetchAllBrands.mockImplementation(async () => new Response(JSON.stringify(mockBrands)));
|
||||
mockedApiClient.fetchAllBrands.mockResolvedValue(new Response(JSON.stringify(mockBrands), { status: 200, statusText: 'OK' }));
|
||||
render(<AdminBrandManager />);
|
||||
await waitFor(() => expect(screen.getByText('No Frills')).toBeInTheDocument());
|
||||
|
||||
|
||||
@@ -8,27 +8,41 @@ import { useApiOnMount } from '../../../hooks/useApiOnMount';
|
||||
|
||||
export const AdminBrandManager: React.FC = () => {
|
||||
// The wrapper function now correctly handles the async nature of the API call.
|
||||
// It awaits the response and parses the JSON body, ensuring that the
|
||||
// `useApiOnMount` hook receives the actual data (Brand[]) rather than the raw Response object.
|
||||
const fetchBrandsWrapper = async () => {
|
||||
const response = await fetchAllBrands();
|
||||
if (!response.ok) {
|
||||
// Improve error handling by attempting to read text from the response body.
|
||||
const errorText = await response.text();
|
||||
throw new Error(errorText || `Request failed with status ${response.status}`);
|
||||
console.log("fetchBrandsWrapper called");
|
||||
try {
|
||||
const response = await fetchAllBrands();
|
||||
console.log("Raw response from fetchAllBrands:", response);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
const errorMessage = errorText || `Request failed with status ${response.status}`;
|
||||
console.error("API error:", errorMessage);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log("Parsed JSON data:", data);
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error("Error in fetchBrandsWrapper:", error);
|
||||
throw error;
|
||||
}
|
||||
// Return the parsed JSON so the hook gets the correct data type.
|
||||
return response.json();
|
||||
};
|
||||
|
||||
const { data: initialBrands, loading, error } = useApiOnMount<Brand[], []>(fetchBrandsWrapper, []);
|
||||
console.log("Data from useApiOnMount:", initialBrands, "Loading:", loading, "Error:", error);
|
||||
|
||||
// We still need local state for brands so we can update it after a logo upload
|
||||
// without needing to re-fetch the entire list.
|
||||
const [brands, setBrands] = useState<Brand[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialBrands) setBrands(initialBrands);
|
||||
console.log("useEffect triggered with initialBrands:", initialBrands);
|
||||
if (initialBrands) {
|
||||
setBrands(initialBrands);
|
||||
console.log("Brands state updated:", initialBrands);
|
||||
}
|
||||
}, [initialBrands]);
|
||||
|
||||
const handleLogoUpload = async (brandId: number, file: File) => {
|
||||
@@ -52,7 +66,6 @@ export const AdminBrandManager: React.FC = () => {
|
||||
try {
|
||||
const response = await uploadBrandLogo(brandId, file);
|
||||
|
||||
// Check for a successful response before attempting to parse JSON.
|
||||
if (!response.ok) {
|
||||
const errorBody = await response.text();
|
||||
throw new Error(errorBody || `Upload failed with status ${response.status}`);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// src/pages/admin/components/ProfileManager.Authenticated.test.tsx
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent, waitFor, cleanup, act } from '@testing-library/react';
|
||||
import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest';
|
||||
import { ProfileManager } from './ProfileManager';
|
||||
import * as apiClient from '../../../services/apiClient';
|
||||
@@ -195,17 +195,12 @@ describe('ProfileManager Authenticated User Features', () => {
|
||||
fireEvent.change(screen.getByLabelText(/city/i), { target: { value: 'NewCity' } });
|
||||
console.log('[TEST LOG] City input value after change:', (cityInput as HTMLInputElement).value);
|
||||
|
||||
console.log('[TEST LOG] About to enter act() block for form submission.');
|
||||
// Wrap the submission in `act` to ensure all state updates from the async hooks
|
||||
// are processed before moving on. This is crucial for complex components.
|
||||
await act(async () => {
|
||||
console.log('[TEST LOG] INSIDE act(): Firing submit event on the form.');
|
||||
fireEvent.submit(screen.getByTestId('profile-form'));
|
||||
// This log confirms the event has been fired within the act block.
|
||||
console.log('[TEST LOG] INSIDE act(): Submit event fired. Awaiting internal state updates to flush...');
|
||||
});
|
||||
console.log('[TEST LOG] Exited act() block. All updates should now be flushed.');
|
||||
// Find the submit button to call its onClick handler, which triggers the form submission.
|
||||
const saveButton = screen.getByRole('button', { name: /save profile/i });
|
||||
|
||||
console.log('[TEST LOG] Directly clicking the save button to trigger the form submit handler.');
|
||||
// Directly click the button, which is the most reliable way to trigger the form's onSubmit.
|
||||
fireEvent.click(saveButton);
|
||||
console.log('[TEST LOG] Waiting for notifyError to be called...');
|
||||
// Since only the address changed and it failed, we expect an error notification (handled by useApi)
|
||||
// and NOT a success message.
|
||||
|
||||
@@ -379,7 +379,7 @@ export const ProfileManager: React.FC<ProfileManagerProps> = ({ isOpen, onClose,
|
||||
</div>
|
||||
|
||||
{activeTab === 'profile' && (
|
||||
<form data-testid="profile-form" onSubmit={handleProfileSave} className="space-y-4">
|
||||
<form aria-label="Profile Form" onSubmit={handleProfileSave} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="fullName" className="block text-sm font-medium text-gray-700 dark:text-gray-300">Full Name</label>
|
||||
<input id="fullName" type="text" value={fullName} onChange={e => setFullName(e.target.value)} className="mt-1 block w-full px-3 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm" />
|
||||
|
||||
@@ -48,6 +48,9 @@ const server = setupServer(
|
||||
// Handler for all POST requests to the AI endpoints
|
||||
http.post('http://localhost/api/ai/:endpoint', async ({ request, params }) => {
|
||||
let body: Record<string, unknown> | FormData = {};
|
||||
// This variable will hold a plain object representation of the request body
|
||||
// for reliable inspection in our tests, especially for FormData.
|
||||
let bodyForSpy: Record<string, unknown> = {};
|
||||
const contentType = request.headers.get('Content-Type');
|
||||
|
||||
if (contentType?.includes('application/json')) {
|
||||
@@ -55,24 +58,31 @@ const server = setupServer(
|
||||
// Ensure the parsed body is an object before assigning, as request.json() can return primitives.
|
||||
if (typeof parsedBody === 'object' && parsedBody !== null && !Array.isArray(parsedBody)) {
|
||||
body = parsedBody as Record<string, unknown>;
|
||||
bodyForSpy = body; // For JSON, the body is already a plain object.
|
||||
}
|
||||
} else if (contentType?.includes('multipart/form-data')) {
|
||||
console.log(`[MSW HANDLER] Intercepted multipart/form-data request for endpoint: ${String(params.endpoint)}`);
|
||||
body = await request.formData();
|
||||
// When MSW processes FormData in JSDOM, the file.name property is lost.
|
||||
// To work around this, we iterate through the FormData. The `value` object
|
||||
// is a File-like object that still retains its original `name`. We attach this
|
||||
// to a custom property that our test assertions can reliably use.
|
||||
(body as FormData).forEach((value, _key) => {
|
||||
// FIX: Instead of trying to modify the File object, we create a clean, plain
|
||||
// object from the FormData to pass to our spy. This is much more stable in a JSDOM environment.
|
||||
for (const [key, value] of (body as FormData).entries()) {
|
||||
if (value instanceof File) {
|
||||
(value as File & { originalName: string }).originalName = value.name;
|
||||
console.log(`[MSW HANDLER DEBUG] Found File. Key: '${key}', Name: '${value.name}', Size: ${value.size}`);
|
||||
// If a key appears multiple times (e.g., 'images'), we collect them in an array.
|
||||
if (!bodyForSpy[key]) {
|
||||
bodyForSpy[key] = { name: value.name, size: value.size, type: value.type };
|
||||
}
|
||||
} else {
|
||||
console.log(`[MSW HANDLER DEBUG] Found text field. Key: '${key}', Value: '${String(value)}'`);
|
||||
bodyForSpy[key] = value;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
requestSpy({
|
||||
endpoint: params.endpoint,
|
||||
method: request.method,
|
||||
body,
|
||||
body: bodyForSpy, // Pass the stable, plain object to the spy.
|
||||
headers: request.headers,
|
||||
});
|
||||
|
||||
@@ -113,13 +123,19 @@ describe('AI API Client (Network Mocking with MSW)', () => {
|
||||
|
||||
expect(req.endpoint).toBe('upload-and-process');
|
||||
expect(req.method).toBe('POST');
|
||||
// FIX: Use a duck-typing check for FormData to avoid environment-specific instance issues.
|
||||
expect(typeof (req.body as FormData).get).toBe('function');
|
||||
|
||||
// DEBUG: Log the body received by the spy. It should now be a plain object.
|
||||
console.log('[TEST DEBUG] uploadAndProcessFlyer - Body received by spy:', req.body);
|
||||
|
||||
const flyerFile = (req.body as FormData).get('flyerFile') as File & { originalName: string };
|
||||
const checksumValue = (req.body as FormData).get('checksum');
|
||||
// FIX: Assert against the properties of the plain object from the spy.
|
||||
const flyerFile = req.body.flyerFile as { name: string };
|
||||
const checksumValue = req.body.checksum;
|
||||
|
||||
// Add assertions to ensure the objects exist before checking their properties.
|
||||
expect(flyerFile).toBeDefined();
|
||||
expect(checksumValue).toBeDefined();
|
||||
|
||||
expect(flyerFile.originalName).toBe('flyer.pdf');
|
||||
expect(flyerFile.name).toBe('flyer.pdf');
|
||||
expect(checksumValue).toBe(checksum);
|
||||
});
|
||||
});
|
||||
@@ -149,9 +165,11 @@ describe('AI API Client (Network Mocking with MSW)', () => {
|
||||
|
||||
expect(req.endpoint).toBe('check-flyer');
|
||||
expect(req.method).toBe('POST');
|
||||
expect(typeof (req.body as FormData).get).toBe('function');
|
||||
const imageFile = (req.body as FormData).get('image') as File & { originalName: string };
|
||||
expect(imageFile.originalName).toBe('flyer.jpg');
|
||||
|
||||
// FIX: Assert against the plain object from the spy.
|
||||
const imageFile = req.body.image as { name: string };
|
||||
expect(imageFile).toBeDefined();
|
||||
expect(imageFile.name).toBe('flyer.jpg');
|
||||
expect(req.headers.get('Authorization')).toBe('Bearer test-token');
|
||||
});
|
||||
});
|
||||
@@ -165,9 +183,11 @@ describe('AI API Client (Network Mocking with MSW)', () => {
|
||||
const req = requestSpy.mock.calls[0][0];
|
||||
|
||||
expect(req.endpoint).toBe('extract-address');
|
||||
expect(typeof (req.body as FormData).get).toBe('function');
|
||||
const imageFile = (req.body as FormData).get('image') as File & { originalName: string };
|
||||
expect(imageFile.originalName).toBe('flyer.jpg');
|
||||
|
||||
// FIX: Assert against the plain object from the spy.
|
||||
const imageFile = req.body.image as { name: string };
|
||||
expect(imageFile).toBeDefined();
|
||||
expect(imageFile.name).toBe('flyer.jpg');
|
||||
expect(req.headers.get('Authorization')).toBe('Bearer test-token');
|
||||
});
|
||||
});
|
||||
@@ -181,9 +201,11 @@ describe('AI API Client (Network Mocking with MSW)', () => {
|
||||
const req = requestSpy.mock.calls[0][0];
|
||||
|
||||
expect(req.endpoint).toBe('extract-logo');
|
||||
expect(typeof (req.body as FormData).get).toBe('function');
|
||||
const imageFile = (req.body as FormData).get('images') as File & { originalName: string };
|
||||
expect(imageFile.originalName).toBe('logo.jpg');
|
||||
|
||||
// FIX: Assert against the plain object from the spy.
|
||||
const imageFile = req.body.images as { name: string };
|
||||
expect(imageFile).toBeDefined();
|
||||
expect(imageFile.name).toBe('logo.jpg');
|
||||
expect(req.headers.get('Authorization')).toBe('Bearer test-token');
|
||||
});
|
||||
});
|
||||
@@ -275,15 +297,32 @@ describe('AI API Client (Network Mocking with MSW)', () => {
|
||||
created_at: new Date().toISOString(),
|
||||
}];
|
||||
const store: import('../types').Store = { store_id: 1, name: 'Test Store', created_at: new Date().toISOString() };
|
||||
const userLocation: GeolocationCoordinates = { latitude: 45, longitude: -75, accuracy: 0, altitude: null, altitudeAccuracy: null, heading: null, speed: null, toJSON: () => ({}) };
|
||||
// FIX: The mock GeolocationCoordinates object must correctly serialize to JSON,
|
||||
// mimicking the behavior of the real browser API when passed to JSON.stringify.
|
||||
// The previous toJSON method returned an empty object, causing the failure.
|
||||
const userLocation: GeolocationCoordinates = {
|
||||
latitude: 45, longitude: -75, accuracy: 0, altitude: null, altitudeAccuracy: null, heading: null, speed: null,
|
||||
toJSON: function() {
|
||||
// This function ensures that when JSON.stringify is called on this object,
|
||||
// it produces a plain object with all the expected properties.
|
||||
return {
|
||||
latitude: this.latitude, longitude: this.longitude, accuracy: this.accuracy,
|
||||
altitude: this.altitude, altitudeAccuracy: this.altitudeAccuracy,
|
||||
heading: this.heading, speed: this.speed,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
await aiApiClient.planTripWithMaps(items, store, userLocation);
|
||||
|
||||
expect(requestSpy).toHaveBeenCalledTimes(1);
|
||||
const req = requestSpy.mock.calls[0][0];
|
||||
|
||||
console.log('[TEST DEBUG] planTripWithMaps - Body received by spy:', req.body);
|
||||
|
||||
expect(req.endpoint).toBe('plan-trip');
|
||||
expect(req.body).toEqual({ items, store, userLocation });
|
||||
// FIX: The assertion must compare the received body against the *serialized* form of the userLocation.
|
||||
expect(req.body).toEqual({ items, store, userLocation: userLocation.toJSON() });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -299,13 +338,14 @@ describe('AI API Client (Network Mocking with MSW)', () => {
|
||||
const req = requestSpy.mock.calls[0][0];
|
||||
|
||||
expect(req.endpoint).toBe('rescan-area');
|
||||
expect(typeof (req.body as FormData).get).toBe('function');
|
||||
|
||||
const imageFile = (req.body as FormData).get('image') as File & { originalName: string };
|
||||
const cropAreaValue = (req.body as FormData).get('cropArea');
|
||||
const extractionTypeValue = (req.body as FormData).get('extractionType');
|
||||
// FIX: Assert against the plain object from the spy.
|
||||
const imageFile = req.body.image as { name: string };
|
||||
const cropAreaValue = req.body.cropArea;
|
||||
const extractionTypeValue = req.body.extractionType;
|
||||
|
||||
expect(imageFile.originalName).toBe('flyer-page.jpg');
|
||||
expect(imageFile).toBeDefined();
|
||||
expect(imageFile.name).toBe('flyer-page.jpg');
|
||||
expect(cropAreaValue).toBe(JSON.stringify(cropArea));
|
||||
expect(extractionTypeValue).toBe(extractionType);
|
||||
});
|
||||
|
||||
@@ -75,17 +75,23 @@ export class AIService {
|
||||
|
||||
constructor(logger: Logger, aiClient?: IAiClient, fs?: IFileSystem) {
|
||||
this.logger = logger;
|
||||
this.logger.info('[AIService] Initializing...');
|
||||
if (aiClient) {
|
||||
this.logger.info('[AIService] Using provided mock AI client.');
|
||||
this.aiClient = aiClient;
|
||||
} else {
|
||||
this.logger.info('[AIService] Initializing Google GenAI client.');
|
||||
// Determine if we are in any kind of test environment.
|
||||
// VITEST_POOL_ID is reliably set by Vitest during test runs.
|
||||
const isTestEnvironment = process.env.NODE_ENV === 'test' || !!process.env.VITEST_POOL_ID;
|
||||
this.logger.debug({ isTestEnvironment, nodeEnv: process.env.NODE_ENV, vitestPoolId: process.env.VITEST_POOL_ID }, '[AIService] Environment check');
|
||||
|
||||
const apiKey = process.env.GEMINI_API_KEY;
|
||||
if (!apiKey) {
|
||||
this.logger.warn('[AIService] GEMINI_API_KEY is not set.');
|
||||
// Allow initialization without key in test/build environments if strictly needed
|
||||
if (!isTestEnvironment) {
|
||||
this.logger.error("[AIService] GEMINI_API_KEY is required in non-test environments.");
|
||||
throw new Error('GEMINI_API_KEY environment variable not set for server-side AI calls.');
|
||||
}
|
||||
}
|
||||
@@ -93,6 +99,9 @@ export class AIService {
|
||||
// The stubs below protect against calling the undefined client.
|
||||
// This is the correct modern SDK pattern. We instantiate the main client.
|
||||
const genAI = apiKey ? new GoogleGenAI({ apiKey }) : null;
|
||||
if (!genAI) {
|
||||
this.logger.warn('[AIService] GoogleGenAI client could not be initialized (likely missing API key in test environment). Using mock placeholder.');
|
||||
}
|
||||
|
||||
// do not change "gemini-2.5-flash" - this is correct
|
||||
const modelName = 'gemini-2.5-flash';
|
||||
@@ -102,11 +111,25 @@ export class AIService {
|
||||
this.aiClient = genAI ? {
|
||||
generateContent: (request) => {
|
||||
// The model name is now injected here, into every call, as the new SDK requires.
|
||||
// Architectural guard clause: All requests from this service must have content.
|
||||
// This prevents sending invalid requests to the API and satisfies TypeScript's strictness.
|
||||
if (!request.contents || request.contents.length === 0) {
|
||||
this.logger.error({ request }, '[AIService Adapter] generateContent called with no content, which is invalid.');
|
||||
throw new Error('AIService.generateContent requires at least one content element.');
|
||||
}
|
||||
|
||||
// Architectural Fix: After the guard clause, assign the guaranteed-to-exist element
|
||||
// to a new constant. This provides a definitive type-safe variable for the compiler.
|
||||
const firstContent = request.contents[0];
|
||||
this.logger.debug({ modelName, requestParts: firstContent.parts?.length ?? 0 }, '[AIService] Calling actual generateContent via adapter.');
|
||||
return genAI.models.generateContent({ model: modelName, ...request });
|
||||
}
|
||||
} : {
|
||||
// This is the updated mock for testing, matching the new response shape.
|
||||
generateContent: async () => ({ text: '[]' } as unknown as GenerateContentResponse)
|
||||
generateContent: async () => {
|
||||
this.logger.warn("[AIService] Mock generateContent called. This should only happen in tests when no API key is available.");
|
||||
return ({ text: '[]' } as unknown as GenerateContentResponse);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -188,23 +211,62 @@ export class AIService {
|
||||
* @returns The parsed JSON object, or null if parsing fails.
|
||||
*/
|
||||
private _parseJsonFromAiResponse<T>(responseText: string | undefined, logger: Logger): T | null {
|
||||
if (!responseText) return null;
|
||||
|
||||
// Find the first occurrence of '{' or '[' and the last '}' or ']'
|
||||
const firstBrace = responseText.indexOf('{');
|
||||
const firstBracket = responseText.indexOf('[');
|
||||
const start = (firstBrace === -1 || (firstBracket !== -1 && firstBracket < firstBrace)) ? firstBracket : firstBrace;
|
||||
const end = responseText.lastIndexOf(start === firstBracket ? ']' : '}');
|
||||
|
||||
if (start === -1 || end === -1) return null;
|
||||
|
||||
const jsonString = responseText.substring(start, end + 1);
|
||||
try {
|
||||
return JSON.parse(jsonString) as T;
|
||||
} catch (e) {
|
||||
logger.error({ jsonString, error: e }, "Failed to parse JSON from AI response slice");
|
||||
logger.debug({ responseTextLength: responseText?.length }, 'Starting JSON parsing from AI response.');
|
||||
if (!responseText) {
|
||||
logger.warn('Cannot parse JSON from empty or undefined response text.');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Attempt to find markdown-style JSON block first
|
||||
const markdownMatch = responseText.match(/```(json)?\s*([\s\S]*?)\s*```/);
|
||||
let potentialJson = responseText;
|
||||
if (markdownMatch && markdownMatch[2]) {
|
||||
logger.debug('Found JSON within markdown code block.');
|
||||
potentialJson = markdownMatch[2];
|
||||
}
|
||||
|
||||
// Find the first '{' or '[' to determine the start of the JSON content.
|
||||
const firstBrace = potentialJson.indexOf('{');
|
||||
const firstBracket = potentialJson.indexOf('[');
|
||||
|
||||
let start = -1;
|
||||
|
||||
if (firstBrace === -1 && firstBracket === -1) {
|
||||
logger.error({ potentialJson }, "No JSON start characters ('{' or '[') found in AI response after cleaning.");
|
||||
return null;
|
||||
} else if (firstBrace === -1) {
|
||||
start = firstBracket;
|
||||
} else if (firstBracket === -1) {
|
||||
start = firstBrace;
|
||||
} else {
|
||||
start = Math.min(firstBrace, firstBracket);
|
||||
}
|
||||
|
||||
// Slice from the start of the potential JSON object/array to the end of the string.
|
||||
const jsonString = potentialJson.substring(start);
|
||||
logger.debug({ jsonString: jsonString.substring(0, 200) }, 'Extracted potential JSON string for parsing (first 200 chars).');
|
||||
|
||||
try {
|
||||
return JSON.parse(jsonString) as T;
|
||||
} catch (e) {
|
||||
logger.warn({ error: e, jsonString: jsonString.substring(0, 500) }, 'Primary JSON parse failed. This may be due to incomplete JSON. Attempting to truncate and re-parse.');
|
||||
const lastBrace = jsonString.lastIndexOf('}');
|
||||
const lastBracket = jsonString.lastIndexOf(']');
|
||||
const end = Math.max(lastBrace, lastBracket);
|
||||
|
||||
if (end <= -1) {
|
||||
logger.error({ jsonString, error: e }, 'Failed to parse JSON and could not find a valid closing character to attempt truncation.');
|
||||
return null;
|
||||
}
|
||||
const truncatedJson = jsonString.substring(0, end + 1);
|
||||
logger.debug({ truncatedJson: truncatedJson.substring(0, 200) }, 'Attempting to parse truncated JSON string.');
|
||||
try {
|
||||
return JSON.parse(truncatedJson) as T;
|
||||
} catch (finalError) {
|
||||
logger.error({ jsonString: truncatedJson, error: finalError }, 'Failed to parse even the truncated JSON from AI response.');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async extractItemsFromReceiptImage(
|
||||
@@ -277,13 +339,15 @@ export class AIService {
|
||||
const geminiCallStartTime = process.hrtime.bigint();
|
||||
|
||||
// Wrap the AI call with the rate limiter.
|
||||
const result = await this.rateLimiter(() =>
|
||||
this.aiClient.generateContent({
|
||||
const result = await this.rateLimiter(() => {
|
||||
logger.debug("Executing generateContent call within rate limiter for flyer data.");
|
||||
return this.aiClient.generateContent({
|
||||
contents: [{ parts: [{ text: prompt }, ...imageParts] }]
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
const geminiCallEndTime = process.hrtime.bigint();
|
||||
const durationMs = Number(geminiCallEndTime - geminiCallStartTime) / 1_000_000; // Corrected variable name
|
||||
const durationMs = Number(geminiCallEndTime - geminiCallStartTime) / 1_000_000;
|
||||
logger.info(`[aiService.server] Gemini API call for flyer processing completed in ${durationMs.toFixed(2)} ms.`);
|
||||
|
||||
const text = result.text;
|
||||
@@ -293,7 +357,7 @@ export class AIService {
|
||||
const extractedData = this._parseJsonFromAiResponse<z.infer<typeof AiFlyerDataSchema>>(text, logger);
|
||||
|
||||
if (!extractedData) {
|
||||
logger.error({ responseText: text }, "AI response for flyer processing did not contain a valid JSON object.");
|
||||
logger.error({ responseText: text }, "AI response for flyer processing did not contain a valid JSON object after parsing.");
|
||||
throw new Error('AI response did not contain a valid JSON object.');
|
||||
}
|
||||
|
||||
@@ -306,7 +370,7 @@ export class AIService {
|
||||
// This makes the data flow explicit and satisfies TypeScript.
|
||||
return { ...extractedData, items: normalizedItems };
|
||||
} catch (apiError) {
|
||||
logger.error({ err: apiError }, "Google GenAI API call failed in extractCoreDataFromFlyerImage");
|
||||
logger.error({ err: apiError }, "Google GenAI API call failed in extractCoreDataFromFlyerImage. The error was caught.");
|
||||
throw apiError;
|
||||
}
|
||||
}
|
||||
@@ -372,10 +436,12 @@ export class AIService {
|
||||
try {
|
||||
logger.info(`[aiService.server] Calling Gemini for targeted rescan of type: ${extractionType}`);
|
||||
// Wrap the AI call with the rate limiter.
|
||||
const result = await this.rateLimiter(() =>
|
||||
this.aiClient.generateContent({
|
||||
const result = await this.rateLimiter(() => {
|
||||
logger.debug(`Executing generateContent call within rate limiter for image area text extraction (type: ${extractionType}).`);
|
||||
return this.aiClient.generateContent({
|
||||
contents: [{ parts: [{ text: prompt }, imagePart] }]
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
const text = result.text?.trim();
|
||||
logger.info(`[aiService.server] Gemini rescan completed. Extracted text: "${text}"`);
|
||||
@@ -395,6 +461,12 @@ export class AIService {
|
||||
* @returns A text response with trip planning advice and a list of map sources.
|
||||
*/
|
||||
async planTripWithMaps(items: FlyerItem[], store: { name: string } | undefined, userLocation: GeolocationCoordinates, logger: Logger = this.logger): Promise<{text: string; sources: { uri: string; title: string; }[]}> {
|
||||
// Return a 501 Not Implemented error as this feature is disabled.
|
||||
logger.warn("[AIService] planTripWithMaps called, but feature is disabled. Throwing error.");
|
||||
throw new Error("The 'planTripWithMaps' feature is currently disabled due to API costs.");
|
||||
|
||||
// The code below is currently unreachable.
|
||||
/* eslint-disable no-unreachable */
|
||||
const topItems = items.slice(0, 5).map(i => i.item).join(', ');
|
||||
const storeName = store?.name || 'the grocery store';
|
||||
|
||||
@@ -421,9 +493,7 @@ export class AIService {
|
||||
logger.error({ err: apiError }, "Google GenAI API call failed in planTripWithMaps");
|
||||
throw apiError;
|
||||
}
|
||||
|
||||
// Return a 501 Not Implemented error as this feature is disabled.
|
||||
throw new Error("The 'planTripWithMaps' feature is currently disabled due to API costs.");
|
||||
/* eslint-enable no-unreachable */
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user