From be9c0bbb9963a9c1814dbc53b085c4b7d644e93d Mon Sep 17 00:00:00 2001 From: Adejoke46 <332049580+Adejoke46@users.noreply.github.com> Date: Fri, 23 Oct 2026 06:27:56 -0700 Subject: [PATCH 1/2] fix(catalog): restore baseline type and identifier consistency --- src/api/catalogApi.ts | 16 ++++++++-------- src/data/mockApis.ts | 8 ++++++++ src/pages/ApiDetailPage.tsx | 6 +++--- 3 files changed, 19 insertions(+), 11 deletions(-) diff --git a/src/api/catalogApi.ts b/src/api/catalogApi.ts index 2cebc49a..8e49835f 100644 --- a/src/api/catalogApi.ts +++ b/src/api/catalogApi.ts @@ -138,7 +138,7 @@ export interface BackendApiRow { /** * Adapts a snake_case, cursor-paginated Callora-Backend API row into the - * frontend `APIIdem` contract. + * frontend `APIItem` contract. * * Rules: * - Stringifies `id`. @@ -221,7 +221,7 @@ export function adaptBackendApi(row: unknown): APIItem | null { * `name`) are dropped instead of poisoning the list, so one malformed row does * not take the whole page down. * - * Accepts both legacy `APIIdem` shapes and backend rows via `adaptBackendApi`. + * Accepts both legacy `APIItem` shapes and backend rows via `adaptBackendApi`. */ export function normalizeCatalog(payload: unknown): APIItem[] { const list = Array.isArray(payload) @@ -282,8 +282,8 @@ export function createCatalogApi( ): CatalogApi { const fetchImpl = options.fetchImpl ?? globalThis.fetch; - let cachedEtg: string | null = null; - let cachedList: APIIdem[] = []; + let cachedEtag: string | null = null; + let cachedList: APIItem[] = []; const resolveBase = (): string => options.baseUrl !== undefined @@ -299,8 +299,8 @@ export function createCatalogApi( const headers: Record = { Accept: "application/json", }; - if (cachedEtg) { - headers["If-None-Match"] = cachedEtg; + if (cachedEtag) { + headers["If-None-Match"] = cachedEtag; } const base = resolveBase(); @@ -356,7 +356,7 @@ export function createCatalogApi( throw abortError(); } - const items = normalizeCatalogue(payload); + const items = normalizeCatalog(payload); cachedList = items; return items; }, @@ -448,7 +448,7 @@ export async function fetchCatalog(signal?: AbortSignal): Promise { // When API_BASE is configured, errors must reject so the UI surfaces its // error or stale-data states rather than hiding failures behind mock data. - return await catalogAPI["fetchCatalog"](signal); + return await catalogApi["fetchCatalog"](signal); } /** diff --git a/src/data/mockApis.ts b/src/data/mockApis.ts index 4e86a6fb..bb870778 100644 --- a/src/data/mockApis.ts +++ b/src/data/mockApis.ts @@ -36,6 +36,14 @@ export type APIItem = { sparklineValues?: number[]; pricingOnRequest?: boolean; baseUrl?: string; + sla?: { + uptimeTargetPct?: number; + p99LatencyMs?: number; + incidentResponseMinutes?: number; + maintenanceWindow?: string; + supportTier?: string; + creditThresholdPct?: number; + }; }; diff --git a/src/pages/ApiDetailPage.tsx b/src/pages/ApiDetailPage.tsx index 64679a30..3b87a631 100644 --- a/src/pages/ApiDetailPage.tsx +++ b/src/pages/ApiDetailPage.tsx @@ -25,12 +25,12 @@ import StatusBadge, { apiStatusToVariant } from "../components/StatusBadge"; import { useToast } from "../components/Toast"; import { useCollections } from "../state/collectionsStore"; import RelatedApisRail from "../components/RelatedApisRail"; -import MOCK_APIS from "../data/mockApis"; +import MOCK_APIS, { type APIItem as ApiDetail } from "../data/mockApis"; import KbdHint from "../components/KbdHint"; import { SHORTCUTS } from "../hooks/useGlobalShortcuts"; import LiveRegion from "../components/LiveRegion"; import { toCurl, type CurlRequest } from "../utils/toCurl"; -import { fetchApiDetail, type ApiDetail } from "../api/catalogApi"; +import { fetchApiDetail } from "../api/catalogApi"; /** * ApiDetailPage @@ -698,7 +698,7 @@ export default function ApiDetailPage({ onBack }: Props) { fetchApiDetail(id, controller.signal) .then((result) => { if (!active) return; - if (result.kind === "not-found") { + if (result.status === "not-found") { setApi(null); setStatus("not-found"); return; From 9bb9fb2594f44882667ff3211d297cd215ac3fb6 Mon Sep 17 00:00:00 2001 From: Godgeneral9 <318583743+Godgeneral9@users.noreply.github.com> Date: Fri, 23 Oct 2026 08:11:29 -0700 Subject: [PATCH 2/2] feat(billing): source deposit history from vault events The Billing History page rendered a hardcoded array, with hashes that were not valid hex and rows labelled as mainnet. Deposit rows now come from the vault's deposit events for the connected wallet, read with Soroban RPC getEvents and filtered by contract, topic and caller. The page has loading, no-wallet, error with Retry, and empty states, plus a note when history falls outside the RPC retention window. Older deposits load with a cursor. Responses for a previous wallet are discarded. Mock rows moved to src/data/billingFixtures.ts with valid hashes. Closes #1319 Co-Authored-By: Claude Haiku 5.5 --- docs/BillingHistoryPreview.md | 4 +- src/data/billingFixtures.ts | 105 +++++++++ src/hooks/useDepositHistory.ts | 123 +++++++++++ src/pages/BillingHistory.test.tsx | 347 ++++++++++++++++++------------ src/pages/BillingHistory.tsx | 165 ++++++-------- src/services/vaultEvents.test.ts | 143 ++++++++++++ src/services/vaultEvents.ts | 144 +++++++++++++ 7 files changed, 785 insertions(+), 246 deletions(-) create mode 100644 src/data/billingFixtures.ts create mode 100644 src/hooks/useDepositHistory.ts create mode 100644 src/services/vaultEvents.test.ts create mode 100644 src/services/vaultEvents.ts diff --git a/docs/BillingHistoryPreview.md b/docs/BillingHistoryPreview.md index 96f47f77..b86853d8 100644 --- a/docs/BillingHistoryPreview.md +++ b/docs/BillingHistoryPreview.md @@ -141,14 +141,14 @@ const data: PreviewCardData = { ## API — BillingHistory page -`BillingHistory` is a default-exported React component at `src/pages/BillingHistory.tsx`. It requires no props — all data comes from the internal `MOCK_TRANSACTIONS` constant. +`BillingHistory` is a default-exported React component at `src/pages/BillingHistory.tsx`. It requires no props. Deposit rows come from the vault contract's `deposit` events for the connected wallet, read with Soroban RPC `getEvents` (`src/services/vaultEvents.ts`, via `src/hooks/useDepositHistory.ts`). Each row shows the real transaction hash. The page shows loading, no-wallet, error with Retry, and empty states, and a note when history falls outside the RPC retention window. Older deposits load with **Load older deposits**. ### Exports | Name | Kind | Description | |------|------|-------------| | `BillingHistory` | named + default export | Page component | -| `MOCK_TRANSACTIONS` | named export | Array of `BillingTransaction` for use in tests and Storybook | +| `MOCK_TRANSACTIONS` | fixture (`src/data/billingFixtures.ts`) | Demo and test rows, not used by the page | | `BillingTransaction` | type | Shape of a single transaction record | | `TxType` | type | `'Deposit' \| 'API Call' \| 'Refund' \| 'Settlement' \| 'Fee'` | | `TxDirection` | type | `'credit' \| 'debit'` | diff --git a/src/data/billingFixtures.ts b/src/data/billingFixtures.ts new file mode 100644 index 00000000..335aec75 --- /dev/null +++ b/src/data/billingFixtures.ts @@ -0,0 +1,105 @@ +/** + * Demo and test fixtures for the Billing History page. Not used by the + * production page, which reads deposits from vault events. Hashes are + * 64-character hex, derived from each row's id. + */ +import type { BillingTransaction } from '../pages/BillingHistory'; + +export const MOCK_TRANSACTIONS: BillingTransaction[] = [ + { + id: 'tx-001', + description: 'USDC vault deposit', + type: 'Deposit', + direction: 'credit', + amount: 100.00, + status: 'success', + timestamp: '2026-07-25T14:32:00Z', + txHash: 'cb23007c9881e61d89fc4ce18aafd4b6347d159d500bf848a36c4fda7a03fa41', + network: 'Stellar Testnet', + confirmations: 120, + }, + { + id: 'tx-002', + description: 'WeatherSim API · 3 calls', + type: 'API Call', + direction: 'debit', + amount: 0.24, + status: 'success', + timestamp: '2026-07-25T15:10:22Z', + txHash: 'e11acea283811fac885409d80da17754501fccdd85a3de55b1bbbb8e9e6e55fb', + network: 'Stellar Testnet', + confirmations: 98, + }, + { + id: 'tx-003', + description: 'Stellar settlement · Epoch 1047', + type: 'Settlement', + direction: 'credit', + amount: 12.80, + status: 'success', + timestamp: '2026-07-26T00:00:00Z', + txHash: '65c17fb5c236df1ce3793ebe1316e8195cc7844a30e1c8b65ebcc4b526c5bb14', + network: 'Stellar Testnet', + confirmations: 60, + }, + { + id: 'tx-004', + description: 'Refund · CreditScore API', + type: 'Refund', + direction: 'credit', + amount: 0.05, + status: 'success', + timestamp: '2026-07-26T09:17:44Z', + txHash: '5e9e2b34ce4c6598b3a52c3f7848a9a00110f0fd49cb4d79e569dd02618a2224', + network: 'Stellar Testnet', + confirmations: 45, + }, + { + id: 'tx-005', + description: 'Network fee · tx D6I2…', + type: 'Fee', + direction: 'debit', + amount: 0.001, + status: 'success', + timestamp: '2026-07-26T09:17:44Z', + txHash: 'e6df263d1f2dcbcdfc1b188f64326a342e14839f937cbb019f3980dd391b1bee', + network: 'Stellar Testnet', + confirmations: 45, + }, + { + id: 'tx-006', + description: 'USDC vault deposit', + type: 'Deposit', + direction: 'credit', + amount: 50.00, + status: 'pending', + timestamp: '2026-07-27T11:04:00Z', + txHash: '3ed78887c6352d23346167a4f0e74cad6dd2e8b85d7cfed5b58b87017b990871', + network: 'Stellar Testnet', + confirmations: 2, + }, + { + id: 'tx-007', + description: 'ChainRisk API · 1 call', + type: 'API Call', + direction: 'debit', + amount: 0.08, + status: 'error', + timestamp: '2026-07-27T13:22:10Z', + txHash: '5b9add6af41c5b2e0de227650bbdebb1cd669937f95402b3729e0456f928afb7', + network: 'Stellar Testnet', + confirmations: 0, + }, + { + id: 'tx-008', + description: 'MarketFeed API · 10 calls', + type: 'API Call', + direction: 'debit', + amount: 0.80, + status: 'success', + timestamp: '2026-07-28T08:55:33Z', + txHash: '3eb279b05065c07591de370b50368c7f7d808790161c2ac62f1bcef3b9d27fb5', + network: 'Stellar Testnet', + confirmations: 88, + }, +]; diff --git a/src/hooks/useDepositHistory.ts b/src/hooks/useDepositHistory.ts new file mode 100644 index 00000000..9a041be1 --- /dev/null +++ b/src/hooks/useDepositHistory.ts @@ -0,0 +1,123 @@ +import { useCallback, useEffect, useState } from "react"; +import { useWalletStore } from "../state/walletStore"; +import { fetchDepositEvents } from "../services/vaultEvents"; +import type { BillingTransaction } from "../pages/BillingHistory"; + +export type DepositHistoryStatus = "no-wallet" | "loading" | "error" | "empty" | "ready"; + +export type DepositHistory = { + status: DepositHistoryStatus; + transactions: BillingTransaction[]; + retentionTruncated: boolean; + error: string | null; + hasMore: boolean; + loadingMore: boolean; + loadMoreError: string | null; + retry: () => void; + loadOlder: () => void; +}; + +type Loaded = { + address: string; + transactions: BillingTransaction[]; + nextCursor: string | null; + retentionTruncated: boolean; + error: string | null; +}; + +const GENERIC_ERROR = "Could not load deposit history. Try again."; + +function errorMessage(error: unknown): string { + return error instanceof Error && error.message ? error.message : GENERIC_ERROR; +} + +function mergeById(existing: BillingTransaction[], incoming: BillingTransaction[]): BillingTransaction[] { + const seen = new Set(existing.map((tx) => tx.id)); + return [...existing, ...incoming.filter((tx) => !seen.has(tx.id))]; +} + +/** + * Deposit history for the connected wallet, read from the vault's deposit + * events. Results are tagged with the address they were fetched for, so a + * response that arrives after the wallet changed is discarded and never + * mixes rows from two addresses. + */ +export function useDepositHistory(): DepositHistory { + const { address } = useWalletStore(); + const [loaded, setLoaded] = useState(null); + const [reloadKey, setReloadKey] = useState(0); + const [loadingMore, setLoadingMore] = useState(false); + const [loadMoreError, setLoadMoreError] = useState(null); + + useEffect(() => { + if (!address) return; + const controller = new AbortController(); + setLoaded(null); + setLoadingMore(false); + setLoadMoreError(null); + fetchDepositEvents({ address, signal: controller.signal }) + .then((page) => { + if (controller.signal.aborted) return; + setLoaded({ + address, + transactions: page.transactions, + nextCursor: page.nextCursor, + retentionTruncated: page.retentionTruncated, + error: null, + }); + }) + .catch((error: unknown) => { + if (controller.signal.aborted) return; + setLoaded({ address, transactions: [], nextCursor: null, retentionTruncated: false, error: errorMessage(error) }); + }); + return () => controller.abort(); + }, [address, reloadKey]); + + const current = loaded && loaded.address === address ? loaded : null; + + const loadOlder = useCallback(() => { + if (!address || !current?.nextCursor || loadingMore) return; + const cursor = current.nextCursor; + setLoadingMore(true); + setLoadMoreError(null); + fetchDepositEvents({ address, cursor }) + .then((page) => { + setLoaded((prev) => { + if (!prev || prev.address !== address) return prev; + return { + ...prev, + transactions: mergeById(prev.transactions, page.transactions), + nextCursor: page.nextCursor, + retentionTruncated: prev.retentionTruncated || page.retentionTruncated, + }; + }); + }) + .catch((error: unknown) => { + setLoadMoreError(errorMessage(error)); + }) + .finally(() => { + setLoadingMore(false); + }); + }, [address, current, loadingMore]); + + const retry = useCallback(() => setReloadKey((key) => key + 1), []); + + let status: DepositHistoryStatus; + if (!address) status = "no-wallet"; + else if (!current) status = "loading"; + else if (current.error) status = "error"; + else if (current.transactions.length === 0) status = "empty"; + else status = "ready"; + + return { + status, + transactions: current?.transactions ?? [], + retentionTruncated: current?.retentionTruncated ?? false, + error: current?.error ?? null, + hasMore: current?.nextCursor != null, + loadingMore, + loadMoreError, + retry, + loadOlder, + }; +} diff --git a/src/pages/BillingHistory.test.tsx b/src/pages/BillingHistory.test.tsx index ce93b44e..9ca97a95 100644 --- a/src/pages/BillingHistory.test.tsx +++ b/src/pages/BillingHistory.test.tsx @@ -20,7 +20,26 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { AccountProvider } from '../hooks/useAccountContext'; import { addAccount, switchAccount, _reset } from '../state/accountStore'; import { MemoryRouter, useLocation } from 'react-router-dom'; -import { BillingHistory, MOCK_TRANSACTIONS } from './BillingHistory'; +import { BillingHistory } from './BillingHistory'; +import { MOCK_TRANSACTIONS } from '../data/billingFixtures'; +import { fetchDepositEvents } from '../services/vaultEvents'; + +const walletState = vi.hoisted(() => ({ + address: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF' as string | null, +})); + +// Deposit history comes from vault events; the page is tested against the +// fixture rows, served as if the connected wallet had made those deposits. +vi.mock('../services/vaultEvents', async () => { + const { MOCK_TRANSACTIONS: rows } = await import('../data/billingFixtures'); + return { + fetchDepositEvents: vi.fn(async () => ({ transactions: rows, nextCursor: null, retentionTruncated: false })), + }; +}); +vi.mock('../state/walletStore', async (importOriginal) => ({ + ...await importOriginal(), + useWalletStore: () => ({ address: walletState.address, status: 'connected', networkPassphrase: null, isNetworkMismatch: false }), +})); // ── Helpers ─────────────────────────────────────────────────────────────────── @@ -28,12 +47,15 @@ import { BillingHistory, MOCK_TRANSACTIONS } from './BillingHistory'; * Render BillingHistory inside a MemoryRouter. * Pass `initialEntries` to pre-seed URL search params, e.g. `['?sort=date&dir=asc']`. */ -function renderBH(initialEntries: string[] = ['/']) { - return render( +async function renderBH(initialEntries: string[] = ['/']) { + const result = render( , ); + // Rows arrive from the deposit history fetch; wait until loading finishes. + await waitFor(() => expect(screen.queryByText(/Loading deposit history/)).toBeNull()); + return result; } /** Get the row element for a given transaction id. */ @@ -51,20 +73,20 @@ function getTriggerInRow(row: HTMLElement) { describe('BillingHistory — page structure', () => { afterEach(() => { cleanup(); _reset(); }); - it('renders the page heading', () => { - renderBH(); + it('renders the page heading', async () => { + await renderBH(); expect( screen.getByRole('heading', { name: /billing history/i, level: 1 }), ).toBeTruthy(); }); - it('renders all mock transactions by default', () => { - renderBH(); + it('renders all mock transactions by default', async () => { + await renderBH(); expect(screen.getByText(`${MOCK_TRANSACTIONS.length} transactions`)).toBeTruthy(); }); - it('renders a table with an accessible aria-label', () => { - renderBH(); + it('renders a table with an accessible aria-label', async () => { + await renderBH(); expect( screen.getByRole('table', { name: /billing transaction history/i }), ).toBeTruthy(); @@ -88,8 +110,8 @@ describe('BillingHistory — page structure', () => { expect(screen.getByText('Jul 25, 10:32 AM')).toBeTruthy(); }); - it('renders column headers with scope="col"', () => { - renderBH(); + it('renders column headers with scope="col"', async () => { + await renderBH(); const headerCells = screen .getAllByRole('columnheader') .filter((th) => th.getAttribute('scope') === 'col'); @@ -97,27 +119,27 @@ describe('BillingHistory — page structure', () => { expect(headerCells.length).toBeGreaterThanOrEqual(6); }); - it('renders a status live region for filter announcements', () => { - renderBH(); + it('renders a status live region for filter announcements', async () => { + await renderBH(); const liveRegion = document.querySelector('[role="status"][aria-live="polite"]'); expect(liveRegion).toBeTruthy(); }); - it('renders a keyboard tip containing "Esc"', () => { - renderBH(); + it('renders a keyboard tip containing "Esc"', async () => { + await renderBH(); // The bottom keyboard tip paragraph expect(screen.getAllByText(/Esc/i).length).toBeGreaterThan(0); }); - it('renders StatusBadge for each transaction', () => { - renderBH(); + it('renders StatusBadge for each transaction', async () => { + await renderBH(); // StatusBadge uses role="img"; there should be at least one per row. const badges = screen.getAllByRole('img'); expect(badges.length).toBeGreaterThanOrEqual(MOCK_TRANSACTIONS.length); }); - it('shows the net balance summary line', () => { - renderBH(); + it('shows the net balance summary line', async () => { + await renderBH(); // Net: label should be visible expect(screen.getByText('Net:')).toBeTruthy(); }); @@ -128,8 +150,8 @@ describe('BillingHistory — page structure', () => { describe('BillingHistory — PreviewCard hover/focus integration', () => { afterEach(cleanup); - it('each transaction row has a PreviewCard trigger button', () => { - renderBH(); + it('each transaction row has a PreviewCard trigger button', async () => { + await renderBH(); // Each row must have exactly one trigger per PreviewCard MOCK_TRANSACTIONS.forEach((tx) => { const row = getRow(tx.id); @@ -137,8 +159,8 @@ describe('BillingHistory — PreviewCard hover/focus integration', () => { }); }); - it('hovering a row description opens the preview tooltip', () => { - renderBH(); + it('hovering a row description opens the preview tooltip', async () => { + await renderBH(); const row = getRow(MOCK_TRANSACTIONS[0].id); const wrapper = row.querySelector('.preview-card__wrapper') as HTMLElement; @@ -150,8 +172,8 @@ describe('BillingHistory — PreviewCard hover/focus integration', () => { expect(screen.queryByRole('tooltip')).toBeNull(); }); - it('focusing a row trigger opens the preview tooltip', () => { - renderBH(); + it('focusing a row trigger opens the preview tooltip', async () => { + await renderBH(); const row = getRow(MOCK_TRANSACTIONS[0].id); const trigger = getTriggerInRow(row); @@ -159,8 +181,8 @@ describe('BillingHistory — PreviewCard hover/focus integration', () => { expect(screen.getByRole('tooltip')).toBeTruthy(); }); - it('preview panel shows the transaction description', () => { - renderBH(); + it('preview panel shows the transaction description', async () => { + await renderBH(); const tx = MOCK_TRANSACTIONS[0]; // "USDC vault deposit" const row = getRow(tx.id); const trigger = getTriggerInRow(row); @@ -169,8 +191,8 @@ describe('BillingHistory — PreviewCard hover/focus integration', () => { expect(screen.getByRole('tooltip')).toHaveTextContent(tx.description); }); - it('preview panel shows the tx hash (truncated)', () => { - renderBH(); + it('preview panel shows the tx hash (truncated)', async () => { + await renderBH(); const tx = MOCK_TRANSACTIONS[0]; const row = getRow(tx.id); const trigger = getTriggerInRow(row); @@ -181,32 +203,32 @@ describe('BillingHistory — PreviewCard hover/focus integration', () => { expect(panel).toHaveTextContent(tx.txHash.slice(0, 8)); }); - it('preview panel shows network name', () => { - renderBH(); + it('preview panel shows network name', async () => { + await renderBH(); const tx = MOCK_TRANSACTIONS[0]; const row = getRow(tx.id); fireEvent.focus(getTriggerInRow(row)); - expect(screen.getByRole('tooltip')).toHaveTextContent('Stellar Mainnet'); + expect(screen.getByRole('tooltip')).toHaveTextContent('Stellar Testnet'); }); - it('preview panel shows confirmation count', () => { - renderBH(); + it('preview panel shows confirmation count', async () => { + await renderBH(); const tx = MOCK_TRANSACTIONS[0]; // 120 confirmations const row = getRow(tx.id); fireEvent.focus(getTriggerInRow(row)); expect(screen.getByRole('tooltip')).toHaveTextContent('120'); }); - it('preview panel shows formatted USDC amount', () => { - renderBH(); + it('preview panel shows formatted USDC amount', async () => { + await renderBH(); const tx = MOCK_TRANSACTIONS[0]; // 100.00 USDC const row = getRow(tx.id); fireEvent.focus(getTriggerInRow(row)); expect(screen.getByRole('tooltip')).toHaveTextContent('100.00 USDC'); }); - it('Escape closes the preview and removes aria-describedby', () => { - renderBH(); + it('Escape closes the preview and removes aria-describedby', async () => { + await renderBH(); const row = getRow(MOCK_TRANSACTIONS[0].id); const trigger = getTriggerInRow(row); @@ -218,8 +240,8 @@ describe('BillingHistory — PreviewCard hover/focus integration', () => { expect(trigger.getAttribute('aria-describedby')).toBeNull(); }); - it('trigger aria-label includes the transaction description', () => { - renderBH(); + it('trigger aria-label includes the transaction description', async () => { + await renderBH(); const tx = MOCK_TRANSACTIONS[0]; const row = getRow(tx.id); const trigger = getTriggerInRow(row); @@ -229,8 +251,8 @@ describe('BillingHistory — PreviewCard hover/focus integration', () => { ); }); - it('panel has pointer-events: none to avoid mouse trapping', () => { - renderBH(); + it('panel has pointer-events: none to avoid mouse trapping', async () => { + await renderBH(); const row = getRow(MOCK_TRANSACTIONS[0].id); fireEvent.focus(getTriggerInRow(row)); expect(screen.getByRole('tooltip').style.pointerEvents).toBe('none'); @@ -242,8 +264,8 @@ describe('BillingHistory — PreviewCard hover/focus integration', () => { describe('BillingHistory — filters', () => { afterEach(cleanup); - it('filter by type "Deposit" shows only deposit rows', () => { - renderBH(); + it('filter by type "Deposit" shows only deposit rows', async () => { + await renderBH(); const typeSelect = screen.getByRole('combobox', { name: /filter by transaction type/i }); fireEvent.change(typeSelect, { target: { value: 'Deposit' } }); @@ -258,8 +280,8 @@ describe('BillingHistory — filters', () => { }); }); - it('filter by status "pending" shows only pending rows', () => { - renderBH(); + it('filter by status "pending" shows only pending rows', async () => { + await renderBH(); const statusSelect = screen.getByRole('combobox', { name: /filter by transaction status/i }); fireEvent.change(statusSelect, { target: { value: 'pending' } }); @@ -268,8 +290,8 @@ describe('BillingHistory — filters', () => { expect(screen.getByText(`${pendingCount} transaction${pendingCount !== 1 ? 's' : ''}`)).toBeTruthy(); }); - it('filter by direction "credit" shows only credit rows', () => { - renderBH(); + it('filter by direction "credit" shows only credit rows', async () => { + await renderBH(); const dirSelect = screen.getByRole('combobox', { name: /filter by transaction direction/i }); fireEvent.change(dirSelect, { target: { value: 'credit' } }); @@ -283,8 +305,8 @@ describe('BillingHistory — filters', () => { }); }); - it('search by description filters rows', () => { - renderBH(); + it('search by description filters rows', async () => { + await renderBH(); const searchInput = screen.getByRole('searchbox', { name: /search transactions/i }); fireEvent.change(searchInput, { target: { value: 'WeatherSim' } }); @@ -295,8 +317,8 @@ describe('BillingHistory — filters', () => { expect(screen.getByText(`${matchCount} transaction${matchCount !== 1 ? 's' : ''}`)).toBeTruthy(); }); - it('search by tx hash filters rows', () => { - renderBH(); + it('search by tx hash filters rows', async () => { + await renderBH(); const searchInput = screen.getByRole('searchbox', { name: /search transactions/i }); // Use the first 8 chars of the first tx hash const hashPrefix = MOCK_TRANSACTIONS[0].txHash.slice(0, 8).toLowerCase(); @@ -306,8 +328,8 @@ describe('BillingHistory — filters', () => { expect(screen.queryByRole('table')).toBeTruthy(); }); - it('shows empty state when no transactions match', () => { - renderBH(); + it('shows empty state when no transactions match', async () => { + await renderBH(); const searchInput = screen.getByRole('searchbox', { name: /search transactions/i }); fireEvent.change(searchInput, { target: { value: 'ZZZNOMATCH999' } }); @@ -318,8 +340,8 @@ describe('BillingHistory — filters', () => { expect(screen.queryByRole('table')).toBeNull(); }); - it('net balance is positive when only credits are shown', () => { - renderBH(); + it('net balance is positive when only credits are shown', async () => { + await renderBH(); const dirSelect = screen.getByRole('combobox', { name: /filter by transaction direction/i }); fireEvent.change(dirSelect, { target: { value: 'credit' } }); @@ -330,8 +352,8 @@ describe('BillingHistory — filters', () => { expect(pageText).toContain('+'); }); - it('live region contains filter summary after type filter change', () => { - renderBH(); + it('live region contains filter summary after type filter change', async () => { + await renderBH(); const typeSelect = screen.getByRole('combobox', { name: /filter by transaction type/i }); fireEvent.change(typeSelect, { target: { value: 'Fee' } }); @@ -339,8 +361,8 @@ describe('BillingHistory — filters', () => { expect(liveRegion.textContent).toContain('Fee'); }); - it('live region reads "Showing all transactions" when no filters active', () => { - renderBH(); + it('live region reads "Showing all transactions" when no filters active', async () => { + await renderBH(); const liveRegion = document.querySelector('[role="status"][aria-live="polite"]') as HTMLElement; expect(liveRegion.textContent).toMatch(/showing all transactions/i); }); @@ -351,8 +373,8 @@ describe('BillingHistory — filters', () => { describe('BillingHistory — table row content', () => { afterEach(cleanup); - it('shows truncated tx hash in the Tx Hash column', () => { - renderBH(); + it('shows truncated tx hash in the Tx Hash column', async () => { + await renderBH(); // First transaction: A3F9B2C1... → "A3F9B2…F0A1" (6+…+4) const hashCode = screen.getAllByTitle(MOCK_TRANSACTIONS[0].txHash)[0]; expect(hashCode).toBeTruthy(); @@ -362,14 +384,14 @@ describe('BillingHistory — table row content', () => { ); }); - it('amount cell has tabular-nums class for alignment', () => { - renderBH(); + it('amount cell has tabular-nums class for alignment', async () => { + await renderBH(); const cells = document.querySelectorAll('td.tabular-nums'); expect(cells.length).toBeGreaterThan(0); }); - it('date cells render