// 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 './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: 'http://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: 'http://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: 'http://example.com/flyer3.jpg', icon_url: 'http://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: 'http://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; const mockedToast = toast as Mocked; 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( , ); expect(screen.getByRole('heading', { name: /processed flyers/i })).toBeInTheDocument(); }); it('should display a message when there are no flyers', () => { render( , ); expect(screen.getByText(/no flyers have been processed yet/i)).toBeInTheDocument(); }); it('should render a list of flyers with correct details', () => { render( , ); // 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( , ); 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( , ); 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( , ); const flyerWithIcon = screen.getByText('Unknown Store').closest('li'); // Flyer ID 3 const iconImage = flyerWithIcon?.querySelector('img'); expect(iconImage).toBeInTheDocument(); expect(iconImage).toHaveAttribute('src', 'http://example.com/icon3.png'); }); it('should render a document icon when icon_url is not present', () => { render( , ); 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( , ); expect(screen.getByText('Unknown Store')).toBeInTheDocument(); }); it('should render a map link if store_address is present and stop propagation on click', () => { render( , ); 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( , ); 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( , ); 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( , ); 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( , ); 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( , ); 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( , ); expect(screen.getByText('• Expires in 6 days')).toBeInTheDocument(); expect(screen.getByText('• Expires in 6 days')).toHaveClass('text-green-600'); }); }); 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( , ); expect(screen.queryByTitle(/clean up files/i)).not.toBeInTheDocument(); }); it('should show the cleanup button for admin users', () => { render( , ); 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( , ); 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( , ); 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( , ); 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'); }); }); }); }); 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(); }); });