// src/features/charts/PriceHistoryChart.test.tsx import React from 'react'; import { render, screen, waitFor } from '@testing-library/react'; import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; import { PriceHistoryChart } from './PriceHistoryChart'; import { useUserData } from '../../hooks/useUserData'; import * as apiClient from '../../services/apiClient'; import type { MasterGroceryItem, HistoricalPriceDataPoint } from '../../types'; import { createMockMasterGroceryItem, createMockHistoricalPriceDataPoint, } from '../../tests/utils/mockFactories'; // Mock the apiClient vi.mock('../../services/apiClient'); // Mock the useUserData hook vi.mock('../../hooks/useUserData'); const mockedUseUserData = useUserData as Mock; // Mock the logger vi.mock('../../services/logger', () => ({ logger: { error: vi.fn(), }, })); // Mock the recharts library to prevent rendering complex SVGs in jsdom vi.mock('recharts', () => ({ ResponsiveContainer: ({ children }: { children: React.ReactNode }) => (
{children}
), // Expose the data prop for testing data transformations LineChart: ({ children, data }: { children: React.ReactNode; data: any[] }) => (
{children}
), CartesianGrid: () =>
, XAxis: () =>
, YAxis: ({ tickFormatter, domain }: any) => { // Execute functions for coverage if (typeof tickFormatter === 'function') { tickFormatter(1000); } if (Array.isArray(domain)) { domain.forEach((d) => { if (typeof d === 'function') d(100); }); } return
; }, Tooltip: ({ formatter }: any) => { // Execute formatter for coverage if (typeof formatter === 'function') { formatter(1000); formatter(undefined); } return
; }, Legend: () =>
, // Fix: Use dataKey if name is not explicitly provided, as the component relies on dataKey Line: ({ name, dataKey }: { name?: string; dataKey?: string }) => (
), })); const mockWatchedItems: MasterGroceryItem[] = [ createMockMasterGroceryItem({ master_grocery_item_id: 1, name: 'Organic Bananas' }), createMockMasterGroceryItem({ master_grocery_item_id: 2, name: 'Almond Milk' }), ]; const mockPriceHistory: HistoricalPriceDataPoint[] = [ createMockHistoricalPriceDataPoint({ master_item_id: 1, summary_date: '2024-10-01', avg_price_in_cents: 110, }), createMockHistoricalPriceDataPoint({ master_item_id: 1, summary_date: '2024-10-08', avg_price_in_cents: 99, }), createMockHistoricalPriceDataPoint({ master_item_id: 2, summary_date: '2024-10-01', avg_price_in_cents: 350, }), createMockHistoricalPriceDataPoint({ master_item_id: 2, summary_date: '2024-10-08', avg_price_in_cents: 349, }), ]; describe('PriceHistoryChart', () => { beforeEach(() => { vi.clearAllMocks(); // Provide a default successful mock for useUserData mockedUseUserData.mockReturnValue({ watchedItems: mockWatchedItems, shoppingLists: [], setWatchedItems: vi.fn(), setShoppingLists: vi.fn(), isLoading: false, error: null, }); }); it('should render a placeholder when there are no watched items', () => { mockedUseUserData.mockReturnValue({ watchedItems: [], shoppingLists: [], setWatchedItems: vi.fn(), setShoppingLists: vi.fn(), isLoading: false, error: null, }); render(); expect( screen.getByText('Add items to your watchlist to see their price trends over time.'), ).toBeInTheDocument(); }); it('should display a loading state while fetching data', () => { vi.mocked(apiClient.fetchHistoricalPriceData).mockReturnValue(new Promise(() => {})); render(); expect(screen.getByText('Loading Price History...')).toBeInTheDocument(); }); it('should display an error message if the API call fails', async () => { vi.mocked(apiClient.fetchHistoricalPriceData).mockRejectedValue(new Error('API is down')); render(); await waitFor(() => { // Use regex to match the error message text which might be split across elements expect(screen.getByText(/API is down/)).toBeInTheDocument(); }); }); it('should display a message if no historical data is returned', async () => { vi.mocked(apiClient.fetchHistoricalPriceData).mockResolvedValue( new Response(JSON.stringify([])), ); render(); await waitFor(() => { expect( screen.getByText( 'Not enough historical data for your watched items. Process more flyers to build a trend.', ), ).toBeInTheDocument(); }); }); it('should render the chart with data on successful fetch', async () => { vi.mocked(apiClient.fetchHistoricalPriceData).mockResolvedValue( new Response(JSON.stringify(mockPriceHistory)), ); render(); await waitFor(() => { // Check that the API was called with the correct item IDs expect(apiClient.fetchHistoricalPriceData).toHaveBeenCalledWith([1, 2]); // Check that the chart components are rendered expect(screen.getByTestId('responsive-container')).toBeInTheDocument(); expect(screen.getByTestId('line-chart')).toBeInTheDocument(); expect(screen.getByTestId('x-axis')).toBeInTheDocument(); expect(screen.getByTestId('y-axis')).toBeInTheDocument(); expect(screen.getByTestId('legend')).toBeInTheDocument(); // Check that a line is rendered for each watched item expect(screen.getByTestId('line-Organic Bananas')).toBeInTheDocument(); expect(screen.getByTestId('line-Almond Milk')).toBeInTheDocument(); }); }); it('should display a loading state while user data is loading', () => { mockedUseUserData.mockReturnValue({ watchedItems: [], shoppingLists: [], setWatchedItems: vi.fn(), setShoppingLists: vi.fn(), isLoading: true, // Test the isLoading state from the useUserData hook error: null, }); vi.mocked(apiClient.fetchHistoricalPriceData).mockReturnValue(new Promise(() => {})); render(); expect(screen.getByText('Loading Price History...')).toBeInTheDocument(); }); it('should clear the chart when the watchlist becomes empty', async () => { vi.mocked(apiClient.fetchHistoricalPriceData).mockResolvedValue( new Response(JSON.stringify(mockPriceHistory)), ); const { rerender } = render(); // Initial render with items await waitFor(() => { expect(screen.getByTestId('line-Organic Bananas')).toBeInTheDocument(); }); // Rerender with empty watchlist mockedUseUserData.mockReturnValue({ watchedItems: [], shoppingLists: [], setWatchedItems: vi.fn(), setShoppingLists: vi.fn(), isLoading: false, error: null, }); rerender(); // Chart should be gone, placeholder should appear await waitFor(() => { expect( screen.getByText('Add items to your watchlist to see their price trends over time.'), ).toBeInTheDocument(); expect(screen.queryByTestId('line-chart')).not.toBeInTheDocument(); }); }); it('should filter out items with only one data point', async () => { const dataWithSinglePoint: HistoricalPriceDataPoint[] = [ createMockHistoricalPriceDataPoint({ master_item_id: 1, summary_date: '2024-10-01', avg_price_in_cents: 110, }), createMockHistoricalPriceDataPoint({ master_item_id: 1, summary_date: '2024-10-08', avg_price_in_cents: 99, }), createMockHistoricalPriceDataPoint({ master_item_id: 2, summary_date: '2024-10-01', avg_price_in_cents: 350, }), // Almond Milk only has one point ]; vi.mocked(apiClient.fetchHistoricalPriceData).mockResolvedValue( new Response(JSON.stringify(dataWithSinglePoint)), ); render(); await waitFor(() => { expect(screen.getByTestId('line-Organic Bananas')).toBeInTheDocument(); expect(screen.queryByTestId('line-Almond Milk')).not.toBeInTheDocument(); }); }); it('should process data to only keep the lowest price for a given day', async () => { const dataWithDuplicateDate: HistoricalPriceDataPoint[] = [ createMockHistoricalPriceDataPoint({ master_item_id: 1, summary_date: '2024-10-01', avg_price_in_cents: 110, }), createMockHistoricalPriceDataPoint({ master_item_id: 1, summary_date: '2024-10-01', avg_price_in_cents: 105, }), // Lower price createMockHistoricalPriceDataPoint({ master_item_id: 1, summary_date: '2024-10-08', avg_price_in_cents: 99, }), ]; vi.mocked(apiClient.fetchHistoricalPriceData).mockResolvedValue( new Response(JSON.stringify(dataWithDuplicateDate)), ); render(); await waitFor(() => { const chart = screen.getByTestId('line-chart'); const chartData = JSON.parse(chart.getAttribute('data-chartdata')!); // The date gets formatted to 'Oct 1' const dataPointForOct1 = chartData.find((d: any) => d.date === 'Oct 1'); expect(dataPointForOct1['Organic Bananas']).toBe(105); }); }); it('should filter out data points with a price of zero', async () => { const dataWithZeroPrice: HistoricalPriceDataPoint[] = [ createMockHistoricalPriceDataPoint({ master_item_id: 1, summary_date: '2024-10-01', avg_price_in_cents: 110, }), createMockHistoricalPriceDataPoint({ master_item_id: 1, summary_date: '2024-10-08', avg_price_in_cents: 0, }), // Zero price should be filtered createMockHistoricalPriceDataPoint({ master_item_id: 1, summary_date: '2024-10-15', avg_price_in_cents: 105, }), ]; vi.mocked(apiClient.fetchHistoricalPriceData).mockResolvedValue( new Response(JSON.stringify(dataWithZeroPrice)), ); render(); await waitFor(() => { const chart = screen.getByTestId('line-chart'); const chartData = JSON.parse(chart.getAttribute('data-chartdata')!); // The date 'Oct 8' should not be in the chart data at all const dataPointForOct8 = chartData.find((d: any) => d.date === 'Oct 8'); expect(dataPointForOct8).toBeUndefined(); // Check that the other two points are there expect(chartData).toHaveLength(2); }); }); it('should handle malformed data points and unmatched items gracefully', async () => { const malformedData: any[] = [ { master_item_id: null, summary_date: '2024-10-01', avg_price_in_cents: 100 }, // Missing ID { master_item_id: 1, summary_date: null, avg_price_in_cents: 100 }, // Missing date { master_item_id: 1, summary_date: '2024-10-01', avg_price_in_cents: null }, // Missing price { master_item_id: 999, summary_date: '2024-10-01', avg_price_in_cents: 100 }, // ID not in watchlist ]; vi.mocked(apiClient.fetchHistoricalPriceData).mockResolvedValue( new Response(JSON.stringify(malformedData)), ); render(); await waitFor(() => { // Should show "Not enough historical data" because all points are invalid or filtered expect( screen.getByText( 'Not enough historical data for your watched items. Process more flyers to build a trend.', ), ).toBeInTheDocument(); }); }); it('should ignore higher prices for the same day', async () => { const dataWithHigherPrice: HistoricalPriceDataPoint[] = [ createMockHistoricalPriceDataPoint({ master_item_id: 1, summary_date: '2024-10-01', avg_price_in_cents: 100, }), createMockHistoricalPriceDataPoint({ master_item_id: 1, summary_date: '2024-10-01', avg_price_in_cents: 150, // Higher price should be ignored }), createMockHistoricalPriceDataPoint({ master_item_id: 1, summary_date: '2024-10-08', avg_price_in_cents: 100, }), ]; vi.mocked(apiClient.fetchHistoricalPriceData).mockResolvedValue( new Response(JSON.stringify(dataWithHigherPrice)), ); render(); await waitFor(() => { const chart = screen.getByTestId('line-chart'); const chartData = JSON.parse(chart.getAttribute('data-chartdata')!); const dataPoint = chartData.find((d: any) => d.date === 'Oct 1'); expect(dataPoint['Organic Bananas']).toBe(100); }); }); it('should handle non-Error objects thrown during fetch', async () => { vi.mocked(apiClient.fetchHistoricalPriceData).mockRejectedValue('String Error'); render(); await waitFor(() => { expect(screen.getByText('Failed to load price history.')).toBeInTheDocument(); }); }); });