From 4815794830306752ac7cc97e08aa9484224bdc87 Mon Sep 17 00:00:00 2001 From: Deb-Auth Date: Sat, 29 Aug 2026 04:31:00 -0700 Subject: [PATCH] Test: Implement Component: Transaction History (Table View) Export Flow --- __tests__/lib/csvExport.test.ts | 140 +++++++++++ .../transactions/TransactionHistoryTable.tsx | 185 +++++++++++++++ .../TransactionHistoryTable.test.tsx | 218 ++++++++++++++++++ hooks/__tests__/useTransactionExport.test.ts | 206 +++++++++++++++++ hooks/useTransactionExport.ts | 77 +++++++ lib/csvExport.ts | 57 +++++ .../transactionHistoryService.test.ts | 61 +++++ services/transactionHistoryService.ts | 40 ++++ 8 files changed, 984 insertions(+) create mode 100644 __tests__/lib/csvExport.test.ts create mode 100644 components/transactions/TransactionHistoryTable.tsx create mode 100644 components/transactions/__tests__/TransactionHistoryTable.test.tsx create mode 100644 hooks/__tests__/useTransactionExport.test.ts create mode 100644 hooks/useTransactionExport.ts create mode 100644 lib/csvExport.ts create mode 100644 services/__tests__/transactionHistoryService.test.ts create mode 100644 services/transactionHistoryService.ts diff --git a/__tests__/lib/csvExport.test.ts b/__tests__/lib/csvExport.test.ts new file mode 100644 index 0000000..2e478f0 --- /dev/null +++ b/__tests__/lib/csvExport.test.ts @@ -0,0 +1,140 @@ +import { escapeCsvValue, toCsv, downloadCsv, type CsvColumn } from '@/lib/csvExport'; + +interface Row { + name: string; + amount: number | null; + note?: string; +} + +const COLUMNS: CsvColumn[] = [ + { header: 'Name', value: (row) => row.name }, + { header: 'Amount', value: (row) => row.amount }, + { header: 'Note', value: (row) => row.note }, +]; + +describe('escapeCsvValue', () => { + it('returns simple values unchanged', () => { + expect(escapeCsvValue('TRK001')).toBe('TRK001'); + expect(escapeCsvValue(42)).toBe('42'); + expect(escapeCsvValue(0)).toBe('0'); + }); + + it('renders nullish values as empty cells', () => { + expect(escapeCsvValue(null)).toBe(''); + expect(escapeCsvValue(undefined)).toBe(''); + }); + + it('quotes values containing a comma', () => { + expect(escapeCsvValue('Lagos, Nigeria')).toBe('"Lagos, Nigeria"'); + }); + + it('quotes and doubles embedded double quotes', () => { + expect(escapeCsvValue('He said "hi"')).toBe('"He said ""hi"""'); + }); + + it('quotes values containing newlines', () => { + expect(escapeCsvValue('line one\nline two')).toBe('"line one\nline two"'); + expect(escapeCsvValue('line one\r\nline two')).toBe('"line one\r\nline two"'); + }); +}); + +describe('toCsv', () => { + it('writes a header row followed by one line per record', () => { + const csv = toCsv( + [ + { name: 'Ada', amount: 10 }, + { name: 'Obi', amount: 20, note: 'urgent' }, + ], + COLUMNS, + ); + + expect(csv.split('\r\n')).toEqual(['Name,Amount,Note', 'Ada,10,', 'Obi,20,urgent']); + }); + + it('still writes the header row for an empty dataset', () => { + expect(toCsv([], COLUMNS)).toBe('Name,Amount,Note'); + }); + + it('escapes cells that would otherwise break the column layout', () => { + const csv = toCsv([{ name: 'Doe, Jane', amount: null, note: 'says "ok"' }], COLUMNS); + + expect(csv).toBe('Name,Amount,Note\r\n"Doe, Jane",,"says ""ok"""'); + }); + + it('escapes header text too', () => { + const csv = toCsv([], [{ header: 'Name, full', value: (row) => row.name }]); + + expect(csv).toBe('"Name, full"'); + }); +}); + +describe('downloadCsv', () => { + const createObjectURL = jest.fn(() => 'blob:mock-url'); + const revokeObjectURL = jest.fn(); + let clickSpy: jest.SpyInstance; + + beforeEach(() => { + jest.clearAllMocks(); + (URL as unknown as { createObjectURL: unknown }).createObjectURL = createObjectURL; + (URL as unknown as { revokeObjectURL: unknown }).revokeObjectURL = revokeObjectURL; + clickSpy = jest.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}); + }); + + afterEach(() => { + clickSpy.mockRestore(); + }); + + it('creates a CSV blob, clicks a download link and revokes the URL', () => { + downloadCsv('Name\r\nAda', 'report.csv'); + + expect(createObjectURL).toHaveBeenCalledTimes(1); + const blob = createObjectURL.mock.calls[0][0] as unknown as Blob; + expect(blob.type).toBe('text/csv;charset=utf-8;'); + + expect(clickSpy).toHaveBeenCalledTimes(1); + const link = clickSpy.mock.instances[0] as HTMLAnchorElement; + expect(link.download).toBe('report.csv'); + expect(link.getAttribute('href')).toBe('blob:mock-url'); + + expect(revokeObjectURL).toHaveBeenCalledWith('blob:mock-url'); + }); + + it('prefixes the payload with a UTF-8 BOM', () => { + downloadCsv('Name\r\nAda', 'report.csv'); + + const blob = createObjectURL.mock.calls[0][0] as unknown as Blob; + // 'Name\r\nAda' is 9 ASCII bytes; the 3-byte BOM brings the blob to 12. + expect(blob.size).toBe(12); + }); + + it('writes the content through unchanged once the BOM is decoded away', async () => { + downloadCsv('Name\r\nAda', 'report.csv'); + + const blob = createObjectURL.mock.calls[0][0] as unknown as Blob; + const text = await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(String(reader.result)); + reader.onerror = () => reject(reader.error); + reader.readAsText(blob); + }); + + // FileReader strips the BOM when decoding as UTF-8, as spreadsheet apps do. + expect(text).toBe('Name\r\nAda'); + }); + + it('leaves no anchor behind in the document', () => { + downloadCsv('Name', 'report.csv'); + + expect(document.querySelectorAll('a[download]')).toHaveLength(0); + }); + + it('cleans up even when the click throws', () => { + clickSpy.mockImplementation(() => { + throw new Error('Download blocked'); + }); + + expect(() => downloadCsv('Name', 'report.csv')).toThrow('Download blocked'); + expect(revokeObjectURL).toHaveBeenCalledWith('blob:mock-url'); + expect(document.querySelectorAll('a[download]')).toHaveLength(0); + }); +}); diff --git a/components/transactions/TransactionHistoryTable.tsx b/components/transactions/TransactionHistoryTable.tsx new file mode 100644 index 0000000..5754125 --- /dev/null +++ b/components/transactions/TransactionHistoryTable.tsx @@ -0,0 +1,185 @@ +'use client'; + +import { Download, Receipt } from 'lucide-react'; +import { useTransactionExport } from '@/hooks/useTransactionExport'; +import type { TransactionRecord } from '@/services/transactionHistoryService'; +import type { TransactionStatus } from '@/types/transaction'; + +interface TransactionHistoryTableProps { + transactions: TransactionRecord[]; + isLoading?: boolean; + error?: string | null; +} + +const STATUS_COLORS: Record = { + PENDING: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200', + SUCCESS: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200', + CONFIRMED: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200', + FAILED: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200', +}; + +function formatDate(value: string): string { + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) return '--'; + return parsed.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); +} + +function truncateHash(hash: string): string { + return hash.length > 16 ? `${hash.slice(0, 8)}...${hash.slice(-6)}` : hash; +} + +/** + * TransactionHistoryTable — the settlement history table with a CSV export. + * + * "Export to CSV" serialises exactly the rows this table is rendering, so the + * downloaded file always matches what the user can see. + */ +export function TransactionHistoryTable({ + transactions, + isLoading = false, + error = null, +}: TransactionHistoryTableProps) { + const { + isExporting, + error: exportError, + didExport, + exportToCsv, + clearError, + } = useTransactionExport(); + + if (isLoading) { + return ( +
+ Loading transactions... +
+ ); + } + + if (error) { + return ( +
+ {error} +
+ ); + } + + const hasRows = transactions.length > 0; + + return ( +
+
+
+

+ Transaction History +

+

+ {transactions.length} transaction{transactions.length === 1 ? '' : 's'} +

+
+ + +
+ + {exportError && ( +
+ {exportError} + +
+ )} + + {didExport && !exportError && ( +

+ Your transactions have been exported. +

+ )} + + {hasRows ? ( +
+ + + + + + + + + + + + {transactions.map((transaction) => ( + + + + + + + + ))} + +
+ Date + + Hash + + Type + + Amount + + Status +
+ {formatDate(transaction.date)} + + {truncateHash(transaction.hash)} + {transaction.type} + {transaction.amount} {transaction.currency} + + + {transaction.status} + +
+
+ ) : ( +
+
+ )} +
+ ); +} diff --git a/components/transactions/__tests__/TransactionHistoryTable.test.tsx b/components/transactions/__tests__/TransactionHistoryTable.test.tsx new file mode 100644 index 0000000..29c5c80 --- /dev/null +++ b/components/transactions/__tests__/TransactionHistoryTable.test.tsx @@ -0,0 +1,218 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { TransactionHistoryTable } from '@/components/transactions/TransactionHistoryTable'; +import { downloadCsv } from '@/lib/csvExport'; +import type { TransactionRecord } from '@/services/transactionHistoryService'; + +jest.mock('@/lib/csvExport', () => { + const actual = jest.requireActual('@/lib/csvExport'); + return { + ...actual, + downloadCsv: jest.fn(), + }; +}); + +const mockDownloadCsv = downloadCsv as jest.Mock; + +const TRANSACTIONS: TransactionRecord[] = [ + { + id: 'tx-1', + hash: 'abc123def4567890abcdef', + date: '2026-02-01T10:00:00.000Z', + type: 'ESCROW_LOCK', + amount: 250, + currency: 'XLM', + status: 'SUCCESS', + counterparty: 'Ada Obi', + deliveryId: 'del-1', + }, + { + id: 'tx-2', + hash: 'short', + date: '2026-02-02T11:30:00.000Z', + type: 'REFUND', + amount: 40, + currency: 'XLM', + status: 'FAILED', + }, +]; + +/** Data rows only — the header row lives in its own . */ +function getBodyRows(): HTMLElement[] { + const [, ...bodyRows] = screen.getAllByRole('row'); + return bodyRows; +} + +describe('TransactionHistoryTable', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('rendering', () => { + it('renders one row per transaction', () => { + render(); + + expect(screen.getByRole('table', { name: 'Transactions' })).toBeInTheDocument(); + expect(getBodyRows()).toHaveLength(2); + expect(screen.getByText('ESCROW_LOCK')).toBeInTheDocument(); + expect(screen.getByText('250 XLM')).toBeInTheDocument(); + expect(screen.getByText('SUCCESS')).toBeInTheDocument(); + }); + + it('truncates long hashes but leaves short ones intact', () => { + render(); + + expect(screen.getByText('abc123de...abcdef')).toBeInTheDocument(); + expect(screen.getByText('short')).toBeInTheDocument(); + }); + + it('shows the row count and pluralises it', () => { + const { rerender } = render(); + expect(screen.getByTestId('transaction-count')).toHaveTextContent('2 transactions'); + + rerender(); + expect(screen.getByTestId('transaction-count')).toHaveTextContent('1 transaction'); + }); + + it('renders a placeholder for an unparseable date', () => { + render( + , + ); + + expect(screen.getByText('--')).toBeInTheDocument(); + }); + }); + + describe('export flow', () => { + it('downloads the current table data as CSV', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: /Export to CSV/ })); + + expect(mockDownloadCsv).toHaveBeenCalledTimes(1); + const [content, filename] = mockDownloadCsv.mock.calls[0]; + expect(filename).toMatch(/^swiftchain-transactions-\d{4}-\d{2}-\d{2}\.csv$/); + expect(content.split('\r\n')).toHaveLength(3); + expect(content).toContain('abc123def4567890abcdef'); + expect(content).toContain('short'); + }); + + it('exports the untruncated hash rather than the rendered one', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: /Export to CSV/ })); + + expect(mockDownloadCsv.mock.calls[0][0]).not.toContain('abc123de...abcdef'); + }); + + it('exports exactly the rows the table is currently showing', async () => { + const user = userEvent.setup(); + const { rerender } = render(); + + rerender(); + await user.click(screen.getByRole('button', { name: /Export to CSV/ })); + + const content = mockDownloadCsv.mock.calls[0][0] as string; + expect(content.split('\r\n')).toHaveLength(2); + expect(content).not.toContain('ESCROW_LOCK'); + }); + + it('confirms the export to the user', async () => { + const user = userEvent.setup(); + render(); + + expect(screen.queryByText('Your transactions have been exported.')).not.toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: /Export to CSV/ })); + + expect(screen.getByText('Your transactions have been exported.')).toBeInTheDocument(); + }); + + it('can be exported repeatedly', async () => { + const user = userEvent.setup(); + render(); + + const button = screen.getByRole('button', { name: /Export to CSV/ }); + await user.click(button); + await user.click(button); + + expect(mockDownloadCsv).toHaveBeenCalledTimes(2); + }); + + it('surfaces a download failure and lets the user dismiss it', async () => { + const user = userEvent.setup(); + mockDownloadCsv.mockImplementationOnce(() => { + throw new Error('Download blocked by the browser'); + }); + render(); + + await user.click(screen.getByRole('button', { name: /Export to CSV/ })); + + expect(screen.getByRole('alert')).toHaveTextContent('Download blocked by the browser'); + expect(screen.queryByText('Your transactions have been exported.')).not.toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Dismiss' })); + + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); + + it('recovers on a retry after a failed export', async () => { + const user = userEvent.setup(); + mockDownloadCsv.mockImplementationOnce(() => { + throw new Error('Download blocked by the browser'); + }); + render(); + + const button = screen.getByRole('button', { name: /Export to CSV/ }); + await user.click(button); + await user.click(button); + + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + expect(screen.getByText('Your transactions have been exported.')).toBeInTheDocument(); + }); + }); + + describe('edge cases', () => { + it('renders the empty state and disables the export button', () => { + render(); + + expect(screen.getByText('No transactions yet')).toBeInTheDocument(); + expect(screen.queryByRole('table')).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Export to CSV/ })).toBeDisabled(); + }); + + it('does not trigger a download from the disabled empty-state button', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: /Export to CSV/ })); + + expect(mockDownloadCsv).not.toHaveBeenCalled(); + }); + + it('renders the loading state instead of the table', () => { + render(); + + expect(screen.getByRole('status')).toHaveTextContent('Loading transactions...'); + expect(screen.queryByRole('button', { name: /Export to CSV/ })).not.toBeInTheDocument(); + }); + + it('renders the error state instead of the table', () => { + render(); + + expect(screen.getByRole('alert')).toHaveTextContent('Unable to load transactions'); + expect(screen.queryByRole('table')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /Export to CSV/ })).not.toBeInTheDocument(); + }); + + it('prefers the loading state over the error state', () => { + render(); + + expect(screen.getByRole('status')).toBeInTheDocument(); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/hooks/__tests__/useTransactionExport.test.ts b/hooks/__tests__/useTransactionExport.test.ts new file mode 100644 index 0000000..8547e0f --- /dev/null +++ b/hooks/__tests__/useTransactionExport.test.ts @@ -0,0 +1,206 @@ +import { act, renderHook } from '@testing-library/react'; +import { + useTransactionExport, + buildTransactionCsvFilename, + TRANSACTION_CSV_COLUMNS, +} from '@/hooks/useTransactionExport'; +import { downloadCsv, toCsv } from '@/lib/csvExport'; +import type { TransactionRecord } from '@/services/transactionHistoryService'; + +jest.mock('@/lib/csvExport', () => { + const actual = jest.requireActual('@/lib/csvExport'); + return { + ...actual, + downloadCsv: jest.fn(), + }; +}); + +const mockDownloadCsv = downloadCsv as jest.Mock; + +const TRANSACTIONS: TransactionRecord[] = [ + { + id: 'tx-1', + hash: 'abc123def456789', + date: '2026-02-01T10:00:00.000Z', + type: 'ESCROW_LOCK', + amount: 250, + currency: 'XLM', + status: 'SUCCESS', + counterparty: 'Ada Obi', + deliveryId: 'del-1', + }, + { + id: 'tx-2', + hash: 'fed987cba654321', + date: '2026-02-02T11:30:00.000Z', + type: 'ESCROW_RELEASE', + amount: 250, + currency: 'XLM', + status: 'PENDING', + }, +]; + +describe('buildTransactionCsvFilename', () => { + it('stamps the filename with the export date', () => { + expect(buildTransactionCsvFilename(new Date('2026-02-01T10:00:00.000Z'))).toBe( + 'swiftchain-transactions-2026-02-01.csv', + ); + }); +}); + +describe('TRANSACTION_CSV_COLUMNS', () => { + it('mirrors the table columns plus the fields only present in the export', () => { + expect(TRANSACTION_CSV_COLUMNS.map((column) => column.header)).toEqual([ + 'Date', + 'Transaction Hash', + 'Type', + 'Amount', + 'Currency', + 'Status', + 'Counterparty', + 'Delivery ID', + ]); + }); +}); + +describe('useTransactionExport', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers().setSystemTime(new Date('2026-02-03T08:00:00.000Z')); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('starts idle', () => { + const { result } = renderHook(() => useTransactionExport()); + + expect(result.current.isExporting).toBe(false); + expect(result.current.error).toBeNull(); + expect(result.current.didExport).toBe(false); + }); + + it('serialises the supplied rows and hands them to the downloader', () => { + const { result } = renderHook(() => useTransactionExport()); + + act(() => result.current.exportToCsv(TRANSACTIONS)); + + expect(mockDownloadCsv).toHaveBeenCalledTimes(1); + const [content, filename] = mockDownloadCsv.mock.calls[0]; + expect(filename).toBe('swiftchain-transactions-2026-02-03.csv'); + expect(content).toBe(toCsv(TRANSACTIONS, TRANSACTION_CSV_COLUMNS)); + }); + + it('writes a header row and one line per transaction', () => { + const { result } = renderHook(() => useTransactionExport()); + + act(() => result.current.exportToCsv(TRANSACTIONS)); + + const lines = (mockDownloadCsv.mock.calls[0][0] as string).split('\r\n'); + expect(lines).toHaveLength(3); + expect(lines[0]).toBe( + 'Date,Transaction Hash,Type,Amount,Currency,Status,Counterparty,Delivery ID', + ); + expect(lines[1]).toBe( + '2026-02-01T10:00:00.000Z,abc123def456789,ESCROW_LOCK,250,XLM,SUCCESS,Ada Obi,del-1', + ); + }); + + it('leaves optional fields as empty cells', () => { + const { result } = renderHook(() => useTransactionExport()); + + act(() => result.current.exportToCsv(TRANSACTIONS)); + + const lines = (mockDownloadCsv.mock.calls[0][0] as string).split('\r\n'); + expect(lines[2]).toBe( + '2026-02-02T11:30:00.000Z,fed987cba654321,ESCROW_RELEASE,250,XLM,PENDING,,', + ); + }); + + it('escapes a counterparty containing a comma', () => { + const { result } = renderHook(() => useTransactionExport()); + + act(() => + result.current.exportToCsv([{ ...TRANSACTIONS[0], counterparty: 'Obi, Ada' }]), + ); + + expect(mockDownloadCsv.mock.calls[0][0]).toContain('"Obi, Ada"'); + }); + + it('flags a successful export', () => { + const { result } = renderHook(() => useTransactionExport()); + + act(() => result.current.exportToCsv(TRANSACTIONS)); + + expect(result.current.didExport).toBe(true); + expect(result.current.error).toBeNull(); + expect(result.current.isExporting).toBe(false); + }); + + it('refuses to export an empty table', () => { + const { result } = renderHook(() => useTransactionExport()); + + act(() => result.current.exportToCsv([])); + + expect(mockDownloadCsv).not.toHaveBeenCalled(); + expect(result.current.error).toBe('There is nothing to export.'); + expect(result.current.didExport).toBe(false); + }); + + it('surfaces a downloader failure', () => { + mockDownloadCsv.mockImplementationOnce(() => { + throw new Error('Download blocked by the browser'); + }); + const { result } = renderHook(() => useTransactionExport()); + + act(() => result.current.exportToCsv(TRANSACTIONS)); + + expect(result.current.error).toBe('Download blocked by the browser'); + expect(result.current.didExport).toBe(false); + expect(result.current.isExporting).toBe(false); + }); + + it('falls back to a generic message for a non-Error failure', () => { + mockDownloadCsv.mockImplementationOnce(() => { + throw 'boom'; + }); + const { result } = renderHook(() => useTransactionExport()); + + act(() => result.current.exportToCsv(TRANSACTIONS)); + + expect(result.current.error).toBe('Failed to generate the CSV file'); + }); + + it('clears a previous error on the next attempt', () => { + const { result } = renderHook(() => useTransactionExport()); + + act(() => result.current.exportToCsv([])); + expect(result.current.error).not.toBeNull(); + + act(() => result.current.exportToCsv(TRANSACTIONS)); + + expect(result.current.error).toBeNull(); + expect(result.current.didExport).toBe(true); + }); + + it('clears the error on demand', () => { + const { result } = renderHook(() => useTransactionExport()); + + act(() => result.current.exportToCsv([])); + act(() => result.current.clearError()); + + expect(result.current.error).toBeNull(); + }); + + it('exports only the rows it is given, not a wider dataset', () => { + const { result } = renderHook(() => useTransactionExport()); + + act(() => result.current.exportToCsv([TRANSACTIONS[1]])); + + const content = mockDownloadCsv.mock.calls[0][0] as string; + expect(content.split('\r\n')).toHaveLength(2); + expect(content).toContain('fed987cba654321'); + expect(content).not.toContain('abc123def456789'); + }); +}); diff --git a/hooks/useTransactionExport.ts b/hooks/useTransactionExport.ts new file mode 100644 index 0000000..bfb1745 --- /dev/null +++ b/hooks/useTransactionExport.ts @@ -0,0 +1,77 @@ +'use client'; + +import { useCallback, useState } from 'react'; +import { toCsv, downloadCsv, type CsvColumn } from '@/lib/csvExport'; +import type { TransactionRecord } from '@/services/transactionHistoryService'; + +/** Column layout of the exported file — mirrors the on-screen table. */ +export const TRANSACTION_CSV_COLUMNS: CsvColumn[] = [ + { header: 'Date', value: (row) => row.date }, + { header: 'Transaction Hash', value: (row) => row.hash }, + { header: 'Type', value: (row) => row.type }, + { header: 'Amount', value: (row) => row.amount }, + { header: 'Currency', value: (row) => row.currency }, + { header: 'Status', value: (row) => row.status }, + { header: 'Counterparty', value: (row) => row.counterparty }, + { header: 'Delivery ID', value: (row) => row.deliveryId }, +]; + +export interface UseTransactionExportResult { + /** True while the file is being generated. */ + isExporting: boolean; + /** Message shown when the export could not be produced. */ + error: string | null; + /** True after a successful export, until the next attempt. */ + didExport: boolean; + /** Serialises the supplied rows and hands the file to the browser. */ + exportToCsv: (rows: TransactionRecord[]) => void; + clearError: () => void; +} + +/** + * Builds a dated filename, e.g. `swiftchain-transactions-2026-02-01.csv`. + */ +export function buildTransactionCsvFilename(now: Date = new Date()): string { + const stamp = now.toISOString().split('T')[0]; + return `swiftchain-transactions-${stamp}.csv`; +} + +/** + * useTransactionExport — turns the rows currently held by the table into a CSV + * download. + * + * The rows are passed in rather than refetched, so the file always matches what + * the user is looking at, filters and sorting included. + */ +export function useTransactionExport(): UseTransactionExportResult { + const [isExporting, setIsExporting] = useState(false); + const [error, setError] = useState(null); + const [didExport, setDidExport] = useState(false); + + const exportToCsv = useCallback((rows: TransactionRecord[]) => { + setDidExport(false); + setError(null); + + if (rows.length === 0) { + setError('There is nothing to export.'); + return; + } + + setIsExporting(true); + try { + const csv = toCsv(rows, TRANSACTION_CSV_COLUMNS); + downloadCsv(csv, buildTransactionCsvFilename()); + setDidExport(true); + } catch (exportError) { + setError( + exportError instanceof Error ? exportError.message : 'Failed to generate the CSV file', + ); + } finally { + setIsExporting(false); + } + }, []); + + const clearError = useCallback(() => setError(null), []); + + return { isExporting, error, didExport, exportToCsv, clearError }; +} diff --git a/lib/csvExport.ts b/lib/csvExport.ts new file mode 100644 index 0000000..0e3a003 --- /dev/null +++ b/lib/csvExport.ts @@ -0,0 +1,57 @@ +export interface CsvColumn { + /** Header text written as the first row of the file. */ + header: string; + /** Extracts the cell value for a row. Nullish values become empty cells. */ + value: (row: T) => string | number | null | undefined; +} + +/** + * Escapes a single CSV cell per RFC 4180: values containing a comma, a quote or + * a newline are wrapped in double quotes, and embedded quotes are doubled. + */ +export function escapeCsvValue(value: string | number | null | undefined): string { + if (value === null || value === undefined) return ''; + const asString = String(value); + if (/[",\r\n]/.test(asString)) { + return `"${asString.replace(/"/g, '""')}"`; + } + return asString; +} + +/** + * Serialises rows into a CSV document. The header row is always written, so an + * empty export still produces a well-formed file. + */ +export function toCsv(rows: T[], columns: CsvColumn[]): string { + const headerLine = columns.map((column) => escapeCsvValue(column.header)).join(','); + const bodyLines = rows.map((row) => + columns.map((column) => escapeCsvValue(column.value(row))).join(','), + ); + return [headerLine, ...bodyLines].join('\r\n'); +} + +/** Byte-order mark — keeps spreadsheet apps from mangling non-ASCII characters. */ +const UTF8_BOM = '\uFEFF'; + +/** + * Triggers a browser download of `content` as `filename`. + * The object URL is revoked once the click has been dispatched so repeated + * exports do not leak blobs. + */ +export function downloadCsv(content: string, filename: string): void { + const blob = new Blob([UTF8_BOM + content], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + + link.href = url; + link.download = filename; + link.style.display = 'none'; + document.body.appendChild(link); + + try { + link.click(); + } finally { + document.body.removeChild(link); + URL.revokeObjectURL(url); + } +} diff --git a/services/__tests__/transactionHistoryService.test.ts b/services/__tests__/transactionHistoryService.test.ts new file mode 100644 index 0000000..470af47 --- /dev/null +++ b/services/__tests__/transactionHistoryService.test.ts @@ -0,0 +1,61 @@ +import api from '@/lib/api'; +import { transactionHistoryService } from '@/services/transactionHistoryService'; + +jest.mock('@/lib/api', () => ({ + __esModule: true, + default: { get: jest.fn() }, +})); + +const mockGet = api.get as jest.Mock; + +describe('transactionHistoryService.getTransactions', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('reads the transaction history endpoint', async () => { + mockGet.mockResolvedValue({ data: { success: true, data: [] } }); + + await transactionHistoryService.getTransactions(); + + expect(mockGet).toHaveBeenCalledWith('/transactions'); + }); + + it('returns the payload on success', async () => { + const payload = { + success: true, + data: [ + { + id: 'tx-1', + hash: 'abc123', + date: '2026-02-01T10:00:00.000Z', + type: 'ESCROW_LOCK', + amount: 250, + currency: 'XLM', + status: 'SUCCESS', + }, + ], + }; + mockGet.mockResolvedValue({ data: payload }); + + await expect(transactionHistoryService.getTransactions()).resolves.toEqual(payload); + }); + + it('converts a transport failure into an unsuccessful response', async () => { + mockGet.mockRejectedValue(new Error('Network Error')); + + await expect(transactionHistoryService.getTransactions()).resolves.toEqual({ + success: false, + message: 'Network Error', + }); + }); + + it('falls back to a generic message for a non-Error rejection', async () => { + mockGet.mockRejectedValue('boom'); + + await expect(transactionHistoryService.getTransactions()).resolves.toEqual({ + success: false, + message: 'Failed to load transaction history', + }); + }); +}); diff --git a/services/transactionHistoryService.ts b/services/transactionHistoryService.ts new file mode 100644 index 0000000..81013ad --- /dev/null +++ b/services/transactionHistoryService.ts @@ -0,0 +1,40 @@ +import api from '@/lib/api'; +import type { TransactionStatus } from '@/types/transaction'; + +export interface TransactionRecord { + id: string; + /** Stellar transaction hash. */ + hash: string; + /** ISO timestamp of when the transaction was submitted. */ + date: string; + type: 'ESCROW_LOCK' | 'ESCROW_RELEASE' | 'REFUND' | 'PAYOUT'; + amount: number; + currency: string; + status: TransactionStatus; + counterparty?: string; + deliveryId?: string; +} + +export interface TransactionHistoryResponse { + success: boolean; + message?: string; + data?: TransactionRecord[]; +} + +/** + * transactionHistoryService — reads the authenticated user's on-chain + * transaction history for the settings and wallet history views. + */ +export const transactionHistoryService = { + async getTransactions(): Promise { + try { + const { data } = await api.get('/transactions'); + return data; + } catch (error) { + return { + success: false, + message: error instanceof Error ? error.message : 'Failed to load transaction history', + }; + } + }, +};