one moar time - we can do it?
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 32m32s
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 32m32s
This commit is contained in:
@@ -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.');
|
||||
});
|
||||
});
|
||||
@@ -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()}>
|
||||
|
||||
@@ -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.');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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}`} />;
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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.`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user