Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a94bfbd3e9 | ||
| 338bbc9440 | |||
|
|
60aad04642 | ||
| 7f2aff9a24 | |||
|
|
689320e7d2 | ||
| e457bbf046 | |||
| 68cdbb6066 | |||
|
|
cea6be7145 | ||
| 74a5ca6331 | |||
|
|
62470e7661 | ||
| 2b517683fd |
@@ -45,7 +45,16 @@
|
||||
"Bash(powershell.exe -Command \"echo ''List all podman containers'' | claude --print\")",
|
||||
"Bash(powershell.exe -Command \"echo ''List my repositories on gitea.projectium.com using gitea-projectium MCP'' | claude --print\")",
|
||||
"Bash(powershell.exe -Command \"echo ''List my repositories on gitea.projectium.com using gitea-projectium MCP'' | claude --print --allowedTools ''mcp__gitea-projectium__*''\")",
|
||||
"Bash(powershell.exe -Command \"echo ''Fetch the homepage of https://gitea.projectium.com and summarize it'' | claude --print --allowedTools ''mcp__fetch__*''\")"
|
||||
"Bash(powershell.exe -Command \"echo ''Fetch the homepage of https://gitea.projectium.com and summarize it'' | claude --print --allowedTools ''mcp__fetch__*''\")",
|
||||
"Bash(dir \"C:\\\\Users\\\\games3\\\\.claude\")",
|
||||
"Bash(dir:*)",
|
||||
"Bash(D:nodejsnpx.cmd -y @modelcontextprotocol/server-fetch --help)",
|
||||
"Bash(cmd /c \"dir /o-d C:\\\\Users\\\\games3\\\\.claude\\\\debug 2>nul | head -10\")",
|
||||
"mcp__memory__read_graph",
|
||||
"mcp__memory__create_entities",
|
||||
"mcp__memory__search_nodes",
|
||||
"mcp__memory__delete_entities",
|
||||
"mcp__sequential-thinking__sequentialthinking"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
48
package-lock.json
generated
48
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "flyer-crawler",
|
||||
"version": "0.9.60",
|
||||
"version": "0.9.65",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "flyer-crawler",
|
||||
"version": "0.9.60",
|
||||
"version": "0.9.65",
|
||||
"dependencies": {
|
||||
"@bull-board/api": "^6.14.2",
|
||||
"@bull-board/express": "^6.14.2",
|
||||
@@ -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",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "flyer-crawler",
|
||||
"private": true,
|
||||
"version": "0.9.60",
|
||||
"version": "0.9.65",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "concurrently \"npm:start:dev\" \"vite\"",
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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(() => ({
|
||||
|
||||
@@ -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<ActivityLogEntry[]> => {
|
||||
const response = await apiClient.fetchActivityLog(limit, offset);
|
||||
queryFn: async (): Promise<ActivityLogItem[]> => {
|
||||
const response = await fetchActivityLog(limit, offset);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({
|
||||
|
||||
@@ -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<AppStats> => {
|
||||
const response = await apiClient.getApplicationStats();
|
||||
const response = await getApplicationStats();
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({
|
||||
|
||||
@@ -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<Category[]> => {
|
||||
const response = await apiClient.fetchCategories();
|
||||
const response = await fetchCategories();
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// src/hooks/queries/useShoppingListsQuery.ts
|
||||
import { useQuery } from '@tantml:parameter>
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import * as apiClient from '../../services/apiClient';
|
||||
import type { ShoppingList } from '../../types';
|
||||
|
||||
|
||||
@@ -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<SuggestedCorrection[]> => {
|
||||
const response = await apiClient.getSuggestedCorrections();
|
||||
const response = await getSuggestedCorrections();
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({
|
||||
|
||||
@@ -2,14 +2,13 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { useFlyerItems } from './useFlyerItems';
|
||||
import { useApiOnMount } from './useApiOnMount';
|
||||
import * as apiClient from '../services/apiClient';
|
||||
import * as useFlyerItemsQueryModule from './queries/useFlyerItemsQuery';
|
||||
import { createMockFlyer, createMockFlyerItem } from '../tests/utils/mockFactories';
|
||||
|
||||
// Mock the underlying useApiOnMount hook to isolate the useFlyerItems hook's logic.
|
||||
vi.mock('./useApiOnMount');
|
||||
// Mock the underlying query hook to isolate the useFlyerItems hook's logic.
|
||||
vi.mock('./queries/useFlyerItemsQuery');
|
||||
|
||||
const mockedUseApiOnMount = vi.mocked(useApiOnMount);
|
||||
const mockedUseFlyerItemsQuery = vi.mocked(useFlyerItemsQueryModule.useFlyerItemsQuery);
|
||||
|
||||
describe('useFlyerItems Hook', () => {
|
||||
const mockFlyer = createMockFlyer({
|
||||
@@ -39,19 +38,16 @@ describe('useFlyerItems Hook', () => {
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
// Clear mock history before each test
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should return initial state and not call useApiOnMount when flyer is null', () => {
|
||||
// Arrange: Mock the return value of the inner hook.
|
||||
mockedUseApiOnMount.mockReturnValue({
|
||||
data: null,
|
||||
loading: false,
|
||||
it('should return initial state when flyer is null', () => {
|
||||
// Arrange: Mock the return value of the query hook.
|
||||
mockedUseFlyerItemsQuery.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
isRefetching: false,
|
||||
reset: vi.fn(),
|
||||
});
|
||||
} as ReturnType<typeof useFlyerItemsQueryModule.useFlyerItemsQuery>);
|
||||
|
||||
// Act: Render the hook with a null flyer.
|
||||
const { result } = renderHook(() => useFlyerItems(null));
|
||||
@@ -60,57 +56,41 @@ describe('useFlyerItems Hook', () => {
|
||||
expect(result.current.flyerItems).toEqual([]);
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
expect(result.current.error).toBeNull();
|
||||
// Assert: Check that useApiOnMount was called with `enabled: false`.
|
||||
expect(mockedUseApiOnMount).toHaveBeenCalledWith(
|
||||
expect.any(Function), // the wrapped fetcher function
|
||||
[null], // dependencies array
|
||||
{ enabled: false }, // options object
|
||||
undefined, // flyer_id
|
||||
);
|
||||
// Assert: Check that useFlyerItemsQuery was called with undefined flyerId.
|
||||
expect(mockedUseFlyerItemsQuery).toHaveBeenCalledWith(undefined);
|
||||
});
|
||||
|
||||
it('should call useApiOnMount with enabled: true when a flyer is provided', () => {
|
||||
mockedUseApiOnMount.mockReturnValue({
|
||||
data: null,
|
||||
loading: true,
|
||||
it('should call useFlyerItemsQuery with flyerId when a flyer is provided', () => {
|
||||
mockedUseFlyerItemsQuery.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
error: null,
|
||||
isRefetching: false,
|
||||
reset: vi.fn(),
|
||||
});
|
||||
} as ReturnType<typeof useFlyerItemsQueryModule.useFlyerItemsQuery>);
|
||||
|
||||
renderHook(() => useFlyerItems(mockFlyer));
|
||||
|
||||
// Assert: Check that useApiOnMount was called with the correct parameters.
|
||||
expect(mockedUseApiOnMount).toHaveBeenCalledWith(
|
||||
expect.any(Function),
|
||||
[mockFlyer],
|
||||
{ enabled: true },
|
||||
mockFlyer.flyer_id,
|
||||
);
|
||||
// Assert: Check that useFlyerItemsQuery was called with the correct flyerId.
|
||||
expect(mockedUseFlyerItemsQuery).toHaveBeenCalledWith(123);
|
||||
});
|
||||
|
||||
it('should return isLoading: true when the inner hook is loading', () => {
|
||||
mockedUseApiOnMount.mockReturnValue({
|
||||
data: null,
|
||||
loading: true,
|
||||
it('should return isLoading: true when the query is loading', () => {
|
||||
mockedUseFlyerItemsQuery.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
error: null,
|
||||
isRefetching: false,
|
||||
reset: vi.fn(),
|
||||
});
|
||||
} as ReturnType<typeof useFlyerItemsQueryModule.useFlyerItemsQuery>);
|
||||
|
||||
const { result } = renderHook(() => useFlyerItems(mockFlyer));
|
||||
|
||||
expect(result.current.isLoading).toBe(true);
|
||||
});
|
||||
|
||||
it('should return flyerItems when the inner hook provides data', () => {
|
||||
mockedUseApiOnMount.mockReturnValue({
|
||||
data: { items: mockFlyerItems },
|
||||
loading: false,
|
||||
it('should return flyerItems when the query provides data', () => {
|
||||
mockedUseFlyerItemsQuery.mockReturnValue({
|
||||
data: mockFlyerItems,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
isRefetching: false,
|
||||
reset: vi.fn(),
|
||||
});
|
||||
} as ReturnType<typeof useFlyerItemsQueryModule.useFlyerItemsQuery>);
|
||||
|
||||
const { result } = renderHook(() => useFlyerItems(mockFlyer));
|
||||
|
||||
@@ -119,15 +99,13 @@ describe('useFlyerItems Hook', () => {
|
||||
expect(result.current.error).toBeNull();
|
||||
});
|
||||
|
||||
it('should return an error when the inner hook returns an error', () => {
|
||||
it('should return an error when the query returns an error', () => {
|
||||
const mockError = new Error('Failed to fetch');
|
||||
mockedUseApiOnMount.mockReturnValue({
|
||||
data: null,
|
||||
loading: false,
|
||||
mockedUseFlyerItemsQuery.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
error: mockError,
|
||||
isRefetching: false,
|
||||
reset: vi.fn(),
|
||||
});
|
||||
} as ReturnType<typeof useFlyerItemsQueryModule.useFlyerItemsQuery>);
|
||||
|
||||
const { result } = renderHook(() => useFlyerItems(mockFlyer));
|
||||
|
||||
@@ -135,46 +113,4 @@ describe('useFlyerItems Hook', () => {
|
||||
expect(result.current.flyerItems).toEqual([]);
|
||||
expect(result.current.error).toEqual(mockError);
|
||||
});
|
||||
|
||||
describe('wrappedFetcher behavior', () => {
|
||||
it('should reject if called with undefined flyerId', async () => {
|
||||
// We need to trigger the hook to get access to the internal wrappedFetcher
|
||||
mockedUseApiOnMount.mockReturnValue({
|
||||
data: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
isRefetching: false,
|
||||
reset: vi.fn(),
|
||||
});
|
||||
renderHook(() => useFlyerItems(mockFlyer));
|
||||
|
||||
// The first argument passed to useApiOnMount is the wrappedFetcher function
|
||||
const wrappedFetcher = mockedUseApiOnMount.mock.calls[0][0];
|
||||
|
||||
// Verify the fetcher rejects when no ID is passed (which shouldn't happen in normal flow due to 'enabled')
|
||||
await expect(wrappedFetcher(undefined)).rejects.toThrow(
|
||||
'Cannot fetch items for an undefined flyer ID.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should call apiClient.fetchFlyerItems when called with a valid ID', async () => {
|
||||
mockedUseApiOnMount.mockReturnValue({
|
||||
data: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
isRefetching: false,
|
||||
reset: vi.fn(),
|
||||
});
|
||||
renderHook(() => useFlyerItems(mockFlyer));
|
||||
|
||||
const wrappedFetcher = mockedUseApiOnMount.mock.calls[0][0];
|
||||
const mockResponse = new Response();
|
||||
const mockedApiClient = vi.mocked(apiClient);
|
||||
mockedApiClient.fetchFlyerItems.mockResolvedValue(mockResponse);
|
||||
const response = await wrappedFetcher(123);
|
||||
|
||||
expect(mockedApiClient.fetchFlyerItems).toHaveBeenCalledWith(123);
|
||||
expect(response).toBe(mockResponse);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,15 +4,15 @@ import { renderHook } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { useMasterItems } from './useMasterItems';
|
||||
import { MasterItemsProvider } from '../providers/MasterItemsProvider';
|
||||
import { useApiOnMount } from './useApiOnMount';
|
||||
import { useMasterItemsQuery } from './queries/useMasterItemsQuery';
|
||||
import type { MasterGroceryItem } from '../types';
|
||||
import { createMockMasterGroceryItem } from '../tests/utils/mockFactories';
|
||||
|
||||
// 1. Mock the useApiOnMount hook, which is the dependency of our provider.
|
||||
vi.mock('./useApiOnMount');
|
||||
// 1. Mock the useMasterItemsQuery hook, which is the dependency of our provider.
|
||||
vi.mock('./queries/useMasterItemsQuery');
|
||||
|
||||
// 2. Create a typed mock for type safety and autocompletion.
|
||||
const mockedUseApiOnMount = vi.mocked(useApiOnMount);
|
||||
const mockedUseMasterItemsQuery = vi.mocked(useMasterItemsQuery);
|
||||
|
||||
// 3. A simple wrapper component that renders our provider.
|
||||
// This is necessary because the useMasterItems hook needs to be a child of MasterItemsProvider.
|
||||
@@ -42,13 +42,11 @@ describe('useMasterItems Hook and MasterItemsProvider', () => {
|
||||
|
||||
it('should return the initial loading state correctly', () => {
|
||||
// Arrange: Configure the mocked hook to return a loading state.
|
||||
mockedUseApiOnMount.mockReturnValue({
|
||||
data: null,
|
||||
loading: true,
|
||||
mockedUseMasterItemsQuery.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
error: null,
|
||||
isRefetching: false,
|
||||
reset: vi.fn(),
|
||||
});
|
||||
} as any);
|
||||
|
||||
// Act: Render the hook within the provider wrapper.
|
||||
const { result } = renderHook(() => useMasterItems(), { wrapper });
|
||||
@@ -75,13 +73,11 @@ describe('useMasterItems Hook and MasterItemsProvider', () => {
|
||||
category_name: 'Bakery',
|
||||
}),
|
||||
];
|
||||
mockedUseApiOnMount.mockReturnValue({
|
||||
mockedUseMasterItemsQuery.mockReturnValue({
|
||||
data: mockItems,
|
||||
loading: false,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
isRefetching: false,
|
||||
reset: vi.fn(),
|
||||
});
|
||||
} as any);
|
||||
|
||||
// Act
|
||||
const { result } = renderHook(() => useMasterItems(), { wrapper });
|
||||
@@ -95,13 +91,11 @@ describe('useMasterItems Hook and MasterItemsProvider', () => {
|
||||
it('should return an error state if the fetch fails', () => {
|
||||
// Arrange: Mock a failed data fetch.
|
||||
const mockError = new Error('Failed to fetch master items');
|
||||
mockedUseApiOnMount.mockReturnValue({
|
||||
data: null,
|
||||
loading: false,
|
||||
mockedUseMasterItemsQuery.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
error: mockError,
|
||||
isRefetching: false,
|
||||
reset: vi.fn(),
|
||||
});
|
||||
} as any);
|
||||
|
||||
// Act
|
||||
const { result } = renderHook(() => useMasterItems(), { wrapper });
|
||||
|
||||
@@ -5,7 +5,8 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { useUserData } from './useUserData';
|
||||
import { useAuth } from './useAuth';
|
||||
import { UserDataProvider } from '../providers/UserDataProvider';
|
||||
import { useApiOnMount } from './useApiOnMount';
|
||||
import { useWatchedItemsQuery } from './queries/useWatchedItemsQuery';
|
||||
import { useShoppingListsQuery } from './queries/useShoppingListsQuery';
|
||||
import type { UserProfile } from '../types';
|
||||
import {
|
||||
createMockMasterGroceryItem,
|
||||
@@ -15,11 +16,13 @@ import {
|
||||
|
||||
// 1. Mock the hook's dependencies
|
||||
vi.mock('../hooks/useAuth');
|
||||
vi.mock('./useApiOnMount');
|
||||
vi.mock('./queries/useWatchedItemsQuery');
|
||||
vi.mock('./queries/useShoppingListsQuery');
|
||||
|
||||
// 2. Create typed mocks for type safety and autocompletion
|
||||
const mockedUseAuth = vi.mocked(useAuth);
|
||||
const mockedUseApiOnMount = vi.mocked(useApiOnMount);
|
||||
const mockedUseWatchedItemsQuery = vi.mocked(useWatchedItemsQuery);
|
||||
const mockedUseShoppingListsQuery = vi.mocked(useShoppingListsQuery);
|
||||
|
||||
// 3. A simple wrapper component that renders our provider.
|
||||
// This is necessary because the useUserData hook needs to be a child of UserDataProvider.
|
||||
@@ -71,13 +74,16 @@ describe('useUserData Hook and UserDataProvider', () => {
|
||||
updateProfile: vi.fn(),
|
||||
});
|
||||
// Arrange: Mock the return value of the inner hooks.
|
||||
mockedUseApiOnMount.mockReturnValue({
|
||||
data: null,
|
||||
loading: false,
|
||||
mockedUseWatchedItemsQuery.mockReturnValue({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
isRefetching: false,
|
||||
reset: vi.fn(),
|
||||
});
|
||||
} as any);
|
||||
mockedUseShoppingListsQuery.mockReturnValue({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
} as any);
|
||||
|
||||
// Act: Render the hook within the provider wrapper.
|
||||
const { result } = renderHook(() => useUserData(), { wrapper });
|
||||
@@ -87,10 +93,9 @@ describe('useUserData Hook and UserDataProvider', () => {
|
||||
expect(result.current.watchedItems).toEqual([]);
|
||||
expect(result.current.shoppingLists).toEqual([]);
|
||||
expect(result.current.error).toBeNull();
|
||||
// Assert: Check that useApiOnMount was called with `enabled: false`.
|
||||
expect(mockedUseApiOnMount).toHaveBeenCalledWith(expect.any(Function), [null], {
|
||||
enabled: false,
|
||||
});
|
||||
// Assert: Check that queries were disabled (called with false)
|
||||
expect(mockedUseWatchedItemsQuery).toHaveBeenCalledWith(false);
|
||||
expect(mockedUseShoppingListsQuery).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('should return loading state when user is authenticated and data is fetching', () => {
|
||||
@@ -104,21 +109,16 @@ describe('useUserData Hook and UserDataProvider', () => {
|
||||
updateProfile: vi.fn(),
|
||||
});
|
||||
// Arrange: Mock one of the inner hooks to be in a loading state.
|
||||
mockedUseApiOnMount
|
||||
.mockReturnValueOnce({
|
||||
data: null,
|
||||
loading: true,
|
||||
error: null,
|
||||
isRefetching: false,
|
||||
reset: vi.fn(),
|
||||
}) // watched items
|
||||
.mockReturnValueOnce({
|
||||
data: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
isRefetching: false,
|
||||
reset: vi.fn(),
|
||||
}); // shopping lists
|
||||
mockedUseWatchedItemsQuery.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
error: null,
|
||||
} as any);
|
||||
mockedUseShoppingListsQuery.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
} as any);
|
||||
|
||||
// Act
|
||||
const { result } = renderHook(() => useUserData(), { wrapper });
|
||||
@@ -138,21 +138,16 @@ describe('useUserData Hook and UserDataProvider', () => {
|
||||
updateProfile: vi.fn(),
|
||||
});
|
||||
// Arrange: Mock successful data fetches for both inner hooks.
|
||||
mockedUseApiOnMount
|
||||
.mockReturnValueOnce({
|
||||
data: mockWatchedItems,
|
||||
loading: false,
|
||||
error: null,
|
||||
isRefetching: false,
|
||||
reset: vi.fn(),
|
||||
})
|
||||
.mockReturnValueOnce({
|
||||
data: mockShoppingLists,
|
||||
loading: false,
|
||||
error: null,
|
||||
isRefetching: false,
|
||||
reset: vi.fn(),
|
||||
});
|
||||
mockedUseWatchedItemsQuery.mockReturnValue({
|
||||
data: mockWatchedItems,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
} as any);
|
||||
mockedUseShoppingListsQuery.mockReturnValue({
|
||||
data: mockShoppingLists,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
} as any);
|
||||
|
||||
// Act
|
||||
const { result } = renderHook(() => useUserData(), { wrapper });
|
||||
@@ -178,55 +173,16 @@ describe('useUserData Hook and UserDataProvider', () => {
|
||||
});
|
||||
const mockError = new Error('Failed to fetch watched items');
|
||||
|
||||
// Arrange: Mock the behavior persistently to handle re-renders.
|
||||
// We use mockImplementation to return based on call order in a loop or similar,
|
||||
// OR just use mockReturnValueOnce enough times.
|
||||
// Since we don't know exact render count, mockImplementation is safer if valid.
|
||||
// But simplified: assuming 2 hooks called per render.
|
||||
|
||||
// reset mocks to be sure
|
||||
mockedUseApiOnMount.mockReset();
|
||||
|
||||
// Define the sequence: 1st call (Watched) -> Error, 2nd call (Shopping) -> Success
|
||||
// We want this to persist for multiple renders.
|
||||
mockedUseApiOnMount.mockImplementation((_fn) => {
|
||||
// We can't easily distinguish based on 'fn' arg without inspecting it,
|
||||
// but we know the order is Watched then Shopping in the provider.
|
||||
// A simple toggle approach works if strict order is maintained.
|
||||
// However, stateless mocks are better.
|
||||
// Let's fallback to setting up "many" return values.
|
||||
return { data: null, loading: false, error: null, isRefetching: false, reset: vi.fn() };
|
||||
});
|
||||
|
||||
mockedUseApiOnMount
|
||||
.mockReturnValueOnce({
|
||||
data: null,
|
||||
loading: false,
|
||||
error: mockError,
|
||||
isRefetching: false,
|
||||
reset: vi.fn(),
|
||||
}) // 1st render: Watched
|
||||
.mockReturnValueOnce({
|
||||
data: mockShoppingLists,
|
||||
loading: false,
|
||||
error: null,
|
||||
isRefetching: false,
|
||||
reset: vi.fn(),
|
||||
}) // 1st render: Shopping
|
||||
.mockReturnValueOnce({
|
||||
data: null,
|
||||
loading: false,
|
||||
error: mockError,
|
||||
isRefetching: false,
|
||||
reset: vi.fn(),
|
||||
}) // 2nd render: Watched
|
||||
.mockReturnValueOnce({
|
||||
data: mockShoppingLists,
|
||||
loading: false,
|
||||
error: null,
|
||||
isRefetching: false,
|
||||
reset: vi.fn(),
|
||||
}); // 2nd render: Shopping
|
||||
mockedUseWatchedItemsQuery.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
error: mockError,
|
||||
} as any);
|
||||
mockedUseShoppingListsQuery.mockReturnValue({
|
||||
data: mockShoppingLists,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
} as any);
|
||||
|
||||
// Act
|
||||
const { result } = renderHook(() => useUserData(), { wrapper });
|
||||
@@ -252,21 +208,16 @@ describe('useUserData Hook and UserDataProvider', () => {
|
||||
logout: vi.fn(),
|
||||
updateProfile: vi.fn(),
|
||||
});
|
||||
mockedUseApiOnMount
|
||||
.mockReturnValueOnce({
|
||||
data: mockWatchedItems,
|
||||
loading: false,
|
||||
error: null,
|
||||
isRefetching: false,
|
||||
reset: vi.fn(),
|
||||
})
|
||||
.mockReturnValueOnce({
|
||||
data: mockShoppingLists,
|
||||
loading: false,
|
||||
error: null,
|
||||
isRefetching: false,
|
||||
reset: vi.fn(),
|
||||
});
|
||||
mockedUseWatchedItemsQuery.mockReturnValue({
|
||||
data: mockWatchedItems,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
} as any);
|
||||
mockedUseShoppingListsQuery.mockReturnValue({
|
||||
data: mockShoppingLists,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
} as any);
|
||||
const { result, rerender } = renderHook(() => useUserData(), { wrapper });
|
||||
await waitFor(() => expect(result.current.watchedItems).not.toEqual([]));
|
||||
|
||||
@@ -279,6 +230,18 @@ describe('useUserData Hook and UserDataProvider', () => {
|
||||
logout: vi.fn(),
|
||||
updateProfile: vi.fn(),
|
||||
});
|
||||
// Update mocks to return empty data for the logged out state
|
||||
mockedUseWatchedItemsQuery.mockReturnValue({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
} as any);
|
||||
mockedUseShoppingListsQuery.mockReturnValue({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
} as any);
|
||||
|
||||
rerender();
|
||||
|
||||
// Assert: The data should now be cleared.
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
ValidationError,
|
||||
NotFoundError,
|
||||
} from '../services/db/errors.db';
|
||||
import { createMockRequest } from '../tests/utils/createMockRequest';
|
||||
import type { Logger } from 'pino';
|
||||
|
||||
// Create a mock logger that we can inject into requests and assert against.
|
||||
@@ -271,7 +272,7 @@ describe('errorHandler Middleware', () => {
|
||||
it('should call next(err) if headers have already been sent', () => {
|
||||
// Supertest doesn't easily allow simulating res.headersSent = true mid-request
|
||||
// We need to mock the express response object directly for this specific test.
|
||||
const mockRequestDirect: Partial<Request> = { path: '/headers-sent-error', method: 'GET' };
|
||||
const mockRequestDirect = createMockRequest({ path: '/headers-sent-error', method: 'GET' });
|
||||
const mockResponseDirect: Partial<Response> = {
|
||||
status: vi.fn().mockReturnThis(),
|
||||
json: vi.fn(),
|
||||
|
||||
@@ -3,6 +3,7 @@ import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { requireFileUpload } from './fileUpload.middleware';
|
||||
import { ValidationError } from '../services/db/errors.db';
|
||||
import { createMockRequest } from '../tests/utils/createMockRequest';
|
||||
|
||||
describe('requireFileUpload Middleware', () => {
|
||||
let mockRequest: Partial<Request>;
|
||||
@@ -11,7 +12,7 @@ describe('requireFileUpload Middleware', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset mocks before each test
|
||||
mockRequest = {};
|
||||
mockRequest = createMockRequest();
|
||||
mockResponse = {
|
||||
status: vi.fn().mockReturnThis(),
|
||||
json: vi.fn(),
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { Request, Response, NextFunction } from 'express';
|
||||
import { createUploadMiddleware, handleMulterError } from './multer.middleware';
|
||||
import { createMockUserProfile } from '../tests/utils/mockFactories';
|
||||
import { ValidationError } from '../services/db/errors.db';
|
||||
import { createMockRequest } from '../tests/utils/createMockRequest';
|
||||
|
||||
// 1. Hoist the mocks so they can be referenced inside vi.mock factories.
|
||||
const mocks = vi.hoisted(() => ({
|
||||
@@ -125,7 +126,7 @@ describe('createUploadMiddleware', () => {
|
||||
createUploadMiddleware({ storageType: 'avatar' });
|
||||
const storageOptions = vi.mocked(multer.diskStorage).mock.calls[0][0];
|
||||
const cb = vi.fn();
|
||||
const mockReq = { user: mockUser } as unknown as Request;
|
||||
const mockReq = createMockRequest({ user: mockUser });
|
||||
|
||||
storageOptions.filename!(mockReq, mockFile, cb);
|
||||
|
||||
@@ -138,7 +139,7 @@ describe('createUploadMiddleware', () => {
|
||||
createUploadMiddleware({ storageType: 'avatar' });
|
||||
const storageOptions = vi.mocked(multer.diskStorage).mock.calls[0][0];
|
||||
const cb = vi.fn();
|
||||
const mockReq = {} as Request; // No user on request
|
||||
const mockReq = createMockRequest(); // No user on request
|
||||
|
||||
storageOptions.filename!(mockReq, mockFile, cb);
|
||||
|
||||
@@ -153,7 +154,7 @@ describe('createUploadMiddleware', () => {
|
||||
createUploadMiddleware({ storageType: 'avatar' });
|
||||
const storageOptions = vi.mocked(multer.diskStorage).mock.calls[0][0];
|
||||
const cb = vi.fn();
|
||||
const mockReq = { user: mockUser } as unknown as Request;
|
||||
const mockReq = createMockRequest({ user: mockUser });
|
||||
|
||||
storageOptions.filename!(mockReq, mockFile, cb);
|
||||
|
||||
@@ -171,7 +172,7 @@ describe('createUploadMiddleware', () => {
|
||||
createUploadMiddleware({ storageType: 'flyer' });
|
||||
const storageOptions = vi.mocked(multer.diskStorage).mock.calls[0][0];
|
||||
const cb = vi.fn();
|
||||
const mockReq = {} as Request;
|
||||
const mockReq = createMockRequest();
|
||||
|
||||
storageOptions.filename!(mockReq, mockFlyerFile, cb);
|
||||
|
||||
@@ -191,7 +192,7 @@ describe('createUploadMiddleware', () => {
|
||||
createUploadMiddleware({ storageType: 'flyer' });
|
||||
const storageOptions = vi.mocked(multer.diskStorage).mock.calls[0][0];
|
||||
const cb = vi.fn();
|
||||
const mockReq = {} as Request;
|
||||
const mockReq = createMockRequest();
|
||||
|
||||
storageOptions.filename!(mockReq, mockFlyerFile, cb);
|
||||
|
||||
@@ -206,7 +207,7 @@ describe('createUploadMiddleware', () => {
|
||||
const cb = vi.fn();
|
||||
const mockImageFile = { mimetype: 'image/png' } as Express.Multer.File;
|
||||
|
||||
multerOptions!.fileFilter!({} as Request, mockImageFile, cb);
|
||||
multerOptions!.fileFilter!(createMockRequest(), mockImageFile, cb);
|
||||
|
||||
expect(cb).toHaveBeenCalledWith(null, true);
|
||||
});
|
||||
@@ -217,7 +218,7 @@ describe('createUploadMiddleware', () => {
|
||||
const cb = vi.fn();
|
||||
const mockTextFile = { mimetype: 'text/plain' } as Express.Multer.File;
|
||||
|
||||
multerOptions!.fileFilter!({} as Request, { ...mockTextFile, fieldname: 'test' }, cb);
|
||||
multerOptions!.fileFilter!(createMockRequest(), { ...mockTextFile, fieldname: 'test' }, cb);
|
||||
|
||||
const error = (cb as Mock).mock.calls[0][0];
|
||||
expect(error).toBeInstanceOf(ValidationError);
|
||||
@@ -232,7 +233,7 @@ describe('handleMulterError Middleware', () => {
|
||||
let mockNext: NextFunction;
|
||||
|
||||
beforeEach(() => {
|
||||
mockRequest = {};
|
||||
mockRequest = createMockRequest();
|
||||
mockResponse = {
|
||||
status: vi.fn().mockReturnThis(),
|
||||
json: vi.fn(),
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Request, Response, NextFunction } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { validateRequest } from './validation.middleware';
|
||||
import { ValidationError } from '../services/db/errors.db';
|
||||
import { createMockRequest } from '../tests/utils/createMockRequest';
|
||||
|
||||
describe('validateRequest Middleware', () => {
|
||||
let mockRequest: Partial<Request>;
|
||||
@@ -16,11 +17,11 @@ describe('validateRequest Middleware', () => {
|
||||
// This more accurately mimics the behavior of Express's request objects
|
||||
// and prevents issues with inherited properties when the middleware
|
||||
// attempts to delete keys before merging validated data.
|
||||
mockRequest = {
|
||||
mockRequest = createMockRequest({
|
||||
params: Object.create(null),
|
||||
query: Object.create(null),
|
||||
body: {},
|
||||
};
|
||||
});
|
||||
mockResponse = {
|
||||
status: vi.fn().mockReturnThis(),
|
||||
json: vi.fn(),
|
||||
|
||||
@@ -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<Response>((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(<ActivityLog userProfile={mockUserProfile} onLogClick={vi.fn()} />);
|
||||
|
||||
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(<ActivityLog userProfile={mockUserProfile} onLogClick={vi.fn()} />);
|
||||
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(<ActivityLog userProfile={mockUserProfile} onLogClick={vi.fn()} />);
|
||||
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(<ActivityLog userProfile={mockUserProfile} />);
|
||||
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(<ActivityLog userProfile={mockUserProfile} onLogClick={onLogClickMock} />);
|
||||
|
||||
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(<ActivityLog userProfile={mockUserProfile} />); // 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(<ActivityLog userProfile={mockUserProfile} />);
|
||||
|
||||
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(<ActivityLog userProfile={mockUserProfile} />);
|
||||
|
||||
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(<ActivityLog userProfile={mockUserProfile} />);
|
||||
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(<ActivityLog userProfile={mockUserProfile} />);
|
||||
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(<ActivityLog userProfile={mockUserProfile} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Failed to load activity.')).toBeInTheDocument();
|
||||
});
|
||||
expect(avatarWithFallbackAlt).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<Response>((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');
|
||||
});
|
||||
|
||||
@@ -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<Response>((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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
|
||||
@@ -94,33 +94,17 @@ vi.mock('../services/emailService.server', () => ({
|
||||
import authRouter from './auth.routes';
|
||||
|
||||
import { UniqueConstraintError } from '../services/db/errors.db'; // Import actual class for instanceof checks
|
||||
import { createTestApp } from '../tests/utils/createTestApp';
|
||||
|
||||
// --- 4. App Setup ---
|
||||
// We need to inject cookie-parser BEFORE the router is mounted.
|
||||
// Since createTestApp mounts the router immediately, we pass middleware to it if supported,
|
||||
// or we construct the app manually here to ensure correct order.
|
||||
// Assuming createTestApp doesn't support pre-middleware injection easily, we will
|
||||
// create a standard express app here for full control, or modify createTestApp usage if possible.
|
||||
// Looking at createTestApp.ts (inferred), it likely doesn't take middleware.
|
||||
// Let's manually build the app for this test file to ensure cookieParser runs first.
|
||||
|
||||
import express from 'express';
|
||||
import { errorHandler } from '../middleware/errorHandler'; // Assuming this exists
|
||||
|
||||
const { mockLogger } = await import('../tests/utils/mockLogger');
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(cookieParser()); // Mount BEFORE router
|
||||
|
||||
// Middleware to inject the mock logger into req
|
||||
app.use((req, res, next) => {
|
||||
req.log = mockLogger;
|
||||
next();
|
||||
// --- 4. App Setup using createTestApp ---
|
||||
const app = createTestApp({
|
||||
router: authRouter,
|
||||
basePath: '/api/auth',
|
||||
// Inject cookieParser via the new middleware option
|
||||
middleware: [cookieParser()],
|
||||
});
|
||||
|
||||
app.use('/api/auth', authRouter);
|
||||
app.use(errorHandler); // Mount AFTER router
|
||||
const { mockLogger } = await import('../tests/utils/mockLogger');
|
||||
|
||||
// --- 5. Tests ---
|
||||
describe('Auth Routes (/api/auth)', () => {
|
||||
|
||||
@@ -56,6 +56,7 @@ import {
|
||||
createMockUserProfile,
|
||||
createMockUserWithPasswordHash,
|
||||
} from '../tests/utils/mockFactories';
|
||||
import { createMockRequest } from '../tests/utils/createMockRequest';
|
||||
|
||||
// Mock dependencies before importing the passport configuration
|
||||
vi.mock('../services/db/index.db', () => ({
|
||||
@@ -112,7 +113,7 @@ describe('Passport Configuration', () => {
|
||||
|
||||
describe('LocalStrategy (Isolated Callback Logic)', () => {
|
||||
// FIX: mockReq needs a 'log' property because the implementation uses req.log
|
||||
const mockReq = { ip: '127.0.0.1', log: logger } as unknown as Request;
|
||||
const mockReq = createMockRequest({ ip: '127.0.0.1' });
|
||||
const done = vi.fn();
|
||||
|
||||
it('should call done(null, user) on successful authentication', async () => {
|
||||
@@ -454,12 +455,12 @@ describe('Passport Configuration', () => {
|
||||
|
||||
it('should call next() if user has "admin" role', () => {
|
||||
// Arrange
|
||||
const mockReq: Partial<Request> = {
|
||||
const mockReq = createMockRequest({
|
||||
user: createMockUserProfile({
|
||||
role: 'admin',
|
||||
user: { user_id: 'admin-id', email: 'admin@test.com' },
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
// Act
|
||||
isAdmin(mockReq as Request, mockRes as Response, mockNext);
|
||||
@@ -471,12 +472,12 @@ describe('Passport Configuration', () => {
|
||||
|
||||
it('should call next with a ForbiddenError if user does not have "admin" role', () => {
|
||||
// Arrange
|
||||
const mockReq: Partial<Request> = {
|
||||
const mockReq = createMockRequest({
|
||||
user: createMockUserProfile({
|
||||
role: 'user',
|
||||
user: { user_id: 'user-id', email: 'user@test.com' },
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
// Act
|
||||
isAdmin(mockReq as Request, mockRes as Response, mockNext);
|
||||
@@ -488,7 +489,7 @@ describe('Passport Configuration', () => {
|
||||
|
||||
it('should call next with a ForbiddenError if req.user is missing', () => {
|
||||
// Arrange
|
||||
const mockReq = {} as Request; // No req.user
|
||||
const mockReq = createMockRequest(); // No req.user
|
||||
|
||||
// Act
|
||||
isAdmin(mockReq, mockRes as Response, mockNext);
|
||||
@@ -500,12 +501,12 @@ describe('Passport Configuration', () => {
|
||||
|
||||
it('should log a warning when a non-admin user tries to access an admin route', () => {
|
||||
// Arrange
|
||||
const mockReq: Partial<Request> = {
|
||||
const mockReq = createMockRequest({
|
||||
user: createMockUserProfile({
|
||||
role: 'user',
|
||||
user: { user_id: 'user-id-123', email: 'user@test.com' },
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
// Act
|
||||
isAdmin(mockReq as Request, mockRes as Response, mockNext);
|
||||
@@ -516,7 +517,7 @@ describe('Passport Configuration', () => {
|
||||
|
||||
it('should log a warning with "unknown" user when req.user is missing', () => {
|
||||
// Arrange
|
||||
const mockReq = {} as Request; // No req.user
|
||||
const mockReq = createMockRequest(); // No req.user
|
||||
|
||||
// Act
|
||||
isAdmin(mockReq, mockRes as Response, mockNext);
|
||||
@@ -533,37 +534,37 @@ describe('Passport Configuration', () => {
|
||||
};
|
||||
|
||||
// Case 1: user is not an object (e.g., a string)
|
||||
const req1 = { user: 'not-an-object' } as unknown as Request;
|
||||
const req1 = createMockRequest({ user: 'not-an-object' } as any);
|
||||
isAdmin(req1, mockRes as Response, mockNext);
|
||||
expect(mockNext).toHaveBeenLastCalledWith(expect.any(ForbiddenError));
|
||||
expect(mockRes.status).not.toHaveBeenCalled();
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Case 2: user is null
|
||||
const req2 = { user: null } as unknown as Request;
|
||||
const req2 = createMockRequest({ user: null } as any);
|
||||
isAdmin(req2, mockRes as Response, mockNext);
|
||||
expect(mockNext).toHaveBeenLastCalledWith(expect.any(ForbiddenError));
|
||||
expect(mockRes.status).not.toHaveBeenCalled();
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Case 3: user object is missing 'user' property
|
||||
const req3 = { user: { role: 'admin' } } as unknown as Request;
|
||||
const req3 = createMockRequest({ user: { role: 'admin' } } as any);
|
||||
isAdmin(req3, mockRes as Response, mockNext);
|
||||
expect(mockNext).toHaveBeenLastCalledWith(expect.any(ForbiddenError));
|
||||
expect(mockRes.status).not.toHaveBeenCalled();
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Case 4: user.user is not an object
|
||||
const req4 = { user: { role: 'admin', user: 'not-an-object' } } as unknown as Request;
|
||||
const req4 = createMockRequest({ user: { role: 'admin', user: 'not-an-object' } } as any);
|
||||
isAdmin(req4, mockRes as Response, mockNext);
|
||||
expect(mockNext).toHaveBeenLastCalledWith(expect.any(ForbiddenError));
|
||||
expect(mockRes.status).not.toHaveBeenCalled();
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Case 5: user.user is missing 'user_id'
|
||||
const req5 = {
|
||||
user: { role: 'admin', user: { email: 'test@test.com' } },
|
||||
} as unknown as Request;
|
||||
const req5 = createMockRequest({
|
||||
user: { role: 'admin', user: { email: 'test@test.com' } } as any,
|
||||
});
|
||||
isAdmin(req5, mockRes as Response, mockNext);
|
||||
expect(mockNext).toHaveBeenLastCalledWith(expect.any(ForbiddenError));
|
||||
expect(mockRes.status).not.toHaveBeenCalled();
|
||||
@@ -575,12 +576,12 @@ describe('Passport Configuration', () => {
|
||||
|
||||
it('should call next with a ForbiddenError if req.user is not a valid UserProfile object', () => {
|
||||
// Arrange
|
||||
const mockReq: Partial<Request> = {
|
||||
const mockReq = createMockRequest({
|
||||
// An object that is not a valid UserProfile (e.g., missing 'role')
|
||||
user: {
|
||||
user: { user_id: 'invalid-user-id' }, // Missing 'role' property
|
||||
} as unknown as UserProfile, // Cast to UserProfile to satisfy req.user type, but it's intentionally malformed
|
||||
};
|
||||
});
|
||||
|
||||
// Act
|
||||
isAdmin(mockReq as Request, mockRes as Response, mockNext);
|
||||
@@ -601,7 +602,7 @@ describe('Passport Configuration', () => {
|
||||
|
||||
it('should populate req.user and call next() if authentication succeeds', () => {
|
||||
// Arrange
|
||||
const mockReq = {} as Request;
|
||||
const mockReq = createMockRequest();
|
||||
const mockUser = createMockUserProfile({
|
||||
role: 'admin',
|
||||
user: { user_id: 'admin-id', email: 'admin@test.com' },
|
||||
@@ -621,7 +622,7 @@ describe('Passport Configuration', () => {
|
||||
|
||||
it('should not populate req.user and still call next() if authentication fails', () => {
|
||||
// Arrange
|
||||
const mockReq = {} as Request;
|
||||
const mockReq = createMockRequest();
|
||||
vi.mocked(passport.authenticate).mockImplementation(
|
||||
(_strategy, _options, callback) => () => callback?.(null, false, undefined),
|
||||
);
|
||||
@@ -634,7 +635,7 @@ describe('Passport Configuration', () => {
|
||||
|
||||
it('should log info and call next() if authentication provides an info message', () => {
|
||||
// Arrange
|
||||
const mockReq = {} as Request;
|
||||
const mockReq = createMockRequest();
|
||||
const mockInfo = { message: 'Token expired' };
|
||||
// Mock passport.authenticate to call its callback with an info object
|
||||
vi.mocked(passport.authenticate).mockImplementation(
|
||||
@@ -652,7 +653,7 @@ describe('Passport Configuration', () => {
|
||||
|
||||
it('should log info and call next() if authentication provides an info Error object', () => {
|
||||
// Arrange
|
||||
const mockReq = {} as Request;
|
||||
const mockReq = createMockRequest();
|
||||
const mockInfoError = new Error('Token is malformed');
|
||||
// Mock passport.authenticate to call its callback with an info object
|
||||
vi.mocked(passport.authenticate).mockImplementation(
|
||||
@@ -673,7 +674,7 @@ describe('Passport Configuration', () => {
|
||||
|
||||
it('should log info.toString() if info object has no message property', () => {
|
||||
// Arrange
|
||||
const mockReq = {} as Request;
|
||||
const mockReq = createMockRequest();
|
||||
const mockInfo = { custom: 'some info' };
|
||||
// Mock passport.authenticate to call its callback with a custom info object
|
||||
vi.mocked(passport.authenticate).mockImplementation(
|
||||
@@ -693,7 +694,7 @@ describe('Passport Configuration', () => {
|
||||
|
||||
it('should call next() and not populate user if passport returns an error', () => {
|
||||
// Arrange
|
||||
const mockReq = {} as Request;
|
||||
const mockReq = createMockRequest();
|
||||
const authError = new Error('Malformed token');
|
||||
// Mock passport.authenticate to call its callback with an error
|
||||
vi.mocked(passport.authenticate).mockImplementation(
|
||||
@@ -729,7 +730,7 @@ describe('Passport Configuration', () => {
|
||||
it('should attach a mock admin user to req when NODE_ENV is "test"', () => {
|
||||
// Arrange
|
||||
vi.stubEnv('NODE_ENV', 'test');
|
||||
const mockReq = {} as Request;
|
||||
const mockReq = createMockRequest();
|
||||
|
||||
// Act
|
||||
mockAuth(mockReq, mockRes as Response, mockNext);
|
||||
@@ -743,7 +744,7 @@ describe('Passport Configuration', () => {
|
||||
it('should do nothing and call next() when NODE_ENV is not "test"', () => {
|
||||
// Arrange
|
||||
vi.stubEnv('NODE_ENV', 'production');
|
||||
const mockReq = {} as Request;
|
||||
const mockReq = createMockRequest();
|
||||
|
||||
// Act
|
||||
mockAuth(mockReq, mockRes as Response, mockNext);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -244,8 +244,9 @@ describe('Flyer DB Service', () => {
|
||||
await expect(flyerRepo.insertFlyer(flyerData, mockLogger)).rejects.toThrow(
|
||||
CheckConstraintError,
|
||||
);
|
||||
// The implementation now generates a more detailed error message.
|
||||
await expect(flyerRepo.insertFlyer(flyerData, mockLogger)).rejects.toThrow(
|
||||
'Invalid URL format provided for image or icon.',
|
||||
"[URL_CHECK_FAIL] Invalid URL format. Image: 'https://example.com/not-a-url', Icon: 'null'",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
// src/services/logger.server.test.ts
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
// Unmock the module we are testing to override the global mock from setupFiles.
|
||||
vi.unmock('./logger.server');
|
||||
|
||||
// Mock pino before importing the logger
|
||||
const pinoMock = vi.fn(() => ({
|
||||
info: vi.fn(),
|
||||
@@ -25,14 +28,25 @@ describe('Server Logger', () => {
|
||||
it('should initialize pino with the correct level for production', async () => {
|
||||
vi.stubEnv('NODE_ENV', 'production');
|
||||
await import('./logger.server');
|
||||
expect(pinoMock).toHaveBeenCalledWith(expect.objectContaining({ level: 'info' }));
|
||||
expect(pinoMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ level: 'info', transport: undefined }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should initialize pino with pretty-print transport for development', async () => {
|
||||
vi.stubEnv('NODE_ENV', 'development');
|
||||
await import('./logger.server');
|
||||
expect(pinoMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ transport: expect.any(Object) }),
|
||||
expect.objectContaining({ level: 'debug', transport: expect.any(Object) }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should initialize pino with debug level and no transport for test', async () => {
|
||||
// This is the default for vitest, but we stub it for clarity.
|
||||
vi.stubEnv('NODE_ENV', 'test');
|
||||
await import('./logger.server');
|
||||
expect(pinoMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ level: 'debug', transport: undefined }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -59,17 +59,40 @@ vi.mock('../../services/storage/storageService', () => {
|
||||
};
|
||||
});
|
||||
|
||||
// FIX: Import the singleton instance directly to spy on it
|
||||
import { aiService } from '../../services/aiService.server';
|
||||
|
||||
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
const { mockExtractCoreData } = vi.hoisted(() => ({
|
||||
mockExtractCoreData: vi.fn(),
|
||||
}));
|
||||
// CRITICAL: This mock function must be declared with vi.hoisted() to ensure it's available
|
||||
// at the module level BEFORE any imports are resolved.
|
||||
const { mockExtractCoreData } = vi.hoisted(() => {
|
||||
return {
|
||||
mockExtractCoreData: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
// 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,
|
||||
};
|
||||
});
|
||||
|
||||
// Mock the connection DB service to intercept withTransaction.
|
||||
// This is crucial because FlyerPersistenceService imports directly from connection.db,
|
||||
@@ -99,9 +122,8 @@ describe('Flyer Processing Background Job Integration Test', () => {
|
||||
process.env.FRONTEND_URL = 'https://example.com';
|
||||
console.error('[TEST SETUP] FRONTEND_URL stubbed to:', process.env.FRONTEND_URL);
|
||||
|
||||
// FIX: Spy on the actual singleton instance. This ensures that when the worker
|
||||
// imports 'aiService', it gets the instance we are controlling here.
|
||||
vi.spyOn(aiService, 'extractCoreDataFromFlyerImage').mockImplementation(mockExtractCoreData);
|
||||
// NOTE: The aiService mock is now set up via vi.mock() at the module level (above).
|
||||
// This ensures workers get the mocked version when they import aiService.
|
||||
|
||||
// NEW: Import workers to start them IN-PROCESS.
|
||||
// This ensures they run in the same memory space as our mocks.
|
||||
|
||||
8
src/tests/setup/global.ts
Normal file
8
src/tests/setup/global.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { vi } from 'vitest';
|
||||
import { mockLogger } from '../utils/mockLogger';
|
||||
|
||||
// Globally mock the logger service so individual test files don't have to.
|
||||
// This ensures 'import { logger } from ...' always returns the mock.
|
||||
vi.mock('../../services/logger.server', () => ({
|
||||
logger: mockLogger,
|
||||
}));
|
||||
@@ -9,6 +9,29 @@ let server: Server;
|
||||
// This will hold the single database pool instance for the entire test run.
|
||||
let globalPool: ReturnType<typeof getPool> | null = null;
|
||||
|
||||
/**
|
||||
* Cleans all BullMQ queues to ensure no stale jobs from previous test runs.
|
||||
* This is critical because old jobs with outdated error messages can pollute test results.
|
||||
*/
|
||||
async function cleanAllQueues() {
|
||||
console.log(`[PID:${process.pid}] Cleaning all BullMQ queues...`);
|
||||
const { flyerQueue, cleanupQueue, emailQueue, analyticsQueue, weeklyAnalyticsQueue, tokenCleanupQueue } = await import('../../services/queues.server');
|
||||
|
||||
const queues = [flyerQueue, cleanupQueue, emailQueue, analyticsQueue, weeklyAnalyticsQueue, tokenCleanupQueue];
|
||||
|
||||
for (const queue of queues) {
|
||||
try {
|
||||
// obliterate() removes ALL data associated with the queue from Redis
|
||||
await queue.obliterate({ force: true });
|
||||
console.log(` ✅ Cleaned queue: ${queue.name}`);
|
||||
} catch (error) {
|
||||
// Log but don't fail - the queue might not exist yet
|
||||
console.log(` ⚠️ Could not clean queue ${queue.name}: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
console.log(`✅ [PID:${process.pid}] All queues cleaned.`);
|
||||
}
|
||||
|
||||
export async function setup() {
|
||||
// Ensure we are in the correct environment for these tests.
|
||||
process.env.NODE_ENV = 'test';
|
||||
@@ -17,6 +40,10 @@ export async function setup() {
|
||||
|
||||
console.log(`\n--- [PID:${process.pid}] Running Integration Test GLOBAL Setup ---`);
|
||||
|
||||
// CRITICAL: Clean all queues BEFORE running any tests to remove stale jobs
|
||||
// from previous test runs that may have outdated error messages.
|
||||
await cleanAllQueues();
|
||||
|
||||
// The integration setup is now the single source of truth for preparing the test DB.
|
||||
// It runs the same seed script that `npm run db:reset:test` used.
|
||||
try {
|
||||
|
||||
9
src/tests/utils/createMockRequest.ts
Normal file
9
src/tests/utils/createMockRequest.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { Request } from 'express';
|
||||
import { mockLogger } from './mockLogger';
|
||||
|
||||
export const createMockRequest = (overrides: Partial<Request> = {}): Request => {
|
||||
return {
|
||||
log: mockLogger,
|
||||
...overrides,
|
||||
} as unknown as Request;
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
// src/tests/utils/createTestApp.ts
|
||||
import express, { type Router } from 'express';
|
||||
import express, { type Router, type RequestHandler } from 'express';
|
||||
import type { Logger } from 'pino';
|
||||
import { errorHandler } from '../../middleware/errorHandler';
|
||||
import { mockLogger } from './mockLogger';
|
||||
@@ -17,6 +17,7 @@ interface CreateAppOptions {
|
||||
router: Router;
|
||||
basePath: string;
|
||||
authenticatedUser?: UserProfile;
|
||||
middleware?: RequestHandler[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -24,10 +25,20 @@ interface CreateAppOptions {
|
||||
* It includes JSON parsing, a mock logger, an optional authenticated user,
|
||||
* the specified router, and the global error handler.
|
||||
*/
|
||||
export const createTestApp = ({ router, basePath, authenticatedUser }: CreateAppOptions) => {
|
||||
export const createTestApp = ({
|
||||
router,
|
||||
basePath,
|
||||
authenticatedUser,
|
||||
middleware = [],
|
||||
}: CreateAppOptions) => {
|
||||
const app = express();
|
||||
app.use(express.json({ strict: false }));
|
||||
|
||||
// Apply custom middleware (e.g. cookieParser)
|
||||
if (middleware.length > 0) {
|
||||
app.use(middleware);
|
||||
}
|
||||
|
||||
// Inject the mock logger and authenticated user into every request.
|
||||
app.use((req, res, next) => {
|
||||
req.log = mockLogger;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import type { Request } from 'express';
|
||||
import { createMockRequest } from '../tests/utils/createMockRequest';
|
||||
|
||||
describe('rateLimit utils', () => {
|
||||
beforeEach(() => {
|
||||
@@ -16,7 +16,7 @@ describe('rateLimit utils', () => {
|
||||
vi.stubEnv('NODE_ENV', 'production');
|
||||
const { shouldSkipRateLimit } = await import('./rateLimit');
|
||||
|
||||
const req = { headers: {} } as Request;
|
||||
const req = createMockRequest({ headers: {} });
|
||||
expect(shouldSkipRateLimit(req)).toBe(false);
|
||||
});
|
||||
|
||||
@@ -24,7 +24,7 @@ describe('rateLimit utils', () => {
|
||||
vi.stubEnv('NODE_ENV', 'development');
|
||||
const { shouldSkipRateLimit } = await import('./rateLimit');
|
||||
|
||||
const req = { headers: {} } as Request;
|
||||
const req = createMockRequest({ headers: {} });
|
||||
expect(shouldSkipRateLimit(req)).toBe(false);
|
||||
});
|
||||
|
||||
@@ -32,7 +32,7 @@ describe('rateLimit utils', () => {
|
||||
vi.stubEnv('NODE_ENV', 'test');
|
||||
const { shouldSkipRateLimit } = await import('./rateLimit');
|
||||
|
||||
const req = { headers: {} } as Request;
|
||||
const req = createMockRequest({ headers: {} });
|
||||
expect(shouldSkipRateLimit(req)).toBe(true);
|
||||
});
|
||||
|
||||
@@ -40,9 +40,9 @@ describe('rateLimit utils', () => {
|
||||
vi.stubEnv('NODE_ENV', 'test');
|
||||
const { shouldSkipRateLimit } = await import('./rateLimit');
|
||||
|
||||
const req = {
|
||||
const req = createMockRequest({
|
||||
headers: { 'x-test-rate-limit-enable': 'true' },
|
||||
} as unknown as Request;
|
||||
});
|
||||
expect(shouldSkipRateLimit(req)).toBe(false);
|
||||
});
|
||||
|
||||
@@ -50,9 +50,9 @@ describe('rateLimit utils', () => {
|
||||
vi.stubEnv('NODE_ENV', 'test');
|
||||
const { shouldSkipRateLimit } = await import('./rateLimit');
|
||||
|
||||
const req = {
|
||||
const req = createMockRequest({
|
||||
headers: { 'x-test-rate-limit-enable': 'false' },
|
||||
} as unknown as Request;
|
||||
});
|
||||
expect(shouldSkipRateLimit(req)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,57 +20,79 @@ const createMockLogger = (): Logger =>
|
||||
|
||||
describe('serverUtils', () => {
|
||||
describe('getBaseUrl', () => {
|
||||
const originalEnv = process.env;
|
||||
let mockLogger: Logger;
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset mocks and environment variables before each test for isolation
|
||||
vi.resetModules();
|
||||
process.env = { ...originalEnv };
|
||||
vi.unstubAllEnvs();
|
||||
mockLogger = createMockLogger();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore original environment variables after each test
|
||||
process.env = originalEnv;
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('should use FRONTEND_URL if it is a valid URL', () => {
|
||||
process.env.FRONTEND_URL = 'https://valid.example.com';
|
||||
vi.stubEnv('FRONTEND_URL', 'https://valid.example.com');
|
||||
const baseUrl = getBaseUrl(mockLogger);
|
||||
expect(baseUrl).toBe('https://valid.example.com');
|
||||
expect(mockLogger.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should trim a trailing slash from FRONTEND_URL', () => {
|
||||
process.env.FRONTEND_URL = 'https://valid.example.com/';
|
||||
vi.stubEnv('FRONTEND_URL', 'https://valid.example.com/');
|
||||
const baseUrl = getBaseUrl(mockLogger);
|
||||
expect(baseUrl).toBe('https://valid.example.com');
|
||||
});
|
||||
|
||||
it('should use BASE_URL if FRONTEND_URL is not set', () => {
|
||||
delete process.env.FRONTEND_URL;
|
||||
process.env.BASE_URL = 'https://base.example.com';
|
||||
vi.stubEnv('BASE_URL', 'https://base.example.com');
|
||||
const baseUrl = getBaseUrl(mockLogger);
|
||||
expect(baseUrl).toBe('https://base.example.com');
|
||||
expect(mockLogger.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should fall back to example.com with default port 3000 if no URL is provided', () => {
|
||||
delete process.env.FRONTEND_URL;
|
||||
delete process.env.BASE_URL;
|
||||
delete process.env.PORT;
|
||||
it('should fall back to localhost with default port 3000 in test environment', () => {
|
||||
vi.stubEnv('NODE_ENV', 'test');
|
||||
const baseUrl = getBaseUrl(mockLogger);
|
||||
expect(baseUrl).toBe('https://example.com:3000');
|
||||
expect(baseUrl).toBe('http://localhost:3000');
|
||||
expect(mockLogger.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should log a warning and fall back if FRONTEND_URL is invalid (does not start with http)', () => {
|
||||
process.env.FRONTEND_URL = 'invalid.url.com';
|
||||
it('should fall back to example.com in non-test environment', () => {
|
||||
vi.stubEnv('NODE_ENV', 'development');
|
||||
vi.stubEnv('PORT', '4000');
|
||||
const baseUrl = getBaseUrl(mockLogger);
|
||||
expect(baseUrl).toBe('https://example.com:3000');
|
||||
expect(baseUrl).toBe('http://example.com:4000');
|
||||
expect(mockLogger.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should log a warning and fall back to localhost if FRONTEND_URL is invalid in test env', () => {
|
||||
vi.stubEnv('NODE_ENV', 'test');
|
||||
vi.stubEnv('FRONTEND_URL', 'invalid.url.com');
|
||||
const baseUrl = getBaseUrl(mockLogger);
|
||||
expect(baseUrl).toBe('http://localhost:3000');
|
||||
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||
"[getBaseUrl] FRONTEND_URL/BASE_URL is invalid or incomplete ('invalid.url.com'). Falling back to default local URL: https://example.com:3000",
|
||||
"[getBaseUrl] FRONTEND_URL/BASE_URL is invalid or incomplete ('invalid.url.com'). Falling back to: http://localhost:3000",
|
||||
);
|
||||
});
|
||||
|
||||
it('should log a warning and fall back to example.com if FRONTEND_URL is invalid in non-test env', () => {
|
||||
vi.stubEnv('NODE_ENV', 'production');
|
||||
vi.stubEnv('FRONTEND_URL', 'invalid.url.com');
|
||||
const baseUrl = getBaseUrl(mockLogger);
|
||||
expect(baseUrl).toBe('http://example.com:3000');
|
||||
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||
"[getBaseUrl] FRONTEND_URL/BASE_URL is invalid or incomplete ('invalid.url.com'). Falling back to: http://example.com:3000",
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw an error if the final URL is invalid', () => {
|
||||
vi.stubEnv('FRONTEND_URL', 'http:invalid');
|
||||
expect(() => getBaseUrl(mockLogger)).toThrow(
|
||||
`[getBaseUrl] Generated URL 'http:invalid' does not match required pattern (must start with http:// or https://)`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -46,6 +46,7 @@ export default defineConfig({
|
||||
globalSetup: './src/tests/setup/global-setup.ts',
|
||||
// The globalApiMock MUST come first to ensure it's applied before other mocks that might depend on it.
|
||||
setupFiles: [
|
||||
'./src/tests/setup/global.ts',
|
||||
'./src/tests/setup/globalApiMock.ts',
|
||||
'./src/tests/setup/tests-setup-unit.ts',
|
||||
],
|
||||
|
||||
@@ -53,6 +53,7 @@ const finalConfig = mergeConfig(
|
||||
},
|
||||
// This setup script starts the backend server before tests run.
|
||||
globalSetup: './src/tests/setup/integration-global-setup.ts',
|
||||
setupFiles: ['./src/tests/setup/global.ts'],
|
||||
// The default timeout is 5000ms (5 seconds)
|
||||
testTimeout: 60000, // Increased timeout for server startup and API calls, especially AI services.
|
||||
hookTimeout: 60000,
|
||||
|
||||
Reference in New Issue
Block a user