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/.github/workflows/ci.yml b/.github/workflows/ci.yml index 21e47a7..053af72 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,5 +18,6 @@ jobs: - run: corepack enable - run: yarn install --immutable + - run: yarn lint - run: yarn test - run: yarn build diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..c85fb67 --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,16 @@ +import { dirname } from "path"; +import { fileURLToPath } from "url"; +import { FlatCompat } from "@eslint/eslintrc"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const compat = new FlatCompat({ + baseDirectory: __dirname, +}); + +const eslintConfig = [ + ...compat.extends("next/core-web-vitals", "next/typescript"), +]; + +export default eslintConfig; diff --git a/package.json b/package.json index 8f25fd8..15b1a94 100644 --- a/package.json +++ b/package.json @@ -22,13 +22,19 @@ "react-dom": "^19.0.0" }, "devDependencies": { + "@eslint/eslintrc": "^3.2.0", "@netlify/plugin-nextjs": "^5.15.12", "@tailwindcss/postcss": "^4.0.0", + "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^7.0.0", + "@testing-library/react": "^16.3.2", "@types/node": "^22.0.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^6.0.4", "autoprefixer": "^10.4.20", + "eslint": "^9.17.0", + "eslint-config-next": "^15.0.0", "jsdom": "^29.1.1", "postcss": "^8.5.0", "tailwindcss": "^4.0.0", diff --git a/src/__tests__/BuyPolicyModal.test.tsx b/src/__tests__/BuyPolicyModal.test.tsx new file mode 100644 index 0000000..f6714e8 --- /dev/null +++ b/src/__tests__/BuyPolicyModal.test.tsx @@ -0,0 +1,81 @@ +import { renderToStaticMarkup } from 'react-dom/server'; +import { BuyPolicyModal } from '../components/BuyPolicyModal'; +import type { Product } from '../types'; + +vi.mock('@/lib/stellar', () => ({ + signTransaction: vi.fn(), + getAddress: vi.fn(), + isConnected: vi.fn(), +})); +vi.mock('@/hooks/useWallet', () => ({ + useWallet: vi.fn(() => ({ + address: null, + connect: vi.fn(), + connecting: false, + error: null, + })), +})); +vi.mock('@/context/ToastContext', () => ({ + useToast: vi.fn(() => ({ + show: vi.fn(), + })), +})); + +function makeProduct(overrides: Partial = {}): Product { + return { + id: 'product-1', + name: 'Crop Insurance', + category: 'crop', + description: 'Test product', + coverageMin: '1000000', + coverageMax: '100000000', + premiumRate: 500, + maxDuration: 30, + ...overrides, + }; +} + +describe('BuyPolicyModal', () => { + it('renders crop-specific latitude and longitude inputs', () => { + const html = renderToStaticMarkup(); + expect(html).toContain('Latitude'); + expect(html).toContain('Longitude'); + expect(html).toContain('Month'); + expect(html).toContain('Year'); + }); + + it('renders flight-specific inputs for flight products', () => { + const html = renderToStaticMarkup(); + expect(html).toContain('Flight Number'); + expect(html).toContain('Date'); + }); + + it('shows fixed defi oracle key for defi products', () => { + const html = renderToStaticMarkup(); + expect(html).toContain('defi'); + expect(html).toContain('Oracle Key (Fixed)'); + }); + + it('shows manual oracle key input for disaster products', () => { + const html = renderToStaticMarkup(); + expect(html).toContain('Oracle Key'); + expect(html).toContain('Max 32 chars'); + }); + + it('shows manual oracle key input for health products', () => { + const html = renderToStaticMarkup(); + expect(html).toContain('Oracle Key'); + expect(html).toContain('Max 32 chars'); + }); + + it('renders the configure step with coverage and duration inputs', () => { + const html = renderToStaticMarkup(); + expect(html).toContain('Coverage Amount (USDC)'); + expect(html).toContain('Duration (days'); + }); + + it('displays the product name in the modal title', () => { + const html = renderToStaticMarkup(); + expect(html).toContain('Buy β€” Test Product'); + }); +}); diff --git a/src/__tests__/CategoryFilter.test.tsx b/src/__tests__/CategoryFilter.test.tsx new file mode 100644 index 0000000..034b60b --- /dev/null +++ b/src/__tests__/CategoryFilter.test.tsx @@ -0,0 +1,58 @@ +import { describe, it, expect, vi } from 'vitest'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { CategoryFilter } from '../components/CategoryFilter'; + +describe('CategoryFilter', () => { + it('renders all category buttons including "All products"', () => { + const onChange = vi.fn(); + const html = renderToStaticMarkup(); + expect(html).toContain('All products'); + const buttonCount = (html.match(/ + + + ); + const closeButton = screen.getByRole('button', { name: /close modal/i }); + expect(closeButton).toHaveFocus(); + }); + + it('restores focus to trigger on close', () => { + const TriggerButton = () => { + const [open, setOpen] = useState(false); + return ( + <> + + setOpen(false)}> +
Content
+
+ + ); + }; + + render(); + const openButton = screen.getByText('Open'); + openButton.focus(); + + fireEvent.click(openButton); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + + // Close modal + fireEvent.keyDown(window, { key: 'Escape' }); + expect(openButton).toHaveFocus(); + }); + + it('renders close button with title', () => { + render(); + expect(screen.getByRole('button', { name: /close modal/i })).toBeInTheDocument(); + }); + + it('renders close button without title', () => { + const { title, ...propsWithoutTitle } = defaultProps; + render(); + expect(screen.getByRole('button', { name: /close modal/i })).toBeInTheDocument(); + }); + + it('sets body overflow hidden when open', () => { + render(); + expect(document.body.style.overflow).toBe('hidden'); + }); + + it('restores body overflow on close', () => { + const { rerender } = render(); + expect(document.body.style.overflow).toBe('hidden'); + + rerender(); + expect(document.body.style.overflow).toBe(''); + }); +}); \ No newline at end of file diff --git a/src/__tests__/NavBar.test.tsx b/src/__tests__/NavBar.test.tsx new file mode 100644 index 0000000..76e97f3 --- /dev/null +++ b/src/__tests__/NavBar.test.tsx @@ -0,0 +1,101 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { NavBar } from '../components/NavBar'; + +const mockUsePathname = vi.fn().mockReturnValue('/'); + +vi.mock('next/navigation', () => ({ + usePathname: () => mockUsePathname(), +})); + +vi.mock('next/link', () => ({ + default: ({ href, children, ...props }: React.ComponentProps<'a'> & { href: string }) => ( + {children} + ), +})); + +vi.mock('@/hooks/useKeyboardShortcut', () => ({ + useKeyboardShortcut: vi.fn(), +})); + +vi.mock('@/lib/stellar', () => ({ + connectWallet: vi.fn(), + disconnectWallet: vi.fn(), + getStoredAddress: vi.fn().mockReturnValue(null), + getConnectedAddress: vi.fn().mockReturnValue(null), + signAuthMessage: vi.fn(), + EXPECTED_NETWORK_PASSPHRASE: 'Test SDF Network ; September 2015', +})); + +vi.mock('@/lib/api', () => ({ + fetchChallenge: vi.fn(), + login: vi.fn(), + setAuthErrorHandler: vi.fn(), +})); + +vi.mock('@/lib/storage', () => ({ + default: { + getSession: vi.fn().mockReturnValue(null), + setSession: vi.fn(), + removeSession: vi.fn(), + }, +})); + +vi.mock('@/components/Logo', () => ({ + Logo: () => Logo, + LogoWordmark: () => Logo, +})); + +vi.mock('@/components/WalletButton', () => ({ + WalletButton: () => Wallet, +})); + +describe('NavBar', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('sets aria-current="page" on the matching link', () => { + mockUsePathname.mockReturnValue('/policies'); + const html = renderToStaticMarkup(); + expect(html).toContain('aria-current="page"'); + }); + + it('does not highlight "/" for sub-paths like "/policies/123"', () => { + mockUsePathname.mockReturnValue('/policies/123'); + const html = renderToStaticMarkup(); + const homeLinkMatch = html.match(/href="\/"[^>]*aria-current/); + expect(homeLinkMatch).toBeNull(); + }); + + it('renders all navigation links', () => { + mockUsePathname.mockReturnValue('/'); + const html = renderToStaticMarkup(); + expect(html).toContain('href="/"'); + expect(html).toContain('href="/policies"'); + expect(html).toContain('href="/dashboard"'); + expect(html).toContain('href="/oracle"'); + expect(html).toContain('href="/pools"'); + }); + + it('has a mobile menu toggle button', () => { + mockUsePathname.mockReturnValue('/'); + const html = renderToStaticMarkup(); + expect(html).toContain('aria-expanded'); + expect(html).toContain('aria-controls="mobile-nav"'); + }); + + it('includes logo and wallet button', () => { + mockUsePathname.mockReturnValue('/'); + const html = renderToStaticMarkup(); + expect(html).toContain('Logo'); + expect(html).toContain('Wallet'); + }); + + it('renders the sticky nav container', () => { + mockUsePathname.mockReturnValue('/'); + const html = renderToStaticMarkup(); + expect(html).toContain('sticky'); + expect(html).toContain('nav'); + }); +}); diff --git a/src/__tests__/NetworkBanner.test.tsx b/src/__tests__/NetworkBanner.test.tsx new file mode 100644 index 0000000..c5c1f76 --- /dev/null +++ b/src/__tests__/NetworkBanner.test.tsx @@ -0,0 +1,32 @@ +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { renderToStaticMarkup } from 'react-dom/server'; + +async function renderBannerWithNetwork(network: string | undefined) { + vi.resetModules(); + vi.stubEnv('NEXT_PUBLIC_STELLAR_NETWORK', network as string); + const { NetworkBanner } = await import('../components/NetworkBanner'); + return renderToStaticMarkup(); +} + +describe('NetworkBanner', () => { + afterEach(() => { + vi.unstubAllEnvs(); + vi.resetModules(); + }); + + it('renders the testnet warning when the network is not PUBLIC', async () => { + const html = await renderBannerWithNetwork('TESTNET'); + expect(html).toContain('Stellar Testnet'); + expect(html).toContain('Do not use real funds'); + }); + + it('is hidden when the network is PUBLIC', async () => { + const html = await renderBannerWithNetwork('PUBLIC'); + expect(html).toBe(''); + }); + + it('defaults to showing the banner when no network env var is set', async () => { + const html = await renderBannerWithNetwork(undefined); + expect(html).toContain('Stellar Testnet'); + }); +}); diff --git a/src/__tests__/OracleDataWidget.test.tsx b/src/__tests__/OracleDataWidget.test.tsx new file mode 100644 index 0000000..6e783e6 --- /dev/null +++ b/src/__tests__/OracleDataWidget.test.tsx @@ -0,0 +1,255 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { OracleDataWidget } from '../components/OracleDataWidget'; + +vi.mock('@/hooks/useOracle', () => ({ + useOracleReading: vi.fn(), +})); + +vi.mock('@/lib/format', () => ({ + formatOracleValue: (value: string, dataType: string) => { + const num = Number(BigInt(value)) / 1e7; + if (dataType === 'weather' || dataType === 'rainfall') return `${num.toFixed(2)} mm`; + if (dataType === 'temperature') return `${num.toFixed(2)} Β°C`; + if (dataType === 'flight') return `${Math.round(num)} min delay`; + if (dataType === 'defi') return num === 1 ? 'Exploit detected' : 'No exploit'; + return num.toFixed(4); + }, + formatDateTime: (ts: number) => new Date(ts * 1000).toLocaleString(), +})); + +vi.mock('@/lib/oracle', () => ({ + oracleKeyLabel: (key: string) => `Label for ${key}`, + confidenceLabel: (c: number) => (c >= 90 ? 'High' : c >= 70 ? 'Medium' : 'Low'), + confidenceColour: (c: number) => (c >= 90 ? 'text-emerald-400' : c >= 70 ? 'text-amber-400' : 'text-red-400'), + parseOracleKey: (key: string) => { + if (key.startsWith('rainfall:')) return { dataType: 'rainfall' }; + if (key.startsWith('temperature:')) return { dataType: 'temperature' }; + if (key.startsWith('flight:')) return { dataType: 'flight' }; + if (key.startsWith('defi')) return { dataType: 'defi' }; + return { dataType: 'unknown' }; + }, +})); + +vi.mock('@/components/LoadingSpinner', () => ({ + LoadingSpinner: () =>
Loading
, +})); + +import { useOracleReading } from '@/hooks/useOracle'; +const mockUseOracleReading = vi.mocked(useOracleReading); + +describe('OracleDataWidget', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('shows loading spinner when loading', () => { + mockUseOracleReading.mockReturnValue({ + reading: null, + loading: true, + error: null, + refetch: vi.fn(), + }); + + const html = renderToStaticMarkup(); + expect(html).toContain('Loading'); + }); + + it('shows error message when fetch fails', () => { + const refetch = vi.fn(); + mockUseOracleReading.mockReturnValue({ + reading: null, + loading: false, + error: 'Network error', + refetch, + }); + + const html = renderToStaticMarkup(); + expect(html).toContain('Network error'); + expect(html).toContain('Retry'); + }); + + it('shows "No oracle data" when reading is null', () => { + mockUseOracleReading.mockReturnValue({ + reading: null, + loading: false, + error: null, + refetch: vi.fn(), + }); + + const html = renderToStaticMarkup(); + expect(html).toContain('No oracle data'); + }); + + it('displays rainfall value with mm unit', () => { + mockUseOracleReading.mockReturnValue({ + reading: { + key: 'rainfall:1,1:2025-01', + value: '324000000', + confidence: 95, + timestamp: 1720000000, + source: 'NOAA', + dataType: 'weather', + }, + loading: false, + error: null, + refetch: vi.fn(), + }); + + const html = renderToStaticMarkup(); + expect(html).toContain('mm'); + }); + + it('displays temperature value with Β°C unit', () => { + mockUseOracleReading.mockReturnValue({ + reading: { + key: 'temperature:1,1:2025-01', + value: '250000000', + confidence: 85, + timestamp: 1720000000, + source: 'WMO', + dataType: 'weather', + }, + loading: false, + error: null, + refetch: vi.fn(), + }); + + const html = renderToStaticMarkup(); + expect(html).toContain('Β°C'); + }); + + it('displays flight delay in minutes', () => { + mockUseOracleReading.mockReturnValue({ + reading: { + key: 'flight:AA123:2025-07-25', + value: '1200000000', + confidence: 100, + timestamp: 1720000000, + source: 'FlightAware', + dataType: 'flight', + }, + loading: false, + error: null, + refetch: vi.fn(), + }); + + const html = renderToStaticMarkup(); + expect(html).toContain('min delay'); + }); + + it('displays defi exploit detected when value is 1', () => { + mockUseOracleReading.mockReturnValue({ + reading: { + key: 'defi', + value: '10000000', + confidence: 100, + timestamp: 1720000000, + source: 'On-chain', + dataType: 'defi', + }, + loading: false, + error: null, + refetch: vi.fn(), + }); + + const html = renderToStaticMarkup(); + expect(html).toContain('Exploit detected'); + }); + + it('displays "No exploit" when defi value is 0', () => { + mockUseOracleReading.mockReturnValue({ + reading: { + key: 'defi', + value: '0', + confidence: 100, + timestamp: 1720000000, + source: 'On-chain', + dataType: 'defi', + }, + loading: false, + error: null, + refetch: vi.fn(), + }); + + const html = renderToStaticMarkup(); + expect(html).toContain('No exploit'); + }); + + it('shows "High" confidence for β‰₯90', () => { + mockUseOracleReading.mockReturnValue({ + reading: { + key: 'rainfall:1,1:2025-01', + value: '10000000', + confidence: 92, + timestamp: 1720000000, + source: 'NOAA', + dataType: 'weather', + }, + loading: false, + error: null, + refetch: vi.fn(), + }); + + const html = renderToStaticMarkup(); + expect(html).toContain('High confidence'); + }); + + it('shows "Medium" confidence for 70-89', () => { + mockUseOracleReading.mockReturnValue({ + reading: { + key: 'rainfall:1,1:2025-01', + value: '10000000', + confidence: 75, + timestamp: 1720000000, + source: 'NOAA', + dataType: 'weather', + }, + loading: false, + error: null, + refetch: vi.fn(), + }); + + const html = renderToStaticMarkup(); + expect(html).toContain('Medium confidence'); + }); + + it('shows "Low" confidence for <70', () => { + mockUseOracleReading.mockReturnValue({ + reading: { + key: 'rainfall:1,1:2025-01', + value: '10000000', + confidence: 50, + timestamp: 1720000000, + source: 'NOAA', + dataType: 'weather', + }, + loading: false, + error: null, + refetch: vi.fn(), + }); + + const html = renderToStaticMarkup(); + expect(html).toContain('Low confidence'); + }); + + it('displays oracle reading label and source', () => { + mockUseOracleReading.mockReturnValue({ + reading: { + key: 'rainfall:1,1:2025-01', + value: '10000000', + confidence: 90, + timestamp: 1720000000, + source: 'NOAA', + dataType: 'weather', + }, + loading: false, + error: null, + refetch: vi.fn(), + }); + + const html = renderToStaticMarkup(); + expect(html).toContain('Oracle Reading'); + expect(html).toContain('NOAA'); + }); +}); diff --git a/src/__tests__/PolicyCard.test.tsx b/src/__tests__/PolicyCard.test.tsx index fb53ad0..0f9c761 100644 --- a/src/__tests__/PolicyCard.test.tsx +++ b/src/__tests__/PolicyCard.test.tsx @@ -20,8 +20,93 @@ function makePolicy(overrides: Partial = {}): Policy { describe('PolicyCard', () => { it('shows a cancelled label when a cancelled policy has no cancelled timestamp', () => { const html = renderToStaticMarkup(); + expect(html).toContain('Cancelled'); + expect(html).not.toContain('Expired'); + }); + + it('shows Expires label for Active policies', () => { + const html = renderToStaticMarkup( + + ); + expect(html).toContain('Expires'); + expect(html).not.toContain('Cancelled'); + expect(html).not.toContain('Claimed'); + }); + + it('shows Expired label for Expired policies', () => { + const html = renderToStaticMarkup( + + ); + expect(html).toContain('Expired'); + expect(html).not.toContain('Expires'); + expect(html).not.toContain('Cancelled'); + expect(html).not.toContain('Claimed'); + }); + + it('shows Claimed label for Claimed policies', () => { + const html = renderToStaticMarkup( + + ); + expect(html).toContain('Claimed'); + expect(html).not.toContain('Expires'); + expect(html).not.toContain('Cancelled'); + expect(html).not.toContain('Expired'); + }); + + it('shows Cancelled label for Cancelled policies', () => { + const html = renderToStaticMarkup( + + ); + expect(html).toContain('Cancelled'); + expect(html).not.toContain('Expires'); + expect(html).not.toContain('Expired'); + expect(html).not.toContain('Claimed'); + }); + it('shows Cancelled label when a cancelled policy has no cancelled timestamp', () => { + const html = renderToStaticMarkup( + + ); expect(html).toContain('Cancelled'); expect(html).not.toContain('Expired'); }); + + it('displays coverage and premium paid amounts', () => { + const html = renderToStaticMarkup( + + ); + expect(html).toContain('Coverage'); + expect(html).toContain('Premium paid'); + expect(html).toContain('1.00'); + expect(html).toContain('0.05'); + }); + + it('displays start date', () => { + const html = renderToStaticMarkup( + + ); + expect(html).toContain('Start date'); + }); + + it('renders view details link', () => { + const html = renderToStaticMarkup( + + ); + expect(html).toContain('View details'); + expect(html).toContain('/policies/policy-abc'); + }); + + it('displays product name when available', () => { + const html = renderToStaticMarkup( + + ); + expect(html).toContain('Crop Insurance'); + }); + + it('displays fallback policy name when product is not available', () => { + const html = renderToStaticMarkup( + + ); + expect(html).toContain('Policy #policy-1'); + }); }); diff --git a/src/__tests__/ProductCard.test.tsx b/src/__tests__/ProductCard.test.tsx new file mode 100644 index 0000000..20e5977 --- /dev/null +++ b/src/__tests__/ProductCard.test.tsx @@ -0,0 +1,120 @@ +import { renderToStaticMarkup } from 'react-dom/server'; +import { ProductCard } from '../components/ProductCard'; +import type { Product } from '../types'; + +vi.mock('@/lib/stellar', () => ({ + connectWallet: vi.fn(), + disconnectWallet: vi.fn(), + getStoredAddress: vi.fn(), + getConnectedAddress: vi.fn(), + signAuthMessage: vi.fn(), + EXPECTED_NETWORK_PASSPHRASE: 'Test SDF Network ; September 2015', +})); + +function makeProduct(overrides: Partial = {}): Product { + return { + id: 'product-1', + name: 'Crop Insurance', + category: 'crop', + triggerType: 'Threshold', + threshold: '30', + comparison: 'LessThan', + coverageMin: '1000000', + coverageMax: '10000000', + premiumRate: 500, + maxDuration: 30, + status: 'Active', + ...overrides, + }; +} + +describe('ProductCard', () => { + it('renders active product with buy button enabled', () => { + const html = renderToStaticMarkup( + + ); + expect(html).toContain('Crop Insurance'); + expect(html).toContain('Buy Policy'); + expect(html).not.toContain('Temporarily unavailable'); + expect(html).not.toContain('No longer available'); + }); + + it('renders paused product with temporarily unavailable message', () => { + const html = renderToStaticMarkup( + + ); + expect(html).toContain('Temporarily unavailable'); + expect(html).not.toContain('Buy Policy'); + }); + + it('renders deprecated product with no longer available message', () => { + const html = renderToStaticMarkup( + + ); + expect(html).toContain('No longer available'); + expect(html).not.toContain('Buy Policy'); + }); + + it('renders crop category with correct icon', () => { + const html = renderToStaticMarkup( + + ); + expect(html).toContain('🌾'); + expect(html).toContain('Crop Insurance'); + }); + + it('renders flight category with correct icon', () => { + const html = renderToStaticMarkup( + + ); + expect(html).toContain('✈️'); + expect(html).toContain('Flight Delay'); + }); + + it('renders disaster category with correct icon', () => { + const html = renderToStaticMarkup( + + ); + expect(html).toContain('πŸŒͺ️'); + expect(html).toContain('Natural Disaster'); + }); + + it('renders health category with correct icon', () => { + const html = renderToStaticMarkup( + + ); + expect(html).toContain('πŸ₯'); + expect(html).toContain('Health'); + }); + + it('renders defi category with correct icon', () => { + const html = renderToStaticMarkup( + + ); + expect(html).toContain('πŸ”'); + expect(html).toContain('DeFi Cover'); + }); + + it('renders fallback icon for unrecognized category', () => { + const html = renderToStaticMarkup( + + ); + expect(html).toContain('πŸ›‘οΈ'); + }); + + it('displays premium rate as percentage', () => { + const html = renderToStaticMarkup( + + ); + expect(html).toContain('Premium'); + expect(html).toContain('5.00%'); + }); + + it('displays max coverage amount', () => { + const html = renderToStaticMarkup( + + ); + expect(html).toContain('Max Coverage'); + expect(html).toContain('1.00'); + }); +}); diff --git a/src/__tests__/ProgressBar.test.tsx b/src/__tests__/ProgressBar.test.tsx new file mode 100644 index 0000000..fea0738 --- /dev/null +++ b/src/__tests__/ProgressBar.test.tsx @@ -0,0 +1,71 @@ +import { describe, it, expect } from 'vitest'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { ProgressBar } from '../components/ProgressBar'; + +describe('ProgressBar', () => { + it('sets aria-valuenow to computed percentage', () => { + const html = renderToStaticMarkup( + + ); + expect(html).toContain('aria-valuenow="50"'); + }); + + it('sets aria-valuemin and aria-valuemax', () => { + const html = renderToStaticMarkup( + + ); + expect(html).toContain('aria-valuemin="0"'); + expect(html).toContain('aria-valuemax="100"'); + }); + + it('does not produce NaN when max is 0', () => { + const html = renderToStaticMarkup( + + ); + expect(html).not.toContain('NaN'); + expect(html).toContain('aria-valuenow="0"'); + }); + + it('clamps percentage to 100 when value exceeds max', () => { + const html = renderToStaticMarkup( + + ); + expect(html).toContain('aria-valuenow="100"'); + }); + + it('clamps percentage to 0 when value is negative', () => { + const html = renderToStaticMarkup( + + ); + expect(html).toContain('aria-valuenow="0"'); + }); + + it('renders label and percentage text when label is provided', () => { + const html = renderToStaticMarkup( + + ); + expect(html).toContain('Progress'); + expect(html).toContain('30%'); + }); + + it('does not render label text when label is omitted', () => { + const html = renderToStaticMarkup( + + ); + expect(html).not.toContain('Progress'); + }); + + it('applies custom className', () => { + const html = renderToStaticMarkup( + + ); + expect(html).toContain('custom'); + }); + + it('renders role="progressbar"', () => { + const html = renderToStaticMarkup( + + ); + expect(html).toContain('role="progressbar"'); + }); +}); diff --git a/src/__tests__/SearchBar.test.tsx b/src/__tests__/SearchBar.test.tsx new file mode 100644 index 0000000..e605302 --- /dev/null +++ b/src/__tests__/SearchBar.test.tsx @@ -0,0 +1,36 @@ +import { describe, it, expect, vi } from 'vitest'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { SearchBar } from '../components/SearchBar'; + +describe('SearchBar', () => { + it('renders with default placeholder', () => { + const onSearch = vi.fn(); + const html = renderToStaticMarkup(); + expect(html).toContain('placeholder="Search…"'); + }); + + it('renders with custom placeholder', () => { + const onSearch = vi.fn(); + const html = renderToStaticMarkup(); + expect(html).toContain('placeholder="Find policies"'); + }); + + it('applies custom className', () => { + const onSearch = vi.fn(); + const html = renderToStaticMarkup(); + expect(html).toContain('my-class'); + }); + + it('renders search input element', () => { + const onSearch = vi.fn(); + const html = renderToStaticMarkup(); + expect(html).toContain(' { + const onSearch = vi.fn(); + const html = renderToStaticMarkup(); + expect(html).not.toContain('Clear search'); + }); +}); diff --git a/src/__tests__/Skeleton.test.tsx b/src/__tests__/Skeleton.test.tsx new file mode 100644 index 0000000..e3e682c --- /dev/null +++ b/src/__tests__/Skeleton.test.tsx @@ -0,0 +1,73 @@ +import { describe, it, expect } from 'vitest'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { Skeleton, SkeletonCard, SkeletonTable, SkeletonText } from '../components/Skeleton'; + +describe('Skeleton', () => { + it('renders without crashing', () => { + const html = renderToStaticMarkup(); + expect(html).toBeTruthy(); + }); + + it('applies the pulse animation class', () => { + const html = renderToStaticMarkup(); + expect(html).toContain('animate-pulse'); + }); + + it('applies a custom className', () => { + const html = renderToStaticMarkup(); + expect(html).toContain('h-8 w-8 rounded-full'); + }); +}); + +describe('SkeletonCard', () => { + it('renders without crashing', () => { + const html = renderToStaticMarkup(); + expect(html).toBeTruthy(); + }); + + it('renders the expected number of placeholder elements', () => { + const html = renderToStaticMarkup(); + const count = (html.match(/animate-pulse/g) || []).length; + expect(count).toBe(6); + }); +}); + +describe('SkeletonTable', () => { + it('renders a header placeholder plus the default number of rows', () => { + const html = renderToStaticMarkup(); + const count = (html.match(/animate-pulse/g) || []).length; + expect(count).toBe(1 + 5); + }); + + it('renders a header placeholder plus a custom number of rows', () => { + const html = renderToStaticMarkup(); + const count = (html.match(/animate-pulse/g) || []).length; + expect(count).toBe(1 + 3); + }); + + it('renders only the header placeholder when rows is 0', () => { + const html = renderToStaticMarkup(); + const count = (html.match(/animate-pulse/g) || []).length; + expect(count).toBe(1); + }); +}); + +describe('SkeletonText', () => { + it('renders the default number of lines', () => { + const html = renderToStaticMarkup(); + const count = (html.match(/animate-pulse/g) || []).length; + expect(count).toBe(3); + }); + + it('renders a custom number of lines', () => { + const html = renderToStaticMarkup(); + const count = (html.match(/animate-pulse/g) || []).length; + expect(count).toBe(5); + }); + + it('makes the last line narrower than the others', () => { + const html = renderToStaticMarkup(); + expect(html).toContain('w-full'); + expect(html).toContain('w-2/3'); + }); +}); diff --git a/src/__tests__/Toast.test.tsx b/src/__tests__/Toast.test.tsx new file mode 100644 index 0000000..a0d369d --- /dev/null +++ b/src/__tests__/Toast.test.tsx @@ -0,0 +1,85 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { ToastContainer } from '../components/Toast'; +import type { Toast as ToastType } from '../types'; + +const mockDismiss = vi.fn(); +const mockToasts: ToastType[] = []; + +vi.mock('@/context/ToastContext', () => ({ + useToast: () => ({ + toasts: mockToasts, + dismiss: mockDismiss, + }), +})); + +describe('ToastContainer', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockToasts.length = 0; + }); + + it('renders nothing when no toasts', () => { + render(); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); + + it('renders a toast', () => { + mockToasts.push({ + id: '1', + message: 'Test message', + variant: 'info', + duration: 5000, + }); + render(); + expect(screen.getByRole('alert')).toBeInTheDocument(); + expect(screen.getByText('Test message')).toBeInTheDocument(); + }); + + it('renders multiple toasts', () => { + mockToasts.push( + { id: '1', message: 'First', variant: 'info', duration: 5000 }, + { id: '2', message: 'Second', variant: 'success', duration: 5000 } + ); + render(); + expect(screen.getAllByRole('alert')).toHaveLength(2); + expect(screen.getByText('First')).toBeInTheDocument(); + expect(screen.getByText('Second')).toBeInTheDocument(); + }); + + it('dismiss button calls dismiss with id', () => { + mockToasts.push({ + id: '123', + message: 'Dismiss me', + variant: 'error', + duration: 5000, + }); + render(); + const dismissButton = screen.getByRole('button', { name: /dismiss/i }); + fireEvent.click(dismissButton); + expect(mockDismiss).toHaveBeenCalledWith('123'); + }); + + it('shows correct icon for variant', () => { + mockToasts.push({ + id: '1', + message: 'Success toast', + variant: 'success', + duration: 5000, + }); + render(); + expect(screen.getByText('βœ“')).toBeInTheDocument(); + }); + + it('applies correct styles for variant', () => { + mockToasts.push({ + id: '1', + message: 'Error toast', + variant: 'error', + duration: 5000, + }); + render(); + const alert = screen.getByRole('alert'); + expect(alert.className).toContain('border-red-500/30'); + }); +}); \ No newline at end of file diff --git a/src/__tests__/ToastContext.test.tsx b/src/__tests__/ToastContext.test.tsx new file mode 100644 index 0000000..d3e42f6 --- /dev/null +++ b/src/__tests__/ToastContext.test.tsx @@ -0,0 +1,94 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { ToastProvider, useToast } from '../context/ToastContext'; + +const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +describe('ToastContext', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('provides show and dismiss functions', () => { + const { result } = renderHook(() => useToast(), { wrapper }); + expect(typeof result.current.show).toBe('function'); + expect(typeof result.current.dismiss).toBe('function'); + }); + + it('show adds a toast', () => { + const { result } = renderHook(() => useToast(), { wrapper }); + act(() => { + result.current.show('Test message'); + }); + expect(result.current.toasts).toHaveLength(1); + expect(result.current.toasts[0].message).toBe('Test message'); + expect(result.current.toasts[0].variant).toBe('info'); + }); + + it('show with variant', () => { + const { result } = renderHook(() => useToast(), { wrapper }); + act(() => { + result.current.show('Success', 'success'); + }); + expect(result.current.toasts[0].variant).toBe('success'); + }); + + it('dismiss removes a toast', () => { + const { result } = renderHook(() => useToast(), { wrapper }); + act(() => { + result.current.show('Test'); + }); + const id = result.current.toasts[0].id; + act(() => { + result.current.dismiss(id); + }); + expect(result.current.toasts).toHaveLength(0); + }); + + it('auto-dismiss after duration', () => { + const { result } = renderHook(() => useToast(), { wrapper }); + act(() => { + result.current.show('Auto dismiss', 'info', 1000); + }); + expect(result.current.toasts).toHaveLength(1); + + act(() => { + vi.advanceTimersByTime(1000); + }); + expect(result.current.toasts).toHaveLength(0); + }); + + it('multiple toasts queue correctly', () => { + const { result } = renderHook(() => useToast(), { wrapper }); + act(() => { + result.current.show('First'); + result.current.show('Second'); + }); + expect(result.current.toasts).toHaveLength(2); + expect(result.current.toasts[0].message).toBe('First'); + expect(result.current.toasts[1].message).toBe('Second'); + }); + + it('dismiss clears timer', () => { + const { result } = renderHook(() => useToast(), { wrapper }); + act(() => { + result.current.show('Test', 'info', 1000); + }); + const id = result.current.toasts[0].id; + act(() => { + result.current.dismiss(id); + }); + // Advance time, should not throw + act(() => { + vi.advanceTimersByTime(2000); + }); + expect(result.current.toasts).toHaveLength(0); + }); +}); \ No newline at end of file diff --git a/src/__tests__/TriggerConditionBadge.test.tsx b/src/__tests__/TriggerConditionBadge.test.tsx new file mode 100644 index 0000000..8264e66 --- /dev/null +++ b/src/__tests__/TriggerConditionBadge.test.tsx @@ -0,0 +1,58 @@ +import { describe, it, expect } from 'vitest'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { TriggerConditionBadge } from '../components/TriggerConditionBadge'; +import type { Product } from '../types'; + +function makeProduct(overrides: Partial = {}): Product { + return { + id: 'product-1', + name: 'Crop Insurance', + category: 'crop', + triggerType: 'Threshold', + threshold: '30', + comparison: 'LessThan', + coverageMin: '1000000', + coverageMax: '10000000', + premiumRate: 500, + maxDuration: 30, + status: 'Active', + ...overrides, + }; +} + +describe('TriggerConditionBadge', () => { + it('renders "<" symbol for LessThan comparison', () => { + const html = renderToStaticMarkup( + + ); + expect(html).toContain('< 30'); + }); + + it('renders ">" symbol for GreaterThan comparison', () => { + const html = renderToStaticMarkup( + + ); + expect(html).toContain('> 50'); + }); + + it('renders "=" symbol for Equal comparison', () => { + const html = renderToStaticMarkup( + + ); + expect(html).toContain('= 100'); + }); + + it('displays the triggerType', () => { + const html = renderToStaticMarkup( + + ); + expect(html).toContain('Binary'); + }); + + it('applies custom className', () => { + const html = renderToStaticMarkup( + + ); + expect(html).toContain('custom-class'); + }); +}); diff --git a/src/__tests__/WalletButton.test.tsx b/src/__tests__/WalletButton.test.tsx new file mode 100644 index 0000000..e79d885 --- /dev/null +++ b/src/__tests__/WalletButton.test.tsx @@ -0,0 +1,58 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { WalletButton } from '../components/WalletButton'; +import { WalletProvider } from '../context/WalletContext'; + +const mockConnectWallet = vi.fn(); + +vi.mock('@/lib/stellar', () => ({ + connectWallet: (...args: unknown[]) => mockConnectWallet(...args), + disconnectWallet: vi.fn(), + getStoredAddress: vi.fn().mockReturnValue(null), + getConnectedAddress: vi.fn().mockReturnValue(null), + signAuthMessage: vi.fn(), + EXPECTED_NETWORK_PASSPHRASE: 'Test SDF Network ; September 2015', +})); + +vi.mock('@/lib/api', () => ({ + fetchChallenge: vi.fn(), + login: vi.fn(), + setAuthErrorHandler: vi.fn(), +})); + +vi.mock('@/lib/storage', () => ({ + default: { + getSession: vi.fn().mockReturnValue(null), + setSession: vi.fn(), + removeSession: vi.fn(), + }, +})); + +describe('WalletButton', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders "Connect Wallet" button when disconnected', () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain('Connect Wallet'); + expect(html).not.toContain('Disconnect'); + }); + + it('applies custom className when provided', () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain('custom-class'); + }); + + it('renders the wallet button wrapper with correct structure', () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain('button'); + expect(html).toContain('rounded-full'); + }); +}); diff --git a/src/__tests__/WalletContext.test.tsx b/src/__tests__/WalletContext.test.tsx new file mode 100644 index 0000000..01ff926 --- /dev/null +++ b/src/__tests__/WalletContext.test.tsx @@ -0,0 +1,83 @@ +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { vi, describe, it, expect, beforeEach, type Mock } from 'vitest'; +import { WalletProvider, useWalletContext } from '../context/WalletContext'; +import { connectWallet } from '@/lib/stellar'; +import { fetchChallenge, login } from '@/lib/api'; + +vi.mock('@/lib/stellar', () => ({ + connectWallet: vi.fn(), + disconnectWallet: vi.fn(), + getStoredAddress: vi.fn(() => null), + getConnectedAddress: vi.fn(), + signAuthMessage: vi.fn(async (c: string) => `signed:${c}`), + EXPECTED_NETWORK_PASSPHRASE: 'Test SDF Network ; September 2015', +})); + +vi.mock('@/lib/api', () => ({ + fetchChallenge: vi.fn(), + login: vi.fn(), + setAuthErrorHandler: vi.fn(), +})); + +vi.mock('@/lib/storage', () => ({ + default: { + getSession: vi.fn(() => null), + setSession: vi.fn(), + removeSession: vi.fn(), + }, +})); + +interface WalletHandle { + readonly ctx: ReturnType; +} + +function renderWallet(): WalletHandle { + let captured: ReturnType | undefined; + const container = document.createElement('div'); + let root: Root; + + function Consumer() { + captured = useWalletContext(); + return null; + } + + act(() => { + root = createRoot(container); + root.render( + + + , + ); + }); + + return { + get ctx() { + return captured!; + }, + }; +} + +describe('WalletProvider auth-failure error handling (issue #221)', () => { + beforeEach(() => { + vi.clearAllMocks(); + (fetchChallenge as Mock).mockResolvedValue('challenge-123'); + }); + + it('preserves the auth-failure message even though disconnect() clears error', async () => { + // A wallet connects fine, but the login exchange fails. disconnect() runs + // inside the catch and ends with setError(null); the fix orders it BEFORE + // setError(...) so the message survives instead of being wiped. + (connectWallet as Mock).mockResolvedValue({ address: 'GTEST', networkPassphrase: null }); + (login as Mock).mockRejectedValue(new Error('login rejected')); + + const handle = renderWallet(); + + await act(async () => { + await handle.ctx.connect(); + }); + + expect(handle.ctx.error).toBe('Auth failed: login rejected'); + expect(handle.ctx.connected).toBe(false); + }); +}); diff --git a/src/__tests__/constants.test.ts b/src/__tests__/constants.test.ts index 2fedea5..72272e2 100644 --- a/src/__tests__/constants.test.ts +++ b/src/__tests__/constants.test.ts @@ -4,7 +4,10 @@ import { CATEGORY_LABELS, STATUS_COLOURS, TOAST_DEFAULT_DURATION_MS, + COPY_FEEDBACK_DURATION_MS, POLLING_INTERVAL_MS, + CLAIM_POLL_INTERVAL_MS, + CLAIM_POLL_MAX_ATTEMPTS, WALLET_STORAGE_KEY, ADDRESS_STORAGE_KEY, } from '../lib/constants'; @@ -33,12 +36,15 @@ describe('constants', () => { expect(STATUS_COLOURS['Rejected']).toBe('red'); }); - it('TOAST_DEFAULT_DURATION_MS is a positive number', () => { + it('TOAST_DEFAULT_DURATION_MS and COPY_FEEDBACK_DURATION_MS are positive numbers', () => { expect(TOAST_DEFAULT_DURATION_MS).toBeGreaterThan(0); + expect(COPY_FEEDBACK_DURATION_MS).toBe(2000); }); - it('POLLING_INTERVAL_MS is at least 10 seconds', () => { + it('POLLING_INTERVAL_MS and CLAIM_POLL_INTERVAL_MS are positive numbers', () => { expect(POLLING_INTERVAL_MS).toBeGreaterThanOrEqual(10_000); + expect(CLAIM_POLL_INTERVAL_MS).toBe(3000); + expect(CLAIM_POLL_MAX_ATTEMPTS).toBe(20); }); it('storage keys are defined strings', () => { diff --git a/src/__tests__/oracle.test.ts b/src/__tests__/oracle.test.ts index 55f5409..d38b3b2 100644 --- a/src/__tests__/oracle.test.ts +++ b/src/__tests__/oracle.test.ts @@ -22,6 +22,23 @@ describe('parseOracleKey', () => { expect(result.period).toBe('2026-06-01'); }); + it('keeps the date when a flight number itself contains a colon (issue #223)', () => { + // "AB:12" as a flight number produces flight:AB:12:2026-06-01. A naive + // split on ':' would drop the date; parsing from the last ':' preserves it. + const key = buildFlightKey('AB:12', '2026-06-01'); + const result = parseOracleKey(key); + expect(result.dataType).toBe('flight'); + expect(result.flightNumber).toBe('AB:12'); + expect(result.period).toBe('2026-06-01'); + }); + + it('parses a flight key with no date segment', () => { + const result = parseOracleKey('flight:KQ100'); + expect(result.dataType).toBe('flight'); + expect(result.flightNumber).toBe('KQ100'); + expect(result.period).toBeUndefined(); + }); + it('handles unknown keys as unknown', () => { expect(parseOracleKey('unknown:key').dataType).toBe('unknown'); }); @@ -47,6 +64,18 @@ describe('buildRainfallKey', () => { const key = buildRainfallKey(0, 0, 2026, 3); expect(key).toContain('2026-03'); }); + + it('clamps coordinate precision so keys stay within the 32-char Soroban limit (issue #222)', () => { + // Raw high-precision coordinates would blow past 32 chars; clamping to 4 + // decimals keeps the generated key comfortably within budget. + const key = buildRainfallKey(-0.091712345678, 34.767912345678, 2026, 12); + expect(key.length).toBeLessThanOrEqual(32); + expect(key).toBe('rainfall:-0.0917,34.7679:2026-12'); + }); + + it('does not pad short coordinates with trailing zeros', () => { + expect(buildRainfallKey(0, 0, 2026, 6)).toBe('rainfall:0,0:2026-06'); + }); }); describe('buildFlightKey', () => { 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__/stellar.test.ts b/src/__tests__/stellar.test.ts new file mode 100644 index 0000000..d46adcb --- /dev/null +++ b/src/__tests__/stellar.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; + +const mockSignMessage = vi.fn(); +const mockDisconnect = vi.fn(); + +vi.mock('@creit.tech/stellar-wallets-kit', () => ({ + StellarWalletsKit: vi.fn().mockImplementation(() => ({ + signMessage: mockSignMessage, + disconnect: mockDisconnect, + setWallet: vi.fn(), + openModal: vi.fn(), + getAddress: vi.fn(), + getNetwork: vi.fn(), + signTransaction: vi.fn(), + })), + WalletNetwork: { + PUBLIC: 'Public Global Stellar Network ; September 2015', + TESTNET: 'Test SDF Network ; September 2015', + }, + allowAllModules: vi.fn(() => []), +})); + +import { + EXPECTED_NETWORK_PASSPHRASE, + getStoredAddress, + disconnectWallet, + signAuthMessage, +} from '../lib/stellar'; +import { WalletError } from '../lib/errors'; +import storage from '../lib/storage'; +import { + ADDRESS_STORAGE_KEY, + WALLET_STORAGE_KEY, + NETWORK_STORAGE_KEY, +} from '../lib/constants'; + +describe('stellar helpers', () => { + beforeEach(() => { + storage.remove(WALLET_STORAGE_KEY); + storage.remove(ADDRESS_STORAGE_KEY); + storage.remove(NETWORK_STORAGE_KEY); + vi.clearAllMocks(); + }); + + describe('EXPECTED_NETWORK_PASSPHRASE', () => { + it('is a non-empty string', () => { + expect(typeof EXPECTED_NETWORK_PASSPHRASE).toBe('string'); + expect(EXPECTED_NETWORK_PASSPHRASE.length).toBeGreaterThan(0); + }); + + it('equals the Testnet passphrase when STELLAR_NETWORK defaults to TESTNET', () => { + // In test environments NEXT_PUBLIC_STELLAR_NETWORK is unset, so STELLAR_NETWORK + // falls back to 'TESTNET' and EXPECTED_NETWORK_PASSPHRASE must match exactly. + expect(EXPECTED_NETWORK_PASSPHRASE).toBe('Test SDF Network ; September 2015'); + }); + }); + + describe('getStoredAddress', () => { + it('returns null when no address has been persisted', () => { + expect(getStoredAddress()).toBeNull(); + }); + + it('returns the address written by the connect flow', () => { + storage.set(ADDRESS_STORAGE_KEY, 'GABCDEF1234567890'); + expect(getStoredAddress()).toBe('GABCDEF1234567890'); + }); + }); + + describe('disconnectWallet', () => { + it('removes the wallet-id, address, and network keys from storage', () => { + storage.set(WALLET_STORAGE_KEY, 'freighter'); + storage.set(ADDRESS_STORAGE_KEY, 'GTEST'); + storage.set(NETWORK_STORAGE_KEY, 'testnet'); + + disconnectWallet(); + + expect(storage.get(WALLET_STORAGE_KEY)).toBeNull(); + expect(storage.get(ADDRESS_STORAGE_KEY)).toBeNull(); + expect(storage.get(NETWORK_STORAGE_KEY)).toBeNull(); + }); + }); + + describe('signAuthMessage', () => { + it('throws WalletError when no wallet address is stored', async () => { + await expect(signAuthMessage('challenge')).rejects.toThrow(WalletError); + }); + + it('passes through the signedMessage returned by kit.signMessage', async () => { + storage.set(ADDRESS_STORAGE_KEY, 'GTEST123'); + storage.set(WALLET_STORAGE_KEY, 'freighter'); + mockSignMessage.mockResolvedValueOnce({ signedMessage: 'base64sig==' }); + + const result = await signAuthMessage('my-challenge-nonce'); + + expect(result).toBe('base64sig=='); + expect(mockSignMessage).toHaveBeenCalledWith({ + message: 'my-challenge-nonce', + address: 'GTEST123', + }); + }); + + it('returns the raw base64 value without any encoding transformation', async () => { + // signAuthMessage must return exactly what the wallet kit provides. + // SEP-43 wallets return base64, not hex. The backend owns decoding. + storage.set(ADDRESS_STORAGE_KEY, 'GTEST123'); + storage.set(WALLET_STORAGE_KEY, 'freighter'); + const b64 = 'aGVsbG8gd29ybGQ='; + mockSignMessage.mockResolvedValueOnce({ signedMessage: b64 }); + + expect(await signAuthMessage('challenge')).toBe(b64); + }); + }); +}); diff --git a/src/__tests__/useClaim.test.tsx b/src/__tests__/useClaim.test.tsx new file mode 100644 index 0000000..32bebae --- /dev/null +++ b/src/__tests__/useClaim.test.tsx @@ -0,0 +1,80 @@ +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { vi, describe, it, expect, beforeEach, type Mock } from 'vitest'; +import { useClaim } from '../hooks/useClaim'; +import { invokeSubmitClaim } from '@/lib/contract'; + +vi.mock('@/hooks/useWallet', () => ({ + useWallet: () => ({ address: 'GADDR' }), +})); + +vi.mock('@/lib/api', () => ({ + fetchUserClaims: vi.fn(async () => []), + fetchClaim: vi.fn(async () => null), +})); + +vi.mock('@/lib/contract', () => ({ + invokeSubmitClaim: vi.fn(), +})); + +interface HookResult { + readonly current: T; +} + +function renderHook(useHook: () => T): HookResult { + let result: T = undefined!; + const container = document.createElement('div'); + let root: Root; + + function TestComponent() { + result = useHook(); + return null; + } + + act(() => { + root = createRoot(container); + root.render(); + }); + + return { + get current() { + return result; + }, + }; +} + +describe('useClaim retry handling (issue #220)', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('surfaces a submit failure after reset() instead of hanging on "Submitting…"', async () => { + (invokeSubmitClaim as Mock).mockRejectedValue(new Error('chain boom')); + + const hook = renderHook(() => useClaim('policy-1')); + // Let the on-mount existing-claim effect settle to 'idle'. + await act(async () => {}); + + // First failed submit surfaces an error as expected. + await act(async () => { + await hook.current.submit('GADDR', 'policy-1'); + }); + expect(hook.current.step).toBe('error'); + expect(hook.current.error).toBe('chain boom'); + + // User clicks "Try again" -> reset() sets cancelledRef = true. + act(() => { + hook.current.reset(); + }); + expect(hook.current.step).toBe('idle'); + expect(hook.current.error).toBeNull(); + + // The second failure must NOT be swallowed: submit() resets cancelledRef + // to false at its top, so the error surfaces instead of the UI hanging. + await act(async () => { + await hook.current.submit('GADDR', 'policy-1'); + }); + expect(hook.current.step).toBe('error'); + expect(hook.current.error).toBe('chain boom'); + }); +}); diff --git a/src/__tests__/useDebounce.test.tsx b/src/__tests__/useDebounce.test.tsx new file mode 100644 index 0000000..4b384c0 --- /dev/null +++ b/src/__tests__/useDebounce.test.tsx @@ -0,0 +1,96 @@ +import { act } from 'react'; +import { useDebounce } from '../hooks/useDebounce'; +import { renderHook, flushMicrotasks } from './renderHook'; + +describe('useDebounce', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('returns the initial value immediately', () => { + const hook = renderHook(() => useDebounce('initial', 500)); + expect(hook.current).toBe('initial'); + }); + + it('does not update the debounced value until the delay elapses', async () => { + let value = 'first'; + const hook = renderHook(() => useDebounce(value, 500)); + expect(hook.current).toBe('first'); + + value = 'second'; + hook.rerender(); + expect(hook.current).toBe('first'); + + await act(async () => { + vi.advanceTimersByTime(250); + }); + expect(hook.current).toBe('first'); + + await act(async () => { + vi.advanceTimersByTime(250); + }); + expect(hook.current).toBe('second'); + }); + + it('collapses rapid successive updates within the delay window to the final value', async () => { + let value = 'a'; + const hook = renderHook(() => useDebounce(value, 500)); + + value = 'b'; + hook.rerender(); + + value = 'c'; + hook.rerender(); + + value = 'd'; + hook.rerender(); + + await act(async () => { + vi.advanceTimersByTime(500); + }); + + expect(hook.current).toBe('d'); + }); + + it('restarts the delay when the value changes within the delay window', async () => { + let value = 'first'; + const hook = renderHook(() => useDebounce(value, 500)); + + await act(async () => { + vi.advanceTimersByTime(300); + }); + expect(hook.current).toBe('first'); + + value = 'second'; + hook.rerender(); + + await act(async () => { + vi.advanceTimersByTime(300); + }); + expect(hook.current).toBe('first'); + + await act(async () => { + vi.advanceTimersByTime(200); + }); + expect(hook.current).toBe('second'); + }); + + it('works with different types of values', async () => { + let value: number | null = 42; + const hook = renderHook(() => useDebounce(value, 500)); + expect(hook.current).toBe(42); + + value = null; + hook.rerender(); + expect(hook.current).toBe(42); + + await act(async () => { + vi.advanceTimersByTime(500); + }); + expect(hook.current).toBeNull(); + }); +}); 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..52404db --- /dev/null +++ b/src/__tests__/usePolicies.test.tsx @@ -0,0 +1,219 @@ +import { act } from 'react'; +import { usePolicies, usePolicy } from '../hooks/usePolicies'; +import { renderHook, flushMicrotasks } from './renderHook'; +import type { Policy } from '../types'; + +const { fetchUserPolicies, fetchPolicy } = vi.hoisted(() => ({ + fetchUserPolicies: vi.fn(), + fetchPolicy: vi.fn(), +})); + +vi.mock('@/lib/api', () => ({ fetchUserPolicies, fetchPolicy })); + +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); + }); +}); + +describe('usePolicy', () => { + beforeEach(() => { + fetchPolicy.mockReset(); + }); + + it('does not fetch and returns null when there is no policy id', async () => { + const hook = renderHook(() => usePolicy(null)); + await flushMicrotasks(); + + expect(hook.current.policy).toBeNull(); + expect(hook.current.loading).toBe(false); + expect(fetchPolicy).not.toHaveBeenCalled(); + }); + + it('shows loading and populates policy on success', async () => { + const policy = makePolicy(); + fetchPolicy.mockResolvedValue(policy); + + const hook = renderHook(() => usePolicy('policy-1')); + expect(hook.current.loading).toBe(true); + + await flushMicrotasks(); + + expect(hook.current.loading).toBe(false); + expect(hook.current.policy).toEqual(policy); + expect(hook.current.error).toBeNull(); + }); + + it('surfaces an error message when the fetch fails', async () => { + fetchPolicy.mockRejectedValue(new Error('policy not found')); + + const hook = renderHook(() => usePolicy('policy-1')); + await flushMicrotasks(); + + expect(hook.current.loading).toBe(false); + expect(hook.current.error).toBe('policy not found'); + expect(hook.current.policy).toBeNull(); + }); + + it('does not let a stale response overwrite the current one when id changes quickly', async () => { + const pending: Record void> = {}; + fetchPolicy.mockImplementation( + (id: string) => + new Promise((resolve) => { pending[id] = resolve; }), + ); + + let id = 'policy-1'; + const hook = renderHook(() => usePolicy(id)); + await flushMicrotasks(); + expect(pending['policy-1']).toBeDefined(); + + // Change id before the first request resolves. + id = 'policy-2'; + hook.rerender(); + await flushMicrotasks(); + expect(pending['policy-2']).toBeDefined(); + + const policy2 = makePolicy({ id: 'policy-2' }); + await act(async () => { + pending['policy-2'](policy2); + await Promise.resolve(); + }); + expect(hook.current.policy).toEqual(policy2); + + // The stale policy-1 response now resolves; it must not overwrite + // the current policy-2. + const policy1 = makePolicy({ id: 'policy-1' }); + await act(async () => { + pending['policy-1'](policy1); + await Promise.resolve(); + }); + + expect(hook.current.policy).toEqual(policy2); + }); + + it('refetch allows manual refresh independent of id changes', async () => { + const policy = makePolicy(); + fetchPolicy.mockResolvedValue(policy); + + const hook = renderHook(() => usePolicy('policy-1')); + await flushMicrotasks(); + expect(hook.current.policy).toEqual(policy); + expect(fetchPolicy).toHaveBeenCalledTimes(1); + + const updatedPolicy = makePolicy({ coverage: '20000000' }); + fetchPolicy.mockResolvedValue(updatedPolicy); + + await hook.current.refetch(); + + expect(hook.current.policy).toEqual(updatedPolicy); + expect(fetchPolicy).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/__tests__/usePools.test.tsx b/src/__tests__/usePools.test.tsx new file mode 100644 index 0000000..d512a8e --- /dev/null +++ b/src/__tests__/usePools.test.tsx @@ -0,0 +1,83 @@ +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 makePoolStats(overrides: Partial = {}): PoolStats { + return { + id: 'pool-1', + name: 'Weather Pool A', + tvl: '1000000000', + utilizationRate: '0.45', + totalPolicies: '150', + totalClaims: '5', + apy: '0.12', + ...overrides, + }; +} + +describe('usePools', () => { + beforeEach(() => { + fetchPoolStats.mockReset(); + }); + + it('loads pools and exposes refetch', async () => { + const pools = [makePoolStats()]; + fetchPoolStats.mockResolvedValue(pools); + + const hook = renderHook(() => usePools()); + expect(hook.current.loading).toBe(true); + expect(hook.current.error).toBeNull(); + + await flushMicrotasks(); + + expect(hook.current.loading).toBe(false); + expect(hook.current.pools).toEqual(pools); + expect(hook.current.error).toBeNull(); + expect(typeof hook.current.refetch).toBe('function'); + }); + + it('surfaces an error message when the fetch fails', async () => { + fetchPoolStats.mockRejectedValue(new Error('api error')); + + const hook = renderHook(() => usePools()); + await flushMicrotasks(); + + expect(hook.current.loading).toBe(false); + expect(hook.current.error).toBe('api error'); + expect(hook.current.pools).toEqual([]); + }); + + it('refetch loads pools again', async () => { + const initialPools = [makePoolStats({ id: 'pool-1' })]; + fetchPoolStats.mockResolvedValue(initialPools); + + const hook = renderHook(() => usePools()); + await flushMicrotasks(); + expect(hook.current.pools).toEqual(initialPools); + + const updatedPools = [ + makePoolStats({ id: 'pool-1', tvl: '2000000000' }), + ]; + fetchPoolStats.mockResolvedValue(updatedPools); + + await hook.current.refetch(); + + expect(hook.current.pools).toEqual(updatedPools); + expect(hook.current.error).toBeNull(); + }); + + it('returns empty pools array on initial load before data arrives', () => { + fetchPoolStats.mockImplementation( + () => new Promise(() => {}), + ); + + const hook = renderHook(() => usePools()); + + expect(hook.current.pools).toEqual([]); + expect(hook.current.loading).toBe(true); + }); +}); diff --git a/src/__tests__/useProducts.test.tsx b/src/__tests__/useProducts.test.tsx new file mode 100644 index 0000000..9713b64 --- /dev/null +++ b/src/__tests__/useProducts.test.tsx @@ -0,0 +1,84 @@ +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: 'Weather Protection', + description: 'Weather insurance product', + minCoverage: '1000000', + maxCoverage: '100000000', + minPremium: '10000', + riskLevel: 'Low', + ...overrides, + }; +} + +describe('useProducts', () => { + beforeEach(() => { + fetchProducts.mockReset(); + }); + + it('loads products and exposes refetch', async () => { + const products = [makeProduct()]; + fetchProducts.mockResolvedValue(products); + + const hook = renderHook(() => useProducts()); + expect(hook.current.loading).toBe(true); + expect(hook.current.error).toBeNull(); + + await flushMicrotasks(); + + expect(hook.current.loading).toBe(false); + expect(hook.current.products).toEqual(products); + expect(hook.current.error).toBeNull(); + expect(typeof hook.current.refetch).toBe('function'); + }); + + it('surfaces an error message when the fetch fails', async () => { + fetchProducts.mockRejectedValue(new Error('network error')); + + const hook = renderHook(() => useProducts()); + await flushMicrotasks(); + + expect(hook.current.loading).toBe(false); + expect(hook.current.error).toBe('network error'); + expect(hook.current.products).toEqual([]); + }); + + it('refetch loads products again', async () => { + const initialProducts = [makeProduct({ id: 'product-1' })]; + fetchProducts.mockResolvedValue(initialProducts); + + const hook = renderHook(() => useProducts()); + await flushMicrotasks(); + expect(hook.current.products).toEqual(initialProducts); + + const updatedProducts = [ + makeProduct({ id: 'product-1' }), + makeProduct({ id: 'product-2' }), + ]; + fetchProducts.mockResolvedValue(updatedProducts); + + await hook.current.refetch(); + + expect(hook.current.products).toEqual(updatedProducts); + expect(hook.current.error).toBeNull(); + }); + + it('returns empty products array on initial load before data arrives', () => { + fetchProducts.mockImplementation( + () => new Promise(() => {}), + ); + + const hook = renderHook(() => useProducts()); + + expect(hook.current.products).toEqual([]); + expect(hook.current.loading).toBe(true); + }); +}); 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/apple-icon.tsx b/src/app/apple-icon.tsx new file mode 100644 index 0000000..221e571 --- /dev/null +++ b/src/app/apple-icon.tsx @@ -0,0 +1,34 @@ +import { ImageResponse } from 'next/og'; + +export const size = { width: 180, height: 180 }; +export const contentType = 'image/png'; + +export default function AppleIcon() { + return new ImageResponse( + ( +
+
+ PS +
+
+ ), + { ...size }, + ); +} diff --git a/src/app/claims/page.tsx b/src/app/claims/page.tsx index 8d52a13..d975ecc 100644 --- a/src/app/claims/page.tsx +++ b/src/app/claims/page.tsx @@ -1,4 +1,4 @@ -'use client'; +ο»Ώ'use client'; import { useState, useEffect, useCallback } from 'react'; import { useWallet } from '@/hooks/useWallet'; @@ -84,7 +84,7 @@ export default function ClaimsPage() { Refresh {lastFetched && ( - + Updated {lastFetched.toLocaleTimeString()} )} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 3fb2d20..ad100e7 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,4 +1,4 @@ -import type { Metadata } from 'next'; +ο»Ώimport type { Metadata } from 'next'; import './globals.css'; import { NavBar } from '@/components/NavBar'; import { ToastContainer } from '@/components/Toast'; @@ -7,13 +7,17 @@ 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', description: 'Automatic payouts triggered by real-world data. No claims adjuster. Powered by Soroban smart contracts.', + manifest: '/manifest.webmanifest', icons: { icon: '/assets/parashield-logo-dark.png', }, @@ -38,6 +42,8 @@ export const metadata: Metadata = { }, }; +const CURRENT_YEAR = new Date().getFullYear(); + const NavBarFallback = (