// 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(); 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(); 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(); 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; const mockPromise = new Promise((resolve) => { resolvePromise = resolve; }); mockOnAddItem.mockImplementation(() => mockPromise); render(); 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(); 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(); 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(); 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(); 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(); // 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(); 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(); 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(); 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(); 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(); 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(); 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(); // 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(); expect(screen.queryByRole('button', { name: /sort items/i })).not.toBeInTheDocument(); }); }); });