Files
flyer-crawler.projectium.com/src/hooks/useUserData.test.tsx

289 lines
9.3 KiB
TypeScript

// src/hooks/useUserData.test.tsx
import React, { ReactNode } from 'react';
import { renderHook, waitFor } from '@testing-library/react';
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 type { UserProfile } from '../types';
import {
createMockMasterGroceryItem,
createMockShoppingList,
createMockUserProfile,
} from '../tests/utils/mockFactories';
// 1. Mock the hook's dependencies
vi.mock('../hooks/useAuth');
vi.mock('./useApiOnMount');
// 2. Create typed mocks for type safety and autocompletion
const mockedUseAuth = vi.mocked(useAuth);
const mockedUseApiOnMount = vi.mocked(useApiOnMount);
// 3. A simple wrapper component that renders our provider.
// This is necessary because the useUserData hook needs to be a child of UserDataProvider.
const wrapper = ({ children }: { children: ReactNode }) => (
<UserDataProvider>{children}</UserDataProvider>
); // No change needed here, just for context
// 4. Mock data for testing
const mockUser: UserProfile = createMockUserProfile({
full_name: 'Test User',
points: 100,
user: { user_id: 'user-123', email: 'test@example.com' },
});
const mockWatchedItems = [createMockMasterGroceryItem({ master_grocery_item_id: 1, name: 'Milk' })];
const mockShoppingLists = [
createMockShoppingList({ shopping_list_id: 1, name: 'Weekly Groceries', user_id: 'user-123' }),
];
describe('useUserData Hook and UserDataProvider', () => {
beforeEach(() => {
// Clear mock history before each test to ensure test isolation.
vi.clearAllMocks();
});
it('should throw an error if useUserData is used outside of UserDataProvider', () => {
// Suppress the expected console.error for this test to keep the output clean.
const originalError = console.error;
console.error = vi.fn();
// Expecting renderHook to throw an error because there's no provider.
expect(() => renderHook(() => useUserData())).toThrow(
'useUserData must be used within a UserDataProvider',
);
// Restore the original console.error function.
console.error = originalError;
});
it('should return initial empty state when user is not authenticated', () => {
// Arrange: Simulate a logged-out user.
mockedUseAuth.mockReturnValue({
userProfile: null,
authStatus: 'SIGNED_OUT',
isLoading: false,
login: vi.fn(),
logout: vi.fn(),
updateProfile: vi.fn(),
});
// Arrange: Mock the return value of the inner hooks.
mockedUseApiOnMount.mockReturnValue({
data: null,
loading: false,
error: null,
isRefetching: false,
reset: vi.fn(),
});
// Act: Render the hook within the provider wrapper.
const { result } = renderHook(() => useUserData(), { wrapper });
// Assert: Check that the context values are in their initial, empty state.
expect(result.current.isLoading).toBe(false);
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,
});
});
it('should return loading state when user is authenticated and data is fetching', () => {
// Arrange: Simulate a logged-in user.
mockedUseAuth.mockReturnValue({
userProfile: mockUser,
authStatus: 'AUTHENTICATED',
isLoading: false,
login: vi.fn(),
logout: vi.fn(),
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
// Act
const { result } = renderHook(() => useUserData(), { wrapper });
// Assert
expect(result.current.isLoading).toBe(true);
});
it('should return data on successful fetch when user is authenticated', async () => {
// Arrange: Simulate a logged-in user.
mockedUseAuth.mockReturnValue({
userProfile: mockUser,
authStatus: 'AUTHENTICATED',
isLoading: false,
login: vi.fn(),
logout: vi.fn(),
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(),
});
// Act
const { result } = renderHook(() => useUserData(), { wrapper });
// Assert: Use `waitFor` to allow the `useEffect` hook to update the state.
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
expect(result.current.watchedItems).toEqual(mockWatchedItems);
expect(result.current.shoppingLists).toEqual(mockShoppingLists);
expect(result.current.error).toBeNull();
});
});
it('should return an error state if one of the fetches fails', async () => {
// Arrange: Simulate a logged-in user.
mockedUseAuth.mockReturnValue({
userProfile: mockUser,
authStatus: 'AUTHENTICATED',
isLoading: false,
login: vi.fn(),
logout: vi.fn(),
updateProfile: vi.fn(),
});
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
// Act
const { result } = renderHook(() => useUserData(), { wrapper });
// Assert
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
expect(result.current.error).toBe('Failed to fetch watched items');
// Data that was fetched successfully should still be populated.
expect(result.current.shoppingLists).toEqual(mockShoppingLists);
// Data that failed should be empty.
expect(result.current.watchedItems).toEqual([]);
});
});
it('should clear data when the user logs out', async () => {
// Arrange: Start with a logged-in user and data.
mockedUseAuth.mockReturnValue({
userProfile: mockUser,
authStatus: 'AUTHENTICATED',
isLoading: false,
login: vi.fn(),
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(),
});
const { result, rerender } = renderHook(() => useUserData(), { wrapper });
await waitFor(() => expect(result.current.watchedItems).not.toEqual([]));
// Act: Simulate logout by re-rendering with a null user.
mockedUseAuth.mockReturnValue({
userProfile: null,
authStatus: 'SIGNED_OUT',
isLoading: false,
login: vi.fn(),
logout: vi.fn(),
updateProfile: vi.fn(),
});
rerender();
// Assert: The data should now be cleared.
expect(result.current.watchedItems).toEqual([]);
expect(result.current.shoppingLists).toEqual([]);
});
});