diff --git a/dashboard/src/pages/EventExplorerPage.tsx b/dashboard/src/pages/EventExplorerPage.tsx index 15971c79..e64af373 100644 --- a/dashboard/src/pages/EventExplorerPage.tsx +++ b/dashboard/src/pages/EventExplorerPage.tsx @@ -15,7 +15,12 @@ import { useFilteredEvents, } from '../hooks/useEventSelectors'; import { useEventStore } from '../store/eventStore'; -import { fetchEvents, fetchStatus, type ContractStatus } from '../services/eventsApi'; +import { + fetchEvents, + fetchStatus, + isListenerApiTimeoutError, + type ContractStatus, +} from '../services/eventsApi'; import { resolveIndexingHealthUrl } from '../services/indexingHealthApi'; import { resolveNotificationHealthUrl } from '../services/notificationHealthApi'; import { generateMockEvents } from '../utils/eventData'; @@ -108,7 +113,7 @@ export function EventExplorerPage() { setEvents(remoteEvents); markSyncSuccess(); } - } catch { + } catch (error) { if (!cancelled) { setEvents(generateMockEvents(DEFAULT_EVENT_COUNT)); setError('Listener API unavailable — showing mock events for demo.'); @@ -170,7 +175,7 @@ export function EventExplorerPage() { setEvents(remoteEvents); markSyncSuccess(); }) - .catch(() => { + .catch((error) => { setEvents(generateMockEvents(DEFAULT_EVENT_COUNT)); setError('Listener API unavailable — showing mock events for demo.'); markSyncFailure('Wallet refresh failed'); diff --git a/dashboard/src/pages/EventsPage.tsx b/dashboard/src/pages/EventsPage.tsx index b2572df8..b46598b9 100644 --- a/dashboard/src/pages/EventsPage.tsx +++ b/dashboard/src/pages/EventsPage.tsx @@ -6,7 +6,7 @@ import { WalletConnectButton } from '../components/WalletConnectButton'; import { getEventsApiBaseUrl } from '../config/eventsApiUrl'; import { useEventLoadingState } from '../hooks/useEventSelectors'; import { useEventStore } from '../store/eventStore'; -import { fetchEvents } from '../services/eventsApi'; +import { fetchEvents, isListenerApiTimeoutError } from '../services/eventsApi'; import { generateMockEvents } from '../utils/eventData'; import { restoreWalletSession } from '../services/wallet'; @@ -50,7 +50,7 @@ export function EventsPage() { setEvents(remoteEvents); markSyncSuccess(); } - } catch { + } catch (error) { if (!cancelled) { setEvents(generateMockEvents(DEFAULT_EVENT_COUNT)); setError('Listener API unavailable — showing mock events for demo.'); diff --git a/dashboard/src/pages/NotificationSearchPage.test.tsx b/dashboard/src/pages/NotificationSearchPage.test.tsx index 1cfb2603..701bacbc 100644 --- a/dashboard/src/pages/NotificationSearchPage.test.tsx +++ b/dashboard/src/pages/NotificationSearchPage.test.tsx @@ -179,33 +179,25 @@ describe('NotificationSearchPage filters', () => { expect(screen.getByLabelText(/filter from date/i)).toHaveValue(''); expect(screen.queryByRole('button', { name: /clear all filters/i })).not.toBeInTheDocument(); }); -}); -describe('searchNotifications query params', () => { - const originalFetch = global.fetch; + it('shows a friendly timeout message when the listener search request expires', async () => { + mockedSearch.mockRejectedValue(new Error('Listener API request timed out after 10000ms.')); - beforeEach(() => { - global.fetch = jest.fn().mockResolvedValue({ - ok: true, - json: async () => emptyResponse(), - }); - }); + render(); - afterEach(() => { - global.fetch = originalFetch; - }); + fireEvent.change(screen.getByLabelText(/free-text search/i), { + target: { value: 'payment' }, + }); it('appends type, status, startDate, and endDate to the URL', async () => { const { searchNotifications: realSearch } = jest.requireActual( '../services/eventsApi', ) as typeof import('../services/eventsApi'); - await realSearch('http://localhost:8787', { - type: 'webhook', - status: 'COMPLETED', - startDate: '2026-01-01', - endDate: '2026-01-31', + await waitFor(() => { + expect(screen.getByText(/The notification search timed out\. Please try again\./i)).toBeInTheDocument(); }); + }); expect(global.fetch).toHaveBeenCalledWith(expect.stringContaining('type=webhook')); const calledUrl = (global.fetch as jest.Mock).mock.calls[0][0] as string; diff --git a/dashboard/src/pages/NotificationSearchPage.tsx b/dashboard/src/pages/NotificationSearchPage.tsx index 20b952ad..cd4fd540 100644 --- a/dashboard/src/pages/NotificationSearchPage.tsx +++ b/dashboard/src/pages/NotificationSearchPage.tsx @@ -6,6 +6,7 @@ import { useDebounce } from '../hooks/useDebounce'; import { EmptyState } from '../components/EmptyState'; import { CopyButton } from '../components/CopyButton'; import { + isListenerApiTimeoutError, searchNotifications, type NotificationSearchResult, type NotificationSearchResponse, @@ -125,7 +126,13 @@ export function NotificationSearchPage() { setResponse(result); } catch (err: unknown) { if (err instanceof Error && err.name === 'AbortError') return; - setError(err instanceof Error ? err.message : 'Search failed'); + setError( + isListenerApiTimeoutError(err) + ? 'The notification search timed out. Please try again.' + : err instanceof Error + ? err.message + : 'Search failed' + ); } finally { setLoading(false); } diff --git a/dashboard/src/services/eventsApi.timeout.test.ts b/dashboard/src/services/eventsApi.timeout.test.ts new file mode 100644 index 00000000..f692b21a --- /dev/null +++ b/dashboard/src/services/eventsApi.timeout.test.ts @@ -0,0 +1,85 @@ +import { fetchEvents, searchNotifications } from './eventsApi'; + +describe('listener API request timeouts', () => { + beforeEach(() => { + jest.useFakeTimers(); + Object.defineProperty(globalThis, 'fetch', { + writable: true, + configurable: true, + value: jest.fn(), + }); + }); + + afterEach(() => { + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + it('rejects fetchEvents when the listener API takes too long to respond', async () => { + const fetchMock = globalThis.fetch as jest.MockedFunction; + fetchMock.mockImplementation( + (_input: RequestInfo | URL, init?: RequestInit) => + new Promise((resolve, reject) => { + const signal = init?.signal; + if (signal) { + signal.addEventListener( + 'abort', + () => { + const abortError = new Error('The operation was aborted.'); + Object.assign(abortError, { name: 'AbortError' }); + reject(abortError); + }, + { once: true } + ); + } + + setTimeout(() => { + resolve({ + ok: true, + json: async () => ({ events: [] }), + } as Response); + }, 30_000); + }) + ); + + const request = expect(fetchEvents('http://localhost:8787/api/events')).rejects.toThrow(/timed out/i); + + await jest.advanceTimersByTimeAsync(10_001); + + await request; + }); + + it('rejects searchNotifications when the request exceeds the dashboard timeout', async () => { + const fetchMock = globalThis.fetch as jest.MockedFunction; + fetchMock.mockImplementation( + (_input: RequestInfo | URL, init?: RequestInit) => + new Promise((resolve, reject) => { + const signal = init?.signal; + if (signal) { + signal.addEventListener( + 'abort', + () => { + const abortError = new Error('The operation was aborted.'); + Object.assign(abortError, { name: 'AbortError' }); + reject(abortError); + }, + { once: true } + ); + } + + setTimeout(() => { + resolve({ + ok: true, + json: async () => ({ results: [], total: 0, limit: 20, offset: 0, itemCount: 0, totalPages: 0 }), + } as Response); + }, 30_000); + }) + ); + + const request = expect(searchNotifications('http://localhost:8787', { q: 'hello' })).rejects.toThrow(/timed out/i); + + await jest.advanceTimersByTimeAsync(10_001); + + await request; + }); +}); diff --git a/dashboard/src/services/eventsApi.ts b/dashboard/src/services/eventsApi.ts index 82f892c7..f7d6776f 100644 --- a/dashboard/src/services/eventsApi.ts +++ b/dashboard/src/services/eventsApi.ts @@ -74,8 +74,52 @@ export interface NotificationSearchParams { sortBy?: 'newest' | 'oldest' | 'status'; } +export const LISTENER_API_TIMEOUT_MS = 10_000; + +export function isListenerApiTimeoutError(error: unknown): boolean { + return error instanceof Error && /timed out|timeout/i.test(error.message); +} + +async function fetchWithTimeout( + input: RequestInfo | URL, + init: RequestInit = {}, + timeoutMs = LISTENER_API_TIMEOUT_MS +): Promise { + const controller = new AbortController(); + const signal = init.signal; + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + + let abortListener: (() => void) | null = null; + if (signal) { + if (signal.aborted) { + controller.abort(); + } else { + abortListener = () => controller.abort(); + signal.addEventListener('abort', abortListener, { once: true }); + } + } + + try { + return await fetch(input, { + ...init, + signal: controller.signal, + }); + } catch (error) { + const err = error as { name?: string }; + if (controller.signal.aborted && err?.name === 'AbortError') { + throw new Error(`Listener API request timed out after ${timeoutMs}ms.`); + } + throw error; + } finally { + clearTimeout(timeoutId); + if (abortListener && signal) { + signal.removeEventListener('abort', abortListener); + } + } +} + export async function fetchEvents(apiUrl: string): Promise { - const response = await fetch(apiUrl); + const response = await fetchWithTimeout(apiUrl); if (!response.ok) { throw new Error(`Failed to fetch events: ${response.status}`); }