289 lines
9.3 KiB
TypeScript
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([]);
|
|
});
|
|
});
|