// src/hooks/useShoppingLists.test.tsx import { renderHook, act, waitFor } from '@testing-library/react'; import { describe, it, expect, vi, beforeEach, type Mock, test } from 'vitest'; import { useShoppingLists } from './useShoppingLists'; import { useApi } from './useApi'; import { useAuth } from '../hooks/useAuth'; import { useUserData } from '../hooks/useUserData'; import * as apiClient from '../services/apiClient'; import { createMockShoppingList, createMockShoppingListItem, createMockUserProfile, createMockUser, } from '../tests/utils/mockFactories'; import React from 'react'; import type { ShoppingList, User } from '../types'; // Import ShoppingList and User types // Define a type for the mock return value of useApi to ensure type safety in tests type MockApiResult = { execute: Mock; error: Error | null; loading: boolean; isRefetching: boolean; data: any; reset: Mock; }; // Mock the hooks that useShoppingLists depends on vi.mock('./useApi'); vi.mock('../hooks/useAuth'); vi.mock('../hooks/useUserData'); // The apiClient is globally mocked in our test setup, so we just need to cast it const mockedUseApi = vi.mocked(useApi); const mockedUseAuth = vi.mocked(useAuth); const mockedUseUserData = vi.mocked(useUserData); // Create a mock User object by extracting it from a mock UserProfile const mockUserProfile = createMockUserProfile({ user: createMockUser({ user_id: 'user-123', email: 'test@example.com' }), }); describe('useShoppingLists Hook', () => { // Create a mock setter function that we can spy on const mockSetShoppingLists = vi.fn() as unknown as React.Dispatch< React.SetStateAction >; // Create mock execute functions for each API operation const mockCreateListApi = vi.fn(); const mockDeleteListApi = vi.fn(); const mockAddItemApi = vi.fn(); const mockUpdateItemApi = vi.fn(); const mockRemoveItemApi = vi.fn(); const defaultApiMocks: MockApiResult[] = [ { execute: mockCreateListApi, error: null, loading: false, isRefetching: false, data: null, reset: vi.fn(), }, { execute: mockDeleteListApi, error: null, loading: false, isRefetching: false, data: null, reset: vi.fn(), }, { execute: mockAddItemApi, error: null, loading: false, isRefetching: false, data: null, reset: vi.fn(), }, { execute: mockUpdateItemApi, error: null, loading: false, isRefetching: false, data: null, reset: vi.fn(), }, { execute: mockRemoveItemApi, error: null, loading: false, isRefetching: false, data: null, reset: vi.fn(), }, ]; // Helper function to set up the useApi mock for a specific test run const setupApiMocks = (mocks: MockApiResult[] = defaultApiMocks) => { let callCount = 0; mockedUseApi.mockImplementation(() => { const mock = mocks[callCount % mocks.length]; callCount++; return mock; }); }; beforeEach(() => { // Reset all mocks before each test to ensure isolation vi.clearAllMocks(); // Mock useApi to return a sequence of successful API configurations by default setupApiMocks(); mockedUseAuth.mockReturnValue({ userProfile: mockUserProfile, authStatus: 'AUTHENTICATED', isLoading: false, login: vi.fn(), logout: vi.fn(), updateProfile: vi.fn(), }); mockedUseUserData.mockReturnValue({ shoppingLists: [], setShoppingLists: mockSetShoppingLists, watchedItems: [], setWatchedItems: vi.fn(), isLoading: false, error: null, }); }); it('should initialize with no active list when there are no lists', () => { const { result } = renderHook(() => useShoppingLists()); expect(result.current.shoppingLists).toEqual([]); expect(result.current.activeListId).toBeNull(); }); it('should set the first list as active on initial load if lists exist', async () => { const mockLists = [ createMockShoppingList({ shopping_list_id: 1, name: 'Groceries', user_id: 'user-123' }), createMockShoppingList({ shopping_list_id: 2, name: 'Hardware Store', user_id: 'user-123' }), ]; mockedUseUserData.mockReturnValue({ shoppingLists: mockLists, setShoppingLists: mockSetShoppingLists, watchedItems: [], setWatchedItems: vi.fn(), isLoading: false, error: null, }); const { result } = renderHook(() => useShoppingLists()); await waitFor(() => expect(result.current.activeListId).toBe(1)); }); it('should not set an active list if the user is not authenticated', () => { mockedUseAuth.mockReturnValue({ userProfile: null, authStatus: 'SIGNED_OUT', isLoading: false, login: vi.fn(), logout: vi.fn(), updateProfile: vi.fn(), }); const mockLists = [ createMockShoppingList({ shopping_list_id: 1, name: 'Groceries', user_id: 'user-123' }), ]; mockedUseUserData.mockReturnValue({ shoppingLists: mockLists, setShoppingLists: mockSetShoppingLists, watchedItems: [], setWatchedItems: vi.fn(), isLoading: false, error: null, }); const { result } = renderHook(() => useShoppingLists()); expect(result.current.activeListId).toBeNull(); }); it('should set activeListId to null when lists become empty', async () => { const mockLists = [ createMockShoppingList({ shopping_list_id: 1, name: 'Groceries', user_id: 'user-123' }), ]; // Initial render with a list mockedUseUserData.mockReturnValue({ shoppingLists: mockLists, setShoppingLists: mockSetShoppingLists, watchedItems: [], setWatchedItems: vi.fn(), isLoading: false, error: null, }); const { result, rerender } = renderHook(() => useShoppingLists()); await waitFor(() => expect(result.current.activeListId).toBe(1)); // Rerender with empty lists mockedUseUserData.mockReturnValue({ shoppingLists: [], setShoppingLists: mockSetShoppingLists, watchedItems: [], setWatchedItems: vi.fn(), isLoading: false, error: null, }); rerender(); // The effect should update the activeListId to null await waitFor(() => expect(result.current.activeListId).toBeNull()); }); it('should expose loading states for API operations', () => { // Mock useApi to return loading: true for each specific operation in sequence mockedUseApi .mockReturnValueOnce({ ...defaultApiMocks[0], loading: true }) // create .mockReturnValueOnce({ ...defaultApiMocks[1], loading: true }) // delete .mockReturnValueOnce({ ...defaultApiMocks[2], loading: true }) // add item .mockReturnValueOnce({ ...defaultApiMocks[3], loading: true }) // update item .mockReturnValueOnce({ ...defaultApiMocks[4], loading: true }); // remove item const { result } = renderHook(() => useShoppingLists()); expect(result.current.isCreatingList).toBe(true); expect(result.current.isDeletingList).toBe(true); expect(result.current.isAddingItem).toBe(true); expect(result.current.isUpdatingItem).toBe(true); expect(result.current.isRemovingItem).toBe(true); }); it('should configure useApi with the correct apiClient methods', async () => { renderHook(() => useShoppingLists()); // useApi is called 5 times in the hook in this order: // 1. createList, 2. deleteList, 3. addItem, 4. updateItem, 5. removeItem const createListApiFn = mockedUseApi.mock.calls[0][0]; const deleteListApiFn = mockedUseApi.mock.calls[1][0]; const addItemApiFn = mockedUseApi.mock.calls[2][0]; const updateItemApiFn = mockedUseApi.mock.calls[3][0]; const removeItemApiFn = mockedUseApi.mock.calls[4][0]; await createListApiFn('New List'); expect(apiClient.createShoppingList).toHaveBeenCalledWith('New List'); await deleteListApiFn(1); expect(apiClient.deleteShoppingList).toHaveBeenCalledWith(1); await addItemApiFn(1, { customItemName: 'Item' }); expect(apiClient.addShoppingListItem).toHaveBeenCalledWith(1, { customItemName: 'Item' }); await updateItemApiFn(1, { is_purchased: true }); expect(apiClient.updateShoppingListItem).toHaveBeenCalledWith(1, { is_purchased: true }); await removeItemApiFn(1); expect(apiClient.removeShoppingListItem).toHaveBeenCalledWith(1); }); describe('createList', () => { it('should call the API and update state on successful creation', async () => { const newList = createMockShoppingList({ shopping_list_id: 99, name: 'New List', user_id: 'user-123', }); let currentLists: ShoppingList[] = []; // Mock the implementation of the setter to simulate a real state update. // This will cause the hook to re-render with the new list. (mockSetShoppingLists as Mock).mockImplementation( (updater: React.SetStateAction) => { currentLists = typeof updater === 'function' ? updater(currentLists) : updater; }, ); // The hook will now see the updated `currentLists` on re-render. mockedUseUserData.mockImplementation(() => ({ shoppingLists: currentLists, setShoppingLists: mockSetShoppingLists, watchedItems: [], setWatchedItems: vi.fn(), isLoading: false, error: null, })); mockCreateListApi.mockResolvedValue(newList); const { result } = renderHook(() => useShoppingLists()); // `act` ensures that all state updates from the hook are processed before assertions are made await act(async () => { await result.current.createList('New List'); }); expect(mockCreateListApi).toHaveBeenCalledWith('New List'); expect(currentLists).toEqual([newList]); }); }); describe('deleteList', () => { // Use a function to get a fresh copy for each test run const getMockLists = () => [ createMockShoppingList({ shopping_list_id: 1, name: 'Groceries', user_id: 'user-123' }), createMockShoppingList({ shopping_list_id: 2, name: 'Hardware Store', user_id: 'user-123' }), ]; let currentLists: ShoppingList[] = []; beforeEach(() => { // Isolate state for each test in this describe block currentLists = getMockLists(); (mockSetShoppingLists as Mock).mockImplementation((updater) => { currentLists = typeof updater === 'function' ? updater(currentLists) : updater; }); mockedUseUserData.mockImplementation(() => ({ shoppingLists: currentLists, setShoppingLists: mockSetShoppingLists, watchedItems: [], setWatchedItems: vi.fn(), isLoading: false, error: null, })); }); it('should call the API and update state on successful deletion', async () => { console.log('TEST: should call the API and update state on successful deletion'); mockDeleteListApi.mockResolvedValue(null); // Successful delete returns null const { result, rerender } = renderHook(() => useShoppingLists()); console.log(' LOG: Initial lists count:', currentLists.length); await act(async () => { console.log(' LOG: Deleting list with ID 1.'); await result.current.deleteList(1); rerender(); }); await waitFor(() => expect(mockDeleteListApi).toHaveBeenCalledWith(1)); console.log(' LOG: Final lists count:', currentLists.length); // Check that the global state setter was called with the correctly filtered list expect(currentLists).toHaveLength(1); expect(currentLists[0].shopping_list_id).toBe(2); console.log(' LOG: SUCCESS! State was updated correctly.'); }); it('should update activeListId if the active list is deleted', async () => { console.log('TEST: should update activeListId if the active list is deleted'); mockDeleteListApi.mockResolvedValue(null); // Render the hook and wait for the initial effect to set activeListId const { result, rerender } = renderHook(() => useShoppingLists()); console.log(' LOG: Initial ActiveListId:', result.current.activeListId); await waitFor(() => expect(result.current.activeListId).toBe(1)); console.log(' LOG: Waited for ActiveListId to be 1.'); await act(async () => { console.log(' LOG: Deleting active list (ID 1).'); await result.current.deleteList(1); rerender(); }); console.log(' LOG: Deletion complete. Checking for new ActiveListId...'); // After deletion, the hook should select the next available list as active await waitFor(() => expect(result.current.activeListId).toBe(2)); console.log(' LOG: SUCCESS! ActiveListId updated to 2.'); }); it('should not change activeListId if a non-active list is deleted', async () => { console.log('TEST: should not change activeListId if a non-active list is deleted'); mockDeleteListApi.mockResolvedValue(null); const { result, rerender } = renderHook(() => useShoppingLists()); console.log(' LOG: Initial ActiveListId:', result.current.activeListId); await waitFor(() => expect(result.current.activeListId).toBe(1)); // Initial active is 1 console.log(' LOG: Waited for ActiveListId to be 1.'); await act(async () => { console.log(' LOG: Deleting non-active list (ID 2).'); await result.current.deleteList(2); // Delete list 2 rerender(); }); await waitFor(() => expect(mockDeleteListApi).toHaveBeenCalledWith(2)); console.log(' LOG: Final lists count:', currentLists.length); expect(currentLists).toHaveLength(1); expect(currentLists[0].shopping_list_id).toBe(1); // Only list 1 remains console.log(' LOG: Final ActiveListId:', result.current.activeListId); expect(result.current.activeListId).toBe(1); // Active list ID should not change console.log(' LOG: SUCCESS! ActiveListId remains 1.'); }); it('should set activeListId to null when the last list is deleted', async () => { console.log('TEST: should set activeListId to null when the last list is deleted'); const singleList = [ createMockShoppingList({ shopping_list_id: 1, name: 'Groceries', user_id: 'user-123' }), ]; // Override the state for this specific test currentLists = singleList; mockDeleteListApi.mockResolvedValue(null); const { result, rerender } = renderHook(() => useShoppingLists()); console.log(' LOG: Initial render. ActiveListId:', result.current.activeListId); await waitFor(() => expect(result.current.activeListId).toBe(1)); console.log(' LOG: ActiveListId successfully set to 1.'); await act(async () => { console.log(' LOG: Calling deleteList(1).'); await result.current.deleteList(1); console.log(' LOG: deleteList(1) finished. Rerendering component with updated lists.'); rerender(); }); console.log(' LOG: act/rerender complete. Final ActiveListId:', result.current.activeListId); await waitFor(() => expect(result.current.activeListId).toBeNull()); console.log(' LOG: SUCCESS! ActiveListId is null as expected.'); }); }); describe('addItemToList', () => { let currentLists: ShoppingList[] = []; const getMockLists = () => [ createMockShoppingList({ shopping_list_id: 1, name: 'Groceries', user_id: 'user-123' }), ]; beforeEach(() => { currentLists = getMockLists(); (mockSetShoppingLists as Mock).mockImplementation((updater) => { currentLists = typeof updater === 'function' ? updater(currentLists) : updater; }); mockedUseUserData.mockImplementation(() => ({ shoppingLists: currentLists, setShoppingLists: mockSetShoppingLists, watchedItems: [], setWatchedItems: vi.fn(), isLoading: false, error: null, })); }); it('should call API and add item to the correct list', async () => { const newItem = createMockShoppingListItem({ shopping_list_item_id: 101, shopping_list_id: 1, custom_item_name: 'Milk', }); mockAddItemApi.mockResolvedValue(newItem); const { result, rerender } = renderHook(() => useShoppingLists()); await act(async () => { await result.current.addItemToList(1, { customItemName: 'Milk' }); rerender(); }); expect(mockAddItemApi).toHaveBeenCalledWith(1, { customItemName: 'Milk' }); expect(currentLists[0].items).toHaveLength(1); expect(currentLists[0].items[0]).toEqual(newItem); }); it('should not call the API if a duplicate item (by master_item_id) is added', async () => { console.log('TEST: should not call the API if a duplicate item (by master_item_id) is added'); const existingItem = createMockShoppingListItem({ shopping_list_item_id: 100, shopping_list_id: 1, master_item_id: 5, custom_item_name: 'Milk', }); // Override state for this specific test currentLists = [ createMockShoppingList({ shopping_list_id: 1, name: 'Groceries', user_id: 'user-123', items: [existingItem], }), ]; const { result, rerender } = renderHook(() => useShoppingLists()); console.log(' LOG: Initial item count:', currentLists[0].items.length); await act(async () => { console.log(' LOG: Attempting to add duplicate masterItemId: 5'); await result.current.addItemToList(1, { masterItemId: 5 }); rerender(); }); // The API should not have been called because the duplicate was caught client-side. expect(mockAddItemApi).not.toHaveBeenCalled(); console.log(' LOG: Final item count:', currentLists[0].items.length); expect(currentLists[0].items).toHaveLength(1); // Length should remain 1 console.log(' LOG: SUCCESS! Duplicate was not added and API was not called.'); }); it('should log an error and not call the API if the listId does not exist', async () => { const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); const { result } = renderHook(() => useShoppingLists()); await act(async () => { // Call with a non-existent list ID (mock lists have IDs 1 and 2) await result.current.addItemToList(999, { customItemName: 'Wont be added' }); }); // The API should not have been called because the list was not found. expect(mockAddItemApi).not.toHaveBeenCalled(); expect(consoleErrorSpy).toHaveBeenCalledWith('useShoppingLists: List with ID 999 not found.'); consoleErrorSpy.mockRestore(); }); }); describe('updateItemInList', () => { const initialItem = createMockShoppingListItem({ shopping_list_item_id: 101, shopping_list_id: 1, custom_item_name: 'Milk', is_purchased: false, quantity: 1, }); const multiLists = [ createMockShoppingList({ shopping_list_id: 1, name: 'Groceries', items: [initialItem] }), createMockShoppingList({ shopping_list_id: 2, name: 'Other' }), ]; beforeEach(() => { mockedUseUserData.mockReturnValue({ shoppingLists: multiLists, setShoppingLists: mockSetShoppingLists, watchedItems: [], setWatchedItems: vi.fn(), isLoading: false, error: null, }); }); it('should call API and update the correct item, leaving other lists unchanged', async () => { const updatedItem = { ...initialItem, is_purchased: true }; mockUpdateItemApi.mockResolvedValue(updatedItem); const { result } = renderHook(() => useShoppingLists()); act(() => { result.current.setActiveListId(1); }); // Set active list await act(async () => { await result.current.updateItemInList(101, { is_purchased: true }); }); expect(mockUpdateItemApi).toHaveBeenCalledWith(101, { is_purchased: true }); const updater = (mockSetShoppingLists as Mock).mock.calls[0][0]; const newState = updater(multiLists); expect(newState[0].items[0].is_purchased).toBe(true); expect(newState[1]).toBe(multiLists[1]); // Verify other list is unchanged }); it('should not call update API if no list is active', async () => { console.log('TEST: should not call update API if no list is active'); const { result } = renderHook(() => useShoppingLists()); console.log(' LOG: Initial render. ActiveListId:', result.current.activeListId); // Wait for the initial effect to set the active list await waitFor(() => expect(result.current.activeListId).toBe(1)); console.log(' LOG: Initial active list is 1.'); act(() => { result.current.setActiveListId(null); }); // Ensure no active list console.log( ' LOG: Manually set activeListId to null. Current value:', result.current.activeListId, ); await act(async () => { console.log(' LOG: Calling updateItemInList while activeListId is null.'); await result.current.updateItemInList(101, { is_purchased: true }); }); expect(mockUpdateItemApi).not.toHaveBeenCalled(); console.log(' LOG: SUCCESS! mockUpdateItemApi was not called.'); }); }); describe('removeItemFromList', () => { const initialItem = createMockShoppingListItem({ shopping_list_item_id: 101, shopping_list_id: 1, custom_item_name: 'Milk', }); const multiLists = [ createMockShoppingList({ shopping_list_id: 1, name: 'Groceries', items: [initialItem] }), createMockShoppingList({ shopping_list_id: 2, name: 'Other' }), ]; beforeEach(() => { mockedUseUserData.mockReturnValue({ shoppingLists: multiLists, setShoppingLists: mockSetShoppingLists, watchedItems: [], setWatchedItems: vi.fn(), isLoading: false, error: null, }); }); it('should call API and remove item from the active list, leaving other lists unchanged', async () => { mockRemoveItemApi.mockResolvedValue(null); const { result } = renderHook(() => useShoppingLists()); act(() => { result.current.setActiveListId(1); }); await act(async () => { await result.current.removeItemFromList(101); }); expect(mockRemoveItemApi).toHaveBeenCalledWith(101); const updater = (mockSetShoppingLists as Mock).mock.calls[0][0]; const newState = updater(multiLists); expect(newState[0].items).toHaveLength(0); expect(newState[1]).toBe(multiLists[1]); // Verify other list is unchanged }); it('should not call remove API if no list is active', async () => { console.log('TEST: should not call remove API if no list is active'); const { result } = renderHook(() => useShoppingLists()); console.log(' LOG: Initial render. ActiveListId:', result.current.activeListId); // Wait for the initial effect to set the active list await waitFor(() => expect(result.current.activeListId).toBe(1)); console.log(' LOG: Initial active list is 1.'); act(() => { result.current.setActiveListId(null); }); // Ensure no active list console.log( ' LOG: Manually set activeListId to null. Current value:', result.current.activeListId, ); await act(async () => { console.log(' LOG: Calling removeItemFromList while activeListId is null.'); await result.current.removeItemFromList(101); }); expect(mockRemoveItemApi).not.toHaveBeenCalled(); console.log(' LOG: SUCCESS! mockRemoveItemApi was not called.'); }); }); describe('API Error Handling', () => { test.each([ { name: 'createList', action: (hook: any) => hook.createList('New List'), apiMock: mockCreateListApi, mockIndex: 0, errorMessage: 'API Failed', }, { name: 'deleteList', action: (hook: any) => hook.deleteList(1), apiMock: mockDeleteListApi, mockIndex: 1, errorMessage: 'Deletion failed', }, { name: 'addItemToList', action: (hook: any) => hook.addItemToList(1, { customItemName: 'Milk' }), apiMock: mockAddItemApi, mockIndex: 2, errorMessage: 'Failed to add item', }, { name: 'updateItemInList', action: (hook: any) => hook.updateItemInList(101, { is_purchased: true }), apiMock: mockUpdateItemApi, mockIndex: 3, errorMessage: 'Update failed', }, { name: 'removeItemFromList', action: (hook: any) => hook.removeItemFromList(101), apiMock: mockRemoveItemApi, mockIndex: 4, errorMessage: 'Removal failed', }, ])( 'should set an error for $name if the API call fails', async ({ action, apiMock, mockIndex, errorMessage }) => { // Setup a default list so activeListId is set automatically const mockList = createMockShoppingList({ shopping_list_id: 1, name: 'List 1' }); mockedUseUserData.mockReturnValue({ shoppingLists: [mockList], setShoppingLists: mockSetShoppingLists, watchedItems: [], setWatchedItems: vi.fn(), isLoading: false, error: null, }); const apiMocksWithError = [...defaultApiMocks]; apiMocksWithError[mockIndex] = { ...apiMocksWithError[mockIndex], error: new Error(errorMessage), }; setupApiMocks(apiMocksWithError); apiMock.mockRejectedValue(new Error(errorMessage)); // Spy on console.error to ensure the catch block is executed for logging const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); const { result } = renderHook(() => useShoppingLists()); // Wait for the effect to set the active list ID await waitFor(() => expect(result.current.activeListId).toBe(1)); await act(async () => { await action(result.current); }); await waitFor(() => { expect(result.current.error).toBe(errorMessage); // Verify that our custom logging within the catch block was called expect(consoleErrorSpy).toHaveBeenCalled(); }); consoleErrorSpy.mockRestore(); }, ); }); it('should not perform actions if user is not authenticated', async () => { mockedUseAuth.mockReturnValue({ userProfile: null, authStatus: 'SIGNED_OUT', isLoading: false, login: vi.fn(), logout: vi.fn(), updateProfile: vi.fn(), }); const { result } = renderHook(() => useShoppingLists()); await act(async () => { await result.current.createList('Should not work'); }); expect(mockCreateListApi).not.toHaveBeenCalled(); }); });