Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 146 additions & 0 deletions __tests__/components/fleet/ReputationReviewGrid.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ReputationReviewGrid } from '@/components/fleet/ReputationReviewGrid';
import { useDriverReviews, type UseDriverReviewsResult } from '@/hooks/useDriverReviews';
import {
applyReviewFilters,
driverReviewFixtures,
} from '../../fixtures/driverReviewFixtures';

jest.mock('@/hooks/useDriverReviews');

const mockUseDriverReviews = useDriverReviews as jest.MockedFunction<typeof useDriverReviews>;

const fetchNextPage = jest.fn();
const refetch = jest.fn();

function result(overrides: Partial<UseDriverReviewsResult> = {}): UseDriverReviewsResult {
return {
reviews: [],
isLoading: false,
error: null,
hasNextPage: false,
isFetchingNextPage: false,
fetchNextPage,
refetch,
...overrides,
};
}

beforeEach(() => {
jest.clearAllMocks();
mockUseDriverReviews.mockImplementation((_driverId, filters) =>
result({ reviews: applyReviewFilters(driverReviewFixtures, filters) }),
);
});

describe('ReputationReviewGrid', () => {
it('requests reviews for the driver with default filters', () => {
render(<ReputationReviewGrid driverId="driver-1" />);
expect(mockUseDriverReviews).toHaveBeenLastCalledWith('driver-1', {
minRating: null,
dateRange: 'all',
verifiedOnly: false,
sort: 'date_desc',
});
});

it('renders a card for each review with its data', () => {
render(<ReputationReviewGrid driverId="driver-1" />);
const cards = screen.getAllByRole('article');
expect(cards).toHaveLength(driverReviewFixtures.length);
const first = within(cards[0]);
expect(first.getByText('Ada Okafor')).toBeInTheDocument();
expect(first.getByText('Comment from Ada Okafor')).toBeInTheDocument();
expect(first.getByLabelText('Rated 5 out of 5')).toBeInTheDocument();
expect(first.getByText('Verified')).toBeInTheDocument();
});

it('narrows results with the rating filter', async () => {
const user = userEvent.setup();
render(<ReputationReviewGrid driverId="driver-1" />);
await user.selectOptions(screen.getByLabelText('Minimum rating'), '5');
const cards = screen.getAllByRole('article');
expect(cards).toHaveLength(2);
expect(screen.queryByText('Femi Yusuf')).not.toBeInTheDocument();
});

it('narrows results with the date filter', async () => {
const user = userEvent.setup();
render(<ReputationReviewGrid driverId="driver-1" />);
await user.selectOptions(screen.getByLabelText('Date range'), '7d');
expect(screen.getAllByRole('article')).toHaveLength(3);
expect(screen.queryByText('Efe Obi')).not.toBeInTheDocument();
});

it('sorts by highest and lowest rating', async () => {
const user = userEvent.setup();
render(<ReputationReviewGrid driverId="driver-1" />);
await user.selectOptions(screen.getByLabelText('Sort by'), 'rating_desc');
expect(within(screen.getAllByRole('article')[0]).getByText('Ada Okafor')).toBeInTheDocument();
await user.selectOptions(screen.getByLabelText('Sort by'), 'rating_asc');
expect(within(screen.getAllByRole('article')[0]).getByText('Femi Yusuf')).toBeInTheDocument();
});

it('sorts by newest and oldest date', async () => {
const user = userEvent.setup();
render(<ReputationReviewGrid driverId="driver-1" />);
expect(within(screen.getAllByRole('article')[0]).getByText('Ada Okafor')).toBeInTheDocument();
await user.selectOptions(screen.getByLabelText('Sort by'), 'date_asc');
expect(within(screen.getAllByRole('article')[0]).getByText('Efe Obi')).toBeInTheDocument();
});

it('shows only verified reviews when verified-only is checked', async () => {
const user = userEvent.setup();
render(<ReputationReviewGrid driverId="driver-1" />);
await user.click(screen.getByLabelText('Verified only'));
const cards = screen.getAllByRole('article');
expect(cards).toHaveLength(3);
cards.forEach((card) => expect(within(card).getByText('Verified')).toBeInTheDocument());
});

it('loads more results when Load more is clicked', async () => {
const user = userEvent.setup();
mockUseDriverReviews.mockReturnValue(
result({ reviews: driverReviewFixtures, hasNextPage: true }),
);
render(<ReputationReviewGrid driverId="driver-1" />);
await user.click(screen.getByRole('button', { name: 'Load more' }));
expect(fetchNextPage).toHaveBeenCalledTimes(1);
});

it('hides Load more when there are no further pages', () => {
render(<ReputationReviewGrid driverId="driver-1" />);
expect(screen.queryByRole('button', { name: 'Load more' })).not.toBeInTheDocument();
});

it('disables the button while the next page is loading', () => {
mockUseDriverReviews.mockReturnValue(
result({ reviews: driverReviewFixtures, hasNextPage: true, isFetchingNextPage: true }),
);
render(<ReputationReviewGrid driverId="driver-1" />);
expect(screen.getByRole('button', { name: 'Loading more...' })).toBeDisabled();
});

it('shows the empty state when there are no reviews', () => {
mockUseDriverReviews.mockReturnValue(result({ reviews: [] }));
render(<ReputationReviewGrid driverId="driver-1" />);
expect(screen.getByText('No reviews match your filters.')).toBeInTheDocument();
expect(screen.queryByRole('article')).not.toBeInTheDocument();
});

it('shows a loading state', () => {
mockUseDriverReviews.mockReturnValue(result({ isLoading: true }));
render(<ReputationReviewGrid driverId="driver-1" />);
expect(screen.getByRole('status')).toHaveTextContent('Loading reviews...');
});

it('shows an error with a working retry', async () => {
const user = userEvent.setup();
mockUseDriverReviews.mockReturnValue(result({ error: 'Failed to load reviews' }));
render(<ReputationReviewGrid driverId="driver-1" />);
expect(screen.getByRole('alert')).toHaveTextContent('Failed to load reviews');
await user.click(screen.getByRole('button', { name: 'Retry' }));
expect(refetch).toHaveBeenCalledTimes(1);
});
});
69 changes: 69 additions & 0 deletions __tests__/fixtures/driverReviewFixtures.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import type {
DriverReview,
DriverReviewFilters,
DriverReviewsPage,
} from '@/types/driverReview';

const DAY_MS = 86_400_000;
const daysAgo = (n: number): string => new Date(Date.now() - n * DAY_MS).toISOString();

const review = (
id: string,
reviewerName: string,
rating: number,
verified: boolean,
ageDays: number,
): DriverReview => ({
id,
driverId: 'driver-1',
reviewerName,
rating,
comment: `Comment from ${reviewerName}`,
createdAt: daysAgo(ageDays),
verified,
});

/** Shape-identical to the backend /fleet/drivers/:id/reviews payload. */
export const driverReviewFixtures: DriverReview[] = [
review('r1', 'Ada Okafor', 5, true, 1),
review('r2', 'Bola Ade', 4, false, 5),
review('r3', 'Chi Eze', 3, true, 20),
review('r4', 'Dayo Bello', 5, false, 40),
review('r5', 'Efe Obi', 2, true, 100),
review('r6', 'Femi Yusuf', 1, false, 3),
];

const RANGE_DAYS = { all: Infinity, '7d': 7, '30d': 30, '90d': 90 } as const;

/** Mirrors the server contract so tests can assert filter/sort behaviour. */
export function applyReviewFilters(
reviews: DriverReview[],
filters: DriverReviewFilters,
): DriverReview[] {
const cutoff = Date.now() - RANGE_DAYS[filters.dateRange] * DAY_MS;
const time = (r: DriverReview) => Date.parse(r.createdAt);
return reviews
.filter((r) => (filters.minRating ? r.rating >= filters.minRating : true))
.filter((r) => (filters.verifiedOnly ? r.verified : true))
.filter((r) => time(r) >= cutoff)
.sort((a, b) => {
switch (filters.sort) {
case 'date_asc':
return time(a) - time(b);
case 'rating_desc':
return b.rating - a.rating || time(b) - time(a);
case 'rating_asc':
return a.rating - b.rating || time(b) - time(a);
default:
return time(b) - time(a);
}
});
}

export function buildReviewsPage(
reviews: DriverReview[],
page: number,
hasMore: boolean,
): DriverReviewsPage {
return { reviews, page, hasMore, total: reviews.length };
}
65 changes: 65 additions & 0 deletions __tests__/hooks/useDriverReviews.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { renderHook, waitFor, act } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { createElement } from 'react';
import { useDriverReviews, REVIEWS_PAGE_SIZE } from '@/hooks/useDriverReviews';
import { driverReviewService } from '@/services/driverReviewService';
import { DEFAULT_REVIEW_FILTERS } from '@/components/fleet/ReputationReviewGrid';
import { buildReviewsPage, driverReviewFixtures } from '../fixtures/driverReviewFixtures';

jest.mock('@/services/driverReviewService', () => ({
driverReviewService: { getDriverReviews: jest.fn() },
}));

const getReviews = driverReviewService.getDriverReviews as jest.Mock;

const wrapper = ({ children }: { children: React.ReactNode }) =>
createElement(
QueryClientProvider,
{ client: new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } }) },
children,
);

beforeEach(() => jest.clearAllMocks());

describe('useDriverReviews', () => {
it('fetches the first page through the service with filters and page size', async () => {
getReviews.mockResolvedValueOnce(buildReviewsPage(driverReviewFixtures.slice(0, 3), 1, false));
const { result } = renderHook(() => useDriverReviews('driver-1', DEFAULT_REVIEW_FILTERS), { wrapper });
await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(getReviews).toHaveBeenCalledWith(
'driver-1',
{ ...DEFAULT_REVIEW_FILTERS, page: 1, pageSize: REVIEWS_PAGE_SIZE },
expect.anything(),
);
expect(result.current.reviews).toHaveLength(3);
expect(result.current.hasNextPage).toBe(false);
});

it('appends the next page when fetchNextPage is called', async () => {
getReviews
.mockResolvedValueOnce(buildReviewsPage(driverReviewFixtures.slice(0, 3), 1, true))
.mockResolvedValueOnce(buildReviewsPage(driverReviewFixtures.slice(3), 2, false));
const { result } = renderHook(() => useDriverReviews('driver-1', DEFAULT_REVIEW_FILTERS), { wrapper });
await waitFor(() => expect(result.current.hasNextPage).toBe(true));
act(() => result.current.fetchNextPage());
await waitFor(() => expect(result.current.reviews).toHaveLength(driverReviewFixtures.length));
expect(getReviews).toHaveBeenLastCalledWith(
'driver-1',
expect.objectContaining({ page: 2 }),
expect.anything(),
);
expect(result.current.hasNextPage).toBe(false);
});

it('exposes a message when the service fails', async () => {
getReviews.mockRejectedValueOnce(new Error('boom'));
const { result } = renderHook(() => useDriverReviews('driver-1', DEFAULT_REVIEW_FILTERS), { wrapper });
await waitFor(() => expect(result.current.error).toBe('boom'));
expect(result.current.reviews).toEqual([]);
});

it('does not fetch without a driverId', () => {
renderHook(() => useDriverReviews('', DEFAULT_REVIEW_FILTERS), { wrapper });
expect(getReviews).not.toHaveBeenCalled();
});
});
57 changes: 57 additions & 0 deletions hooks/useDriverReviews.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
'use client';

import { useInfiniteQuery } from '@tanstack/react-query';
import { driverReviewService } from '@/services/driverReviewService';
import type { DriverReview, DriverReviewFilters } from '@/types/driverReview';

export const REVIEWS_PAGE_SIZE = 6;

export interface UseDriverReviewsResult {
reviews: DriverReview[];
isLoading: boolean;
error: string | null;
hasNextPage: boolean;
isFetchingNextPage: boolean;
fetchNextPage: () => void;
refetch: () => void;
}

/**
* useDriverReviews - paginated (load more) driver reviews with server-side
* filtering and sorting. Components consume this hook, never the service.
*/
export function useDriverReviews(
driverId: string,
filters: DriverReviewFilters,
): UseDriverReviewsResult {
const query = useInfiniteQuery({
queryKey: ['driver-reviews', driverId, filters],
queryFn: ({ pageParam, signal }) =>
driverReviewService.getDriverReviews(
driverId,
{ ...filters, page: pageParam, pageSize: REVIEWS_PAGE_SIZE },
signal,
),
initialPageParam: 1,
getNextPageParam: (last) => (last.hasMore ? last.page + 1 : undefined),
enabled: Boolean(driverId),
});

return {
reviews: query.data?.pages.flatMap((p) => p.reviews) ?? [],
isLoading: query.isLoading,
error: query.error
? query.error instanceof Error && query.error.message
? query.error.message
: 'Failed to load reviews'
: null,
hasNextPage: Boolean(query.hasNextPage),
isFetchingNextPage: query.isFetchingNextPage,
fetchNextPage: () => {
void query.fetchNextPage();
},
refetch: () => {
void query.refetch();
},
};
}
32 changes: 32 additions & 0 deletions services/driverReviewService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import axios from 'axios';
import type { DriverReviewsPage, DriverReviewsQuery } from '@/types/driverReview';

const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL ?? '';

/**
* driverReviewService - fetches a driver's paginated reviews from the backend.
* Hooks call this; components never call it directly.
*/
export const driverReviewService = {
async getDriverReviews(
driverId: string,
query: DriverReviewsQuery,
signal?: AbortSignal,
): Promise<DriverReviewsPage> {
const { data } = await axios.get<DriverReviewsPage>(
`${API_BASE_URL}/fleet/drivers/${driverId}/reviews`,
{
params: {
page: query.page,
pageSize: query.pageSize,
sort: query.sort,
dateRange: query.dateRange,
verifiedOnly: query.verifiedOnly,
...(query.minRating ? { minRating: query.minRating } : {}),
},
signal,
},
);
return data;
},
};
Loading
Loading