diff --git a/src/hooks/useTransfers.js b/src/hooks/useTransfers.js index dea1e61..9e3448c 100644 --- a/src/hooks/useTransfers.js +++ b/src/hooks/useTransfers.js @@ -1,28 +1,88 @@ - -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { listTransfers, createTransfer } from '../services/api.js'; import { ContractViolationError } from '../services/contracts/schema.js'; import { getUserErrorMessage, normalizeError } from '../services/errors.js'; +import { DEMO_PUBLIC_KEY } from '../services/wallet.js'; +import { + DEFAULT_RESULT_CAP, + transferQueryScopeKey, +} from '../utils/transferSearch.js'; + +function isAbortError(err) { + return ( + err?.name === 'AbortError' || + err?.code === 'ERR_CANCELED' || + err?.code === 'ECONNABORTED' + ); +} /** * Hook for loading and creating transfers. + * + * List requests always carry an actor scope, honour AbortSignal cancellation, + * and ignore stale responses so a slow reply for an old filter cannot + * overwrite the current result set. + * + * @param {{ + * actorId?: string, + * filters?: {search?: string, status?: string, range?: string}, + * limit?: number, + * }} [options] * @returns {{transfers: Array, loading: boolean, error: string|null, * retryable: boolean, reload: Function|undefined, addTransfer: Function}} */ -export function useTransfers() { +export function useTransfers(options = {}) { + const actorId = options.actorId || DEMO_PUBLIC_KEY; + const filters = options.filters || {}; + const limit = options.limit ?? DEFAULT_RESULT_CAP; + const [transfers, setTransfers] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [retryable, setRetryable] = useState(false); + const requestGen = useRef(0); + const abortRef = useRef(null); + + const scopeKey = transferQueryScopeKey({ + actorId, + search: filters.search, + status: filters.status, + range: filters.range, + limit, + }); + const reload = useCallback(async () => { + const gen = ++requestGen.current; + const expectedScope = scopeKey; + + if (abortRef.current) { + abortRef.current.abort(); + } + const controller = new AbortController(); + abortRef.current = controller; + setLoading(true); setError(null); setRetryable(false); try { - const data = await listTransfers(); + const data = await listTransfers({ + actorId, + search: filters.search, + status: filters.status, + range: filters.range, + limit, + signal: controller.signal, + }); + // Drop stale replies: a newer filter/request already superseded this one. + if (gen !== requestGen.current || expectedScope !== scopeKey) { + return; + } setTransfers(data); } catch (err) { + if (isAbortError(err) || gen !== requestGen.current) { + return; + } if (err instanceof ContractViolationError) { // A schema change, not a flaky request. Retrying will not help, and // showing an empty list would imply the transfers no longer exist. @@ -37,19 +97,43 @@ export function useTransfers() { setRetryable(normalized.retryable); } } finally { - setLoading(false); + if (gen === requestGen.current) { + setLoading(false); + } } - }, []); + }, [actorId, filters.search, filters.status, filters.range, limit, scopeKey]); useEffect(() => { reload(); + return () => { + if (abortRef.current) { + abortRef.current.abort(); + } + }; }, [reload]); - const addTransfer = useCallback(async (payload) => { - const created = await createTransfer(payload); - setTransfers((prev) => [created, ...prev]); - return created; - }, []); + const addTransfer = useCallback( + async (payload) => { + const created = await createTransfer({ + ...payload, + actorId: payload?.actorId || actorId, + }); + setTransfers((prev) => { + // Prepend only when the new row matches the active filter scope. + const matchesSearch = + !filters.search || + String(created.recipient ?? '') + .toLowerCase() + .includes(String(filters.search).toLowerCase()); + const matchesStatus = + !filters.status || created.status === filters.status; + if (!matchesSearch || !matchesStatus) return prev; + return [created, ...prev].slice(0, limit); + }); + return created; + }, + [actorId, filters.search, filters.status, limit], + ); // Existing consumers use reload for both pull-to-refresh and the error-state // retry action. Withhold it only while a non-retryable error is displayed. diff --git a/src/pages/SendMoney.jsx b/src/pages/SendMoney.jsx index c1e03f0..57b3e26 100644 --- a/src/pages/SendMoney.jsx +++ b/src/pages/SendMoney.jsx @@ -36,7 +36,7 @@ import './SendMoney.css'; export default function SendMoney() { const navigate = useNavigate(); const { wallet, isConnected, connect } = useWallet(); - const { addTransfer } = useTransfers(); + const { addTransfer } = useTransfers({ actorId: wallet?.publicKey }); const { locale } = useApp(); const isOnline = useOnlineStatus(); @@ -187,8 +187,9 @@ export default function SendMoney() { submissionLock.current = true; setSubmitting(true); try { - if (!isConnected) { - await connect(); + const account = isConnected ? wallet : await connect(); + if (!account?.publicKey) { + throw new Error('Connect a wallet before sending.'); } // Rebuild at confirmation time so the committed amounts match the note: @@ -210,6 +211,7 @@ export default function SendMoney() { // can reproduce exactly what was quoted rather than re-deriving it from // a rate that may since have moved. const created = await addTransfer({ + actorId: account.publicKey, recipient, from, to, diff --git a/src/pages/Transfers.jsx b/src/pages/Transfers.jsx index def8104..809b1c1 100644 --- a/src/pages/Transfers.jsx +++ b/src/pages/Transfers.jsx @@ -2,10 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { Link, useSearchParams } from 'react-router-dom'; import Chart from '../components/Chart.jsx'; import { formatMoney, parseDecimal } from '../utils/money.js'; -import { - TRANSFER_STATUSES, - normalizeStatus, -} from '../services/contracts/transfer.js'; +import { TRANSFER_STATUSES } from '../services/contracts/transfer.js'; import TransferRow from '../components/TransferRow.jsx'; import { TRANSFER_STATUS_LABELS } from '../components/StatusBadge.jsx'; import Skeleton from '../components/Skeleton.jsx'; @@ -16,9 +13,15 @@ import Pagination from '../components/Pagination.jsx'; import PullToRefresh from '../components/PullToRefresh.jsx'; import SelectionToolbar from '../components/SelectionToolbar.jsx'; import { useTransfers } from '../hooks/useTransfers.js'; +import { useDebouncedValue } from '../hooks/useDebouncedValue.js'; import { useOnlineStatus } from '../hooks/useOnlineStatus.js'; import { useApp } from '../context/AppContext.jsx'; -import { DATE_RANGE_PRESETS, isWithinDateRange } from '../utils/dateRange.js'; +import { DATE_RANGE_PRESETS } from '../utils/dateRange.js'; +import { DEMO_PUBLIC_KEY } from '../services/wallet.js'; +import { + DEFAULT_RESULT_CAP, + SEARCH_DEBOUNCE_MS, +} from '../utils/transferSearch.js'; import './Transfers.css'; // Derived from the contract so a new lifecycle state cannot be filterable in @@ -35,11 +38,13 @@ const PAGE_SIZE = 5; /** * Transfers page: lists all transfers with their status. - * Filter state is synced to the URL query string. + * Filter state is synced to the URL query string. Free-text search is + * debounced before it drives the actor-scoped list query so keystrokes do + * not fan out requests; obsolete in-flight queries are aborted by the hook. */ export default function Transfers() { - const { transfers, loading, error, reload } = useTransfers(); - const { locale } = useApp(); + const { locale, wallet } = useApp(); + const actorId = wallet?.publicKey || DEMO_PUBLIC_KEY; const isOnline = useOnlineStatus(); const [searchParams, setSearchParams] = useSearchParams(); @@ -49,9 +54,48 @@ export default function Transfers() { const [wasOffline, setWasOffline] = useState(false); const [syncingAfterReconnect, setSyncingAfterReconnect] = useState(false); - const search = searchParams.get('search') || ''; const status = searchParams.get('status') || ''; const range = searchParams.get('range') || ''; + const urlSearch = searchParams.get('search') || ''; + + // Draft input updates immediately; the debounced value drives URL + query. + const [searchDraft, setSearchDraft] = useState(urlSearch); + const debouncedSearch = useDebouncedValue(searchDraft, SEARCH_DEBOUNCE_MS); + + // Keep draft aligned when the URL changes externally (back/forward, clear). + useEffect(() => { + setSearchDraft(urlSearch); + }, [urlSearch]); + + // Publish debounced search into the URL so shareable links stay accurate. + useEffect(() => { + setSearchParams( + (prev) => { + const current = prev.get('search') || ''; + if (current === debouncedSearch) return prev; + const next = new URLSearchParams(prev); + if (debouncedSearch) next.set('search', debouncedSearch); + else next.delete('search'); + return next; + }, + { replace: true }, + ); + }, [debouncedSearch, setSearchParams]); + + const queryFilters = useMemo( + () => ({ + search: debouncedSearch, + status, + range, + }), + [debouncedSearch, status, range], + ); + + const { transfers, loading, error, reload } = useTransfers({ + actorId, + filters: queryFilters, + limit: DEFAULT_RESULT_CAP, + }); // Selection state const [selectedIds, setSelectedIds] = useState(() => new Set()); @@ -60,39 +104,29 @@ export default function Transfers() { // Pagination state const [page, setPage] = useState(1); - // Reset page and selection when filters change + // Reset page and selection when the committed filters change useEffect(() => { setPage(1); setSelectedIds(new Set()); setSelectAllAcross(false); - }, [search, status, range]); + }, [debouncedSearch, status, range]); - // Normalise the query-string status so a legacy or provider spelling in a - // shared/bookmarked URL (?status=settled) still selects the right rows. - const canonicalStatus = normalizeStatus(status); // Track connectivity so that a reconnect triggers an automatic reload. // The reload reconciles the true status of transfers that may have been // created or settled while the connection was down — without resubmitting // anything. useEffect(() => { - if (isOnline && wasOffline && !loading) { + if (isOnline && wasOffline && !loading && reload) { setSyncingAfterReconnect(true); - reload().finally(() => { + Promise.resolve(reload()).finally(() => { setSyncingAfterReconnect(false); }); } setWasOffline(!isOnline); }, [isOnline, wasOffline, loading, reload]); - const filteredTransfers = useMemo(() => { - return transfers.filter((t) => { - if (status && normalizeStatus(t.status) !== canonicalStatus) return false; - if (search && !t.recipient.toLowerCase().includes(search.toLowerCase())) - return false; - if (!isWithinDateRange(t.createdAt, range)) return false; - return true; - }); - }, [transfers, search, status, canonicalStatus, range]); + // API already applies actor scope, filters, stable sort, and the result cap. + const filteredTransfers = transfers; // Paginated data const totalPages = Math.ceil(filteredTransfers.length / PAGE_SIZE) || 1; @@ -112,17 +146,9 @@ export default function Transfers() { : selectedIds.size; const hasMorePages = totalPages > 1; - const handleSearchChange = useCallback( - (e) => { - const value = e.target.value; - setSearchParams((prev) => { - if (value) prev.set('search', value); - else prev.delete('search'); - return prev; - }); - }, - [setSearchParams], - ); + const handleSearchChange = useCallback((e) => { + setSearchDraft(e.target.value); + }, []); const handleStatusChange = useCallback( (e) => { @@ -148,7 +174,7 @@ export default function Transfers() { [setSearchParams], ); - const hasActiveFilters = Boolean(search || status || range); + const hasActiveFilters = Boolean(debouncedSearch || status || range); // Selection handlers const handleToggleSelect = useCallback((id) => { @@ -188,6 +214,11 @@ export default function Transfers() { setSelectAllAcross(false); }, []); + const handleClearFilters = useCallback(() => { + setSearchDraft(''); + setSearchParams({}); + }, [setSearchParams]); + const renderContent = () => { if (loading) { return ( @@ -215,7 +246,7 @@ export default function Transfers() { } action={ hasActiveFilters ? ( - + ) : ( @@ -288,7 +319,7 @@ export default function Transfers() { type="search" className="transfers-filters-search" placeholder="Search by recipient…" - value={search} + value={searchDraft} onChange={handleSearchChange} aria-label="Search transfers by recipient" /> diff --git a/src/services/api.js b/src/services/api.js index d8d2797..ce71f6e 100644 --- a/src/services/api.js +++ b/src/services/api.js @@ -11,6 +11,13 @@ import { parseTransferList, transferContract, } from './contracts/transfer.js'; +import { DEMO_PUBLIC_KEY } from './wallet.js'; +import { + DEFAULT_RESULT_CAP, + applyTransferSearch, + normalizeTransferQuery, + requireActorId, +} from '../utils/transferSearch.js'; const STORAGE_KEY = 'remitflow.transfers'; @@ -25,6 +32,7 @@ const SEED_TRANSFERS = [ receiveAmount: 294620, status: 'completed', createdAt: '2026-05-28T10:15:00Z', + actorId: DEMO_PUBLIC_KEY, }, { id: 'tx_1002', @@ -35,6 +43,7 @@ const SEED_TRANSFERS = [ receiveAmount: 9920, status: 'pending', createdAt: '2026-06-02T08:42:00Z', + actorId: DEMO_PUBLIC_KEY, }, ]; @@ -67,18 +76,57 @@ function reportRejected(rejected) { } } +function abortedError() { + if (typeof DOMException === 'function') { + return new DOMException( + 'The transfer list request was aborted.', + 'AbortError', + ); + } + const err = new Error('The transfer list request was aborted.'); + err.name = 'AbortError'; + return err; +} + /** - * List all transfers, newest first. + * List transfers for one actor, newest first, with optional filters. + * + * Every call must carry `actorId` so results cannot cross wallet scope. + * Pass `signal` to cancel an obsolete in-flight query when filters change. + * Results are stably sorted and capped so large histories stay responsive. * * One malformed record is dropped and logged so the rest of the list still * renders. A response where *every* record fails is a schema change, not bad * data, and is raised so the UI can say so instead of showing "no transfers". * - * @returns {Promise} contract-normalised transfers + * @param {{ + * actorId?: string, + * search?: string, + * status?: string, + * range?: string, + * limit?: number, + * signal?: AbortSignal, + * }} [options] + * @returns {Promise} contract-normalised transfers */ -export function listTransfers() { +export function listTransfers(options = {}) { + const actorId = requireActorId(options.actorId ?? DEMO_PUBLIC_KEY); + const query = normalizeTransferQuery({ + actorId, + search: options.search, + status: options.status, + range: options.range, + limit: options.limit ?? DEFAULT_RESULT_CAP, + }); + const signal = options.signal; + return new Promise((resolve, reject) => { - setTimeout(() => { + if (signal?.aborted) { + reject(abortedError()); + return; + } + + const timer = setTimeout(() => { try { const { transfers, rejected, breaking } = parseTransferList(read(), { source: 'listTransfers', @@ -94,15 +142,23 @@ export function listTransfers() { ); } if (rejected.length) reportRejected(rejected); - resolve( - transfers - .slice() - .sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)), - ); + + const { items } = applyTransferSearch(transfers, query, { + legacyActorId: DEMO_PUBLIC_KEY, + }); + resolve(items); } catch (error) { reject(error); } }, 400); + + if (signal) { + const onAbort = () => { + clearTimeout(timer); + reject(abortedError()); + }; + signal.addEventListener('abort', onAbort, { once: true }); + } }); } @@ -120,12 +176,17 @@ export function createTransfer(payload) { return new Promise((resolve, reject) => { setTimeout(() => { try { + const actorId = + typeof payload?.actorId === 'string' && payload.actorId.trim() + ? payload.actorId.trim() + : DEMO_PUBLIC_KEY; const transfer = parseTransfer( { id: 'tx_' + Date.now(), status: 'pending', createdAt: new Date().toISOString(), ...payload, + actorId, }, { source: 'createTransfer' }, ); diff --git a/src/services/contracts/transfer.js b/src/services/contracts/transfer.js index ce4e901..e95c76f 100644 --- a/src/services/contracts/transfer.js +++ b/src/services/contracts/transfer.js @@ -68,6 +68,8 @@ export const transferContract = defineContract({ rate: { type: 'decimal', required: false, min: 0 }, expiresAt: { type: 'timestamp', required: false }, failureReason: { type: 'string', required: false, nullable: true }, + // Sender scope. Absent on legacy records; list queries always supply actorId. + actorId: { type: 'string', required: false, minLength: 1 }, }, }); diff --git a/src/services/wallet.js b/src/services/wallet.js index 73e5929..34ae28a 100644 --- a/src/services/wallet.js +++ b/src/services/wallet.js @@ -5,7 +5,7 @@ const STORAGE_KEY = 'remitflow.wallet'; // A deterministic fake Stellar public key for demo purposes. -const DEMO_PUBLIC_KEY = +export const DEMO_PUBLIC_KEY = 'GBQAZ7Z3X7DEMOPUBLICKEY4REMITFLOWWALLET123456789ABCDEF'; /** diff --git a/src/utils/transferSearch.js b/src/utils/transferSearch.js new file mode 100644 index 0000000..8580bad --- /dev/null +++ b/src/utils/transferSearch.js @@ -0,0 +1,153 @@ +/** + * Deterministic transfer search, filter, sort, and result capping. + * + * Client-side filter fan-out races and unbounded scans get slow as history + * grows. These helpers define one canonical filter/sort/scope semantics used + * by the list API and the Transfers page so stale responses, missing actor + * scope, and uncapped result sets cannot silently corrupt the UI. + */ + +import { normalizeStatus } from '../services/contracts/transfer.js'; +import { isWithinDateRange } from './dateRange.js'; + +/** Debounce window for free-text search queries (ms). */ +export const SEARCH_DEBOUNCE_MS = 300; + +/** Default maximum rows a single list query may return. */ +export const DEFAULT_RESULT_CAP = 100; + +/** Hard ceiling — callers may request less, never more. */ +export const MAX_RESULT_CAP = 500; + +/** + * @param {unknown} actorId + * @returns {string} + */ +export function requireActorId(actorId) { + if (typeof actorId !== 'string' || actorId.trim() === '') { + throw new Error('actorId is required on every transfer list request'); + } + return actorId.trim(); +} + +/** + * Canonical filter bag bound into every request and stale-response check. + * @param {{search?: string, status?: string, range?: string, actorId?: string, limit?: number}} input + */ +export function normalizeTransferQuery(input = {}) { + const actorId = requireActorId(input.actorId); + const search = String(input.search ?? '').trim(); + const statusRaw = String(input.status ?? '').trim(); + const status = statusRaw ? (normalizeStatus(statusRaw) ?? statusRaw) : ''; + const range = String(input.range ?? '').trim(); + const requested = + Number.isFinite(input.limit) && input.limit > 0 + ? Math.floor(input.limit) + : DEFAULT_RESULT_CAP; + const limit = Math.min(Math.max(requested, 1), MAX_RESULT_CAP); + + return { actorId, search, status, range, limit }; +} + +/** + * Stable string identity for a query — used to ignore stale responses. + * @param {{search?: string, status?: string, range?: string, actorId?: string, limit?: number}} query + */ +export function transferQueryScopeKey(query) { + const normalized = normalizeTransferQuery(query); + return JSON.stringify(normalized); +} + +/** + * Whether a transfer belongs to the requesting actor. + * Legacy rows without actorId are only visible to the supplied legacyActor + * (the historical demo owner) so migration does not leak across wallets. + * + * @param {object} transfer + * @param {string} actorId + * @param {{legacyActorId?: string}} [options] + */ +export function isVisibleToActor(transfer, actorId, options = {}) { + if (!actorId) return false; + const owner = transfer?.actorId; + if (typeof owner === 'string' && owner.length > 0) { + return owner === actorId; + } + const legacy = options.legacyActorId; + return Boolean(legacy) && actorId === legacy; +} + +/** + * Apply status / search / date-range predicates (actor scope is separate). + * @param {object} transfer + * @param {{search?: string, status?: string, range?: string}} filters + * @param {Date} [now] + */ +export function matchesTransferFilters( + transfer, + filters = {}, + now = new Date(), +) { + const status = filters.status + ? (normalizeStatus(filters.status) ?? filters.status) + : ''; + if (status && normalizeStatus(transfer.status) !== status) return false; + + const search = String(filters.search ?? '') + .trim() + .toLowerCase(); + if (search) { + const recipient = String(transfer.recipient ?? '').toLowerCase(); + if (!recipient.includes(search)) return false; + } + + if (!isWithinDateRange(transfer.createdAt, filters.range ?? '', now)) { + return false; + } + + return true; +} + +/** + * Newest-first ordering with id as the deterministic tie-breaker. + * @param {object[]} transfers + * @returns {object[]} + */ +export function stableSortTransfers(transfers) { + return (transfers ?? []).slice().sort((a, b) => { + const aTime = Date.parse(a?.createdAt ?? '') || 0; + const bTime = Date.parse(b?.createdAt ?? '') || 0; + if (bTime !== aTime) return bTime - aTime; + return String(b?.id ?? '').localeCompare(String(a?.id ?? '')); + }); +} + +/** + * Filter by actor + predicates, stable-sort, and cap. + * + * @param {object[]} transfers + * @param {{search?: string, status?: string, range?: string, actorId: string, limit?: number}} query + * @param {{now?: Date, legacyActorId?: string}} [options] + * @returns {{items: object[], totalMatched: number, capped: boolean, scopeKey: string}} + */ +export function applyTransferSearch(transfers, query, options = {}) { + const normalized = normalizeTransferQuery(query); + const now = options.now ?? new Date(); + const scoped = (transfers ?? []).filter((t) => + isVisibleToActor(t, normalized.actorId, { + legacyActorId: options.legacyActorId, + }), + ); + const matched = scoped.filter((t) => + matchesTransferFilters(t, normalized, now), + ); + const sorted = stableSortTransfers(matched); + const items = sorted.slice(0, normalized.limit); + + return { + items, + totalMatched: matched.length, + capped: matched.length > normalized.limit, + scopeKey: transferQueryScopeKey(normalized), + }; +} diff --git a/test/integration/transfers-filter.test.jsx b/test/integration/transfers-filter.test.jsx index fb9dead..8f15a12 100644 --- a/test/integration/transfers-filter.test.jsx +++ b/test/integration/transfers-filter.test.jsx @@ -112,8 +112,10 @@ describe('Transfers page filter sync', () => { await waitFor(() => { expect(window.location.search).toBe(''); }); - expect(screen.getByText(/amina@exam/)).toBeInTheDocument(); - expect(screen.getByText(/GBQAZ7Z3X7/)).toBeInTheDocument(); + await waitFor(() => { + expect(screen.getByText(/amina@exam/)).toBeInTheDocument(); + expect(screen.getByText(/GBQAZ7Z3X7/)).toBeInTheDocument(); + }); }); it('filters by date-range preset and syncs to URL', async () => { diff --git a/test/integration/transfers-search-scale.test.jsx b/test/integration/transfers-search-scale.test.jsx new file mode 100644 index 0000000..0265197 --- /dev/null +++ b/test/integration/transfers-search-scale.test.jsx @@ -0,0 +1,64 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import App from '../../src/App.jsx'; +import { DEMO_PUBLIC_KEY } from '../../src/services/wallet.js'; + +function buildHistory(count) { + return Array.from({ length: count }, (_, i) => ({ + id: `tx_${String(i).padStart(4, '0')}`, + recipient: + i === 7 + ? 'amina@example.com' + : `user${String(i).padStart(4, '0')}@example.com`, + from: 'USD', + to: 'NGN', + sendAmount: 10 + i, + receiveAmount: 1000 + i, + status: i % 2 === 0 ? 'completed' : 'pending', + createdAt: new Date(Date.UTC(2026, 5, 1, 12, 0, i)).toISOString(), + actorId: DEMO_PUBLIC_KEY, + })); +} + +describe('Transfers search at scale', () => { + beforeEach(() => { + window.history.pushState({}, '', '/transfers'); + localStorage.setItem( + 'remitflow.transfers', + JSON.stringify(buildHistory(120)), + ); + }); + + afterEach(() => { + localStorage.clear(); + }); + + it('keeps pagination responsive on a capped large history', async () => { + render(); + await screen.findByRole('heading', { name: /your transfers/i }); + await waitFor(() => { + expect(screen.getByLabelText(/search transfers/i)).toBeInTheDocument(); + }); + + // Page size is 5; capped list still exposes next page. + const next = await screen.findByRole('button', { name: /next/i }); + expect(next).toBeEnabled(); + }); + + it('debounces search into the URL and finds the matching recipient', async () => { + const user = userEvent.setup(); + render(); + await screen.findByRole('heading', { name: /your transfers/i }); + + await user.type(screen.getByLabelText(/search transfers/i), 'amina'); + + await waitFor( + () => { + expect(window.location.search).toContain('search=amina'); + expect(screen.getByText(/amina@exam/)).toBeInTheDocument(); + }, + { timeout: 4000 }, + ); + }); +}); diff --git a/test/unit/list-transfers-search.test.js b/test/unit/list-transfers-search.test.js new file mode 100644 index 0000000..77ff81e --- /dev/null +++ b/test/unit/list-transfers-search.test.js @@ -0,0 +1,139 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as api from '../../src/services/api.js'; +import { DEMO_PUBLIC_KEY } from '../../src/services/wallet.js'; + +const STORAGE_KEY = 'remitflow.transfers'; + +function seed(rows) { + localStorage.setItem(STORAGE_KEY, JSON.stringify(rows)); +} + +describe('listTransfers search / cancellation / scope', () => { + beforeEach(() => { + vi.useFakeTimers(); + localStorage.clear(); + }); + + afterEach(() => { + vi.useRealTimers(); + localStorage.clear(); + }); + + it('requires actor scope and hides other actors', async () => { + seed([ + { + id: 'tx_mine', + recipient: 'me@example.com', + from: 'USD', + to: 'NGN', + sendAmount: 10, + receiveAmount: 20, + status: 'completed', + createdAt: '2026-06-01T00:00:00Z', + actorId: DEMO_PUBLIC_KEY, + }, + { + id: 'tx_theirs', + recipient: 'them@example.com', + from: 'USD', + to: 'NGN', + sendAmount: 10, + receiveAmount: 20, + status: 'completed', + createdAt: '2026-06-02T00:00:00Z', + actorId: 'GOTHERWALLET', + }, + ]); + + const pending = api.listTransfers({ actorId: DEMO_PUBLIC_KEY }); + await vi.advanceTimersByTimeAsync(400); + const rows = await pending; + expect(rows.map((r) => r.id)).toEqual(['tx_mine']); + }); + + it('rejects when actorId is missing and no fallback is provided via empty string', async () => { + expect(() => api.listTransfers({ actorId: '' })).toThrow(/actorId/i); + }); + + it('cancels an obsolete in-flight query via AbortSignal', async () => { + seed([ + { + id: 'tx_1', + recipient: 'amina@example.com', + from: 'USD', + to: 'NGN', + sendAmount: 10, + receiveAmount: 20, + status: 'completed', + createdAt: '2026-06-01T00:00:00Z', + actorId: DEMO_PUBLIC_KEY, + }, + ]); + + const controller = new AbortController(); + const pending = api.listTransfers({ + actorId: DEMO_PUBLIC_KEY, + signal: controller.signal, + }); + controller.abort(); + await expect(pending).rejects.toMatchObject({ name: 'AbortError' }); + }); + + it('caps large result sets', async () => { + const rows = Array.from({ length: 250 }, (_, i) => ({ + id: `tx_${i}`, + recipient: `user${i}@example.com`, + from: 'USD', + to: 'NGN', + sendAmount: 10, + receiveAmount: 20, + status: 'completed', + createdAt: new Date(Date.UTC(2026, 5, 1, 0, 0, i)).toISOString(), + actorId: DEMO_PUBLIC_KEY, + })); + seed(rows); + + const pending = api.listTransfers({ + actorId: DEMO_PUBLIC_KEY, + limit: 40, + }); + await vi.advanceTimersByTimeAsync(400); + const result = await pending; + expect(result).toHaveLength(40); + }); + + it('applies search filter server-side', async () => { + seed([ + { + id: 'tx_1', + recipient: 'amina@example.com', + from: 'USD', + to: 'NGN', + sendAmount: 10, + receiveAmount: 20, + status: 'completed', + createdAt: '2026-06-01T00:00:00Z', + actorId: DEMO_PUBLIC_KEY, + }, + { + id: 'tx_2', + recipient: 'bola@example.com', + from: 'USD', + to: 'NGN', + sendAmount: 10, + receiveAmount: 20, + status: 'pending', + createdAt: '2026-06-02T00:00:00Z', + actorId: DEMO_PUBLIC_KEY, + }, + ]); + + const pending = api.listTransfers({ + actorId: DEMO_PUBLIC_KEY, + search: 'bola', + }); + await vi.advanceTimersByTimeAsync(400); + const result = await pending; + expect(result.map((r) => r.id)).toEqual(['tx_2']); + }); +}); diff --git a/test/unit/transfer-search.test.js b/test/unit/transfer-search.test.js new file mode 100644 index 0000000..b51b3c1 --- /dev/null +++ b/test/unit/transfer-search.test.js @@ -0,0 +1,160 @@ +import { describe, expect, it } from 'vitest'; +import { + DEFAULT_RESULT_CAP, + MAX_RESULT_CAP, + applyTransferSearch, + isVisibleToActor, + matchesTransferFilters, + normalizeTransferQuery, + requireActorId, + stableSortTransfers, + transferQueryScopeKey, +} from '../../src/utils/transferSearch.js'; + +const ACTOR = 'GACTOR_A'; +const OTHER = 'GACTOR_B'; +const LEGACY = 'GLEGACY'; + +function tx(overrides) { + return { + id: 'tx_1', + recipient: 'amina@example.com', + from: 'USD', + to: 'NGN', + sendAmount: '100', + receiveAmount: '1000', + status: 'completed', + createdAt: '2026-06-01T12:00:00Z', + actorId: ACTOR, + ...overrides, + }; +} + +describe('transferSearch scope and filters', () => { + it('requires a non-empty actorId on every query', () => { + expect(() => requireActorId('')).toThrow(/actorId is required/i); + expect(() => requireActorId(null)).toThrow(/actorId is required/i); + expect(requireActorId(' GXYZ ')).toBe('GXYZ'); + }); + + it('hides another actor’s transfers and migrates legacy rows safely', () => { + expect(isVisibleToActor(tx({ actorId: ACTOR }), ACTOR)).toBe(true); + expect(isVisibleToActor(tx({ actorId: OTHER }), ACTOR)).toBe(false); + expect( + isVisibleToActor(tx({ actorId: undefined }), LEGACY, { + legacyActorId: LEGACY, + }), + ).toBe(true); + expect( + isVisibleToActor(tx({ actorId: undefined }), ACTOR, { + legacyActorId: LEGACY, + }), + ).toBe(false); + }); + + it('matches status aliases, recipient search, and date range', () => { + const row = tx({ status: 'settled', createdAt: '2026-06-04T00:00:00Z' }); + const now = new Date('2026-06-05T12:00:00Z'); + expect(matchesTransferFilters(row, { status: 'completed' }, now)).toBe( + true, + ); + expect(matchesTransferFilters(row, { search: 'AMINA' }, now)).toBe(true); + expect(matchesTransferFilters(row, { search: 'nope' }, now)).toBe(false); + expect(matchesTransferFilters(row, { range: '7d' }, now)).toBe(true); + expect( + matchesTransferFilters( + tx({ createdAt: '2026-01-01T00:00:00Z' }), + { range: '7d' }, + now, + ), + ).toBe(false); + }); + + it('stable-sorts by createdAt desc with id as tie-breaker', () => { + const rows = [ + tx({ id: 'tx_a', createdAt: '2026-06-01T00:00:00Z' }), + tx({ id: 'tx_c', createdAt: '2026-06-02T00:00:00Z' }), + tx({ id: 'tx_b', createdAt: '2026-06-02T00:00:00Z' }), + ]; + expect(stableSortTransfers(rows).map((r) => r.id)).toEqual([ + 'tx_c', + 'tx_b', + 'tx_a', + ]); + }); + + it('caps results and reports totalMatched for large histories', () => { + const rows = Array.from({ length: 250 }, (_, i) => + tx({ + id: `tx_${String(i).padStart(4, '0')}`, + createdAt: new Date(Date.UTC(2026, 5, 1, 0, 0, i)).toISOString(), + }), + ); + const result = applyTransferSearch( + rows, + { actorId: ACTOR, limit: 50 }, + { legacyActorId: LEGACY }, + ); + expect(result.items).toHaveLength(50); + expect(result.totalMatched).toBe(250); + expect(result.capped).toBe(true); + expect(result.items[0].id > result.items[49].id).toBe(true); + }); + + it('normalizes and clamps the query limit', () => { + expect(normalizeTransferQuery({ actorId: ACTOR, limit: 9999 }).limit).toBe( + MAX_RESULT_CAP, + ); + expect(normalizeTransferQuery({ actorId: ACTOR }).limit).toBe( + DEFAULT_RESULT_CAP, + ); + }); + + it('builds a stable scope key so stale responses can be detected', () => { + const a = transferQueryScopeKey({ + actorId: ACTOR, + search: 'amina', + status: 'completed', + range: '7d', + limit: 100, + }); + const b = transferQueryScopeKey({ + actorId: ACTOR, + search: 'amina', + status: 'completed', + range: '7d', + limit: 100, + }); + const c = transferQueryScopeKey({ + actorId: ACTOR, + search: 'other', + status: 'completed', + range: '7d', + limit: 100, + }); + expect(a).toBe(b); + expect(a).not.toBe(c); + }); + + it('filters at scale within a tight budget', () => { + const rows = Array.from({ length: 5000 }, (_, i) => + tx({ + id: `tx_${i}`, + actorId: i % 3 === 0 ? OTHER : ACTOR, + recipient: i % 10 === 0 ? 'amina@example.com' : `user${i}@example.com`, + status: i % 2 === 0 ? 'completed' : 'pending', + createdAt: new Date(Date.UTC(2026, 0, 1 + (i % 150))).toISOString(), + }), + ); + const started = performance.now(); + const result = applyTransferSearch( + rows, + { actorId: ACTOR, search: 'amina', status: 'completed', limit: 100 }, + { now: new Date('2026-06-05T12:00:00Z'), legacyActorId: LEGACY }, + ); + const elapsed = performance.now() - started; + expect(result.items.length).toBeLessThanOrEqual(100); + expect(result.items.every((r) => r.actorId === ACTOR)).toBe(true); + expect(elapsed).toBeLessThan(100); + }); +}); diff --git a/test/unit/useTransfers-search-race.test.jsx b/test/unit/useTransfers-search-race.test.jsx new file mode 100644 index 0000000..b8ab290 --- /dev/null +++ b/test/unit/useTransfers-search-race.test.jsx @@ -0,0 +1,119 @@ +import { renderHook, waitFor, act } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useTransfers } from '../../src/hooks/useTransfers.js'; +import * as api from '../../src/services/api.js'; +import { DEMO_PUBLIC_KEY } from '../../src/services/wallet.js'; + +describe('useTransfers race and cancellation', () => { + beforeEach(() => { + localStorage.clear(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + localStorage.clear(); + }); + + it('does not let a stale slower response overwrite newer filters', async () => { + let resolveSlow; + const slow = new Promise((resolve) => { + resolveSlow = resolve; + }); + + const listSpy = vi + .spyOn(api, 'listTransfers') + .mockImplementation((opts) => { + if (opts.search === 'old') { + return slow.then(() => [ + { + id: 'tx_old', + recipient: 'old@example.com', + from: 'USD', + to: 'NGN', + sendAmount: '1', + receiveAmount: '2', + status: 'completed', + createdAt: '2026-06-01T00:00:00Z', + actorId: DEMO_PUBLIC_KEY, + }, + ]); + } + return Promise.resolve([ + { + id: 'tx_new', + recipient: 'new@example.com', + from: 'USD', + to: 'NGN', + sendAmount: '1', + receiveAmount: '2', + status: 'completed', + createdAt: '2026-06-02T00:00:00Z', + actorId: DEMO_PUBLIC_KEY, + }, + ]); + }); + + const { result, rerender } = renderHook( + ({ search }) => + useTransfers({ + actorId: DEMO_PUBLIC_KEY, + filters: { search }, + }), + { initialProps: { search: 'old' } }, + ); + + await waitFor(() => { + expect(listSpy).toHaveBeenCalled(); + }); + + rerender({ search: 'new' }); + + await waitFor(() => { + expect(result.current.transfers.map((t) => t.id)).toEqual(['tx_new']); + }); + + await act(async () => { + resolveSlow(); + await slow; + }); + + // Stale "old" payload must not clobber the current filter results. + expect(result.current.transfers.map((t) => t.id)).toEqual(['tx_new']); + }); + + it('aborts the previous request when filters change', async () => { + const signals = []; + vi.spyOn(api, 'listTransfers').mockImplementation((opts) => { + signals.push(opts.signal); + return new Promise(() => { + /* never resolves — cancellation is the assertion */ + }); + }); + + const { rerender } = renderHook( + ({ search }) => + useTransfers({ + actorId: DEMO_PUBLIC_KEY, + filters: { search }, + }), + { initialProps: { search: 'a' } }, + ); + + await waitFor(() => expect(signals.length).toBe(1)); + rerender({ search: 'ab' }); + await waitFor(() => expect(signals.length).toBe(2)); + expect(signals[0].aborted).toBe(true); + }); + + it('passes actorId on every list request', async () => { + const listSpy = vi.spyOn(api, 'listTransfers').mockResolvedValue([]); + renderHook(() => + useTransfers({ actorId: 'GCUSTOM', filters: { status: 'pending' } }), + ); + await waitFor(() => expect(listSpy).toHaveBeenCalled()); + expect(listSpy.mock.calls[0][0]).toMatchObject({ + actorId: 'GCUSTOM', + status: 'pending', + }); + }); +});