From 20eae4346de275b458ba9118aea517d755cfc8f7 Mon Sep 17 00:00:00 2001 From: AdaBliss Date: Sat, 29 Aug 2026 03:29:07 -0700 Subject: [PATCH] Test: Implement Component: Transaction History (Grid View) Data Formatting Add the cross-border transaction history grid view and tests for how it formats data. No transaction history component existed in the codebase, so this adds the presentational component under test alongside the suite. Every value on a card goes through shared formatters, so a card and a list row can never disagree about how an amount or an escrow status reads. Amounts are shown with their asset or ISO currency code rather than a locale symbol: corridor currencies share symbols (several use the dollar sign) and Intl symbol output varies with the host ICU build. - lib/transactionFormatters: grouped amounts with stellar 7dp precision, direction signing, escrow status labels and badge styles, corridor and date formatting, with em-dash placeholders for missing or non-finite values so a bad feed cannot print NaN - 24 grid tests covering amount and escrow formatting, card content, the details action and the empty state - 25 unit tests pinning the formatters directly --- .../components/TransactionHistoryGrid.tsx | 125 ++++++++ .../__tests__/TransactionHistoryGrid.test.tsx | 300 ++++++++++++++++++ lib/__tests__/transactionFormatters.test.ts | 152 +++++++++ lib/transactionFormatters.ts | 102 ++++++ types/transactionHistory.ts | 34 ++ 5 files changed, 713 insertions(+) create mode 100644 features/transactions/components/TransactionHistoryGrid.tsx create mode 100644 features/transactions/components/__tests__/TransactionHistoryGrid.test.tsx create mode 100644 lib/__tests__/transactionFormatters.test.ts create mode 100644 lib/transactionFormatters.ts create mode 100644 types/transactionHistory.ts diff --git a/features/transactions/components/TransactionHistoryGrid.tsx b/features/transactions/components/TransactionHistoryGrid.tsx new file mode 100644 index 0000000..feef7a3 --- /dev/null +++ b/features/transactions/components/TransactionHistoryGrid.tsx @@ -0,0 +1,125 @@ +'use client'; + +import { Inbox } from 'lucide-react'; +import { EmptyState } from '@/components/ui/EmptyState'; +import { + escrowStatusStyle, + formatCorridor, + formatEscrowStatus, + formatFiatAmount, + formatSignedAssetAmount, + formatTransactionDate, +} from '@/lib/transactionFormatters'; +import type { CrossBorderTransaction } from '@/types/transactionHistory'; + +interface TransactionHistoryGridProps { + transactions: CrossBorderTransaction[]; + onSelect?: (_transaction: CrossBorderTransaction) => void; +} + +/** + * Card grid view of a user's cross-border transactions. + * + * The same data as the list view, laid out as scannable cards. Every displayed + * value goes through the shared formatters so a card and a row can never + * disagree about how an amount or an escrow status reads. + */ +export function TransactionHistoryGrid({ + transactions, + onSelect, +}: TransactionHistoryGridProps) { + if (transactions.length === 0) { + return ( + + ); + } + + return ( + + ); +} diff --git a/features/transactions/components/__tests__/TransactionHistoryGrid.test.tsx b/features/transactions/components/__tests__/TransactionHistoryGrid.test.tsx new file mode 100644 index 0000000..fab4875 --- /dev/null +++ b/features/transactions/components/__tests__/TransactionHistoryGrid.test.tsx @@ -0,0 +1,300 @@ +/** + * TransactionHistoryGrid component tests. + * + * The focus is data formatting inside the cards: currency amounts must be + * grouped, signed by direction and shown with their asset/ISO code, and escrow + * statuses must render as human-readable badges rather than raw wire values. + */ + +import { render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { TransactionHistoryGrid } from '@/features/transactions/components/TransactionHistoryGrid'; +import type { CrossBorderTransaction } from '@/types/transactionHistory'; +import type { EscrowStatus } from '@/types/status'; + +function transaction( + overrides: Partial = {}, +): CrossBorderTransaction { + return { + id: 'tx-1', + reference: 'SC-20260425-0001', + direction: 'SENT', + counterparty: 'Amara Okafor', + amount: 1250.5, + assetCode: 'XLM', + fiatAmount: 640.25, + fiatCurrency: 'USD', + originCountry: 'NG', + destinationCountry: 'GB', + escrowStatus: 'LOCKED', + createdAt: '2026-04-25T12:00:00.000Z', + ...overrides, + }; +} + +function cards() { + return screen.queryAllByTestId('transaction-card'); +} + +function firstCard() { + return within(cards()[0]); +} + +describe('TransactionHistoryGrid', () => { + describe('currency amount formatting', () => { + it('groups thousands and pads to two decimals', () => { + render( + , + ); + + expect(firstCard().getByTestId('card-amount')).toHaveTextContent( + '-1,250.50 XLM', + ); + }); + + it('marks outgoing transfers with a minus and incoming with a plus', () => { + render( + , + ); + + const amounts = screen + .getAllByTestId('card-amount') + .map((el) => el.textContent); + expect(amounts).toEqual(['-1,250.50 XLM', '+800.00 XLM']); + }); + + it('shows the settlement asset code alongside the amount', () => { + render( + , + ); + + const amounts = screen + .getAllByTestId('card-amount') + .map((el) => el.textContent); + expect(amounts).toEqual(['-500.00 USDC', '-500.00 XLM']); + }); + + it('keeps stellar precision for sub-cent amounts', () => { + render( + , + ); + + expect(firstCard().getByTestId('card-amount')).toHaveTextContent( + '+0.0000001 XLM', + ); + }); + + it('formats a large amount without scientific notation', () => { + render( + , + ); + + expect(firstCard().getByTestId('card-amount')).toHaveTextContent( + '+12,345,678.90 XLM', + ); + }); + + it('renders the local currency leg with its ISO code', () => { + render( + , + ); + + expect(firstCard().getByTestId('card-fiat-amount')).toHaveTextContent( + '1,234,567.80 NGN', + ); + }); + + it('renders a placeholder when no fiat quote is attached', () => { + render( + , + ); + + expect(firstCard().getByTestId('card-fiat-amount')).toHaveTextContent('—'); + }); + + it('never prints NaN when the feed sends a bad amount', () => { + render( + , + ); + + const amount = firstCard().getByTestId('card-amount'); + expect(amount).toHaveTextContent('—'); + expect(amount.textContent).not.toMatch(/NaN/); + }); + + it('renders a zero-value transfer as a real amount', () => { + render( + , + ); + + expect(firstCard().getByTestId('card-amount')).toHaveTextContent( + '-0.00 XLM', + ); + expect(firstCard().getByTestId('card-fiat-amount')).toHaveTextContent( + '0.00 USD', + ); + }); + }); + + describe('escrow status formatting', () => { + it.each<[EscrowStatus, string]>([ + ['LOCKED', 'Locked in escrow'], + ['RELEASED', 'Released'], + ['DISPUTED', 'Disputed'], + ['NOT_LOCKED', 'Not locked'], + ])('renders %s as the readable label %s', (escrowStatus, expected) => { + render( + , + ); + + expect(firstCard().getByTestId('card-escrow-status')).toHaveTextContent( + expected, + ); + }); + + it('never shows the raw underscored wire value', () => { + render( + , + ); + + expect(cards()[0].textContent).not.toContain('NOT_LOCKED'); + }); + + it('gives the badge an accessible label', () => { + render( + , + ); + + expect( + screen.getByLabelText('Escrow status: Disputed'), + ).toBeInTheDocument(); + }); + + it('styles each status distinctly so they are visually separable', () => { + render( + , + ); + + const classNames = screen + .getAllByTestId('card-escrow-status') + .map((el) => el.className); + expect(new Set(classNames).size).toBe(3); + }); + }); + + describe('card content', () => { + it('renders one card per transaction in the given order', () => { + render( + , + ); + + const rendered = cards(); + expect(rendered).toHaveLength(2); + expect(rendered[0]).toHaveTextContent('Amara Okafor'); + expect(rendered[1]).toHaveTextContent('Wei Zhang'); + }); + + it('shows the reference, corridor and date', () => { + render(); + + const card = firstCard(); + expect(card.getByTestId('card-reference')).toHaveTextContent( + 'SC-20260425-0001', + ); + expect(card.getByTestId('card-corridor')).toHaveTextContent('NG → GB'); + expect(card.getByTestId('card-date')).toHaveTextContent('Apr 25, 2026'); + }); + + it('renders a placeholder for an unparseable timestamp', () => { + render( + , + ); + + expect(firstCard().getByTestId('card-date')).toHaveTextContent('—'); + }); + + it('labels the grid for assistive technology', () => { + render(); + + expect( + screen.getByRole('list', { name: 'Cross-border transactions' }), + ).toBeInTheDocument(); + }); + + it('passes the selected transaction to the details handler', async () => { + const user = userEvent.setup(); + const onSelect = jest.fn(); + const tx = transaction({ id: 'tx-9' }); + + render( + , + ); + + await user.click(screen.getByRole('button', { name: 'View details' })); + + expect(onSelect).toHaveBeenCalledTimes(1); + expect(onSelect).toHaveBeenCalledWith(tx); + }); + + it('omits the details action when no handler is supplied', () => { + render(); + + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); + }); + + describe('empty state', () => { + it('renders an empty state instead of a bare grid', () => { + render(); + + expect( + screen.getByText('No cross-border transactions yet'), + ).toBeInTheDocument(); + expect(cards()).toHaveLength(0); + expect(screen.queryByRole('list')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/lib/__tests__/transactionFormatters.test.ts b/lib/__tests__/transactionFormatters.test.ts new file mode 100644 index 0000000..94dd7ac --- /dev/null +++ b/lib/__tests__/transactionFormatters.test.ts @@ -0,0 +1,152 @@ +/** + * Unit tests for the cross-border transaction display formatters. + * + * These are the single source of truth for how amounts and escrow statuses + * read in both the grid and list views, so they are pinned down directly as + * well as through the component tests. + */ + +import { + escrowStatusStyle, + ESCROW_STATUS_STYLES, + formatAssetAmount, + formatCorridor, + formatEscrowStatus, + formatFiatAmount, + formatSignedAssetAmount, + formatTransactionDate, +} from '@/lib/transactionFormatters'; +import type { EscrowStatus } from '@/types/status'; + +describe('formatAssetAmount', () => { + it('groups thousands and pads to two decimals', () => { + expect(formatAssetAmount(1250.5, 'XLM')).toBe('1,250.50 XLM'); + expect(formatAssetAmount(1000000, 'USDC')).toBe('1,000,000.00 USDC'); + }); + + it('keeps stellar precision up to seven decimal places', () => { + expect(formatAssetAmount(0.0000001, 'XLM')).toBe('0.0000001 XLM'); + expect(formatAssetAmount(12.1234567, 'XLM')).toBe('12.1234567 XLM'); + }); + + it('rounds beyond seven decimal places rather than overflowing the cell', () => { + expect(formatAssetAmount(1.123456789, 'XLM')).toBe('1.1234568 XLM'); + }); + + it('formats zero as a real amount, not a placeholder', () => { + expect(formatAssetAmount(0, 'XLM')).toBe('0.00 XLM'); + }); + + it('preserves the sign of a negative amount', () => { + expect(formatAssetAmount(-42.5, 'XLM')).toBe('-42.50 XLM'); + }); + + it('renders a placeholder instead of NaN or Infinity', () => { + expect(formatAssetAmount(Number.NaN, 'XLM')).toBe('—'); + expect(formatAssetAmount(Number.POSITIVE_INFINITY, 'XLM')).toBe('—'); + }); +}); + +describe('formatFiatAmount', () => { + it('renders the amount with its ISO currency code', () => { + expect(formatFiatAmount(640.25, 'USD')).toBe('640.25 USD'); + expect(formatFiatAmount(1234567.8, 'NGN')).toBe('1,234,567.80 NGN'); + }); + + it('always shows exactly two decimals', () => { + expect(formatFiatAmount(10, 'EUR')).toBe('10.00 EUR'); + expect(formatFiatAmount(10.129, 'EUR')).toBe('10.13 EUR'); + }); + + it('returns a placeholder when the corridor has no fiat quote', () => { + expect(formatFiatAmount(undefined, 'USD')).toBe('—'); + expect(formatFiatAmount(640.25, undefined)).toBe('—'); + expect(formatFiatAmount(undefined, undefined)).toBe('—'); + }); + + it('returns a placeholder for a non-finite amount', () => { + expect(formatFiatAmount(Number.NaN, 'USD')).toBe('—'); + }); + + it('treats a zero quote as a real amount', () => { + expect(formatFiatAmount(0, 'USD')).toBe('0.00 USD'); + }); +}); + +describe('formatSignedAssetAmount', () => { + it('prefixes outgoing transfers with a minus sign', () => { + expect(formatSignedAssetAmount(1250.5, 'XLM', 'SENT')).toBe('-1,250.50 XLM'); + }); + + it('prefixes incoming transfers with a plus sign', () => { + expect(formatSignedAssetAmount(800, 'XLM', 'RECEIVED')).toBe('+800.00 XLM'); + }); + + it('signs a zero amount by direction rather than dropping the sign', () => { + expect(formatSignedAssetAmount(0, 'USDC', 'SENT')).toBe('-0.00 USDC'); + expect(formatSignedAssetAmount(0, 'USDC', 'RECEIVED')).toBe('+0.00 USDC'); + }); + + it('does not sign the placeholder for a non-finite amount', () => { + expect(formatSignedAssetAmount(Number.NaN, 'XLM', 'SENT')).toBe('—'); + }); +}); + +describe('formatEscrowStatus', () => { + it.each<[EscrowStatus, string]>([ + ['LOCKED', 'Locked in escrow'], + ['RELEASED', 'Released'], + ['DISPUTED', 'Disputed'], + ['NOT_LOCKED', 'Not locked'], + ])('renders %s as %s', (status, expected) => { + expect(formatEscrowStatus(status)).toBe(expected); + }); + + it('never leaks the raw wire format to the user', () => { + const statuses: EscrowStatus[] = [ + 'LOCKED', + 'RELEASED', + 'DISPUTED', + 'NOT_LOCKED', + ]; + + for (const status of statuses) { + expect(formatEscrowStatus(status)).not.toContain('_'); + } + }); + + it('falls back to Unknown for an unrecognised status', () => { + expect(formatEscrowStatus('SOMETHING_NEW' as EscrowStatus)).toBe('Unknown'); + }); +}); + +describe('escrowStatusStyle', () => { + it('gives every known status a distinct badge style', () => { + const styles = Object.values(ESCROW_STATUS_STYLES); + expect(new Set(styles).size).toBe(styles.length); + }); + + it('returns the neutral style for an unrecognised status', () => { + expect(escrowStatusStyle('SOMETHING_NEW' as EscrowStatus)).toBe( + ESCROW_STATUS_STYLES.NOT_LOCKED, + ); + }); +}); + +describe('formatCorridor', () => { + it('renders origin and destination with a direction arrow', () => { + expect(formatCorridor('NG', 'GB')).toBe('NG → GB'); + }); +}); + +describe('formatTransactionDate', () => { + it('renders a short, unambiguous date', () => { + expect(formatTransactionDate('2026-04-25T12:00:00.000Z')).toBe( + 'Apr 25, 2026', + ); + }); + + it('returns a placeholder for an unparseable timestamp', () => { + expect(formatTransactionDate('not-a-date')).toBe('—'); + }); +}); diff --git a/lib/transactionFormatters.ts b/lib/transactionFormatters.ts new file mode 100644 index 0000000..6bae4c4 --- /dev/null +++ b/lib/transactionFormatters.ts @@ -0,0 +1,102 @@ +/** + * Display formatting for cross-border transaction history. + * + * Amounts are rendered as a locale-grouped number followed by the ISO asset or + * currency code rather than a localised symbol. Corridors span currencies whose + * symbols collide (several use "$"), and `Intl` currency symbols also vary with + * the host ICU build, which would make the UI inconsistent across environments. + */ + +import type { EscrowStatus } from '@/types/status'; +import type { TransactionDirection } from '@/types/transactionHistory'; + +/** Stellar assets carry up to 7 decimal places. */ +const MAX_ASSET_DECIMALS = 7; +const FIAT_DECIMALS = 2; + +const assetFormatter = new Intl.NumberFormat('en-US', { + minimumFractionDigits: FIAT_DECIMALS, + maximumFractionDigits: MAX_ASSET_DECIMALS, +}); + +const fiatFormatter = new Intl.NumberFormat('en-US', { + minimumFractionDigits: FIAT_DECIMALS, + maximumFractionDigits: FIAT_DECIMALS, +}); + +const ESCROW_STATUS_LABELS: Record = { + LOCKED: 'Locked in escrow', + RELEASED: 'Released', + DISPUTED: 'Disputed', + NOT_LOCKED: 'Not locked', +}; + +export const ESCROW_STATUS_STYLES: Record = { + LOCKED: 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200', + RELEASED: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200', + DISPUTED: + 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200', + NOT_LOCKED: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300', +}; + +const UNKNOWN_AMOUNT = '—'; + +/** + * Formats a settlement amount, e.g. `1234.5, "XLM"` becomes `1,234.50 XLM`. + * Non-finite amounts render as an em dash so a bad feed cannot print "NaN". + */ +export function formatAssetAmount(amount: number, assetCode: string): string { + if (!Number.isFinite(amount)) return UNKNOWN_AMOUNT; + return `${assetFormatter.format(amount)} ${assetCode}`; +} + +/** + * Formats the local-currency leg of a transfer, e.g. `1,234.50 NGN`. + * Returns an em dash when the corridor has no fiat quote attached. + */ +export function formatFiatAmount( + amount: number | undefined, + currency: string | undefined, +): string { + if (typeof amount !== 'number' || !Number.isFinite(amount) || !currency) { + return UNKNOWN_AMOUNT; + } + return `${fiatFormatter.format(amount)} ${currency}`; +} + +/** Prefixes an amount with the direction sign a user expects on a ledger. */ +export function formatSignedAssetAmount( + amount: number, + assetCode: string, + direction: TransactionDirection, +): string { + const formatted = formatAssetAmount(amount, assetCode); + if (formatted === UNKNOWN_AMOUNT) return formatted; + return `${direction === 'SENT' ? '-' : '+'}${formatted}`; +} + +/** Turns the wire-format escrow status into sentence-case display text. */ +export function formatEscrowStatus(status: EscrowStatus): string { + return ESCROW_STATUS_LABELS[status] ?? 'Unknown'; +} + +/** Tailwind classes for an escrow status badge, with a neutral fallback. */ +export function escrowStatusStyle(status: EscrowStatus): string { + return ESCROW_STATUS_STYLES[status] ?? ESCROW_STATUS_STYLES.NOT_LOCKED; +} + +/** Renders a corridor as `NG -> GB`, using an arrow the terminal-safe way. */ +export function formatCorridor(origin: string, destination: string): string { + return `${origin} → ${destination}`; +} + +/** Short, unambiguous date for a history row, e.g. `Apr 25, 2026`. */ +export function formatTransactionDate(iso: string): string { + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return UNKNOWN_AMOUNT; + return date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }); +} diff --git a/types/transactionHistory.ts b/types/transactionHistory.ts new file mode 100644 index 0000000..772462a --- /dev/null +++ b/types/transactionHistory.ts @@ -0,0 +1,34 @@ +/** + * Cross-border transaction history types. + * + * Shared by the list and grid presentations of a user's settled and in-flight + * cross-border transfers. + */ + +import type { EscrowStatus } from './status'; + +export type TransactionDirection = 'SENT' | 'RECEIVED'; + +export interface CrossBorderTransaction { + id: string; + /** Human-facing reference shown to the user, e.g. "SC-20260425-0001". */ + reference: string; + direction: TransactionDirection; + /** Display name of the other party in the corridor. */ + counterparty: string; + /** Amount in the settlement asset. */ + amount: number; + /** Settlement asset code, e.g. "XLM" or "USDC". */ + assetCode: string; + /** Quoted amount in the corridor's local fiat currency, when available. */ + fiatAmount?: number; + /** ISO 4217 code for {@link fiatAmount}. */ + fiatCurrency?: string; + /** ISO 3166-1 alpha-2 origin country. */ + originCountry: string; + /** ISO 3166-1 alpha-2 destination country. */ + destinationCountry: string; + escrowStatus: EscrowStatus; + /** ISO 8601 creation timestamp. */ + createdAt: string; +}