diff --git a/package-lock.json b/package-lock.json index 2ef7e42e..a90395b4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -50,6 +50,7 @@ }, "devDependencies": { "@tailwindcss/postcss": "4.1.17", + "@tanstack/react-query-devtools": "^5.91.2", "@testcontainers/postgresql": "^11.8.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.0", @@ -4887,9 +4888,20 @@ "license": "MIT" }, "node_modules/@tanstack/query-core": { - "version": "5.90.12", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.12.tgz", - "integrity": "sha512-T1/8t5DhV/SisWjDnaiU2drl6ySvsHj1bHBCWNXd+/T+Hh1cf6JodyEYMd5sgwm+b/mETT4EV3H+zCVczCU5hg==", + "version": "5.90.16", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.16.tgz", + "integrity": "sha512-MvtWckSVufs/ja463/K4PyJeqT+HMlJWtw6PrCpywznd2NSgO3m4KwO9RqbFqGg6iDE8vVMFWMeQI4Io3eEYww==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/query-devtools": { + "version": "5.92.0", + "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.92.0.tgz", + "integrity": "sha512-N8D27KH1vEpVacvZgJL27xC6yPFUy0Zkezn5gnB3L3gRCxlDeSuiya7fKge8Y91uMTnC8aSxBQhcK6ocY7alpQ==", + "dev": true, "license": "MIT", "funding": { "type": "github", @@ -4897,12 +4909,12 @@ } }, "node_modules/@tanstack/react-query": { - "version": "5.90.12", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.12.tgz", - "integrity": "sha512-graRZspg7EoEaw0a8faiUASCyJrqjKPdqJ9EwuDRUF9mEYJ1YPczI9H+/agJ0mOJkPCJDk0lsz5QTrLZ/jQ2rg==", + "version": "5.90.16", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.16.tgz", + "integrity": "sha512-bpMGOmV4OPmif7TNMteU/Ehf/hoC0Kf98PDc0F4BZkFrEapRMEqI/V6YS0lyzwSV6PQpY1y4xxArUIfBW5LVxQ==", "license": "MIT", "dependencies": { - "@tanstack/query-core": "5.90.12" + "@tanstack/query-core": "5.90.16" }, "funding": { "type": "github", @@ -4912,6 +4924,24 @@ "react": "^18 || ^19" } }, + "node_modules/@tanstack/react-query-devtools": { + "version": "5.91.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.91.2.tgz", + "integrity": "sha512-ZJ1503ay5fFeEYFUdo7LMNFzZryi6B0Cacrgr2h1JRkvikK1khgIq6Nq2EcblqEdIlgB/r7XDW8f8DQ89RuUgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tanstack/query-devtools": "5.92.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@tanstack/react-query": "^5.90.14", + "react": "^18 || ^19" + } + }, "node_modules/@testcontainers/postgresql": { "version": "11.10.0", "resolved": "https://registry.npmjs.org/@testcontainers/postgresql/-/postgresql-11.10.0.tgz", diff --git a/package.json b/package.json index c25a0939..eec99640 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,7 @@ }, "devDependencies": { "@tailwindcss/postcss": "4.1.17", + "@tanstack/react-query-devtools": "^5.91.2", "@testcontainers/postgresql": "^11.8.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.0", diff --git a/src/features/flyer/ExtractedDataTable.test.tsx b/src/features/flyer/ExtractedDataTable.test.tsx index 96b29a5c..28e48f17 100644 --- a/src/features/flyer/ExtractedDataTable.test.tsx +++ b/src/features/flyer/ExtractedDataTable.test.tsx @@ -157,8 +157,6 @@ describe('ExtractedDataTable', () => { vi.mocked(useUserData).mockReturnValue({ watchedItems: [], shoppingLists: mockShoppingLists, - setWatchedItems: vi.fn(), - setShoppingLists: vi.fn(), isLoading: false, error: null, }); @@ -222,8 +220,6 @@ describe('ExtractedDataTable', () => { vi.mocked(useUserData).mockReturnValue({ watchedItems: [mockMasterItems[0]], // 'Apples' is watched shoppingLists: [], - setWatchedItems: vi.fn(), - setShoppingLists: vi.fn(), isLoading: false, error: null, }); @@ -355,8 +351,6 @@ describe('ExtractedDataTable', () => { vi.mocked(useUserData).mockReturnValue({ watchedItems: [mockMasterItems[2], mockMasterItems[0]], shoppingLists: [], - setWatchedItems: vi.fn(), - setShoppingLists: vi.fn(), isLoading: false, error: null, }); @@ -456,8 +450,6 @@ describe('ExtractedDataTable', () => { createMockMasterGroceryItem({ master_grocery_item_id: 2, name: 'Apple' }), ], shoppingLists: [], - setWatchedItems: vi.fn(), - setShoppingLists: vi.fn(), isLoading: false, error: null, }); diff --git a/src/hooks/mutations/useAddWatchedItemMutation.ts b/src/hooks/mutations/useAddWatchedItemMutation.ts index 134ad37e..b6934a9e 100644 --- a/src/hooks/mutations/useAddWatchedItemMutation.ts +++ b/src/hooks/mutations/useAddWatchedItemMutation.ts @@ -37,7 +37,7 @@ export const useAddWatchedItemMutation = () => { return useMutation({ mutationFn: async ({ itemName, category }: AddWatchedItemParams) => { - const response = await apiClient.addWatchedItem(itemName, category); + const response = await apiClient.addWatchedItem(itemName, category ?? ''); if (!response.ok) { const error = await response.json().catch(() => ({ diff --git a/src/hooks/queries/useActivityLogQuery.ts b/src/hooks/queries/useActivityLogQuery.ts index 253194bb..418791d1 100644 --- a/src/hooks/queries/useActivityLogQuery.ts +++ b/src/hooks/queries/useActivityLogQuery.ts @@ -1,18 +1,7 @@ // src/hooks/queries/useActivityLogQuery.ts import { useQuery } from '@tanstack/react-query'; -import * as apiClient from '../../services/apiClient'; - -interface ActivityLogEntry { - activity_log_id: number; - user_id: string; - action: string; - entity_type: string | null; - entity_id: number | null; - details: any; - ip_address: string | null; - user_agent: string | null; - created_at: string; -} +import { fetchActivityLog } from '../../services/apiClient'; +import type { ActivityLogItem } from '../../types'; /** * Query hook for fetching the admin activity log. @@ -33,8 +22,8 @@ interface ActivityLogEntry { export const useActivityLogQuery = (limit: number = 20, offset: number = 0) => { return useQuery({ queryKey: ['activity-log', { limit, offset }], - queryFn: async (): Promise => { - const response = await apiClient.fetchActivityLog(limit, offset); + queryFn: async (): Promise => { + const response = await fetchActivityLog(limit, offset); if (!response.ok) { const error = await response.json().catch(() => ({ diff --git a/src/hooks/queries/useApplicationStatsQuery.ts b/src/hooks/queries/useApplicationStatsQuery.ts index 1c1ff95a..b5160082 100644 --- a/src/hooks/queries/useApplicationStatsQuery.ts +++ b/src/hooks/queries/useApplicationStatsQuery.ts @@ -1,6 +1,6 @@ // src/hooks/queries/useApplicationStatsQuery.ts import { useQuery } from '@tanstack/react-query'; -import { apiClient, AppStats } from '../../services/apiClient'; +import { getApplicationStats, AppStats } from '../../services/apiClient'; /** * Query hook for fetching application-wide statistics (admin feature). @@ -21,7 +21,7 @@ export const useApplicationStatsQuery = () => { return useQuery({ queryKey: ['application-stats'], queryFn: async (): Promise => { - const response = await apiClient.getApplicationStats(); + const response = await getApplicationStats(); if (!response.ok) { const error = await response.json().catch(() => ({ diff --git a/src/hooks/queries/useCategoriesQuery.ts b/src/hooks/queries/useCategoriesQuery.ts index 3849e8b8..cc2cb0d3 100644 --- a/src/hooks/queries/useCategoriesQuery.ts +++ b/src/hooks/queries/useCategoriesQuery.ts @@ -1,6 +1,6 @@ // src/hooks/queries/useCategoriesQuery.ts import { useQuery } from '@tanstack/react-query'; -import { apiClient } from '../../services/apiClient'; +import { fetchCategories } from '../../services/apiClient'; import type { Category } from '../../types'; /** @@ -16,7 +16,7 @@ export const useCategoriesQuery = () => { return useQuery({ queryKey: ['categories'], queryFn: async (): Promise => { - const response = await apiClient.fetchCategories(); + const response = await fetchCategories(); if (!response.ok) { const error = await response.json().catch(() => ({ diff --git a/src/hooks/queries/useSuggestedCorrectionsQuery.ts b/src/hooks/queries/useSuggestedCorrectionsQuery.ts index 46655203..1aa7534f 100644 --- a/src/hooks/queries/useSuggestedCorrectionsQuery.ts +++ b/src/hooks/queries/useSuggestedCorrectionsQuery.ts @@ -1,6 +1,6 @@ // src/hooks/queries/useSuggestedCorrectionsQuery.ts import { useQuery } from '@tanstack/react-query'; -import { apiClient } from '../../services/apiClient'; +import { getSuggestedCorrections } from '../../services/apiClient'; import type { SuggestedCorrection } from '../../types'; /** @@ -16,7 +16,7 @@ export const useSuggestedCorrectionsQuery = () => { return useQuery({ queryKey: ['suggested-corrections'], queryFn: async (): Promise => { - const response = await apiClient.getSuggestedCorrections(); + const response = await getSuggestedCorrections(); if (!response.ok) { const error = await response.json().catch(() => ({ diff --git a/src/hooks/useFlyers.test.tsx b/src/hooks/useFlyers.test.tsx index 0bb863ff..6f9bdfec 100644 --- a/src/hooks/useFlyers.test.tsx +++ b/src/hooks/useFlyers.test.tsx @@ -4,15 +4,15 @@ import { renderHook, act } from '@testing-library/react'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { useFlyers } from './useFlyers'; import { FlyersProvider } from '../providers/FlyersProvider'; -import { useInfiniteQuery } from './useInfiniteQuery'; +import { useFlyersQuery } from './queries/useFlyersQuery'; import type { Flyer } from '../types'; import { createMockFlyer } from '../tests/utils/mockFactories'; -// 1. Mock the useInfiniteQuery hook, which is the dependency of our FlyersProvider. -vi.mock('./useInfiniteQuery'); +// 1. Mock the useFlyersQuery hook, which is the dependency of our FlyersProvider. +vi.mock('./queries/useFlyersQuery'); // 2. Create a typed mock of the hook for type safety and autocompletion. -const mockedUseInfiniteQuery = vi.mocked(useInfiniteQuery); +const mockedUseFlyersQuery = vi.mocked(useFlyersQuery); // 3. A simple wrapper component that renders our provider. // This is necessary because the useFlyers hook needs to be a child of FlyersProvider. @@ -22,7 +22,6 @@ const wrapper = ({ children }: { children: ReactNode }) => ( describe('useFlyers Hook and FlyersProvider', () => { // Create mock functions that we can spy on to see if they are called. - const mockFetchNextPage = vi.fn(); const mockRefetch = vi.fn(); beforeEach(() => { @@ -46,16 +45,32 @@ describe('useFlyers Hook and FlyersProvider', () => { it('should return the initial loading state correctly', () => { // Arrange: Configure the mocked hook to return a loading state. - mockedUseInfiniteQuery.mockReturnValue({ - data: [], + mockedUseFlyersQuery.mockReturnValue({ + data: undefined, isLoading: true, error: null, - fetchNextPage: mockFetchNextPage, - hasNextPage: false, refetch: mockRefetch, isRefetching: false, - isFetchingNextPage: false, - }); + // TanStack Query properties (partial mock) + status: 'pending', + fetchStatus: 'fetching', + isPending: true, + isSuccess: false, + isError: false, + isFetched: false, + isFetchedAfterMount: false, + isStale: false, + isPlaceholderData: false, + dataUpdatedAt: 0, + errorUpdatedAt: 0, + failureCount: 0, + failureReason: null, + errorUpdateCount: 0, + isInitialLoading: true, + isLoadingError: false, + isRefetchError: false, + promise: Promise.resolve([]), + } as any); // Act: Render the hook within the provider wrapper. const { result } = renderHook(() => useFlyers(), { wrapper }); @@ -66,7 +81,7 @@ describe('useFlyers Hook and FlyersProvider', () => { expect(result.current.flyersError).toBeNull(); }); - it('should return flyers data and hasNextPage on successful fetch', () => { + it('should return flyers data on successful fetch', () => { // Arrange: Mock a successful data fetch. const mockFlyers: Flyer[] = [ createMockFlyer({ @@ -77,16 +92,31 @@ describe('useFlyers Hook and FlyersProvider', () => { created_at: '2024-01-01', }), ]; - mockedUseInfiniteQuery.mockReturnValue({ + mockedUseFlyersQuery.mockReturnValue({ data: mockFlyers, isLoading: false, error: null, - fetchNextPage: mockFetchNextPage, - hasNextPage: true, refetch: mockRefetch, isRefetching: false, - isFetchingNextPage: false, - }); + status: 'success', + fetchStatus: 'idle', + isPending: false, + isSuccess: true, + isError: false, + isFetched: true, + isFetchedAfterMount: true, + isStale: false, + isPlaceholderData: false, + dataUpdatedAt: Date.now(), + errorUpdatedAt: 0, + failureCount: 0, + failureReason: null, + errorUpdateCount: 0, + isInitialLoading: false, + isLoadingError: false, + isRefetchError: false, + promise: Promise.resolve(mockFlyers), + } as any); // Act const { result } = renderHook(() => useFlyers(), { wrapper }); @@ -94,22 +124,38 @@ describe('useFlyers Hook and FlyersProvider', () => { // Assert expect(result.current.isLoadingFlyers).toBe(false); expect(result.current.flyers).toEqual(mockFlyers); - expect(result.current.hasNextFlyersPage).toBe(true); + // Note: hasNextFlyersPage is always false now since we're not using infinite query + expect(result.current.hasNextFlyersPage).toBe(false); }); it('should return an error state if the fetch fails', () => { // Arrange: Mock a failed data fetch. const mockError = new Error('Failed to fetch'); - mockedUseInfiniteQuery.mockReturnValue({ - data: [], + mockedUseFlyersQuery.mockReturnValue({ + data: undefined, isLoading: false, error: mockError, - fetchNextPage: mockFetchNextPage, - hasNextPage: false, refetch: mockRefetch, isRefetching: false, - isFetchingNextPage: false, - }); + status: 'error', + fetchStatus: 'idle', + isPending: false, + isSuccess: false, + isError: true, + isFetched: true, + isFetchedAfterMount: true, + isStale: false, + isPlaceholderData: false, + dataUpdatedAt: 0, + errorUpdatedAt: Date.now(), + failureCount: 1, + failureReason: mockError, + errorUpdateCount: 1, + isInitialLoading: false, + isLoadingError: true, + isRefetchError: false, + promise: Promise.resolve(undefined), + } as any); // Act const { result } = renderHook(() => useFlyers(), { wrapper }); @@ -120,41 +166,33 @@ describe('useFlyers Hook and FlyersProvider', () => { expect(result.current.flyersError).toBe(mockError); }); - it('should call fetchNextFlyersPage when the context function is invoked', () => { - // Arrange - mockedUseInfiniteQuery.mockReturnValue({ - data: [], - isLoading: false, - error: null, - hasNextPage: true, - isRefetching: false, - isFetchingNextPage: false, - fetchNextPage: mockFetchNextPage, // Pass the mock function - refetch: mockRefetch, - }); - const { result } = renderHook(() => useFlyers(), { wrapper }); - - // Act: Use `act` to wrap state updates. - act(() => { - result.current.fetchNextFlyersPage(); - }); - - // Assert - expect(mockFetchNextPage).toHaveBeenCalledTimes(1); - }); - it('should call refetchFlyers when the context function is invoked', () => { // Arrange - mockedUseInfiniteQuery.mockReturnValue({ + mockedUseFlyersQuery.mockReturnValue({ data: [], isLoading: false, error: null, - hasNextPage: false, - isRefetching: false, - isFetchingNextPage: false, - fetchNextPage: mockFetchNextPage, refetch: mockRefetch, - }); + isRefetching: false, + status: 'success', + fetchStatus: 'idle', + isPending: false, + isSuccess: true, + isError: false, + isFetched: true, + isFetchedAfterMount: true, + isStale: false, + isPlaceholderData: false, + dataUpdatedAt: Date.now(), + errorUpdatedAt: 0, + failureCount: 0, + failureReason: null, + errorUpdateCount: 0, + isInitialLoading: false, + isLoadingError: false, + isRefetchError: false, + promise: Promise.resolve([]), + } as any); const { result } = renderHook(() => useFlyers(), { wrapper }); // Act @@ -165,4 +203,40 @@ describe('useFlyers Hook and FlyersProvider', () => { // Assert expect(mockRefetch).toHaveBeenCalledTimes(1); }); + + it('should have fetchNextFlyersPage as a no-op (infinite scroll not implemented)', () => { + // Arrange + mockedUseFlyersQuery.mockReturnValue({ + data: [], + isLoading: false, + error: null, + refetch: mockRefetch, + isRefetching: false, + status: 'success', + fetchStatus: 'idle', + isPending: false, + isSuccess: true, + isError: false, + isFetched: true, + isFetchedAfterMount: true, + isStale: false, + isPlaceholderData: false, + dataUpdatedAt: Date.now(), + errorUpdatedAt: 0, + failureCount: 0, + failureReason: null, + errorUpdateCount: 0, + isInitialLoading: false, + isLoadingError: false, + isRefetchError: false, + promise: Promise.resolve([]), + } as any); + const { result } = renderHook(() => useFlyers(), { wrapper }); + + // Act & Assert: fetchNextFlyersPage should exist but be a no-op + expect(result.current.fetchNextFlyersPage).toBeDefined(); + expect(typeof result.current.fetchNextFlyersPage).toBe('function'); + // Calling it should not throw + expect(() => result.current.fetchNextFlyersPage()).not.toThrow(); + }); }); diff --git a/src/pages/admin/ActivityLog.test.tsx b/src/pages/admin/ActivityLog.test.tsx index 05f675e4..e4d0030f 100644 --- a/src/pages/admin/ActivityLog.test.tsx +++ b/src/pages/admin/ActivityLog.test.tsx @@ -1,21 +1,20 @@ // src/pages/admin/ActivityLog.test.tsx import React from 'react'; -import { render, screen, waitFor, fireEvent, act } from '@testing-library/react'; +import { render, screen, fireEvent } from '@testing-library/react'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { ActivityLog } from './ActivityLog'; -import * as apiClient from '../../services/apiClient'; +import { useActivityLogQuery } from '../../hooks/queries/useActivityLogQuery'; import type { ActivityLogItem, UserProfile } from '../../types'; import { createMockActivityLogItem, createMockUserProfile } from '../../tests/utils/mockFactories'; -// The apiClient and logger are now mocked globally via src/tests/setup/tests-setup-unit.ts. -// We can cast it to its mocked type to get type safety and autocompletion. -const mockedApiClient = vi.mocked(apiClient); +// Mock the TanStack Query hook +vi.mock('../../hooks/queries/useActivityLogQuery'); + +const mockedUseActivityLogQuery = vi.mocked(useActivityLogQuery); // Mock date-fns to return a consistent value for snapshots vi.mock('date-fns', () => { return { - // Only mock the specific function used in the component. - // This avoids potential issues with `importOriginal` in complex mocking scenarios. formatDistanceToNow: vi.fn(() => 'about 5 hours ago'), }; }); @@ -55,7 +54,7 @@ const mockLogs: ActivityLogItem[] = [ user_id: 'user-101', action: 'user_registered', display_text: 'New user joined', - details: { full_name: 'Newbie User' }, // No avatar provided to test fallback + details: { full_name: 'Newbie User' }, }), createMockActivityLogItem({ activity_log_id: 5, @@ -69,7 +68,7 @@ const mockLogs: ActivityLogItem[] = [ createMockActivityLogItem({ activity_log_id: 6, user_id: 'user-103', - action: 'unknown_action' as any, // Force unknown action to test default case + action: 'unknown_action' as any, display_text: 'Something happened', details: {} as any, }), @@ -78,6 +77,12 @@ const mockLogs: ActivityLogItem[] = [ describe('ActivityLog', () => { beforeEach(() => { vi.clearAllMocks(); + // Default mock implementation + mockedUseActivityLogQuery.mockReturnValue({ + data: [], + isLoading: false, + error: null, + } as any); }); it('should not render if userProfile is null', () => { @@ -86,108 +91,116 @@ describe('ActivityLog', () => { }); it('should show a loading state initially', async () => { - let resolvePromise: (value: Response) => void; - const mockPromise = new Promise((resolve) => { - resolvePromise = resolve; - }); - // Cast to any to bypass strict type checking for the mock return value vs Promise - mockedApiClient.fetchActivityLog.mockReturnValue(mockPromise as any); + mockedUseActivityLogQuery.mockReturnValue({ + data: undefined, + isLoading: true, + error: null, + } as any); render(); expect(screen.getByText('Loading activity...')).toBeInTheDocument(); - - await act(async () => { - resolvePromise!(new Response(JSON.stringify([]))); - }); }); it('should display an error message if fetching logs fails', async () => { - mockedApiClient.fetchActivityLog.mockRejectedValue(new Error('API is down')); + mockedUseActivityLogQuery.mockReturnValue({ + data: undefined, + isLoading: false, + error: new Error('API is down'), + } as any); + render(); - await waitFor(() => { - expect(screen.getByText('API is down')).toBeInTheDocument(); - }); + expect(screen.getByText('API is down')).toBeInTheDocument(); }); it('should display a message when there are no logs', async () => { - mockedApiClient.fetchActivityLog.mockResolvedValue(new Response(JSON.stringify([]))); + mockedUseActivityLogQuery.mockReturnValue({ + data: [], + isLoading: false, + error: null, + } as any); + render(); - await waitFor(() => { - expect(screen.getByText('No recent activity to show.')).toBeInTheDocument(); - }); + expect(screen.getByText('No recent activity to show.')).toBeInTheDocument(); }); it('should render a list of activities successfully covering all types', async () => { - mockedApiClient.fetchActivityLog.mockResolvedValue(new Response(JSON.stringify(mockLogs))); + mockedUseActivityLogQuery.mockReturnValue({ + data: mockLogs, + isLoading: false, + error: null, + } as any); + render(); - await waitFor(() => { - // Check for specific text from different log types - expect(screen.getByText('Walmart')).toBeInTheDocument(); // From flyer_processed - expect(screen.getByText('Pasta Carbonara')).toBeInTheDocument(); // From recipe_created - expect(screen.getByText('Weekly Groceries')).toBeInTheDocument(); // From list_shared - expect(screen.getByText('Newbie User')).toBeInTheDocument(); // From user_registered - expect(screen.getByText('Best Pizza')).toBeInTheDocument(); // From recipe_favorited - expect(screen.getByText('An unknown activity occurred.')).toBeInTheDocument(); // From unknown_action - // Check for user names - expect(screen.getByText('Jane Doe', { exact: false })).toBeInTheDocument(); + // Check for specific text from different log types + expect(screen.getByText('Walmart')).toBeInTheDocument(); + expect(screen.getByText('Pasta Carbonara')).toBeInTheDocument(); + expect(screen.getByText('Weekly Groceries')).toBeInTheDocument(); + expect(screen.getByText('Newbie User')).toBeInTheDocument(); + expect(screen.getByText('Best Pizza')).toBeInTheDocument(); + expect(screen.getByText('An unknown activity occurred.')).toBeInTheDocument(); - // Check for avatar - const avatar = screen.getByAltText('Test User'); - expect(avatar).toBeInTheDocument(); - expect(avatar).toHaveAttribute('src', 'https://example.com/avatar.png'); + // Check for user names + expect(screen.getByText('Jane Doe', { exact: false })).toBeInTheDocument(); - // Check for fallback avatar (Newbie User has no avatar) - // The fallback is an SVG inside a span. We can check for the span's class or the SVG. - // The container for fallback has specific classes. - // We can look for the container associated with the "Newbie User" item. - const newbieItem = screen.getByText('Newbie User').closest('li'); - const fallbackIcon = newbieItem?.querySelector('svg'); - expect(fallbackIcon).toBeInTheDocument(); + // Check for avatar + const avatar = screen.getByAltText('Test User'); + expect(avatar).toBeInTheDocument(); + expect(avatar).toHaveAttribute('src', 'https://example.com/avatar.png'); - // Check for the mocked date - expect(screen.getAllByText('about 5 hours ago')).toHaveLength(mockLogs.length); - }); + // Check for fallback avatar (Newbie User has no avatar) + const newbieItem = screen.getByText('Newbie User').closest('li'); + const fallbackIcon = newbieItem?.querySelector('svg'); + expect(fallbackIcon).toBeInTheDocument(); + + // Check for the mocked date + expect(screen.getAllByText('about 5 hours ago')).toHaveLength(mockLogs.length); }); it('should call onLogClick when a clickable log item is clicked', async () => { const onLogClickMock = vi.fn(); - mockedApiClient.fetchActivityLog.mockResolvedValue(new Response(JSON.stringify(mockLogs))); + mockedUseActivityLogQuery.mockReturnValue({ + data: mockLogs, + isLoading: false, + error: null, + } as any); + render(); - await waitFor(() => { - // Recipe Created - const clickableRecipe = screen.getByText('Pasta Carbonara'); - fireEvent.click(clickableRecipe); - expect(onLogClickMock).toHaveBeenCalledWith(mockLogs[1]); + // Recipe Created + const clickableRecipe = screen.getByText('Pasta Carbonara'); + fireEvent.click(clickableRecipe); + expect(onLogClickMock).toHaveBeenCalledWith(mockLogs[1]); - // List Shared - const clickableList = screen.getByText('Weekly Groceries'); - fireEvent.click(clickableList); - expect(onLogClickMock).toHaveBeenCalledWith(mockLogs[2]); + // List Shared + const clickableList = screen.getByText('Weekly Groceries'); + fireEvent.click(clickableList); + expect(onLogClickMock).toHaveBeenCalledWith(mockLogs[2]); - // Recipe Favorited - const clickableFav = screen.getByText('Best Pizza'); - fireEvent.click(clickableFav); - expect(onLogClickMock).toHaveBeenCalledWith(mockLogs[4]); - }); + // Recipe Favorited + const clickableFav = screen.getByText('Best Pizza'); + fireEvent.click(clickableFav); + expect(onLogClickMock).toHaveBeenCalledWith(mockLogs[4]); expect(onLogClickMock).toHaveBeenCalledTimes(3); }); it('should not render clickable styling if onLogClick is undefined', async () => { - mockedApiClient.fetchActivityLog.mockResolvedValue(new Response(JSON.stringify(mockLogs))); - render(); // onLogClick is undefined + mockedUseActivityLogQuery.mockReturnValue({ + data: mockLogs, + isLoading: false, + error: null, + } as any); - await waitFor(() => { - const recipeName = screen.getByText('Pasta Carbonara'); - expect(recipeName).not.toHaveClass('cursor-pointer'); - expect(recipeName).not.toHaveClass('text-blue-500'); + render(); - const listName = screen.getByText('Weekly Groceries'); - expect(listName).not.toHaveClass('cursor-pointer'); - }); + const recipeName = screen.getByText('Pasta Carbonara'); + expect(recipeName).not.toHaveClass('cursor-pointer'); + expect(recipeName).not.toHaveClass('text-blue-500'); + + const listName = screen.getByText('Weekly Groceries'); + expect(listName).not.toHaveClass('cursor-pointer'); }); it('should handle missing details in logs gracefully (fallback values)', async () => { @@ -197,113 +210,67 @@ describe('ActivityLog', () => { user_id: 'u1', action: 'flyer_processed', display_text: '...', - details: { flyer_id: 1, store_name: '' } as any, // Missing store_name, explicit empty to override mock default + details: { flyer_id: 1, store_name: '' } as any, }), createMockActivityLogItem({ activity_log_id: 102, user_id: 'u2', action: 'recipe_created', display_text: '...', - details: { recipe_id: 1, recipe_name: '' } as any, // Missing recipe_name + details: { recipe_id: 1, recipe_name: '' } as any, }), createMockActivityLogItem({ activity_log_id: 103, user_id: 'u3', action: 'user_registered', display_text: '...', - details: { full_name: '' } as any, // Missing full_name + details: { full_name: '' } as any, }), createMockActivityLogItem({ activity_log_id: 104, user_id: 'u4', action: 'recipe_favorited', display_text: '...', - details: { recipe_id: 2, recipe_name: '' } as any, // Missing recipe_name + details: { recipe_id: 2, recipe_name: '' } as any, }), createMockActivityLogItem({ activity_log_id: 105, user_id: 'u5', action: 'list_shared', display_text: '...', - details: { shopping_list_id: 1, list_name: '', shared_with_name: '' } as any, // Missing list_name and shared_with_name + details: { shopping_list_id: 1, list_name: '', shared_with_name: '' } as any, }), createMockActivityLogItem({ activity_log_id: 106, user_id: 'u6', action: 'flyer_processed', display_text: '...', - user_avatar_url: 'http://img.com/a.png', // FIX: Moved from details - user_full_name: '', // FIX: Moved from details to test fallback alt text + user_avatar_url: 'http://img.com/a.png', + user_full_name: '', details: { flyer_id: 2, store_name: 'Mock Store' } as any, }), ]; - mockedApiClient.fetchActivityLog.mockResolvedValue( - new Response(JSON.stringify(logsWithMissingDetails)), - ); + mockedUseActivityLogQuery.mockReturnValue({ + data: logsWithMissingDetails, + isLoading: false, + error: null, + } as any); - // Debug: verify structure of logs to ensure defaults are overridden - console.log( - 'Testing fallback rendering with logs:', - JSON.stringify(logsWithMissingDetails, null, 2), - ); - - const { container } = render(); - - await waitFor(() => { - console.log('[TEST DEBUG] Waiting for UI to update...'); - // Use screen.debug to log the current state of the DOM, which is invaluable for debugging. - screen.debug(undefined, 30000); - - console.log('[TEST DEBUG] Checking for fallback text elements...'); - expect(screen.getAllByText('a store')[0]).toBeInTheDocument(); - expect(screen.getByText('Untitled Recipe')).toBeInTheDocument(); - expect(screen.getByText('A new user')).toBeInTheDocument(); - expect(screen.getByText('a recipe')).toBeInTheDocument(); - expect(screen.getByText('a shopping list')).toBeInTheDocument(); - expect(screen.getByText('another user')).toBeInTheDocument(); - console.log('[TEST DEBUG] All fallback text elements found!'); - - console.log('[TEST DEBUG] Checking for avatar with fallback alt text...'); - // Check for empty alt text on avatar (item 106) - const avatars = screen.getAllByRole('img'); - console.log( - '[TEST DEBUG] Found avatars with alts:', - avatars.map((img) => img.getAttribute('alt')), - ); - const avatarWithFallbackAlt = avatars.find( - (img) => img.getAttribute('alt') === 'User Avatar', - ); - expect(avatarWithFallbackAlt).toBeInTheDocument(); - console.log('[TEST DEBUG] Fallback avatar with correct alt text found!'); - }); - }); - - it('should display error message from API response when not OK', async () => { - mockedApiClient.fetchActivityLog.mockResolvedValue( - new Response(JSON.stringify({ message: 'Server says no' }), { status: 500 }), - ); render(); - await waitFor(() => { - expect(screen.getByText('Server says no')).toBeInTheDocument(); - }); - }); - it('should display default error message from API response when not OK and no message provided', async () => { - mockedApiClient.fetchActivityLog.mockResolvedValue( - new Response(JSON.stringify({}), { status: 500 }), + expect(screen.getAllByText('a store')[0]).toBeInTheDocument(); + expect(screen.getByText('Untitled Recipe')).toBeInTheDocument(); + expect(screen.getByText('A new user')).toBeInTheDocument(); + expect(screen.getByText('a recipe')).toBeInTheDocument(); + expect(screen.getByText('a shopping list')).toBeInTheDocument(); + expect(screen.getByText('another user')).toBeInTheDocument(); + + // Check for avatar with fallback alt text + const avatars = screen.getAllByRole('img'); + const avatarWithFallbackAlt = avatars.find( + (img) => img.getAttribute('alt') === 'User Avatar', ); - render(); - await waitFor(() => { - expect(screen.getByText('Failed to fetch logs')).toBeInTheDocument(); - }); - }); - - it('should display generic error message when fetch throws non-Error object', async () => { - mockedApiClient.fetchActivityLog.mockRejectedValue('String error'); - render(); - await waitFor(() => { - expect(screen.getByText('Failed to load activity.')).toBeInTheDocument(); - }); + expect(avatarWithFallbackAlt).toBeInTheDocument(); }); }); diff --git a/src/pages/admin/AdminStatsPage.test.tsx b/src/pages/admin/AdminStatsPage.test.tsx index b63956fb..9c5b1954 100644 --- a/src/pages/admin/AdminStatsPage.test.tsx +++ b/src/pages/admin/AdminStatsPage.test.tsx @@ -1,16 +1,18 @@ // src/pages/admin/AdminStatsPage.test.tsx import React from 'react'; -import { render, screen, waitFor, act } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; import { MemoryRouter } from 'react-router-dom'; import { AdminStatsPage } from './AdminStatsPage'; -import * as apiClient from '../../services/apiClient'; +import { useApplicationStatsQuery } from '../../hooks/queries/useApplicationStatsQuery'; import type { AppStats } from '../../services/apiClient'; import { createMockAppStats } from '../../tests/utils/mockFactories'; import { StatCard } from '../../components/StatCard'; -// The apiClient and logger are now mocked globally via src/tests/setup/tests-setup-unit.ts. -const mockedApiClient = vi.mocked(apiClient); +// Mock the TanStack Query hook +vi.mock('../../hooks/queries/useApplicationStatsQuery'); + +const mockedUseApplicationStatsQuery = vi.mocked(useApplicationStatsQuery); // Mock the child StatCard component to use the shared mock and allow spying vi.mock('../../components/StatCard', async () => { @@ -34,36 +36,24 @@ describe('AdminStatsPage', () => { beforeEach(() => { vi.clearAllMocks(); mockedStatCard.mockClear(); + // Default mock implementation + mockedUseApplicationStatsQuery.mockReturnValue({ + data: undefined, + isLoading: false, + error: null, + } as any); }); it('should render a loading spinner while fetching stats', async () => { - let resolvePromise: (value: Response) => void; - const mockPromise = new Promise((resolve) => { - resolvePromise = resolve; - }); - // Cast to any to bypass strict type checking for the mock return value vs Promise - mockedApiClient.getApplicationStats.mockReturnValue(mockPromise as any); + mockedUseApplicationStatsQuery.mockReturnValue({ + data: undefined, + isLoading: true, + error: null, + } as any); renderWithRouter(); expect(screen.getByRole('status', { name: /loading/i })).toBeInTheDocument(); - - await act(async () => { - resolvePromise!( - new Response( - JSON.stringify( - createMockAppStats({ - userCount: 0, - flyerCount: 0, - flyerItemCount: 0, - storeCount: 0, - pendingCorrectionCount: 0, - recipeCount: 0, - }), - ), - ), - ); - }); }); it('should display stats cards when data is fetched successfully', async () => { @@ -75,29 +65,31 @@ describe('AdminStatsPage', () => { pendingCorrectionCount: 5, recipeCount: 150, }); - mockedApiClient.getApplicationStats.mockResolvedValue(new Response(JSON.stringify(mockStats))); + mockedUseApplicationStatsQuery.mockReturnValue({ + data: mockStats, + isLoading: false, + error: null, + } as any); + renderWithRouter(); - // Wait for the stats to be displayed - await waitFor(() => { - expect(screen.getByText('Total Users')).toBeInTheDocument(); - expect(screen.getByText('123')).toBeInTheDocument(); + expect(screen.getByText('Total Users')).toBeInTheDocument(); + expect(screen.getByText('123')).toBeInTheDocument(); - expect(screen.getByText('Flyers Processed')).toBeInTheDocument(); - expect(screen.getByText('456')).toBeInTheDocument(); + expect(screen.getByText('Flyers Processed')).toBeInTheDocument(); + expect(screen.getByText('456')).toBeInTheDocument(); - expect(screen.getByText('Total Flyer Items')).toBeInTheDocument(); - expect(screen.getByText('7,890')).toBeInTheDocument(); // Note: toLocaleString() adds a comma + expect(screen.getByText('Total Flyer Items')).toBeInTheDocument(); + expect(screen.getByText('7,890')).toBeInTheDocument(); - expect(screen.getByText('Stores Tracked')).toBeInTheDocument(); - expect(screen.getByText('42')).toBeInTheDocument(); + expect(screen.getByText('Stores Tracked')).toBeInTheDocument(); + expect(screen.getByText('42')).toBeInTheDocument(); - expect(screen.getByText('Pending Corrections')).toBeInTheDocument(); - expect(screen.getByText('5')).toBeInTheDocument(); + expect(screen.getByText('Pending Corrections')).toBeInTheDocument(); + expect(screen.getByText('5')).toBeInTheDocument(); - expect(screen.getByText('Total Recipes')).toBeInTheDocument(); - expect(screen.getByText('150')).toBeInTheDocument(); - }); + expect(screen.getByText('Total Recipes')).toBeInTheDocument(); + expect(screen.getByText('150')).toBeInTheDocument(); }); it('should pass the correct props to each StatCard component', async () => { @@ -109,16 +101,15 @@ describe('AdminStatsPage', () => { pendingCorrectionCount: 5, recipeCount: 150, }); - mockedApiClient.getApplicationStats.mockResolvedValue(new Response(JSON.stringify(mockStats))); + mockedUseApplicationStatsQuery.mockReturnValue({ + data: mockStats, + isLoading: false, + error: null, + } as any); renderWithRouter(); - await waitFor(() => { - // Wait for the component to have been called at least once - expect(mockedStatCard).toHaveBeenCalled(); - }); - - // Verify it was called 5 times, once for each stat + // Verify it was called 6 times, once for each stat expect(mockedStatCard).toHaveBeenCalledTimes(6); // Check props for each card individually for robustness @@ -173,15 +164,18 @@ describe('AdminStatsPage', () => { flyerItemCount: 123456789, recipeCount: 50000, }); - mockedApiClient.getApplicationStats.mockResolvedValue(new Response(JSON.stringify(mockStats))); + mockedUseApplicationStatsQuery.mockReturnValue({ + data: mockStats, + isLoading: false, + error: null, + } as any); + renderWithRouter(); - await waitFor(() => { - expect(screen.getByText('1,234,567')).toBeInTheDocument(); - expect(screen.getByText('9,876')).toBeInTheDocument(); - expect(screen.getByText('123,456,789')).toBeInTheDocument(); - expect(screen.getByText('50,000')).toBeInTheDocument(); - }); + expect(screen.getByText('1,234,567')).toBeInTheDocument(); + expect(screen.getByText('9,876')).toBeInTheDocument(); + expect(screen.getByText('123,456,789')).toBeInTheDocument(); + expect(screen.getByText('50,000')).toBeInTheDocument(); }); it('should correctly display zero values for all stats', async () => { @@ -193,49 +187,46 @@ describe('AdminStatsPage', () => { pendingCorrectionCount: 0, recipeCount: 0, }); - mockedApiClient.getApplicationStats.mockResolvedValue( - new Response(JSON.stringify(mockZeroStats)), - ); + mockedUseApplicationStatsQuery.mockReturnValue({ + data: mockZeroStats, + isLoading: false, + error: null, + } as any); + renderWithRouter(); - await waitFor(() => { - // `getAllByText` will find all instances of '0'. There should be 5. - const zeroValueElements = screen.getAllByText('0'); - expect(zeroValueElements).toHaveLength(6); + // `getAllByText` will find all instances of '0'. There should be 6. + const zeroValueElements = screen.getAllByText('0'); + expect(zeroValueElements).toHaveLength(6); - // Also check that the titles are present to be sure we have the cards. - expect(screen.getByText('Total Users')).toBeInTheDocument(); - expect(screen.getByText('Pending Corrections')).toBeInTheDocument(); - }); + // Also check that the titles are present to be sure we have the cards. + expect(screen.getByText('Total Users')).toBeInTheDocument(); + expect(screen.getByText('Pending Corrections')).toBeInTheDocument(); }); it('should display an error message if fetching stats fails', async () => { const errorMessage = 'Failed to connect to the database.'; - mockedApiClient.getApplicationStats.mockRejectedValue(new Error(errorMessage)); + mockedUseApplicationStatsQuery.mockReturnValue({ + data: undefined, + isLoading: false, + error: new Error(errorMessage), + } as any); + renderWithRouter(); - // Wait for the error message to appear - await waitFor(() => { - expect(screen.getByText(errorMessage)).toBeInTheDocument(); - }); - }); - - it('should display a generic error message for unknown errors', async () => { - mockedApiClient.getApplicationStats.mockRejectedValue('Unknown error object'); - renderWithRouter(); - - await waitFor(() => { - expect(screen.getByText('An unknown error occurred.')).toBeInTheDocument(); - }); + expect(screen.getByText(errorMessage)).toBeInTheDocument(); }); it('should render a link back to the admin dashboard', async () => { - mockedApiClient.getApplicationStats.mockResolvedValue( - new Response(JSON.stringify(createMockAppStats())), - ); + mockedUseApplicationStatsQuery.mockReturnValue({ + data: createMockAppStats(), + isLoading: false, + error: null, + } as any); + renderWithRouter(); - const link = await screen.findByRole('link', { name: /back to admin dashboard/i }); + const link = screen.getByRole('link', { name: /back to admin dashboard/i }); expect(link).toBeInTheDocument(); expect(link).toHaveAttribute('href', '/admin'); }); diff --git a/src/pages/admin/CorrectionsPage.test.tsx b/src/pages/admin/CorrectionsPage.test.tsx index 4197052f..7b6e4d0f 100644 --- a/src/pages/admin/CorrectionsPage.test.tsx +++ b/src/pages/admin/CorrectionsPage.test.tsx @@ -1,10 +1,12 @@ // src/pages/admin/CorrectionsPage.test.tsx import React from 'react'; -import { render, screen, waitFor, fireEvent, act } from '@testing-library/react'; +import { render, screen, fireEvent } from '@testing-library/react'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { MemoryRouter } from 'react-router-dom'; import { CorrectionsPage } from './CorrectionsPage'; -import * as apiClient from '../../services/apiClient'; +import { useSuggestedCorrectionsQuery } from '../../hooks/queries/useSuggestedCorrectionsQuery'; +import { useMasterItemsQuery } from '../../hooks/queries/useMasterItemsQuery'; +import { useCategoriesQuery } from '../../hooks/queries/useCategoriesQuery'; import type { SuggestedCorrection, MasterGroceryItem, Category } from '../../types'; import { createMockSuggestedCorrection, @@ -12,11 +14,16 @@ import { createMockCategory, } from '../../tests/utils/mockFactories'; -// The apiClient and logger are now mocked globally via src/tests/setup/tests-setup-unit.ts. -const mockedApiClient = vi.mocked(apiClient); +// Mock the TanStack Query hooks +vi.mock('../../hooks/queries/useSuggestedCorrectionsQuery'); +vi.mock('../../hooks/queries/useMasterItemsQuery'); +vi.mock('../../hooks/queries/useCategoriesQuery'); + +const mockedUseSuggestedCorrectionsQuery = vi.mocked(useSuggestedCorrectionsQuery); +const mockedUseMasterItemsQuery = vi.mocked(useMasterItemsQuery); +const mockedUseCategoriesQuery = vi.mocked(useCategoriesQuery); // Mock the child CorrectionRow component to isolate the test to the page itself -// The CorrectionRow component is now located in a sub-directory. vi.mock('./components/CorrectionRow', async () => { const { MockCorrectionRow } = await import('../../tests/utils/componentMocks'); return { CorrectionRow: MockCorrectionRow }; @@ -61,169 +68,170 @@ describe('CorrectionsPage', () => { }), ]; const mockCategories: Category[] = [createMockCategory({ category_id: 1, name: 'Produce' })]; + const mockRefetch = vi.fn(); beforeEach(() => { vi.clearAllMocks(); + // Default mock implementations for the hooks + mockedUseSuggestedCorrectionsQuery.mockReturnValue({ + data: [], + isLoading: false, + error: null, + refetch: mockRefetch, + } as any); + mockedUseMasterItemsQuery.mockReturnValue({ + data: [], + isLoading: false, + error: null, + } as any); + mockedUseCategoriesQuery.mockReturnValue({ + data: [], + isLoading: false, + error: null, + } as any); }); it('should render a loading spinner while fetching data', async () => { - let resolvePromise: (value: Response) => void; - const mockPromise = new Promise((resolve) => { - resolvePromise = resolve; - }); - // Cast to any to bypass strict type checking for the mock return value vs Promise - mockedApiClient.getSuggestedCorrections.mockReturnValue(mockPromise as any); - // Mock other calls to resolve immediately so Promise.all waits on the one we control - mockedApiClient.fetchMasterItems.mockResolvedValue(new Response(JSON.stringify([]))); - mockedApiClient.fetchCategories.mockResolvedValue(new Response(JSON.stringify([]))); + mockedUseSuggestedCorrectionsQuery.mockReturnValue({ + data: undefined, + isLoading: true, + error: null, + refetch: mockRefetch, + } as any); renderWithRouter(); expect(screen.getByRole('status', { name: /loading/i })).toBeInTheDocument(); - - await act(async () => { - resolvePromise!(new Response(JSON.stringify([]))); - }); }); it('should display corrections when data is fetched successfully', async () => { - mockedApiClient.getSuggestedCorrections.mockResolvedValue( - new Response(JSON.stringify(mockCorrections)), - ); - mockedApiClient.fetchMasterItems.mockResolvedValue( - new Response(JSON.stringify(mockMasterItems)), - ); - mockedApiClient.fetchCategories.mockResolvedValue(new Response(JSON.stringify(mockCategories))); + mockedUseSuggestedCorrectionsQuery.mockReturnValue({ + data: mockCorrections, + isLoading: false, + error: null, + refetch: mockRefetch, + } as any); + mockedUseMasterItemsQuery.mockReturnValue({ + data: mockMasterItems, + isLoading: false, + error: null, + } as any); + mockedUseCategoriesQuery.mockReturnValue({ + data: mockCategories, + isLoading: false, + error: null, + } as any); + renderWithRouter(); - await waitFor(() => { - // Check for the mocked CorrectionRow components - expect(screen.getByTestId('correction-row-1')).toBeInTheDocument(); // This will now use suggested_correction_id - expect(screen.getByTestId('correction-row-2')).toBeInTheDocument(); // This will now use suggested_correction_id - // Check for the text content within the mocked rows - expect(screen.getByText('Bananas')).toBeInTheDocument(); - expect(screen.getByText('Apples')).toBeInTheDocument(); - }); + // Check for the mocked CorrectionRow components + expect(screen.getByTestId('correction-row-1')).toBeInTheDocument(); + expect(screen.getByTestId('correction-row-2')).toBeInTheDocument(); + // Check for the text content within the mocked rows + expect(screen.getByText('Bananas')).toBeInTheDocument(); + expect(screen.getByText('Apples')).toBeInTheDocument(); }); it('should display a message when there are no pending corrections', async () => { - mockedApiClient.getSuggestedCorrections.mockResolvedValue(new Response(JSON.stringify([]))); - mockedApiClient.fetchMasterItems.mockResolvedValue( - new Response(JSON.stringify(mockMasterItems)), - ); - mockedApiClient.fetchCategories.mockResolvedValue(new Response(JSON.stringify(mockCategories))); + mockedUseSuggestedCorrectionsQuery.mockReturnValue({ + data: [], + isLoading: false, + error: null, + refetch: mockRefetch, + } as any); + mockedUseMasterItemsQuery.mockReturnValue({ + data: mockMasterItems, + isLoading: false, + error: null, + } as any); + mockedUseCategoriesQuery.mockReturnValue({ + data: mockCategories, + isLoading: false, + error: null, + } as any); + renderWithRouter(); - await waitFor(() => { - expect(screen.getByText(/no pending corrections. great job!/i)).toBeInTheDocument(); - }); + expect(screen.getByText(/no pending corrections. great job!/i)).toBeInTheDocument(); }); it('should display an error message if fetching corrections fails', async () => { const errorMessage = 'Network Error: Failed to fetch'; - mockedApiClient.getSuggestedCorrections.mockRejectedValue(new Error(errorMessage)); - mockedApiClient.fetchMasterItems.mockResolvedValue( - new Response(JSON.stringify(mockMasterItems)), - ); - mockedApiClient.fetchCategories.mockResolvedValue(new Response(JSON.stringify(mockCategories))); + mockedUseSuggestedCorrectionsQuery.mockReturnValue({ + data: undefined, + isLoading: false, + error: new Error(errorMessage), + refetch: mockRefetch, + } as any); + mockedUseMasterItemsQuery.mockReturnValue({ + data: mockMasterItems, + isLoading: false, + error: null, + } as any); + mockedUseCategoriesQuery.mockReturnValue({ + data: mockCategories, + isLoading: false, + error: null, + } as any); + renderWithRouter(); - await waitFor(() => { - expect(screen.getByText(errorMessage)).toBeInTheDocument(); - }); + expect(screen.getByText(errorMessage)).toBeInTheDocument(); }); - it('should display an error message if fetching master items fails', async () => { - const errorMessage = 'Could not retrieve master items list.'; - mockedApiClient.getSuggestedCorrections.mockResolvedValue( - new Response(JSON.stringify(mockCorrections)), - ); - mockedApiClient.fetchMasterItems.mockRejectedValue(new Error(errorMessage)); - mockedApiClient.fetchCategories.mockResolvedValue(new Response(JSON.stringify(mockCategories))); - renderWithRouter(); - - await waitFor(() => { - expect(screen.getByText(errorMessage)).toBeInTheDocument(); - }); - }); - - it('should display an error message if fetching categories fails', async () => { - const errorMessage = 'Could not retrieve categories.'; - mockedApiClient.getSuggestedCorrections.mockResolvedValue( - new Response(JSON.stringify(mockCorrections)), - ); - mockedApiClient.fetchMasterItems.mockResolvedValue( - new Response(JSON.stringify(mockMasterItems)), - ); - mockedApiClient.fetchCategories.mockRejectedValue(new Error(errorMessage)); - renderWithRouter(); - - await waitFor(() => { - expect(screen.getByText(errorMessage)).toBeInTheDocument(); - }); - }); - - it('should handle unknown errors gracefully', async () => { - mockedApiClient.getSuggestedCorrections.mockRejectedValue('Unknown string error'); - mockedApiClient.fetchMasterItems.mockResolvedValue( - new Response(JSON.stringify(mockMasterItems)), - ); - mockedApiClient.fetchCategories.mockResolvedValue(new Response(JSON.stringify(mockCategories))); - renderWithRouter(); - - await waitFor(() => { - expect( - screen.getByText('An unknown error occurred while fetching corrections.'), - ).toBeInTheDocument(); - }); - }); - - it('should refresh corrections when the refresh button is clicked', async () => { - // Mock the initial data load - mockedApiClient.getSuggestedCorrections.mockResolvedValue( - new Response(JSON.stringify(mockCorrections)), - ); - mockedApiClient.fetchMasterItems.mockResolvedValue( - new Response(JSON.stringify(mockMasterItems)), - ); - mockedApiClient.fetchCategories.mockResolvedValue(new Response(JSON.stringify(mockCategories))); + it('should call refetch when the refresh button is clicked', async () => { + mockedUseSuggestedCorrectionsQuery.mockReturnValue({ + data: mockCorrections, + isLoading: false, + error: null, + refetch: mockRefetch, + } as any); + mockedUseMasterItemsQuery.mockReturnValue({ + data: mockMasterItems, + isLoading: false, + error: null, + } as any); + mockedUseCategoriesQuery.mockReturnValue({ + data: mockCategories, + isLoading: false, + error: null, + } as any); renderWithRouter(); - // Wait for the initial data to be rendered - await waitFor(() => expect(screen.getByText('Bananas')).toBeInTheDocument()); - - // All APIs should have been called once on initial load - expect(mockedApiClient.getSuggestedCorrections).toHaveBeenCalledTimes(1); - expect(mockedApiClient.fetchMasterItems).toHaveBeenCalledTimes(1); - expect(mockedApiClient.fetchCategories).toHaveBeenCalledTimes(1); + expect(screen.getByText('Bananas')).toBeInTheDocument(); // Click refresh const refreshButton = screen.getByTitle('Refresh Corrections'); fireEvent.click(refreshButton); - // Wait for the APIs to be called a second time - await waitFor(() => expect(mockedApiClient.getSuggestedCorrections).toHaveBeenCalledTimes(2)); - expect(mockedApiClient.fetchMasterItems).toHaveBeenCalledTimes(2); - expect(mockedApiClient.fetchCategories).toHaveBeenCalledTimes(2); + expect(mockRefetch).toHaveBeenCalledTimes(1); }); - it('should remove a correction from the list when processed', async () => { - mockedApiClient.getSuggestedCorrections.mockResolvedValue( - new Response(JSON.stringify(mockCorrections)), - ); - mockedApiClient.fetchMasterItems.mockResolvedValue( - new Response(JSON.stringify(mockMasterItems)), - ); - mockedApiClient.fetchCategories.mockResolvedValue(new Response(JSON.stringify(mockCategories))); + it('should call onProcessed callback when a correction is processed', async () => { + mockedUseSuggestedCorrectionsQuery.mockReturnValue({ + data: mockCorrections, + isLoading: false, + error: null, + refetch: mockRefetch, + } as any); + mockedUseMasterItemsQuery.mockReturnValue({ + data: mockMasterItems, + isLoading: false, + error: null, + } as any); + mockedUseCategoriesQuery.mockReturnValue({ + data: mockCategories, + isLoading: false, + error: null, + } as any); renderWithRouter(); - await waitFor(() => expect(screen.getByTestId('correction-row-1')).toBeInTheDocument()); + expect(screen.getByTestId('correction-row-1')).toBeInTheDocument(); // Click the process button in the mock row for ID 1 fireEvent.click(screen.getByTestId('process-btn-1')); - // It should disappear - await waitFor(() => expect(screen.queryByTestId('correction-row-1')).not.toBeInTheDocument()); - expect(screen.getByTestId('correction-row-2')).toBeInTheDocument(); + // The onProcessed callback should trigger a refetch + expect(mockRefetch).toHaveBeenCalledTimes(1); }); }); diff --git a/src/routes/ai.routes.ts b/src/routes/ai.routes.ts index d891e8a0..640b69e9 100644 --- a/src/routes/ai.routes.ts +++ b/src/routes/ai.routes.ts @@ -13,7 +13,7 @@ import { createUploadMiddleware, handleMulterError, } from '../middleware/multer.middleware'; -// Removed: import { logger } from '../services/logger.server'; // This was a duplicate, fixed. +import { logger } from '../services/logger.server'; // Needed for module-level logging (e.g., Zod schema transforms) // All route handlers now use req.log (request-scoped logger) as per ADR-004 import { UserProfile } from '../types'; // This was a duplicate, fixed. // All route handlers now use req.log (request-scoped logger) as per ADR-004 @@ -72,7 +72,8 @@ const rescanAreaSchema = z.object({ return JSON.parse(val); } catch (err) { // Log the actual parsing error for better debugging if invalid JSON is sent. - req.log.warn( + // Using module-level logger since Zod transforms don't have access to request context + logger.warn( { error: errMsg(err), receivedValue: val }, 'Failed to parse cropArea in rescanAreaSchema', ); diff --git a/src/routes/passport.routes.ts b/src/routes/passport.routes.ts index 03c636a2..7d9be6ab 100644 --- a/src/routes/passport.routes.ts +++ b/src/routes/passport.routes.ts @@ -11,7 +11,7 @@ import * as bcrypt from 'bcrypt'; import { Request, Response, NextFunction } from 'express'; import * as db from '../services/db/index.db'; -// Removed: import { logger } from '../services/logger.server'; +import { logger } from '../services/logger.server'; // All route handlers now use req.log (request-scoped logger) as per ADR-004 import { UserProfile } from '../types'; // All route handlers now use req.log (request-scoped logger) as per ADR-004 @@ -271,12 +271,12 @@ const jwtOptions = { if (!JWT_SECRET) { logger.fatal('[Passport] CRITICAL: JWT_SECRET is missing or empty in environment variables! JwtStrategy will fail.'); } else { - req.log.info(`[Passport] JWT_SECRET loaded successfully (length: ${JWT_SECRET.length}).`); + logger.info(`[Passport] JWT_SECRET loaded successfully (length: ${JWT_SECRET.length}).`); } passport.use( new JwtStrategy(jwtOptions, async (jwt_payload, done) => { - req.log.debug( + logger.debug( { jwt_payload: jwt_payload ? { user_id: jwt_payload.user_id } : 'null' }, '[JWT Strategy] Verifying token payload:', ); @@ -286,18 +286,18 @@ passport.use( const userProfile = await db.userRepo.findUserProfileById(jwt_payload.user_id, logger); // --- JWT STRATEGY DEBUG LOGGING --- - req.log.debug( + logger.debug( `[JWT Strategy] DB lookup for user ID ${jwt_payload.user_id} result: ${userProfile ? 'FOUND' : 'NOT FOUND'}`, ); if (userProfile) { return done(null, userProfile); // User profile object will be available as req.user in protected routes } else { - req.log.warn(`JWT authentication failed: user with ID ${jwt_payload.user_id} not found.`); + logger.warn(`JWT authentication failed: user with ID ${jwt_payload.user_id} not found.`); return done(null, false); // User not found or invalid token } } catch (err: unknown) { - req.log.error({ error: err }, 'Error during JWT authentication strategy:'); + logger.error({ error: err }, 'Error during JWT authentication strategy:'); return done(err, false); } }),