Skip to content
Open
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
117 changes: 117 additions & 0 deletions __tests__/components/settings/NotificationPreferences.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { NotificationPreferences } from '@/components/settings/NotificationPreferences';
import { useNotificationPreferences } from '@/hooks/useNotificationPreferences';
import { toast } from 'sonner';

jest.mock('@/hooks/useNotificationPreferences');
jest.mock('sonner', () => ({
toast: { error: jest.fn(), success: jest.fn() },
}));

const mockedHook = useNotificationPreferences as jest.MockedFunction<
typeof useNotificationPreferences
>;
const togglePreference = jest.fn();
const savePreferences = jest.fn().mockResolvedValue(true);
const resetPreferences = jest.fn();
const retry = jest.fn().mockResolvedValue(undefined);

const groupedPreferences = {
deliveries: [
{
key: 'delivery_created_in_app',
category: 'deliveries' as const,
label: 'Delivery Created',
channel: 'in_app' as const,
enabled: true,
},
],
escrow: [
{
key: 'escrow_locked_in_app',
category: 'escrow' as const,
label: 'Escrow Locked',
channel: 'in_app' as const,
enabled: false,
},
],
marketing: [],
system: [],
};

function mockHook(
overrides: Partial<ReturnType<typeof useNotificationPreferences>> = {},
) {
mockedHook.mockReturnValue({
preferences: groupedPreferences,
isLoading: false,
isSaving: false,
error: null,
togglePreference,
savePreferences,
clearError: jest.fn(),
resetPreferences,
retry,
...overrides,
});
}

describe('NotificationPreferences page contract', () => {
beforeEach(() => {
jest.clearAllMocks();
mockHook();
});

it('renders grouped categories and their toggles', () => {
render(<NotificationPreferences />);
expect(screen.getByTestId('category-deliveries')).toBeInTheDocument();
expect(screen.getByTestId('category-escrow')).toBeInTheDocument();
expect(screen.getByRole('switch', { name: 'Delivery Created notification' })).toBeChecked();
});

it('persists toggle changes when save is selected', async () => {
const user = userEvent.setup();
render(<NotificationPreferences />);

await user.click(screen.getByRole('switch', { name: 'Escrow Locked notification' }));
expect(togglePreference).toHaveBeenCalledWith('escrow_locked_in_app', true);
await user.click(screen.getByRole('button', { name: 'Save Preferences' }));
expect(savePreferences).toHaveBeenCalledTimes(1);
});

it('shows a save confirmation toast', async () => {
const user = userEvent.setup();
render(<NotificationPreferences />);
await user.click(screen.getByRole('button', { name: 'Save Preferences' }));

await waitFor(() =>
expect(toast.success).toHaveBeenCalledWith('Notification preferences saved'),
);
});

it('resets preferences to the backend defaults', async () => {
const user = userEvent.setup();
render(<NotificationPreferences />);
await user.click(screen.getByRole('button', { name: 'Reset to defaults' }));
expect(resetPreferences).toHaveBeenCalledTimes(1);
});

it('shows one empty state when no preferences are loaded', () => {
mockHook({
preferences: { deliveries: [], escrow: [], marketing: [], system: [] },
});
render(<NotificationPreferences />);
expect(screen.getByText('No notification preferences available.')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Save Preferences' })).toBeDisabled();
});

it('shows an error state and retries loading', async () => {
mockHook({ error: 'Unable to load preferences' });
const user = userEvent.setup();
render(<NotificationPreferences />);
expect(screen.getByRole('alert')).toHaveTextContent('Unable to load preferences');
await user.click(screen.getByRole('button', { name: 'Retry' }));
expect(retry).toHaveBeenCalledTimes(1);
});
});
51 changes: 48 additions & 3 deletions __tests__/hooks/useDriverReviews.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,25 @@ 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 type { DriverReviewFilters } from '@/types/driverReview';
import { buildReviewsPage, driverReviewFixtures } from '../fixtures/driverReviewFixtures';

const DEFAULT_REVIEW_FILTERS: DriverReviewFilters = {
minRating: null,
dateRange: 'all',
verifiedOnly: false,
sort: 'date_desc',
};

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

const getReviews = driverReviewService.getDriverReviews as jest.Mock;
const getReputation = driverReviewService.getDriverReputation as jest.Mock;

const wrapper = ({ children }: { children: React.ReactNode }) =>
createElement(
Expand All @@ -19,7 +30,17 @@ const wrapper = ({ children }: { children: React.ReactNode }) =>
children,
);

beforeEach(() => jest.clearAllMocks());
beforeEach(() => {
jest.clearAllMocks();
getReputation.mockResolvedValue({
driverId: 'driver-1',
score: 92,
totalReviews: 24,
verifiedReviews: 20,
updatedAt: '2026-10-18T00:00:00.000Z',
source: 'on_chain',
});
});

describe('useDriverReviews', () => {
it('fetches the first page through the service with filters and page size', async () => {
Expand All @@ -33,6 +54,14 @@ describe('useDriverReviews', () => {
);
expect(result.current.reviews).toHaveLength(3);
expect(result.current.hasNextPage).toBe(false);
expect(result.current.reputation?.score).toBe(92);
expect(result.current.pagination).toEqual({
loaded: 3,
total: 3,
currentPage: 1,
pageSize: REVIEWS_PAGE_SIZE,
hasMore: false,
});
});

it('appends the next page when fetchNextPage is called', async () => {
Expand Down Expand Up @@ -61,5 +90,21 @@ describe('useDriverReviews', () => {
it('does not fetch without a driverId', () => {
renderHook(() => useDriverReviews('', DEFAULT_REVIEW_FILTERS), { wrapper });
expect(getReviews).not.toHaveBeenCalled();
expect(getReputation).not.toHaveBeenCalled();
});

it('fetches the on-chain reputation from its dedicated endpoint service', async () => {
getReviews.mockResolvedValueOnce(buildReviewsPage([], 1, false));
const { result } = renderHook(
() => useDriverReviews('driver-1', DEFAULT_REVIEW_FILTERS),
{ wrapper },
);

await waitFor(() => expect(result.current.isLoading).toBe(false));

expect(getReputation).toHaveBeenCalledWith('driver-1', expect.anything());
expect(result.current.reputation).toEqual(
expect.objectContaining({ score: 92, source: 'on_chain' }),
);
});
});
117 changes: 117 additions & 0 deletions components/deliveries/SignatureCanvas.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
'use client';

import { useRef } from 'react';
import { Eraser, Save } from 'lucide-react';
import { useSignatureCanvas } from '@/hooks/useSignatureCanvas';

export interface SignatureCanvasProps {
onSave: (_dataUrl: string) => void;
width?: number;
height?: number;
disabled?: boolean;
className?: string;
}

/** Canvas signature capture with a keyboard-accessible typed fallback. */
export function SignatureCanvas({
onSave,
width = 720,
height = 260,
disabled = false,
className = '',
}: SignatureCanvasProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const {
beginStroke,
clear,
continueStroke,
endStroke,
hasSignature,
isDrawing,
isTooLight,
save,
setTypedSignature,
typedSignature,
} = useSignatureCanvas({ canvasRef, onSave, disabled });

const cannotSave = disabled || !hasSignature || isTooLight;

return (
<section
className={`space-y-4 rounded-lg border border-slate-200 bg-white p-4 ${className}`.trim()}
aria-labelledby="recipient-signature-heading"
>
<div>
<h2 id="recipient-signature-heading" className="text-sm font-semibold text-slate-900">
Recipient signature
</h2>
<p className="mt-1 text-sm text-slate-600">
Sign inside the box using a mouse, stylus, or touch screen.
</p>
</div>

<canvas
ref={canvasRef}
width={width}
height={height}
aria-label="Recipient signature drawing area"
data-drawing={isDrawing}
className="block aspect-[18/7] w-full touch-none rounded border border-slate-300 bg-white focus:outline-none focus:ring-2 focus:ring-blue-600 disabled:cursor-not-allowed"
onPointerDown={beginStroke}
onPointerMove={continueStroke}
onPointerUp={endStroke}
onPointerCancel={endStroke}
onPointerLeave={endStroke}
role="img"
/>

{hasSignature && isTooLight && (
<p role="alert" className="text-sm font-medium text-amber-700">
Signature is too light. Add a longer stroke before saving.
</p>
)}

<div>
<label htmlFor="typed-signature" className="text-sm font-medium text-slate-800">
Keyboard signature fallback
</label>
<p id="typed-signature-help" className="mt-1 text-xs text-slate-500">
Type the recipient&apos;s name when drawing is not accessible.
</p>
<textarea
id="typed-signature"
value={typedSignature}
onChange={(event) => setTypedSignature(event.target.value)}
disabled={disabled}
aria-describedby="typed-signature-help"
rows={2}
className="mt-2 w-full resize-none rounded border border-slate-300 px-3 py-2 text-sm text-slate-900 focus:border-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-100 disabled:bg-slate-100"
placeholder="Type recipient name"
/>
</div>

<div className="flex flex-wrap justify-end gap-2">
<button
type="button"
onClick={clear}
disabled={disabled || !hasSignature}
className="inline-flex min-h-10 items-center justify-center gap-2 rounded border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-50"
>
<Eraser aria-hidden="true" className="h-4 w-4" />
Clear
</button>
<button
type="button"
onClick={save}
disabled={cannotSave}
className="inline-flex min-h-10 items-center justify-center gap-2 rounded bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:bg-slate-300"
>
<Save aria-hidden="true" className="h-4 w-4" />
Save signature
</button>
</div>
</section>
);
}

export default SignatureCanvas;
Loading
Loading