Files
flyer-crawler.projectium.com/src/features/shopping/WatchedItemsList.test.tsx
Torben Sorensen 21a6a796cf
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 12m34s
fix some uploading flyer issues + more unit tests
2025-12-29 23:23:27 -08:00

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();
});
});
});