Files
flyer-crawler.projectium.com/src/hooks/useShoppingLists.test.tsx
Torben Sorensen b392b82c25
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 15m20s
fix unit tests
2025-12-30 00:09:57 -08:00

751 lines
26 KiB
TypeScript

// 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');
vi.mock('../services/apiClient');
// 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<ShoppingList[]>
>;
// 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<ShoppingList[]>) => {
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();
});
});