Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cea6be7145 | ||
| 74a5ca6331 | |||
|
|
62470e7661 | ||
| 2b517683fd |
48
package-lock.json
generated
48
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "flyer-crawler",
|
||||
"version": "0.9.60",
|
||||
"version": "0.9.62",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "flyer-crawler",
|
||||
"version": "0.9.60",
|
||||
"version": "0.9.62",
|
||||
"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.62",
|
||||
"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(() => ({
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user