diff --git a/__tests__/components/fleet/ReputationReviewGrid.test.tsx b/__tests__/components/fleet/ReputationReviewGrid.test.tsx new file mode 100644 index 0000000..cd9662d --- /dev/null +++ b/__tests__/components/fleet/ReputationReviewGrid.test.tsx @@ -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; + +const fetchNextPage = jest.fn(); +const refetch = jest.fn(); + +function result(overrides: Partial = {}): 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + await user.click(screen.getByRole('button', { name: 'Load more' })); + expect(fetchNextPage).toHaveBeenCalledTimes(1); + }); + + it('hides Load more when there are no further pages', () => { + render(); + 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(); + expect(screen.getByRole('button', { name: 'Loading more...' })).toBeDisabled(); + }); + + it('shows the empty state when there are no reviews', () => { + mockUseDriverReviews.mockReturnValue(result({ reviews: [] })); + render(); + 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(); + 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(); + expect(screen.getByRole('alert')).toHaveTextContent('Failed to load reviews'); + await user.click(screen.getByRole('button', { name: 'Retry' })); + expect(refetch).toHaveBeenCalledTimes(1); + }); +}); diff --git a/__tests__/fixtures/driverReviewFixtures.ts b/__tests__/fixtures/driverReviewFixtures.ts new file mode 100644 index 0000000..2802b55 --- /dev/null +++ b/__tests__/fixtures/driverReviewFixtures.ts @@ -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 }; +} diff --git a/__tests__/hooks/useDriverReviews.test.ts b/__tests__/hooks/useDriverReviews.test.ts new file mode 100644 index 0000000..66fa867 --- /dev/null +++ b/__tests__/hooks/useDriverReviews.test.ts @@ -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(); + }); +}); diff --git a/hooks/useDriverReviews.ts b/hooks/useDriverReviews.ts new file mode 100644 index 0000000..ff27124 --- /dev/null +++ b/hooks/useDriverReviews.ts @@ -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(); + }, + }; +} diff --git a/services/driverReviewService.ts b/services/driverReviewService.ts new file mode 100644 index 0000000..cdfc5d7 --- /dev/null +++ b/services/driverReviewService.ts @@ -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 { + const { data } = await axios.get( + `${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; + }, +}; diff --git a/types/driverReview.ts b/types/driverReview.ts new file mode 100644 index 0000000..a69d537 --- /dev/null +++ b/types/driverReview.ts @@ -0,0 +1,31 @@ +export type ReviewSort = 'date_desc' | 'date_asc' | 'rating_desc' | 'rating_asc'; +export type ReviewDateRange = 'all' | '7d' | '30d' | '90d'; + +export interface DriverReview { + id: string; + driverId: string; + reviewerName: string; + rating: number; + comment: string; + createdAt: string; + verified: boolean; +} + +export interface DriverReviewFilters { + minRating: number | null; + dateRange: ReviewDateRange; + verifiedOnly: boolean; + sort: ReviewSort; +} + +export interface DriverReviewsQuery extends DriverReviewFilters { + page: number; + pageSize: number; +} + +export interface DriverReviewsPage { + reviews: DriverReview[]; + page: number; + hasMore: boolean; + total: number; +}