All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 12m34s
297 lines
12 KiB
TypeScript
297 lines
12 KiB
TypeScript
// src/features/shopping/WatchedItemsList.test.tsx
|
|
import React from 'react';
|
|
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import { WatchedItemsList } from './WatchedItemsList';
|
|
import type { MasterGroceryItem } from '../../types';
|
|
import { logger } from '../../services/logger.client';
|
|
import { createMockMasterGroceryItem, createMockUser } from '../../tests/utils/mockFactories';
|
|
|
|
// Mock the logger to spy on error calls
|
|
vi.mock('../../services/logger.client');
|
|
|
|
const mockUser = createMockUser({ user_id: 'user-123', email: 'test@example.com' });
|
|
|
|
const mockItems: MasterGroceryItem[] = [
|
|
createMockMasterGroceryItem({
|
|
master_grocery_item_id: 1,
|
|
name: 'Apples',
|
|
category_id: 1,
|
|
category_name: 'Produce',
|
|
}),
|
|
createMockMasterGroceryItem({
|
|
master_grocery_item_id: 2,
|
|
name: 'Milk',
|
|
category_id: 2,
|
|
category_name: 'Dairy',
|
|
}),
|
|
createMockMasterGroceryItem({
|
|
master_grocery_item_id: 3,
|
|
name: 'Bread',
|
|
category_id: 3,
|
|
category_name: 'Bakery',
|
|
}),
|
|
createMockMasterGroceryItem({
|
|
master_grocery_item_id: 4,
|
|
name: 'Eggs',
|
|
category_id: 2,
|
|
category_name: 'Dairy',
|
|
}),
|
|
];
|
|
|
|
const mockOnAddItem = vi.fn();
|
|
const mockOnRemoveItem = vi.fn();
|
|
const mockOnAddItemToList = vi.fn();
|
|
|
|
const defaultProps = {
|
|
items: mockItems,
|
|
onAddItem: mockOnAddItem,
|
|
onRemoveItem: mockOnRemoveItem,
|
|
user: mockUser,
|
|
activeListId: 1,
|
|
onAddItemToList: mockOnAddItemToList,
|
|
};
|
|
|
|
describe('WatchedItemsList (in shopping feature)', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mockOnAddItem.mockResolvedValue(undefined);
|
|
mockOnRemoveItem.mockResolvedValue(undefined);
|
|
});
|
|
|
|
it('should render a login message when user is not authenticated', () => {
|
|
render(<WatchedItemsList {...defaultProps} user={null} />);
|
|
expect(
|
|
screen.getByText(/please log in to create and manage your personal watchlist/i),
|
|
).toBeInTheDocument();
|
|
expect(screen.queryByRole('form')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('should render the form and item list when user is authenticated', () => {
|
|
render(<WatchedItemsList {...defaultProps} />);
|
|
expect(screen.getByPlaceholderText(/add item/i)).toBeInTheDocument();
|
|
expect(screen.getByRole('combobox', { name: /filter by category/i })).toBeInTheDocument();
|
|
expect(screen.getByText('Apples')).toBeInTheDocument();
|
|
expect(screen.getByText('Milk')).toBeInTheDocument();
|
|
expect(screen.getByText('Bread')).toBeInTheDocument();
|
|
});
|
|
|
|
it('should allow adding a new item', async () => {
|
|
render(<WatchedItemsList {...defaultProps} />);
|
|
|
|
fireEvent.change(screen.getByPlaceholderText(/add item/i), { target: { value: 'Cheese' } });
|
|
// Use getByDisplayValue to reliably select the category dropdown, which has no label.
|
|
// Also, use the correct category name from the CATEGORIES constant.
|
|
const categorySelect = screen.getByDisplayValue('Select a category');
|
|
fireEvent.change(categorySelect, { target: { value: 'Dairy & Eggs' } });
|
|
|
|
fireEvent.submit(screen.getByRole('button', { name: 'Add' }));
|
|
|
|
await waitFor(() => {
|
|
expect(mockOnAddItem).toHaveBeenCalledWith('Cheese', 'Dairy & Eggs');
|
|
});
|
|
|
|
// Check if form resets
|
|
expect(screen.getByPlaceholderText(/add item/i)).toHaveValue('');
|
|
});
|
|
|
|
it('should show a loading spinner while adding an item', async () => {
|
|
// Create a promise that we can resolve manually to control the loading state
|
|
let resolvePromise: (value: void | PromiseLike<void>) => void;
|
|
const mockPromise = new Promise<void>((resolve) => {
|
|
resolvePromise = resolve;
|
|
});
|
|
mockOnAddItem.mockImplementation(() => mockPromise);
|
|
|
|
render(<WatchedItemsList {...defaultProps} />);
|
|
|
|
fireEvent.change(screen.getByPlaceholderText(/add item/i), { target: { value: 'Cheese' } });
|
|
fireEvent.change(screen.getByDisplayValue('Select a category'), {
|
|
target: { value: 'Dairy & Eggs' },
|
|
});
|
|
const addButton = screen.getByRole('button', { name: 'Add' });
|
|
fireEvent.click(addButton);
|
|
|
|
// The button text is replaced by the spinner, so we use the captured reference
|
|
await waitFor(() => {
|
|
expect(addButton).toBeDisabled();
|
|
});
|
|
expect(addButton.querySelector('.animate-spin')).toBeInTheDocument();
|
|
|
|
// Resolve the promise to complete the async operation and allow the test to finish
|
|
await act(async () => {
|
|
resolvePromise();
|
|
await mockPromise;
|
|
});
|
|
});
|
|
|
|
it('should allow removing an item', async () => {
|
|
render(<WatchedItemsList {...defaultProps} />);
|
|
const removeButton = screen.getByRole('button', { name: /remove apples/i });
|
|
fireEvent.click(removeButton);
|
|
|
|
await waitFor(() => {
|
|
expect(mockOnRemoveItem).toHaveBeenCalledWith(1); // ID for Apples is 1
|
|
});
|
|
});
|
|
|
|
it('should filter items by category', () => {
|
|
render(<WatchedItemsList {...defaultProps} />);
|
|
const categoryFilter = screen.getByRole('combobox', { name: /filter by category/i });
|
|
|
|
fireEvent.change(categoryFilter, { target: { value: 'Dairy' } });
|
|
|
|
expect(screen.getByText('Milk')).toBeInTheDocument();
|
|
expect(screen.queryByText('Apples')).not.toBeInTheDocument();
|
|
expect(screen.queryByText('Bread')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('should sort items ascending and descending', () => {
|
|
render(<WatchedItemsList {...defaultProps} />);
|
|
const sortButton = screen.getByRole('button', { name: /sort items descending/i });
|
|
|
|
const itemsAsc = screen.getAllByRole('listitem');
|
|
expect(itemsAsc[0]).toHaveTextContent('Apples');
|
|
expect(itemsAsc[1]).toHaveTextContent('Bread'); // This was a duplicate, fixed.
|
|
expect(itemsAsc[2]).toHaveTextContent('Eggs');
|
|
expect(itemsAsc[3]).toHaveTextContent('Milk');
|
|
|
|
// Click to sort descending
|
|
fireEvent.click(sortButton);
|
|
|
|
const itemsDesc = screen.getAllByRole('listitem');
|
|
expect(itemsDesc[0]).toHaveTextContent('Milk');
|
|
expect(itemsDesc[1]).toHaveTextContent('Eggs');
|
|
expect(itemsDesc[2]).toHaveTextContent('Bread');
|
|
expect(itemsDesc[3]).toHaveTextContent('Apples');
|
|
|
|
// Click again to sort ascending
|
|
fireEvent.click(sortButton);
|
|
|
|
const itemsAscAgain = screen.getAllByRole('listitem');
|
|
expect(itemsAscAgain[0]).toHaveTextContent('Apples');
|
|
expect(itemsAscAgain[1]).toHaveTextContent('Bread');
|
|
expect(itemsAscAgain[2]).toHaveTextContent('Eggs');
|
|
expect(itemsAscAgain[3]).toHaveTextContent('Milk');
|
|
});
|
|
|
|
it('should call onAddItemToList when plus icon is clicked', () => {
|
|
render(<WatchedItemsList {...defaultProps} />);
|
|
const addToListButton = screen.getByTitle('Add Apples to list');
|
|
fireEvent.click(addToListButton);
|
|
expect(mockOnAddItemToList).toHaveBeenCalledWith(1); // ID for Apples
|
|
});
|
|
|
|
it('should disable the add to list button if activeListId is null', () => {
|
|
render(<WatchedItemsList {...defaultProps} activeListId={null} />);
|
|
// Multiple buttons will have this title, so we must use `getAllByTitle`.
|
|
const addToListButtons = screen.getAllByTitle('Select a shopping list first');
|
|
// Assert that at least one such button exists and that they are all disabled.
|
|
expect(addToListButtons.length).toBeGreaterThan(0);
|
|
addToListButtons.forEach((button) => expect(button).toBeDisabled());
|
|
});
|
|
|
|
it('should display a message when the list is empty', () => {
|
|
render(<WatchedItemsList {...defaultProps} items={[]} />);
|
|
expect(screen.getByText(/your watchlist is empty/i)).toBeInTheDocument();
|
|
});
|
|
|
|
describe('Form Validation and Disabled States', () => {
|
|
it('should disable the "Add" button if item name is empty or whitespace', () => {
|
|
render(<WatchedItemsList {...defaultProps} />);
|
|
const nameInput = screen.getByPlaceholderText(/add item/i);
|
|
const categorySelect = screen.getByDisplayValue('Select a category');
|
|
const addButton = screen.getByRole('button', { name: 'Add' });
|
|
|
|
// Initially disabled
|
|
expect(addButton).toBeDisabled();
|
|
|
|
// With category but no name
|
|
fireEvent.change(categorySelect, { target: { value: 'Fruits & Vegetables' } });
|
|
expect(addButton).toBeDisabled();
|
|
|
|
// With whitespace name
|
|
fireEvent.change(nameInput, { target: { value: ' ' } });
|
|
expect(addButton).toBeDisabled();
|
|
|
|
// With valid name
|
|
fireEvent.change(nameInput, { target: { value: 'Grapes' } });
|
|
expect(addButton).toBeEnabled();
|
|
});
|
|
|
|
it('should disable the "Add" button if category is not selected', () => {
|
|
render(<WatchedItemsList {...defaultProps} />);
|
|
const nameInput = screen.getByPlaceholderText(/add item/i);
|
|
const addButton = screen.getByRole('button', { name: 'Add' });
|
|
|
|
// Initially disabled
|
|
expect(addButton).toBeDisabled();
|
|
|
|
// With name but no category
|
|
fireEvent.change(nameInput, { target: { value: 'Grapes' } });
|
|
expect(addButton).toBeDisabled();
|
|
});
|
|
|
|
it('should not submit if form is submitted with invalid data', () => {
|
|
render(<WatchedItemsList {...defaultProps} />);
|
|
const nameInput = screen.getByPlaceholderText(/add item/i);
|
|
const form = nameInput.closest('form')!;
|
|
const categorySelect = screen.getByDisplayValue('Select a category');
|
|
fireEvent.change(categorySelect, { target: { value: 'Dairy & Eggs' } });
|
|
|
|
fireEvent.change(nameInput, { target: { value: ' ' } });
|
|
fireEvent.submit(form);
|
|
expect(mockOnAddItem).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('Error Handling', () => {
|
|
it('should reset loading state and log an error if onAddItem rejects', async () => {
|
|
const apiError = new Error('Item already exists');
|
|
mockOnAddItem.mockRejectedValue(apiError);
|
|
const loggerSpy = vi.spyOn(logger, 'error');
|
|
|
|
render(<WatchedItemsList {...defaultProps} />);
|
|
|
|
const nameInput = screen.getByPlaceholderText(/add item/i);
|
|
const categorySelect = screen.getByDisplayValue('Select a category');
|
|
const addButton = screen.getByRole('button', { name: 'Add' });
|
|
|
|
fireEvent.change(nameInput, { target: { value: 'Duplicate Item' } });
|
|
fireEvent.change(categorySelect, { target: { value: 'Fruits & Vegetables' } });
|
|
fireEvent.click(addButton);
|
|
|
|
// After the promise rejects, the button should be enabled again
|
|
await waitFor(() => expect(addButton).toBeEnabled());
|
|
|
|
// And the error should be logged
|
|
expect(loggerSpy).toHaveBeenCalledWith('Failed to add watched item from WatchedItemsList', {
|
|
error: apiError,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('UI Edge Cases', () => {
|
|
it('should display a specific message when a filter results in no items', () => {
|
|
const { rerender } = render(<WatchedItemsList {...defaultProps} />);
|
|
const categoryFilter = screen.getByRole('combobox', { name: /filter by category/i });
|
|
|
|
// Select 'Produce' - 'Apples' should be visible
|
|
fireEvent.change(categoryFilter, { target: { value: 'Produce' } });
|
|
expect(screen.getByText('Apples')).toBeInTheDocument();
|
|
|
|
// Rerender with the 'Produce' item removed, but keep the filter active
|
|
const itemsWithoutProduce = mockItems.filter((item) => item.category_name !== 'Produce');
|
|
rerender(<WatchedItemsList {...defaultProps} items={itemsWithoutProduce} />);
|
|
|
|
// Now the message should appear for the 'Produce' category
|
|
expect(screen.getByText('No watched items in the "Produce" category.')).toBeInTheDocument();
|
|
});
|
|
|
|
it('should hide the sort button if there is only one item', () => {
|
|
render(<WatchedItemsList {...defaultProps} items={[mockItems[0]]} />);
|
|
expect(screen.queryByRole('button', { name: /sort items/i })).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|