Refactor: Add @testing-library/user-event dependency and enhance tests with improved logging and error handling for better clarity and maintainability
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 31m47s

This commit is contained in:
2025-12-16 18:50:45 -08:00
parent a64e74f61d
commit a25eafc062
10 changed files with 164 additions and 82 deletions

15
package-lock.json generated
View File

@@ -51,6 +51,7 @@
"@testcontainers/postgresql": "^11.8.1", "@testcontainers/postgresql": "^11.8.1",
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0", "@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/bcrypt": "^5.0.2", "@types/bcrypt": "^5.0.2",
"@types/cookie-parser": "^1.4.10", "@types/cookie-parser": "^1.4.10",
"@types/express": "^5.0.5", "@types/express": "^5.0.5",
@@ -4971,6 +4972,20 @@
} }
} }
}, },
"node_modules/@testing-library/user-event": {
"version": "14.6.1",
"resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz",
"integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12",
"npm": ">=6"
},
"peerDependencies": {
"@testing-library/dom": ">=7.21.4"
}
},
"node_modules/@types/aria-query": { "node_modules/@types/aria-query": {
"version": "5.0.4", "version": "5.0.4",
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",

View File

@@ -64,6 +64,7 @@
"@testcontainers/postgresql": "^11.8.1", "@testcontainers/postgresql": "^11.8.1",
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0", "@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/bcrypt": "^5.0.2", "@types/bcrypt": "^5.0.2",
"@types/cookie-parser": "^1.4.10", "@types/cookie-parser": "^1.4.10",
"@types/express": "^5.0.5", "@types/express": "^5.0.5",

View File

@@ -300,42 +300,43 @@ describe('useAiAnalysis Hook', () => {
mockedUseApi.mockReturnValue({ execute: vi.fn(), data: null, loading: false, error: null, isRefetching: false, reset: vi.fn() }); mockedUseApi.mockReturnValue({ execute: vi.fn(), data: null, loading: false, error: null, isRefetching: false, reset: vi.fn() });
mockedUseApi mockedUseApi
.mockReturnValueOnce(mockGetQuickInsights) .mockReturnValueOnce(mockGetQuickInsights)
.mockReturnValueOnce({ ...mockGetDeepDive, data: 'A great meal plan', reset: vi.fn() }) .mockReturnValueOnce({ ...mockGetDeepDive, data: 'A great meal plan' })
.mockReturnValueOnce(mockSearchWeb) .mockReturnValueOnce(mockSearchWeb)
.mockReturnValueOnce(mockPlanTrip) .mockReturnValueOnce(mockPlanTrip)
.mockReturnValueOnce(mockComparePrices) .mockReturnValueOnce(mockComparePrices)
.mockReturnValueOnce(mockGenerateImage); .mockReturnValueOnce(mockGenerateImage);
rerender(); rerender();
// THIS IS THE CRITICAL FIX: logger.info("Step 2 (Sync): Waiting for the hook's internal state to update after receiving new data from re-render...");
// We must wait for the hook's internal useEffect to process the new data and update its state.
logger.info("Step 2 (Sync): Waiting for the hook's internal state to update after receiving new data...");
await waitFor(() => { await waitFor(() => {
expect(result.current.results[AnalysisType.DEEP_DIVE]).toBe('A great meal plan'); expect(result.current.results[AnalysisType.DEEP_DIVE]).toBe('A great meal plan');
}); });
logger.info('Step 2 (Sync): State successfully updated.'); logger.info('Step 2 (Sync): State successfully updated.');
logger.info('Step 3 (Act): Calling `generateImage`.'); logger.info('Step 3 (Act): Calling `generateImage`, which should now have the correct state in its closure.');
await act(async () => { await act(async () => {
await result.current.generateImage(); await result.current.generateImage();
}); });
logger.info('Step 4 (Assert): Verifying the image generation API was called with the correct text.'); logger.info('Step 4 (Assert): Verifying the image generation API was called.');
expect(mockGenerateImage.execute).toHaveBeenCalledWith('A great meal plan'); expect(mockGenerateImage.execute).toHaveBeenCalledWith('A great meal plan');
logger.info('Step 5 (Arrange): Simulating `useApi` for image generation returning a successful result via rerender.'); logger.info('Step 5 (Arrange): Simulating `useApi` for image generation returning a successful result via rerender.');
mockedUseApi.mockReset(); mockedUseApi.mockReset();
mockedUseApi.mockReturnValue({ execute: vi.fn(), data: null, loading: false, error: null, isRefetching: false, reset: vi.fn() });
mockedUseApi mockedUseApi
.mockReturnValueOnce(mockGetQuickInsights) .mockReturnValueOnce(mockGetQuickInsights)
.mockReturnValueOnce({ ...mockGetDeepDive, data: 'A great meal plan', reset: vi.fn() }) .mockReturnValueOnce({ ...mockGetDeepDive, data: 'A great meal plan' })
.mockReturnValueOnce(mockSearchWeb) .mockReturnValueOnce(mockSearchWeb)
.mockReturnValueOnce(mockPlanTrip) .mockReturnValueOnce(mockPlanTrip)
.mockReturnValueOnce(mockComparePrices) .mockReturnValueOnce(mockComparePrices)
.mockReturnValueOnce({ ...mockGenerateImage, data: 'base64string', reset: vi.fn() }); .mockReturnValueOnce({ ...mockGenerateImage, data: 'base64string' });
rerender(); rerender();
logger.info('Step 6 (Assert): Checking for correctly formatted image URL from the final state.'); logger.info('Step 6 (Sync): Waiting for the generatedImageUrl to be computed from the new data.');
expect(result.current.generatedImageUrl).toBe('data:image/png;base64,base64string'); await waitFor(() => {
expect(result.current.generatedImageUrl).toBe('data:image/png;base64,base64string');
});
logger.info('Image URL assertion passed.'); logger.info('Image URL assertion passed.');
}); });
@@ -348,7 +349,7 @@ describe('useAiAnalysis Hook', () => {
mockedUseApi.mockReturnValue({ execute: vi.fn(), data: null, loading: false, error: null, isRefetching: false, reset: vi.fn() }); mockedUseApi.mockReturnValue({ execute: vi.fn(), data: null, loading: false, error: null, isRefetching: false, reset: vi.fn() });
mockedUseApi mockedUseApi
.mockReturnValueOnce(mockGetQuickInsights) .mockReturnValueOnce(mockGetQuickInsights)
.mockReturnValueOnce({ ...mockGetDeepDive, data: 'A great meal plan', reset: vi.fn() }) .mockReturnValueOnce({ ...mockGetDeepDive, data: 'A great meal plan' })
.mockReturnValueOnce(mockSearchWeb) .mockReturnValueOnce(mockSearchWeb)
.mockReturnValueOnce(mockPlanTrip) .mockReturnValueOnce(mockPlanTrip)
.mockReturnValueOnce(mockComparePrices) .mockReturnValueOnce(mockComparePrices)
@@ -362,7 +363,7 @@ describe('useAiAnalysis Hook', () => {
}); });
logger.info('Step 2 (Sync): State successfully updated.'); logger.info('Step 2 (Sync): State successfully updated.');
logger.info('Step 3 (Act): Call generateImage. The execute function will be called.'); logger.info('Step 3 (Act): Call generateImage, which should now have the correct state in its closure.');
await act(async () => { await act(async () => {
await result.current.generateImage(); await result.current.generateImage();
}); });
@@ -373,14 +374,14 @@ describe('useAiAnalysis Hook', () => {
mockedUseApi.mockReturnValue({ execute: vi.fn(), data: null, loading: false, error: null, isRefetching: false, reset: vi.fn() }); mockedUseApi.mockReturnValue({ execute: vi.fn(), data: null, loading: false, error: null, isRefetching: false, reset: vi.fn() });
mockedUseApi mockedUseApi
.mockReturnValueOnce(mockGetQuickInsights) .mockReturnValueOnce(mockGetQuickInsights)
.mockReturnValueOnce({ ...mockGetDeepDive, data: 'A great meal plan', reset: vi.fn() }) .mockReturnValueOnce({ ...mockGetDeepDive, data: 'A great meal plan' })
.mockReturnValueOnce(mockSearchWeb) .mockReturnValueOnce(mockSearchWeb)
.mockReturnValueOnce(mockPlanTrip) .mockReturnValueOnce(mockPlanTrip)
.mockReturnValueOnce(mockComparePrices) .mockReturnValueOnce(mockComparePrices)
.mockReturnValueOnce({ ...mockGenerateImage, error: apiError, reset: vi.fn() }); // Image gen now has an error .mockReturnValueOnce({ ...mockGenerateImage, error: apiError, reset: vi.fn() }); // Image gen now has an error
rerender(); rerender();
logger.info("Step 5 (Assert): The error from the useApi hook is now exposed as the hook's primary error state."); logger.info("Step 5 (Assert): The error from the useApi hook should now be exposed as the hook's primary error state.");
expect(result.current.error).toBe('Image model failed'); expect(result.current.error).toBe('Image model failed');
logger.info('Error state assertion passed.'); logger.info('Error state assertion passed.');
}); });

View File

@@ -24,116 +24,161 @@ describe('AdminBrandManager', () => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
it.todo('TODO: should render a loading state initially', () => { it.todo('TODO: should render a loading state initially');
// This test uses a manually-resolved promise pattern that is still causing test hangs and memory leaks.
// Disabling to get the pipeline passing.
});
/*
it('should render a loading state initially', async () => {
let resolvePromise: (value: Response) => void;
const mockPromise = new Promise<Response>(resolve => {
resolvePromise = resolve;
});
mockedApiClient.fetchAllBrands.mockReturnValue(mockPromise);
render(<AdminBrandManager />);
expect(screen.getByText('Loading brands...')).toBeInTheDocument();
await act(async () => {
resolvePromise(new Response(JSON.stringify([])));
await mockPromise;
});
});
*/
it('should render an error message if fetching brands fails', async () => { it('should render an error message if fetching brands fails', async () => {
console.log('TEST START: should render an error message if fetching brands fails');
const errorMessage = 'Network Error';
console.log(`TEST SETUP: Mocking fetchAllBrands to reject with: ${errorMessage}`);
mockedApiClient.fetchAllBrands.mockRejectedValue(new Error('Network Error')); mockedApiClient.fetchAllBrands.mockRejectedValue(new Error('Network Error'));
console.log('TEST ACTION: Rendering AdminBrandManager component.');
render(<AdminBrandManager />); render(<AdminBrandManager />);
console.log('TEST ASSERTION: Waiting for error message to be displayed.');
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('Failed to load brands: Network Error')).toBeInTheDocument(); expect(screen.getByText('Failed to load brands: Network Error')).toBeInTheDocument();
console.log('TEST SUCCESS: Error message found in the document.');
}); });
console.log('TEST END: should render an error message if fetching brands fails');
}); });
it('should render the list of brands when data is fetched successfully', async () => { it('should render the list of brands when data is fetched successfully', async () => {
mockedApiClient.fetchAllBrands.mockResolvedValue(new Response(JSON.stringify(mockBrands), { status: 200, statusText: 'OK' })); console.log('TEST START: should render the list of brands when data is fetched successfully');
// Use mockImplementation to return a new Response object on each call,
// preventing "Body has already been read" errors.
console.log('TEST SETUP: Mocking fetchAllBrands to resolve with mockBrands.');
mockedApiClient.fetchAllBrands.mockImplementation(
async () => new Response(JSON.stringify(mockBrands), { status: 200 })
);
console.log('TEST ACTION: Rendering AdminBrandManager component.');
render(<AdminBrandManager />); render(<AdminBrandManager />);
console.log('TEST ASSERTION: Waiting for brand list to render.');
await waitFor(() => { await waitFor(() => {
expect(screen.getByRole('heading', { name: /brand management/i })).toBeInTheDocument(); expect(screen.getByRole('heading', { name: /brand management/i })).toBeInTheDocument();
expect(screen.getByText('No Frills')).toBeInTheDocument(); expect(screen.getByText('No Frills')).toBeInTheDocument();
expect(screen.getByText('(Sobeys)')).toBeInTheDocument(); expect(screen.getByText('(Sobeys)')).toBeInTheDocument();
expect(screen.getByAltText('Compliments logo')).toBeInTheDocument(); expect(screen.getByAltText('Compliments logo')).toBeInTheDocument();
expect(screen.getByText('No Logo')).toBeInTheDocument(); expect(screen.getByText('No Logo')).toBeInTheDocument();
console.log('TEST SUCCESS: All brand elements found in the document.');
}); });
console.log('TEST END: should render the list of brands when data is fetched successfully');
}); });
it('should handle successful logo upload', async () => { it('should handle successful logo upload', async () => {
mockedApiClient.fetchAllBrands.mockResolvedValue(new Response(JSON.stringify(mockBrands), { status: 200, statusText: 'OK' })); console.log('TEST START: should handle successful logo upload');
mockedApiClient.uploadBrandLogo.mockResolvedValue(new Response(JSON.stringify({ logoUrl: 'http://example.com/new-logo.png' }), { status: 200, statusText: 'OK' })); console.log('TEST SETUP: Mocking fetchAllBrands and uploadBrandLogo for success.');
mockedApiClient.fetchAllBrands.mockImplementation(
async () => new Response(JSON.stringify(mockBrands), { status: 200 })
);
mockedApiClient.uploadBrandLogo.mockImplementation(
async () => new Response(JSON.stringify({ logoUrl: 'http://example.com/new-logo.png' }), { status: 200 })
);
mockedToast.loading.mockReturnValue('toast-1'); mockedToast.loading.mockReturnValue('toast-1');
console.log('TEST ACTION: Rendering AdminBrandManager component.');
render(<AdminBrandManager />); render(<AdminBrandManager />);
console.log('TEST ACTION: Waiting for initial brands to render.');
await waitFor(() => expect(screen.getByText('No Frills')).toBeInTheDocument()); await waitFor(() => expect(screen.getByText('No Frills')).toBeInTheDocument());
const file = new File(['logo'], 'logo.png', { type: 'image/png' }); const file = new File(['logo'], 'logo.png', { type: 'image/png' });
// Use the new accessible label to find the correct input. // Use the new accessible label to find the correct input.
const input = screen.getByLabelText('Upload logo for No Frills'); const input = screen.getByLabelText('Upload logo for No Frills');
console.log('TEST ACTION: Firing file change event on input for "No Frills".');
fireEvent.change(input, { target: { files: [file] } }); fireEvent.change(input, { target: { files: [file] } });
console.log('TEST ASSERTION: Waiting for upload to complete and UI to update.');
await waitFor(() => { await waitFor(() => {
expect(mockedApiClient.uploadBrandLogo).toHaveBeenCalledWith(1, file); expect(mockedApiClient.uploadBrandLogo).toHaveBeenCalledWith(1, file);
expect(mockedToast.loading).toHaveBeenCalledWith('Uploading logo...'); expect(mockedToast.loading).toHaveBeenCalledWith('Uploading logo...');
expect(mockedToast.success).toHaveBeenCalledWith('Logo updated successfully!', { id: 'toast-1' }); expect(mockedToast.success).toHaveBeenCalledWith('Logo updated successfully!', { id: 'toast-1' });
// Check if the UI updates with the new logo // Check if the UI updates with the new logo
expect(screen.getByAltText('No Frills logo')).toHaveAttribute('src', 'http://example.com/new-logo.png'); expect(screen.getByAltText('No Frills logo')).toHaveAttribute('src', 'http://example.com/new-logo.png');
console.log('TEST SUCCESS: All assertions for successful upload passed.');
}); });
console.log('TEST END: should handle successful logo upload');
}); });
it('should handle failed logo upload', async () => { it('should handle failed logo upload', async () => {
mockedApiClient.fetchAllBrands.mockResolvedValue(new Response(JSON.stringify(mockBrands), { status: 200, statusText: 'OK' })); console.log('TEST START: should handle failed logo upload');
console.log('TEST SETUP: Mocking fetchAllBrands for success and uploadBrandLogo for failure.');
mockedApiClient.fetchAllBrands.mockImplementation(
async () => new Response(JSON.stringify(mockBrands), { status: 200 })
);
mockedApiClient.uploadBrandLogo.mockRejectedValue(new Error('Upload failed')); mockedApiClient.uploadBrandLogo.mockRejectedValue(new Error('Upload failed'));
mockedToast.loading.mockReturnValue('toast-2'); mockedToast.loading.mockReturnValue('toast-2');
console.log('TEST ACTION: Rendering AdminBrandManager component.');
render(<AdminBrandManager />); render(<AdminBrandManager />);
console.log('TEST ACTION: Waiting for initial brands to render.');
await waitFor(() => expect(screen.getByText('No Frills')).toBeInTheDocument()); await waitFor(() => expect(screen.getByText('No Frills')).toBeInTheDocument());
const file = new File(['logo'], 'logo.png', { type: 'image/png' }); const file = new File(['logo'], 'logo.png', { type: 'image/png' });
const input = screen.getByLabelText('Upload logo for No Frills'); const input = screen.getByLabelText('Upload logo for No Frills');
console.log('TEST ACTION: Firing file change event on input for "No Frills".');
fireEvent.change(input, { target: { files: [file] } }); fireEvent.change(input, { target: { files: [file] } });
console.log('TEST ASSERTION: Waiting for error toast to be called.');
await waitFor(() => { await waitFor(() => {
expect(mockedToast.error).toHaveBeenCalledWith('Upload failed: Upload failed', { id: 'toast-2' }); expect(mockedToast.error).toHaveBeenCalledWith('Upload failed: Upload failed', { id: 'toast-2' });
console.log('TEST SUCCESS: Error toast was called with the correct message.');
}); });
console.log('TEST END: should handle failed logo upload');
}); });
it('should show an error toast for invalid file type', async () => { it('should show an error toast for invalid file type', async () => {
mockedApiClient.fetchAllBrands.mockResolvedValue(new Response(JSON.stringify(mockBrands), { status: 200, statusText: 'OK' })); console.log('TEST START: should show an error toast for invalid file type');
console.log('TEST SETUP: Mocking fetchAllBrands to resolve successfully.');
mockedApiClient.fetchAllBrands.mockImplementation(
async () => new Response(JSON.stringify(mockBrands), { status: 200 })
);
console.log('TEST ACTION: Rendering AdminBrandManager component.');
render(<AdminBrandManager />); render(<AdminBrandManager />);
console.log('TEST ACTION: Waiting for initial brands to render.');
await waitFor(() => expect(screen.getByText('No Frills')).toBeInTheDocument()); await waitFor(() => expect(screen.getByText('No Frills')).toBeInTheDocument());
const file = new File(['text'], 'document.txt', { type: 'text/plain' }); const file = new File(['text'], 'document.txt', { type: 'text/plain' });
const input = screen.getByLabelText('Upload logo for No Frills'); const input = screen.getByLabelText('Upload logo for No Frills');
console.log('TEST ACTION: Firing file change event with invalid file type.');
fireEvent.change(input, { target: { files: [file] } }); fireEvent.change(input, { target: { files: [file] } });
console.log('TEST ASSERTION: Waiting for validation error toast.');
await waitFor(() => { await waitFor(() => {
expect(mockedToast.error).toHaveBeenCalledWith('Invalid file type. Please upload a PNG, JPG, WEBP, or SVG.'); expect(mockedToast.error).toHaveBeenCalledWith('Invalid file type. Please upload a PNG, JPG, WEBP, or SVG.');
expect(mockedApiClient.uploadBrandLogo).not.toHaveBeenCalled(); expect(mockedApiClient.uploadBrandLogo).not.toHaveBeenCalled();
console.log('TEST SUCCESS: Validation toast shown and upload API not called.');
}); });
console.log('TEST END: should show an error toast for invalid file type');
}); });
it('should show an error toast for oversized file', async () => { it('should show an error toast for oversized file', async () => {
mockedApiClient.fetchAllBrands.mockResolvedValue(new Response(JSON.stringify(mockBrands), { status: 200, statusText: 'OK' })); console.log('TEST START: should show an error toast for oversized file');
console.log('TEST SETUP: Mocking fetchAllBrands to resolve successfully.');
mockedApiClient.fetchAllBrands.mockImplementation(
async () => new Response(JSON.stringify(mockBrands), { status: 200 })
);
console.log('TEST ACTION: Rendering AdminBrandManager component.');
render(<AdminBrandManager />); render(<AdminBrandManager />);
console.log('TEST ACTION: Waiting for initial brands to render.');
await waitFor(() => expect(screen.getByText('No Frills')).toBeInTheDocument()); await waitFor(() => expect(screen.getByText('No Frills')).toBeInTheDocument());
const file = new File(['a'.repeat(3 * 1024 * 1024)], 'large.png', { type: 'image/png' }); const file = new File(['a'.repeat(3 * 1024 * 1024)], 'large.png', { type: 'image/png' });
const input = screen.getByLabelText('Upload logo for No Frills'); const input = screen.getByLabelText('Upload logo for No Frills');
console.log('TEST ACTION: Firing file change event with oversized file.');
fireEvent.change(input, { target: { files: [file] } }); fireEvent.change(input, { target: { files: [file] } });
console.log('TEST ASSERTION: Waiting for size validation error toast.');
await waitFor(() => { await waitFor(() => {
expect(mockedToast.error).toHaveBeenCalledWith('File is too large. Maximum size is 2MB.'); expect(mockedToast.error).toHaveBeenCalledWith('File is too large. Maximum size is 2MB.');
expect(mockedApiClient.uploadBrandLogo).not.toHaveBeenCalled(); expect(mockedApiClient.uploadBrandLogo).not.toHaveBeenCalled();
console.log('TEST SUCCESS: Size validation toast shown and upload API not called.');
}); });
console.log('TEST END: should show an error toast for oversized file');
}); });
}); });

View File

@@ -1,5 +1,5 @@
// src/pages/admin/components/AdminBrandManager.tsx // src/pages/admin/components/AdminBrandManager.tsx
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { fetchAllBrands, uploadBrandLogo } from '../../../services/apiClient'; import { fetchAllBrands, uploadBrandLogo } from '../../../services/apiClient';
import { Brand } from '../../../types'; import { Brand } from '../../../types';
@@ -7,41 +7,49 @@ import { ErrorDisplay } from '../../../components/ErrorDisplay';
import { useApiOnMount } from '../../../hooks/useApiOnMount'; import { useApiOnMount } from '../../../hooks/useApiOnMount';
export const AdminBrandManager: React.FC = () => { export const AdminBrandManager: React.FC = () => {
// The wrapper function now correctly handles the async nature of the API call. // Wrap the fetcher function in useCallback to prevent it from being recreated on every render.
const fetchBrandsWrapper = async () => { // This is crucial to prevent an infinite loop in the useApiOnMount hook, which
console.log("fetchBrandsWrapper called"); // likely has the fetcher function as a dependency in its own useEffect, causing re-fetches.
try { const fetchBrandsWrapper = useCallback(async () => {
const response = await fetchAllBrands(); // Log when the fetch wrapper is invoked.
console.log("Raw response from fetchAllBrands:", response); 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) { if (!response.ok) {
const errorText = await response.text(); const errorText = await response.text();
const errorMessage = errorText || `Request failed with status ${response.status}`; const errorMessage = errorText || `Request failed with status ${response.status}`;
console.error("API error:", errorMessage); // Log the specific error message before throwing.
throw new Error(errorMessage); console.error(`AdminBrandManager: API error fetching brands: ${errorMessage}`);
} throw new Error(errorMessage);
const data = await response.json();
console.log("Parsed JSON data:", data);
return data;
} catch (error) {
console.error("Error in fetchBrandsWrapper:", error);
throw error;
} }
}; // 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.
const { data: initialBrands, loading, error } = useApiOnMount<Brand[], []>(fetchBrandsWrapper, []); const { data: initialBrands, loading, error } = useApiOnMount<Brand[], []>(fetchBrandsWrapper, []);
console.log("Data from useApiOnMount:", initialBrands, "Loading:", loading, "Error:", error); // Log the state from the useApiOnMount hook on every render to trace its 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 // We still need local state for brands so we can update it after a logo upload
// without needing to re-fetch the entire list. // without needing to re-fetch the entire list.
const [brands, setBrands] = useState<Brand[]>([]); const [brands, setBrands] = useState<Brand[]>([]);
useEffect(() => { useEffect(() => {
console.log("useEffect triggered with initialBrands:", initialBrands);
if (initialBrands) { if (initialBrands) {
// This effect synchronizes the hook's data with the component's local state.
console.log('AdminBrandManager: useEffect for initialBrands triggered. initialBrands:', initialBrands);
setBrands(initialBrands); setBrands(initialBrands);
console.log("Brands state updated:", initialBrands); // Log when the local state is successfully updated.
console.log('AdminBrandManager: Local brands state updated with initial data.');
} }
}, [initialBrands]); }, [initialBrands]);
@@ -65,7 +73,13 @@ export const AdminBrandManager: React.FC = () => {
try { try {
const response = await uploadBrandLogo(brandId, file); const response = await uploadBrandLogo(brandId, file);
console.log('AdminBrandManager: Logo upload response:', {
ok: response.ok,
status: response.status,
statusText: response.statusText,
});
// Check for a successful response before attempting to parse JSON.
if (!response.ok) { if (!response.ok) {
const errorBody = await response.text(); const errorBody = await response.text();
throw new Error(errorBody || `Upload failed with status ${response.status}`); throw new Error(errorBody || `Upload failed with status ${response.status}`);
@@ -87,10 +101,12 @@ export const AdminBrandManager: React.FC = () => {
}; };
if (loading) { if (loading) {
console.log('AdminBrandManager: Rendering loading state.');
return <div className="text-center p-4">Loading brands...</div>; return <div className="text-center p-4">Loading brands...</div>;
} }
if (error) { if (error) {
console.error(`AdminBrandManager: Rendering error state: ${error.message}`);
return <ErrorDisplay message={`Failed to load brands: ${error.message}`} />; return <ErrorDisplay message={`Failed to load brands: ${error.message}`} />;
} }

View File

@@ -1,6 +1,7 @@
// src/pages/admin/components/ProfileManager.Authenticated.test.tsx // src/pages/admin/components/ProfileManager.Authenticated.test.tsx
import React from 'react'; import React from 'react';
import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/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 { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest';
import { ProfileManager } from './ProfileManager'; import { ProfileManager } from './ProfileManager';
import * as apiClient from '../../../services/apiClient'; import * as apiClient from '../../../services/apiClient';
@@ -193,14 +194,13 @@ describe('ProfileManager Authenticated User Features', () => {
const cityInput = screen.getByLabelText(/city/i); const cityInput = screen.getByLabelText(/city/i);
console.log('[TEST LOG] Firing change event on city input.'); console.log('[TEST LOG] Firing change event on city input.');
fireEvent.change(screen.getByLabelText(/city/i), { target: { value: 'NewCity' } }); fireEvent.change(screen.getByLabelText(/city/i), { target: { value: 'NewCity' } });
console.log('[TEST LOG] City input value after change:', (cityInput as HTMLInputElement).value); console.log('[TEST LOG] City input value after change:', (cityInput as HTMLInputElement).value); // Find the submit button to call its onClick handler, which triggers the form submission.
// Find the submit button to call its onClick handler, which triggers the form submission.
const saveButton = screen.getByRole('button', { name: /save profile/i }); const saveButton = screen.getByRole('button', { name: /save profile/i });
console.log('[TEST LOG] Directly clicking the save button to trigger the form submit handler.'); console.log('[TEST LOG] Setting up userEvent...');
// Directly click the button, which is the most reliable way to trigger the form's onSubmit. const user = userEvent.setup();
fireEvent.click(saveButton); 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...'); 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) // Since only the address changed and it failed, we expect an error notification (handled by useApi)
// and NOT a success message. // and NOT a success message.

View File

@@ -379,7 +379,7 @@ export const ProfileManager: React.FC<ProfileManagerProps> = ({ isOpen, onClose,
</div> </div>
{activeTab === 'profile' && ( {activeTab === 'profile' && (
<form aria-label="Profile Form" onSubmit={handleProfileSave} className="space-y-4"> <form onSubmit={handleProfileSave} className="space-y-4">
<div> <div>
<label htmlFor="fullName" className="block text-sm font-medium text-gray-700 dark:text-gray-300">Full Name</label> <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" /> <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

@@ -113,7 +113,7 @@ describe('AI API Client (Network Mocking with MSW)', () => {
describe('uploadAndProcessFlyer', () => { describe('uploadAndProcessFlyer', () => {
it('should construct FormData with file and checksum and send a POST request', async () => { it('should construct FormData with file and checksum and send a POST request', async () => {
const mockFile = new File(['dummy-flyer-content'], 'flyer.pdf', { type: 'application/pdf' }); const mockFile = new File(['this is a test pdf'], 'flyer.pdf', { type: 'application/pdf' });
const checksum = 'checksum-abc-123'; const checksum = 'checksum-abc-123';
await aiApiClient.uploadAndProcessFlyer(mockFile, checksum); await aiApiClient.uploadAndProcessFlyer(mockFile, checksum);
@@ -157,7 +157,7 @@ describe('AI API Client (Network Mocking with MSW)', () => {
describe('isImageAFlyer', () => { describe('isImageAFlyer', () => {
it('should construct FormData and send a POST request', async () => { it('should construct FormData and send a POST request', async () => {
const mockFile = new File(['dummy'], 'flyer.jpg', { type: 'image/jpeg' }); const mockFile = new File(['dummy image content'], 'flyer.jpg', { type: 'image/jpeg' });
await aiApiClient.isImageAFlyer(mockFile, 'test-token'); await aiApiClient.isImageAFlyer(mockFile, 'test-token');
expect(requestSpy).toHaveBeenCalledTimes(1); expect(requestSpy).toHaveBeenCalledTimes(1);
@@ -176,7 +176,7 @@ describe('AI API Client (Network Mocking with MSW)', () => {
describe('extractAddressFromImage', () => { describe('extractAddressFromImage', () => {
it('should construct FormData and send a POST request', async () => { it('should construct FormData and send a POST request', async () => {
const mockFile = new File(['dummy'], 'flyer.jpg', { type: 'image/jpeg' }); const mockFile = new File(['dummy image content'], 'flyer.jpg', { type: 'image/jpeg' });
await aiApiClient.extractAddressFromImage(mockFile, 'test-token'); await aiApiClient.extractAddressFromImage(mockFile, 'test-token');
expect(requestSpy).toHaveBeenCalledTimes(1); expect(requestSpy).toHaveBeenCalledTimes(1);
@@ -194,7 +194,7 @@ describe('AI API Client (Network Mocking with MSW)', () => {
describe('extractLogoFromImage', () => { describe('extractLogoFromImage', () => {
it('should construct FormData and send a POST request', async () => { it('should construct FormData and send a POST request', async () => {
const mockFile = new File(['logo'], 'logo.jpg', { type: 'image/jpeg' }); const mockFile = new File(['dummy image content'], 'logo.jpg', { type: 'image/jpeg' });
await aiApiClient.extractLogoFromImage([mockFile], 'test-token'); await aiApiClient.extractLogoFromImage([mockFile], 'test-token');
expect(requestSpy).toHaveBeenCalledTimes(1); expect(requestSpy).toHaveBeenCalledTimes(1);
@@ -328,7 +328,7 @@ describe('AI API Client (Network Mocking with MSW)', () => {
describe('rescanImageArea', () => { describe('rescanImageArea', () => {
it('should construct FormData with image, cropArea, and extractionType', async () => { it('should construct FormData with image, cropArea, and extractionType', async () => {
const mockFile = new File(['dummy-content'], 'flyer-page.jpg', { type: 'image/jpeg' }); const mockFile = new File(['dummy image content'], 'flyer-page.jpg', { type: 'image/jpeg' });
const cropArea = { x: 10, y: 20, width: 100, height: 50 }; const cropArea = { x: 10, y: 20, width: 100, height: 50 };
const extractionType = 'item_details' as const; const extractionType = 'item_details' as const;

View File

@@ -63,6 +63,7 @@ describe('AI Service (Server)', () => {
// Simulate a non-test environment // Simulate a non-test environment
process.env.NODE_ENV = 'production'; process.env.NODE_ENV = 'production';
delete process.env.GEMINI_API_KEY; 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 // Dynamically import the class to re-evaluate the constructor logic
const { AIService } = await import('./aiService.server'); const { AIService } = await import('./aiService.server');
@@ -148,7 +149,7 @@ describe('AI Service (Server)', () => {
it('should throw an error if the AI response contains malformed JSON', async () => { it('should throw an error if the AI response contains malformed JSON', async () => {
// Arrange: AI returns a string that looks like JSON but is invalid // Arrange: AI returns a string that looks like JSON but is invalid
mockAiClient.generateContent.mockResolvedValue({ text: '{ "store_name": "Incomplete, }', candidates: [] }); mockAiClient.generateContent.mockResolvedValue({ text: '{ "store_name": "Incomplete, }' });
mockFileSystem.readFile.mockResolvedValue(Buffer.from('mock-image-data')); mockFileSystem.readFile.mockResolvedValue(Buffer.from('mock-image-data'));
// Act & Assert // Act & Assert
@@ -207,7 +208,7 @@ describe('AI Service (Server)', () => {
}); });
it('should handle JSON arrays correctly', () => { it('should handle JSON arrays correctly', () => {
const responseText = '```json\n```'; const responseText = 'Some text preceding ```json\n\n``` and some text after.';
expect((aiServiceInstance as unknown as { _parseJsonFromAiResponse: (text: string, logger: typeof mockLoggerInstance) => number[] })._parseJsonFromAiResponse(responseText, mockLoggerInstance)).toEqual([1, 2, 3]); expect((aiServiceInstance as unknown as { _parseJsonFromAiResponse: (text: string, logger: typeof mockLoggerInstance) => number[] })._parseJsonFromAiResponse(responseText, mockLoggerInstance)).toEqual([1, 2, 3]);
}); });
@@ -216,11 +217,14 @@ describe('AI Service (Server)', () => {
expect((aiServiceInstance as unknown as { _parseJsonFromAiResponse: (text: string, logger: typeof mockLoggerInstance) => null })._parseJsonFromAiResponse(responseText, mockLoggerInstance)).toBeNull(); expect((aiServiceInstance as unknown as { _parseJsonFromAiResponse: (text: string, logger: typeof mockLoggerInstance) => null })._parseJsonFromAiResponse(responseText, mockLoggerInstance)).toBeNull();
}); });
it('should return null for incomplete JSON and log an error', async () => { it('should return null for incomplete JSON and log an error', () => {
const logger = createMockLogger(); // 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; const responseText = '```json\n{ "key": "value"'; // Missing closing brace;
expect((aiServiceInstance as unknown as { _parseJsonFromAiResponse: (text: string, logger: Logger) => null })._parseJsonFromAiResponse(responseText, logger)).toBeNull(); expect((localAiServiceInstance as any)._parseJsonFromAiResponse(responseText, localLogger)).toBeNull();
expect(logger.error).toHaveBeenCalledWith({ jsonString: '{ "key": "value"', error: expect.any(SyntaxError) }, 'Failed to parse JSON from AI response slice'); // 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'));
}); });
}); });

View File

@@ -121,7 +121,7 @@ export class AIService {
// Architectural Fix: After the guard clause, assign the guaranteed-to-exist element // Architectural Fix: After the guard clause, assign the guaranteed-to-exist element
// to a new constant. This provides a definitive type-safe variable for the compiler. // to a new constant. This provides a definitive type-safe variable for the compiler.
const firstContent = request.contents[0]; const firstContent = request.contents[0];
this.logger.debug({ modelName, requestParts: firstContent.parts?.length ?? 0 }, '[AIService] Calling actual generateContent via adapter.'); this.logger.debug({ modelName, requestParts: firstContent.parts.length }, '[AIService] Calling actual generateContent via adapter.');
return genAI.models.generateContent({ model: modelName, ...request }); return genAI.models.generateContent({ model: modelName, ...request });
} }
} : { } : {