All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 19m58s
321 lines
10 KiB
TypeScript
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();
|
|
});
|
|
});
|