Files
flyer-crawler.projectium.com/src/hooks/useWatchedItems.test.tsx
Torben Sorensen 9fd15f3a50
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 19m58s
unit test auto-provider refactor
2026-01-02 11:33:11 -08:00

321 lines
10 KiB
TypeScript

// src/hooks/useWatchedItems.test.tsx
import { renderHook, act, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { useWatchedItems } from './useWatchedItems';
import { useApi } from './useApi';
import { useAuth } from '../hooks/useAuth';
import { useUserData } from '../hooks/useUserData';
import * as apiClient from '../services/apiClient';
import type { MasterGroceryItem, User } from '../types';
import {
createMockMasterGroceryItem,
createMockUser,
createMockUserProfile,
} from '../tests/utils/mockFactories';
// Mock the hooks that useWatchedItems 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);
const mockUser: User = createMockUser({ user_id: 'user-123', email: 'test@example.com' });
const mockInitialItems = [
createMockMasterGroceryItem({ master_grocery_item_id: 1, name: 'Milk' }),
createMockMasterGroceryItem({ master_grocery_item_id: 2, name: 'Bread' }),
];
describe('useWatchedItems Hook', () => {
// Create a mock setter function that we can spy on
const mockSetWatchedItems = vi.fn();
const mockAddWatchedItemApi = vi.fn();
const mockRemoveWatchedItemApi = vi.fn();
beforeEach(() => {
// Reset all mocks before each test to ensure isolation
// Use resetAllMocks to ensure previous test implementations (like mockResolvedValue) don't leak.
vi.resetAllMocks();
// Default mock for useApi to handle any number of calls/re-renders safely
mockedUseApi.mockReturnValue({
execute: vi.fn(),
error: null,
data: null,
loading: false,
isRefetching: false,
reset: vi.fn(),
});
// Specific overrides for the first render sequence:
// 1st call = addWatchedItemApi, 2nd call = removeWatchedItemApi
mockedUseApi
.mockReturnValueOnce({
execute: mockAddWatchedItemApi,
error: null,
data: null,
loading: false,
isRefetching: false,
reset: vi.fn(),
})
.mockReturnValueOnce({
execute: mockRemoveWatchedItemApi,
error: null,
data: null,
loading: false,
isRefetching: false,
reset: vi.fn(),
});
// Provide a default implementation for the mocked hooks
mockedUseAuth.mockReturnValue({
userProfile: createMockUserProfile({ user: mockUser }),
authStatus: 'AUTHENTICATED',
isLoading: false,
login: vi.fn(),
logout: vi.fn(),
updateProfile: vi.fn(),
});
mockedUseUserData.mockReturnValue({
watchedItems: mockInitialItems,
setWatchedItems: mockSetWatchedItems,
shoppingLists: [],
setShoppingLists: vi.fn(),
isLoading: false,
error: null,
});
});
it('should initialize with the watched items from useData', () => {
const { result } = renderHook(() => useWatchedItems());
expect(result.current.watchedItems).toEqual(mockInitialItems);
expect(result.current.error).toBeNull();
});
it('should configure useApi with the correct apiClient methods', async () => {
renderHook(() => useWatchedItems());
// useApi is called twice: once for add, once for remove
const addApiCall = mockedUseApi.mock.calls[0][0];
const removeApiCall = mockedUseApi.mock.calls[1][0];
// Test the add callback
await addApiCall('New Item', 'Category');
expect(apiClient.addWatchedItem).toHaveBeenCalledWith('New Item', 'Category');
// Test the remove callback
await removeApiCall(123);
expect(apiClient.removeWatchedItem).toHaveBeenCalledWith(123);
});
describe('addWatchedItem', () => {
it('should call the API and update state on successful addition', async () => {
const newItem = createMockMasterGroceryItem({ master_grocery_item_id: 3, name: 'Cheese' });
mockAddWatchedItemApi.mockResolvedValue(newItem);
const { result } = renderHook(() => useWatchedItems());
await act(async () => {
await result.current.addWatchedItem('Cheese', 'Dairy');
});
expect(mockAddWatchedItemApi).toHaveBeenCalledWith('Cheese', 'Dairy');
// Check that the global state setter was called with an updater function
expect(mockSetWatchedItems).toHaveBeenCalledWith(expect.any(Function));
// To verify the logic inside the updater, we can call it directly
const updater = mockSetWatchedItems.mock.calls[0][0];
const newState = updater(mockInitialItems);
expect(newState).toHaveLength(3);
expect(newState).toContainEqual(newItem);
});
it('should set an error message if the API call fails', async () => {
// Clear existing mocks to set a specific sequence for this test
mockedUseApi.mockReset();
// Default fallback
mockedUseApi.mockReturnValue({
execute: vi.fn(),
error: null,
data: null,
loading: false,
isRefetching: false,
reset: vi.fn(),
});
// Mock the first call (add) to return an error immediately
mockedUseApi
.mockReturnValueOnce({
execute: mockAddWatchedItemApi,
error: new Error('API Error'),
data: null,
loading: false,
isRefetching: false,
reset: vi.fn(),
})
.mockReturnValueOnce({
execute: mockRemoveWatchedItemApi,
error: null,
data: null,
loading: false,
isRefetching: false,
reset: vi.fn(),
});
const { result } = renderHook(() => useWatchedItems());
await act(async () => {
await result.current.addWatchedItem('Failing Item', 'Error');
});
expect(result.current.error).toBe('API Error');
expect(mockSetWatchedItems).not.toHaveBeenCalled();
});
it('should not add duplicate items to the state', async () => {
// Item ID 1 ('Milk') already exists in mockInitialItems
const existingItem = createMockMasterGroceryItem({ master_grocery_item_id: 1, name: 'Milk' });
mockAddWatchedItemApi.mockResolvedValue(existingItem);
const { result } = renderHook(() => useWatchedItems());
await act(async () => {
await result.current.addWatchedItem('Milk', 'Dairy');
});
expect(mockAddWatchedItemApi).toHaveBeenCalledWith('Milk', 'Dairy');
// Get the updater function passed to setWatchedItems
const updater = mockSetWatchedItems.mock.calls[0][0];
const newState = updater(mockInitialItems);
// Should be unchanged
expect(newState).toEqual(mockInitialItems);
expect(newState).toHaveLength(2);
});
it('should sort items alphabetically by name when adding a new item', async () => {
const unsortedItems = [
createMockMasterGroceryItem({ master_grocery_item_id: 2, name: 'Zucchini' }),
createMockMasterGroceryItem({ master_grocery_item_id: 1, name: 'Apple' }),
];
const newItem = createMockMasterGroceryItem({ master_grocery_item_id: 3, name: 'Banana' });
mockAddWatchedItemApi.mockResolvedValue(newItem);
const { result } = renderHook(() => useWatchedItems());
await act(async () => {
await result.current.addWatchedItem('Banana', 'Fruit');
});
const updater = mockSetWatchedItems.mock.calls[0][0];
const newState = updater(unsortedItems);
expect(newState).toHaveLength(3);
expect(newState[0].name).toBe('Apple');
expect(newState[1].name).toBe('Banana');
expect(newState[2].name).toBe('Zucchini');
});
});
describe('removeWatchedItem', () => {
it('should call the API and update state on successful removal', async () => {
const itemIdToRemove = 1;
mockRemoveWatchedItemApi.mockResolvedValue(null); // Successful 204 returns null
const { result } = renderHook(() => useWatchedItems());
await act(async () => {
await result.current.removeWatchedItem(itemIdToRemove);
});
await waitFor(() => {
expect(mockRemoveWatchedItemApi).toHaveBeenCalledWith(itemIdToRemove);
});
expect(mockSetWatchedItems).toHaveBeenCalledWith(expect.any(Function));
// Verify the logic inside the updater function
const updater = mockSetWatchedItems.mock.calls[0][0];
const newState = updater(mockInitialItems);
expect(newState).toHaveLength(1);
expect(
newState.some((item: MasterGroceryItem) => item.master_grocery_item_id === itemIdToRemove),
).toBe(false);
});
it('should set an error message if the API call fails', async () => {
// Clear existing mocks
mockedUseApi.mockReset();
// Ensure the execute function returns null/undefined so the hook doesn't try to set state
mockAddWatchedItemApi.mockResolvedValue(null);
// Default fallback
mockedUseApi.mockReturnValue({
execute: vi.fn(),
error: null,
data: null,
loading: false,
isRefetching: false,
reset: vi.fn(),
});
// Mock sequence: 1st (add) success, 2nd (remove) error
mockedUseApi
.mockReturnValueOnce({
execute: vi.fn(),
error: null,
data: null,
loading: false,
isRefetching: false,
reset: vi.fn(),
})
.mockReturnValueOnce({
execute: vi.fn(),
error: new Error('Deletion Failed'),
data: null,
loading: false,
isRefetching: false,
reset: vi.fn(),
});
const { result } = renderHook(() => useWatchedItems());
await act(async () => {
await result.current.removeWatchedItem(999);
});
expect(result.current.error).toBe('Deletion Failed');
expect(mockSetWatchedItems).not.toHaveBeenCalled();
});
});
it('should not perform actions if the 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(() => useWatchedItems());
await act(async () => {
await result.current.addWatchedItem('Test', 'Category');
await result.current.removeWatchedItem(1);
});
expect(mockAddWatchedItemApi).not.toHaveBeenCalled();
expect(mockRemoveWatchedItemApi).not.toHaveBeenCalled();
});
});