From e6822496f189943c8fe4202ff32fed35ef4ad7ee Mon Sep 17 00:00:00 2001 From: ayo-ola0710 Date: Sat, 30 May 2026 14:48:51 +0100 Subject: [PATCH 1/2] feat: implement blockchain ledger page with API integration and event filtering --- .../BlockchainLedger.test.tsx | 414 ++++++++++++++ .../BlockchainLedger/BlockchainLedger.tsx | 509 +++++++++++++++++- .../src/services/api/endpoints/ledger.test.ts | 177 ++++++ frontend/src/services/api/endpoints/ledger.ts | 45 ++ frontend/src/services/api/index.ts | 1 + 5 files changed, 1144 insertions(+), 2 deletions(-) create mode 100644 frontend/src/pages/BlockchainLedger/BlockchainLedger.test.tsx create mode 100644 frontend/src/services/api/endpoints/ledger.test.ts create mode 100644 frontend/src/services/api/endpoints/ledger.ts diff --git a/frontend/src/pages/BlockchainLedger/BlockchainLedger.test.tsx b/frontend/src/pages/BlockchainLedger/BlockchainLedger.test.tsx new file mode 100644 index 0000000..dc6e1f9 --- /dev/null +++ b/frontend/src/pages/BlockchainLedger/BlockchainLedger.test.tsx @@ -0,0 +1,414 @@ +import React from 'react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor, fireEvent } from '@testing-library/react'; +import BlockchainLedger from './BlockchainLedger'; +import type { PaginatedLedgerBlocks } from '@services/api/endpoints/ledger'; + +// ─── Mock ledgerApi ─────────────────────────────────────────────────────────── + +vi.mock('@services/api/endpoints/ledger', () => ({ + ledgerApi: { + getBlocks: vi.fn(), + }, +})); + +import { ledgerApi } from '@services/api/endpoints/ledger'; + +const mockGetBlocks = ledgerApi.getBlocks as ReturnType; + +// ─── Fixtures ───────────────────────────────────────────────────────────────── + +const makeBlock = (n: number) => ({ + blockNumber: 50_000_000 + n, + timestamp: `2024-03-15T${String(10 + n).padStart(2, '0')}:00:00.000Z`, + shipmentId: `ship-${n}`, + shipmentReference: `NAV-2024-00${n}`, + milestoneEvent: 'DELIVERED' as const, + transactionHash: `${'a'.repeat(60)}000${n}`, + ledger: 50_000_000 + n, + verified: true, +}); + +const singlePageResponse: PaginatedLedgerBlocks = { + data: [makeBlock(1), makeBlock(2), makeBlock(3)], + nextCursor: null, + hasMore: false, + total: 3, +}; + +const multiPageFirstResponse: PaginatedLedgerBlocks = { + data: [makeBlock(1), makeBlock(2)], + nextCursor: 'cursor-page-2', + hasMore: true, + total: 4, +}; + +const multiPageSecondResponse: PaginatedLedgerBlocks = { + data: [makeBlock(3), makeBlock(4)], + nextCursor: null, + hasMore: false, + total: 4, +}; + +const emptyResponse: PaginatedLedgerBlocks = { + data: [], + nextCursor: null, + hasMore: false, + total: 0, +}; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +const renderPage = () => render(); + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe('BlockchainLedger page', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + // ── Loading skeleton ────────────────────────────────────────────────────── + + describe('loading state', () => { + it('shows loading skeletons while fetching', () => { + // Never resolves during this test + mockGetBlocks.mockReturnValue(new Promise(() => {})); + renderPage(); + + // Table headers should be visible + expect(screen.getByText('Block #')).toBeInTheDocument(); + expect(screen.getByText('Timestamp')).toBeInTheDocument(); + expect(screen.getByText('Milestone')).toBeInTheDocument(); + + // No block data rendered yet + expect(screen.queryByText('NAV-2024-001')).not.toBeInTheDocument(); + }); + }); + + // ── Data rendering ──────────────────────────────────────────────────────── + + describe('data rendering', () => { + it('renders table with block data after loading', async () => { + mockGetBlocks.mockResolvedValue(singlePageResponse); + renderPage(); + + await waitFor(() => { + expect(screen.getByText('NAV-2024-001')).toBeInTheDocument(); + expect(screen.getByText('NAV-2024-002')).toBeInTheDocument(); + expect(screen.getByText('NAV-2024-003')).toBeInTheDocument(); + }); + }); + + it('renders block numbers with # prefix', async () => { + mockGetBlocks.mockResolvedValue(singlePageResponse); + renderPage(); + + await waitFor(() => { + expect(screen.getByText('#50,000,001')).toBeInTheDocument(); + }); + }); + + it('renders milestone badge for each block', async () => { + mockGetBlocks.mockResolvedValue(singlePageResponse); + renderPage(); + + await waitFor(() => { + const delivered = screen.getAllByText('Delivered'); + // 3 blocks all DELIVERED + expect(delivered.length).toBeGreaterThanOrEqual(3); + }); + }); + + it('renders "Verified" status for verified blocks', async () => { + mockGetBlocks.mockResolvedValue(singlePageResponse); + renderPage(); + + await waitFor(() => { + const verified = screen.getAllByText('Verified'); + expect(verified.length).toBe(3); + }); + }); + + it('renders "Pending" status for unverified blocks', async () => { + const unverified: PaginatedLedgerBlocks = { + data: [{ ...makeBlock(1), verified: false }], + nextCursor: null, + hasMore: false, + total: 1, + }; + mockGetBlocks.mockResolvedValue(unverified); + renderPage(); + + await waitFor(() => { + expect(screen.getByText('Pending')).toBeInTheDocument(); + }); + }); + + it('renders tx hash links pointing to Stellar Expert', async () => { + mockGetBlocks.mockResolvedValue(singlePageResponse); + renderPage(); + + await waitFor(() => { + const links = screen.getAllByRole('link'); + const txLinks = links.filter((l) => + l.getAttribute('href')?.includes('stellar.expert/explorer/public/tx'), + ); + expect(txLinks.length).toBeGreaterThan(0); + }); + }); + + it('tx hash links open in new tab', async () => { + mockGetBlocks.mockResolvedValue(singlePageResponse); + renderPage(); + + await waitFor(() => { + const links = screen.getAllByRole('link'); + links.forEach((link) => { + expect(link).toHaveAttribute('target', '_blank'); + expect(link).toHaveAttribute('rel', 'noopener noreferrer'); + }); + }); + }); + + it('renders total block count in stats bar', async () => { + mockGetBlocks.mockResolvedValue(singlePageResponse); + renderPage(); + + await waitFor(() => { + expect(screen.getByText('3')).toBeInTheDocument(); + }); + }); + }); + + // ── Empty state ─────────────────────────────────────────────────────────── + + describe('empty state', () => { + it('renders empty state when no blocks are returned', async () => { + mockGetBlocks.mockResolvedValue(emptyResponse); + renderPage(); + + await waitFor(() => { + expect(screen.getByTestId ? screen.queryByText('No blocks found') : screen.getByText('No blocks found')).toBeInTheDocument(); + }); + }); + + it('does not show pagination when no blocks', async () => { + mockGetBlocks.mockResolvedValue(emptyResponse); + renderPage(); + + await waitFor(() => { + expect(screen.queryByLabelText('Load next page')).not.toBeInTheDocument(); + }); + }); + }); + + // ── Error state ─────────────────────────────────────────────────────────── + + describe('error state', () => { + it('renders error banner when API call fails', async () => { + mockGetBlocks.mockRejectedValue(new Error('Network Error')); + renderPage(); + + await waitFor(() => { + expect(screen.getByRole('alert')).toBeInTheDocument(); + expect(screen.getByText('Error loading ledger')).toBeInTheDocument(); + }); + }); + }); + + // ── Pagination ──────────────────────────────────────────────────────────── + + describe('cursor-based pagination', () => { + it('shows Next button when hasMore is true', async () => { + mockGetBlocks.mockResolvedValue(multiPageFirstResponse); + renderPage(); + + await waitFor(() => { + expect(screen.getByLabelText('Load next page')).toBeInTheDocument(); + }); + }); + + it('Next button is disabled when hasMore is false', async () => { + mockGetBlocks.mockResolvedValue(singlePageResponse); + renderPage(); + + await waitFor(() => { + // Pagination bar only shown when hasMore || pageIndex > 0 + expect(screen.queryByLabelText('Load next page')).not.toBeInTheDocument(); + }); + }); + + it('Previous button is disabled on first page', async () => { + mockGetBlocks.mockResolvedValue(multiPageFirstResponse); + renderPage(); + + await waitFor(() => { + const prevBtn = screen.getByLabelText('Previous page'); + expect(prevBtn).toBeDisabled(); + }); + }); + + it('clicking Next loads page 2 data', async () => { + mockGetBlocks + .mockResolvedValueOnce(multiPageFirstResponse) + .mockResolvedValueOnce(multiPageSecondResponse); + + renderPage(); + + await waitFor(() => { + expect(screen.getByText('NAV-2024-001')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByLabelText('Load next page')); + + await waitFor(() => { + expect(screen.getByText('NAV-2024-003')).toBeInTheDocument(); + expect(screen.getByText('NAV-2024-004')).toBeInTheDocument(); + }); + + expect(mockGetBlocks).toHaveBeenCalledTimes(2); + expect(mockGetBlocks).toHaveBeenNthCalledWith(2, { + limit: 15, + cursor: 'cursor-page-2', + }); + }); + + it('clicking Previous goes back to page 1 data', async () => { + mockGetBlocks + .mockResolvedValueOnce(multiPageFirstResponse) // page 1 + .mockResolvedValueOnce(multiPageSecondResponse) // page 2 + .mockResolvedValueOnce(multiPageFirstResponse); // back to page 1 + + renderPage(); + + await waitFor(() => screen.getByText('NAV-2024-001')); + + fireEvent.click(screen.getByLabelText('Load next page')); + await waitFor(() => screen.getByText('NAV-2024-003')); + + const prevBtn = screen.getByLabelText('Previous page'); + expect(prevBtn).not.toBeDisabled(); + fireEvent.click(prevBtn); + + await waitFor(() => { + expect(screen.getByText('NAV-2024-001')).toBeInTheDocument(); + }); + }); + }); + + // ── Filtering ───────────────────────────────────────────────────────────── + + describe('filter functionality', () => { + it('renders "All Events" filter button', async () => { + mockGetBlocks.mockResolvedValue(singlePageResponse); + renderPage(); + + await waitFor(() => { + expect(screen.getByText('All Events')).toBeInTheDocument(); + }); + }); + + it('clicking a milestone filter calls API with that event', async () => { + mockGetBlocks.mockResolvedValue(singlePageResponse); + renderPage(); + + await waitFor(() => screen.getByText('Delivered')); + + // Click the filter button (in the filter bar – not the table badge) + const filterBtn = screen.getByRole('button', { name: /^Delivered$/ }); + fireEvent.click(filterBtn); + + await waitFor(() => { + // Last call should include milestoneEvent: 'DELIVERED' + const lastCall = mockGetBlocks.mock.calls.at(-1)?.[0] as { milestoneEvent?: string } | undefined; + expect(lastCall?.milestoneEvent).toBe('DELIVERED'); + }); + }); + + it('selecting a filter resets to page 1', async () => { + mockGetBlocks + .mockResolvedValueOnce(multiPageFirstResponse) + .mockResolvedValueOnce(multiPageSecondResponse) + .mockResolvedValue(singlePageResponse); + + renderPage(); + await waitFor(() => screen.getByText('NAV-2024-001')); + + // Go to page 2 + fireEvent.click(screen.getByLabelText('Load next page')); + await waitFor(() => screen.getByText('NAV-2024-003')); + + // Apply filter → should reset pagination (no cursor on next call) + const filterBtn = screen.getByRole('button', { name: /^Delivered$/ }); + fireEvent.click(filterBtn); + + await waitFor(() => { + // After filter reset, getBlocks is called without cursor + const lastCall = mockGetBlocks.mock.calls.at(-1)?.[0] as { cursor?: string; milestoneEvent?: string } | undefined; + expect(lastCall?.cursor).toBeUndefined(); + expect(lastCall?.milestoneEvent).toBe('DELIVERED'); + }); + }); + }); + + // ── Refresh ─────────────────────────────────────────────────────────────── + + describe('refresh button', () => { + it('renders refresh button', async () => { + mockGetBlocks.mockResolvedValue(singlePageResponse); + renderPage(); + + await waitFor(() => { + expect(screen.getByLabelText('Refresh ledger data')).toBeInTheDocument(); + }); + }); + + it('clicking refresh re-fetches data', async () => { + mockGetBlocks.mockResolvedValue(singlePageResponse); + renderPage(); + + await waitFor(() => screen.getByText('NAV-2024-001')); + + const refreshBtn = screen.getByLabelText('Refresh ledger data'); + fireEvent.click(refreshBtn); + + await waitFor(() => { + expect(mockGetBlocks).toHaveBeenCalledTimes(2); + }); + }); + }); + + // ── Accessibility ───────────────────────────────────────────────────────── + + describe('accessibility', () => { + it('table has accessible aria-label', async () => { + mockGetBlocks.mockResolvedValue(singlePageResponse); + renderPage(); + + await waitFor(() => { + expect( + screen.getByRole('table', { name: /blockchain ledger events table/i }), + ).toBeInTheDocument(); + }); + }); + + it('h1 heading is present', async () => { + mockGetBlocks.mockResolvedValue(singlePageResponse); + renderPage(); + + await waitFor(() => { + expect(screen.getByRole('heading', { level: 1, name: /blockchain ledger/i })).toBeInTheDocument(); + }); + }); + + it('page renders as main landmark', async () => { + mockGetBlocks.mockResolvedValue(singlePageResponse); + renderPage(); + + await waitFor(() => { + expect(screen.getByRole('main')).toBeInTheDocument(); + }); + }); + }); +}); diff --git a/frontend/src/pages/BlockchainLedger/BlockchainLedger.tsx b/frontend/src/pages/BlockchainLedger/BlockchainLedger.tsx index 1af5ec0..faf5fc3 100644 --- a/frontend/src/pages/BlockchainLedger/BlockchainLedger.tsx +++ b/frontend/src/pages/BlockchainLedger/BlockchainLedger.tsx @@ -1,7 +1,512 @@ -import React from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; +import { + ExternalLink, + Hash, + Layers, + AlertCircle, + RefreshCw, + ChevronLeft, + ChevronRight, + Filter, + CheckCircle2, + Clock, +} from 'lucide-react'; +import { ledgerApi } from '@services/api/endpoints/ledger'; +import type { LedgerBlock, MilestoneEvent, GetLedgerBlocksParams } from '@services/api/endpoints/ledger'; + +// ─── Constants ──────────────────────────────────────────────────────────────── + +const STELLAR_EXPERT_TX_BASE = 'https://stellar.expert/explorer/public/tx'; +const PAGE_LIMIT = 15; + +const MILESTONE_LABELS: Record = { + SHIPMENT_CREATED: 'Shipment Created', + PICKUP_CONFIRMED: 'Pickup Confirmed', + IN_TRANSIT: 'In Transit', + CUSTOMS_CLEARED: 'Customs Cleared', + OUT_FOR_DELIVERY: 'Out for Delivery', + DELIVERED: 'Delivered', + CANCELLED: 'Cancelled', + SETTLEMENT_INITIATED: 'Settlement Initiated', + SETTLEMENT_COMPLETED: 'Settlement Completed', + PROOF_SUBMITTED: 'Proof Submitted', +}; + +const MILESTONE_COLORS: Record = { + SHIPMENT_CREATED: { dot: 'bg-blue-400', badge: 'bg-blue-400/10', text: 'text-blue-400' }, + PICKUP_CONFIRMED: { dot: 'bg-cyan-400', badge: 'bg-cyan-400/10', text: 'text-cyan-400' }, + IN_TRANSIT: { dot: 'bg-primary', badge: 'bg-primary/10', text: 'text-primary' }, + CUSTOMS_CLEARED: { dot: 'bg-purple-400', badge: 'bg-purple-400/10', text: 'text-purple-400' }, + OUT_FOR_DELIVERY: { dot: 'bg-orange-400', badge: 'bg-orange-400/10', text: 'text-orange-400' }, + DELIVERED: { dot: 'bg-accent-green', badge: 'bg-accent-green/10', text: 'text-green-400' }, + CANCELLED: { dot: 'bg-accent-red', badge: 'bg-accent-red/10', text: 'text-red-400' }, + SETTLEMENT_INITIATED: { dot: 'bg-yellow-400', badge: 'bg-yellow-400/10', text: 'text-yellow-400' }, + SETTLEMENT_COMPLETED: { dot: 'bg-emerald-400', badge: 'bg-emerald-400/10', text: 'text-emerald-400' }, + PROOF_SUBMITTED: { dot: 'bg-indigo-400', badge: 'bg-indigo-400/10', text: 'text-indigo-400' }, +}; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function formatTimestamp(iso: string): string { + return new Intl.DateTimeFormat('en-US', { + month: 'short', + day: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }).format(new Date(iso)); +} + +function truncateHash(hash: string, chars = 8): string { + if (hash.length <= chars * 2 + 3) return hash; + return `${hash.slice(0, chars)}...${hash.slice(-chars)}`; +} + +// ─── Sub-components ─────────────────────────────────────────────────────────── + +function PageHeader({ onRefresh, refreshing }: { onRefresh: () => void; refreshing: boolean }) { + return ( +
+
+
+
+ +
+

+ Blockchain Ledger +

+
+

+ Immutable on-chain milestone history for all shipment events +

+
+ +
+ ); +} + +function StatsBar({ total, hasMore }: { total?: number; hasMore: boolean }) { + return ( +
+ {[ + { + label: 'Total Blocks', + value: total !== undefined ? total.toLocaleString() : '—', + icon: , + }, + { + label: 'Network', + value: 'Stellar Mainnet', + icon: , + }, + { + label: 'Status', + value: hasMore ? 'Syncing' : 'Up to date', + icon: hasMore + ? + : , + }, + ].map(({ label, value, icon }) => ( +
+
+ {icon} +
+
+

{label}

+

{value}

+
+
+ ))} +
+ ); +} + +interface FilterBarProps { + filter: MilestoneEvent | ''; + onFilterChange: (v: MilestoneEvent | '') => void; +} + +function FilterBar({ filter, onFilterChange }: FilterBarProps) { + return ( +
+
+ + Filter: +
+ + {(Object.keys(MILESTONE_LABELS) as MilestoneEvent[]).map((event) => { + const colors = MILESTONE_COLORS[event]; + return ( + + ); + })} +
+ ); +} + +function SkeletonRow({ cols }: { cols: number }) { + return ( + + {Array.from({ length: cols }).map((_, i) => ( + +
+ + ))} + + ); +} + +function TableSkeleton() { + return ( + <> + {Array.from({ length: 8 }).map((_, i) => ( + + ))} + + ); +} + +interface LedgerTableProps { + blocks: LedgerBlock[]; + loading: boolean; +} + +function LedgerTable({ blocks, loading }: LedgerTableProps) { + const thClass = + 'px-5 py-3.5 text-left text-[11px] font-semibold text-[#62ffff] uppercase tracking-widest border-b border-[rgba(98,255,255,0.15)] whitespace-nowrap'; + const tdClass = + 'px-5 py-4 text-sm text-text-primary border-b border-[rgba(98,255,255,0.08)]'; + + return ( +
+ + + + + + + + + + + + + {loading ? ( + + ) : ( + blocks.map((block) => { + const colors = MILESTONE_COLORS[block.milestoneEvent] ?? { + dot: 'bg-text-secondary', + badge: 'bg-text-secondary/10', + text: 'text-text-secondary', + }; + const label = MILESTONE_LABELS[block.milestoneEvent] ?? block.milestoneEvent; + const explorerUrl = `${STELLAR_EXPERT_TX_BASE}/${block.transactionHash}`; + + return ( + + {/* Block number */} + + + {/* Timestamp */} + + + {/* Shipment reference */} + + + {/* Milestone event */} + + + {/* Tx hash */} + + + {/* Verification status */} + + + ); + }) + )} + +
Block #TimestampShipment RefMilestoneTx HashStatus
+ + #{block.blockNumber.toLocaleString()} + + + {formatTimestamp(block.timestamp)} + + + {block.shipmentReference} + + + + + {label} + + + + {truncateHash(block.transactionHash)} + + + + {block.verified ? ( + + + Verified + + ) : ( + + + Pending + + )} +
+
+ ); +} + +function EmptyLedger() { + return ( +
+
+ +
+
+

No blocks found

+

+ No on-chain events match your current filter. Try selecting a different milestone type or clearing the filter. +

+
+
+ ); +} + +interface PagerProps { + hasPrev: boolean; + hasNext: boolean; + onPrev: () => void; + onNext: () => void; + pageLabel: string; + loading: boolean; +} + +function CursorPager({ hasPrev, hasNext, onPrev, onNext, pageLabel, loading }: PagerProps) { + return ( +
+ {pageLabel} +
+ + +
+
+ ); +} + +// ─── Main Page ──────────────────────────────────────────────────────────────── + +interface PageState { + cursor: string | null; + page: number; +} const BlockchainLedger: React.FC = () => { - return
Blockchain Ledger Page (Coming Soon)
; + const [blocks, setBlocks] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [total, setTotal] = useState(undefined); + const [hasMore, setHasMore] = useState(false); + const [filter, setFilter] = useState(''); + + // Cursor stack: index 0 = first page (no cursor), subsequent = "next" cursors + const [cursorStack, setCursorStack] = useState>([null]); + const [pageIndex, setPageIndex] = useState(0); + + const currentCursor = cursorStack[pageIndex] ?? null; + + const fetchBlocks = useCallback( + async (cursor: string | null, milestoneEvent: MilestoneEvent | '') => { + setLoading(true); + setError(null); + try { + const params: GetLedgerBlocksParams = { limit: PAGE_LIMIT }; + if (cursor) params.cursor = cursor; + if (milestoneEvent) params.milestoneEvent = milestoneEvent; + + const result = await ledgerApi.getBlocks(params); + setBlocks(result.data); + setHasMore(result.hasMore); + if (result.total !== undefined) setTotal(result.total); + + // Store the next cursor at cursorStack[pageIndex + 1] if available + if (result.hasMore && result.nextCursor) { + setCursorStack((prev) => { + const next = [...prev]; + next[pageIndex + 1] = result.nextCursor; + return next; + }); + } + } catch { + setError('Failed to load blockchain ledger data. Please try again.'); + } finally { + setLoading(false); + } + }, + [pageIndex], + ); + + // Re-fetch whenever cursor or filter changes + useEffect(() => { + void fetchBlocks(currentCursor, filter); + }, [currentCursor, filter]); // eslint-disable-line react-hooks/exhaustive-deps + + const handleFilterChange = (value: MilestoneEvent | '') => { + setFilter(value); + // Reset pagination when filter changes + setCursorStack([null]); + setPageIndex(0); + }; + + const handleNext = () => { + if (hasMore) setPageIndex((i) => i + 1); + }; + + const handlePrev = () => { + if (pageIndex > 0) setPageIndex((i) => i - 1); + }; + + const handleRefresh = () => { + void fetchBlocks(currentCursor, filter); + }; + + const pageLabel = total !== undefined + ? `Page ${pageIndex + 1} · ${total.toLocaleString()} total blocks` + : `Page ${pageIndex + 1}`; + + return ( +
+ {/* Header */} + + + {/* Stats bar */} + + + {/* Filter bar */} +
+ +
+ + {/* Error state */} + {error && !loading && ( + + )} + + {/* Table / empty state */} + {!error || loading ? ( + <> + {!loading && blocks.length === 0 ? ( +
+ +
+ ) : ( + + )} + + {/* Cursor pagination */} + {(hasMore || pageIndex > 0) && ( + 0} + hasNext={hasMore} + onPrev={handlePrev} + onNext={handleNext} + pageLabel={pageLabel} + loading={loading} + /> + )} + + ) : null} +
+ ); }; export default BlockchainLedger; diff --git a/frontend/src/services/api/endpoints/ledger.test.ts b/frontend/src/services/api/endpoints/ledger.test.ts new file mode 100644 index 0000000..63b3537 --- /dev/null +++ b/frontend/src/services/api/endpoints/ledger.test.ts @@ -0,0 +1,177 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { ledgerApi } from './ledger'; +import type { PaginatedLedgerBlocks, LedgerBlock } from './ledger'; + +// ─── Mock axios client ──────────────────────────────────────────────────────── + +vi.mock('../client', () => ({ + apiClient: { + get: vi.fn(), + }, +})); + +import { apiClient } from '../client'; + +const mockApiClient = apiClient as { get: ReturnType }; + +// ─── Fixtures ───────────────────────────────────────────────────────────────── + +const mockBlock: LedgerBlock = { + blockNumber: 50_000_001, + timestamp: '2024-03-15T10:30:00.000Z', + shipmentId: 'ship-001', + shipmentReference: 'NAV-2024-001', + milestoneEvent: 'DELIVERED', + transactionHash: 'a'.repeat(64), + ledger: 50_000_001, + verified: true, +}; + +const mockSecondBlock: LedgerBlock = { + blockNumber: 50_000_002, + timestamp: '2024-03-15T11:00:00.000Z', + shipmentId: 'ship-002', + shipmentReference: 'NAV-2024-002', + milestoneEvent: 'IN_TRANSIT', + transactionHash: 'b'.repeat(64), + ledger: 50_000_002, + verified: false, +}; + +const mockPaginatedResponse: PaginatedLedgerBlocks = { + data: [mockBlock, mockSecondBlock], + nextCursor: 'cursor-abc-123', + hasMore: true, + total: 250, +}; + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe('ledgerApi', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('getBlocks', () => { + it('calls GET /ledger/blocks without params', async () => { + mockApiClient.get.mockResolvedValueOnce({ data: mockPaginatedResponse }); + + const result = await ledgerApi.getBlocks(); + + expect(mockApiClient.get).toHaveBeenCalledOnce(); + expect(mockApiClient.get).toHaveBeenCalledWith('/ledger/blocks', { params: undefined }); + expect(result).toEqual(mockPaginatedResponse); + }); + + it('returns paginated blocks with correct shape', async () => { + mockApiClient.get.mockResolvedValueOnce({ data: mockPaginatedResponse }); + + const result = await ledgerApi.getBlocks(); + + expect(result.data).toHaveLength(2); + expect(result.nextCursor).toBe('cursor-abc-123'); + expect(result.hasMore).toBe(true); + expect(result.total).toBe(250); + }); + + it('passes cursor param when provided', async () => { + mockApiClient.get.mockResolvedValueOnce({ data: mockPaginatedResponse }); + + await ledgerApi.getBlocks({ cursor: 'cursor-abc-123' }); + + expect(mockApiClient.get).toHaveBeenCalledWith('/ledger/blocks', { + params: { cursor: 'cursor-abc-123' }, + }); + }); + + it('passes limit param when provided', async () => { + mockApiClient.get.mockResolvedValueOnce({ data: mockPaginatedResponse }); + + await ledgerApi.getBlocks({ limit: 25 }); + + expect(mockApiClient.get).toHaveBeenCalledWith('/ledger/blocks', { + params: { limit: 25 }, + }); + }); + + it('passes milestoneEvent filter when provided', async () => { + mockApiClient.get.mockResolvedValueOnce({ data: mockPaginatedResponse }); + + await ledgerApi.getBlocks({ milestoneEvent: 'DELIVERED' }); + + expect(mockApiClient.get).toHaveBeenCalledWith('/ledger/blocks', { + params: { milestoneEvent: 'DELIVERED' }, + }); + }); + + it('passes combined params correctly', async () => { + mockApiClient.get.mockResolvedValueOnce({ data: mockPaginatedResponse }); + + await ledgerApi.getBlocks({ + cursor: 'cursor-xyz', + limit: 10, + milestoneEvent: 'IN_TRANSIT', + shipmentId: 'ship-001', + }); + + expect(mockApiClient.get).toHaveBeenCalledWith('/ledger/blocks', { + params: { + cursor: 'cursor-xyz', + limit: 10, + milestoneEvent: 'IN_TRANSIT', + shipmentId: 'ship-001', + }, + }); + }); + + it('returns empty data when no blocks exist', async () => { + const emptyResponse: PaginatedLedgerBlocks = { + data: [], + nextCursor: null, + hasMore: false, + total: 0, + }; + mockApiClient.get.mockResolvedValueOnce({ data: emptyResponse }); + + const result = await ledgerApi.getBlocks(); + + expect(result.data).toHaveLength(0); + expect(result.hasMore).toBe(false); + expect(result.nextCursor).toBeNull(); + }); + + it('propagates network errors', async () => { + const networkError = new Error('Network Error'); + mockApiClient.get.mockRejectedValueOnce(networkError); + + await expect(ledgerApi.getBlocks()).rejects.toThrow('Network Error'); + }); + + it('propagates 401 errors', async () => { + const authError = Object.assign(new Error('Unauthorized'), { response: { status: 401 } }); + mockApiClient.get.mockRejectedValueOnce(authError); + + await expect(ledgerApi.getBlocks()).rejects.toThrow('Unauthorized'); + }); + + it('block data matches LedgerBlock interface shape', async () => { + mockApiClient.get.mockResolvedValueOnce({ data: mockPaginatedResponse }); + + const result = await ledgerApi.getBlocks(); + const block = result.data[0]; + + expect(typeof block.blockNumber).toBe('number'); + expect(typeof block.timestamp).toBe('string'); + expect(typeof block.shipmentId).toBe('string'); + expect(typeof block.shipmentReference).toBe('string'); + expect(typeof block.milestoneEvent).toBe('string'); + expect(typeof block.transactionHash).toBe('string'); + expect(typeof block.ledger).toBe('number'); + expect(typeof block.verified).toBe('boolean'); + }); + }); +}); diff --git a/frontend/src/services/api/endpoints/ledger.ts b/frontend/src/services/api/endpoints/ledger.ts new file mode 100644 index 0000000..522a77c --- /dev/null +++ b/frontend/src/services/api/endpoints/ledger.ts @@ -0,0 +1,45 @@ +import { apiClient } from "../client"; + +export type MilestoneEvent = + | "SHIPMENT_CREATED" + | "PICKUP_CONFIRMED" + | "IN_TRANSIT" + | "CUSTOMS_CLEARED" + | "OUT_FOR_DELIVERY" + | "DELIVERED" + | "CANCELLED" + | "SETTLEMENT_INITIATED" + | "SETTLEMENT_COMPLETED" + | "PROOF_SUBMITTED"; + +export interface LedgerBlock { + blockNumber: number; + timestamp: string; + shipmentId: string; + shipmentReference: string; + milestoneEvent: MilestoneEvent; + transactionHash: string; + ledger: number; + verified: boolean; +} + +export interface PaginatedLedgerBlocks { + data: LedgerBlock[]; + nextCursor: string | null; + hasMore: boolean; + total?: number; +} + +export interface GetLedgerBlocksParams { + cursor?: string; + limit?: number; + shipmentId?: string; + milestoneEvent?: MilestoneEvent; +} + +export const ledgerApi = { + getBlocks: async (params?: GetLedgerBlocksParams): Promise => { + const res = await apiClient.get("/ledger/blocks", { params }); + return res.data; + }, +}; diff --git a/frontend/src/services/api/index.ts b/frontend/src/services/api/index.ts index 3c7dc34..de11bd2 100644 --- a/frontend/src/services/api/index.ts +++ b/frontend/src/services/api/index.ts @@ -3,3 +3,4 @@ export * from "./endpoints/auth"; export * from "./endpoints/shipments"; export * from "./endpoints/analytics"; export * from "./endpoints/anomalies"; +export * from "./endpoints/ledger"; From 2bfda1b068029446cb7a9bc9e14653c6cbda66ca Mon Sep 17 00:00:00 2001 From: ayo-ola0710 Date: Sat, 30 May 2026 14:53:48 +0100 Subject: [PATCH 2/2] fixed cli build --- .../src/pages/BlockchainLedger/BlockchainLedger.test.tsx | 4 ++-- frontend/src/pages/BlockchainLedger/BlockchainLedger.tsx | 5 +---- frontend/src/services/api/endpoints/ledger.test.ts | 2 +- 3 files changed, 4 insertions(+), 7 deletions(-) diff --git a/frontend/src/pages/BlockchainLedger/BlockchainLedger.test.tsx b/frontend/src/pages/BlockchainLedger/BlockchainLedger.test.tsx index dc6e1f9..a4293ff 100644 --- a/frontend/src/pages/BlockchainLedger/BlockchainLedger.test.tsx +++ b/frontend/src/pages/BlockchainLedger/BlockchainLedger.test.tsx @@ -1,4 +1,4 @@ -import React from 'react'; + import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, waitFor, fireEvent } from '@testing-library/react'; import BlockchainLedger from './BlockchainLedger'; @@ -189,7 +189,7 @@ describe('BlockchainLedger page', () => { renderPage(); await waitFor(() => { - expect(screen.getByTestId ? screen.queryByText('No blocks found') : screen.getByText('No blocks found')).toBeInTheDocument(); + expect(screen.getByText('No blocks found')).toBeInTheDocument(); }); }); diff --git a/frontend/src/pages/BlockchainLedger/BlockchainLedger.tsx b/frontend/src/pages/BlockchainLedger/BlockchainLedger.tsx index faf5fc3..99f0a77 100644 --- a/frontend/src/pages/BlockchainLedger/BlockchainLedger.tsx +++ b/frontend/src/pages/BlockchainLedger/BlockchainLedger.tsx @@ -372,10 +372,7 @@ function CursorPager({ hasPrev, hasNext, onPrev, onNext, pageLabel, loading }: P // ─── Main Page ──────────────────────────────────────────────────────────────── -interface PageState { - cursor: string | null; - page: number; -} + const BlockchainLedger: React.FC = () => { const [blocks, setBlocks] = useState([]); diff --git a/frontend/src/services/api/endpoints/ledger.test.ts b/frontend/src/services/api/endpoints/ledger.test.ts index 63b3537..06bfe57 100644 --- a/frontend/src/services/api/endpoints/ledger.test.ts +++ b/frontend/src/services/api/endpoints/ledger.test.ts @@ -12,7 +12,7 @@ vi.mock('../client', () => ({ import { apiClient } from '../client'; -const mockApiClient = apiClient as { get: ReturnType }; +const mockApiClient = apiClient as unknown as { get: ReturnType }; // ─── Fixtures ─────────────────────────────────────────────────────────────────