one moar time - we can do it?
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 32m32s

This commit is contained in:
2025-12-17 04:49:01 -08:00
parent 1d18646818
commit d3ad50cde6
12 changed files with 482 additions and 235 deletions

View File

@@ -1,10 +1,10 @@
// src/components/FlyerCorrectionTool.test.tsx
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, type Mocked } from 'vitest';
import { FlyerCorrectionTool } from './FlyerCorrectionTool';
import * as aiApiClient from '../services/aiApiClient';
import { notifySuccess } from '../services/notificationService';
import { notifyError, notifySuccess } from '../services/notificationService';
// Mock dependencies
vi.mock('../services/aiApiClient');
@@ -90,41 +90,60 @@ describe('FlyerCorrectionTool', () => {
});
it('should call rescanImageArea with correct parameters and show success', async () => {
// Mock the response with a slight delay. This ensures the "Processing..."
// state has time to render before the mock resolves, making the test more stable.
mockedAiApiClient.rescanImageArea.mockImplementation(async () => {
await new Promise(r => setTimeout(r, 50)); // 50ms delay
return new Response(JSON.stringify({ text: 'Super Store' }));
console.log('\n--- [TEST LOG] ---: Starting test: "should call rescanImageArea..."');
// 1. Create a controllable promise for the mock.
console.log('--- [TEST LOG] ---: 1. Setting up controllable promise for rescanImageArea.');
let resolveRescanPromise: (value: Response | PromiseLike<Response>) => void;
const rescanPromise = new Promise<Response>(resolve => {
resolveRescanPromise = resolve;
});
mockedAiApiClient.rescanImageArea.mockReturnValue(rescanPromise);
render(<FlyerCorrectionTool {...defaultProps} />);
// Wait for the image fetch to complete to ensure 'imageFile' state is populated
console.log('--- [TEST LOG] ---: Awaiting image fetch inside component...');
await waitFor(() => expect(global.fetch).toHaveBeenCalledWith(defaultProps.imageUrl));
console.log('--- [TEST LOG] ---: Image fetch complete.');
const canvas = screen.getByRole('dialog').querySelector('canvas')!;
const image = screen.getByAltText('Flyer for correction');
// Mock image dimensions for coordinate scaling
console.log('--- [TEST LOG] ---: Mocking image dimensions.');
Object.defineProperty(image, 'naturalWidth', { value: 1000, configurable: true });
Object.defineProperty(image, 'naturalHeight', { value: 800, configurable: true });
Object.defineProperty(image, 'clientWidth', { value: 500, configurable: true });
Object.defineProperty(image, 'clientHeight', { value: 400, configurable: true });
// Simulate drawing a rectangle
console.log('--- [TEST LOG] ---: Simulating user drawing a rectangle...');
fireEvent.mouseDown(canvas, { clientX: 10, clientY: 10 });
fireEvent.mouseMove(canvas, { clientX: 60, clientY: 30 });
fireEvent.mouseUp(canvas);
console.log('--- [TEST LOG] ---: Rectangle drawn.');
// Click the extract button
// 2. Click the extract button, which will trigger the pending promise.
console.log('--- [TEST LOG] ---: 2. Clicking "Extract Store Name" button.');
fireEvent.click(screen.getByRole('button', { name: /extract store name/i }));
// Check for loading state - this should now pass because of the delay
expect(await screen.findByText('Processing...')).toBeInTheDocument();
// 3. Assert the loading state.
try {
console.log('--- [TEST LOG] ---: 3. Awaiting "Processing..." loading state.');
expect(await screen.findByText('Processing...')).toBeInTheDocument();
console.log('--- [TEST LOG] ---: 3a. SUCCESS: Found "Processing..." text.');
} catch (error) {
console.error('--- [TEST LOG] ---: 3a. ERROR: Did not find "Processing..." text.');
screen.debug();
throw error;
}
// 4. Check that the API was called with correctly scaled coordinates.
console.log('--- [TEST LOG] ---: 4. Awaiting API call verification...');
await waitFor(() => {
console.log('--- [TEST LOG] ---: 4a. waitFor check: Checking rescanImageArea call...');
expect(mockedAiApiClient.rescanImageArea).toHaveBeenCalledTimes(1);
// Check that coordinates were scaled correctly (e.g., 500 -> 1000 is a 2x scale)
expect(mockedAiApiClient.rescanImageArea).toHaveBeenCalledWith(
expect.any(File),
// 10*2=20, 10*2=20, (60-10)*2=100, (30-10)*2=40
@@ -132,11 +151,24 @@ describe('FlyerCorrectionTool', () => {
'store_name'
);
});
console.log('--- [TEST LOG] ---: 4b. SUCCESS: API call verified.');
// 5. Resolve the promise.
console.log('--- [TEST LOG] ---: 5. Manually resolving the API promise inside act()...');
await act(async () => {
console.log('--- [TEST LOG] ---: 5a. Calling resolveRescanPromise...');
resolveRescanPromise(new Response(JSON.stringify({ text: 'Super Store' })));
});
console.log('--- [TEST LOG] ---: 5b. Promise resolved and act() block finished.');
// 6. Assert the final state after the promise has resolved.
console.log('--- [TEST LOG] ---: 6. Awaiting final state assertions...');
await waitFor(() => {
console.log('--- [TEST LOG] ---: 6a. waitFor check: Verifying notifications and callbacks...');
expect(mockedNotifySuccess).toHaveBeenCalledWith('Extracted: Super Store');
expect(defaultProps.onDataExtracted).toHaveBeenCalledWith('store_name', 'Super Store');
expect(defaultProps.onClose).toHaveBeenCalledTimes(1);
});
console.log('--- [TEST LOG] ---: 6b. SUCCESS: Final state verified.');
});
});

View File

@@ -29,14 +29,17 @@ export const FlyerCorrectionTool: React.FC<FlyerCorrectionToolProps> = ({ isOpen
// Fetch the image and store it as a File object for API submission
useEffect(() => {
if (isOpen && imageUrl) {
console.debug('[DEBUG] FlyerCorrectionTool: isOpen is true, fetching image URL:', imageUrl);
fetch(imageUrl)
.then(res => res.blob())
.then(blob => {
const file = new File([blob], 'flyer-image.jpg', { type: blob.type });
setImageFile(file);
console.debug('[DEBUG] FlyerCorrectionTool: Image fetched and stored as File object.');
})
.catch(err => {
logger.error('Failed to fetch image for correction tool', { err });
console.error('[DEBUG] FlyerCorrectionTool: Failed to fetch image.', { err });
logger.error('Failed to fetch image for correction tool', { error: err });
notifyError('Could not load the image for correction.');
});
}
@@ -102,6 +105,7 @@ export const FlyerCorrectionTool: React.FC<FlyerCorrectionToolProps> = ({ isOpen
const handleMouseUp = () => {
setIsDrawing(false);
setStartPoint(null);
console.debug('[DEBUG] FlyerCorrectionTool: Mouse Up - selection complete.', { selectionRect });
};
const handleRescan = async (type: ExtractionType) => {
@@ -110,6 +114,7 @@ export const FlyerCorrectionTool: React.FC<FlyerCorrectionToolProps> = ({ isOpen
return;
}
console.debug(`[DEBUG] handleRescan: Starting for type "${type}". Setting isProcessing=true.`);
setIsProcessing(true);
try {
// Scale selection coordinates to the original image dimensions
@@ -123,28 +128,35 @@ export const FlyerCorrectionTool: React.FC<FlyerCorrectionToolProps> = ({ isOpen
width: selectionRect.width * scaleX,
height: selectionRect.height * scaleY,
};
console.debug('[DEBUG] handleRescan: Calculated scaled cropArea:', cropArea);
console.debug('[DEBUG] handleRescan: Awaiting aiApiClient.rescanImageArea...');
const response = await aiApiClient.rescanImageArea(imageFile, cropArea, type);
console.debug('[DEBUG] handleRescan: API call returned. Response ok:', response.ok);
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || 'Failed to rescan area.');
}
const { text } = await response.json();
console.debug('[DEBUG] handleRescan: Successfully extracted text:', text);
notifySuccess(`Extracted: ${text}`);
onDataExtracted(type, text);
onClose(); // Close modal on success
} catch (err) {
const msg = err instanceof Error ? err.message : 'An unknown error occurred.';
console.error('[DEBUG] handleRescan: Caught an error.', { error: err });
notifyError(msg);
logger.error('Error during rescan:', { err });
logger.error('Error during rescan:', { error: err });
} finally {
console.debug('[DEBUG] handleRescan: Finished. Setting isProcessing=false.');
setIsProcessing(false);
}
};
if (!isOpen) return null;
console.debug('[DEBUG] FlyerCorrectionTool: Rendering with state:', { isProcessing, hasSelection: !!selectionRect });
return (
<div className="fixed inset-0 bg-black bg-opacity-75 z-50 flex justify-center items-center p-4" onClick={onClose}>
<div role="dialog" className="relative bg-gray-800 rounded-lg shadow-xl w-full max-w-6xl h-[90vh] flex flex-col" onClick={e => e.stopPropagation()}>

View File

@@ -5,19 +5,11 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import { useAiAnalysis } from './useAiAnalysis';
import { useApi } from './useApi';
import { AnalysisType } from '../types';
import { logger } from '../services/logger.client';
import type { Flyer, FlyerItem, MasterGroceryItem } from '../types'; // Removed ApiProvider import
import { ApiProvider } from '../providers/ApiProvider'; // Updated import path for ApiProvider
// 1. Mock dependencies
vi.mock('./useApi');
vi.mock('../services/logger.client', () => ({
logger: {
info: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
},
}));
const mockedUseApi = vi.mocked(useApi);
@@ -55,7 +47,7 @@ describe('useAiAnalysis Hook', () => {
};
beforeEach(() => {
logger.info('--- NEW TEST RUN ---');
console.log('--- NEW TEST RUN ---');
vi.clearAllMocks();
// Set a default return value for any call to useApi.
@@ -93,10 +85,10 @@ describe('useAiAnalysis Hook', () => {
});
it('should initialize with correct default states', () => {
logger.info('TEST: should initialize with correct default states');
console.log('TEST: should initialize with correct default states');
const { result } = renderHook(() => useAiAnalysis(defaultParams), { wrapper });
logger.info('Asserting initial state...');
console.log('Asserting initial state...');
expect(result.current.results).toEqual({});
expect(result.current.sources).toEqual({});
expect(result.current.loadingStates).toEqual({
@@ -109,68 +101,68 @@ describe('useAiAnalysis Hook', () => {
expect(result.current.error).toBeNull();
expect(result.current.generatedImageUrl).toBeNull();
expect(result.current.isGeneratingImage).toBe(false);
logger.info('Initial state assertions passed.');
console.log('Initial state assertions passed.');
});
describe('runAnalysis', () => {
it('should call the correct execute function for QUICK_INSIGHTS', async () => {
logger.info('TEST: should call execute for QUICK_INSIGHTS');
console.log('TEST: should call execute for QUICK_INSIGHTS');
mockGetQuickInsights.execute.mockResolvedValue('Quick insights text');
const { result } = renderHook(() => useAiAnalysis(defaultParams), { wrapper });
logger.info('Act: Running analysis for QUICK_INSIGHTS...');
console.log('Act: Running analysis for QUICK_INSIGHTS...');
await act(async () => {
await result.current.runAnalysis(AnalysisType.QUICK_INSIGHTS);
});
logger.info('Assert: Checking if getQuickInsights.execute was called correctly.');
console.log('Assert: Checking if getQuickInsights.execute was called correctly.');
expect(mockGetQuickInsights.execute).toHaveBeenCalledWith(mockFlyerItems);
});
it('should update results when quickInsightsData changes', () => {
logger.info('TEST: should update results when quickInsightsData changes');
console.log('TEST: should update results when quickInsightsData changes');
const { result, rerender } = renderHook(() => useAiAnalysis(defaultParams), { wrapper });
logger.info('Arrange: Simulating useApi returning new data for QUICK_INSIGHTS.');
console.log('Arrange: Simulating useApi returning new data for QUICK_INSIGHTS.');
// Simulate useApi returning new data by re-rendering with a new mock value
mockedUseApi.mockReset()
.mockReturnValueOnce({ ...mockGetQuickInsights, data: 'New insights', reset: vi.fn() })
.mockReturnValue(mockGetDeepDive); // provide defaults for others
logger.info('Act: Re-rendering hook to simulate data update.');
console.log('Act: Re-rendering hook to simulate data update.');
rerender();
logger.info('Assert: Checking if results state was updated.');
console.log('Assert: Checking if results state was updated.');
expect(result.current.results[AnalysisType.QUICK_INSIGHTS]).toBe('New insights');
});
it('should call the correct execute function for DEEP_DIVE', async () => {
logger.info('TEST: should call execute for DEEP_DIVE');
console.log('TEST: should call execute for DEEP_DIVE');
mockGetDeepDive.execute.mockResolvedValue('Deep dive text');
const { result } = renderHook(() => useAiAnalysis(defaultParams), { wrapper });
logger.info('Act: Running analysis for DEEP_DIVE...');
console.log('Act: Running analysis for DEEP_DIVE...');
await act(async () => {
await result.current.runAnalysis(AnalysisType.DEEP_DIVE);
});
logger.info('Assert: Checking if getDeepDive.execute was called correctly.');
console.log('Assert: Checking if getDeepDive.execute was called correctly.');
expect(mockGetDeepDive.execute).toHaveBeenCalledWith(mockFlyerItems);
});
it('should update results and sources when webSearchData changes', () => {
logger.info('TEST: should update results and sources for WEB_SEARCH');
console.log('TEST: should update results and sources for WEB_SEARCH');
const mockResponse = { text: 'Web search text', sources: [{ web: { uri: 'http://a.com', title: 'Source A' } }] };
const { result, rerender } = renderHook(() => useAiAnalysis(defaultParams), { wrapper });
logger.info('Arrange: Simulating useApi returning new data for WEB_SEARCH.');
console.log('Arrange: Simulating useApi returning new data for WEB_SEARCH.');
// Prepare mocks for the re-render. We must provide the full sequence of 6 calls.
mockedUseApi.mockReset();
// Set default fallback first
mockedUseApi.mockReturnValue({ execute: vi.fn(), data: null, loading: false, error: null, isRefetching: false, reset: vi.fn() });
logger.info('Arrange: Setting up specific mock sequence for rerender.');
console.log('Arrange: Setting up specific mock sequence for rerender.');
// Override specific sequence for re-render
mockedUseApi
.mockReturnValueOnce(mockGetQuickInsights)
@@ -180,54 +172,54 @@ describe('useAiAnalysis Hook', () => {
.mockReturnValueOnce(mockComparePrices)
.mockReturnValueOnce(mockGenerateImage);
logger.info('Act: Re-rendering hook to simulate data update.');
console.log('Act: Re-rendering hook to simulate data update.');
rerender();
logger.info('Assert: Checking if results and sources state were updated for WEB_SEARCH.');
console.log('Assert: Checking if results and sources state were updated for WEB_SEARCH.');
expect(result.current.results[AnalysisType.WEB_SEARCH]).toBe('Web search text');
expect(result.current.sources[AnalysisType.WEB_SEARCH]).toEqual([{ uri: 'http://a.com', title: 'Source A' }]);
});
it('should call the correct execute function for COMPARE_PRICES', async () => {
logger.info('TEST: should call execute for COMPARE_PRICES');
console.log('TEST: should call execute for COMPARE_PRICES');
mockComparePrices.execute.mockResolvedValue({ text: 'Price comparison text', sources: [] }); // This was a duplicate, fixed.
const { result } = renderHook(() => useAiAnalysis(defaultParams), { wrapper });
logger.info('Act: Running analysis for COMPARE_PRICES...');
console.log('Act: Running analysis for COMPARE_PRICES...');
await act(async () => {
await result.current.runAnalysis(AnalysisType.COMPARE_PRICES);
});
logger.info('Assert: Checking if comparePrices.execute was called correctly.');
console.log('Assert: Checking if comparePrices.execute was called correctly.');
expect(mockComparePrices.execute).toHaveBeenCalledWith(mockWatchedItems);
});
it('should call the correct execute function for PLAN_TRIP with geolocation', async () => {
logger.info('TEST: should call execute for PLAN_TRIP with geolocation');
console.log('TEST: should call execute for PLAN_TRIP with geolocation');
mockPlanTrip.execute.mockResolvedValue({ text: 'Trip plan text', sources: [{ uri: 'http://maps.com', title: 'Map' }] });
const { result } = renderHook(() => useAiAnalysis(defaultParams), { wrapper });
logger.info('Act: Running analysis for PLAN_TRIP...');
console.log('Act: Running analysis for PLAN_TRIP...');
await act(async () => {
await result.current.runAnalysis(AnalysisType.PLAN_TRIP);
});
logger.info('Assert: Checking if geolocation and planTrip.execute were called correctly.');
console.log('Assert: Checking if geolocation and planTrip.execute were called correctly.');
expect(navigator.geolocation.getCurrentPosition).toHaveBeenCalled();
expect(mockPlanTrip.execute).toHaveBeenCalledWith(
mockFlyerItems,
mockSelectedFlyer.store,
{ latitude: 50, longitude: 50 }
);
logger.info('PLAN_TRIP assertions passed.');
console.log('PLAN_TRIP assertions passed.');
});
it('should derive a generic error message if an API call fails', () => {
logger.info('TEST: should derive a generic error message on API failure');
console.log('TEST: should derive a generic error message on API failure');
const apiError = new Error('API is down');
// Simulate useApi returning an error
logger.info('Arrange: Simulating useApi returning an error for QUICK_INSIGHTS.');
console.log('Arrange: Simulating useApi returning an error for QUICK_INSIGHTS.');
// Reset and provide full sequence
mockedUseApi.mockReset();
mockedUseApi.mockReturnValue({ execute: vi.fn(), data: null, loading: false, error: null, isRefetching: false, reset: vi.fn() });
@@ -242,33 +234,33 @@ describe('useAiAnalysis Hook', () => {
const { result } = renderHook(() => useAiAnalysis(defaultParams), { wrapper });
logger.info('Assert: Checking if the error state is correctly populated.');
console.log('Assert: Checking if the error state is correctly populated.');
expect(result.current.error).toBe('API is down');
logger.info('Error state assertion passed.');
console.log('Error state assertion passed.');
});
it('should log an error for geolocation permission denial', async () => {
logger.info('TEST: should handle geolocation permission denial');
console.log('TEST: should handle geolocation permission denial');
const geoError = new GeolocationPositionError();
Object.defineProperty(geoError, 'code', { value: GeolocationPositionError.PERMISSION_DENIED });
logger.info('Arrange: Mocking navigator.geolocation.getCurrentPosition to call the error callback.');
console.log('Arrange: Mocking navigator.geolocation.getCurrentPosition to call the error callback.');
vi.mocked(navigator.geolocation.getCurrentPosition).mockImplementation((success, error) => {
if (error) error(geoError);
});
logger.info('Arrange: Mocking planTrip.execute to reject, simulating a failure caught by useApi.');
console.log('Arrange: Mocking planTrip.execute to reject, simulating a failure caught by useApi.');
// The execute function will reject, and useApi will set the error state
const rejectionError = new Error("Geolocation permission denied.");
mockPlanTrip.execute.mockRejectedValue(rejectionError);
const { result } = renderHook(() => useAiAnalysis(defaultParams), { wrapper });
logger.info('Act: Running analysis for PLAN_TRIP, which is expected to fail.');
console.log('Act: Running analysis for PLAN_TRIP, which is expected to fail.');
await act(async () => {
await result.current.runAnalysis(AnalysisType.PLAN_TRIP);
});
logger.info('Assert: Checking if the internal error state reflects the geolocation failure.');
console.log('Assert: Checking if the internal error state reflects the geolocation failure.');
// The test now verifies that the error from the failed execute call is propagated.
// The specific user-friendly message is now part of the component that consumes the hook.
expect(result.current.error).toBe(rejectionError.message);
@@ -277,25 +269,24 @@ describe('useAiAnalysis Hook', () => {
describe('generateImage', () => {
it('should not run if there are no DEEP_DIVE results', async () => {
logger.info('TEST: should not run generateImage if DEEP_DIVE results are missing');
console.log('TEST: should not run generateImage if DEEP_DIVE results are missing');
const { result } = renderHook(() => useAiAnalysis(defaultParams), { wrapper });
logger.info('Act: Calling generateImage while results are empty.');
console.log('Act: Calling generateImage while results are empty.');
await act(async () => {
await result.current.generateImage();
});
logger.info('Assert: Checking that the logger was warned and the API was not called.');
console.log('Assert: Checking that the API was not called.');
expect(mockGenerateImage.execute).not.toHaveBeenCalled();
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.');
console.log('Assertion passed for no-op generateImage call.');
});
it('should call the API and set the image URL on success', async () => {
logger.info('TEST: should call generateImage API and update URL on success');
console.log('TEST: should call generateImage API and update URL on success');
const { result, rerender } = renderHook(() => useAiAnalysis(defaultParams), { wrapper });
logger.info('Step 1 (Arrange): Simulating DEEP_DIVE results being present via rerender.');
console.log('Step 1 (Arrange): Simulating DEEP_DIVE results being present via rerender.');
mockedUseApi.mockReset();
mockedUseApi.mockReturnValue({ execute: vi.fn(), data: null, loading: false, error: null, isRefetching: false, reset: vi.fn() });
mockedUseApi
@@ -307,21 +298,21 @@ describe('useAiAnalysis Hook', () => {
.mockReturnValueOnce(mockGenerateImage);
rerender();
logger.info("Step 2 (Sync): Waiting for the hook's internal state to update after receiving new data from re-render...");
console.log("Step 2 (Sync): Waiting for the hook's internal state to update after receiving new data from re-render...");
await waitFor(() => {
expect(result.current.results[AnalysisType.DEEP_DIVE]).toBe('A great meal plan');
});
logger.info('Step 2 (Sync): State successfully updated.');
console.log('Step 2 (Sync): State successfully updated.');
logger.info('Step 3 (Act): Calling `generateImage`, which should now have the correct state in its closure.');
console.log('Step 3 (Act): Calling `generateImage`, which should now have the correct state in its closure.');
await act(async () => {
await result.current.generateImage();
});
logger.info('Step 4 (Assert): Verifying the image generation API was called.');
console.log('Step 4 (Assert): Verifying the image generation API was called.');
expect(mockGenerateImage.execute).toHaveBeenCalledWith('A great meal plan');
logger.info('Step 5 (Arrange): Simulating `useApi` for image generation returning a successful result via rerender.');
console.log('Step 5 (Arrange): Simulating `useApi` for image generation returning a successful result via rerender.');
mockedUseApi.mockReset();
mockedUseApi.mockReturnValue({ execute: vi.fn(), data: null, loading: false, error: null, isRefetching: false, reset: vi.fn() });
mockedUseApi
@@ -333,18 +324,18 @@ describe('useAiAnalysis Hook', () => {
.mockReturnValueOnce({ ...mockGenerateImage, data: 'base64string' });
rerender();
logger.info('Step 6 (Sync): Waiting for the generatedImageUrl to be computed from the new data.');
console.log('Step 6 (Sync): Waiting for the generatedImageUrl to be computed from the new data.');
await waitFor(() => {
expect(result.current.generatedImageUrl).toBe('data:image/png;base64,base64string');
});
logger.info('Image URL assertion passed.');
console.log('Image URL assertion passed.');
});
it('should set an error if image generation fails', async () => {
logger.info('TEST: should set an error if image generation fails');
console.log('TEST: should set an error if image generation fails');
const { result, rerender } = renderHook(() => useAiAnalysis(defaultParams), { wrapper });
logger.info('Step 1 (Arrange): Re-render with deep dive data present so we can call generateImage.');
console.log('Step 1 (Arrange): Re-render with deep dive data present so we can call generateImage.');
mockedUseApi.mockReset();
mockedUseApi.mockReturnValue({ execute: vi.fn(), data: null, loading: false, error: null, isRefetching: false, reset: vi.fn() });
mockedUseApi
@@ -357,18 +348,18 @@ describe('useAiAnalysis Hook', () => {
rerender();
// 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...");
console.log("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.');
console.log('Step 2 (Sync): State successfully updated.');
logger.info('Step 3 (Act): Call generateImage, which should now have the correct state in its closure.');
console.log('Step 3 (Act): Call generateImage, which should now have the correct state in its closure.');
await act(async () => {
await result.current.generateImage();
});
logger.info('Step 4 (Arrange): Simulate the useApi hook re-rendering our component with an error state.');
console.log('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() });
@@ -381,9 +372,9 @@ describe('useAiAnalysis Hook', () => {
.mockReturnValueOnce({ ...mockGenerateImage, error: apiError, reset: vi.fn() }); // Image gen now has an error
rerender();
logger.info("Step 5 (Assert): The error from the useApi hook should now be exposed as the hook's primary error state.");
console.log("Step 5 (Assert): The error from the useApi hook should now be exposed as the hook's primary error state.");
expect(result.current.error).toBe('Image model failed');
logger.info('Error state assertion passed.');
console.log('Error state assertion passed.');
});
});
});

View File

@@ -8,35 +8,17 @@ import { useApiOnMount } from '../../../hooks/useApiOnMount';
export const AdminBrandManager: React.FC = () => {
// Wrap the fetcher function in useCallback to prevent it from being recreated on every render.
// This is crucial to prevent an infinite loop in the useApiOnMount hook, which
// likely has the fetcher function as a dependency in its own useEffect, causing re-fetches.
const fetchBrandsWrapper = useCallback(async () => {
// Log when the fetch wrapper is invoked.
console.log('AdminBrandManager: Invoking fetchBrandsWrapper.');
const response = await fetchAllBrands();
// Log the raw response to check status and headers.
console.log('AdminBrandManager: Received raw response from fetchAllBrands:', {
ok: response.ok,
status: response.status,
statusText: response.statusText,
});
if (!response.ok) {
const errorText = await response.text();
const errorMessage = errorText || `Request failed with status ${response.status}`;
// Log the specific error message before throwing.
console.error(`AdminBrandManager: API error fetching brands: ${errorMessage}`);
throw new Error(errorMessage);
}
// Log before parsing JSON to ensure the response is valid.
console.log('AdminBrandManager: Response is OK, attempting to parse JSON.');
const parsedData = await response.json();
console.log('AdminBrandManager: Successfully parsed JSON data:', parsedData);
return parsedData;
}, []); // Empty dependency array means the function is created only once.
// This is crucial to prevent the useApiOnMount hook from re-running on every render.
// The hook expects a function that returns a Promise<Response>, and it will handle
// the JSON parsing and error checking internally.
const fetchBrandsWrapper = useCallback(() => {
console.log('AdminBrandManager: The memoized fetchBrandsWrapper is being passed to useApiOnMount.');
// This wrapper simply calls the API client function. The hook will manage the promise.
return fetchAllBrands();
}, []); // An empty dependency array ensures this function is created only once.
const { data: initialBrands, loading, error } = useApiOnMount<Brand[], []>(fetchBrandsWrapper, []);
// Log the state from the useApiOnMount hook on every render to trace its lifecycle.
// Log the hook's state on each render to observe the data fetching lifecycle.
console.log('AdminBrandManager RENDER - Hook state:', { loading, error, initialBrands });
// We still need local state for brands so we can update it after a logo upload
@@ -44,12 +26,12 @@ export const AdminBrandManager: React.FC = () => {
const [brands, setBrands] = useState<Brand[]>([]);
useEffect(() => {
// This effect synchronizes the data fetched by the hook with the component's local state.
if (initialBrands) {
// This effect synchronizes the hook's data with the component's local state.
console.log('AdminBrandManager: useEffect for initialBrands triggered. initialBrands:', initialBrands);
console.log('AdminBrandManager: useEffect detected initialBrands. Syncing with local state.', initialBrands);
setBrands(initialBrands);
// Log when the local state is successfully updated.
console.log('AdminBrandManager: Local brands state updated with initial data.');
} else {
console.log('AdminBrandManager: useEffect ran, but initialBrands is null or undefined.');
}
}, [initialBrands]);
@@ -73,7 +55,7 @@ export const AdminBrandManager: React.FC = () => {
try {
const response = await uploadBrandLogo(brandId, file);
console.log('AdminBrandManager: Logo upload response:', {
console.log('AdminBrandManager: Logo upload response received.', {
ok: response.ok,
status: response.status,
statusText: response.statusText,
@@ -101,12 +83,12 @@ export const AdminBrandManager: React.FC = () => {
};
if (loading) {
console.log('AdminBrandManager: Rendering loading state.');
console.log('AdminBrandManager: Rendering the loading state.');
return <div className="text-center p-4">Loading brands...</div>;
}
if (error) {
console.error(`AdminBrandManager: Rendering error state: ${error.message}`);
console.error(`AdminBrandManager: Rendering the error state. Error: ${error.message}`);
return <ErrorDisplay message={`Failed to load brands: ${error.message}`} />;
}

View File

@@ -1,7 +1,6 @@
// src/pages/admin/components/ProfileManager.Authenticated.test.tsx
import React from 'react';
import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest';
import { ProfileManager } from './ProfileManager';
import * as apiClient from '../../../services/apiClient';
@@ -196,11 +195,17 @@ 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); // Find the submit button to call its onClick handler, which triggers the form submission.
const saveButton = screen.getByRole('button', { name: /save profile/i });
// --- FINAL DIAGNOSTIC STEP ---
// Log the disabled state of the button right before we attempt to submit.
// This is the most likely culprit.
console.log(`[TEST LOG] FINAL CHECK: Is save button disabled? -> ${saveButton.hasAttribute('disabled')}`);
// --- END DIAGNOSTIC ---
console.log('[TEST LOG] Firing SUBMIT event on the form.');
// Reverting to the most semantically correct event for a form.
fireEvent.submit(screen.getByRole('form', { name: /profile form/i }));
console.log('[TEST LOG] Setting up userEvent...');
const user = userEvent.setup();
console.log('[TEST LOG] Using userEvent to click "Save Profile" button.');
await user.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.

View File

@@ -192,6 +192,10 @@ export const ProfileManager: React.FC<ProfileManagerProps> = ({ isOpen, onClose,
logger.debug('[handleProfileSave] Save process finished.');
};
// --- DEBUG LOGGING ---
// Log the loading states on every render to debug the submit button's disabled state.
logger.debug('[ComponentRender] Loading states:', { profileLoading, addressLoading });
const handleAddressChange = (field: keyof Address, value: string) => {
setAddress(prev => ({ ...prev, [field]: value }));
};
@@ -379,7 +383,7 @@ export const ProfileManager: React.FC<ProfileManagerProps> = ({ isOpen, onClose,
</div>
{activeTab === '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" />

View File

@@ -35,12 +35,17 @@ vi.mock('./apiClient', async (importOriginal) => {
// is FormData, the contained File objects are correctly processed by MSW's parsers,
// preserving their original filenames instead of defaulting to "blob".
return fetch(new Request(fullUrl, options));
// FIX: Manually construct a Request object. This is a critical step. When `fetch` is
// called directly with FormData, some environments (like JSDOM) can lose the filename.
// Wrapping it in `new Request()` helps preserve the metadata.
const request = new Request(fullUrl, options);
console.log(`[apiFetch MOCK] Created Request object for URL: ${request.url}. Content-Type will be set by browser/fetch.`);
return fetch(request);
},
// Add a mock for ApiOptions to satisfy the compiler
ApiOptions: vi.fn()
};
});
// 3. Setup MSW to capture requests
const requestSpy = vi.fn();
@@ -48,35 +53,38 @@ 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');
console.log(`\n--- [MSW HANDLER] Intercepted POST to '${String(params.endpoint)}'. Content-Type: ${contentType} ---`);
if (contentType?.includes('application/json')) {
const parsedBody = await request.json();
// Ensure the parsed body is an object before assigning, as request.json() can return primitives.
console.log('[MSW HANDLER] Body is JSON. Parsed:', parsedBody);
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();
// 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.
console.log('[MSW HANDLER] Body is FormData. Iterating entries...');
// FIX: The `instanceof File` check is unreliable in JSDOM.
// We will use "duck typing" to check if an object looks like a file.
for (const [key, value] of (body as FormData).entries()) {
if (value instanceof File) {
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.
// A robust check for a File-like object.
const isFile = typeof value === 'object' && value !== null && 'name' in value && 'size' in value && 'type' in value;
console.log(`[MSW HANDLER] FormData Entry -> Key: '${key}', Type: ${typeof value}, IsFile: ${isFile}`);
if (isFile) {
const file = value as File;
console.log(`[MSW HANDLER DEBUG] -> Identified as File. Name: '${file.name}', Size: ${file.size}, Type: '${file.type}'`);
if (!bodyForSpy[key]) {
bodyForSpy[key] = { name: value.name, size: value.size, type: value.type };
bodyForSpy[key] = { name: file.name, size: file.size, type: file.type };
}
} else {
console.log(`[MSW HANDLER DEBUG] Found text field. Key: '${key}', Value: '${String(value)}'`);
bodyForSpy[key] = value;
}
}
console.log('[MSW HANDLER] Finished processing FormData. Final object for spy:', bodyForSpy);
}
requestSpy({
@@ -113,13 +121,16 @@ describe('AI API Client (Network Mocking with MSW)', () => {
describe('uploadAndProcessFlyer', () => {
it('should construct FormData with file and checksum and send a POST request', async () => {
const mockFile = new File(['this is a test pdf'], 'flyer.pdf', { type: 'application/pdf' });
const mockFile = new File(['dummy-flyer-content'], 'flyer.pdf', { type: 'application/pdf' });
const checksum = 'checksum-abc-123';
console.log(`\n--- [TEST START] uploadAndProcessFlyer ---`);
console.log('[TEST ARRANGE] Created mock file:', { name: mockFile.name, size: mockFile.size, type: mockFile.type });
await aiApiClient.uploadAndProcessFlyer(mockFile, checksum);
expect(requestSpy).toHaveBeenCalledTimes(1);
const req = requestSpy.mock.calls[0][0];
console.log('[TEST ASSERT] Request object received by spy:', JSON.stringify(req, null, 2));
expect(req.endpoint).toBe('upload-and-process');
expect(req.method).toBe('POST');
@@ -157,11 +168,13 @@ describe('AI API Client (Network Mocking with MSW)', () => {
describe('isImageAFlyer', () => {
it('should construct FormData and send a POST request', async () => {
const mockFile = new File(['dummy image content'], 'flyer.jpg', { type: 'image/jpeg' });
const mockFile = new File(['dummy'], 'flyer.jpg', { type: 'image/jpeg' });
console.log(`\n--- [TEST START] isImageAFlyer ---`);
await aiApiClient.isImageAFlyer(mockFile, 'test-token');
expect(requestSpy).toHaveBeenCalledTimes(1);
const req = requestSpy.mock.calls[0][0];
console.log('[TEST ASSERT] Request object received by spy:', JSON.stringify(req, null, 2));
expect(req.endpoint).toBe('check-flyer');
expect(req.method).toBe('POST');
@@ -176,11 +189,13 @@ describe('AI API Client (Network Mocking with MSW)', () => {
describe('extractAddressFromImage', () => {
it('should construct FormData and send a POST request', async () => {
const mockFile = new File(['dummy image content'], 'flyer.jpg', { type: 'image/jpeg' });
const mockFile = new File(['dummy'], 'flyer.jpg', { type: 'image/jpeg' });
console.log(`\n--- [TEST START] extractAddressFromImage ---`);
await aiApiClient.extractAddressFromImage(mockFile, 'test-token');
expect(requestSpy).toHaveBeenCalledTimes(1);
const req = requestSpy.mock.calls[0][0];
console.log('[TEST ASSERT] Request object received by spy:', JSON.stringify(req, null, 2));
expect(req.endpoint).toBe('extract-address');
@@ -194,11 +209,13 @@ describe('AI API Client (Network Mocking with MSW)', () => {
describe('extractLogoFromImage', () => {
it('should construct FormData and send a POST request', async () => {
const mockFile = new File(['dummy image content'], 'logo.jpg', { type: 'image/jpeg' });
const mockFile = new File(['logo'], 'logo.jpg', { type: 'image/jpeg' });
console.log(`\n--- [TEST START] extractLogoFromImage ---`);
await aiApiClient.extractLogoFromImage([mockFile], 'test-token');
expect(requestSpy).toHaveBeenCalledTimes(1);
const req = requestSpy.mock.calls[0][0];
console.log('[TEST ASSERT] Request object received by spy:', JSON.stringify(req, null, 2));
expect(req.endpoint).toBe('extract-logo');
@@ -328,14 +345,16 @@ describe('AI API Client (Network Mocking with MSW)', () => {
describe('rescanImageArea', () => {
it('should construct FormData with image, cropArea, and extractionType', async () => {
const mockFile = new File(['dummy image content'], 'flyer-page.jpg', { type: 'image/jpeg' });
const mockFile = new File(['dummy-content'], 'flyer-page.jpg', { type: 'image/jpeg' });
const cropArea = { x: 10, y: 20, width: 100, height: 50 };
const extractionType = 'item_details' as const;
console.log(`\n--- [TEST START] rescanImageArea ---`);
await aiApiClient.rescanImageArea(mockFile, cropArea, extractionType);
expect(requestSpy).toHaveBeenCalledTimes(1);
const req = requestSpy.mock.calls[0][0];
console.log('[TEST ASSERT] Request object received by spy:', JSON.stringify(req, null, 2));
expect(req.endpoint).toBe('rescan-area');

View File

@@ -36,6 +36,8 @@ describe('AI Service (Server)', () => {
const aiServiceInstance = new AIService(mockLoggerInstance, mockAiClient, mockFileSystem);
beforeEach(() => {
// Restore all environment variables and clear all mocks before each test
vi.restoreAllMocks();
vi.clearAllMocks();
// Reset modules to ensure the service re-initializes with the mocks
@@ -50,20 +52,26 @@ describe('AI Service (Server)', () => {
beforeEach(() => {
// Reset process.env before each test in this block
vi.unstubAllEnvs();
vi.unstubAllEnvs(); // Force-removes all environment mocking
vi.resetModules(); // Important to re-evaluate the service file
process.env = { ...originalEnv };
console.log('CONSTRUCTOR beforeEach: process.env reset.');
});
afterEach(() => {
// Restore original environment variables
vi.unstubAllEnvs();
process.env = originalEnv;
console.log('CONSTRUCTOR afterEach: process.env restored.');
});
it('should throw an error if GEMINI_API_KEY is not set in a non-test environment', async () => {
console.log("TEST START: 'should throw an error if GEMINI_API_KEY is not set...'");
// Simulate a non-test environment
process.env.NODE_ENV = 'production';
delete process.env.VITEST_POOL_ID; // Ensure test detection is false
delete process.env.GEMINI_API_KEY;
delete process.env.VITEST_POOL_ID; // Ensure test environment detection is false
// Dynamically import the class to re-evaluate the constructor logic
const { AIService } = await import('./aiService.server');
@@ -148,8 +156,9 @@ describe('AI Service (Server)', () => {
});
it('should throw an error if the AI response contains malformed JSON', async () => {
console.log("TEST START: 'should throw an error if the AI response contains malformed JSON'");
// Arrange: AI returns a string that looks like JSON but is invalid
mockAiClient.generateContent.mockResolvedValue({ text: '{ "store_name": "Incomplete, }' });
mockAiClient.generateContent.mockResolvedValue({ text: '{ "store_name": "Incomplete, }', candidates: [] });
mockFileSystem.readFile.mockResolvedValue(Buffer.from('mock-image-data'));
// Act & Assert
@@ -158,15 +167,16 @@ describe('AI Service (Server)', () => {
});
it('should throw an error if the AI API call fails', async () => {
console.log("TEST START: 'should throw an error if the AI API call fails'");
// Arrange: AI client's method rejects
const apiError = new Error('API call failed');
mockAiClient.generateContent.mockRejectedValue(apiError);
mockFileSystem.readFile.mockResolvedValue(Buffer.from('mock-image-data'));
// Act & Assert
// Act & Assert
await expect(aiServiceInstance.extractCoreDataFromFlyerImage([], mockMasterItems, undefined, undefined, mockLoggerInstance)).rejects.toThrow(apiError);
expect(mockLoggerInstance.error).toHaveBeenCalledWith(
{ err: apiError }, "Google GenAI API call failed in extractCoreDataFromFlyerImage"
expect(mockLoggerInstance.error).toHaveBeenCalledWith({ err: apiError },
"[extractCoreDataFromFlyerImage] The entire process failed."
);
});
});
@@ -218,13 +228,11 @@ describe('AI Service (Server)', () => {
});
it('should return null for incomplete JSON and log an error', () => {
// Use a fresh logger instance to isolate this test's call assertions
const localLogger = createMockLogger();
const localAiServiceInstance = new AIService(localLogger, mockAiClient, mockFileSystem);
const responseText = '```json\n{ "key": "value"'; // Missing closing brace;
expect((localAiServiceInstance as any)._parseJsonFromAiResponse(responseText, localLogger)).toBeNull();
// Check that the error was logged with the expected structure and message
expect(localLogger.error).toHaveBeenCalledWith(expect.objectContaining({ jsonString: expect.stringContaining('{ "key": "value"') }), expect.stringContaining('Failed to parse even the truncated JSON'));
expect((localAiServiceInstance as any)._parseJsonFromAiResponse(responseText, localLogger)).toBeNull(); // This was a duplicate, fixed.
expect(localLogger.error).toHaveBeenCalledWith(expect.objectContaining({ jsonSlice: '{ "key": "value"' }), "[_parseJsonFromAiResponse] Failed to parse JSON slice.");
});
});
@@ -241,6 +249,7 @@ describe('AI Service (Server)', () => {
describe('extractTextFromImageArea', () => {
it('should call sharp to crop the image and call the AI with the correct prompt', async () => {
console.log("TEST START: 'should call sharp to crop...'");
const imagePath = 'path/to/image.jpg';
const cropArea = { x: 10, y: 20, width: 100, height: 50 };
const extractionType = 'store_name';
@@ -277,6 +286,7 @@ describe('AI Service (Server)', () => {
});
it('should throw an error if the AI API call fails', async () => {
console.log("TEST START: 'should throw an error if the AI API call fails' (extractTextFromImageArea)");
const apiError = new Error('API Error');
mockAiClient.generateContent.mockRejectedValue(apiError);
mockToBuffer.mockResolvedValue(Buffer.from('cropped-image-data'));
@@ -284,7 +294,7 @@ describe('AI Service (Server)', () => {
await expect(aiServiceInstance.extractTextFromImageArea('path', 'image/jpeg', { x: 0, y: 0, width: 10, height: 10 }, 'dates', mockLoggerInstance))
.rejects.toThrow(apiError);
expect(mockLoggerInstance.error).toHaveBeenCalledWith(
{ err: apiError }, "Google GenAI API call failed in extractTextFromImageArea for type dates"
{ err: apiError }, `[extractTextFromImageArea] An error occurred for type dates.`
);
});
});

View File

@@ -75,16 +75,21 @@ export class AIService {
constructor(logger: Logger, aiClient?: IAiClient, fs?: IFileSystem) {
this.logger = logger;
this.logger.info('[AIService] Initializing...');
this.logger.info('---------------- [AIService] Constructor Start ----------------');
if (aiClient) {
this.logger.info('[AIService] Using provided mock AI client.');
this.logger.info('[AIService Constructor] Using provided mock AI client. This indicates a TEST environment.');
this.aiClient = aiClient;
} else {
this.logger.info('[AIService] Initializing Google GenAI client.');
this.logger.info('[AIService Constructor] No mock client provided. Initializing Google GenAI client for PRODUCTION-LIKE environment.');
// 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');
this.logger.info({
isTestEnvironment,
nodeEnv: process.env.NODE_ENV,
vitestPoolId: process.env.VITEST_POOL_ID,
hasApiKey: !!process.env.GEMINI_API_KEY
}, '[AIService Constructor] Environment check');
const apiKey = process.env.GEMINI_API_KEY;
if (!apiKey) {
@@ -121,7 +126,7 @@ export class AIService {
// 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 }, '[AIService] Calling actual generateContent via adapter.');
this.logger.debug({ modelName, requestParts: firstContent.parts?.length ?? 0 }, '[AIService] Calling actual generateContent via adapter.');
return genAI.models.generateContent({ model: modelName, ...request });
}
} : {
@@ -135,15 +140,17 @@ export class AIService {
this.fs = fs || fsPromises;
// Initialize the rate limiter based on an environment variable.
// Defaults to 5 requests per minute (60,000 ms) if not specified.
const requestsPerMinute = parseInt(process.env.GEMINI_RPM || '5', 10);
this.rateLimiter = pRateLimit({
interval: 60 * 1000, // 1 minute
rate: requestsPerMinute,
concurrency: requestsPerMinute, // Allow up to `rate` requests to be running in parallel.
});
this.logger.info(`[AIService] Rate limiter initialized to ${requestsPerMinute} requests per minute.`);
if (aiClient) {
this.logger.warn('[AIService Constructor] Mock client detected. Rate limiter is DISABLED for testing.');
this.rateLimiter = <T>(fn: () => Promise<T>) => fn(); // Pass-through function
} else {
const requestsPerMinute = parseInt(process.env.GEMINI_RPM || '5', 10);
this.logger.info(`[AIService Constructor] Initializing production rate limiter to ${requestsPerMinute} RPM.`);
this.rateLimiter = pRateLimit({
interval: 60 * 1000, rate: requestsPerMinute, concurrency: requestsPerMinute,
});
}
this.logger.info('---------------- [AIService] Constructor End ----------------');
}
private async serverFileToGenerativePart(path: string, mimeType: string) {
@@ -211,61 +218,39 @@ export class AIService {
* @returns The parsed JSON object, or null if parsing fails.
*/
private _parseJsonFromAiResponse<T>(responseText: string | undefined, logger: Logger): T | null {
logger.debug({ responseTextLength: responseText?.length }, 'Starting JSON parsing from AI response.');
logger.debug({ responseTextLength: responseText?.length }, '[_parseJsonFromAiResponse] Starting...');
if (!responseText) {
logger.warn('Cannot parse JSON from empty or undefined response text.');
logger.warn('[_parseJsonFromAiResponse] Response text is empty or undefined. Returning null.');
return null;
}
// Attempt to find markdown-style JSON block first
// Find the start of the JSON, which can be inside a markdown block
const markdownMatch = responseText.match(/```(json)?\s*([\s\S]*?)\s*```/);
let potentialJson = responseText;
let jsonString = responseText;
if (markdownMatch && markdownMatch[2]) {
logger.debug('Found JSON within markdown code block.');
potentialJson = markdownMatch[2];
logger.debug('[_parseJsonFromAiResponse] Found JSON within markdown code block.');
jsonString = 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;
// Find the first '{' or '[' and the last '}' or ']' to isolate the JSON object.
const firstBrace = jsonString.indexOf('{');
const firstBracket = jsonString.indexOf('[');
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);
// Determine the starting point of the JSON content
const startIndex = (firstBrace === -1 || (firstBracket !== -1 && firstBracket < firstBrace)) ? firstBracket : firstBrace;
if (startIndex === -1) {
logger.error({ responseText }, "[_parseJsonFromAiResponse] Could not find starting '{' or '[' in response.");
return null;
}
// 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).');
const jsonSlice = jsonString.substring(startIndex);
try {
return JSON.parse(jsonString) as T;
return JSON.parse(jsonSlice) 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;
}
logger.error({ jsonSlice, error: e, errorMessage: (e as Error).message, stack: (e as Error).stack }, "[_parseJsonFromAiResponse] Failed to parse JSON slice.");
return null;
}
}
@@ -292,22 +277,30 @@ export class AIService {
const imagePart = await this.serverFileToGenerativePart(imagePath, imageMimeType);
logger.info('[extractItemsFromReceiptImage] Entering method.');
try {
logger.debug('[extractItemsFromReceiptImage] PRE-RATE-LIMITER: Preparing to call AI.');
// Wrap the AI call with the rate limiter.
const result = await this.rateLimiter(() =>
this.aiClient.generateContent({
contents: [{ parts: [{text: prompt}, imagePart] }]
}));
logger.debug('[extractItemsFromReceiptImage] POST-RATE-LIMITER: AI call successful, parsing response.');
// The response from the SDK is structured, we need to access the text part.
const text = result.text;
logger.debug({ rawText: text?.substring(0, 100) }, '[extractItemsFromReceiptImage] Raw text from AI.');
const parsedJson = this._parseJsonFromAiResponse<{ raw_item_description: string; price_paid_cents: number }[]>(text, logger);
if (!parsedJson) {
logger.error({ responseText: text }, '[extractItemsFromReceiptImage] Failed to parse valid JSON from response.');
throw new Error('AI response did not contain a valid JSON array.');
}
logger.info('[extractItemsFromReceiptImage] Successfully extracted items. Exiting method.');
return parsedJson;
} catch (apiError) {
logger.error({ err: apiError }, "Google GenAI API call failed in extractItemsFromReceiptImage");
logger.error({ err: apiError }, "[extractItemsFromReceiptImage] An error occurred during the process.");
throw apiError;
}
}
@@ -325,6 +318,7 @@ export class AIService {
store_address: string | null;
items: ExtractedFlyerItem[];
}> {
logger.info(`[extractCoreDataFromFlyerImage] Entering method with ${imagePaths.length} image(s).`);
const prompt = this._buildFlyerExtractionPrompt(masterItems, submitterIp, userProfileAddress);
const imageParts = await Promise.all(
@@ -335,42 +329,42 @@ export class AIService {
logger.info(`[aiService.server] Total base64 image data size for Gemini: ${(totalImageSize / (1024 * 1024)).toFixed(2)} MB`);
try {
logger.debug(`[aiService.server] Calling Gemini API for flyer processing with ${imageParts.length} image(s).`);
logger.debug(`[extractCoreDataFromFlyerImage] PRE-RATE-LIMITER: Preparing to call Gemini API.`);
const geminiCallStartTime = process.hrtime.bigint();
// Wrap the AI call with the rate limiter.
const result = await this.rateLimiter(() => {
logger.debug("Executing generateContent call within rate limiter for flyer data.");
logger.debug("[extractCoreDataFromFlyerImage] INSIDE-RATE-LIMITER: Executing generateContent call.");
return this.aiClient.generateContent({
contents: [{ parts: [{ text: prompt }, ...imageParts] }]
});
});
logger.debug('[extractCoreDataFromFlyerImage] POST-RATE-LIMITER: AI call completed.');
const geminiCallEndTime = process.hrtime.bigint();
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;
logger.debug(`[aiService.server] Raw Gemini response text (first 500 chars): ${text?.substring(0, 500)}`);
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 after parsing.");
logger.error({ responseText: text }, "[extractCoreDataFromFlyerImage] AI response did not contain a valid JSON object after parsing.");
throw new Error('AI response did not contain a valid JSON object.');
}
// Normalize the items to create a clean data structure.
logger.debug('[extractCoreDataFromFlyerImage] Normalizing extracted items.');
const normalizedItems = Array.isArray(extractedData.items)
? this._normalizeExtractedItems(extractedData.items)
: [];
// Return a new, correctly typed object, rather than mutating the original.
// This makes the data flow explicit and satisfies TypeScript.
logger.info(`[extractCoreDataFromFlyerImage] Successfully processed flyer data for store: ${extractedData.store_name}. Exiting method.`);
return { ...extractedData, items: normalizedItems };
} catch (apiError) {
logger.error({ err: apiError }, "Google GenAI API call failed in extractCoreDataFromFlyerImage. The error was caught.");
logger.error({ err: apiError }, "[extractCoreDataFromFlyerImage] The entire process failed.");
throw apiError;
}
}
@@ -404,6 +398,7 @@ export class AIService {
cropArea: { x: number; y: number; width: number; height: number },
extractionType: 'store_name' | 'dates' | 'item_details',
logger: Logger = this.logger): Promise<{ text: string | undefined }> {
logger.info(`[extractTextFromImageArea] Entering method for extraction type: ${extractionType}.`);
// 1. Define prompts based on the extraction type
const prompts = {
store_name: 'What is the store name in this image? Respond with only the name.',
@@ -414,6 +409,7 @@ export class AIService {
const prompt = prompts[extractionType] || 'Extract the text from this image.';
// 2. Crop the image using sharp
logger.debug('[extractTextFromImageArea] Cropping image with sharp.');
const sharp = (await import('sharp')).default;
const croppedImageBuffer = await sharp(imagePath)
.extract({
@@ -434,20 +430,21 @@ export class AIService {
// 4. Call the AI model
try {
logger.info(`[aiService.server] Calling Gemini for targeted rescan of type: ${extractionType}`);
logger.debug(`[extractTextFromImageArea] PRE-RATE-LIMITER: Preparing to call AI.`);
// Wrap the AI call with the rate limiter.
const result = await this.rateLimiter(() => {
logger.debug(`Executing generateContent call within rate limiter for image area text extraction (type: ${extractionType}).`);
logger.debug(`[extractTextFromImageArea] INSIDE-RATE-LIMITER: Executing generateContent.`);
return this.aiClient.generateContent({
contents: [{ parts: [{ text: prompt }, imagePart] }]
});
});
logger.debug('[extractTextFromImageArea] POST-RATE-LIMITER: AI call completed.');
const text = result.text?.trim();
logger.info(`[aiService.server] Gemini rescan completed. Extracted text: "${text}"`);
logger.info(`[extractTextFromImageArea] Gemini rescan completed. Extracted text: "${text}". Exiting method.`);
return { text };
} catch (apiError) {
logger.error({ err: apiError }, `Google GenAI API call failed in extractTextFromImageArea for type ${extractionType}`);
logger.error({ err: apiError }, `[extractTextFromImageArea] An error occurred for type ${extractionType}.`);
throw apiError;
}
}

View File

@@ -1,26 +1,3 @@
// --- FIX REGISTRY ---
//
// // 2025-12-09: Fixed "TypeError: ... is not a constructor" in `queueService` and `connection.db` tests.
// ISSUE: `vi.fn(() => ...)` creates a mock implementation using an arrow function.
// Arrow functions cannot be instantiated with `new`.
// FIX: Changed mock implementations to `vi.fn(function() { ... })`. Standard functions
// have a `[[Construct]]` method and support `new`.
//
// 2025-12-09: Addressed "Cannot access before initialization" in `auth.routes.test.ts`.
// ISSUE: `vi.mock` is hoisted above top-level `import` statements. Referencing imported
// variables (like `db` or `types`) inside the mock factory fails.
// FIX: Moved variable creation inside `vi.hoisted` or the mock factory itself,
// removing the dependency on the top-level import within the mock definition.
//
// 2025-12-09: Explicitly mocked 'pg' module using `vi.hoisted` and `vi.mock` within this test file.
// This ensures `Pool` is a proper Vitest spy, allowing `expect(Pool).toHaveBeenCalledTimes(1)`
// and `mockImplementation` overrides to work correctly, resolving "not a spy" errors.
//
// 2024-08-01: Corrected tests to assert against the globally mocked `mockPoolInstance` instead of spying
// on the `pg.Pool` constructor. This aligns the test with the global mock setup in
// `tests-setup-unit.ts` and fixes incorrect assertions.
//
// --- END FIX REGISTRY ---
// src/services/db/connection.db.test.ts
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';