From 69a7fd9f9426d01ce1f0d901a213ff6f438896e1 Mon Sep 17 00:00:00 2001 From: Valerie <122825327+Jayy4rl@users.noreply.github.com> Date: Sat, 25 Jul 2026 15:53:47 +0100 Subject: [PATCH 01/25] fix: remove dead code and deduplicate utilization color logic (#266) - Remove unused usePool() hook from usePools.ts (#249) - Remove unused constants: HORIZON_URL, USDC_ASSET_CODE, USDC_ISSUER, USDC_ISSUER_TESTNET, ORACLE_CONTRACT_ID (#248) - Refactor utilizationColor() to extract color name logic, use in pools/page.tsx to eliminate duplication (#247) - Remove unused formatDuration() function (#246) --- src/app/pools/page.tsx | 4 ++-- src/hooks/usePools.ts | 21 +-------------------- src/lib/constants.ts | 16 ---------------- src/lib/format.ts | 23 +++++++++++++---------- 4 files changed, 16 insertions(+), 48 deletions(-) diff --git a/src/app/pools/page.tsx b/src/app/pools/page.tsx index 36e1d1d..0c5c050 100644 --- a/src/app/pools/page.tsx +++ b/src/app/pools/page.tsx @@ -2,7 +2,7 @@ import { useState, useEffect } from 'react'; import type { PoolStats } from '@/types'; -import { formatUSDC } from '@/lib/format'; +import { formatUSDC, getUtilizationColorName } from '@/lib/format'; import { Badge } from '@/components/Badge'; import { ProgressBar } from '@/components/ProgressBar'; import { EmptyState } from '@/components/EmptyState'; @@ -104,7 +104,7 @@ export default function PoolsPage() { 0.8 ? 'red' : pool.utilizationRate > 0.5 ? 'amber' : 'teal'} + colour={getUtilizationColorName(pool.utilizationRate)} /> diff --git a/src/hooks/usePools.ts b/src/hooks/usePools.ts index 09671de..26b002b 100644 --- a/src/hooks/usePools.ts +++ b/src/hooks/usePools.ts @@ -1,7 +1,7 @@ 'use client'; import { useState, useEffect, useCallback } from 'react'; -import { fetchPoolStats, fetchPoolById } from '@/lib/api'; +import { fetchPoolStats } from '@/lib/api'; import type { PoolStats } from '@/types'; export function usePools() { @@ -26,22 +26,3 @@ export function usePools() { return { pools, loading, error, refetch: load }; } - -export function usePool(poolId: string | null) { - const [pool, setPool] = useState(null); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - useEffect(() => { - if (!poolId) return; - let cancelled = false; - setLoading(true); - fetchPoolById(poolId) - .then((p) => { if (!cancelled) setPool(p); }) - .catch((err) => { if (!cancelled) setError(err instanceof Error ? err.message : 'Failed to load pool'); }) - .finally(() => { if (!cancelled) setLoading(false); }); - return () => { cancelled = true; }; - }, [poolId]); - - return { pool, loading, error }; -} diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 02a78ad..7f3fa35 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -4,25 +4,12 @@ export const API_URL = export const STELLAR_NETWORK = (process.env.NEXT_PUBLIC_STELLAR_NETWORK ?? 'TESTNET') as 'TESTNET' | 'PUBLIC'; -export const HORIZON_URL = - process.env.NEXT_PUBLIC_HORIZON_URL ?? - (STELLAR_NETWORK === 'PUBLIC' - ? 'https://horizon.stellar.org' - : 'https://horizon-testnet.stellar.org'); - export const SOROBAN_RPC_URL = process.env.NEXT_PUBLIC_SOROBAN_RPC_URL ?? (STELLAR_NETWORK === 'PUBLIC' ? 'https://soroban.stellar.org' : 'https://soroban-testnet.stellar.org'); -export const USDC_ASSET_CODE = 'USDC'; -export const USDC_ISSUER_TESTNET = 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5'; -export const USDC_ISSUER = - STELLAR_NETWORK === 'PUBLIC' - ? 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN' - : USDC_ISSUER_TESTNET; - export const STROOPS_PER_UNIT = 10_000_000n; // Minimum deposit: 0.01 USDC (100,000 stroops) @@ -33,9 +20,6 @@ export const MIN_DEPOSIT_STROOPS = 100_000n; export const POLICY_CONTRACT_ID = process.env.NEXT_PUBLIC_POLICY_CONTRACT_ID ?? ''; -export const ORACLE_CONTRACT_ID = - process.env.NEXT_PUBLIC_ORACLE_CONTRACT_ID ?? ''; - export const CLAIMS_CONTRACT_ID = process.env.NEXT_PUBLIC_CLAIMS_CONTRACT_ID ?? ''; diff --git a/src/lib/format.ts b/src/lib/format.ts index 4fa16ce..04fd880 100644 --- a/src/lib/format.ts +++ b/src/lib/format.ts @@ -64,13 +64,6 @@ export function formatDateTime(epochSeconds: number): string { }); } -export function formatDuration(days: number): string { - if (days === 1) return '1 day'; - if (days < 30) return `${days} days`; - if (days < 365) return `${Math.round(days / 30)} mo`; - return `${(days / 365).toFixed(1)} yr`; -} - export function basisPointsToPercent(bps: number, decimals = 2): string { return `${(bps / 100).toFixed(decimals)}%`; } @@ -126,10 +119,20 @@ export function timeLeft(endEpochSeconds: number): string { return `Expired ${formatDate(endEpochSeconds)}`; } +export function getUtilizationColorName(rate: number): 'teal' | 'amber' | 'red' { + if (rate < 0.5) return 'teal'; + if (rate < 0.8) return 'amber'; + return 'red'; +} + export function utilizationColor(rate: number): string { - if (rate < 0.5) return 'text-emerald-400'; - if (rate < 0.8) return 'text-amber-400'; - return 'text-red-400'; + const colorName = getUtilizationColorName(rate); + const colorMap: Record<'teal' | 'amber' | 'red', string> = { + teal: 'text-emerald-400', + amber: 'text-amber-400', + red: 'text-red-400', + }; + return colorMap[colorName]; } export function estimatePremium(coverageDisplay: string, premiumRateBps: number): string { From cd63178e6a90d152d8e04fbf2203c963d8b616a6 Mon Sep 17 00:00:00 2001 From: "Dev. Fashman" Date: Sat, 25 Jul 2026 16:11:00 +0100 Subject: [PATCH 02/25] fix: wire claim backend sync, validate config at startup, fix network comparison (#267) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - #240: Normalize STELLAR_NETWORK env var (uppercase + trim) so 'testnet'/'mainnet'/'public' all resolve correctly instead of silently falling through to testnet infrastructure. - #241: Add validateConfig() that checks POLICY/CLAIMS/ORACLE_CONTRACT_ID are non-empty and valid Stellar strkeys at app startup, so misconfiguration fails fast with a clear error instead of deep in the purchase/claim flow. - #244: Call api.submitClaim() after invokeSubmitClaim() succeeds in useClaim.ts, mirroring the existing buy-policy backend sync pattern so submitted claims reach the backend for UI polling/history. - #245: Remove unused oracleValueUnit() — its logic is already handled by formatOracleValue() in format.ts. Co-authored-by: Dev Jaja --- .env.example | 2 +- src/app/layout.tsx | 3 +++ src/hooks/useClaim.ts | 18 ++++++++++++++++- src/lib/constants.ts | 46 +++++++++++++++++++++++++++++++++++++++++-- src/lib/oracle.ts | 8 -------- 5 files changed, 65 insertions(+), 12 deletions(-) diff --git a/.env.example b/.env.example index 3102fe8..03dc5c3 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,5 @@ NEXT_PUBLIC_API_URL=http://localhost:3001/api/v1 -NEXT_PUBLIC_STELLAR_NETWORK=testnet +NEXT_PUBLIC_STELLAR_NETWORK=TESTNET NEXT_PUBLIC_CONTRACT_ID= NEXT_PUBLIC_POSTHOG_KEY=your_posthog_project_key_here NEXT_PUBLIC_POSTHOG_HOST=https://app.posthog.com diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 3fb2d20..c9a7483 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -7,9 +7,12 @@ import { WalletProvider } from '@/context/WalletContext'; import { ToastProvider } from '@/context/ToastContext'; import { Analytics } from '@/components/Analytics'; import { ErrorBoundary } from '@/components/ErrorBoundary'; +import { validateConfig } from '@/lib/constants'; import Link from 'next/link'; import { LogoWordmark } from '@/components/Logo'; +validateConfig(); + export const metadata: Metadata = { metadataBase: new URL('https://parashield.app'), title: 'Parashield — Parametric Insurance on Stellar', diff --git a/src/hooks/useClaim.ts b/src/hooks/useClaim.ts index 82210b4..4f9ac38 100644 --- a/src/hooks/useClaim.ts +++ b/src/hooks/useClaim.ts @@ -1,16 +1,18 @@ 'use client'; import { useState, useCallback, useRef, useEffect } from 'react'; -import { fetchClaim, fetchUserClaims } from '@/lib/api'; +import { fetchClaim, fetchUserClaims, submitClaim as recordClaimSubmission } from '@/lib/api'; import type { Claim } from '@/types'; import { toUserMessage } from '@/lib/errors'; import { invokeSubmitClaim } from '@/lib/contract'; import { useWallet } from '@/hooks/useWallet'; +import { useToast } from '@/context/ToastContext'; type ClaimStep = 'idle' | 'submitting' | 'polling' | 'done' | 'timeout' | 'error'; export function useClaim(policyId?: string) { const { address } = useWallet(); + const { show: showToast } = useToast(); const [step, setStep] = useState('idle'); const [claimId, setClaimId] = useState(null); const [claim, setClaim] = useState(null); @@ -113,6 +115,20 @@ export function useClaim(policyId?: string) { try { const txHash = await invokeSubmitClaim(claimant, policyId); if (cancelledRef.current) return { error: null }; + + // The claim is already final on-chain at this point; the backend still + // needs to be told about it or the claim never shows up in the UI, which + // reads from the API (issue #244). A failure here is not a failed + // claim, so surface it as a warning rather than an error. + try { + await recordClaimSubmission(claimant, policyId); + } catch (syncErr) { + showToast( + `Claim submitted on-chain (${txHash.slice(0, 8)}…) but could not be recorded: ${toUserMessage(syncErr)}`, + 'warning', + ); + } + setClaimId(txHash); setStep('polling'); return { error: null }; diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 7f3fa35..be24397 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -1,8 +1,19 @@ export const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3001/api/v1'; -export const STELLAR_NETWORK = - (process.env.NEXT_PUBLIC_STELLAR_NETWORK ?? 'TESTNET') as 'TESTNET' | 'PUBLIC'; +const _rawNetwork = (process.env.NEXT_PUBLIC_STELLAR_NETWORK ?? 'TESTNET') + .trim() + .toUpperCase() as 'TESTNET' | 'PUBLIC' | 'MAINNET'; + +if (_rawNetwork !== 'TESTNET' && _rawNetwork !== 'PUBLIC') { + console.warn( + `[parashield] Unrecognised NEXT_PUBLIC_STELLAR_NETWORK "${process.env.NEXT_PUBLIC_STELLAR_NETWORK}". ` + + 'Expected "TESTNET" or "PUBLIC". Falling back to TESTNET.', + ); +} + +export const STELLAR_NETWORK: 'TESTNET' | 'PUBLIC' = + _rawNetwork === 'PUBLIC' || _rawNetwork === 'MAINNET' ? 'PUBLIC' : 'TESTNET'; export const SOROBAN_RPC_URL = process.env.NEXT_PUBLIC_SOROBAN_RPC_URL ?? @@ -17,12 +28,43 @@ export const STROOPS_PER_UNIT = 10_000_000n; // This prevents dust deposits that may round to 0 or be rejected by the pool contract. export const MIN_DEPOSIT_STROOPS = 100_000n; +const _CONTRACT_RE = /^C[A-Z2-7]{55}$/; + +function validateContractId(envKey: string, label: string): string { + const raw = process.env[envKey] ?? ''; + const id = raw.trim(); + if (!id) { + throw new Error( + `[parashield] ${label} (${envKey}) is not set. ` + + `Add ${envKey}= to your .env file and restart the app.`, + ); + } + if (!_CONTRACT_RE.test(id)) { + throw new Error( + `[parashield] ${label} (${envKey}) is invalid: "${id}". ` + + 'Expected a Stellar contract ID (starts with C, 56 alphanumeric characters).', + ); + } + return id; +} + export const POLICY_CONTRACT_ID = process.env.NEXT_PUBLIC_POLICY_CONTRACT_ID ?? ''; export const CLAIMS_CONTRACT_ID = process.env.NEXT_PUBLIC_CLAIMS_CONTRACT_ID ?? ''; +/** + * Validate that all required contract IDs are present and well-formed. + * Call once at app startup (e.g. from layout.tsx) so misconfiguration + * fails fast with a clear message instead of deep in a purchase/claim flow. + */ +export function validateConfig(): void { + validateContractId('NEXT_PUBLIC_POLICY_CONTRACT_ID', 'Policy Contract ID'); + validateContractId('NEXT_PUBLIC_ORACLE_CONTRACT_ID', 'Oracle Contract ID'); + validateContractId('NEXT_PUBLIC_CLAIMS_CONTRACT_ID', 'Claims Contract ID'); +} + import type { Category, PolicyStatus, ClaimStatus } from '@/types'; export const CATEGORY_LABELS: Record = { diff --git a/src/lib/oracle.ts b/src/lib/oracle.ts index d8c1581..6ae7eee 100644 --- a/src/lib/oracle.ts +++ b/src/lib/oracle.ts @@ -54,14 +54,6 @@ export function oracleKeyLabel(key: string): string { } } -export function oracleValueUnit(dataType: string): string { - if (dataType === 'rainfall') return 'mm'; - if (dataType === 'temperature') return '°C'; - if (dataType === 'flight') return 'min'; - if (dataType === 'unknown') return ''; - return ''; -} - export function confidenceLabel(confidence: number): string { if (confidence >= 90) return 'High'; if (confidence >= 70) return 'Medium'; From 8fa47eff7ca3f8e0c7deb6ab520c6605eb9ec5ad Mon Sep 17 00:00:00 2001 From: Ada-Girly881 Date: Sat, 25 Jul 2026 17:10:32 +0100 Subject: [PATCH 03/25] fix: remove dead useProduct hook, dynamic footer year, memoize list cards, add hook tests (#268) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove the unused useProduct(id) hook from useProducts.ts — no product detail page consumes it, matching the dead-code pattern already fixed elsewhere in the codebase. - Compute the footer copyright year once via new Date().getFullYear() in layout.tsx and reuse it in both the desktop and mobile footers instead of the hardcoded "© 2026" literal. - Wrap PolicyCard, ProductCard, and ClaimHistoryTable in React.memo so list/table components rendered inside .map() don't re-render on every background poll tick when their own props haven't changed. - Add vitest coverage for all 8 custom hooks (useClaim, useDebounce, useKeyboardShortcut, useOracle, usePolicies, usePools, useProducts, useWallet), with dedicated regression tests for the loading/error/ cancellation and stale-response-guard behavior previously fixed by hand in useClaim, usePolicies, and useOracle. Closes #250, #251, #252, #253 --- src/__tests__/renderHook.tsx | 48 +++++++ src/__tests__/useClaim.test.tsx | 156 +++++++++++++++++++++ src/__tests__/useDebounce.test.tsx | 52 +++++++ src/__tests__/useKeyboardShortcut.test.tsx | 75 ++++++++++ src/__tests__/useOracle.test.tsx | 145 +++++++++++++++++++ src/__tests__/usePolicies.test.tsx | 122 ++++++++++++++++ src/__tests__/usePools.test.tsx | 65 +++++++++ src/__tests__/useProducts.test.tsx | 70 +++++++++ src/__tests__/useWallet.test.tsx | 44 ++++++ src/app/layout.tsx | 6 +- src/components/ClaimHistoryTable.tsx | 6 +- src/components/PolicyCard.tsx | 5 +- src/components/ProductCard.tsx | 6 +- src/hooks/useProducts.ts | 22 +-- 14 files changed, 794 insertions(+), 28 deletions(-) create mode 100644 src/__tests__/renderHook.tsx create mode 100644 src/__tests__/useClaim.test.tsx create mode 100644 src/__tests__/useDebounce.test.tsx create mode 100644 src/__tests__/useKeyboardShortcut.test.tsx create mode 100644 src/__tests__/useOracle.test.tsx create mode 100644 src/__tests__/usePolicies.test.tsx create mode 100644 src/__tests__/usePools.test.tsx create mode 100644 src/__tests__/useProducts.test.tsx create mode 100644 src/__tests__/useWallet.test.tsx diff --git a/src/__tests__/renderHook.tsx b/src/__tests__/renderHook.tsx new file mode 100644 index 0000000..c0ae1b4 --- /dev/null +++ b/src/__tests__/renderHook.tsx @@ -0,0 +1,48 @@ +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; + +export interface HookHandle { + get current(): T; + rerender(): void; + unmount(): void; +} + +export function renderHook(useHook: () => T): HookHandle { + let result: T = undefined!; + let root: Root; + + const container = document.createElement('div'); + + function TestComponent() { + result = useHook(); + return null; + } + + act(() => { + root = createRoot(container); + root.render(); + }); + + return { + get current() { + return result; + }, + rerender() { + act(() => { + root.render(); + }); + }, + unmount() { + act(() => { + root.unmount(); + }); + }, + }; +} + +export function flushMicrotasks(): Promise { + return act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); +} diff --git a/src/__tests__/useClaim.test.tsx b/src/__tests__/useClaim.test.tsx new file mode 100644 index 0000000..3f2a319 --- /dev/null +++ b/src/__tests__/useClaim.test.tsx @@ -0,0 +1,156 @@ +import { act } from 'react'; +import { useClaim } from '../hooks/useClaim'; +import { renderHook, flushMicrotasks } from './renderHook'; +import type { Claim } from '../types'; + +const { fetchClaim, fetchUserClaims, invokeSubmitClaim, useWallet } = vi.hoisted(() => ({ + fetchClaim: vi.fn(), + fetchUserClaims: vi.fn(), + invokeSubmitClaim: vi.fn(), + useWallet: vi.fn(), +})); + +vi.mock('@/lib/api', () => ({ fetchClaim, fetchUserClaims })); +vi.mock('@/lib/contract', () => ({ invokeSubmitClaim })); +vi.mock('@/hooks/useWallet', () => ({ useWallet })); + +function makeClaim(overrides: Partial = {}): Claim { + return { + id: 'claim-1', + policyId: 'policy-1', + claimant: 'GWALLET', + triggerMet: true, + status: 'Processing', + submittedAt: 1_720_000_000, + processedAt: null, + ...overrides, + }; +} + +describe('useClaim', () => { + beforeEach(() => { + fetchClaim.mockReset(); + fetchUserClaims.mockReset(); + invokeSubmitClaim.mockReset(); + useWallet.mockReset(); + useWallet.mockReturnValue({ address: 'GWALLET' }); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('stays idle when there is no wallet or policyId', async () => { + useWallet.mockReturnValue({ address: null }); + const hook = renderHook(() => useClaim(undefined)); + await flushMicrotasks(); + + expect(hook.current.step).toBe('idle'); + expect(fetchUserClaims).not.toHaveBeenCalled(); + }); + + it('picks up an existing in-flight claim and starts polling', async () => { + const existing = makeClaim({ status: 'Processing' }); + fetchUserClaims.mockResolvedValue([existing]); + + const hook = renderHook(() => useClaim('policy-1')); + await flushMicrotasks(); + + expect(hook.current.step).toBe('polling'); + expect(hook.current.claim).toEqual(existing); + expect(hook.current.claimId).toBe(existing.id); + }); + + it('marks an already-settled claim as done without polling', async () => { + const settled = makeClaim({ status: 'Paid', txHash: 'tx-abc' }); + fetchUserClaims.mockResolvedValue([settled]); + + const hook = renderHook(() => useClaim('policy-1')); + await flushMicrotasks(); + + expect(hook.current.step).toBe('done'); + expect(hook.current.claimId).toBe('tx-abc'); + }); + + it('submits a claim and transitions from submitting to polling', async () => { + fetchUserClaims.mockResolvedValue([]); + invokeSubmitClaim.mockResolvedValue('tx-hash-1'); + + const hook = renderHook(() => useClaim('policy-1')); + await flushMicrotasks(); + expect(hook.current.step).toBe('idle'); + + let submitResult: { error: string | null } | undefined; + await act(async () => { + submitResult = await hook.current.submit('GWALLET', 'policy-1'); + }); + + expect(submitResult).toEqual({ error: null }); + expect(hook.current.step).toBe('polling'); + expect(hook.current.claimId).toBe('tx-hash-1'); + }); + + it('surfaces a user-facing error and stops at the error step when submission fails', async () => { + fetchUserClaims.mockResolvedValue([]); + invokeSubmitClaim.mockRejectedValue(new Error('simulation failed')); + + const hook = renderHook(() => useClaim('policy-1')); + await flushMicrotasks(); + + let submitResult: { error: string | null } | undefined; + await act(async () => { + submitResult = await hook.current.submit('GWALLET', 'policy-1'); + }); + + expect(hook.current.step).toBe('error'); + expect(hook.current.error).toBe('simulation failed'); + expect(submitResult?.error).toBe('simulation failed'); + }); + + it('moves to the timeout step after 20 unresolved polling attempts', async () => { + fetchUserClaims.mockResolvedValue([makeClaim({ status: 'Processing' })]); + fetchClaim.mockResolvedValue(makeClaim({ status: 'Processing' })); + + const hook = renderHook(() => useClaim('policy-1')); + await flushMicrotasks(); + expect(hook.current.step).toBe('polling'); + + await act(async () => { + await vi.advanceTimersByTimeAsync(20 * 3000 + 100); + }); + + expect(hook.current.step).toBe('timeout'); + }); + + it('reset() during an in-flight submit prevents a late response from resurrecting the claim', async () => { + fetchUserClaims.mockResolvedValue([]); + let resolveSubmit!: (txHash: string) => void; + invokeSubmitClaim.mockImplementation( + () => new Promise((resolve) => { resolveSubmit = resolve; }), + ); + + const hook = renderHook(() => useClaim('policy-1')); + await flushMicrotasks(); + + let submitPromise!: Promise<{ error: string | null }>; + act(() => { + submitPromise = hook.current.submit('GWALLET', 'policy-1'); + }); + expect(hook.current.step).toBe('submitting'); + + // User cancels (e.g. navigates away) before the contract call settles. + act(() => { hook.current.reset(); }); + expect(hook.current.step).toBe('idle'); + + // The contract call now resolves late — the cancelledRef guard must stop + // submit() from reviving polling after reset() already cancelled it. + await act(async () => { + resolveSubmit('tx-hash-late'); + await submitPromise; + }); + + expect(hook.current.step).toBe('idle'); + expect(hook.current.claimId).toBeNull(); + }); +}); diff --git a/src/__tests__/useDebounce.test.tsx b/src/__tests__/useDebounce.test.tsx new file mode 100644 index 0000000..c15ba50 --- /dev/null +++ b/src/__tests__/useDebounce.test.tsx @@ -0,0 +1,52 @@ +import { act } from 'react'; +import { useDebounce } from '../hooks/useDebounce'; +import { renderHook } from './renderHook'; + +describe('useDebounce', () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it('returns the initial value immediately', () => { + const hook = renderHook(() => useDebounce('first', 300)); + expect(hook.current).toBe('first'); + }); + + it('does not update before the delay has elapsed', () => { + let value = 'first'; + const hook = renderHook(() => useDebounce(value, 300)); + + value = 'second'; + hook.rerender(); + act(() => { vi.advanceTimersByTime(299); }); + + expect(hook.current).toBe('first'); + }); + + it('updates to the latest value once the delay elapses', () => { + let value = 'first'; + const hook = renderHook(() => useDebounce(value, 300)); + + value = 'second'; + hook.rerender(); + act(() => { vi.advanceTimersByTime(300); }); + + expect(hook.current).toBe('second'); + }); + + it('resets the timer on rapid successive changes, keeping only the final value', () => { + let value = 'first'; + const hook = renderHook(() => useDebounce(value, 300)); + + value = 'second'; + hook.rerender(); + act(() => { vi.advanceTimersByTime(200); }); + + value = 'third'; + hook.rerender(); + act(() => { vi.advanceTimersByTime(200); }); + expect(hook.current).toBe('first'); + + act(() => { vi.advanceTimersByTime(100); }); + expect(hook.current).toBe('third'); + }); +}); diff --git a/src/__tests__/useKeyboardShortcut.test.tsx b/src/__tests__/useKeyboardShortcut.test.tsx new file mode 100644 index 0000000..14f82bf --- /dev/null +++ b/src/__tests__/useKeyboardShortcut.test.tsx @@ -0,0 +1,75 @@ +import { act } from 'react'; +import { useKeyboardShortcut } from '../hooks/useKeyboardShortcut'; +import { renderHook } from './renderHook'; + +function pressKey(key: string, modifiers: Partial = {}) { + act(() => { + window.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, ...modifiers })); + }); +} + +describe('useKeyboardShortcut', () => { + it('invokes the handler when the matching key is pressed', () => { + const handler = vi.fn(); + renderHook(() => useKeyboardShortcut('k', handler)); + + pressKey('k'); + + expect(handler).toHaveBeenCalledTimes(1); + }); + + it('ignores keys that do not match', () => { + const handler = vi.fn(); + renderHook(() => useKeyboardShortcut('k', handler)); + + pressKey('j'); + + expect(handler).not.toHaveBeenCalled(); + }); + + it('is case-insensitive', () => { + const handler = vi.fn(); + renderHook(() => useKeyboardShortcut('k', handler)); + + pressKey('K'); + + expect(handler).toHaveBeenCalledTimes(1); + }); + + it('requires configured modifiers to be held', () => { + const handler = vi.fn(); + renderHook(() => useKeyboardShortcut('k', handler, { ctrl: true })); + + pressKey('k'); + expect(handler).not.toHaveBeenCalled(); + + pressKey('k', { ctrlKey: true }); + expect(handler).toHaveBeenCalledTimes(1); + }); + + it('calls the latest handler after a re-render without double-registering the listener', () => { + const firstHandler = vi.fn(); + const secondHandler = vi.fn(); + let handler = firstHandler; + + const hook = renderHook(() => useKeyboardShortcut('k', handler)); + + handler = secondHandler; + hook.rerender(); + + pressKey('k'); + + expect(firstHandler).not.toHaveBeenCalled(); + expect(secondHandler).toHaveBeenCalledTimes(1); + }); + + it('removes the listener on unmount', () => { + const handler = vi.fn(); + const hook = renderHook(() => useKeyboardShortcut('k', handler)); + + hook.unmount(); + pressKey('k'); + + expect(handler).not.toHaveBeenCalled(); + }); +}); diff --git a/src/__tests__/useOracle.test.tsx b/src/__tests__/useOracle.test.tsx new file mode 100644 index 0000000..72c7de8 --- /dev/null +++ b/src/__tests__/useOracle.test.tsx @@ -0,0 +1,145 @@ +import { act } from 'react'; +import { useOracleReading, useAllOracleReadings } from '../hooks/useOracle'; +import { renderHook, flushMicrotasks } from './renderHook'; +import type { OracleReading } from '../types'; + +const { fetchOracleReading, fetchAllOracleReadings } = vi.hoisted(() => ({ + fetchOracleReading: vi.fn(), + fetchAllOracleReadings: vi.fn(), +})); + +vi.mock('@/lib/api', () => ({ fetchOracleReading, fetchAllOracleReadings })); + +function makeReading(overrides: Partial = {}): OracleReading { + return { + key: 'rainfall:lagos', + dataType: 'weather', + value: '1000000', + confidence: 95, + timestamp: 1_720_000_000, + source: 'test-oracle', + ...overrides, + }; +} + +describe('useOracleReading', () => { + beforeEach(() => { + fetchOracleReading.mockReset(); + }); + + it('does nothing when key is null', async () => { + const hook = renderHook(() => useOracleReading(null)); + await flushMicrotasks(); + + expect(hook.current.loading).toBe(false); + expect(hook.current.reading).toBeNull(); + expect(fetchOracleReading).not.toHaveBeenCalled(); + }); + + it('shows loading on the first fetch and populates the reading on success', async () => { + const reading = makeReading(); + fetchOracleReading.mockResolvedValue(reading); + + const hook = renderHook(() => useOracleReading('rainfall:lagos')); + expect(hook.current.loading).toBe(true); + + await flushMicrotasks(); + + expect(hook.current.loading).toBe(false); + expect(hook.current.reading).toEqual(reading); + }); + + it('surfaces an error message when the fetch fails', async () => { + fetchOracleReading.mockRejectedValue(new Error('oracle offline')); + + const hook = renderHook(() => useOracleReading('rainfall:lagos')); + await flushMicrotasks(); + + expect(hook.current.loading).toBe(false); + expect(hook.current.error).toBe('oracle offline'); + }); + + it('discards a stale response for a key that is no longer current (out-of-order guard)', async () => { + let resolveFirst!: (value: OracleReading) => void; + fetchOracleReading.mockImplementationOnce( + () => new Promise((resolve) => { resolveFirst = resolve; }), + ); + + let key = 'rainfall:lagos'; + const hook = renderHook(() => useOracleReading(key)); + await flushMicrotasks(); + + // Switch to a new key before the first request resolves. + const secondReading = makeReading({ key: 'rainfall:nairobi' }); + fetchOracleReading.mockResolvedValueOnce(secondReading); + key = 'rainfall:nairobi'; + hook.rerender(); + await flushMicrotasks(); + + // The first (stale) request now resolves — it must not overwrite the + // reading that belongs to the current key. + await act(async () => { + resolveFirst(makeReading({ key: 'rainfall:lagos', value: 'stale' })); + await Promise.resolve(); + }); + + expect(hook.current.reading).toEqual(secondReading); + }); + + it('does not show the loading spinner again on background polls', async () => { + fetchOracleReading.mockResolvedValue(makeReading()); + + const hook = renderHook(() => useOracleReading('rainfall:lagos')); + await flushMicrotasks(); + expect(hook.current.loading).toBe(false); + + await act(async () => { + await hook.current.refetch(); + }); + + expect(hook.current.loading).toBe(false); + }); +}); + +describe('useAllOracleReadings', () => { + beforeEach(() => { + fetchAllOracleReadings.mockReset(); + }); + + it('starts in a loading state and populates readings on success', async () => { + const readings = [makeReading()]; + fetchAllOracleReadings.mockResolvedValue(readings); + + const hook = renderHook(() => useAllOracleReadings()); + expect(hook.current.loading).toBe(true); + + await flushMicrotasks(); + + expect(hook.current.loading).toBe(false); + expect(hook.current.readings).toEqual(readings); + }); + + it('surfaces an error message when the fetch fails', async () => { + fetchAllOracleReadings.mockRejectedValue(new Error('oracle offline')); + + const hook = renderHook(() => useAllOracleReadings()); + await flushMicrotasks(); + + expect(hook.current.loading).toBe(false); + expect(hook.current.error).toBe('oracle offline'); + }); + + it('does not show the loading spinner again on a manual refetch', async () => { + fetchAllOracleReadings.mockResolvedValue([makeReading()]); + + const hook = renderHook(() => useAllOracleReadings()); + await flushMicrotasks(); + expect(hook.current.loading).toBe(false); + + await act(async () => { + await hook.current.refetch(); + }); + + expect(hook.current.loading).toBe(false); + }); +}); diff --git a/src/__tests__/usePolicies.test.tsx b/src/__tests__/usePolicies.test.tsx new file mode 100644 index 0000000..900d8e2 --- /dev/null +++ b/src/__tests__/usePolicies.test.tsx @@ -0,0 +1,122 @@ +import { act } from 'react'; +import { usePolicies } from '../hooks/usePolicies'; +import { renderHook, flushMicrotasks } from './renderHook'; +import type { Policy } from '../types'; + +const { fetchUserPolicies } = vi.hoisted(() => ({ fetchUserPolicies: vi.fn() })); + +vi.mock('@/lib/api', () => ({ fetchUserPolicies, fetchPolicy: vi.fn() })); + +function makePolicy(overrides: Partial = {}): Policy { + return { + id: 'policy-1', + productId: 'product-1', + policyholder: 'GABCDEF1234567890', + coverage: '10000000', + premiumPaid: '500000', + oracleKey: 'weather:lagos', + startTime: 1_720_000_000, + endTime: 1_720_086_400, + status: 'Active', + ...overrides, + }; +} + +describe('usePolicies', () => { + beforeEach(() => { + fetchUserPolicies.mockReset(); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('does not fetch and returns an empty list when there is no wallet address', async () => { + const hook = renderHook(() => usePolicies(null)); + await flushMicrotasks(); + + expect(hook.current.policies).toEqual([]); + expect(hook.current.loading).toBe(false); + expect(fetchUserPolicies).not.toHaveBeenCalled(); + }); + + it('shows loading on the first fetch and populates policies on success', async () => { + const policies = [makePolicy()]; + fetchUserPolicies.mockResolvedValue(policies); + + const hook = renderHook(() => usePolicies('GWALLET')); + expect(hook.current.loading).toBe(true); + + await flushMicrotasks(); + + expect(hook.current.loading).toBe(false); + expect(hook.current.policies).toEqual(policies); + expect(hook.current.error).toBeNull(); + }); + + it('surfaces an error message when the fetch fails', async () => { + fetchUserPolicies.mockRejectedValue(new Error('failed to reach api')); + + const hook = renderHook(() => usePolicies('GWALLET')); + await flushMicrotasks(); + + expect(hook.current.loading).toBe(false); + expect(hook.current.error).toBe('failed to reach api'); + }); + + it('does not flash the loading skeleton on background poll refreshes', async () => { + fetchUserPolicies.mockResolvedValue([makePolicy()]); + + const hook = renderHook(() => usePolicies('GWALLET')); + await flushMicrotasks(); + expect(hook.current.loading).toBe(false); + + // Advance past the poll interval and let the background refresh run. + const updated = [makePolicy({ id: 'policy-2' })]; + fetchUserPolicies.mockResolvedValue(updated); + + await act(async () => { + await vi.advanceTimersByTimeAsync(30_000); + }); + + expect(hook.current.loading).toBe(false); + expect(hook.current.policies).toEqual(updated); + }); + + it('discards a stale response from a previous wallet after the wallet address changes', async () => { + const pending: Record void> = {}; + fetchUserPolicies.mockImplementation( + (wallet: string) => + new Promise((resolve) => { pending[wallet] = resolve; }), + ); + + let wallet = 'GWALLET_A'; + const hook = renderHook(() => usePolicies(wallet)); + await flushMicrotasks(); + expect(pending['GWALLET_A']).toBeDefined(); + + // Switch wallets before the first request resolves — this aborts the + // controller tied to the in-flight request for GWALLET_A. + wallet = 'GWALLET_B'; + hook.rerender(); + await flushMicrotasks(); + expect(pending['GWALLET_B']).toBeDefined(); + + const policiesForB = [makePolicy({ id: 'policy-b' })]; + await act(async () => { + pending['GWALLET_B'](policiesForB); + await Promise.resolve(); + }); + expect(hook.current.policies).toEqual(policiesForB); + + // The stale GWALLET_A response now resolves; it must not overwrite the + // policies that belong to the current wallet. + await act(async () => { + pending['GWALLET_A']([makePolicy({ id: 'stale-policy' })]); + await Promise.resolve(); + }); + + expect(hook.current.policies).toEqual(policiesForB); + }); +}); diff --git a/src/__tests__/usePools.test.tsx b/src/__tests__/usePools.test.tsx new file mode 100644 index 0000000..21b12e2 --- /dev/null +++ b/src/__tests__/usePools.test.tsx @@ -0,0 +1,65 @@ +import { act } from 'react'; +import { usePools } from '../hooks/usePools'; +import { renderHook, flushMicrotasks } from './renderHook'; +import type { PoolStats } from '../types'; + +const { fetchPoolStats } = vi.hoisted(() => ({ fetchPoolStats: vi.fn() })); + +vi.mock('@/lib/api', () => ({ fetchPoolStats })); + +function makePool(overrides: Partial = {}): PoolStats { + return { + poolId: 'pool-1', + category: 'crop', + totalLiquidity: '1000000', + activePolicies: 3, + utilizationRate: 0.4, + apy: 0.12, + ...overrides, + }; +} + +describe('usePools', () => { + beforeEach(() => fetchPoolStats.mockReset()); + + it('starts in a loading state and populates pools on success', async () => { + const pools = [makePool()]; + fetchPoolStats.mockResolvedValue(pools); + + const hook = renderHook(() => usePools()); + expect(hook.current.loading).toBe(true); + + await flushMicrotasks(); + + expect(hook.current.loading).toBe(false); + expect(hook.current.pools).toEqual(pools); + expect(hook.current.error).toBeNull(); + }); + + it('surfaces an error message when the fetch fails', async () => { + fetchPoolStats.mockRejectedValue(new Error('network down')); + + const hook = renderHook(() => usePools()); + await flushMicrotasks(); + + expect(hook.current.loading).toBe(false); + expect(hook.current.error).toBe('network down'); + expect(hook.current.pools).toEqual([]); + }); + + it('refetch re-runs the load and clears a previous error on success', async () => { + fetchPoolStats.mockRejectedValueOnce(new Error('first failure')); + const hook = renderHook(() => usePools()); + await flushMicrotasks(); + expect(hook.current.error).toBe('first failure'); + + const pools = [makePool({ poolId: 'pool-2' })]; + fetchPoolStats.mockResolvedValueOnce(pools); + await act(async () => { + await hook.current.refetch(); + }); + + expect(hook.current.error).toBeNull(); + expect(hook.current.pools).toEqual(pools); + }); +}); diff --git a/src/__tests__/useProducts.test.tsx b/src/__tests__/useProducts.test.tsx new file mode 100644 index 0000000..8b55b97 --- /dev/null +++ b/src/__tests__/useProducts.test.tsx @@ -0,0 +1,70 @@ +import { act } from 'react'; +import { useProducts } from '../hooks/useProducts'; +import { renderHook, flushMicrotasks } from './renderHook'; +import type { Product } from '../types'; + +const { fetchProducts } = vi.hoisted(() => ({ fetchProducts: vi.fn() })); + +vi.mock('@/lib/api', () => ({ fetchProducts })); + +function makeProduct(overrides: Partial = {}): Product { + return { + id: 'product-1', + name: 'Crop Cover', + category: 'crop', + triggerType: 'Threshold', + threshold: '100', + comparison: 'LessThan', + coverageMin: '1000000', + coverageMax: '10000000', + premiumRate: 500, + maxDuration: 30, + status: 'Active', + ...overrides, + }; +} + +describe('useProducts', () => { + beforeEach(() => fetchProducts.mockReset()); + + it('starts in a loading state and populates products on success', async () => { + const products = [makeProduct()]; + fetchProducts.mockResolvedValue(products); + + const hook = renderHook(() => useProducts()); + expect(hook.current.loading).toBe(true); + + await flushMicrotasks(); + + expect(hook.current.loading).toBe(false); + expect(hook.current.products).toEqual(products); + expect(hook.current.error).toBeNull(); + }); + + it('surfaces an error message when the fetch fails', async () => { + fetchProducts.mockRejectedValue(new Error('service unavailable')); + + const hook = renderHook(() => useProducts()); + await flushMicrotasks(); + + expect(hook.current.loading).toBe(false); + expect(hook.current.error).toBe('service unavailable'); + expect(hook.current.products).toEqual([]); + }); + + it('refetch re-runs the load and clears a previous error on success', async () => { + fetchProducts.mockRejectedValueOnce(new Error('first failure')); + const hook = renderHook(() => useProducts()); + await flushMicrotasks(); + expect(hook.current.error).toBe('first failure'); + + const products = [makeProduct({ id: 'product-2' })]; + fetchProducts.mockResolvedValueOnce(products); + await act(async () => { + await hook.current.refetch(); + }); + + expect(hook.current.error).toBeNull(); + expect(hook.current.products).toEqual(products); + }); +}); diff --git a/src/__tests__/useWallet.test.tsx b/src/__tests__/useWallet.test.tsx new file mode 100644 index 0000000..320e853 --- /dev/null +++ b/src/__tests__/useWallet.test.tsx @@ -0,0 +1,44 @@ +import { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { useWallet } from '../hooks/useWallet'; +import { WalletProvider } from '../context/WalletContext'; +import { renderHook } from './renderHook'; + +describe('useWallet', () => { + it('throws when used outside of a WalletProvider', () => { + // Errors thrown during render are noisy in test output; silence the + // expected React error boundary log for this one assertion. + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + + expect(() => renderHook(() => useWallet())).toThrow( + 'useWalletContext must be used inside ', + ); + + consoleError.mockRestore(); + }); + + it('returns the disconnected initial state when wrapped in a WalletProvider', () => { + let value: ReturnType | undefined; + + function Consumer() { + value = useWallet(); + return null; + } + + const container = document.createElement('div'); + act(() => { + createRoot(container).render( + + + , + ); + }); + + expect(value?.address).toBeNull(); + expect(value?.connected).toBe(false); + expect(value?.connecting).toBe(false); + expect(value?.error).toBeNull(); + expect(typeof value?.connect).toBe('function'); + expect(typeof value?.disconnect).toBe('function'); + }); +}); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index c9a7483..0948db0 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -41,6 +41,8 @@ export const metadata: Metadata = { }, }; +const CURRENT_YEAR = new Date().getFullYear(); + const NavBarFallback = (