Files
flyer-crawler.projectium.com/src/features/charts/PriceHistoryChart.test.tsx
Torben Sorensen 768d02b9ed
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 14m14s
several fixes to various tests
2025-12-26 23:37:39 -08:00

385 lines
13 KiB
TypeScript

// 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 }) => (
<div data-testid="responsive-container">{children}</div>
),
// Expose the data prop for testing data transformations
LineChart: ({ children, data }: { children: React.ReactNode; data: any[] }) => (
<div data-testid="line-chart" data-chartdata={JSON.stringify(data)}>
{children}
</div>
),
CartesianGrid: () => <div data-testid="cartesian-grid" />,
XAxis: () => <div data-testid="x-axis" />,
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 <div data-testid="y-axis" />;
},
Tooltip: ({ formatter }: any) => {
// Execute formatter for coverage
if (typeof formatter === 'function') {
formatter(1000);
formatter(undefined);
}
return <div data-testid="tooltip" />;
},
Legend: () => <div data-testid="legend" />,
// Fix: Use dataKey if name is not explicitly provided, as the component relies on dataKey
Line: ({ name, dataKey }: { name?: string; dataKey?: string }) => (
<div data-testid={`line-${name || dataKey}`} />
),
}));
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(<PriceHistoryChart />);
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(<PriceHistoryChart />);
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(<PriceHistoryChart />);
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(<PriceHistoryChart />);
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(<PriceHistoryChart />);
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(<PriceHistoryChart />);
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(<PriceHistoryChart />);
// 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(<PriceHistoryChart />);
// 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(<PriceHistoryChart />);
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(<PriceHistoryChart />);
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(<PriceHistoryChart />);
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(<PriceHistoryChart />);
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(<PriceHistoryChart />);
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(<PriceHistoryChart />);
await waitFor(() => {
expect(screen.getByText('Failed to load price history.')).toBeInTheDocument();
});
});
});