All checks were successful
Basil CI/CD Pipeline / Code Linting (push) Successful in 58s
Basil CI/CD Pipeline / Web Tests (push) Successful in 1m9s
Basil CI/CD Pipeline / API Tests (push) Successful in 1m27s
Basil CI/CD Pipeline / Shared Package Tests (push) Successful in 1m1s
Basil CI/CD Pipeline / E2E Tests (push) Has been skipped
Basil CI/CD Pipeline / Security Scanning (push) Successful in 1m8s
Basil CI/CD Pipeline / Build All Packages (push) Successful in 1m32s
Basil CI/CD Pipeline / Build & Push Docker Images (push) Successful in 8m1s
Basil CI/CD Pipeline / Trigger Deployment (push) Successful in 10s
Fix failing tests after UI improvements in previous commit: - Remove size slider tests (feature removed from UI) - Remove search type toggle tests (unified search replaces title/tag toggle) - Update search placeholder to match new unified search input - Update URL param tests for unified search behavior All 27 tests now passing. Resolves test failures from task #343. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
619 lines
16 KiB
TypeScript
619 lines
16 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
|
import userEvent from '@testing-library/user-event';
|
|
import { MemoryRouter } from 'react-router-dom';
|
|
import RecipeList from './RecipeList';
|
|
import { recipesApi, tagsApi } from '../services/api';
|
|
|
|
// Mock the API services
|
|
vi.mock('../services/api', () => ({
|
|
recipesApi: {
|
|
getAll: vi.fn(),
|
|
},
|
|
tagsApi: {
|
|
getAll: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
// Mock useNavigate
|
|
const mockNavigate = vi.fn();
|
|
vi.mock('react-router-dom', async () => {
|
|
const actual = await vi.importActual('react-router-dom');
|
|
return {
|
|
...actual,
|
|
useNavigate: () => mockNavigate,
|
|
};
|
|
});
|
|
|
|
// Helper to create mock recipes
|
|
const createMockRecipes = (count: number, startId: number = 1) => {
|
|
return Array.from({ length: count }, (_, i) => ({
|
|
id: String(startId + i),
|
|
title: `Recipe ${startId + i}`,
|
|
description: `Description for recipe ${startId + i}`,
|
|
totalTime: 30 + i * 5,
|
|
servings: 4,
|
|
imageUrl: `/uploads/recipes/recipe-${startId + i}.jpg`,
|
|
tags: [],
|
|
}));
|
|
};
|
|
|
|
// Helper to render with router
|
|
const renderWithRouter = (
|
|
component: React.ReactElement,
|
|
initialEntries: string[] = ['/recipes']
|
|
) => {
|
|
return render(
|
|
<MemoryRouter initialEntries={initialEntries}>{component}</MemoryRouter>
|
|
);
|
|
};
|
|
|
|
// Mock window.scrollTo
|
|
window.scrollTo = vi.fn();
|
|
|
|
describe('RecipeList Component', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
// Reset localStorage mock
|
|
Storage.prototype.getItem = vi.fn(() => null);
|
|
Storage.prototype.setItem = vi.fn();
|
|
Storage.prototype.removeItem = vi.fn();
|
|
|
|
vi.mocked(tagsApi.getAll).mockResolvedValue({
|
|
data: [
|
|
{ id: '1', name: 'Italian' },
|
|
{ id: '2', name: 'Dessert' },
|
|
{ id: '3', name: 'Quick' },
|
|
],
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
describe('Loading and Error States', () => {
|
|
it('should show loading state initially', () => {
|
|
vi.mocked(recipesApi.getAll).mockImplementation(
|
|
() => new Promise(() => {}) // Never resolves
|
|
);
|
|
|
|
renderWithRouter(<RecipeList />);
|
|
|
|
expect(screen.getByText('Loading recipes...')).toBeInTheDocument();
|
|
});
|
|
|
|
it('should display error message on API failure', async () => {
|
|
vi.mocked(recipesApi.getAll).mockRejectedValue(new Error('Network error'));
|
|
|
|
renderWithRouter(<RecipeList />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Failed to load recipes')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('should display empty state when no recipes', async () => {
|
|
vi.mocked(recipesApi.getAll).mockResolvedValue({
|
|
data: [],
|
|
total: 0,
|
|
page: 1,
|
|
pageSize: 24,
|
|
});
|
|
|
|
renderWithRouter(<RecipeList />);
|
|
|
|
await waitFor(() => {
|
|
expect(
|
|
screen.getByText(/No recipes yet. Import one from a URL or create your own!/)
|
|
).toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Recipe Display', () => {
|
|
it('should display recipes after loading', async () => {
|
|
const mockRecipes = createMockRecipes(2);
|
|
|
|
vi.mocked(recipesApi.getAll).mockResolvedValue({
|
|
data: mockRecipes as any,
|
|
total: 2,
|
|
page: 1,
|
|
pageSize: 24,
|
|
});
|
|
|
|
renderWithRouter(<RecipeList />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Recipe 1')).toBeInTheDocument();
|
|
expect(screen.getByText('Recipe 2')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('should display recipe metadata when available', async () => {
|
|
const mockRecipes = [
|
|
{
|
|
id: '1',
|
|
title: 'Recipe with Metadata',
|
|
totalTime: 45,
|
|
servings: 6,
|
|
},
|
|
];
|
|
|
|
vi.mocked(recipesApi.getAll).mockResolvedValue({
|
|
data: mockRecipes as any,
|
|
total: 1,
|
|
page: 1,
|
|
pageSize: 24,
|
|
});
|
|
|
|
renderWithRouter(<RecipeList />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('45 min')).toBeInTheDocument();
|
|
expect(screen.getByText('6 servings')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('should truncate long descriptions', async () => {
|
|
const longDescription = 'A'.repeat(150);
|
|
const mockRecipes = [
|
|
{
|
|
id: '1',
|
|
title: 'Recipe with Long Description',
|
|
description: longDescription,
|
|
},
|
|
];
|
|
|
|
vi.mocked(recipesApi.getAll).mockResolvedValue({
|
|
data: mockRecipes as any,
|
|
total: 1,
|
|
page: 1,
|
|
pageSize: 24,
|
|
});
|
|
|
|
renderWithRouter(<RecipeList />);
|
|
|
|
await waitFor(() => {
|
|
const description = screen.getByText(/^A{100}\.\.\.$/);
|
|
expect(description).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('should navigate to recipe detail when card is clicked', async () => {
|
|
const mockRecipes = [{ id: '1', title: 'Test Recipe' }];
|
|
|
|
vi.mocked(recipesApi.getAll).mockResolvedValue({
|
|
data: mockRecipes as any,
|
|
total: 1,
|
|
page: 1,
|
|
pageSize: 24,
|
|
});
|
|
|
|
renderWithRouter(<RecipeList />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Test Recipe')).toBeInTheDocument();
|
|
});
|
|
|
|
const recipeCard = screen.getByText('Test Recipe').closest('.recipe-card');
|
|
recipeCard?.click();
|
|
|
|
expect(mockNavigate).toHaveBeenCalledWith('/recipes/1');
|
|
});
|
|
});
|
|
|
|
describe('Pagination Controls', () => {
|
|
it('should render pagination controls', async () => {
|
|
vi.mocked(recipesApi.getAll).mockResolvedValue({
|
|
data: createMockRecipes(24) as any,
|
|
total: 100,
|
|
page: 1,
|
|
pageSize: 24,
|
|
});
|
|
|
|
renderWithRouter(<RecipeList />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Per page:')).toBeInTheDocument();
|
|
expect(screen.getByText('12')).toBeInTheDocument();
|
|
expect(screen.getByText('24')).toBeInTheDocument();
|
|
expect(screen.getByText('48')).toBeInTheDocument();
|
|
expect(screen.getByText('All')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('should display page info', async () => {
|
|
vi.mocked(recipesApi.getAll).mockResolvedValue({
|
|
data: createMockRecipes(24) as any,
|
|
total: 100,
|
|
page: 1,
|
|
pageSize: 24,
|
|
});
|
|
|
|
renderWithRouter(<RecipeList />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Page 1 of 5')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('should change items per page when button clicked', async () => {
|
|
vi.mocked(recipesApi.getAll).mockResolvedValue({
|
|
data: createMockRecipes(12) as any,
|
|
total: 100,
|
|
page: 1,
|
|
pageSize: 12,
|
|
});
|
|
|
|
renderWithRouter(<RecipeList />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Recipe 1')).toBeInTheDocument();
|
|
});
|
|
|
|
const button12 = screen.getByRole('button', { name: '12' });
|
|
await userEvent.click(button12);
|
|
|
|
await waitFor(() => {
|
|
expect(recipesApi.getAll).toHaveBeenCalledWith(
|
|
expect.objectContaining({ limit: 12 })
|
|
);
|
|
});
|
|
});
|
|
|
|
it('should navigate to next page', async () => {
|
|
vi.mocked(recipesApi.getAll).mockResolvedValue({
|
|
data: createMockRecipes(24) as any,
|
|
total: 100,
|
|
page: 1,
|
|
pageSize: 24,
|
|
});
|
|
|
|
renderWithRouter(<RecipeList />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Page 1 of 5')).toBeInTheDocument();
|
|
});
|
|
|
|
const nextButton = screen.getByRole('button', { name: 'Next' });
|
|
await userEvent.click(nextButton);
|
|
|
|
await waitFor(() => {
|
|
expect(recipesApi.getAll).toHaveBeenCalledWith(
|
|
expect.objectContaining({ page: 2 })
|
|
);
|
|
});
|
|
});
|
|
|
|
it('should disable Prev button on first page', async () => {
|
|
vi.mocked(recipesApi.getAll).mockResolvedValue({
|
|
data: createMockRecipes(24) as any,
|
|
total: 100,
|
|
page: 1,
|
|
pageSize: 24,
|
|
});
|
|
|
|
renderWithRouter(<RecipeList />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Recipe 1')).toBeInTheDocument();
|
|
});
|
|
|
|
const prevButton = screen.getByRole('button', { name: 'Prev' });
|
|
expect(prevButton).toBeDisabled();
|
|
});
|
|
|
|
it('should load all recipes when "All" is selected', async () => {
|
|
vi.mocked(recipesApi.getAll).mockResolvedValue({
|
|
data: createMockRecipes(50) as any,
|
|
total: 50,
|
|
page: 1,
|
|
pageSize: 10000,
|
|
});
|
|
|
|
renderWithRouter(<RecipeList />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Recipe 1')).toBeInTheDocument();
|
|
});
|
|
|
|
const allButton = screen.getByRole('button', { name: 'All' });
|
|
await userEvent.click(allButton);
|
|
|
|
await waitFor(() => {
|
|
expect(recipesApi.getAll).toHaveBeenCalledWith(
|
|
expect.objectContaining({ limit: 10000 })
|
|
);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Column Controls', () => {
|
|
it('should render column control buttons', async () => {
|
|
vi.mocked(recipesApi.getAll).mockResolvedValue({
|
|
data: createMockRecipes(3) as any,
|
|
total: 3,
|
|
page: 1,
|
|
pageSize: 24,
|
|
});
|
|
|
|
renderWithRouter(<RecipeList />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Columns:')).toBeInTheDocument();
|
|
});
|
|
|
|
// Find column buttons by their parent container
|
|
const columnButtons = screen.getAllByRole('button').filter(btn =>
|
|
['3', '5', '7', '9'].includes(btn.textContent || '')
|
|
);
|
|
expect(columnButtons).toHaveLength(4);
|
|
});
|
|
|
|
it('should change column count when button clicked', async () => {
|
|
vi.mocked(recipesApi.getAll).mockResolvedValue({
|
|
data: createMockRecipes(3) as any,
|
|
total: 3,
|
|
page: 1,
|
|
pageSize: 24,
|
|
});
|
|
|
|
renderWithRouter(<RecipeList />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Recipe 1')).toBeInTheDocument();
|
|
});
|
|
|
|
// Find the column button with text "3"
|
|
const columnButtons = screen.getAllByRole('button').filter(btn =>
|
|
btn.textContent === '3' && btn.closest('.column-buttons')
|
|
);
|
|
|
|
await userEvent.click(columnButtons[0]);
|
|
|
|
// Check that grid has 3 columns
|
|
const grid = document.querySelector('.recipe-grid-enhanced');
|
|
expect(grid).toHaveStyle({ gridTemplateColumns: 'repeat(3, 1fr)' });
|
|
});
|
|
|
|
it('should save column count to localStorage', async () => {
|
|
vi.mocked(recipesApi.getAll).mockResolvedValue({
|
|
data: createMockRecipes(3) as any,
|
|
total: 3,
|
|
page: 1,
|
|
pageSize: 24,
|
|
});
|
|
|
|
renderWithRouter(<RecipeList />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Recipe 1')).toBeInTheDocument();
|
|
});
|
|
|
|
const columnButtons = screen.getAllByRole('button').filter(btn =>
|
|
btn.textContent === '9' && btn.closest('.column-buttons')
|
|
);
|
|
|
|
await userEvent.click(columnButtons[0]);
|
|
|
|
expect(Storage.prototype.setItem).toHaveBeenCalledWith(
|
|
'basil_recipes_columnCount',
|
|
'9'
|
|
);
|
|
});
|
|
});
|
|
|
|
|
|
describe('Search Functionality', () => {
|
|
it('should render unified search input', async () => {
|
|
vi.mocked(recipesApi.getAll).mockResolvedValue({
|
|
data: createMockRecipes(3) as any,
|
|
total: 3,
|
|
page: 1,
|
|
pageSize: 24,
|
|
});
|
|
|
|
renderWithRouter(<RecipeList />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByPlaceholderText('Search recipes by title or tag...')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('should load available tags for autocomplete', async () => {
|
|
vi.mocked(recipesApi.getAll).mockResolvedValue({
|
|
data: createMockRecipes(3) as any,
|
|
total: 3,
|
|
page: 1,
|
|
pageSize: 24,
|
|
});
|
|
|
|
renderWithRouter(<RecipeList />);
|
|
|
|
await waitFor(() => {
|
|
expect(tagsApi.getAll).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
it('should display empty state with search term when no results found', async () => {
|
|
vi.mocked(recipesApi.getAll).mockResolvedValue({
|
|
data: [],
|
|
total: 0,
|
|
page: 1,
|
|
pageSize: 24,
|
|
});
|
|
|
|
renderWithRouter(<RecipeList />, ['/recipes?search=nonexistent']);
|
|
|
|
await waitFor(() => {
|
|
expect(
|
|
screen.getByText(/No recipes found matching "nonexistent"/)
|
|
).toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('URL Parameters', () => {
|
|
it('should initialize from URL params with page and limit', async () => {
|
|
vi.mocked(recipesApi.getAll).mockResolvedValue({
|
|
data: createMockRecipes(12) as any,
|
|
total: 100,
|
|
page: 2,
|
|
pageSize: 12,
|
|
});
|
|
|
|
renderWithRouter(<RecipeList />, ['/recipes?page=2&limit=12']);
|
|
|
|
await waitFor(() => {
|
|
expect(recipesApi.getAll).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
page: 2,
|
|
limit: 12,
|
|
})
|
|
);
|
|
});
|
|
});
|
|
|
|
it('should initialize search from URL params', async () => {
|
|
vi.mocked(recipesApi.getAll).mockResolvedValue({
|
|
data: createMockRecipes(12) as any,
|
|
total: 100,
|
|
page: 1,
|
|
pageSize: 24,
|
|
});
|
|
|
|
renderWithRouter(<RecipeList />, ['/recipes?search=pasta']);
|
|
|
|
await waitFor(() => {
|
|
expect(recipesApi.getAll).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
search: 'pasta',
|
|
})
|
|
);
|
|
});
|
|
});
|
|
|
|
it('should initialize unified search from URL params', async () => {
|
|
vi.mocked(recipesApi.getAll).mockResolvedValue({
|
|
data: createMockRecipes(12) as any,
|
|
total: 100,
|
|
page: 1,
|
|
pageSize: 24,
|
|
});
|
|
|
|
renderWithRouter(<RecipeList />, ['/recipes?search=Italian']);
|
|
|
|
await waitFor(() => {
|
|
expect(recipesApi.getAll).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
search: 'Italian',
|
|
})
|
|
);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('LocalStorage Persistence', () => {
|
|
it('should load column count from localStorage', async () => {
|
|
Storage.prototype.getItem = vi.fn((key: string) => {
|
|
if (key === 'basil_recipes_columnCount') return '9';
|
|
return null;
|
|
});
|
|
|
|
vi.mocked(recipesApi.getAll).mockResolvedValue({
|
|
data: createMockRecipes(3) as any,
|
|
total: 3,
|
|
page: 1,
|
|
pageSize: 24,
|
|
});
|
|
|
|
renderWithRouter(<RecipeList />);
|
|
|
|
await waitFor(() => {
|
|
const grid = document.querySelector('.recipe-grid-enhanced');
|
|
expect(grid).toHaveStyle({ gridTemplateColumns: 'repeat(9, 1fr)' });
|
|
});
|
|
});
|
|
|
|
|
|
it('should load items per page from localStorage', async () => {
|
|
Storage.prototype.getItem = vi.fn((key: string) => {
|
|
if (key === 'basil_recipes_itemsPerPage') return '48';
|
|
return null;
|
|
});
|
|
|
|
vi.mocked(recipesApi.getAll).mockResolvedValue({
|
|
data: createMockRecipes(48) as any,
|
|
total: 100,
|
|
page: 1,
|
|
pageSize: 48,
|
|
});
|
|
|
|
renderWithRouter(<RecipeList />);
|
|
|
|
await waitFor(() => {
|
|
expect(recipesApi.getAll).toHaveBeenCalledWith(
|
|
expect.objectContaining({ limit: 48 })
|
|
);
|
|
});
|
|
});
|
|
|
|
it('should save items per page to localStorage', async () => {
|
|
vi.mocked(recipesApi.getAll).mockResolvedValue({
|
|
data: createMockRecipes(12) as any,
|
|
total: 100,
|
|
page: 1,
|
|
pageSize: 12,
|
|
});
|
|
|
|
renderWithRouter(<RecipeList />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Recipe 1')).toBeInTheDocument();
|
|
});
|
|
|
|
const button48 = screen.getByRole('button', { name: '48' });
|
|
await userEvent.click(button48);
|
|
|
|
expect(Storage.prototype.setItem).toHaveBeenCalledWith(
|
|
'basil_recipes_itemsPerPage',
|
|
'48'
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('Toolbar Display', () => {
|
|
it('should render sticky toolbar', async () => {
|
|
vi.mocked(recipesApi.getAll).mockResolvedValue({
|
|
data: createMockRecipes(3) as any,
|
|
total: 3,
|
|
page: 1,
|
|
pageSize: 24,
|
|
});
|
|
|
|
renderWithRouter(<RecipeList />);
|
|
|
|
await waitFor(() => {
|
|
const toolbar = document.querySelector('.recipe-list-toolbar');
|
|
expect(toolbar).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('should display page title', async () => {
|
|
vi.mocked(recipesApi.getAll).mockResolvedValue({
|
|
data: createMockRecipes(3) as any,
|
|
total: 3,
|
|
page: 1,
|
|
pageSize: 24,
|
|
});
|
|
|
|
renderWithRouter(<RecipeList />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByRole('heading', { name: 'My Recipes' })).toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|
|
});
|