All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 12m34s
405 lines
15 KiB
TypeScript
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('');
|
|
});
|
|
});
|
|
});
|