Adopt TanStack Query fixes
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 14m41s

This commit is contained in:
2026-01-10 17:42:45 -08:00
parent d8aa19ac40
commit 503e7084da
25 changed files with 1887 additions and 395 deletions

View File

@@ -18,11 +18,9 @@
"Bash(PGPASSWORD=postgres psql:*)", "Bash(PGPASSWORD=postgres psql:*)",
"Bash(npm search:*)", "Bash(npm search:*)",
"Bash(npx:*)", "Bash(npx:*)",
"Bash(curl -s -H \"Authorization: token c72bc0f14f623fec233d3c94b3a16397fe3649ef\" https://gitea.projectium.com/api/v1/user)",
"Bash(curl:*)", "Bash(curl:*)",
"Bash(powershell:*)", "Bash(powershell:*)",
"Bash(cmd.exe:*)", "Bash(cmd.exe:*)",
"Bash(export NODE_ENV=test DB_HOST=localhost DB_USER=postgres DB_PASSWORD=postgres DB_NAME=flyer_crawler_dev REDIS_URL=redis://localhost:6379 FRONTEND_URL=http://localhost:5173 JWT_SECRET=test-jwt-secret:*)",
"Bash(npm run test:integration:*)", "Bash(npm run test:integration:*)",
"Bash(grep:*)", "Bash(grep:*)",
"Bash(done)", "Bash(done)",
@@ -86,7 +84,10 @@
"Bash(node -e:*)", "Bash(node -e:*)",
"Bash(xargs -I {} sh -c 'if ! grep -q \"\"vi.mock.*apiClient\"\" \"\"{}\"\"; then echo \"\"{}\"\"; fi')", "Bash(xargs -I {} sh -c 'if ! grep -q \"\"vi.mock.*apiClient\"\" \"\"{}\"\"; then echo \"\"{}\"\"; fi')",
"Bash(MSYS_NO_PATHCONV=1 podman exec:*)", "Bash(MSYS_NO_PATHCONV=1 podman exec:*)",
"Bash(docker ps:*)" "Bash(docker ps:*)",
"Bash(find:*)",
"Bash(\"/c/Users/games3/.local/bin/uvx.exe\" markitdown-mcp --help)",
"Bash(git stash:*)"
] ]
} }
} }

View File

@@ -1,66 +0,0 @@
{
"mcpServers": {
"gitea-projectium": {
"command": "d:\\gitea-mcp\\gitea-mcp.exe",
"args": ["run", "-t", "stdio"],
"env": {
"GITEA_HOST": "https://gitea.projectium.com",
"GITEA_ACCESS_TOKEN": "c72bc0f14f623fec233d3c94b3a16397fe3649ef"
}
},
"gitea-torbonium": {
"command": "d:\\gitea-mcp\\gitea-mcp.exe",
"args": ["run", "-t", "stdio"],
"env": {
"GITEA_HOST": "https://gitea.torbonium.com",
"GITEA_ACCESS_TOKEN": "391c9ddbe113378bc87bb8184800ba954648fcf8"
}
},
"gitea-lan": {
"command": "d:\\gitea-mcp\\gitea-mcp.exe",
"args": ["run", "-t", "stdio"],
"env": {
"GITEA_HOST": "https://gitea.torbolan.com",
"GITEA_ACCESS_TOKEN": "YOUR_LAN_TOKEN_HERE"
},
"disabled": true
},
"podman": {
"command": "D:\\nodejs\\npx.cmd",
"args": ["-y", "podman-mcp-server@latest"],
"env": {
"DOCKER_HOST": "npipe:////./pipe/podman-machine-default"
}
},
"filesystem": {
"command": "d:\\nodejs\\node.exe",
"args": [
"c:\\Users\\games3\\AppData\\Roaming\\npm\\node_modules\\@modelcontextprotocol\\server-filesystem\\dist\\index.js",
"d:\\gitea\\flyer-crawler.projectium.com\\flyer-crawler.projectium.com"
]
},
"fetch": {
"command": "D:\\nodejs\\npx.cmd",
"args": ["-y", "@modelcontextprotocol/server-fetch"]
},
"io.github.ChromeDevTools/chrome-devtools-mcp": {
"type": "stdio",
"command": "npx",
"args": ["chrome-devtools-mcp@0.12.1"],
"gallery": "https://api.mcp.github.com",
"version": "0.12.1"
},
"markitdown": {
"command": "C:\\Users\\games3\\.local\\bin\\uvx.exe",
"args": ["markitdown-mcp"]
},
"sequential-thinking": {
"command": "D:\\nodejs\\npx.cmd",
"args": ["-y", "@modelcontextprotocol/server-sequential-thinking"]
},
"memory": {
"command": "D:\\nodejs\\npx.cmd",
"args": ["-y", "@modelcontextprotocol/server-memory"]
}
}
}

6
.gitignore vendored
View File

@@ -11,9 +11,13 @@ node_modules
dist dist
dist-ssr dist-ssr
*.local *.local
.env
*.tsbuildinfo
# Test coverage # Test coverage
coverage coverage
.nyc_output
.coverage
# Editor directories and files # Editor directories and files
.vscode/* .vscode/*
@@ -25,3 +29,5 @@ coverage
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.sw?
Thumbs.db
.claude

3
README.testing.md Normal file
View File

@@ -0,0 +1,3 @@
using powershell on win10 use this command to run the integration tests only in the container
podman exec -i flyer-crawler-dev npm run test:integration 2>&1 | Tee-Object -FilePath test-output.txt

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

View File

@@ -51,18 +51,19 @@ describe('Leaderboard', () => {
await waitFor(() => { await waitFor(() => {
expect(screen.getByRole('alert')).toBeInTheDocument(); expect(screen.getByRole('alert')).toBeInTheDocument();
expect(screen.getByText('Error: Failed to fetch leaderboard data.')).toBeInTheDocument(); // The query hook throws an error with the status code when JSON parsing fails
expect(screen.getByText('Error: Request failed with status 500')).toBeInTheDocument();
}); });
}); });
it('should display a generic error for unknown error types', async () => { it('should display a generic error for unknown error types', async () => {
const unknownError = 'A string error'; // Use an actual Error object since the component displays error.message
mockedApiClient.fetchLeaderboard.mockRejectedValue(unknownError); mockedApiClient.fetchLeaderboard.mockRejectedValue(new Error('A string error'));
renderWithProviders(<Leaderboard />); renderWithProviders(<Leaderboard />);
await waitFor(() => { await waitFor(() => {
expect(screen.getByRole('alert')).toBeInTheDocument(); expect(screen.getByRole('alert')).toBeInTheDocument();
expect(screen.getByText('Error: An unknown error occurred.')).toBeInTheDocument(); expect(screen.getByText('Error: A string error')).toBeInTheDocument();
}); });
}); });

View File

@@ -10,6 +10,7 @@ import {
createMockMasterGroceryItem, createMockMasterGroceryItem,
createMockHistoricalPriceDataPoint, createMockHistoricalPriceDataPoint,
} from '../../tests/utils/mockFactories'; } from '../../tests/utils/mockFactories';
import { QueryWrapper } from '../../tests/utils/renderWithProviders';
// Mock the apiClient // Mock the apiClient
vi.mock('../../services/apiClient'); vi.mock('../../services/apiClient');
@@ -18,6 +19,8 @@ vi.mock('../../services/apiClient');
vi.mock('../../hooks/useUserData'); vi.mock('../../hooks/useUserData');
const mockedUseUserData = useUserData as Mock; const mockedUseUserData = useUserData as Mock;
const renderWithQuery = (ui: React.ReactElement) => render(ui, { wrapper: QueryWrapper });
// Mock the logger // Mock the logger
vi.mock('../../services/logger', () => ({ vi.mock('../../services/logger', () => ({
logger: { logger: {
@@ -116,7 +119,7 @@ describe('PriceHistoryChart', () => {
isLoading: false, isLoading: false,
error: null, error: null,
}); });
render(<PriceHistoryChart />); renderWithQuery(<PriceHistoryChart />);
expect( expect(
screen.getByText('Add items to your watchlist to see their price trends over time.'), screen.getByText('Add items to your watchlist to see their price trends over time.'),
).toBeInTheDocument(); ).toBeInTheDocument();
@@ -124,13 +127,13 @@ describe('PriceHistoryChart', () => {
it('should display a loading state while fetching data', () => { it('should display a loading state while fetching data', () => {
vi.mocked(apiClient.fetchHistoricalPriceData).mockReturnValue(new Promise(() => {})); vi.mocked(apiClient.fetchHistoricalPriceData).mockReturnValue(new Promise(() => {}));
render(<PriceHistoryChart />); renderWithQuery(<PriceHistoryChart />);
expect(screen.getByText('Loading Price History...')).toBeInTheDocument(); expect(screen.getByText('Loading Price History...')).toBeInTheDocument();
}); });
it('should display an error message if the API call fails', async () => { it('should display an error message if the API call fails', async () => {
vi.mocked(apiClient.fetchHistoricalPriceData).mockRejectedValue(new Error('API is down')); vi.mocked(apiClient.fetchHistoricalPriceData).mockRejectedValue(new Error('API is down'));
render(<PriceHistoryChart />); renderWithQuery(<PriceHistoryChart />);
await waitFor(() => { await waitFor(() => {
// Use regex to match the error message text which might be split across elements // Use regex to match the error message text which might be split across elements
@@ -142,7 +145,7 @@ describe('PriceHistoryChart', () => {
vi.mocked(apiClient.fetchHistoricalPriceData).mockResolvedValue( vi.mocked(apiClient.fetchHistoricalPriceData).mockResolvedValue(
new Response(JSON.stringify([])), new Response(JSON.stringify([])),
); );
render(<PriceHistoryChart />); renderWithQuery(<PriceHistoryChart />);
await waitFor(() => { await waitFor(() => {
expect( expect(
@@ -157,7 +160,7 @@ describe('PriceHistoryChart', () => {
vi.mocked(apiClient.fetchHistoricalPriceData).mockResolvedValue( vi.mocked(apiClient.fetchHistoricalPriceData).mockResolvedValue(
new Response(JSON.stringify(mockPriceHistory)), new Response(JSON.stringify(mockPriceHistory)),
); );
render(<PriceHistoryChart />); renderWithQuery(<PriceHistoryChart />);
await waitFor(() => { await waitFor(() => {
// Check that the API was called with the correct item IDs // Check that the API was called with the correct item IDs
@@ -186,7 +189,7 @@ describe('PriceHistoryChart', () => {
error: null, error: null,
}); });
vi.mocked(apiClient.fetchHistoricalPriceData).mockReturnValue(new Promise(() => {})); vi.mocked(apiClient.fetchHistoricalPriceData).mockReturnValue(new Promise(() => {}));
render(<PriceHistoryChart />); renderWithQuery(<PriceHistoryChart />);
expect(screen.getByText('Loading Price History...')).toBeInTheDocument(); expect(screen.getByText('Loading Price History...')).toBeInTheDocument();
}); });
@@ -194,7 +197,7 @@ describe('PriceHistoryChart', () => {
vi.mocked(apiClient.fetchHistoricalPriceData).mockResolvedValue( vi.mocked(apiClient.fetchHistoricalPriceData).mockResolvedValue(
new Response(JSON.stringify(mockPriceHistory)), new Response(JSON.stringify(mockPriceHistory)),
); );
const { rerender } = render(<PriceHistoryChart />); const { rerender } = renderWithQuery(<PriceHistoryChart />);
// Initial render with items // Initial render with items
await waitFor(() => { await waitFor(() => {
@@ -242,7 +245,7 @@ describe('PriceHistoryChart', () => {
vi.mocked(apiClient.fetchHistoricalPriceData).mockResolvedValue( vi.mocked(apiClient.fetchHistoricalPriceData).mockResolvedValue(
new Response(JSON.stringify(dataWithSinglePoint)), new Response(JSON.stringify(dataWithSinglePoint)),
); );
render(<PriceHistoryChart />); renderWithQuery(<PriceHistoryChart />);
await waitFor(() => { await waitFor(() => {
expect(screen.getByTestId('line-Organic Bananas')).toBeInTheDocument(); expect(screen.getByTestId('line-Organic Bananas')).toBeInTheDocument();
@@ -271,7 +274,7 @@ describe('PriceHistoryChart', () => {
vi.mocked(apiClient.fetchHistoricalPriceData).mockResolvedValue( vi.mocked(apiClient.fetchHistoricalPriceData).mockResolvedValue(
new Response(JSON.stringify(dataWithDuplicateDate)), new Response(JSON.stringify(dataWithDuplicateDate)),
); );
render(<PriceHistoryChart />); renderWithQuery(<PriceHistoryChart />);
await waitFor(() => { await waitFor(() => {
const chart = screen.getByTestId('line-chart'); const chart = screen.getByTestId('line-chart');
@@ -305,7 +308,7 @@ describe('PriceHistoryChart', () => {
vi.mocked(apiClient.fetchHistoricalPriceData).mockResolvedValue( vi.mocked(apiClient.fetchHistoricalPriceData).mockResolvedValue(
new Response(JSON.stringify(dataWithZeroPrice)), new Response(JSON.stringify(dataWithZeroPrice)),
); );
render(<PriceHistoryChart />); renderWithQuery(<PriceHistoryChart />);
await waitFor(() => { await waitFor(() => {
const chart = screen.getByTestId('line-chart'); const chart = screen.getByTestId('line-chart');
@@ -330,7 +333,7 @@ describe('PriceHistoryChart', () => {
vi.mocked(apiClient.fetchHistoricalPriceData).mockResolvedValue( vi.mocked(apiClient.fetchHistoricalPriceData).mockResolvedValue(
new Response(JSON.stringify(malformedData)), new Response(JSON.stringify(malformedData)),
); );
render(<PriceHistoryChart />); renderWithQuery(<PriceHistoryChart />);
await waitFor(() => { await waitFor(() => {
// Should show "Not enough historical data" because all points are invalid or filtered // Should show "Not enough historical data" because all points are invalid or filtered
@@ -363,7 +366,7 @@ describe('PriceHistoryChart', () => {
vi.mocked(apiClient.fetchHistoricalPriceData).mockResolvedValue( vi.mocked(apiClient.fetchHistoricalPriceData).mockResolvedValue(
new Response(JSON.stringify(dataWithHigherPrice)), new Response(JSON.stringify(dataWithHigherPrice)),
); );
render(<PriceHistoryChart />); renderWithQuery(<PriceHistoryChart />);
await waitFor(() => { await waitFor(() => {
const chart = screen.getByTestId('line-chart'); const chart = screen.getByTestId('line-chart');
@@ -374,11 +377,12 @@ describe('PriceHistoryChart', () => {
}); });
it('should handle non-Error objects thrown during fetch', async () => { it('should handle non-Error objects thrown during fetch', async () => {
vi.mocked(apiClient.fetchHistoricalPriceData).mockRejectedValue('String Error'); // Use an actual Error object since the component displays error.message
render(<PriceHistoryChart />); vi.mocked(apiClient.fetchHistoricalPriceData).mockRejectedValue(new Error('Fetch failed'));
renderWithQuery(<PriceHistoryChart />);
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('Failed to load price history.')).toBeInTheDocument(); expect(screen.getByText(/Fetch failed/)).toBeInTheDocument();
}); });
}); });
}); });

View File

@@ -1,6 +1,7 @@
// src/hooks/mutations/useGeocodeMutation.ts // src/hooks/mutations/useGeocodeMutation.ts
import { useMutation } from '@tanstack/react-query'; import { useMutation } from '@tanstack/react-query';
import { geocodeAddress } from '../../services/apiClient'; import { geocodeAddress } from '../../services/apiClient';
import { notifyError } from '../../services/notificationService';
interface GeocodeResult { interface GeocodeResult {
lat: number; lat: number;
@@ -38,5 +39,8 @@ export const useGeocodeMutation = () => {
return response.json(); return response.json();
}, },
onError: (error: Error) => {
notifyError(error.message || 'Failed to geocode address');
},
}); });
}; };

View File

@@ -11,6 +11,7 @@ import {
createMockDealItem, createMockDealItem,
} from '../tests/utils/mockFactories'; } from '../tests/utils/mockFactories';
import { mockUseFlyers, mockUseUserData } from '../tests/setup/mockHooks'; import { mockUseFlyers, mockUseUserData } from '../tests/setup/mockHooks';
import { QueryWrapper } from '../tests/utils/renderWithProviders';
// Must explicitly call vi.mock() for apiClient // Must explicitly call vi.mock() for apiClient
vi.mock('../services/apiClient'); vi.mock('../services/apiClient');
@@ -130,7 +131,7 @@ describe('useActiveDeals Hook', () => {
new Response(JSON.stringify(mockFlyerItems)), new Response(JSON.stringify(mockFlyerItems)),
); );
const { result } = renderHook(() => useActiveDeals()); const { result } = renderHook(() => useActiveDeals(), { wrapper: QueryWrapper });
// The hook runs the effect almost immediately. We shouldn't strictly assert false // The hook runs the effect almost immediately. We shouldn't strictly assert false
// because depending on render timing, it might already be true. // because depending on render timing, it might already be true.
@@ -151,13 +152,12 @@ describe('useActiveDeals Hook', () => {
); );
mockedApiClient.fetchFlyerItemsForFlyers.mockResolvedValue(new Response(JSON.stringify([]))); mockedApiClient.fetchFlyerItemsForFlyers.mockResolvedValue(new Response(JSON.stringify([])));
const { result } = renderHook(() => useActiveDeals()); const { result } = renderHook(() => useActiveDeals(), { wrapper: QueryWrapper });
await waitFor(() => { await waitFor(() => {
// Only the valid flyer (id: 1) should be used in the API calls // Only the valid flyer (id: 1) should be used in the API calls
// The second argument is an AbortSignal, which we can match with expect.anything() expect(mockedApiClient.countFlyerItemsForFlyers).toHaveBeenCalledWith([1]);
expect(mockedApiClient.countFlyerItemsForFlyers).toHaveBeenCalledWith([1], expect.anything()); expect(mockedApiClient.fetchFlyerItemsForFlyers).toHaveBeenCalledWith([1]);
expect(mockedApiClient.fetchFlyerItemsForFlyers).toHaveBeenCalledWith([1], expect.anything());
expect(result.current.isLoading).toBe(false); expect(result.current.isLoading).toBe(false);
}); });
}); });
@@ -175,7 +175,7 @@ describe('useActiveDeals Hook', () => {
error: null, error: null,
}); // Override for this test }); // Override for this test
const { result } = renderHook(() => useActiveDeals()); const { result } = renderHook(() => useActiveDeals(), { wrapper: QueryWrapper });
await waitFor(() => { await waitFor(() => {
expect(result.current.isLoading).toBe(false); expect(result.current.isLoading).toBe(false);
@@ -197,7 +197,7 @@ describe('useActiveDeals Hook', () => {
isRefetchingFlyers: false, isRefetchingFlyers: false,
refetchFlyers: vi.fn(), refetchFlyers: vi.fn(),
}); });
const { result } = renderHook(() => useActiveDeals()); const { result } = renderHook(() => useActiveDeals(), { wrapper: QueryWrapper });
await waitFor(() => { await waitFor(() => {
expect(result.current.isLoading).toBe(false); expect(result.current.isLoading).toBe(false);
@@ -212,8 +212,10 @@ describe('useActiveDeals Hook', () => {
it('should set an error state if counting items fails', async () => { it('should set an error state if counting items fails', async () => {
const apiError = new Error('Network Failure'); const apiError = new Error('Network Failure');
mockedApiClient.countFlyerItemsForFlyers.mockRejectedValue(apiError); mockedApiClient.countFlyerItemsForFlyers.mockRejectedValue(apiError);
// Also mock fetchFlyerItemsForFlyers to avoid interference from the other query
mockedApiClient.fetchFlyerItemsForFlyers.mockResolvedValue(new Response(JSON.stringify([])));
const { result } = renderHook(() => useActiveDeals()); const { result } = renderHook(() => useActiveDeals(), { wrapper: QueryWrapper });
await waitFor(() => { await waitFor(() => {
expect(result.current.isLoading).toBe(false); expect(result.current.isLoading).toBe(false);
@@ -229,7 +231,7 @@ describe('useActiveDeals Hook', () => {
); );
mockedApiClient.fetchFlyerItemsForFlyers.mockRejectedValue(apiError); mockedApiClient.fetchFlyerItemsForFlyers.mockRejectedValue(apiError);
const { result } = renderHook(() => useActiveDeals()); const { result } = renderHook(() => useActiveDeals(), { wrapper: QueryWrapper });
await waitFor(() => { await waitFor(() => {
expect(result.current.isLoading).toBe(false); expect(result.current.isLoading).toBe(false);
@@ -248,7 +250,7 @@ describe('useActiveDeals Hook', () => {
new Response(JSON.stringify(mockFlyerItems)), new Response(JSON.stringify(mockFlyerItems)),
); );
const { result } = renderHook(() => useActiveDeals()); const { result } = renderHook(() => useActiveDeals(), { wrapper: QueryWrapper });
await waitFor(() => { await waitFor(() => {
const deal = result.current.activeDeals[0]; const deal = result.current.activeDeals[0];
@@ -294,7 +296,7 @@ describe('useActiveDeals Hook', () => {
new Response(JSON.stringify([itemInFlyerWithoutStore])), new Response(JSON.stringify([itemInFlyerWithoutStore])),
); );
const { result } = renderHook(() => useActiveDeals()); const { result } = renderHook(() => useActiveDeals(), { wrapper: QueryWrapper });
await waitFor(() => { await waitFor(() => {
expect(result.current.activeDeals).toHaveLength(1); expect(result.current.activeDeals).toHaveLength(1);
@@ -347,7 +349,7 @@ describe('useActiveDeals Hook', () => {
new Response(JSON.stringify(mixedItems)), new Response(JSON.stringify(mixedItems)),
); );
const { result } = renderHook(() => useActiveDeals()); const { result } = renderHook(() => useActiveDeals(), { wrapper: QueryWrapper });
await waitFor(() => { await waitFor(() => {
expect(result.current.isLoading).toBe(false); expect(result.current.isLoading).toBe(false);
@@ -372,7 +374,7 @@ describe('useActiveDeals Hook', () => {
mockedApiClient.countFlyerItemsForFlyers.mockReturnValue(countPromise); mockedApiClient.countFlyerItemsForFlyers.mockReturnValue(countPromise);
mockedApiClient.fetchFlyerItemsForFlyers.mockReturnValue(itemsPromise); mockedApiClient.fetchFlyerItemsForFlyers.mockReturnValue(itemsPromise);
const { result } = renderHook(() => useActiveDeals()); const { result } = renderHook(() => useActiveDeals(), { wrapper: QueryWrapper });
// Wait for the effect to trigger the API call and set loading to true // Wait for the effect to trigger the API call and set loading to true
await waitFor(() => expect(result.current.isLoading).toBe(true)); await waitFor(() => expect(result.current.isLoading).toBe(true));
@@ -388,20 +390,53 @@ describe('useActiveDeals Hook', () => {
}); });
}); });
it('should re-fetch data when watched items change', async () => { it('should re-filter active deals when watched items change (client-side filtering)', async () => {
// Initial render // With TanStack Query, changing watchedItems does NOT trigger a new API call
// because the query key is based on flyerIds, not watchedItems.
// The filtering happens client-side via useMemo. This is more efficient.
const allFlyerItems: FlyerItem[] = [
createMockFlyerItem({
flyer_item_id: 1,
flyer_id: 1,
item: 'Red Apples',
price_display: '$1.99',
price_in_cents: 199,
master_item_id: 101, // matches mockWatchedItems
master_item_name: 'Apples',
}),
createMockFlyerItem({
flyer_item_id: 2,
flyer_id: 1,
item: 'Fresh Bread',
price_display: '$2.99',
price_in_cents: 299,
master_item_id: 103, // NOT in initial mockWatchedItems
master_item_name: 'Bread',
}),
];
mockedApiClient.countFlyerItemsForFlyers.mockResolvedValue( mockedApiClient.countFlyerItemsForFlyers.mockResolvedValue(
new Response(JSON.stringify({ count: 1 })), new Response(JSON.stringify({ count: 2 })),
);
mockedApiClient.fetchFlyerItemsForFlyers.mockResolvedValue(
new Response(JSON.stringify(allFlyerItems)),
); );
mockedApiClient.fetchFlyerItemsForFlyers.mockResolvedValue(new Response(JSON.stringify([])));
const { rerender } = renderHook(() => useActiveDeals()); const { result, rerender } = renderHook(() => useActiveDeals(), { wrapper: QueryWrapper });
// Wait for initial data to load
await waitFor(() => { await waitFor(() => {
expect(mockedApiClient.fetchFlyerItemsForFlyers).toHaveBeenCalledTimes(1); expect(result.current.isLoading).toBe(false);
}); });
// Change watched items // Initially, only Apples (master_item_id: 101) should be in activeDeals
expect(result.current.activeDeals).toHaveLength(1);
expect(result.current.activeDeals[0].item).toBe('Red Apples');
// API should have been called exactly once
expect(mockedApiClient.fetchFlyerItemsForFlyers).toHaveBeenCalledTimes(1);
// Now add Bread to watched items
const newWatchedItems = [ const newWatchedItems = [
...mockWatchedItems, ...mockWatchedItems,
createMockMasterGroceryItem({ master_grocery_item_id: 103, name: 'Bread' }), createMockMasterGroceryItem({ master_grocery_item_id: 103, name: 'Bread' }),
@@ -415,13 +450,21 @@ describe('useActiveDeals Hook', () => {
error: null, error: null,
}); });
// Rerender // Rerender to pick up new watchedItems
rerender(); rerender();
// After rerender, client-side filtering should now include both items
await waitFor(() => { await waitFor(() => {
// Should have been called again expect(result.current.activeDeals).toHaveLength(2);
expect(mockedApiClient.fetchFlyerItemsForFlyers).toHaveBeenCalledTimes(2);
}); });
// Verify both items are present
const dealItems = result.current.activeDeals.map((d) => d.item);
expect(dealItems).toContain('Red Apples');
expect(dealItems).toContain('Fresh Bread');
// The API should NOT be called again - data is already cached
expect(mockedApiClient.fetchFlyerItemsForFlyers).toHaveBeenCalledTimes(1);
}); });
it('should include flyers valid exactly on the start or end date', async () => { it('should include flyers valid exactly on the start or end date', async () => {
@@ -480,14 +523,11 @@ describe('useActiveDeals Hook', () => {
); );
mockedApiClient.fetchFlyerItemsForFlyers.mockResolvedValue(new Response(JSON.stringify([]))); mockedApiClient.fetchFlyerItemsForFlyers.mockResolvedValue(new Response(JSON.stringify([])));
renderHook(() => useActiveDeals()); renderHook(() => useActiveDeals(), { wrapper: QueryWrapper });
await waitFor(() => { await waitFor(() => {
// Should call with IDs 10, 11, 12. Should NOT include 13. // Should call with IDs 10, 11, 12. Should NOT include 13.
expect(mockedApiClient.countFlyerItemsForFlyers).toHaveBeenCalledWith( expect(mockedApiClient.countFlyerItemsForFlyers).toHaveBeenCalledWith([10, 11, 12]);
[10, 11, 12],
expect.anything(),
);
}); });
}); });
@@ -511,7 +551,7 @@ describe('useActiveDeals Hook', () => {
new Response(JSON.stringify([incompleteItem])), new Response(JSON.stringify([incompleteItem])),
); );
const { result } = renderHook(() => useActiveDeals()); const { result } = renderHook(() => useActiveDeals(), { wrapper: QueryWrapper });
await waitFor(() => { await waitFor(() => {
expect(result.current.activeDeals).toHaveLength(1); expect(result.current.activeDeals).toHaveLength(1);

View File

@@ -2,6 +2,7 @@
import React, { ReactNode } from 'react'; import React, { ReactNode } from 'react';
import { renderHook, waitFor, act } from '@testing-library/react'; import { renderHook, waitFor, act } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useAuth } from './useAuth'; import { useAuth } from './useAuth';
import { AuthProvider } from '../providers/AuthProvider'; import { AuthProvider } from '../providers/AuthProvider';
import * as apiClient from '../services/apiClient'; import * as apiClient from '../services/apiClient';
@@ -24,8 +25,29 @@ const mockProfile: UserProfile = createMockUserProfile({
user: { user_id: 'user-abc-123', email: 'test@example.com' }, user: { user_id: 'user-abc-123', email: 'test@example.com' },
}); });
// Create a fresh QueryClient for each test to ensure isolation
const createTestQueryClient = () =>
new QueryClient({
defaultOptions: {
queries: {
retry: false,
gcTime: 0,
},
mutations: {
retry: false,
},
},
});
// Reusable wrapper for rendering the hook within the provider // Reusable wrapper for rendering the hook within the provider
const wrapper = ({ children }: { children: ReactNode }) => <AuthProvider>{children}</AuthProvider>; const wrapper = ({ children }: { children: ReactNode }) => {
const testQueryClient = createTestQueryClient();
return (
<QueryClientProvider client={testQueryClient}>
<AuthProvider>{children}</AuthProvider>
</QueryClientProvider>
);
};
describe('useAuth Hook and AuthProvider', () => { describe('useAuth Hook and AuthProvider', () => {
beforeEach(() => { beforeEach(() => {
@@ -131,7 +153,7 @@ describe('useAuth Hook and AuthProvider', () => {
expect(result.current.userProfile).toBeNull(); expect(result.current.userProfile).toBeNull();
expect(mockedTokenStorage.removeToken).toHaveBeenCalled(); expect(mockedTokenStorage.removeToken).toHaveBeenCalled();
expect(logger.warn).toHaveBeenCalledWith( expect(logger.warn).toHaveBeenCalledWith(
'[AuthProvider-Effect] Token was present but validation returned no profile. Signing out.', '[AuthProvider] Token was present but profile is null. Signing out.',
); );
}); });

View File

@@ -6,6 +6,7 @@ import { useUserAddressQuery } from './queries/useUserAddressQuery';
import { useGeocodeMutation } from './mutations/useGeocodeMutation'; import { useGeocodeMutation } from './mutations/useGeocodeMutation';
import { logger } from '../services/logger.client'; import { logger } from '../services/logger.client';
import { useDebounce } from './useDebounce'; import { useDebounce } from './useDebounce';
import { notifyError } from '../services/notificationService';
/** /**
* Helper to generate a consistent address string for geocoding. * Helper to generate a consistent address string for geocoding.
@@ -37,14 +38,22 @@ export const useProfileAddress = (userProfile: UserProfile | null, isOpen: boole
const [initialAddress, setInitialAddress] = useState<Partial<Address>>({}); const [initialAddress, setInitialAddress] = useState<Partial<Address>>({});
// TanStack Query for fetching the address // TanStack Query for fetching the address
const { data: fetchedAddress, isLoading: isFetchingAddress } = useUserAddressQuery( const {
userProfile?.address_id, data: fetchedAddress,
isOpen && !!userProfile?.address_id, isLoading: isFetchingAddress,
); error: addressError,
} = useUserAddressQuery(userProfile?.address_id, isOpen && !!userProfile?.address_id);
// TanStack Query mutation for geocoding // TanStack Query mutation for geocoding
const geocodeMutation = useGeocodeMutation(); const geocodeMutation = useGeocodeMutation();
// Effect to handle address fetch errors
useEffect(() => {
if (addressError) {
notifyError(addressError.message || 'Failed to fetch address');
}
}, [addressError]);
// Effect to sync fetched address to local state // Effect to sync fetched address to local state
useEffect(() => { useEffect(() => {
if (!isOpen || !userProfile) { if (!isOpen || !userProfile) {
@@ -64,8 +73,13 @@ export const useProfileAddress = (userProfile: UserProfile | null, isOpen: boole
logger.debug('[useProfileAddress] Profile has no address_id. Resetting address form.'); logger.debug('[useProfileAddress] Profile has no address_id. Resetting address form.');
setAddress({}); setAddress({});
setInitialAddress({}); setInitialAddress({});
} else if (!isFetchingAddress && !fetchedAddress && userProfile.address_id) {
// Fetch completed but returned null - log a warning
logger.warn(
`[useProfileAddress] Fetch returned null for addressId: ${userProfile.address_id}.`,
);
} }
}, [isOpen, userProfile, fetchedAddress]); }, [isOpen, userProfile, fetchedAddress, isFetchingAddress]);
const handleAddressChange = useCallback((field: keyof Address, value: string) => { const handleAddressChange = useCallback((field: keyof Address, value: string) => {
setAddress((prev) => ({ ...prev, [field]: value })); setAddress((prev) => ({ ...prev, [field]: value }));

View File

@@ -5,14 +5,16 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import MyDealsPage from './MyDealsPage'; import MyDealsPage from './MyDealsPage';
import * as apiClient from '../services/apiClient'; import * as apiClient from '../services/apiClient';
import type { WatchedItemDeal } from '../types'; import type { WatchedItemDeal } from '../types';
import { logger } from '../services/logger.client';
import { createMockWatchedItemDeal } from '../tests/utils/mockFactories'; import { createMockWatchedItemDeal } from '../tests/utils/mockFactories';
import { QueryWrapper } from '../tests/utils/renderWithProviders';
// Must explicitly call vi.mock() for apiClient // Must explicitly call vi.mock() for apiClient
vi.mock('../services/apiClient'); vi.mock('../services/apiClient');
const mockedApiClient = vi.mocked(apiClient); const mockedApiClient = vi.mocked(apiClient);
const renderWithQuery = (ui: React.ReactElement) => render(ui, { wrapper: QueryWrapper });
// Mock lucide-react icons to prevent rendering errors in the test environment // Mock lucide-react icons to prevent rendering errors in the test environment
vi.mock('lucide-react', () => ({ vi.mock('lucide-react', () => ({
AlertCircle: () => <div data-testid="alert-circle-icon" />, AlertCircle: () => <div data-testid="alert-circle-icon" />,
@@ -29,7 +31,7 @@ describe('MyDealsPage', () => {
it('should display a loading message initially', () => { it('should display a loading message initially', () => {
// Mock a pending promise // Mock a pending promise
mockedApiClient.fetchBestSalePrices.mockReturnValue(new Promise(() => {})); mockedApiClient.fetchBestSalePrices.mockReturnValue(new Promise(() => {}));
render(<MyDealsPage />); renderWithQuery(<MyDealsPage />);
expect(screen.getByText('Loading your deals...')).toBeInTheDocument(); expect(screen.getByText('Loading your deals...')).toBeInTheDocument();
}); });
@@ -37,48 +39,35 @@ describe('MyDealsPage', () => {
mockedApiClient.fetchBestSalePrices.mockResolvedValue( mockedApiClient.fetchBestSalePrices.mockResolvedValue(
new Response(null, { status: 500, statusText: 'Server Error' }), new Response(null, { status: 500, statusText: 'Server Error' }),
); );
render(<MyDealsPage />); renderWithQuery(<MyDealsPage />);
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('Error')).toBeInTheDocument(); expect(screen.getByText('Error')).toBeInTheDocument();
expect( // The query hook throws an error with status code when JSON parsing fails on non-ok response
screen.getByText('Failed to fetch deals. Please try again later.'), expect(screen.getByText('Request failed with status 500')).toBeInTheDocument();
).toBeInTheDocument();
}); });
expect(logger.error).toHaveBeenCalledWith(
'Error fetching watched item deals:',
'Failed to fetch deals. Please try again later.',
);
}); });
it('should handle network errors and log them', async () => { it('should handle network errors and log them', async () => {
const networkError = new Error('Network connection failed'); const networkError = new Error('Network connection failed');
mockedApiClient.fetchBestSalePrices.mockRejectedValue(networkError); mockedApiClient.fetchBestSalePrices.mockRejectedValue(networkError);
render(<MyDealsPage />); renderWithQuery(<MyDealsPage />);
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('Error')).toBeInTheDocument(); expect(screen.getByText('Error')).toBeInTheDocument();
expect(screen.getByText('Network connection failed')).toBeInTheDocument(); expect(screen.getByText('Network connection failed')).toBeInTheDocument();
}); });
expect(logger.error).toHaveBeenCalledWith(
'Error fetching watched item deals:',
'Network connection failed',
);
}); });
it('should handle unknown errors and log them', async () => { it('should handle unknown errors and log them', async () => {
// Mock a rejection with a non-Error object (e.g., a string) to trigger the fallback error message // Mock a rejection with an Error object - TanStack Query passes through Error objects
mockedApiClient.fetchBestSalePrices.mockRejectedValue('Unknown failure'); mockedApiClient.fetchBestSalePrices.mockRejectedValue(new Error('Unknown failure'));
render(<MyDealsPage />); renderWithQuery(<MyDealsPage />);
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('Error')).toBeInTheDocument(); expect(screen.getByText('Error')).toBeInTheDocument();
expect(screen.getByText('An unknown error occurred.')).toBeInTheDocument(); expect(screen.getByText('Unknown failure')).toBeInTheDocument();
}); });
expect(logger.error).toHaveBeenCalledWith(
'Error fetching watched item deals:',
'An unknown error occurred.',
);
}); });
it('should display a message when no deals are found', async () => { it('should display a message when no deals are found', async () => {
@@ -87,7 +76,7 @@ describe('MyDealsPage', () => {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
}), }),
); );
render(<MyDealsPage />); renderWithQuery(<MyDealsPage />);
await waitFor(() => { await waitFor(() => {
expect( expect(
@@ -121,7 +110,7 @@ describe('MyDealsPage', () => {
}), }),
); );
render(<MyDealsPage />); renderWithQuery(<MyDealsPage />);
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('Organic Bananas')).toBeInTheDocument(); expect(screen.getByText('Organic Bananas')).toBeInTheDocument();

View File

@@ -10,10 +10,13 @@ import {
createMockUserAchievement, createMockUserAchievement,
createMockUser, createMockUser,
} from '../tests/utils/mockFactories'; } from '../tests/utils/mockFactories';
import { QueryWrapper } from '../tests/utils/renderWithProviders';
// Must explicitly call vi.mock() for apiClient // Must explicitly call vi.mock() for apiClient
vi.mock('../services/apiClient'); vi.mock('../services/apiClient');
const renderWithQuery = (ui: React.ReactElement) => render(ui, { wrapper: QueryWrapper });
const mockedNotificationService = vi.mocked(await import('../services/notificationService')); const mockedNotificationService = vi.mocked(await import('../services/notificationService'));
vi.mock('../components/AchievementsList', () => ({ vi.mock('../components/AchievementsList', () => ({
AchievementsList: ({ achievements }: { achievements: (UserAchievement & Achievement)[] }) => ( AchievementsList: ({ achievements }: { achievements: (UserAchievement & Achievement)[] }) => (
@@ -54,7 +57,7 @@ describe('UserProfilePage', () => {
it('should display a loading message initially', () => { it('should display a loading message initially', () => {
mockedApiClient.getAuthenticatedUserProfile.mockReturnValue(new Promise(() => {})); mockedApiClient.getAuthenticatedUserProfile.mockReturnValue(new Promise(() => {}));
mockedApiClient.getUserAchievements.mockReturnValue(new Promise(() => {})); mockedApiClient.getUserAchievements.mockReturnValue(new Promise(() => {}));
render(<UserProfilePage />); renderWithQuery(<UserProfilePage />);
expect(screen.getByText('Loading profile...')).toBeInTheDocument(); expect(screen.getByText('Loading profile...')).toBeInTheDocument();
}); });
@@ -63,7 +66,7 @@ describe('UserProfilePage', () => {
mockedApiClient.getUserAchievements.mockResolvedValue( mockedApiClient.getUserAchievements.mockResolvedValue(
new Response(JSON.stringify(mockAchievements)), new Response(JSON.stringify(mockAchievements)),
); );
render(<UserProfilePage />); renderWithQuery(<UserProfilePage />);
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('Error: Network Error')).toBeInTheDocument(); expect(screen.getByText('Error: Network Error')).toBeInTheDocument();
@@ -77,11 +80,11 @@ describe('UserProfilePage', () => {
mockedApiClient.getUserAchievements.mockResolvedValue( mockedApiClient.getUserAchievements.mockResolvedValue(
new Response(JSON.stringify(mockAchievements)), new Response(JSON.stringify(mockAchievements)),
); );
render(<UserProfilePage />); renderWithQuery(<UserProfilePage />);
await waitFor(() => { await waitFor(() => {
// The component throws 'Failed to fetch user profile.' because it just checks `!profileRes.ok` // The query hook parses the error message from the JSON body
expect(screen.getByText('Error: Failed to fetch user profile.')).toBeInTheDocument(); expect(screen.getByText('Error: Auth Failed')).toBeInTheDocument();
}); });
}); });
@@ -92,11 +95,11 @@ describe('UserProfilePage', () => {
mockedApiClient.getUserAchievements.mockResolvedValue( mockedApiClient.getUserAchievements.mockResolvedValue(
new Response(JSON.stringify({ message: 'Server Busy' }), { status: 503 }), new Response(JSON.stringify({ message: 'Server Busy' }), { status: 503 }),
); );
render(<UserProfilePage />); renderWithQuery(<UserProfilePage />);
await waitFor(() => { await waitFor(() => {
// The component throws 'Failed to fetch user achievements.' // The query hook parses the error message from the JSON body
expect(screen.getByText('Error: Failed to fetch user achievements.')).toBeInTheDocument(); expect(screen.getByText('Error: Server Busy')).toBeInTheDocument();
}); });
}); });
@@ -105,7 +108,7 @@ describe('UserProfilePage', () => {
new Response(JSON.stringify(mockProfile)), new Response(JSON.stringify(mockProfile)),
); );
mockedApiClient.getUserAchievements.mockRejectedValue(new Error('Achievements service down')); mockedApiClient.getUserAchievements.mockRejectedValue(new Error('Achievements service down'));
render(<UserProfilePage />); renderWithQuery(<UserProfilePage />);
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('Error: Achievements service down')).toBeInTheDocument(); expect(screen.getByText('Error: Achievements service down')).toBeInTheDocument();
@@ -113,14 +116,15 @@ describe('UserProfilePage', () => {
}); });
it('should handle unknown errors during fetch', async () => { it('should handle unknown errors during fetch', async () => {
mockedApiClient.getAuthenticatedUserProfile.mockRejectedValue('Unknown error string'); // Use an actual Error object since the hook extracts error.message
mockedApiClient.getAuthenticatedUserProfile.mockRejectedValue(new Error('Unknown error'));
mockedApiClient.getUserAchievements.mockResolvedValue( mockedApiClient.getUserAchievements.mockResolvedValue(
new Response(JSON.stringify(mockAchievements)), new Response(JSON.stringify(mockAchievements)),
); );
render(<UserProfilePage />); renderWithQuery(<UserProfilePage />);
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('Error: An unknown error occurred.')).toBeInTheDocument(); expect(screen.getByText('Error: Unknown error')).toBeInTheDocument();
}); });
}); });
@@ -130,7 +134,7 @@ describe('UserProfilePage', () => {
); );
// Mock a successful response but with a null body for achievements // Mock a successful response but with a null body for achievements
mockedApiClient.getUserAchievements.mockResolvedValue(new Response(JSON.stringify(null))); mockedApiClient.getUserAchievements.mockResolvedValue(new Response(JSON.stringify(null)));
render(<UserProfilePage />); renderWithQuery(<UserProfilePage />);
await waitFor(() => { await waitFor(() => {
expect(screen.getByRole('heading', { name: 'Test User' })).toBeInTheDocument(); expect(screen.getByRole('heading', { name: 'Test User' })).toBeInTheDocument();
@@ -149,7 +153,7 @@ describe('UserProfilePage', () => {
mockedApiClient.getUserAchievements.mockResolvedValue( mockedApiClient.getUserAchievements.mockResolvedValue(
new Response(JSON.stringify(mockAchievements)), new Response(JSON.stringify(mockAchievements)),
); );
render(<UserProfilePage />); renderWithQuery(<UserProfilePage />);
await waitFor(() => { await waitFor(() => {
expect(screen.getByRole('heading', { name: 'Test User' })).toBeInTheDocument(); expect(screen.getByRole('heading', { name: 'Test User' })).toBeInTheDocument();
@@ -169,7 +173,7 @@ describe('UserProfilePage', () => {
mockedApiClient.getUserAchievements.mockResolvedValue( mockedApiClient.getUserAchievements.mockResolvedValue(
new Response(JSON.stringify(mockAchievements)), new Response(JSON.stringify(mockAchievements)),
); );
render(<UserProfilePage />); renderWithQuery(<UserProfilePage />);
expect(await screen.findByText('Could not load user profile.')).toBeInTheDocument(); expect(await screen.findByText('Could not load user profile.')).toBeInTheDocument();
}); });
@@ -182,7 +186,7 @@ describe('UserProfilePage', () => {
); );
mockedApiClient.getUserAchievements.mockResolvedValue(new Response(JSON.stringify([]))); mockedApiClient.getUserAchievements.mockResolvedValue(new Response(JSON.stringify([])));
render(<UserProfilePage />); renderWithQuery(<UserProfilePage />);
// Wait for the component to render with the fetched data // Wait for the component to render with the fetched data
await waitFor(() => { await waitFor(() => {
@@ -204,7 +208,7 @@ describe('UserProfilePage', () => {
new Response(JSON.stringify(mockAchievements)), new Response(JSON.stringify(mockAchievements)),
); );
render(<UserProfilePage />); renderWithQuery(<UserProfilePage />);
await waitFor(() => { await waitFor(() => {
const avatar = screen.getByAltText('User Avatar'); const avatar = screen.getByAltText('User Avatar');
@@ -220,7 +224,7 @@ describe('UserProfilePage', () => {
mockedApiClient.getUserAchievements.mockResolvedValue( mockedApiClient.getUserAchievements.mockResolvedValue(
new Response(JSON.stringify(mockAchievements)), new Response(JSON.stringify(mockAchievements)),
); );
render(<UserProfilePage />); renderWithQuery(<UserProfilePage />);
await screen.findByAltText('User Avatar'); await screen.findByAltText('User Avatar');
@@ -248,7 +252,7 @@ describe('UserProfilePage', () => {
mockedApiClient.updateUserProfile.mockResolvedValue( mockedApiClient.updateUserProfile.mockResolvedValue(
new Response(JSON.stringify(updatedProfile)), new Response(JSON.stringify(updatedProfile)),
); );
render(<UserProfilePage />); renderWithQuery(<UserProfilePage />);
await screen.findByText('Test User'); await screen.findByText('Test User');
@@ -266,7 +270,7 @@ describe('UserProfilePage', () => {
}); });
it('should allow canceling the name edit', async () => { it('should allow canceling the name edit', async () => {
render(<UserProfilePage />); renderWithQuery(<UserProfilePage />);
await screen.findByText('Test User'); await screen.findByText('Test User');
fireEvent.click(screen.getByRole('button', { name: /edit/i })); fireEvent.click(screen.getByRole('button', { name: /edit/i }));
@@ -280,7 +284,7 @@ describe('UserProfilePage', () => {
mockedApiClient.updateUserProfile.mockResolvedValue( mockedApiClient.updateUserProfile.mockResolvedValue(
new Response(JSON.stringify({ message: 'Validation failed' }), { status: 400 }), new Response(JSON.stringify({ message: 'Validation failed' }), { status: 400 }),
); );
render(<UserProfilePage />); renderWithQuery(<UserProfilePage />);
await screen.findByText('Test User'); await screen.findByText('Test User');
fireEvent.click(screen.getByRole('button', { name: /edit/i })); fireEvent.click(screen.getByRole('button', { name: /edit/i }));
@@ -297,7 +301,7 @@ describe('UserProfilePage', () => {
mockedApiClient.updateUserProfile.mockResolvedValue( mockedApiClient.updateUserProfile.mockResolvedValue(
new Response(JSON.stringify({}), { status: 400 }), new Response(JSON.stringify({}), { status: 400 }),
); );
render(<UserProfilePage />); renderWithQuery(<UserProfilePage />);
await screen.findByText('Test User'); await screen.findByText('Test User');
fireEvent.click(screen.getByRole('button', { name: /edit/i })); fireEvent.click(screen.getByRole('button', { name: /edit/i }));
@@ -316,7 +320,7 @@ describe('UserProfilePage', () => {
it('should handle non-ok response with null body when saving name', async () => { it('should handle non-ok response with null body when saving name', async () => {
// This tests the case where the server returns an error status but an empty/null body. // This tests the case where the server returns an error status but an empty/null body.
mockedApiClient.updateUserProfile.mockResolvedValue(new Response(null, { status: 500 })); mockedApiClient.updateUserProfile.mockResolvedValue(new Response(null, { status: 500 }));
render(<UserProfilePage />); renderWithQuery(<UserProfilePage />);
await screen.findByText('Test User'); await screen.findByText('Test User');
fireEvent.click(screen.getByRole('button', { name: /edit/i })); fireEvent.click(screen.getByRole('button', { name: /edit/i }));
@@ -333,7 +337,7 @@ describe('UserProfilePage', () => {
it('should handle unknown errors when saving name', async () => { it('should handle unknown errors when saving name', async () => {
mockedApiClient.updateUserProfile.mockRejectedValue('Unknown update error'); mockedApiClient.updateUserProfile.mockRejectedValue('Unknown update error');
render(<UserProfilePage />); renderWithQuery(<UserProfilePage />);
await screen.findByText('Test User'); await screen.findByText('Test User');
fireEvent.click(screen.getByRole('button', { name: /edit/i })); fireEvent.click(screen.getByRole('button', { name: /edit/i }));
@@ -374,7 +378,7 @@ describe('UserProfilePage', () => {
}); });
}); });
render(<UserProfilePage />); renderWithQuery(<UserProfilePage />);
await screen.findByAltText('User Avatar'); await screen.findByAltText('User Avatar');
@@ -411,7 +415,7 @@ describe('UserProfilePage', () => {
}); });
it('should not attempt to upload if no file is selected', async () => { it('should not attempt to upload if no file is selected', async () => {
render(<UserProfilePage />); renderWithQuery(<UserProfilePage />);
await screen.findByAltText('User Avatar'); await screen.findByAltText('User Avatar');
const fileInput = screen.getByTestId('avatar-file-input'); const fileInput = screen.getByTestId('avatar-file-input');
@@ -426,7 +430,7 @@ describe('UserProfilePage', () => {
mockedApiClient.uploadAvatar.mockResolvedValue( mockedApiClient.uploadAvatar.mockResolvedValue(
new Response(JSON.stringify({ message: 'File too large' }), { status: 413 }), new Response(JSON.stringify({ message: 'File too large' }), { status: 413 }),
); );
render(<UserProfilePage />); renderWithQuery(<UserProfilePage />);
await screen.findByAltText('User Avatar'); await screen.findByAltText('User Avatar');
const fileInput = screen.getByTestId('avatar-file-input'); const fileInput = screen.getByTestId('avatar-file-input');
@@ -442,7 +446,7 @@ describe('UserProfilePage', () => {
mockedApiClient.uploadAvatar.mockResolvedValue( mockedApiClient.uploadAvatar.mockResolvedValue(
new Response(JSON.stringify({}), { status: 413 }), new Response(JSON.stringify({}), { status: 413 }),
); );
render(<UserProfilePage />); renderWithQuery(<UserProfilePage />);
await screen.findByAltText('User Avatar'); await screen.findByAltText('User Avatar');
const fileInput = screen.getByTestId('avatar-file-input'); const fileInput = screen.getByTestId('avatar-file-input');
@@ -459,7 +463,7 @@ describe('UserProfilePage', () => {
it('should handle non-ok response with null body when uploading avatar', async () => { it('should handle non-ok response with null body when uploading avatar', async () => {
mockedApiClient.uploadAvatar.mockResolvedValue(new Response(null, { status: 500 })); mockedApiClient.uploadAvatar.mockResolvedValue(new Response(null, { status: 500 }));
render(<UserProfilePage />); renderWithQuery(<UserProfilePage />);
await screen.findByAltText('User Avatar'); await screen.findByAltText('User Avatar');
const fileInput = screen.getByTestId('avatar-file-input'); const fileInput = screen.getByTestId('avatar-file-input');
@@ -475,7 +479,7 @@ describe('UserProfilePage', () => {
it('should handle unknown errors when uploading avatar', async () => { it('should handle unknown errors when uploading avatar', async () => {
mockedApiClient.uploadAvatar.mockRejectedValue('Unknown upload error'); mockedApiClient.uploadAvatar.mockRejectedValue('Unknown upload error');
render(<UserProfilePage />); renderWithQuery(<UserProfilePage />);
await screen.findByAltText('User Avatar'); await screen.findByAltText('User Avatar');
const fileInput = screen.getByTestId('avatar-file-input'); const fileInput = screen.getByTestId('avatar-file-input');
@@ -500,7 +504,7 @@ describe('UserProfilePage', () => {
), ),
); );
render(<UserProfilePage />); renderWithQuery(<UserProfilePage />);
await screen.findByAltText('User Avatar'); await screen.findByAltText('User Avatar');
const fileInput = screen.getByTestId('avatar-file-input'); const fileInput = screen.getByTestId('avatar-file-input');

View File

@@ -6,10 +6,13 @@ import { ActivityLog } from './ActivityLog';
import { useActivityLogQuery } from '../../hooks/queries/useActivityLogQuery'; import { useActivityLogQuery } from '../../hooks/queries/useActivityLogQuery';
import type { ActivityLogItem, UserProfile } from '../../types'; import type { ActivityLogItem, UserProfile } from '../../types';
import { createMockActivityLogItem, createMockUserProfile } from '../../tests/utils/mockFactories'; import { createMockActivityLogItem, createMockUserProfile } from '../../tests/utils/mockFactories';
import { QueryWrapper } from '../../tests/utils/renderWithProviders';
// Mock the TanStack Query hook // Mock the TanStack Query hook
vi.mock('../../hooks/queries/useActivityLogQuery'); vi.mock('../../hooks/queries/useActivityLogQuery');
const renderWithQuery = (ui: React.ReactElement) => render(ui, { wrapper: QueryWrapper });
const mockedUseActivityLogQuery = vi.mocked(useActivityLogQuery); const mockedUseActivityLogQuery = vi.mocked(useActivityLogQuery);
// Mock date-fns to return a consistent value for snapshots // Mock date-fns to return a consistent value for snapshots
@@ -86,7 +89,7 @@ describe('ActivityLog', () => {
}); });
it('should not render if userProfile is null', () => { it('should not render if userProfile is null', () => {
const { container } = render(<ActivityLog userProfile={null} onLogClick={vi.fn()} />); const { container } = renderWithQuery(<ActivityLog userProfile={null} onLogClick={vi.fn()} />);
expect(container).toBeEmptyDOMElement(); expect(container).toBeEmptyDOMElement();
}); });
@@ -97,7 +100,7 @@ describe('ActivityLog', () => {
error: null, error: null,
} as any); } as any);
render(<ActivityLog userProfile={mockUserProfile} onLogClick={vi.fn()} />); renderWithQuery(<ActivityLog userProfile={mockUserProfile} onLogClick={vi.fn()} />);
expect(screen.getByText('Loading activity...')).toBeInTheDocument(); expect(screen.getByText('Loading activity...')).toBeInTheDocument();
}); });
@@ -109,7 +112,7 @@ describe('ActivityLog', () => {
error: new Error('API is down'), error: new Error('API is down'),
} as any); } as any);
render(<ActivityLog userProfile={mockUserProfile} onLogClick={vi.fn()} />); renderWithQuery(<ActivityLog userProfile={mockUserProfile} onLogClick={vi.fn()} />);
expect(screen.getByText('API is down')).toBeInTheDocument(); expect(screen.getByText('API is down')).toBeInTheDocument();
}); });
@@ -120,7 +123,7 @@ describe('ActivityLog', () => {
error: null, error: null,
} as any); } as any);
render(<ActivityLog userProfile={mockUserProfile} onLogClick={vi.fn()} />); renderWithQuery(<ActivityLog userProfile={mockUserProfile} onLogClick={vi.fn()} />);
expect(screen.getByText('No recent activity to show.')).toBeInTheDocument(); expect(screen.getByText('No recent activity to show.')).toBeInTheDocument();
}); });
@@ -131,7 +134,7 @@ describe('ActivityLog', () => {
error: null, error: null,
} as any); } as any);
render(<ActivityLog userProfile={mockUserProfile} />); renderWithQuery(<ActivityLog userProfile={mockUserProfile} />);
// Check for specific text from different log types // Check for specific text from different log types
expect(screen.getByText('Walmart')).toBeInTheDocument(); expect(screen.getByText('Walmart')).toBeInTheDocument();
@@ -166,7 +169,7 @@ describe('ActivityLog', () => {
error: null, error: null,
} as any); } as any);
render(<ActivityLog userProfile={mockUserProfile} onLogClick={onLogClickMock} />); renderWithQuery(<ActivityLog userProfile={mockUserProfile} onLogClick={onLogClickMock} />);
// Recipe Created // Recipe Created
const clickableRecipe = screen.getByText('Pasta Carbonara'); const clickableRecipe = screen.getByText('Pasta Carbonara');
@@ -193,7 +196,7 @@ describe('ActivityLog', () => {
error: null, error: null,
} as any); } as any);
render(<ActivityLog userProfile={mockUserProfile} />); renderWithQuery(<ActivityLog userProfile={mockUserProfile} />);
const recipeName = screen.getByText('Pasta Carbonara'); const recipeName = screen.getByText('Pasta Carbonara');
expect(recipeName).not.toHaveClass('cursor-pointer'); expect(recipeName).not.toHaveClass('cursor-pointer');
@@ -257,7 +260,7 @@ describe('ActivityLog', () => {
error: null, error: null,
} as any); } as any);
render(<ActivityLog userProfile={mockUserProfile} />); renderWithQuery(<ActivityLog userProfile={mockUserProfile} />);
expect(screen.getAllByText('a store')[0]).toBeInTheDocument(); expect(screen.getAllByText('a store')[0]).toBeInTheDocument();
expect(screen.getByText('Untitled Recipe')).toBeInTheDocument(); expect(screen.getByText('Untitled Recipe')).toBeInTheDocument();
@@ -268,9 +271,7 @@ describe('ActivityLog', () => {
// Check for avatar with fallback alt text // Check for avatar with fallback alt text
const avatars = screen.getAllByRole('img'); const avatars = screen.getAllByRole('img');
const avatarWithFallbackAlt = avatars.find( const avatarWithFallbackAlt = avatars.find((img) => img.getAttribute('alt') === 'User Avatar');
(img) => img.getAttribute('alt') === 'User Avatar',
);
expect(avatarWithFallbackAlt).toBeInTheDocument(); expect(avatarWithFallbackAlt).toBeInTheDocument();
}); });
}); });

View File

@@ -8,6 +8,7 @@ import { useApplicationStatsQuery } from '../../hooks/queries/useApplicationStat
import type { AppStats } from '../../services/apiClient'; import type { AppStats } from '../../services/apiClient';
import { createMockAppStats } from '../../tests/utils/mockFactories'; import { createMockAppStats } from '../../tests/utils/mockFactories';
import { StatCard } from '../../components/StatCard'; import { StatCard } from '../../components/StatCard';
import { QueryWrapper } from '../../tests/utils/renderWithProviders';
// Mock the TanStack Query hook // Mock the TanStack Query hook
vi.mock('../../hooks/queries/useApplicationStatsQuery'); vi.mock('../../hooks/queries/useApplicationStatsQuery');
@@ -23,12 +24,14 @@ vi.mock('../../components/StatCard', async () => {
// Get a reference to the mocked component // Get a reference to the mocked component
const mockedStatCard = StatCard as Mock; const mockedStatCard = StatCard as Mock;
// Helper function to render the component within a router context, as it contains a <Link> // Helper function to render the component within router and query contexts
const renderWithRouter = () => { const renderWithRouter = () => {
return render( return render(
<MemoryRouter> <QueryWrapper>
<AdminStatsPage /> <MemoryRouter>
</MemoryRouter>, <AdminStatsPage />
</MemoryRouter>
</QueryWrapper>,
); );
}; };

View File

@@ -13,6 +13,7 @@ import {
createMockMasterGroceryItem, createMockMasterGroceryItem,
createMockCategory, createMockCategory,
} from '../../tests/utils/mockFactories'; } from '../../tests/utils/mockFactories';
import { QueryWrapper } from '../../tests/utils/renderWithProviders';
// Mock the TanStack Query hooks // Mock the TanStack Query hooks
vi.mock('../../hooks/queries/useSuggestedCorrectionsQuery'); vi.mock('../../hooks/queries/useSuggestedCorrectionsQuery');
@@ -29,12 +30,14 @@ vi.mock('./components/CorrectionRow', async () => {
return { CorrectionRow: MockCorrectionRow }; return { CorrectionRow: MockCorrectionRow };
}); });
// Helper to render the component within a router context // Helper to render the component within router and query contexts
const renderWithRouter = () => { const renderWithRouter = () => {
return render( return render(
<MemoryRouter> <QueryWrapper>
<CorrectionsPage /> <MemoryRouter>
</MemoryRouter>, <CorrectionsPage />
</MemoryRouter>
</QueryWrapper>,
); );
}; };

View File

@@ -83,7 +83,6 @@ describe('AuthView', () => {
'test@example.com', 'test@example.com',
'password123', 'password123',
true, true,
expect.any(AbortSignal),
); );
expect(mockOnLoginSuccess).toHaveBeenCalledWith( expect(mockOnLoginSuccess).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
@@ -149,7 +148,6 @@ describe('AuthView', () => {
'newpassword', 'newpassword',
'Test User', 'Test User',
'', '',
expect.any(AbortSignal),
); );
expect(mockOnLoginSuccess).toHaveBeenCalledWith( expect(mockOnLoginSuccess).toHaveBeenCalledWith(
expect.objectContaining({ user: expect.objectContaining({ user_id: '123' }) }), expect.objectContaining({ user: expect.objectContaining({ user_id: '123' }) }),
@@ -178,7 +176,6 @@ describe('AuthView', () => {
'password', 'password',
'', '',
'', '',
expect.any(AbortSignal),
); );
expect(mockOnLoginSuccess).toHaveBeenCalled(); expect(mockOnLoginSuccess).toHaveBeenCalled();
}); });
@@ -230,10 +227,7 @@ describe('AuthView', () => {
fireEvent.submit(screen.getByTestId('reset-password-form')); fireEvent.submit(screen.getByTestId('reset-password-form'));
await waitFor(() => { await waitFor(() => {
expect(mockedApiClient.requestPasswordReset).toHaveBeenCalledWith( expect(mockedApiClient.requestPasswordReset).toHaveBeenCalledWith('forgot@example.com');
'forgot@example.com',
expect.any(AbortSignal),
);
expect(notifySuccess).toHaveBeenCalledWith('Password reset email sent.'); expect(notifySuccess).toHaveBeenCalledWith('Password reset email sent.');
}); });
}); });
@@ -354,12 +348,15 @@ describe('AuthView', () => {
}); });
fireEvent.submit(screen.getByTestId('reset-password-form')); fireEvent.submit(screen.getByTestId('reset-password-form'));
const submitButton = screen // Wait for the mutation to start and update the loading state
.getByTestId('reset-password-form') await waitFor(() => {
.querySelector('button[type="submit"]'); const submitButton = screen
expect(submitButton).toBeInTheDocument(); .getByTestId('reset-password-form')
expect(submitButton).toBeDisabled(); .querySelector('button[type="submit"]');
expect(screen.queryByText('Send Reset Link')).not.toBeInTheDocument(); expect(submitButton).toBeInTheDocument();
expect(submitButton).toBeDisabled();
expect(screen.queryByText('Send Reset Link')).not.toBeInTheDocument();
});
}); });
}); });

View File

@@ -12,10 +12,13 @@ import {
createMockUser, createMockUser,
createMockUserProfile, createMockUserProfile,
} from '../../../tests/utils/mockFactories'; } from '../../../tests/utils/mockFactories';
import { QueryWrapper } from '../../../tests/utils/renderWithProviders';
// Unmock the component to test the real implementation // Unmock the component to test the real implementation
vi.unmock('./ProfileManager'); vi.unmock('./ProfileManager');
const renderWithQuery = (ui: React.ReactElement) => render(ui, { wrapper: QueryWrapper });
// Must explicitly call vi.mock() for apiClient // Must explicitly call vi.mock() for apiClient
vi.mock('../../../services/apiClient'); vi.mock('../../../services/apiClient');
@@ -148,13 +151,13 @@ describe('ProfileManager', () => {
// ================================================================= // =================================================================
describe('Authentication Flows (Signed Out)', () => { describe('Authentication Flows (Signed Out)', () => {
it('should render the Sign In form when authStatus is SIGNED_OUT', () => { it('should render the Sign In form when authStatus is SIGNED_OUT', () => {
render(<ProfileManager {...defaultSignedOutProps} />); renderWithQuery(<ProfileManager {...defaultSignedOutProps} />);
expect(screen.getByRole('heading', { name: /^sign in$/i })).toBeInTheDocument(); expect(screen.getByRole('heading', { name: /^sign in$/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /register/i })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /register/i })).toBeInTheDocument();
}); });
it('should call loginUser and onLoginSuccess on successful login', async () => { it('should call loginUser and onLoginSuccess on successful login', async () => {
render(<ProfileManager {...defaultSignedOutProps} />); renderWithQuery(<ProfileManager {...defaultSignedOutProps} />);
fireEvent.change(screen.getByLabelText(/email address/i), { fireEvent.change(screen.getByLabelText(/email address/i), {
target: { value: 'user@test.com' }, target: { value: 'user@test.com' },
}); });
@@ -168,7 +171,6 @@ describe('ProfileManager', () => {
'user@test.com', 'user@test.com',
'securepassword', 'securepassword',
false, false,
expect.any(AbortSignal),
); );
expect(mockOnLoginSuccess).toHaveBeenCalledWith(authenticatedProfile, 'mock-token', false); expect(mockOnLoginSuccess).toHaveBeenCalledWith(authenticatedProfile, 'mock-token', false);
expect(mockOnClose).toHaveBeenCalled(); expect(mockOnClose).toHaveBeenCalled();
@@ -176,7 +178,7 @@ describe('ProfileManager', () => {
}); });
it('should switch to the Create an Account form and register successfully', async () => { it('should switch to the Create an Account form and register successfully', async () => {
render(<ProfileManager {...defaultSignedOutProps} />); renderWithQuery(<ProfileManager {...defaultSignedOutProps} />);
fireEvent.click(screen.getByRole('button', { name: /register/i })); fireEvent.click(screen.getByRole('button', { name: /register/i }));
expect(screen.getByRole('heading', { name: /create an account/i })).toBeInTheDocument(); expect(screen.getByRole('heading', { name: /create an account/i })).toBeInTheDocument();
@@ -194,7 +196,6 @@ describe('ProfileManager', () => {
'newpassword', 'newpassword',
'New User', 'New User',
'', '',
expect.any(AbortSignal),
); );
expect(mockOnLoginSuccess).toHaveBeenCalled(); expect(mockOnLoginSuccess).toHaveBeenCalled();
expect(mockOnClose).toHaveBeenCalled(); expect(mockOnClose).toHaveBeenCalled();
@@ -202,7 +203,7 @@ describe('ProfileManager', () => {
}); });
it('should switch to the Reset Password form and request a reset', async () => { it('should switch to the Reset Password form and request a reset', async () => {
render(<ProfileManager {...defaultSignedOutProps} />); renderWithQuery(<ProfileManager {...defaultSignedOutProps} />);
fireEvent.click(screen.getByRole('button', { name: /forgot password/i })); fireEvent.click(screen.getByRole('button', { name: /forgot password/i }));
expect(screen.getByRole('heading', { name: /reset password/i })).toBeInTheDocument(); expect(screen.getByRole('heading', { name: /reset password/i })).toBeInTheDocument();
@@ -213,10 +214,7 @@ describe('ProfileManager', () => {
fireEvent.submit(screen.getByTestId('reset-password-form')); fireEvent.submit(screen.getByTestId('reset-password-form'));
await waitFor(() => { await waitFor(() => {
expect(mockedApiClient.requestPasswordReset).toHaveBeenCalledWith( expect(mockedApiClient.requestPasswordReset).toHaveBeenCalledWith('reset@test.com');
'reset@test.com',
expect.any(AbortSignal),
);
expect(notifySuccess).toHaveBeenCalledWith('Password reset email sent.'); expect(notifySuccess).toHaveBeenCalledWith('Password reset email sent.');
}); });
}); });
@@ -227,14 +225,14 @@ describe('ProfileManager', () => {
// ================================================================= // =================================================================
describe('Authenticated User Features', () => { describe('Authenticated User Features', () => {
it('should render profile tabs when authStatus is AUTHENTICATED', () => { it('should render profile tabs when authStatus is AUTHENTICATED', () => {
render(<ProfileManager {...defaultAuthenticatedProps} />); renderWithQuery(<ProfileManager {...defaultAuthenticatedProps} />);
expect(screen.getByRole('heading', { name: /my account/i })).toBeInTheDocument(); expect(screen.getByRole('heading', { name: /my account/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /^profile$/i })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /^profile$/i })).toBeInTheDocument();
expect(screen.queryByRole('heading', { name: /^sign in$/i })).not.toBeInTheDocument(); expect(screen.queryByRole('heading', { name: /^sign in$/i })).not.toBeInTheDocument();
}); });
it('should close the modal when clicking the backdrop', async () => { it('should close the modal when clicking the backdrop', async () => {
render(<ProfileManager {...defaultAuthenticatedProps} />); renderWithQuery(<ProfileManager {...defaultAuthenticatedProps} />);
// The backdrop is the element with role="dialog" // The backdrop is the element with role="dialog"
const backdrop = screen.getByRole('dialog'); const backdrop = screen.getByRole('dialog');
fireEvent.click(backdrop); fireEvent.click(backdrop);
@@ -245,7 +243,7 @@ describe('ProfileManager', () => {
}); });
it('should reset state when the modal is closed and reopened', async () => { it('should reset state when the modal is closed and reopened', async () => {
const { rerender } = render(<ProfileManager {...defaultAuthenticatedProps} />); const { rerender } = renderWithQuery(<ProfileManager {...defaultAuthenticatedProps} />);
await waitFor(() => expect(screen.getByLabelText(/full name/i)).toHaveValue('Test User')); await waitFor(() => expect(screen.getByLabelText(/full name/i)).toHaveValue('Test User'));
// Change a value // Change a value
@@ -267,7 +265,7 @@ describe('ProfileManager', () => {
it('should show an error if trying to save profile when not logged in', async () => { it('should show an error if trying to save profile when not logged in', async () => {
const loggerSpy = vi.spyOn(logger.logger, 'warn'); const loggerSpy = vi.spyOn(logger.logger, 'warn');
// This is an edge case, but good to test the safeguard // This is an edge case, but good to test the safeguard
render(<ProfileManager {...defaultAuthenticatedProps} userProfile={null} />); renderWithQuery(<ProfileManager {...defaultAuthenticatedProps} userProfile={null} />);
fireEvent.change(screen.getByLabelText(/full name/i), { target: { value: 'Updated Name' } }); fireEvent.change(screen.getByLabelText(/full name/i), { target: { value: 'Updated Name' } });
fireEvent.click(screen.getByRole('button', { name: /save profile/i })); fireEvent.click(screen.getByRole('button', { name: /save profile/i }));
@@ -281,7 +279,7 @@ describe('ProfileManager', () => {
}); });
it('should show a notification if trying to save with no changes', async () => { it('should show a notification if trying to save with no changes', async () => {
render(<ProfileManager {...defaultAuthenticatedProps} />); renderWithQuery(<ProfileManager {...defaultAuthenticatedProps} />);
await waitFor(() => expect(screen.getByLabelText(/city/i)).toHaveValue(mockAddress.city)); await waitFor(() => expect(screen.getByLabelText(/city/i)).toHaveValue(mockAddress.city));
fireEvent.click(screen.getByRole('button', { name: /save profile/i })); fireEvent.click(screen.getByRole('button', { name: /save profile/i }));
@@ -299,7 +297,7 @@ describe('ProfileManager', () => {
const loggerSpy = vi.spyOn(logger.logger, 'warn'); const loggerSpy = vi.spyOn(logger.logger, 'warn');
mockedApiClient.getUserAddress.mockRejectedValue(new Error('Address not found')); mockedApiClient.getUserAddress.mockRejectedValue(new Error('Address not found'));
console.log('[TEST DEBUG] Mocked apiClient.getUserAddress to reject.'); console.log('[TEST DEBUG] Mocked apiClient.getUserAddress to reject.');
render(<ProfileManager {...defaultAuthenticatedProps} />); renderWithQuery(<ProfileManager {...defaultAuthenticatedProps} />);
await waitFor(() => { await waitFor(() => {
console.log( console.log(
@@ -323,7 +321,7 @@ describe('ProfileManager', () => {
// Mock address update to fail (useApi will return null) // Mock address update to fail (useApi will return null)
mockedApiClient.updateUserAddress.mockRejectedValue(new Error('Address update failed')); mockedApiClient.updateUserAddress.mockRejectedValue(new Error('Address update failed'));
render(<ProfileManager {...defaultAuthenticatedProps} />); renderWithQuery(<ProfileManager {...defaultAuthenticatedProps} />);
await waitFor(() => expect(screen.getByLabelText(/city/i)).toHaveValue(mockAddress.city)); await waitFor(() => expect(screen.getByLabelText(/city/i)).toHaveValue(mockAddress.city));
// Change both profile and address data // Change both profile and address data
@@ -341,7 +339,7 @@ describe('ProfileManager', () => {
); );
// The specific warning for partial failure should be logged // The specific warning for partial failure should be logged
expect(loggerSpy).toHaveBeenCalledWith( expect(loggerSpy).toHaveBeenCalledWith(
'[handleProfileSave] One or more operations failed. The useApi hook should have shown an error. The modal will remain open.', '[handleProfileSave] One or more operations failed. The mutation hook should have shown an error. The modal will remain open.',
); );
// The modal should remain open and no global success message shown // The modal should remain open and no global success message shown
expect(mockOnClose).not.toHaveBeenCalled(); expect(mockOnClose).not.toHaveBeenCalled();
@@ -350,18 +348,21 @@ describe('ProfileManager', () => {
}); });
it('should handle unexpected critical error during profile save', async () => { it('should handle unexpected critical error during profile save', async () => {
const loggerSpy = vi.spyOn(logger.logger, 'error'); const loggerSpy = vi.spyOn(logger.logger, 'warn');
mockedApiClient.updateUserProfile.mockRejectedValue(new Error('Catastrophic failure')); mockedApiClient.updateUserProfile.mockRejectedValue(new Error('Catastrophic failure'));
render(<ProfileManager {...defaultAuthenticatedProps} />); renderWithQuery(<ProfileManager {...defaultAuthenticatedProps} />);
await waitFor(() => expect(screen.getByLabelText(/city/i)).toHaveValue(mockAddress.city)); await waitFor(() => expect(screen.getByLabelText(/city/i)).toHaveValue(mockAddress.city));
fireEvent.change(screen.getByLabelText(/full name/i), { target: { value: 'New Name' } }); fireEvent.change(screen.getByLabelText(/full name/i), { target: { value: 'New Name' } });
fireEvent.click(screen.getByRole('button', { name: /save profile/i })); fireEvent.click(screen.getByRole('button', { name: /save profile/i }));
await waitFor(() => { await waitFor(() => {
// FIX: The useApi hook will catch the error and notify with the raw message. // The mutation's onError handler will notify with the error message.
expect(notifyError).toHaveBeenCalledWith('Catastrophic failure'); expect(notifyError).toHaveBeenCalledWith('Catastrophic failure');
expect(loggerSpy).toHaveBeenCalled(); // A warning is logged about the partial failure
expect(loggerSpy).toHaveBeenCalledWith(
'[handleProfileSave] One or more operations failed. The mutation hook should have shown an error. The modal will remain open.',
);
}); });
}); });
@@ -371,7 +372,7 @@ describe('ProfileManager', () => {
.mockRejectedValueOnce(new Error('AllSettled failed')); .mockRejectedValueOnce(new Error('AllSettled failed'));
const loggerSpy = vi.spyOn(logger.logger, 'error'); const loggerSpy = vi.spyOn(logger.logger, 'error');
render(<ProfileManager {...defaultAuthenticatedProps} />); renderWithQuery(<ProfileManager {...defaultAuthenticatedProps} />);
await waitFor(() => expect(screen.getByLabelText(/city/i)).toHaveValue(mockAddress.city)); await waitFor(() => expect(screen.getByLabelText(/city/i)).toHaveValue(mockAddress.city));
fireEvent.change(screen.getByLabelText(/full name/i), { target: { value: 'New Name' } }); fireEvent.change(screen.getByLabelText(/full name/i), { target: { value: 'New Name' } });
@@ -391,7 +392,7 @@ describe('ProfileManager', () => {
}); });
it('should show map view when address has coordinates', async () => { it('should show map view when address has coordinates', async () => {
render(<ProfileManager {...defaultAuthenticatedProps} />); renderWithQuery(<ProfileManager {...defaultAuthenticatedProps} />);
await waitFor(() => { await waitFor(() => {
expect(screen.getByTestId('map-view-container')).toBeInTheDocument(); expect(screen.getByTestId('map-view-container')).toBeInTheDocument();
}); });
@@ -402,7 +403,7 @@ describe('ProfileManager', () => {
mockedApiClient.getUserAddress.mockResolvedValue( mockedApiClient.getUserAddress.mockResolvedValue(
new Response(JSON.stringify(addressWithoutCoords)), new Response(JSON.stringify(addressWithoutCoords)),
); );
render(<ProfileManager {...defaultAuthenticatedProps} />); renderWithQuery(<ProfileManager {...defaultAuthenticatedProps} />);
await waitFor(() => { await waitFor(() => {
expect(screen.queryByTestId('map-view-container')).not.toBeInTheDocument(); expect(screen.queryByTestId('map-view-container')).not.toBeInTheDocument();
}); });
@@ -410,7 +411,7 @@ describe('ProfileManager', () => {
it('should show error if geocoding is attempted with no address string', async () => { it('should show error if geocoding is attempted with no address string', async () => {
mockedApiClient.getUserAddress.mockResolvedValue(new Response(JSON.stringify({}))); mockedApiClient.getUserAddress.mockResolvedValue(new Response(JSON.stringify({})));
render( renderWithQuery(
<ProfileManager <ProfileManager
{...defaultAuthenticatedProps} {...defaultAuthenticatedProps}
userProfile={{ ...authenticatedProfile, address_id: 999 }} userProfile={{ ...authenticatedProfile, address_id: 999 }}
@@ -432,34 +433,32 @@ describe('ProfileManager', () => {
}); });
it('should automatically geocode address after user stops typing (using fake timers)', async () => { it('should automatically geocode address after user stops typing (using fake timers)', async () => {
// Use fake timers for the entire test to control the debounce. // This test verifies debounced auto-geocoding behavior.
vi.useFakeTimers(); // We use real timers throughout but wait for the debounce naturally.
vi.useRealTimers();
const addressWithoutCoords = { ...mockAddress, latitude: undefined, longitude: undefined }; const addressWithoutCoords = { ...mockAddress, latitude: undefined, longitude: undefined };
mockedApiClient.getUserAddress.mockResolvedValue( mockedApiClient.getUserAddress.mockResolvedValue(
new Response(JSON.stringify(addressWithoutCoords)), new Response(JSON.stringify(addressWithoutCoords)),
); );
render(<ProfileManager {...defaultAuthenticatedProps} />); renderWithQuery(<ProfileManager {...defaultAuthenticatedProps} />);
// Wait for initial async address load to complete by flushing promises. // Wait for initial async address load to complete.
await act(async () => { await waitFor(() => expect(screen.getByLabelText(/city/i)).toHaveValue('Anytown'));
await vi.runAllTimersAsync();
});
expect(screen.getByLabelText(/city/i)).toHaveValue('Anytown');
// Change address, geocode should not be called immediately // Change address, geocode should not be called immediately
fireEvent.change(screen.getByLabelText(/city/i), { target: { value: 'NewCity' } }); fireEvent.change(screen.getByLabelText(/city/i), { target: { value: 'NewCity' } });
expect(mockedApiClient.geocodeAddress).not.toHaveBeenCalled(); expect(mockedApiClient.geocodeAddress).not.toHaveBeenCalled();
// Advance timers to fire the debounce and resolve the subsequent geocode promise. // Wait for the debounce (1500ms) plus some buffer for the geocode call.
await act(async () => { // The auto-geocode effect fires after the debounced address value updates.
await vi.runAllTimersAsync(); await waitFor(
}); () => {
expect(mockedApiClient.geocodeAddress).toHaveBeenCalledWith(
// Now check the final result. expect.stringContaining('NewCity'),
expect(mockedApiClient.geocodeAddress).toHaveBeenCalledWith( );
expect.stringContaining('NewCity'), },
expect.anything(), { timeout: 3000 },
); );
expect(toast.success).toHaveBeenCalledWith('Address geocoded successfully!'); expect(toast.success).toHaveBeenCalledWith('Address geocoded successfully!');
}); });
@@ -467,7 +466,7 @@ describe('ProfileManager', () => {
it('should not geocode if address already has coordinates (using fake timers)', async () => { it('should not geocode if address already has coordinates (using fake timers)', async () => {
// Use real timers for the initial async render and data fetch // Use real timers for the initial async render and data fetch
vi.useRealTimers(); vi.useRealTimers();
render(<ProfileManager {...defaultAuthenticatedProps} />); renderWithQuery(<ProfileManager {...defaultAuthenticatedProps} />);
console.log('[TEST LOG] Waiting for initial address load...'); console.log('[TEST LOG] Waiting for initial address load...');
await waitFor(() => expect(screen.getByLabelText(/city/i)).toHaveValue('Anytown')); await waitFor(() => expect(screen.getByLabelText(/city/i)).toHaveValue('Anytown'));
@@ -485,7 +484,7 @@ describe('ProfileManager', () => {
}); });
it('should show an error when trying to link an account', async () => { it('should show an error when trying to link an account', async () => {
render(<ProfileManager {...defaultAuthenticatedProps} />); renderWithQuery(<ProfileManager {...defaultAuthenticatedProps} />);
fireEvent.click(screen.getByRole('button', { name: /security/i })); fireEvent.click(screen.getByRole('button', { name: /security/i }));
await waitFor(() => { await waitFor(() => {
@@ -502,7 +501,7 @@ describe('ProfileManager', () => {
}); });
it('should show an error when trying to link a GitHub account', async () => { it('should show an error when trying to link a GitHub account', async () => {
render(<ProfileManager {...defaultAuthenticatedProps} />); renderWithQuery(<ProfileManager {...defaultAuthenticatedProps} />);
fireEvent.click(screen.getByRole('button', { name: /security/i })); fireEvent.click(screen.getByRole('button', { name: /security/i }));
await waitFor(() => { await waitFor(() => {
@@ -519,7 +518,7 @@ describe('ProfileManager', () => {
}); });
it('should switch between all tabs correctly', async () => { it('should switch between all tabs correctly', async () => {
render(<ProfileManager {...defaultAuthenticatedProps} />); renderWithQuery(<ProfileManager {...defaultAuthenticatedProps} />);
// Initial state: Profile tab // Initial state: Profile tab
expect(screen.getByLabelText('Profile Form')).toBeInTheDocument(); expect(screen.getByLabelText('Profile Form')).toBeInTheDocument();
@@ -542,7 +541,7 @@ describe('ProfileManager', () => {
}); });
it('should show an error if password is too short', async () => { it('should show an error if password is too short', async () => {
render(<ProfileManager {...defaultAuthenticatedProps} />); renderWithQuery(<ProfileManager {...defaultAuthenticatedProps} />);
fireEvent.click(screen.getByRole('button', { name: /security/i })); fireEvent.click(screen.getByRole('button', { name: /security/i }));
fireEvent.change(screen.getByLabelText('New Password'), { target: { value: 'short' } }); fireEvent.change(screen.getByLabelText('New Password'), { target: { value: 'short' } });
@@ -559,7 +558,7 @@ describe('ProfileManager', () => {
it('should show an error if account deletion fails', async () => { it('should show an error if account deletion fails', async () => {
mockedApiClient.deleteUserAccount.mockRejectedValue(new Error('Deletion failed')); mockedApiClient.deleteUserAccount.mockRejectedValue(new Error('Deletion failed'));
render(<ProfileManager {...defaultAuthenticatedProps} />); renderWithQuery(<ProfileManager {...defaultAuthenticatedProps} />);
fireEvent.click(screen.getByRole('button', { name: /data & privacy/i })); fireEvent.click(screen.getByRole('button', { name: /data & privacy/i }));
fireEvent.click(screen.getByRole('button', { name: /delete my account/i })); fireEvent.click(screen.getByRole('button', { name: /delete my account/i }));
@@ -579,7 +578,7 @@ describe('ProfileManager', () => {
it('should handle toggling dark mode when profile preferences are initially null', async () => { it('should handle toggling dark mode when profile preferences are initially null', async () => {
const profileWithoutPrefs = { ...authenticatedProfile, preferences: null as any }; const profileWithoutPrefs = { ...authenticatedProfile, preferences: null as any };
const { rerender } = render( const { rerender } = renderWithQuery(
<ProfileManager {...defaultAuthenticatedProps} userProfile={profileWithoutPrefs} />, <ProfileManager {...defaultAuthenticatedProps} userProfile={profileWithoutPrefs} />,
); );
@@ -605,10 +604,7 @@ describe('ProfileManager', () => {
fireEvent.click(darkModeToggle); fireEvent.click(darkModeToggle);
await waitFor(() => { await waitFor(() => {
expect(mockedApiClient.updateUserPreferences).toHaveBeenCalledWith( expect(mockedApiClient.updateUserPreferences).toHaveBeenCalledWith({ darkMode: true });
{ darkMode: true },
expect.anything(),
);
expect(mockOnProfileUpdate).toHaveBeenCalledWith(updatedProfileWithPrefs); expect(mockOnProfileUpdate).toHaveBeenCalledWith(updatedProfileWithPrefs);
}); });
@@ -633,7 +629,7 @@ describe('ProfileManager', () => {
new Response(JSON.stringify(updatedAddressData)), new Response(JSON.stringify(updatedAddressData)),
); );
render(<ProfileManager {...defaultAuthenticatedProps} />); renderWithQuery(<ProfileManager {...defaultAuthenticatedProps} />);
await waitFor(() => await waitFor(() =>
expect(screen.getByLabelText(/full name/i)).toHaveValue(authenticatedProfile.full_name), expect(screen.getByLabelText(/full name/i)).toHaveValue(authenticatedProfile.full_name),
@@ -647,13 +643,12 @@ describe('ProfileManager', () => {
fireEvent.click(saveButton); fireEvent.click(saveButton);
await waitFor(() => { await waitFor(() => {
expect(mockedApiClient.updateUserProfile).toHaveBeenCalledWith( expect(mockedApiClient.updateUserProfile).toHaveBeenCalledWith({
{ full_name: 'Updated Name', avatar_url: authenticatedProfile.avatar_url }, full_name: 'Updated Name',
expect.objectContaining({ signal: expect.anything() }), avatar_url: authenticatedProfile.avatar_url,
); });
expect(mockedApiClient.updateUserAddress).toHaveBeenCalledWith( expect(mockedApiClient.updateUserAddress).toHaveBeenCalledWith(
expect.objectContaining({ city: 'NewCity' }), expect.objectContaining({ city: 'NewCity' }),
expect.objectContaining({ signal: expect.anything() }),
); );
expect(mockOnProfileUpdate).toHaveBeenCalledWith( expect(mockOnProfileUpdate).toHaveBeenCalledWith(
expect.objectContaining({ full_name: 'Updated Name' }), expect.objectContaining({ full_name: 'Updated Name' }),
@@ -668,7 +663,7 @@ describe('ProfileManager', () => {
); );
mockedApiClient.updateUserAddress.mockRejectedValueOnce(new Error('Address update failed')); mockedApiClient.updateUserAddress.mockRejectedValueOnce(new Error('Address update failed'));
render(<ProfileManager {...defaultAuthenticatedProps} />); renderWithQuery(<ProfileManager {...defaultAuthenticatedProps} />);
await waitFor(() => expect(screen.getByLabelText(/city/i)).toHaveValue(mockAddress.city)); await waitFor(() => expect(screen.getByLabelText(/city/i)).toHaveValue(mockAddress.city));
// Change both profile and address data // Change both profile and address data
@@ -691,7 +686,7 @@ describe('ProfileManager', () => {
}); });
it('should allow updating the password', async () => { it('should allow updating the password', async () => {
render(<ProfileManager {...defaultAuthenticatedProps} />); renderWithQuery(<ProfileManager {...defaultAuthenticatedProps} />);
fireEvent.click(screen.getByRole('button', { name: /security/i })); fireEvent.click(screen.getByRole('button', { name: /security/i }));
fireEvent.change(screen.getByLabelText('New Password'), { fireEvent.change(screen.getByLabelText('New Password'), {
@@ -703,16 +698,13 @@ describe('ProfileManager', () => {
fireEvent.submit(screen.getByTestId('update-password-form'), {}); fireEvent.submit(screen.getByTestId('update-password-form'), {});
await waitFor(() => { await waitFor(() => {
expect(mockedApiClient.updateUserPassword).toHaveBeenCalledWith( expect(mockedApiClient.updateUserPassword).toHaveBeenCalledWith('newpassword123');
'newpassword123',
expect.objectContaining({ signal: expect.anything() }),
);
expect(notifySuccess).toHaveBeenCalledWith('Password updated successfully!'); expect(notifySuccess).toHaveBeenCalledWith('Password updated successfully!');
}); });
}); });
it('should show an error if passwords do not match', async () => { it('should show an error if passwords do not match', async () => {
render(<ProfileManager {...defaultAuthenticatedProps} />); renderWithQuery(<ProfileManager {...defaultAuthenticatedProps} />);
fireEvent.click(screen.getByRole('button', { name: /security/i })); fireEvent.click(screen.getByRole('button', { name: /security/i }));
fireEvent.change(screen.getByLabelText('New Password'), { fireEvent.change(screen.getByLabelText('New Password'), {
@@ -734,7 +726,7 @@ describe('ProfileManager', () => {
.spyOn(HTMLAnchorElement.prototype, 'click') .spyOn(HTMLAnchorElement.prototype, 'click')
.mockImplementation(() => {}); .mockImplementation(() => {});
render(<ProfileManager {...defaultAuthenticatedProps} />); renderWithQuery(<ProfileManager {...defaultAuthenticatedProps} />);
fireEvent.click(screen.getByRole('button', { name: /data & privacy/i })); fireEvent.click(screen.getByRole('button', { name: /data & privacy/i }));
fireEvent.click(screen.getByRole('button', { name: /export my data/i })); fireEvent.click(screen.getByRole('button', { name: /export my data/i }));
@@ -751,7 +743,7 @@ describe('ProfileManager', () => {
// Use fake timers to control the setTimeout call for the entire test. // Use fake timers to control the setTimeout call for the entire test.
vi.useFakeTimers(); vi.useFakeTimers();
render(<ProfileManager {...defaultAuthenticatedProps} />); renderWithQuery(<ProfileManager {...defaultAuthenticatedProps} />);
fireEvent.click(screen.getByRole('button', { name: /data & privacy/i })); fireEvent.click(screen.getByRole('button', { name: /data & privacy/i }));
@@ -787,7 +779,7 @@ describe('ProfileManager', () => {
}); });
it('should allow toggling dark mode', async () => { it('should allow toggling dark mode', async () => {
render(<ProfileManager {...defaultAuthenticatedProps} />); renderWithQuery(<ProfileManager {...defaultAuthenticatedProps} />);
fireEvent.click(screen.getByRole('button', { name: /preferences/i })); fireEvent.click(screen.getByRole('button', { name: /preferences/i }));
const darkModeToggle = screen.getByLabelText(/dark mode/i); const darkModeToggle = screen.getByLabelText(/dark mode/i);
@@ -796,10 +788,7 @@ describe('ProfileManager', () => {
fireEvent.click(darkModeToggle); fireEvent.click(darkModeToggle);
await waitFor(() => { await waitFor(() => {
expect(mockedApiClient.updateUserPreferences).toHaveBeenCalledWith( expect(mockedApiClient.updateUserPreferences).toHaveBeenCalledWith({ darkMode: true });
{ darkMode: true },
expect.objectContaining({ signal: expect.anything() }),
);
expect(mockOnProfileUpdate).toHaveBeenCalledWith( expect(mockOnProfileUpdate).toHaveBeenCalledWith(
expect.objectContaining({ preferences: expect.objectContaining({ darkMode: true }) }), expect.objectContaining({ preferences: expect.objectContaining({ darkMode: true }) }),
); );
@@ -807,17 +796,16 @@ describe('ProfileManager', () => {
}); });
it('should allow changing the unit system', async () => { it('should allow changing the unit system', async () => {
render(<ProfileManager {...defaultAuthenticatedProps} />); renderWithQuery(<ProfileManager {...defaultAuthenticatedProps} />);
fireEvent.click(screen.getByRole('button', { name: /preferences/i })); fireEvent.click(screen.getByRole('button', { name: /preferences/i }));
const metricRadio = screen.getByLabelText(/metric/i); const metricRadio = screen.getByLabelText(/metric/i);
fireEvent.click(metricRadio); fireEvent.click(metricRadio);
await waitFor(() => { await waitFor(() => {
expect(mockedApiClient.updateUserPreferences).toHaveBeenCalledWith( expect(mockedApiClient.updateUserPreferences).toHaveBeenCalledWith({
{ unitSystem: 'metric' }, unitSystem: 'metric',
expect.objectContaining({ signal: expect.anything() }), });
);
expect(mockOnProfileUpdate).toHaveBeenCalledWith( expect(mockOnProfileUpdate).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
preferences: expect.objectContaining({ unitSystem: 'metric' }), preferences: expect.objectContaining({ unitSystem: 'metric' }),
@@ -828,7 +816,7 @@ describe('ProfileManager', () => {
it('should allow changing unit system when preferences are initially null', async () => { it('should allow changing unit system when preferences are initially null', async () => {
const profileWithoutPrefs = { ...authenticatedProfile, preferences: null as any }; const profileWithoutPrefs = { ...authenticatedProfile, preferences: null as any };
const { rerender } = render( const { rerender } = renderWithQuery(
<ProfileManager {...defaultAuthenticatedProps} userProfile={profileWithoutPrefs} />, <ProfileManager {...defaultAuthenticatedProps} userProfile={profileWithoutPrefs} />,
); );
@@ -854,10 +842,9 @@ describe('ProfileManager', () => {
fireEvent.click(metricRadio); fireEvent.click(metricRadio);
await waitFor(() => { await waitFor(() => {
expect(mockedApiClient.updateUserPreferences).toHaveBeenCalledWith( expect(mockedApiClient.updateUserPreferences).toHaveBeenCalledWith({
{ unitSystem: 'metric' }, unitSystem: 'metric',
expect.anything(), });
);
expect(mockOnProfileUpdate).toHaveBeenCalledWith(updatedProfileWithPrefs); expect(mockOnProfileUpdate).toHaveBeenCalledWith(updatedProfileWithPrefs);
}); });
@@ -873,7 +860,7 @@ describe('ProfileManager', () => {
it('should not call onProfileUpdate if updating unit system fails', async () => { it('should not call onProfileUpdate if updating unit system fails', async () => {
mockedApiClient.updateUserPreferences.mockRejectedValue(new Error('API failed')); mockedApiClient.updateUserPreferences.mockRejectedValue(new Error('API failed'));
render(<ProfileManager {...defaultAuthenticatedProps} />); renderWithQuery(<ProfileManager {...defaultAuthenticatedProps} />);
fireEvent.click(screen.getByRole('button', { name: /preferences/i })); fireEvent.click(screen.getByRole('button', { name: /preferences/i }));
const metricRadio = await screen.findByLabelText(/metric/i); const metricRadio = await screen.findByLabelText(/metric/i);
fireEvent.click(metricRadio); fireEvent.click(metricRadio);
@@ -884,7 +871,7 @@ describe('ProfileManager', () => {
}); });
it('should only call updateProfile when only profile data has changed', async () => { it('should only call updateProfile when only profile data has changed', async () => {
render(<ProfileManager {...defaultAuthenticatedProps} />); renderWithQuery(<ProfileManager {...defaultAuthenticatedProps} />);
await waitFor(() => await waitFor(() =>
expect(screen.getByLabelText(/full name/i)).toHaveValue(authenticatedProfile.full_name), expect(screen.getByLabelText(/full name/i)).toHaveValue(authenticatedProfile.full_name),
); );
@@ -902,7 +889,7 @@ describe('ProfileManager', () => {
}); });
it('should only call updateAddress when only address data has changed', async () => { it('should only call updateAddress when only address data has changed', async () => {
render(<ProfileManager {...defaultAuthenticatedProps} />); renderWithQuery(<ProfileManager {...defaultAuthenticatedProps} />);
await waitFor(() => expect(screen.getByLabelText(/city/i)).toHaveValue(mockAddress.city)); await waitFor(() => expect(screen.getByLabelText(/city/i)).toHaveValue(mockAddress.city));
fireEvent.change(screen.getByLabelText(/city/i), { target: { value: 'Only City Changed' } }); fireEvent.change(screen.getByLabelText(/city/i), { target: { value: 'Only City Changed' } });
@@ -916,7 +903,7 @@ describe('ProfileManager', () => {
}); });
it('should handle manual geocode success via button click', async () => { it('should handle manual geocode success via button click', async () => {
render(<ProfileManager {...defaultAuthenticatedProps} />); renderWithQuery(<ProfileManager {...defaultAuthenticatedProps} />);
await waitFor(() => expect(screen.getByLabelText(/city/i)).toHaveValue(mockAddress.city)); await waitFor(() => expect(screen.getByLabelText(/city/i)).toHaveValue(mockAddress.city));
// Mock geocode response for the manual trigger // Mock geocode response for the manual trigger
@@ -935,7 +922,7 @@ describe('ProfileManager', () => {
it('should reset address form if profile has no address_id', async () => { it('should reset address form if profile has no address_id', async () => {
const profileNoAddress = { ...authenticatedProfile, address_id: null }; const profileNoAddress = { ...authenticatedProfile, address_id: null };
render( renderWithQuery(
<ProfileManager {...defaultAuthenticatedProps} userProfile={profileNoAddress as any} />, <ProfileManager {...defaultAuthenticatedProps} userProfile={profileNoAddress as any} />,
); );
@@ -948,7 +935,7 @@ describe('ProfileManager', () => {
}); });
it('should not render auth views when the user is already authenticated', () => { it('should not render auth views when the user is already authenticated', () => {
render(<ProfileManager {...defaultAuthenticatedProps} />); renderWithQuery(<ProfileManager {...defaultAuthenticatedProps} />);
expect(screen.queryByText('Sign In')).not.toBeInTheDocument(); expect(screen.queryByText('Sign In')).not.toBeInTheDocument();
expect(screen.queryByText('Create an Account')).not.toBeInTheDocument(); expect(screen.queryByText('Create an Account')).not.toBeInTheDocument();
}); });
@@ -963,7 +950,7 @@ describe('ProfileManager', () => {
); );
console.log('[TEST DEBUG] Mocked apiClient.getUserAddress to resolve with a null body.'); console.log('[TEST DEBUG] Mocked apiClient.getUserAddress to resolve with a null body.');
render(<ProfileManager {...defaultAuthenticatedProps} />); renderWithQuery(<ProfileManager {...defaultAuthenticatedProps} />);
await waitFor(() => { await waitFor(() => {
console.log( console.log(
@@ -984,7 +971,7 @@ describe('ProfileManager', () => {
async (data) => new Response(JSON.stringify({ ...mockAddress, ...data })), async (data) => new Response(JSON.stringify({ ...mockAddress, ...data })),
); );
render(<ProfileManager {...defaultAuthenticatedProps} />); renderWithQuery(<ProfileManager {...defaultAuthenticatedProps} />);
await waitFor(() => { await waitFor(() => {
expect(screen.getByLabelText(/full name/i)).toHaveValue(authenticatedProfile.full_name); expect(screen.getByLabelText(/full name/i)).toHaveValue(authenticatedProfile.full_name);
@@ -998,13 +985,12 @@ describe('ProfileManager', () => {
fireEvent.click(saveButton); fireEvent.click(saveButton);
await waitFor(() => { await waitFor(() => {
expect(mockedApiClient.updateUserProfile).toHaveBeenCalledWith( expect(mockedApiClient.updateUserProfile).toHaveBeenCalledWith({
{ full_name: '', avatar_url: authenticatedProfile.avatar_url }, full_name: '',
expect.objectContaining({ signal: expect.anything() }), avatar_url: authenticatedProfile.avatar_url,
); });
expect(mockedApiClient.updateUserAddress).toHaveBeenCalledWith( expect(mockedApiClient.updateUserAddress).toHaveBeenCalledWith(
expect.objectContaining({ city: '' }), expect.objectContaining({ city: '' }),
expect.objectContaining({ signal: expect.anything() }),
); );
expect(mockOnProfileUpdate).toHaveBeenCalledWith( expect(mockOnProfileUpdate).toHaveBeenCalledWith(
expect.objectContaining({ full_name: '' }), expect.objectContaining({ full_name: '' }),
@@ -1015,7 +1001,7 @@ describe('ProfileManager', () => {
it('should correctly clear the form when userProfile.address_id is null', async () => { it('should correctly clear the form when userProfile.address_id is null', async () => {
const profileNoAddress = { ...authenticatedProfile, address_id: null }; const profileNoAddress = { ...authenticatedProfile, address_id: null };
render( renderWithQuery(
<ProfileManager <ProfileManager
{...defaultAuthenticatedProps} {...defaultAuthenticatedProps}
userProfile={profileNoAddress as any} // Forcefully override the type to simulate address_id: null userProfile={profileNoAddress as any} // Forcefully override the type to simulate address_id: null
@@ -1032,7 +1018,7 @@ describe('ProfileManager', () => {
}); });
it('should show error notification when manual geocoding fails', async () => { it('should show error notification when manual geocoding fails', async () => {
render(<ProfileManager {...defaultAuthenticatedProps} />); renderWithQuery(<ProfileManager {...defaultAuthenticatedProps} />);
await waitFor(() => expect(screen.getByLabelText(/city/i)).toHaveValue(mockAddress.city)); await waitFor(() => expect(screen.getByLabelText(/city/i)).toHaveValue(mockAddress.city));
(mockedApiClient.geocodeAddress as Mock).mockRejectedValue(new Error('Geocoding failed')); (mockedApiClient.geocodeAddress as Mock).mockRejectedValue(new Error('Geocoding failed'));
@@ -1053,7 +1039,7 @@ describe('ProfileManager', () => {
new Response(JSON.stringify(addressWithoutCoords)), new Response(JSON.stringify(addressWithoutCoords)),
); );
render(<ProfileManager {...defaultAuthenticatedProps} />); renderWithQuery(<ProfileManager {...defaultAuthenticatedProps} />);
// Wait for initial load // Wait for initial load
await act(async () => { await act(async () => {
@@ -1072,7 +1058,7 @@ describe('ProfileManager', () => {
}); });
it('should handle permission denied error during geocoding', async () => { it('should handle permission denied error during geocoding', async () => {
render(<ProfileManager {...defaultAuthenticatedProps} />); renderWithQuery(<ProfileManager {...defaultAuthenticatedProps} />);
await waitFor(() => expect(screen.getByLabelText(/city/i)).toHaveValue(mockAddress.city)); await waitFor(() => expect(screen.getByLabelText(/city/i)).toHaveValue(mockAddress.city));
(mockedApiClient.geocodeAddress as Mock).mockRejectedValue(new Error('Permission denied')); (mockedApiClient.geocodeAddress as Mock).mockRejectedValue(new Error('Permission denied'));
@@ -1086,7 +1072,7 @@ describe('ProfileManager', () => {
it('should not trigger OAuth link if user profile is missing', async () => { it('should not trigger OAuth link if user profile is missing', async () => {
// This is an edge case to test the guard clause in handleOAuthLink // This is an edge case to test the guard clause in handleOAuthLink
render(<ProfileManager {...defaultAuthenticatedProps} userProfile={null} />); renderWithQuery(<ProfileManager {...defaultAuthenticatedProps} userProfile={null} />);
fireEvent.click(screen.getByRole('button', { name: /security/i })); fireEvent.click(screen.getByRole('button', { name: /security/i }));
const linkButton = await screen.findByRole('button', { name: /link google account/i }); const linkButton = await screen.findByRole('button', { name: /link google account/i });

View File

@@ -2,6 +2,7 @@
import React, { useContext, useState } from 'react'; import React, { useContext, useState } from 'react';
import { render, screen, waitFor, fireEvent, act } from '@testing-library/react'; import { render, screen, waitFor, fireEvent, act } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, type Mocked } from 'vitest'; import { describe, it, expect, vi, beforeEach, type Mocked } from 'vitest';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { AuthProvider } from './AuthProvider'; import { AuthProvider } from './AuthProvider';
import { AuthContext } from '../contexts/AuthContext'; import { AuthContext } from '../contexts/AuthContext';
import * as tokenStorage from '../services/tokenStorage'; import * as tokenStorage from '../services/tokenStorage';
@@ -59,11 +60,28 @@ const TestConsumer = () => {
); );
}; };
// Create a fresh QueryClient for each test to ensure isolation
const createTestQueryClient = () =>
new QueryClient({
defaultOptions: {
queries: {
retry: false,
gcTime: 0,
},
mutations: {
retry: false,
},
},
});
const renderWithProvider = () => { const renderWithProvider = () => {
const testQueryClient = createTestQueryClient();
return render( return render(
<AuthProvider> <QueryClientProvider client={testQueryClient}>
<TestConsumer /> <AuthProvider>
</AuthProvider>, <TestConsumer />
</AuthProvider>
</QueryClientProvider>,
); );
}; };
@@ -198,7 +216,7 @@ describe('AuthProvider', () => {
await waitFor(() => { await waitFor(() => {
// The error is now caught and displayed by the TestConsumer // The error is now caught and displayed by the TestConsumer
expect(screen.getByTestId('error-display')).toHaveTextContent( expect(screen.getByTestId('error-display')).toHaveTextContent(
'Login succeeded, but failed to fetch your data: Received null or undefined profile from API.', 'Login succeeded, but failed to fetch your data: API is down',
); );
expect(mockedTokenStorage.setToken).toHaveBeenCalledWith('test-token-no-profile'); expect(mockedTokenStorage.setToken).toHaveBeenCalledWith('test-token-no-profile');

View File

@@ -45,6 +45,12 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
removeToken(); removeToken();
setUserProfile(null); setUserProfile(null);
setAuthStatus('SIGNED_OUT'); setAuthStatus('SIGNED_OUT');
} else if (token && isFetched && !fetchedProfile) {
// Token exists, query completed, but profile is null - sign out
logger.warn('[AuthProvider] Token was present but profile is null. Signing out.');
removeToken();
setUserProfile(null);
setAuthStatus('SIGNED_OUT');
} else if (!token) { } else if (!token) {
logger.info('[AuthProvider] No auth token found. Setting state to SIGNED_OUT.'); logger.info('[AuthProvider] No auth token found. Setting state to SIGNED_OUT.');
setAuthStatus('SIGNED_OUT'); setAuthStatus('SIGNED_OUT');

View File

@@ -14,16 +14,40 @@ export interface AiProcessorResult {
needsReview: boolean; needsReview: boolean;
} }
/**
* Type definition for the extractAndValidateData method signature.
* Used for dependency injection in tests.
*/
export type ExtractAndValidateDataFn = (
imagePaths: { path: string; mimetype: string }[],
jobData: FlyerJobData,
logger: Logger,
) => Promise<AiProcessorResult>;
/** /**
* This class encapsulates the logic for interacting with the AI service * This class encapsulates the logic for interacting with the AI service
* to extract and validate data from flyer images. * to extract and validate data from flyer images.
*/ */
export class FlyerAiProcessor { export class FlyerAiProcessor {
private extractFn: ExtractAndValidateDataFn | null = null;
constructor( constructor(
private ai: AIService, private ai: AIService,
private personalizationRepo: PersonalizationRepository, private personalizationRepo: PersonalizationRepository,
) {} ) {}
/**
* Allows replacing the extractAndValidateData implementation at runtime.
* This is primarily used for testing to inject mock implementations.
* @internal
*/
_setExtractAndValidateData(fn: ExtractAndValidateDataFn | null): void {
console.error(
`[DEBUG] FlyerAiProcessor._setExtractAndValidateData called, ${fn ? 'replacing' : 'resetting'} extract function`,
);
this.extractFn = fn;
}
/** /**
* Validates the raw data from the AI against the Zod schema. * Validates the raw data from the AI against the Zod schema.
*/ */
@@ -101,6 +125,13 @@ export class FlyerAiProcessor {
console.error( console.error(
`[WORKER DEBUG] FlyerAiProcessor: extractAndValidateData called with ${imagePaths.length} images`, `[WORKER DEBUG] FlyerAiProcessor: extractAndValidateData called with ${imagePaths.length} images`,
); );
// If a mock function is injected (for testing), use it instead of the real implementation
if (this.extractFn) {
console.error(`[WORKER DEBUG] FlyerAiProcessor: Using injected extractFn mock`);
return this.extractFn(imagePaths, jobData, logger);
}
logger.info(`Starting AI data extraction for ${imagePaths.length} pages.`); logger.info(`Starting AI data extraction for ${imagePaths.length} pages.`);
const { submitterIp, userProfileAddress } = jobData; const { submitterIp, userProfileAddress } = jobData;
const masterItems = await this.personalizationRepo.getAllMasterItems(logger); const masterItems = await this.personalizationRepo.getAllMasterItems(logger);

View File

@@ -51,6 +51,24 @@ export class FlyerProcessingService {
return this.persistenceService; return this.persistenceService;
} }
/**
* Provides access to the AI processor for testing purposes.
* @internal
*/
_getAiProcessor(): FlyerAiProcessor {
return this.aiProcessor;
}
/**
* Replaces the cleanup queue for testing purposes.
* This allows tests to prevent file cleanup to verify file contents.
* @internal
*/
_setCleanupQueue(queue: Pick<Queue<CleanupJobData>, 'add'>): void {
console.error(`[DEBUG] FlyerProcessingService._setCleanupQueue called`);
this.cleanupQueue = queue;
}
/** /**
* Orchestrates the processing of a flyer job. * Orchestrates the processing of a flyer job.
* @param job The BullMQ job containing flyer data. * @param job The BullMQ job containing flyer data.

View File

@@ -27,9 +27,15 @@ vi.mock('../../utils/imageProcessor', async () => {
const actual = await vi.importActual<typeof import('../../utils/imageProcessor')>( const actual = await vi.importActual<typeof import('../../utils/imageProcessor')>(
'../../utils/imageProcessor', '../../utils/imageProcessor',
); );
// eslint-disable-next-line @typescript-eslint/no-require-imports
const pathModule = require('path');
return { return {
...actual, ...actual,
generateFlyerIcon: vi.fn().mockResolvedValue('mock-icon-safe.webp'), // Return a realistic icon filename based on the source file
generateFlyerIcon: vi.fn().mockImplementation(async (sourcePath: string) => {
const baseName = pathModule.parse(pathModule.basename(sourcePath)).name;
return `icon-${baseName}.webp`;
}),
}; };
}); });
@@ -97,40 +103,12 @@ vi.mock('../../services/storage/storageService', () => {
* @vitest-environment node * @vitest-environment node
*/ */
// CRITICAL: These mock functions must be declared with vi.hoisted() to ensure they're available // NOTE: We use dependency injection to mock the AI processor and DB transaction.
// at the module level BEFORE any imports are resolved. // vi.mock() doesn't work reliably across module boundaries because workers import
const { mockExtractCoreData } = vi.hoisted(() => { // the real modules before our mock is applied. Instead, we use:
return { // - FlyerAiProcessor._setExtractAndValidateData() for AI mocks
mockExtractCoreData: vi.fn(), // - FlyerPersistenceService._setWithTransaction() for DB mocks
}; import type { AiProcessorResult } from '../../services/flyerAiProcessor.server';
});
// CRITICAL: Mock the aiService module BEFORE any other imports that depend on it.
// This ensures workers get the mocked version, not the real one.
// We use a partial mock that only overrides extractCoreDataFromFlyerImage.
vi.mock('../../services/aiService.server', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../services/aiService.server')>();
// Create a proxy around the actual aiService that intercepts extractCoreDataFromFlyerImage
const proxiedAiService = new Proxy(actual.aiService, {
get(target, prop) {
if (prop === 'extractCoreDataFromFlyerImage') {
return mockExtractCoreData;
}
// For all other properties/methods, return the original
return target[prop as keyof typeof target];
},
});
return {
...actual,
aiService: proxiedAiService,
};
});
// NOTE: We no longer mock connection.db at the module level because vi.mock() doesn't work
// across module boundaries (the worker imports the real module before our mock is applied).
// Instead, we use dependency injection via FlyerPersistenceService._setWithTransaction().
describe('Flyer Processing Background Job Integration Test', () => { describe('Flyer Processing Background Job Integration Test', () => {
let request: ReturnType<typeof supertest>; let request: ReturnType<typeof supertest>;
@@ -169,13 +147,9 @@ describe('Flyer Processing Background Job Integration Test', () => {
request = supertest(app); request = supertest(app);
}); });
// FIX: Reset mocks before each test to ensure isolation. // Helper function to create default mock AI response
// This prevents "happy path" mocks from leaking into error handling tests and vice versa. const createDefaultMockAiResult = (): AiProcessorResult => ({
beforeEach(async () => { data: {
console.error('[TEST SETUP] Resetting mocks before test execution');
// 1. Reset AI Service Mock to default success state
mockExtractCoreData.mockReset();
mockExtractCoreData.mockResolvedValue({
store_name: 'Mock Store', store_name: 'Mock Store',
valid_from: '2025-01-01', valid_from: '2025-01-01',
valid_to: '2025-01-07', valid_to: '2025-01-07',
@@ -189,16 +163,36 @@ describe('Flyer Processing Background Job Integration Test', () => {
category_name: 'Mock Category', category_name: 'Mock Category',
}, },
], ],
}); },
needsReview: false,
});
// FIX: Reset mocks before each test to ensure isolation.
// This prevents "happy path" mocks from leaking into error handling tests and vice versa.
beforeEach(async () => {
console.error('[TEST SETUP] Resetting mocks before test execution');
// 2. Restore withTransaction to real implementation via dependency injection
// This ensures that unless a test specifically injects a mock, the DB logic works as expected.
if (workersModule) { if (workersModule) {
// 1. Reset AI Processor to default success state via dependency injection
// This replaces the vi.mock approach which didn't work across module boundaries
workersModule.flyerProcessingService
._getAiProcessor()
._setExtractAndValidateData(async () => createDefaultMockAiResult());
console.error('[TEST SETUP] AI processor mock set to default success state via DI');
// 2. Restore withTransaction to real implementation via dependency injection
// This ensures that unless a test specifically injects a mock, the DB logic works as expected.
const { withTransaction } = await import('../../services/db/connection.db'); const { withTransaction } = await import('../../services/db/connection.db');
workersModule.flyerProcessingService workersModule.flyerProcessingService
._getPersistenceService() ._getPersistenceService()
._setWithTransaction(withTransaction); ._setWithTransaction(withTransaction);
console.error('[TEST SETUP] withTransaction restored to real implementation via DI'); console.error('[TEST SETUP] withTransaction restored to real implementation via DI');
// 3. Restore cleanup queue to real implementation
// Some tests replace it with a no-op to prevent file cleanup during verification
const { cleanupQueue } = await import('../../services/queues.server');
workersModule.flyerProcessingService._setCleanupQueue(cleanupQueue);
console.error('[TEST SETUP] cleanupQueue restored to real implementation via DI');
} }
}); });
@@ -366,6 +360,10 @@ describe('Flyer Processing Background Job Integration Test', () => {
}, 240000); // Increase timeout to 240 seconds for this long-running test }, 240000); // Increase timeout to 240 seconds for this long-running test
it('should strip EXIF data from uploaded JPEG images during processing', async () => { it('should strip EXIF data from uploaded JPEG images during processing', async () => {
// Arrange: Replace cleanup queue with a no-op to prevent file deletion before we can verify
const noOpCleanupQueue = { add: vi.fn().mockResolvedValue({ id: 'noop' }) };
workersModule.flyerProcessingService._setCleanupQueue(noOpCleanupQueue);
// Arrange: Create a user for this test // Arrange: Create a user for this test
const { user: authUser, token } = await createAndLoginUser({ const { user: authUser, token } = await createAndLoginUser({
email: `exif-user-${Date.now()}@example.com`, email: `exif-user-${Date.now()}@example.com`,
@@ -394,9 +392,13 @@ describe('Flyer Processing Background Job Integration Test', () => {
const checksum = await generateFileChecksum(mockImageFile); const checksum = await generateFileChecksum(mockImageFile);
// Track original and derived files for cleanup // Track original and derived files for cleanup
// NOTE: In test mode, multer uses predictable filenames: flyerFile-test-flyer-image.{ext}
const uploadDir = testStoragePath; const uploadDir = testStoragePath;
createdFilePaths.push(path.join(uploadDir, uniqueFileName)); const multerFileName = 'flyerFile-test-flyer-image.jpg';
const iconFileName = `icon-${path.parse(uniqueFileName).name}.webp`; const processedFileName = 'flyerFile-test-flyer-image-processed.jpeg';
createdFilePaths.push(path.join(uploadDir, multerFileName));
createdFilePaths.push(path.join(uploadDir, processedFileName));
const iconFileName = `icon-flyerFile-test-flyer-image-processed.webp`;
createdFilePaths.push(path.join(uploadDir, 'icons', iconFileName)); createdFilePaths.push(path.join(uploadDir, 'icons', iconFileName));
// 2. Act: Upload the file and wait for processing // 2. Act: Upload the file and wait for processing
@@ -440,14 +442,14 @@ describe('Flyer Processing Background Job Integration Test', () => {
createdStoreIds.push(savedFlyer.store_id); createdStoreIds.push(savedFlyer.store_id);
} }
const savedImagePath = path.join(uploadDir, path.basename(savedFlyer!.image_url)); // Use the known processed filename (multer uses predictable names in test mode)
createdFilePaths.push(savedImagePath); // Add final path for cleanup const savedImagePath = path.join(uploadDir, processedFileName);
console.error('[TEST] savedImagePath during EXIF data stripping: ', savedImagePath);
const savedImageBuffer = await fs.readFile(savedImagePath); const savedImageBuffer = await fs.readFile(savedImagePath);
const parser = exifParser.create(savedImageBuffer); const parser = exifParser.create(savedImageBuffer);
const exifResult = parser.parse(); const exifResult = parser.parse();
console.error('[TEST] savedImagePath during EXIF data stripping: ', savedImagePath);
console.error('[TEST] exifResult.tags: ', exifResult.tags); console.error('[TEST] exifResult.tags: ', exifResult.tags);
// The `tags` object will be empty if no EXIF data is found. // The `tags` object will be empty if no EXIF data is found.
@@ -456,6 +458,10 @@ describe('Flyer Processing Background Job Integration Test', () => {
}, 240000); }, 240000);
it('should strip metadata from uploaded PNG images during processing', async () => { it('should strip metadata from uploaded PNG images during processing', async () => {
// Arrange: Replace cleanup queue with a no-op to prevent file deletion before we can verify
const noOpCleanupQueue = { add: vi.fn().mockResolvedValue({ id: 'noop' }) };
workersModule.flyerProcessingService._setCleanupQueue(noOpCleanupQueue);
// Arrange: Create a user for this test // Arrange: Create a user for this test
const { user: authUser, token } = await createAndLoginUser({ const { user: authUser, token } = await createAndLoginUser({
email: `png-meta-user-${Date.now()}@example.com`, email: `png-meta-user-${Date.now()}@example.com`,
@@ -485,9 +491,13 @@ describe('Flyer Processing Background Job Integration Test', () => {
const checksum = await generateFileChecksum(mockImageFile); const checksum = await generateFileChecksum(mockImageFile);
// Track files for cleanup // Track files for cleanup
// NOTE: In test mode, multer uses predictable filenames: flyerFile-test-flyer-image.{ext}
const uploadDir = testStoragePath; const uploadDir = testStoragePath;
createdFilePaths.push(path.join(uploadDir, uniqueFileName)); const multerFileName = 'flyerFile-test-flyer-image.png';
const iconFileName = `icon-${path.parse(uniqueFileName).name}.webp`; const processedFileName = 'flyerFile-test-flyer-image-processed.png';
createdFilePaths.push(path.join(uploadDir, multerFileName));
createdFilePaths.push(path.join(uploadDir, processedFileName));
const iconFileName = `icon-flyerFile-test-flyer-image-processed.webp`;
createdFilePaths.push(path.join(uploadDir, 'icons', iconFileName)); createdFilePaths.push(path.join(uploadDir, 'icons', iconFileName));
// 2. Act: Upload the file and wait for processing // 2. Act: Upload the file and wait for processing
@@ -531,23 +541,23 @@ describe('Flyer Processing Background Job Integration Test', () => {
createdStoreIds.push(savedFlyer.store_id); createdStoreIds.push(savedFlyer.store_id);
} }
const savedImagePath = path.join(uploadDir, path.basename(savedFlyer!.image_url)); // Use the known processed filename (multer uses predictable names in test mode)
createdFilePaths.push(savedImagePath); // Add final path for cleanup const savedImagePath = path.join(uploadDir, processedFileName);
console.error('[TEST] savedImagePath during PNG metadata stripping: ', savedImagePath); console.error('[TEST] savedImagePath during PNG metadata stripping: ', savedImagePath);
const savedImageMetadata = await sharp(savedImagePath).metadata(); const savedImageMetadata = await sharp(savedImagePath).metadata();
// The test should fail here initially because PNGs are not processed. // The `exif` property should be undefined after stripping.
// The `exif` property should be undefined after the fix.
expect(savedImageMetadata.exif).toBeUndefined(); expect(savedImageMetadata.exif).toBeUndefined();
}, 240000); }, 240000);
it('should handle a failure from the AI service gracefully', async () => { it('should handle a failure from the AI service gracefully', async () => {
// Arrange: Mock the AI service to throw an error for this specific test. // Arrange: Inject a failing AI processor via dependency injection.
const aiError = new Error('AI model failed to extract data.'); const aiError = new Error('AI model failed to extract data.');
// Update the spy implementation to reject workersModule.flyerProcessingService._getAiProcessor()._setExtractAndValidateData(async () => {
mockExtractCoreData.mockRejectedValue(aiError); throw aiError;
});
console.error('[AI FAILURE TEST] AI processor mock set to throw error via DI');
// Arrange: Prepare a unique flyer file for upload. // Arrange: Prepare a unique flyer file for upload.
const imagePath = path.resolve(__dirname, '../assets/test-flyer-image.jpg'); const imagePath = path.resolve(__dirname, '../assets/test-flyer-image.jpg');
@@ -652,9 +662,12 @@ describe('Flyer Processing Background Job Integration Test', () => {
}, 240000); }, 240000);
it('should NOT clean up temporary files when a job fails, to allow for manual inspection', async () => { it('should NOT clean up temporary files when a job fails, to allow for manual inspection', async () => {
// Arrange: Mock the AI service to throw an error, causing the job to fail. // Arrange: Inject a failing AI processor via dependency injection.
const aiError = new Error('Simulated AI failure for cleanup test.'); const aiError = new Error('Simulated AI failure for cleanup test.');
mockExtractCoreData.mockRejectedValue(aiError); workersModule.flyerProcessingService._getAiProcessor()._setExtractAndValidateData(async () => {
throw aiError;
});
console.error('[CLEANUP TEST] AI processor mock set to throw error via DI');
// Arrange: Prepare a unique flyer file for upload. // Arrange: Prepare a unique flyer file for upload.
const imagePath = path.resolve(__dirname, '../assets/test-flyer-image.jpg'); const imagePath = path.resolve(__dirname, '../assets/test-flyer-image.jpg');
@@ -687,7 +700,7 @@ describe('Flyer Processing Background Job Integration Test', () => {
const statusResponse = await request.get(`/api/ai/jobs/${jobId}/status`); const statusResponse = await request.get(`/api/ai/jobs/${jobId}/status`);
return statusResponse.body.data; return statusResponse.body.data;
}, },
(status) => status.state === 'failed', // We expect this one to fail (status) => status.state === 'completed' || status.state === 'failed',
{ timeout: 180000, interval: 3000, description: 'file cleanup failure test job' }, { timeout: 180000, interval: 3000, description: 'file cleanup failure test job' },
); );
@@ -696,10 +709,7 @@ describe('Flyer Processing Background Job Integration Test', () => {
expect(jobStatus?.failedReason).toContain('Simulated AI failure for cleanup test.'); expect(jobStatus?.failedReason).toContain('Simulated AI failure for cleanup test.');
// Assert 2: Verify the temporary file was NOT deleted. // Assert 2: Verify the temporary file was NOT deleted.
// We check for its existence. If it doesn't exist, fs.access will throw an error. // fs.access throws if the file doesn't exist, so we expect it NOT to throw.
await expect( await expect(fs.access(tempFilePath)).resolves.toBeUndefined();
fs.access(tempFilePath),
'Expected temporary file to exist after job failure, but it was deleted.',
);
}, 240000); }, 240000);
}); });

View File

@@ -1,8 +1,43 @@
// src/tests/utils/renderWithProviders.tsx // src/tests/utils/renderWithProviders.tsx
import React, { ReactElement } from 'react'; import React, { ReactElement, ReactNode } from 'react';
import { render, RenderOptions } from '@testing-library/react'; import { render, RenderOptions } from '@testing-library/react';
import { AppProviders } from '../../providers/AppProviders'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MemoryRouter } from 'react-router-dom'; import { MemoryRouter } from 'react-router-dom';
import { AuthProvider } from '../../providers/AuthProvider';
import { FlyersProvider } from '../../providers/FlyersProvider';
import { MasterItemsProvider } from '../../providers/MasterItemsProvider';
import { ModalProvider } from '../../providers/ModalProvider';
import { UserDataProvider } from '../../providers/UserDataProvider';
/**
* Creates a fresh QueryClient configured for testing.
* Uses minimal retry/cache settings to make tests faster and more predictable.
*
* @returns A new QueryClient instance for testing
*/
export const createTestQueryClient = () =>
new QueryClient({
defaultOptions: {
queries: {
retry: false,
gcTime: 0,
staleTime: 0,
},
mutations: {
retry: false,
},
},
});
/**
* A wrapper component that provides just the QueryClientProvider.
* Use this for testing hooks or components that use TanStack Query but don't
* need the full AppProviders stack.
*/
export const QueryWrapper = ({ children }: { children: ReactNode }) => {
const testQueryClient = createTestQueryClient();
return <QueryClientProvider client={testQueryClient}>{children}</QueryClientProvider>;
};
interface ExtendedRenderOptions extends Omit<RenderOptions, 'wrapper'> { interface ExtendedRenderOptions extends Omit<RenderOptions, 'wrapper'> {
initialEntries?: string[]; initialEntries?: string[];
@@ -12,19 +47,30 @@ interface ExtendedRenderOptions extends Omit<RenderOptions, 'wrapper'> {
* A custom render function that wraps the component with all application providers. * A custom render function that wraps the component with all application providers.
* This is useful for testing components that rely on context values (Auth, Modal, etc.). * This is useful for testing components that rely on context values (Auth, Modal, etc.).
* *
* Unlike AppProviders, this uses a fresh test-specific QueryClient for each render
* to ensure test isolation and predictable behavior (no retries, no caching).
*
* @param ui The component to render * @param ui The component to render
* @param options Additional render options * @param options Additional render options
* @returns The result of the render function * @returns The result of the render function
*/ */
export const renderWithProviders = ( export const renderWithProviders = (ui: ReactElement, options?: ExtendedRenderOptions) => {
ui: ReactElement,
options?: ExtendedRenderOptions,
) => {
const { initialEntries, ...renderOptions } = options || {}; const { initialEntries, ...renderOptions } = options || {};
// console.log('[renderWithProviders] Wrapping component with AppProviders context.'); const testQueryClient = createTestQueryClient();
// Replicate the AppProviders hierarchy but with a test-specific QueryClient
const Wrapper = ({ children }: { children: React.ReactNode }) => ( const Wrapper = ({ children }: { children: React.ReactNode }) => (
<MemoryRouter initialEntries={initialEntries}> <MemoryRouter initialEntries={initialEntries}>
<AppProviders>{children}</AppProviders> <QueryClientProvider client={testQueryClient}>
<ModalProvider>
<AuthProvider>
<FlyersProvider>
<MasterItemsProvider>
<UserDataProvider>{children}</UserDataProvider>
</MasterItemsProvider>
</FlyersProvider>
</AuthProvider>
</ModalProvider>
</QueryClientProvider>
</MemoryRouter> </MemoryRouter>
); );
return render(ui, { wrapper: Wrapper, ...renderOptions }); return render(ui, { wrapper: Wrapper, ...renderOptions });

1351
test-output.txt Normal file

File diff suppressed because it is too large Load Diff