243 lines
7.8 KiB
TypeScript
243 lines
7.8 KiB
TypeScript
// src/pages/admin/AdminStatsPage.test.tsx
|
|
import React from 'react';
|
|
import { render, screen, waitFor, act } from '@testing-library/react';
|
|
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
|
import { MemoryRouter } from 'react-router-dom';
|
|
import { AdminStatsPage } from './AdminStatsPage';
|
|
import * as apiClient from '../../services/apiClient';
|
|
import type { AppStats } from '../../services/apiClient';
|
|
import { createMockAppStats } from '../../tests/utils/mockFactories';
|
|
import { StatCard } from '../../components/StatCard';
|
|
|
|
// The apiClient and logger are now mocked globally via src/tests/setup/tests-setup-unit.ts.
|
|
const mockedApiClient = vi.mocked(apiClient);
|
|
|
|
// Mock the child StatCard component to use the shared mock and allow spying
|
|
vi.mock('../../components/StatCard', async () => {
|
|
const { MockStatCard } = await import('../../tests/utils/componentMocks');
|
|
return { StatCard: vi.fn(MockStatCard) };
|
|
});
|
|
|
|
// Get a reference to the mocked component
|
|
const mockedStatCard = StatCard as Mock;
|
|
|
|
// Helper function to render the component within a router context, as it contains a <Link>
|
|
const renderWithRouter = () => {
|
|
return render(
|
|
<MemoryRouter>
|
|
<AdminStatsPage />
|
|
</MemoryRouter>,
|
|
);
|
|
};
|
|
|
|
describe('AdminStatsPage', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mockedStatCard.mockClear();
|
|
});
|
|
|
|
it('should render a loading spinner while fetching stats', async () => {
|
|
let resolvePromise: (value: Response) => void;
|
|
const mockPromise = new Promise<Response>((resolve) => {
|
|
resolvePromise = resolve;
|
|
});
|
|
// Cast to any to bypass strict type checking for the mock return value vs Promise
|
|
mockedApiClient.getApplicationStats.mockReturnValue(mockPromise as any);
|
|
|
|
renderWithRouter();
|
|
|
|
expect(screen.getByRole('status', { name: /loading/i })).toBeInTheDocument();
|
|
|
|
await act(async () => {
|
|
resolvePromise!(
|
|
new Response(
|
|
JSON.stringify(
|
|
createMockAppStats({
|
|
userCount: 0,
|
|
flyerCount: 0,
|
|
flyerItemCount: 0,
|
|
storeCount: 0,
|
|
pendingCorrectionCount: 0,
|
|
recipeCount: 0,
|
|
}),
|
|
),
|
|
),
|
|
);
|
|
});
|
|
});
|
|
|
|
it('should display stats cards when data is fetched successfully', async () => {
|
|
const mockStats: AppStats = createMockAppStats({
|
|
userCount: 123,
|
|
flyerCount: 456,
|
|
flyerItemCount: 7890,
|
|
storeCount: 42,
|
|
pendingCorrectionCount: 5,
|
|
recipeCount: 150,
|
|
});
|
|
mockedApiClient.getApplicationStats.mockResolvedValue(new Response(JSON.stringify(mockStats)));
|
|
renderWithRouter();
|
|
|
|
// Wait for the stats to be displayed
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Total Users')).toBeInTheDocument();
|
|
expect(screen.getByText('123')).toBeInTheDocument();
|
|
|
|
expect(screen.getByText('Flyers Processed')).toBeInTheDocument();
|
|
expect(screen.getByText('456')).toBeInTheDocument();
|
|
|
|
expect(screen.getByText('Total Flyer Items')).toBeInTheDocument();
|
|
expect(screen.getByText('7,890')).toBeInTheDocument(); // Note: toLocaleString() adds a comma
|
|
|
|
expect(screen.getByText('Stores Tracked')).toBeInTheDocument();
|
|
expect(screen.getByText('42')).toBeInTheDocument();
|
|
|
|
expect(screen.getByText('Pending Corrections')).toBeInTheDocument();
|
|
expect(screen.getByText('5')).toBeInTheDocument();
|
|
|
|
expect(screen.getByText('Total Recipes')).toBeInTheDocument();
|
|
expect(screen.getByText('150')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('should pass the correct props to each StatCard component', async () => {
|
|
const mockStats: AppStats = createMockAppStats({
|
|
userCount: 123,
|
|
flyerCount: 456,
|
|
flyerItemCount: 7890,
|
|
storeCount: 42,
|
|
pendingCorrectionCount: 5,
|
|
recipeCount: 150,
|
|
});
|
|
mockedApiClient.getApplicationStats.mockResolvedValue(new Response(JSON.stringify(mockStats)));
|
|
|
|
renderWithRouter();
|
|
|
|
await waitFor(() => {
|
|
// Wait for the component to have been called at least once
|
|
expect(mockedStatCard).toHaveBeenCalled();
|
|
});
|
|
|
|
// Verify it was called 5 times, once for each stat
|
|
expect(mockedStatCard).toHaveBeenCalledTimes(6);
|
|
|
|
// Check props for each card individually for robustness
|
|
expect(mockedStatCard).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
title: 'Total Users',
|
|
value: '123',
|
|
}),
|
|
undefined,
|
|
);
|
|
expect(mockedStatCard).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
title: 'Flyers Processed',
|
|
value: '456',
|
|
}),
|
|
undefined,
|
|
);
|
|
expect(mockedStatCard).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
title: 'Total Flyer Items',
|
|
value: '7,890',
|
|
}),
|
|
undefined,
|
|
);
|
|
expect(mockedStatCard).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
title: 'Stores Tracked',
|
|
value: '42',
|
|
}),
|
|
undefined,
|
|
);
|
|
expect(mockedStatCard).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
title: 'Pending Corrections',
|
|
value: '5',
|
|
}),
|
|
undefined,
|
|
);
|
|
expect(mockedStatCard).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
title: 'Total Recipes',
|
|
value: '150',
|
|
}),
|
|
undefined,
|
|
);
|
|
});
|
|
|
|
it('should format large numbers with commas for readability', async () => {
|
|
const mockStats: AppStats = createMockAppStats({
|
|
userCount: 1234567,
|
|
flyerCount: 9876,
|
|
flyerItemCount: 123456789,
|
|
recipeCount: 50000,
|
|
});
|
|
mockedApiClient.getApplicationStats.mockResolvedValue(new Response(JSON.stringify(mockStats)));
|
|
renderWithRouter();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('1,234,567')).toBeInTheDocument();
|
|
expect(screen.getByText('9,876')).toBeInTheDocument();
|
|
expect(screen.getByText('123,456,789')).toBeInTheDocument();
|
|
expect(screen.getByText('50,000')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('should correctly display zero values for all stats', async () => {
|
|
const mockZeroStats: AppStats = createMockAppStats({
|
|
userCount: 0,
|
|
flyerCount: 0,
|
|
flyerItemCount: 0,
|
|
storeCount: 0,
|
|
pendingCorrectionCount: 0,
|
|
recipeCount: 0,
|
|
});
|
|
mockedApiClient.getApplicationStats.mockResolvedValue(
|
|
new Response(JSON.stringify(mockZeroStats)),
|
|
);
|
|
renderWithRouter();
|
|
|
|
await waitFor(() => {
|
|
// `getAllByText` will find all instances of '0'. There should be 5.
|
|
const zeroValueElements = screen.getAllByText('0');
|
|
expect(zeroValueElements).toHaveLength(6);
|
|
|
|
// Also check that the titles are present to be sure we have the cards.
|
|
expect(screen.getByText('Total Users')).toBeInTheDocument();
|
|
expect(screen.getByText('Pending Corrections')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('should display an error message if fetching stats fails', async () => {
|
|
const errorMessage = 'Failed to connect to the database.';
|
|
mockedApiClient.getApplicationStats.mockRejectedValue(new Error(errorMessage));
|
|
renderWithRouter();
|
|
|
|
// Wait for the error message to appear
|
|
await waitFor(() => {
|
|
expect(screen.getByText(errorMessage)).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('should display a generic error message for unknown errors', async () => {
|
|
mockedApiClient.getApplicationStats.mockRejectedValue('Unknown error object');
|
|
renderWithRouter();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('An unknown error occurred.')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('should render a link back to the admin dashboard', async () => {
|
|
mockedApiClient.getApplicationStats.mockResolvedValue(
|
|
new Response(JSON.stringify(createMockAppStats())),
|
|
);
|
|
renderWithRouter();
|
|
|
|
const link = await screen.findByRole('link', { name: /back to admin dashboard/i });
|
|
expect(link).toBeInTheDocument();
|
|
expect(link).toHaveAttribute('href', '/admin');
|
|
});
|
|
});
|