All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 14m14s
385 lines
13 KiB
TypeScript
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();
|
|
});
|
|
});
|
|
});
|