Files
flyer-crawler.projectium.com/src/features/flyer/FlyerList.test.tsx
Torben Sorensen a42ee5a461
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 15m11s
unit tests - wheeee! Claude is the mvp
2026-01-09 21:59:09 -08:00

483 lines
16 KiB
TypeScript

// src/features/flyer/FlyerList.test.tsx
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach, type Mocked } from 'vitest';
import { FlyerList } from './FlyerList';
import { formatShortDate } from '../../utils/dateUtils';
import type { Flyer, UserProfile } from '../../types';
import { createMockUserProfile } from '../../tests/utils/mockFactories';
import { createMockFlyer } from '../../tests/utils/mockFactories';
import * as apiClient from '../../services/apiClient';
import toast from 'react-hot-toast';
// Mock the apiClient and toast notifications
vi.mock('../../services/apiClient');
vi.mock('react-hot-toast', () => ({ default: { success: vi.fn(), error: vi.fn() } }));
const mockFlyers: Flyer[] = [
createMockFlyer({
flyer_id: 1,
file_name: 'metro_flyer_oct_1.pdf',
item_count: 50,
image_url: 'https://example.com/flyer1.jpg',
store: { store_id: 101, name: 'Metro' },
valid_from: '2023-10-05',
valid_to: '2023-10-11',
created_at: '2023-10-01T10:00:00Z',
}),
createMockFlyer({
flyer_id: 2,
file_name: 'walmart_flyer.pdf',
item_count: 75,
image_url: 'https://example.com/flyer2.jpg',
store: { store_id: 102, name: 'Walmart' },
valid_from: '2023-10-06',
valid_to: '2023-10-06', // Same day
icon_url: '', // Force empty to test default icon rendering
}),
{
...createMockFlyer({
flyer_id: 3,
file_name: 'no-store-flyer.pdf',
item_count: 10,
image_url: 'https://example.com/flyer3.jpg',
icon_url: 'https://example.com/icon3.png',
valid_from: '2023-10-07',
valid_to: '2023-10-08',
store_address: '456 Side St, Ottawa',
created_at: '2023-10-03T12:00:00Z',
}),
store: null as any, // Force null to ensure "Unknown Store" fallback triggers (overriding factory defaults)
},
createMockFlyer({
flyer_id: 4,
file_name: 'bad-date-flyer.pdf',
item_count: 5,
image_url: 'https://example.com/flyer4.jpg',
store: { store_id: 103, name: 'Date Store' },
created_at: 'invalid-date',
valid_from: 'invalid-from',
valid_to: null,
}),
];
const mockedApiClient = apiClient as Mocked<typeof apiClient>;
const mockedToast = toast as Mocked<typeof toast>;
describe('FlyerList', () => {
const mockOnFlyerSelect = vi.fn();
const mockProfile: UserProfile = createMockUserProfile({
user: { user_id: '1', email: 'test@example.com' },
role: 'user',
});
beforeEach(() => {
vi.clearAllMocks();
});
it('should render the heading', () => {
render(
<FlyerList
flyers={[]}
onFlyerSelect={mockOnFlyerSelect}
selectedFlyerId={null}
profile={null}
/>,
);
expect(screen.getByRole('heading', { name: /processed flyers/i })).toBeInTheDocument();
});
it('should display a message when there are no flyers', () => {
render(
<FlyerList
flyers={[]}
onFlyerSelect={mockOnFlyerSelect}
selectedFlyerId={null}
profile={null}
/>,
);
expect(screen.getByText(/no flyers have been processed yet/i)).toBeInTheDocument();
});
it('should render a list of flyers with correct details', () => {
render(
<FlyerList
flyers={mockFlyers}
onFlyerSelect={mockOnFlyerSelect}
selectedFlyerId={null}
profile={mockProfile}
/>,
);
// Check first flyer
expect(screen.getByText('Metro')).toBeInTheDocument();
expect(screen.getByText(/50 items/i)).toBeInTheDocument();
expect(screen.getByText(/Valid: Oct 5 - Oct 11/i)).toBeInTheDocument();
// Check second flyer
expect(screen.getByText('Walmart')).toBeInTheDocument();
expect(screen.getByText(/Valid: Oct 6/i)).toBeInTheDocument(); // Single day validity
});
it('should call onFlyerSelect with the correct flyer when an item is clicked', () => {
render(
<FlyerList
flyers={mockFlyers}
onFlyerSelect={mockOnFlyerSelect}
selectedFlyerId={null}
profile={mockProfile}
/>,
);
const firstFlyerItem = screen.getByText('Metro').closest('li');
fireEvent.click(firstFlyerItem!);
expect(mockOnFlyerSelect).toHaveBeenCalledTimes(1);
expect(mockOnFlyerSelect).toHaveBeenCalledWith(mockFlyers[0]);
});
it('should apply a selected style to the currently selected flyer', () => {
render(
<FlyerList
flyers={mockFlyers}
onFlyerSelect={mockOnFlyerSelect}
selectedFlyerId={1}
profile={mockProfile}
/>,
);
const selectedItem = screen.getByText('Metro').closest('li');
expect(selectedItem).toHaveClass('bg-brand-light', 'dark:bg-brand-dark/30');
});
describe('UI Details and Edge Cases', () => {
it('should render an image icon when icon_url is present', () => {
render(
<FlyerList
flyers={mockFlyers}
onFlyerSelect={mockOnFlyerSelect}
selectedFlyerId={null}
profile={mockProfile}
/>,
);
const flyerWithIcon = screen.getByText('Unknown Store').closest('li'); // Flyer ID 3
const iconImage = flyerWithIcon?.querySelector('img');
expect(iconImage).toBeInTheDocument();
expect(iconImage).toHaveAttribute('src', 'https://example.com/icon3.png');
});
it('should render a document icon when icon_url is not present', () => {
render(
<FlyerList
flyers={mockFlyers}
onFlyerSelect={mockOnFlyerSelect}
selectedFlyerId={null}
profile={mockProfile}
/>,
);
const flyerWithoutIcon = screen.getByText('Walmart').closest('li'); // Flyer ID 2
const iconImage = flyerWithoutIcon?.querySelector('img');
const documentIcon = flyerWithoutIcon?.querySelector('svg');
expect(iconImage).not.toBeInTheDocument();
expect(documentIcon).toBeInTheDocument();
});
it('should render "Unknown Store" if store data is missing', () => {
render(
<FlyerList
flyers={mockFlyers}
onFlyerSelect={mockOnFlyerSelect}
selectedFlyerId={null}
profile={mockProfile}
/>,
);
expect(screen.getByText('Unknown Store')).toBeInTheDocument();
});
it('should render a map link if store_address is present and stop propagation on click', () => {
render(
<FlyerList
flyers={mockFlyers}
onFlyerSelect={mockOnFlyerSelect}
selectedFlyerId={null}
profile={mockProfile}
/>,
);
const flyerWithAddress = screen.getByText('Unknown Store').closest('li');
const mapLink = flyerWithAddress?.querySelector('a');
expect(mapLink).toBeInTheDocument();
expect(mapLink).toHaveAttribute(
'href',
'https://www.google.com/maps/search/?api=1&query=456%20Side%20St%2C%20Ottawa',
);
// Test that clicking the map link does not select the flyer
fireEvent.click(mapLink!);
expect(mockOnFlyerSelect).not.toHaveBeenCalled();
});
it('should render a detailed tooltip', () => {
render(
<FlyerList
flyers={mockFlyers}
onFlyerSelect={mockOnFlyerSelect}
selectedFlyerId={null}
profile={mockProfile}
/>,
);
const firstFlyerItem = screen.getByText('Metro').closest('li');
const tooltipText = firstFlyerItem?.getAttribute('title');
expect(tooltipText).toContain('File: metro_flyer_oct_1.pdf');
expect(tooltipText).toContain('Store: Metro');
expect(tooltipText).toContain('Items: 50');
expect(tooltipText).toContain('Deals valid from October 5, 2023 to October 11, 2023');
// Use a regex for the processed time to avoid timezone-related flakiness in tests.
expect(tooltipText).toMatch(/Processed: October 1, 2023 at \d{1,2}:\d{2}:\d{2} (AM|PM)/);
});
it('should handle invalid dates gracefully in display and tooltip', () => {
render(
<FlyerList
flyers={mockFlyers}
onFlyerSelect={mockOnFlyerSelect}
selectedFlyerId={null}
profile={mockProfile}
/>,
);
const badDateItem = screen.getByText('Date Store').closest('li');
// Display should not show "Valid:" text if dates are invalid
expect(badDateItem).toHaveTextContent('5 items');
expect(badDateItem).not.toHaveTextContent(/Valid:/);
// Tooltip should show N/A for invalid dates
const tooltipText = badDateItem?.getAttribute('title');
expect(tooltipText).toContain('Validity: N/A');
expect(tooltipText).toContain('Processed: N/A');
});
});
describe('Expiration Status Logic', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('should show "Expired" for past dates', () => {
// Flyer 1 valid_to is 2023-10-11
vi.setSystemTime(new Date('2023-10-12T12:00:00Z'));
render(
<FlyerList
flyers={[mockFlyers[0]]}
onFlyerSelect={mockOnFlyerSelect}
selectedFlyerId={null}
profile={mockProfile}
/>,
);
expect(screen.getByText('• Expired')).toBeInTheDocument();
expect(screen.getByText('• Expired')).toHaveClass('text-red-500');
});
it('should show "Expires today" when valid_to is today', () => {
vi.setSystemTime(new Date('2023-10-11T12:00:00Z'));
render(
<FlyerList
flyers={[mockFlyers[0]]}
onFlyerSelect={mockOnFlyerSelect}
selectedFlyerId={null}
profile={mockProfile}
/>,
);
expect(screen.getByText('• Expires today')).toBeInTheDocument();
expect(screen.getByText('• Expires today')).toHaveClass('text-orange-500');
});
it('should show "Expires in X days" (orange) for <= 3 days', () => {
vi.setSystemTime(new Date('2023-10-09T12:00:00Z')); // 2 days left
render(
<FlyerList
flyers={[mockFlyers[0]]}
onFlyerSelect={mockOnFlyerSelect}
selectedFlyerId={null}
profile={mockProfile}
/>,
);
expect(screen.getByText('• Expires in 2 days')).toBeInTheDocument();
expect(screen.getByText('• Expires in 2 days')).toHaveClass('text-orange-500');
});
it('should show "Expires in X days" (green) for > 3 days', () => {
vi.setSystemTime(new Date('2023-10-05T12:00:00Z')); // 6 days left
render(
<FlyerList
flyers={[mockFlyers[0]]}
onFlyerSelect={mockOnFlyerSelect}
selectedFlyerId={null}
profile={mockProfile}
/>,
);
expect(screen.getByText('• Expires in 6 days')).toBeInTheDocument();
expect(screen.getByText('• Expires in 6 days')).toHaveClass('text-green-600');
});
it('should show "Expires in 1 day" (singular) when exactly 1 day left', () => {
vi.setSystemTime(new Date('2023-10-10T12:00:00Z')); // 1 day left until Oct 11
render(
<FlyerList
flyers={[mockFlyers[0]]}
onFlyerSelect={mockOnFlyerSelect}
selectedFlyerId={null}
profile={mockProfile}
/>,
);
expect(screen.getByText('• Expires in 1 day')).toBeInTheDocument();
expect(screen.getByText('• Expires in 1 day')).toHaveClass('text-orange-500');
});
});
describe('Admin Functionality', () => {
const adminProfile: UserProfile = createMockUserProfile({
user: { user_id: 'admin-1', email: 'admin@example.com' },
role: 'admin',
});
it('should not show the cleanup button for non-admin users', () => {
render(
<FlyerList
flyers={mockFlyers}
onFlyerSelect={mockOnFlyerSelect}
selectedFlyerId={null}
profile={mockProfile}
/>,
);
expect(screen.queryByTitle(/clean up files/i)).not.toBeInTheDocument();
});
it('should show the cleanup button for admin users', () => {
render(
<FlyerList
flyers={mockFlyers}
onFlyerSelect={mockOnFlyerSelect}
selectedFlyerId={null}
profile={adminProfile}
/>,
);
expect(screen.getByTitle('Clean up files for flyer ID 1')).toBeInTheDocument();
expect(screen.getByTitle('Clean up files for flyer ID 2')).toBeInTheDocument();
});
it('should call cleanupFlyerFiles when admin clicks and confirms', async () => {
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
mockedApiClient.cleanupFlyerFiles.mockResolvedValue(new Response(null, { status: 200 }));
render(
<FlyerList
flyers={mockFlyers}
onFlyerSelect={mockOnFlyerSelect}
selectedFlyerId={null}
profile={adminProfile}
/>,
);
const cleanupButton = screen.getByTitle('Clean up files for flyer ID 1');
fireEvent.click(cleanupButton);
expect(confirmSpy).toHaveBeenCalledWith(
'Are you sure you want to clean up the files for flyer ID 1? This action cannot be undone.',
);
await waitFor(() => {
expect(mockedApiClient.cleanupFlyerFiles).toHaveBeenCalledWith(1);
});
});
it('should not call cleanupFlyerFiles when admin clicks and cancels', () => {
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false);
render(
<FlyerList
flyers={mockFlyers}
onFlyerSelect={mockOnFlyerSelect}
selectedFlyerId={null}
profile={adminProfile}
/>,
);
const cleanupButton = screen.getByTitle('Clean up files for flyer ID 1');
fireEvent.click(cleanupButton);
expect(confirmSpy).toHaveBeenCalled();
expect(mockedApiClient.cleanupFlyerFiles).not.toHaveBeenCalled();
});
it('should show an error toast if cleanup API call fails', async () => {
vi.spyOn(window, 'confirm').mockReturnValue(true);
const apiError = new Error('Cleanup failed');
mockedApiClient.cleanupFlyerFiles.mockRejectedValue(apiError);
render(
<FlyerList
flyers={mockFlyers}
onFlyerSelect={mockOnFlyerSelect}
selectedFlyerId={null}
profile={adminProfile}
/>,
);
const cleanupButton = screen.getByTitle('Clean up files for flyer ID 1');
fireEvent.click(cleanupButton);
await waitFor(() => {
expect(mockedApiClient.cleanupFlyerFiles).toHaveBeenCalledWith(1);
expect(mockedToast.error).toHaveBeenCalledWith('Cleanup failed');
});
});
it('should show generic error toast if cleanup API call fails with non-Error object', async () => {
vi.spyOn(window, 'confirm').mockReturnValue(true);
// Reject with a non-Error value (e.g., a string or object)
mockedApiClient.cleanupFlyerFiles.mockRejectedValue('Some non-error value');
render(
<FlyerList
flyers={mockFlyers}
onFlyerSelect={mockOnFlyerSelect}
selectedFlyerId={null}
profile={adminProfile}
/>,
);
const cleanupButton = screen.getByTitle('Clean up files for flyer ID 1');
fireEvent.click(cleanupButton);
await waitFor(() => {
expect(mockedApiClient.cleanupFlyerFiles).toHaveBeenCalledWith(1);
expect(mockedToast.error).toHaveBeenCalledWith('Failed to enqueue cleanup job.');
});
});
});
});
describe('formatShortDate', () => {
it('should return null for null, undefined, or empty string input', () => {
expect(formatShortDate(null)).toBeNull();
expect(formatShortDate(undefined)).toBeNull();
expect(formatShortDate('')).toBeNull();
});
it('should correctly format a valid ISO date string (YYYY-MM-DD)', () => {
expect(formatShortDate('2023-10-05')).toBe('Oct 5');
expect(formatShortDate('2024-01-20')).toBe('Jan 20');
expect(formatShortDate('2023-12-31')).toBe('Dec 31');
});
it('should return null for various invalid date string formats', () => {
expect(formatShortDate('invalid-date-string')).toBeNull();
expect(formatShortDate('2023-20-20')).toBeNull(); // Invalid month
expect(formatShortDate('2023-02-30')).toBeNull(); // Invalid day
expect(formatShortDate('not a date')).toBeNull();
});
});