Files
flyer-crawler.projectium.com/src/features/shopping/ShoppingList.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

405 lines
15 KiB
TypeScript

// src/features/shopping/ShoppingList.test.tsx
import React from 'react';
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest';
import { ShoppingListComponent } from './ShoppingList'; // This path is now relative to the new folder
import type { ShoppingList } from '../../types';
import * as aiApiClient from '../../services/aiApiClient';
import {
createMockShoppingList,
createMockShoppingListItem,
createMockUser,
} from '../../tests/utils/mockFactories';
// The logger and aiApiClient are now mocked globally.
// Mock the AI API client (relative to new location)
// We will spy on the function directly in the test instead of mocking the whole module.
const mockUser = createMockUser({ user_id: 'user-123', email: 'test@example.com' });
const mockLists: ShoppingList[] = [
createMockShoppingList({
shopping_list_id: 1,
name: 'Weekly Groceries',
user_id: 'user-123',
items: [
createMockShoppingListItem({
shopping_list_item_id: 101,
shopping_list_id: 1,
master_item_id: 1,
custom_item_name: null,
master_item: { name: 'Apples' },
}),
createMockShoppingListItem({
shopping_list_item_id: 102,
shopping_list_id: 1,
master_item_id: null,
custom_item_name: 'Special Bread',
master_item: null,
}),
createMockShoppingListItem({
shopping_list_item_id: 103,
shopping_list_id: 1,
master_item_id: 2,
is_purchased: true,
custom_item_name: null,
master_item: { name: 'Milk' },
}),
createMockShoppingListItem({
shopping_list_item_id: 104,
shopping_list_id: 1,
master_item_id: null,
custom_item_name: null,
master_item: null,
}), // Item with no name
],
}),
createMockShoppingList({
shopping_list_id: 2,
name: 'Party Supplies',
user_id: 'user-123',
items: [],
}),
];
describe('ShoppingListComponent (in shopping feature)', () => {
const mockOnSelectList = vi.fn();
const mockOnCreateList = vi.fn();
const mockOnDeleteList = vi.fn();
const mockOnAddItem = vi.fn();
const mockOnUpdateItem = vi.fn();
const mockOnRemoveItem = vi.fn();
const defaultProps = {
user: mockUser,
lists: mockLists,
activeListId: 1,
onSelectList: mockOnSelectList,
onCreateList: mockOnCreateList,
onDeleteList: mockOnDeleteList,
onAddItem: mockOnAddItem,
onUpdateItem: mockOnUpdateItem,
onRemoveItem: mockOnRemoveItem,
};
beforeEach(() => {
vi.clearAllMocks();
// Mock browser APIs
window.prompt = vi.fn();
window.confirm = vi.fn();
window.AudioContext = vi.fn().mockImplementation(() => ({
decodeAudioData: vi.fn().mockResolvedValue({}),
createBufferSource: vi.fn().mockReturnValue({
connect: vi.fn(),
start: vi.fn(),
buffer: {},
}),
close: vi.fn(),
sampleRate: 44100,
}));
});
// Restore all mocks after each test to ensure test isolation.
afterEach(() => {
vi.restoreAllMocks();
});
it('should render a login message when user is not authenticated', () => {
render(<ShoppingListComponent {...defaultProps} user={null} />);
expect(screen.getByText(/please log in to manage your shopping lists/i)).toBeInTheDocument();
});
it('should render correctly when authenticated with an active list', () => {
render(<ShoppingListComponent {...defaultProps} />);
expect(screen.getByRole('heading', { name: /shopping list/i })).toBeInTheDocument();
expect(screen.getByRole('combobox')).toHaveValue('1');
expect(screen.getByText('Apples')).toBeInTheDocument();
expect(screen.getByText('Special Bread')).toBeInTheDocument();
expect(screen.getByText('Milk')).toBeInTheDocument(); // Purchased item
expect(screen.getByRole('heading', { name: /purchased/i })).toBeInTheDocument();
});
it('should display a message if there are no lists', () => {
render(<ShoppingListComponent {...defaultProps} lists={[]} activeListId={null} />);
expect(screen.getByText(/no shopping lists found/i)).toBeInTheDocument();
});
it('should call onSelectList when changing the list in the dropdown', () => {
render(<ShoppingListComponent {...defaultProps} />);
fireEvent.change(screen.getByRole('combobox'), { target: { value: '2' } });
expect(mockOnSelectList).toHaveBeenCalledWith(2);
});
it('should call onCreateList when creating a new list', async () => {
(window.prompt as Mock).mockReturnValue('New List Name');
render(<ShoppingListComponent {...defaultProps} />);
fireEvent.click(screen.getByRole('button', { name: /new list/i }));
await waitFor(() => {
expect(mockOnCreateList).toHaveBeenCalledWith('New List Name');
});
});
it('should not call onCreateList if prompt is cancelled', async () => {
(window.prompt as Mock).mockReturnValue(null);
render(<ShoppingListComponent {...defaultProps} />);
fireEvent.click(screen.getByRole('button', { name: /new list/i }));
await waitFor(() => {
expect(mockOnCreateList).not.toHaveBeenCalled();
});
});
it('should call onDeleteList when deleting a list after confirmation', async () => {
(window.confirm as Mock).mockReturnValue(true);
render(<ShoppingListComponent {...defaultProps} />);
fireEvent.click(screen.getByRole('button', { name: /delete list/i }));
await waitFor(() => {
expect(window.confirm).toHaveBeenCalled();
expect(mockOnDeleteList).toHaveBeenCalledWith(1);
});
});
it('should not call onDeleteList if deletion is not confirmed', async () => {
(window.confirm as Mock).mockReturnValue(false);
render(<ShoppingListComponent {...defaultProps} />);
fireEvent.click(screen.getByRole('button', { name: /delete list/i }));
await waitFor(() => {
expect(window.confirm).toHaveBeenCalled();
expect(mockOnDeleteList).not.toHaveBeenCalled();
});
});
it('should call onAddItem when adding a custom item', async () => {
render(<ShoppingListComponent {...defaultProps} />);
const input = screen.getByPlaceholderText(/add a custom item/i);
const addButton = screen.getByRole('button', { name: 'Add' });
fireEvent.change(input, { target: { value: 'New Custom Item' } });
fireEvent.click(addButton);
// Assert that the callback was called with the correct value.
expect(mockOnAddItem).toHaveBeenCalledWith({ customItemName: 'New Custom Item' });
// Also, verify that the input field was cleared after submission.
await waitFor(() => {
expect(input).toHaveValue('');
});
});
it('should call onUpdateItem when toggling an item checkbox', () => {
render(<ShoppingListComponent {...defaultProps} />);
const checkboxes = screen.getAllByRole('checkbox');
const appleCheckbox = checkboxes[0]; // 'Apples' is the first item
fireEvent.click(appleCheckbox);
expect(mockOnUpdateItem).toHaveBeenCalledWith(101, { is_purchased: true });
});
it('should call onRemoveItem when clicking the remove button', () => {
render(<ShoppingListComponent {...defaultProps} />);
// Items are in a div, not listitem. We find the button near the item text.
const appleItem = screen.getByText('Apples').closest('div');
const removeButton = appleItem!.querySelector('button')!;
fireEvent.click(removeButton);
expect(mockOnRemoveItem).toHaveBeenCalledWith(101);
});
it('should call generateSpeechFromText when "Read aloud" is clicked', async () => {
const generateSpeechSpy = vi.spyOn(aiApiClient, 'generateSpeechFromText').mockResolvedValue({
json: () => Promise.resolve('base64-audio-string'),
} as Response);
render(<ShoppingListComponent {...defaultProps} />);
const readAloudButton = screen.getByTitle(/read list aloud/i);
fireEvent.click(readAloudButton);
await waitFor(() => {
expect(generateSpeechSpy).toHaveBeenCalledWith(
'Here is your shopping list: Apples, Special Bread',
);
});
});
it('should show an alert if reading aloud fails', async () => {
const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {});
vi.spyOn(aiApiClient, 'generateSpeechFromText').mockRejectedValue(new Error('AI API is down'));
render(<ShoppingListComponent {...defaultProps} />);
const readAloudButton = screen.getByTitle(/read list aloud/i);
fireEvent.click(readAloudButton);
await waitFor(() => {
expect(alertSpy).toHaveBeenCalledWith('Could not read list aloud: AI API is down');
});
alertSpy.mockRestore();
});
it('should show a generic alert if reading aloud fails with a non-Error object', async () => {
const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {});
vi.spyOn(aiApiClient, 'generateSpeechFromText').mockRejectedValue('A string error');
render(<ShoppingListComponent {...defaultProps} />);
const readAloudButton = screen.getByTitle(/read list aloud/i);
fireEvent.click(readAloudButton);
await waitFor(() => {
expect(alertSpy).toHaveBeenCalledWith(
'Could not read list aloud: An unknown error occurred while generating audio.',
);
});
alertSpy.mockRestore();
});
it('should handle interactions with purchased items', () => {
render(<ShoppingListComponent {...defaultProps} />);
// Find the purchased item 'Milk'
const milkItem = screen.getByText('Milk').closest('div');
expect(milkItem).toBeInTheDocument();
// Test un-checking the item
const checkbox = milkItem!.querySelector('input[type="checkbox"]');
expect(checkbox).toBeChecked();
fireEvent.click(checkbox!);
expect(mockOnUpdateItem).toHaveBeenCalledWith(103, { is_purchased: false });
// Test removing the item
const removeButton = milkItem!.querySelector('button');
expect(removeButton).toBeInTheDocument();
fireEvent.click(removeButton!);
expect(mockOnRemoveItem).toHaveBeenCalledWith(103);
});
describe('Loading States and Disabled States', () => {
it('should disable the "Add" button for custom items when input is empty or whitespace', () => {
render(<ShoppingListComponent {...defaultProps} />);
const input = screen.getByPlaceholderText(/add a custom item/i);
const addButton = screen.getByRole('button', { name: 'Add' });
expect(addButton).toBeDisabled();
fireEvent.change(input, { target: { value: ' ' } });
expect(addButton).toBeDisabled();
fireEvent.change(input, { target: { value: 'Something' } });
expect(addButton).toBeEnabled();
});
it('should show a loading spinner while adding a custom item', async () => {
let resolvePromise: (value: void | PromiseLike<void>) => void;
const mockPromise = new Promise<void>((resolve) => {
resolvePromise = resolve;
});
mockOnAddItem.mockReturnValue(mockPromise);
render(<ShoppingListComponent {...defaultProps} />);
const input = screen.getByPlaceholderText(/add a custom item/i);
const addButton = screen.getByRole('button', { name: 'Add' });
fireEvent.change(input, { target: { value: 'Loading Item' } });
fireEvent.click(addButton);
await waitFor(() => {
expect(addButton).toBeDisabled();
expect(addButton.querySelector('.animate-spin')).toBeInTheDocument();
});
// Resolve promise to avoid test warnings
await act(async () => {
resolvePromise();
await mockPromise;
});
});
it('should show a loading spinner while creating a new list', async () => {
let resolvePromise: (value: void | PromiseLike<void>) => void;
const mockPromise = new Promise<void>((resolve) => {
resolvePromise = resolve;
});
mockOnCreateList.mockReturnValue(mockPromise);
(window.prompt as Mock).mockReturnValue('New List');
render(<ShoppingListComponent {...defaultProps} />);
const newListButton = screen.getByRole('button', { name: /new list/i });
fireEvent.click(newListButton);
await waitFor(() => {
expect(newListButton).toBeDisabled();
});
// Resolve promise to avoid test warnings
await act(async () => {
resolvePromise();
await mockPromise;
});
});
it('should show a loading spinner while reading the list aloud', async () => {
let resolvePromise: (value: Response | PromiseLike<Response>) => void;
const mockPromise = new Promise<Response>((resolve) => {
resolvePromise = resolve;
});
vi.spyOn(aiApiClient, 'generateSpeechFromText').mockReturnValue(mockPromise);
render(<ShoppingListComponent {...defaultProps} />);
const readAloudButton = screen.getByTitle(/read list aloud/i);
fireEvent.click(readAloudButton);
await waitFor(() => {
expect(readAloudButton).toBeDisabled();
expect(readAloudButton.querySelector('.animate-spin')).toBeInTheDocument();
});
// Resolve promise to avoid test warnings
await act(async () => {
resolvePromise({ json: () => Promise.resolve('audio') } as Response);
await mockPromise;
});
});
it('should disable the "Read aloud" button if there are no items to read', () => {
const listWithOnlyPurchasedItems: ShoppingList[] = [
createMockShoppingList({
shopping_list_id: 1,
name: 'Weekly Groceries',
user_id: 'user-123',
items: [mockLists[0].items[2]], // Only the purchased 'Milk' item
}),
];
render(<ShoppingListComponent {...defaultProps} lists={listWithOnlyPurchasedItems} />);
expect(screen.getByTitle(/read list aloud/i)).toBeDisabled();
});
});
describe('UI Edge Cases', () => {
it('should not call onCreateList if the prompt returns an empty or whitespace string', async () => {
(window.prompt as Mock).mockReturnValue(' ');
render(<ShoppingListComponent {...defaultProps} />);
fireEvent.click(screen.getByRole('button', { name: /new list/i }));
await waitFor(() => {
expect(mockOnCreateList).not.toHaveBeenCalled();
});
});
it('should display a message for an active list with no items', () => {
render(<ShoppingListComponent {...defaultProps} activeListId={2} />); // Party Supplies list is empty
expect(screen.getByText('This list is empty.')).toBeInTheDocument();
});
it('should render an item gracefully if it has no custom name or master item name', () => {
render(<ShoppingListComponent {...defaultProps} />);
// The item with ID 104 has no name. We find its checkbox to confirm it rendered.
const checkboxes = screen.getAllByRole('checkbox');
// Apples, Special Bread, Nameless Item, Milk (purchased)
expect(checkboxes).toHaveLength(4);
const namelessItemCheckbox = checkboxes[2];
// The span next to it should be empty
expect(namelessItemCheckbox.nextElementSibling).toHaveTextContent('');
});
});
});