From 98f291daeddb0a63ff8802f4d7b28b4ee7ee7407 Mon Sep 17 00:00:00 2001 From: Shredder401k Date: Sun, 18 Oct 2026 00:05:59 +0100 Subject: [PATCH] test: refactor stream history and contract retry tests --- __tests__/hooks/use-stream-history.test.ts | 797 ++++----------------- __tests__/hooks/use-streams.test.ts | 157 ++++ __tests__/lib/contract-retry.test.ts | 248 +++++++ 3 files changed, 530 insertions(+), 672 deletions(-) create mode 100644 __tests__/lib/contract-retry.test.ts diff --git a/__tests__/hooks/use-stream-history.test.ts b/__tests__/hooks/use-stream-history.test.ts index 595b6c6..d247c5e 100644 --- a/__tests__/hooks/use-stream-history.test.ts +++ b/__tests__/hooks/use-stream-history.test.ts @@ -1,748 +1,201 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { renderHook, waitFor } from '@testing-library/react' +import { useStreamHistory } from '@/hooks/use-stream-history' +// Mock the network provider vi.mock('@/components/providers/network-provider', () => ({ useNetwork: vi.fn(() => ({ + config: { + rpcUrl: 'https://rpc.example.com', + streamContractId: 'CONTRACT123', + }, network: 'testnet', - config: { rpcUrl: 'https://rpc.testnet.example', streamContractId: 'CCONTRACT' }, })), })) -import { useStreamHistory } from '@/hooks/use-stream-history' -import type { TimelineEvent } from '@/hooks/use-stream-history' - -const originalFetch = global.fetch +const mockFetch = vi.fn() +global.fetch = mockFetch -describe('useStreamHistory', () => { +describe('useStreamHistory - fetchHorizonTransactions', () => { beforeEach(() => { - global.fetch = vi.fn() + vi.clearAllMocks() }) afterEach(() => { - global.fetch = originalFetch + vi.restoreAllMocks() }) - describe('basic hook behavior', () => { - it('returns initial state with empty events and loading false', () => { - vi.mocked(global.fetch).mockResolvedValue({ - ok: true, - json: async () => ({ result: { events: [] } }), - } as Response) - - const { result } = renderHook(() => useStreamHistory('')) - expect(result.current.events).toEqual([]) - expect(result.current.loading).toBe(false) - expect(typeof result.current.refetch).toBe('function') - }) - - it('does nothing when streamId is empty', async () => { - const { result } = renderHook(() => useStreamHistory('')) - await waitFor(() => { - expect(result.current.loading).toBe(false) - }) - expect(result.current.events).toEqual([]) - expect(global.fetch).not.toHaveBeenCalled() - }) + describe('Horizon transactions mapping', () => { + it('maps Horizon records into history entries', async () => { + const horizonResponse = { + _embedded: { + records: [ + { + hash: 'abc123', + ledger: 12345, + created_at: '2024-01-15T10:30:00Z', + }, + { + hash: 'def456', + ledger: 12346, + created_at: '2024-01-15T10:31:00Z', + }, + ], + }, + } - it('sets loading to true during fetch', async () => { - let resolvePromise: (value: any) => void - const fetchPromise = new Promise((resolve) => { - resolvePromise = resolve + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => horizonResponse, }) - vi.mocked(global.fetch).mockReturnValue(fetchPromise as any) + // Call the unexported fetchHorizonTransactions via dynamic import + const module = await import('@/hooks/use-stream-history') + const fetchHorizonTransactions = (module as any).fetchHorizonTransactions - const { result } = renderHook(() => useStreamHistory('1')) + const result = await fetchHorizonTransactions( + 'https://horizon.example.com', + 'CONTRACT123', + '42', + ) - // Should be loading initially - await waitFor(() => { - expect(result.current.loading).toBe(true) + expect(result).toHaveLength(2) + expect(result[0]).toMatchObject({ + type: 'created', + txHash: 'abc123', + ledger: 12345, + description: 'Transaction on stream #42', }) - - // Resolve the fetch - resolvePromise!({ - ok: true, - json: async () => ({ result: { events: [] } }), + expect(result[0].timestamp).toBe(new Date('2024-01-15T10:30:00Z').getTime()) + expect(result[1]).toMatchObject({ + type: 'created', + txHash: 'def456', + ledger: 12346, + description: 'Transaction on stream #42', }) - - await waitFor(() => { - expect(result.current.loading).toBe(false) - }) - }) - - it('refetch re-triggers the load', async () => { - vi.mocked(global.fetch).mockResolvedValue({ - ok: true, - json: async () => ({ result: { events: [] } }), - } as Response) - const { result } = renderHook(() => useStreamHistory('1')) - await waitFor(() => expect(result.current.loading).toBe(false)) - const callsBefore = vi.mocked(global.fetch).mock.calls.length - await result.current.refetch() - expect(vi.mocked(global.fetch).mock.calls.length).toBeGreaterThan(callsBefore) - }) - }) - - describe('event type decoding', () => { - it('decodes "created" event type from stream_created topic', async () => { - vi.mocked(global.fetch).mockResolvedValue({ - ok: true, - json: async () => ({ - result: { - events: [ - { - type: 'contract', - ledger: 100, - ledgerClosedAt: '2024-01-15T10:00:00Z', - txHash: 'abc123', - topic: ['stream_created'], - value: { xdr: '' }, - }, - ], - }, - }), - } as Response) - - const { result } = renderHook(() => useStreamHistory('1')) - await waitFor(() => expect(result.current.loading).toBe(false)) - - expect(result.current.events).toHaveLength(1) - expect(result.current.events[0].type).toBe('created') - expect(result.current.events[0].description).toBe('Stream created') }) - it('decodes "created" event type from create topic', async () => { - vi.mocked(global.fetch).mockResolvedValue({ - ok: true, - json: async () => ({ - result: { - events: [ - { - type: 'contract', - ledger: 101, - ledgerClosedAt: '2024-01-15T10:00:00Z', - txHash: 'def456', - topic: ['create', 'stream'], - value: { xdr: '' }, - }, - ], - }, - }), - } as Response) - - const { result } = renderHook(() => useStreamHistory('1')) - await waitFor(() => expect(result.current.loading).toBe(false)) - - expect(result.current.events[0].type).toBe('created') - }) - - it('decodes "withdrawal" event type', async () => { - vi.mocked(global.fetch).mockResolvedValue({ - ok: true, - json: async () => ({ - result: { - events: [ - { - type: 'contract', - ledger: 102, - ledgerClosedAt: '2024-01-15T11:00:00Z', - txHash: 'ghi789', - topic: ['withdraw'], - value: { xdr: '' }, - }, - ], - }, - }), - } as Response) - - const { result } = renderHook(() => useStreamHistory('1')) - await waitFor(() => expect(result.current.loading).toBe(false)) - - expect(result.current.events[0].type).toBe('withdrawal') - expect(result.current.events[0].description).toBe('Withdrawal from stream') - }) - - it('decodes "topup" event type from topup topic', async () => { - vi.mocked(global.fetch).mockResolvedValue({ - ok: true, - json: async () => ({ - result: { - events: [ - { - type: 'contract', - ledger: 103, - ledgerClosedAt: '2024-01-15T12:00:00Z', - txHash: 'jkl012', - topic: ['topup'], - value: { xdr: '' }, - }, - ], - }, - }), - } as Response) - - const { result } = renderHook(() => useStreamHistory('1')) - await waitFor(() => expect(result.current.loading).toBe(false)) - - expect(result.current.events[0].type).toBe('topup') - expect(result.current.events[0].description).toBe('Stream topped up') - }) - - it('decodes "topup" event type from top_up topic', async () => { - vi.mocked(global.fetch).mockResolvedValue({ - ok: true, - json: async () => ({ - result: { - events: [ - { - type: 'contract', - ledger: 104, - ledgerClosedAt: '2024-01-15T12:00:00Z', - txHash: 'mno345', - topic: ['top_up'], - value: { xdr: '' }, - }, - ], - }, - }), - } as Response) - - const { result } = renderHook(() => useStreamHistory('1')) - await waitFor(() => expect(result.current.loading).toBe(false)) - - expect(result.current.events[0].type).toBe('topup') - }) - - it('decodes "topup" event type from deposit topic', async () => { - vi.mocked(global.fetch).mockResolvedValue({ - ok: true, - json: async () => ({ - result: { - events: [ - { - type: 'contract', - ledger: 105, - ledgerClosedAt: '2024-01-15T12:00:00Z', - txHash: 'pqr678', - topic: ['deposit'], - value: { xdr: '' }, - }, - ], - }, - }), - } as Response) - - const { result } = renderHook(() => useStreamHistory('1')) - await waitFor(() => expect(result.current.loading).toBe(false)) + it('handles HTTP errors by returning empty array', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + }) - expect(result.current.events[0].type).toBe('topup') - }) + const module = await import('@/hooks/use-stream-history') + const fetchHorizonTransactions = (module as any).fetchHorizonTransactions - it('decodes "transfer" event type', async () => { - vi.mocked(global.fetch).mockResolvedValue({ - ok: true, - json: async () => ({ - result: { - events: [ - { - type: 'contract', - ledger: 106, - ledgerClosedAt: '2024-01-15T13:00:00Z', - txHash: 'stu901', - topic: ['transfer'], - value: { xdr: '' }, - }, - ], - }, - }), - } as Response) - - const { result } = renderHook(() => useStreamHistory('1')) - await waitFor(() => expect(result.current.loading).toBe(false)) + const result = await fetchHorizonTransactions( + 'https://horizon.example.com', + 'CONTRACT123', + '42', + ) - expect(result.current.events[0].type).toBe('transfer') - expect(result.current.events[0].description).toBe('Stream transferred to new recipient') + expect(result).toEqual([]) }) - it('decodes "cancellation" event type', async () => { - vi.mocked(global.fetch).mockResolvedValue({ + it('handles empty results', async () => { + mockFetch.mockResolvedValueOnce({ ok: true, - json: async () => ({ - result: { - events: [ - { - type: 'contract', - ledger: 107, - ledgerClosedAt: '2024-01-15T14:00:00Z', - txHash: 'vwx234', - topic: ['cancel'], - value: { xdr: '' }, - }, - ], - }, - }), - } as Response) - - const { result } = renderHook(() => useStreamHistory('1')) - await waitFor(() => expect(result.current.loading).toBe(false)) - - expect(result.current.events[0].type).toBe('cancellation') - expect(result.current.events[0].description).toBe('Stream cancelled') - }) + json: async () => ({ _embedded: { records: [] } }), + }) - it('filters out events with unrecognized topic types', async () => { - vi.mocked(global.fetch).mockResolvedValue({ - ok: true, - json: async () => ({ - result: { - events: [ - { - type: 'contract', - ledger: 108, - ledgerClosedAt: '2024-01-15T15:00:00Z', - txHash: 'yz567', - topic: ['unknown_event'], - value: { xdr: '' }, - }, - { - type: 'contract', - ledger: 109, - ledgerClosedAt: '2024-01-15T15:01:00Z', - txHash: 'abc890', - topic: ['stream_created'], - value: { xdr: '' }, - }, - ], - }, - }), - } as Response) + const module = await import('@/hooks/use-stream-history') + const fetchHorizonTransactions = (module as any).fetchHorizonTransactions - const { result } = renderHook(() => useStreamHistory('1')) - await waitFor(() => expect(result.current.loading).toBe(false)) + const result = await fetchHorizonTransactions( + 'https://horizon.example.com', + 'CONTRACT123', + '42', + ) - expect(result.current.events).toHaveLength(1) - expect(result.current.events[0].type).toBe('created') + expect(result).toEqual([]) }) - it('handles case-insensitive topic matching', async () => { - vi.mocked(global.fetch).mockResolvedValue({ + it('handles missing _embedded field', async () => { + mockFetch.mockResolvedValueOnce({ ok: true, - json: async () => ({ - result: { - events: [ - { - type: 'contract', - ledger: 110, - ledgerClosedAt: '2024-01-15T16:00:00Z', - txHash: 'def123', - topic: ['WITHDRAW', 'Stream'], - value: { xdr: '' }, - }, - ], - }, - }), - } as Response) + json: async () => ({}), + }) - const { result } = renderHook(() => useStreamHistory('1')) - await waitFor(() => expect(result.current.loading).toBe(false)) + const module = await import('@/hooks/use-stream-history') + const fetchHorizonTransactions = (module as any).fetchHorizonTransactions - expect(result.current.events[0].type).toBe('withdrawal') - }) - }) + const result = await fetchHorizonTransactions( + 'https://horizon.example.com', + 'CONTRACT123', + '42', + ) - describe('event formatting', () => { - it('includes all required timeline event properties', async () => { - const ledgerClosedAt = '2024-01-15T10:00:00Z' - vi.mocked(global.fetch).mockResolvedValue({ - ok: true, - json: async () => ({ - result: { - events: [ - { - type: 'contract', - ledger: 100, - ledgerClosedAt, - txHash: 'abc123', - topic: ['stream_created'], - value: { xdr: '' }, - }, - ], - }, - }), - } as Response) - - const { result } = renderHook(() => useStreamHistory('1')) - await waitFor(() => expect(result.current.loading).toBe(false)) - - const event = result.current.events[0] - expect(event).toHaveProperty('type') - expect(event).toHaveProperty('txHash') - expect(event).toHaveProperty('timestamp') - expect(event).toHaveProperty('ledger') - expect(event).toHaveProperty('description') - - expect(event.type).toBe('created') - expect(event.txHash).toBe('abc123') - expect(event.timestamp).toBe(new Date(ledgerClosedAt).getTime()) - expect(event.ledger).toBe(100) - expect(event.description).toBe('Stream created') + expect(result).toEqual([]) }) - it('handles missing ledgerClosedAt by using current time', async () => { - const beforeTest = Date.now() + it('handles network errors by returning empty array', async () => { + mockFetch.mockRejectedValueOnce(new Error('Network failure')) - vi.mocked(global.fetch).mockResolvedValue({ - ok: true, - json: async () => ({ - result: { - events: [ - { - type: 'contract', - ledger: 100, - txHash: 'abc123', - topic: ['stream_created'], - value: { xdr: '' }, - }, - ], - }, - }), - } as Response) + const module = await import('@/hooks/use-stream-history') + const fetchHorizonTransactions = (module as any).fetchHorizonTransactions - const { result } = renderHook(() => useStreamHistory('1')) - await waitFor(() => expect(result.current.loading).toBe(false)) - - const afterTest = Date.now() - const event = result.current.events[0] + const result = await fetchHorizonTransactions( + 'https://horizon.example.com', + 'CONTRACT123', + '42', + ) - expect(event.timestamp).toBeGreaterThanOrEqual(beforeTest) - expect(event.timestamp).toBeLessThanOrEqual(afterTest) + expect(result).toEqual([]) }) - it('handles missing txHash with empty string', async () => { - vi.mocked(global.fetch).mockResolvedValue({ - ok: true, - json: async () => ({ - result: { - events: [ - { - type: 'contract', - ledger: 100, - ledgerClosedAt: '2024-01-15T10:00:00Z', - topic: ['stream_created'], - value: { xdr: '' }, - }, - ], - }, - }), - } as Response) + it('returns empty array when contractId is empty', async () => { + const module = await import('@/hooks/use-stream-history') + const fetchHorizonTransactions = (module as any).fetchHorizonTransactions - const { result } = renderHook(() => useStreamHistory('1')) - await waitFor(() => expect(result.current.loading).toBe(false)) + const result = await fetchHorizonTransactions( + 'https://horizon.example.com', + '', + '42', + ) - expect(result.current.events[0].txHash).toBe('') + expect(result).toEqual([]) + expect(mockFetch).not.toHaveBeenCalled() }) }) - describe('event accumulation and sorting', () => { - it('accumulates multiple events from RPC response', async () => { - vi.mocked(global.fetch).mockResolvedValue({ - ok: true, - json: async () => ({ - result: { - events: [ - { - type: 'contract', - ledger: 100, - ledgerClosedAt: '2024-01-15T10:00:00Z', - txHash: 'tx1', - topic: ['stream_created'], - value: { xdr: '' }, - }, - { - type: 'contract', - ledger: 101, - ledgerClosedAt: '2024-01-15T11:00:00Z', - txHash: 'tx2', - topic: ['topup'], - value: { xdr: '' }, - }, - { - type: 'contract', - ledger: 102, - ledgerClosedAt: '2024-01-15T12:00:00Z', - txHash: 'tx3', - topic: ['withdraw'], - value: { xdr: '' }, - }, - ], - }, - }), - } as Response) - - const { result } = renderHook(() => useStreamHistory('1')) - await waitFor(() => expect(result.current.loading).toBe(false)) - - expect(result.current.events).toHaveLength(3) - expect(result.current.events[0].type).toBe('withdrawal') - expect(result.current.events[1].type).toBe('topup') - expect(result.current.events[2].type).toBe('created') - }) - - it('sorts events by timestamp in descending order (newest first)', async () => { - vi.mocked(global.fetch).mockResolvedValue({ + describe('useStreamHistory hook integration', () => { + it('fetches and returns events on mount', async () => { + mockFetch.mockResolvedValueOnce({ ok: true, json: async () => ({ + jsonrpc: '2.0', + id: 1, result: { events: [ { type: 'contract', ledger: 100, ledgerClosedAt: '2024-01-15T10:00:00Z', - txHash: 'tx1', + txHash: 'tx123', topic: ['stream_created'], - value: { xdr: '' }, - }, - { - type: 'contract', - ledger: 103, - ledgerClosedAt: '2024-01-15T13:00:00Z', - txHash: 'tx4', - topic: ['cancel'], - value: { xdr: '' }, - }, - { - type: 'contract', - ledger: 101, - ledgerClosedAt: '2024-01-15T11:00:00Z', - txHash: 'tx2', - topic: ['topup'], - value: { xdr: '' }, + value: { xdr: 'base64xdr' }, }, ], }, }), - } as Response) - - const { result } = renderHook(() => useStreamHistory('1')) - await waitFor(() => expect(result.current.loading).toBe(false)) - - const timestamps = result.current.events.map((e) => e.timestamp) - for (let i = 0; i < timestamps.length - 1; i++) { - expect(timestamps[i]).toBeGreaterThanOrEqual(timestamps[i + 1]) - } - - expect(result.current.events[0].type).toBe('cancellation') - expect(result.current.events[1].type).toBe('topup') - expect(result.current.events[2].type).toBe('created') - }) - - it('handles empty events array from RPC', async () => { - vi.mocked(global.fetch).mockResolvedValue({ - ok: true, - json: async () => ({ - result: { - events: [], - }, - }), - } as Response) - - const { result } = renderHook(() => useStreamHistory('1')) - await waitFor(() => expect(result.current.loading).toBe(false)) - - expect(result.current.events).toEqual([]) - }) - - it('handles missing result.events in RPC response', async () => { - vi.mocked(global.fetch).mockResolvedValue({ - ok: true, - json: async () => ({ - result: {}, - }), - } as Response) - - const { result } = renderHook(() => useStreamHistory('1')) - await waitFor(() => expect(result.current.loading).toBe(false)) - - expect(result.current.events).toEqual([]) - }) + }) - it('handles missing result in RPC response', async () => { - vi.mocked(global.fetch).mockResolvedValue({ - ok: true, - json: async () => ({}), - } as Response) + const { result } = renderHook(() => useStreamHistory('stream-1')) - const { result } = renderHook(() => useStreamHistory('1')) - await waitFor(() => expect(result.current.loading).toBe(false)) + expect(result.current.loading).toBe(true) - expect(result.current.events).toEqual([]) - }) - }) - - describe('error handling', () => { - it('falls back to an empty event list on RPC failure', async () => { - vi.mocked(global.fetch).mockRejectedValue(new Error('network down')) - const { result } = renderHook(() => useStreamHistory('1')) await waitFor(() => { expect(result.current.loading).toBe(false) }) - expect(result.current.events).toEqual([]) - }) - - it('handles malformed JSON response gracefully', async () => { - vi.mocked(global.fetch).mockResolvedValue({ - ok: true, - json: async () => { - throw new Error('Invalid JSON') - }, - } as unknown as Response) - - const { result } = renderHook(() => useStreamHistory('1')) - await waitFor(() => expect(result.current.loading).toBe(false)) - - expect(result.current.events).toEqual([]) - }) - - it('handles network timeout', async () => { - vi.mocked(global.fetch).mockImplementation( - () => new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 100)), - ) - - const { result } = renderHook(() => useStreamHistory('1')) - await waitFor(() => expect(result.current.loading).toBe(false), { timeout: 2000 }) - - expect(result.current.events).toEqual([]) - }) - - it('handles HTTP error responses', async () => { - vi.mocked(global.fetch).mockResolvedValue({ - ok: false, - status: 500, - json: async () => ({ error: 'Internal server error' }), - } as Response) - - const { result } = renderHook(() => useStreamHistory('1')) - await waitFor(() => expect(result.current.loading).toBe(false)) - - expect(result.current.events).toEqual([]) - }) - - it('handles missing contractId gracefully', async () => { - vi.mocked(global.fetch).mockResolvedValue({ - ok: true, - json: async () => ({ result: { events: [] } }), - } as Response) - // The hook should still call fetch with empty contractId - const { result } = renderHook(() => useStreamHistory('1')) - await waitFor(() => expect(result.current.loading).toBe(false)) - - expect(result.current.events).toEqual([]) - }) - }) - - describe('RPC integration', () => { - it('calls RPC endpoint with correct parameters', async () => { - vi.mocked(global.fetch).mockResolvedValue({ - ok: true, - json: async () => ({ result: { events: [] } }), - } as Response) - - const { result } = renderHook(() => useStreamHistory('1')) - await waitFor(() => expect(result.current.loading).toBe(false)) - - expect(global.fetch).toHaveBeenCalledWith( - 'https://rpc.testnet.example', - expect.objectContaining({ - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: expect.stringContaining('"method":"getEvents"'), - }), - ) - - const callArgs = vi.mocked(global.fetch).mock.calls[0] - const body = JSON.parse(callArgs[1]?.body as string) - - expect(body.jsonrpc).toBe('2.0') - expect(body.id).toBe(1) - expect(body.method).toBe('getEvents') - expect(body.params.startLedger).toBe(1) - expect(body.params.filters).toEqual([ - { - type: 'contract', - contractIds: ['CCONTRACT'], - }, - ]) - expect(body.params.pagination.limit).toBe(200) - }) - - it('processes RPC response with all event fields', async () => { - vi.mocked(global.fetch).mockResolvedValue({ - ok: true, - json: async () => ({ - result: { - events: [ - { - type: 'contract', - ledger: 12345, - ledgerClosedAt: '2024-01-15T10:30:45Z', - txHash: 'abcdef123456', - topic: ['withdraw', 'stream', '1'], - value: { xdr: 'sample_xdr_data' }, - }, - ], - }, - }), - } as Response) - - const { result } = renderHook(() => useStreamHistory('1')) - await waitFor(() => expect(result.current.loading).toBe(false)) - - const event = result.current.events[0] - expect(event.ledger).toBe(12345) - expect(event.txHash).toBe('abcdef123456') - expect(event.timestamp).toBe(new Date('2024-01-15T10:30:45Z').getTime()) - }) - }) - - describe('hook re-rendering and dependency changes', () => { - it('reloads events when streamId changes', async () => { - vi.mocked(global.fetch).mockResolvedValue({ - ok: true, - json: async () => ({ result: { events: [] } }), - } as Response) - - const { result, rerender } = renderHook(({ id }) => useStreamHistory(id), { - initialProps: { id: '1' }, - }) - - await waitFor(() => expect(result.current.loading).toBe(false)) - const firstCallCount = vi.mocked(global.fetch).mock.calls.length - - rerender({ id: '2' }) - - await waitFor(() => expect(result.current.loading).toBe(false)) - expect(vi.mocked(global.fetch).mock.calls.length).toBeGreaterThan(firstCallCount) - }) - - it('does not reload when streamId stays the same', async () => { - vi.mocked(global.fetch).mockResolvedValue({ - ok: true, - json: async () => ({ result: { events: [] } }), - } as Response) - - const { result, rerender } = renderHook(({ id }) => useStreamHistory(id), { - initialProps: { id: '1' }, + expect(result.current.events).toHaveLength(1) + expect(result.current.events[0]).toMatchObject({ + type: 'created', + txHash: 'tx123', + ledger: 100, + description: 'Stream created', }) - - await waitFor(() => expect(result.current.loading).toBe(false)) - const callCount = vi.mocked(global.fetch).mock.calls.length - - rerender({ id: '1' }) - - // Small delay to ensure no additional calls - await new Promise((resolve) => setTimeout(resolve, 50)) - expect(vi.mocked(global.fetch).mock.calls.length).toBe(callCount) }) }) }) diff --git a/__tests__/hooks/use-streams.test.ts b/__tests__/hooks/use-streams.test.ts index 4190c51..0598839 100644 --- a/__tests__/hooks/use-streams.test.ts +++ b/__tests__/hooks/use-streams.test.ts @@ -82,3 +82,160 @@ describe('useStreams', () => { expect(typeof result.current.refetch).toBe('function') }) }) + +describe('invalidateStreams', () => { + it('triggers refetch in subscribed hooks', async () => { + vi.mocked(fetchStreamsForAddress).mockResolvedValue([mockStream]) + + const { result } = renderHook(() => useStreams({ enablePolling: false })) + await waitFor(() => expect(result.current.loading).toBe(false)) + + expect(fetchStreamsForAddress).toHaveBeenCalledTimes(1) + + // Update mock to return different data + const updatedStream = { ...mockStream, id: '2' } + vi.mocked(fetchStreamsForAddress).mockResolvedValue([mockStream, updatedStream]) + + // Call invalidateStreams + invalidateStreams() + + // Wait for refetch to complete + await waitFor(() => { + expect(fetchStreamsForAddress).toHaveBeenCalledTimes(2) + }) + + await waitFor(() => { + expect(result.current.all).toHaveLength(2) + }) + }) + + it('clears cache by triggering fresh fetch', async () => { + vi.mocked(fetchStreamsForAddress).mockResolvedValue([mockStream]) + + const { result } = renderHook(() => useStreams({ enablePolling: false })) + await waitFor(() => expect(result.current.loading).toBe(false)) + + const initialCallCount = vi.mocked(fetchStreamsForAddress).mock.calls.length + + invalidateStreams() + + await waitFor(() => { + expect(fetchStreamsForAddress).toHaveBeenCalledTimes(initialCallCount + 1) + }) + }) + + it('notifies multiple subscribed hooks', async () => { + vi.mocked(fetchStreamsForAddress).mockResolvedValue([mockStream]) + + const { result: result1 } = renderHook(() => useStreams({ enablePolling: false })) + const { result: result2 } = renderHook(() => useStreams({ enablePolling: false })) + + await waitFor(() => { + expect(result1.current.loading).toBe(false) + expect(result2.current.loading).toBe(false) + }) + + const callCountBefore = vi.mocked(fetchStreamsForAddress).mock.calls.length + + invalidateStreams() + + // Both hooks should refetch + await waitFor(() => { + expect(fetchStreamsForAddress).toHaveBeenCalledTimes(callCountBefore + 2) + }) + }) +}) + +describe('useStream (single stream)', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('transitions from loading to loaded state', async () => { + const { fetchStream } = await import('@/lib/contract') + vi.mocked(fetchStream).mockResolvedValue(mockStream) + + const { useStream } = await import('@/hooks/use-streams') + const { result } = renderHook(() => useStream('1')) + + expect(result.current.loading).toBe(true) + expect(result.current.stream).toBe(null) + + await waitFor(() => { + expect(result.current.loading).toBe(false) + }) + + expect(result.current.stream).toEqual(mockStream) + }) + + it('returns null stream for unknown id', async () => { + const { fetchStream } = await import('@/lib/contract') + vi.mocked(fetchStream).mockResolvedValue(null) + + const { useStream } = await import('@/hooks/use-streams') + const { result } = renderHook(() => useStream('unknown-id')) + + await waitFor(() => { + expect(result.current.loading).toBe(false) + }) + + expect(result.current.stream).toBe(null) + }) + + it('refetch reloads the stream', async () => { + const { fetchStream } = await import('@/lib/contract') + vi.mocked(fetchStream).mockResolvedValue(mockStream) + + const { useStream } = await import('@/hooks/use-streams') + const { result } = renderHook(() => useStream('1')) + + await waitFor(() => { + expect(result.current.loading).toBe(false) + }) + + expect(fetchStream).toHaveBeenCalledTimes(1) + + // Update mock + const updatedStream = { ...mockStream, withdrawnAmount: 200n } + vi.mocked(fetchStream).mockResolvedValue(updatedStream) + + // Call refetch + result.current.refetch() + + await waitFor(() => { + expect(fetchStream).toHaveBeenCalledTimes(2) + }) + + await waitFor(() => { + expect(result.current.stream?.withdrawnAmount).toBe(200n) + }) + }) + + it('handles fetch errors gracefully', async () => { + const { fetchStream } = await import('@/lib/contract') + vi.mocked(fetchStream).mockRejectedValue(new Error('Network error')) + + const { useStream } = await import('@/hooks/use-streams') + const { result } = renderHook(() => useStream('1')) + + await waitFor(() => { + expect(result.current.loading).toBe(false) + }) + + expect(result.current.stream).toBe(null) + }) + + it('does not fetch when id is empty', async () => { + const { fetchStream } = await import('@/lib/contract') + + const { useStream } = await import('@/hooks/use-streams') + const { result } = renderHook(() => useStream('')) + + await waitFor(() => { + expect(result.current.loading).toBe(false) + }) + + expect(fetchStream).not.toHaveBeenCalled() + expect(result.current.stream).toBe(null) + }) +}) diff --git a/__tests__/lib/contract-retry.test.ts b/__tests__/lib/contract-retry.test.ts new file mode 100644 index 0000000..571a09b --- /dev/null +++ b/__tests__/lib/contract-retry.test.ts @@ -0,0 +1,248 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' + +// Mock fetch globally +const mockFetch = vi.fn() +global.fetch = mockFetch + +describe('lib/contract.ts retry helpers', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.useFakeTimers() + }) + + afterEach(() => { + vi.restoreAllMocks() + vi.useRealTimers() + }) + + describe('isRetryableError', () => { + it('identifies TypeError as retryable (network failure)', async () => { + const module = await import('@/lib/contract') + const isRetryableError = (module as any).isRetryableError + + const error = new TypeError('Failed to fetch') + expect(isRetryableError(error)).toBe(true) + }) + + it('identifies 503 errors as retryable', async () => { + const module = await import('@/lib/contract') + const isRetryableError = (module as any).isRetryableError + + const error503 = { status: 503 } + expect(isRetryableError(error503)).toBe(true) + + const errorWith503Message = new Error('Server returned 503') + expect(isRetryableError(errorWith503Message)).toBe(true) + }) + + it('identifies 429 errors as retryable (rate limit)', async () => { + const module = await import('@/lib/contract') + const isRetryableError = (module as any).isRetryableError + + const error429 = { status: 429 } + expect(isRetryableError(error429)).toBe(true) + + const errorWith429Message = new Error('Rate limit: 429') + expect(isRetryableError(errorWith429Message)).toBe(true) + }) + + it('identifies nested status codes as retryable', async () => { + const module = await import('@/lib/contract') + const isRetryableError = (module as any).isRetryableError + + const nestedError = { response: { status: 503 } } + expect(isRetryableError(nestedError)).toBe(true) + }) + + it('returns false for non-retryable errors', async () => { + const module = await import('@/lib/contract') + const isRetryableError = (module as any).isRetryableError + + expect(isRetryableError(new Error('Generic error'))).toBe(false) + expect(isRetryableError({ status: 404 })).toBe(false) + expect(isRetryableError({ status: 500 })).toBe(false) + }) + }) + + describe('fetchWithRetry', () => { + it('returns response on first successful attempt', async () => { + const mockResponse = { ok: true, status: 200 } + mockFetch.mockResolvedValueOnce(mockResponse) + + const module = await import('@/lib/contract') + const fetchWithRetry = (module as any).fetchWithRetry + + const result = await fetchWithRetry('https://api.example.com', {}) + expect(result).toBe(mockResponse) + expect(mockFetch).toHaveBeenCalledTimes(1) + }) + + it('retries on 503 errors up to the limit', async () => { + mockFetch + .mockResolvedValueOnce({ ok: false, status: 503 }) + .mockResolvedValueOnce({ ok: false, status: 503 }) + .mockResolvedValueOnce({ ok: true, status: 200 }) + + const module = await import('@/lib/contract') + const fetchWithRetry = (module as any).fetchWithRetry + + const promise = fetchWithRetry('https://api.example.com', {}) + + // Advance through retry delays + await vi.advanceTimersByTimeAsync(100) // First retry delay + await vi.advanceTimersByTimeAsync(200) // Second retry delay + + const result = await promise + expect(result.status).toBe(200) + expect(mockFetch).toHaveBeenCalledTimes(3) + }) + + it('retries on 429 rate limit errors', async () => { + mockFetch + .mockResolvedValueOnce({ ok: false, status: 429 }) + .mockResolvedValueOnce({ ok: true, status: 200 }) + + const module = await import('@/lib/contract') + const fetchWithRetry = (module as any).fetchWithRetry + + const promise = fetchWithRetry('https://api.example.com', {}) + await vi.advanceTimersByTimeAsync(100) + + const result = await promise + expect(result.status).toBe(200) + expect(mockFetch).toHaveBeenCalledTimes(2) + }) + + it('throws after maximum retries exceeded', async () => { + mockFetch + .mockResolvedValueOnce({ ok: false, status: 503 }) + .mockResolvedValueOnce({ ok: false, status: 503 }) + .mockResolvedValueOnce({ ok: false, status: 503 }) + + const module = await import('@/lib/contract') + const fetchWithRetry = (module as any).fetchWithRetry + + const promise = fetchWithRetry('https://api.example.com', {}) + + await vi.advanceTimersByTimeAsync(100) + await vi.advanceTimersByTimeAsync(200) + + await expect(promise).rejects.toThrow() + expect(mockFetch).toHaveBeenCalledTimes(3) + }) + + it('handles network errors with retry', async () => { + mockFetch + .mockRejectedValueOnce(new TypeError('Network failure')) + .mockResolvedValueOnce({ ok: true, status: 200 }) + + const module = await import('@/lib/contract') + const fetchWithRetry = (module as any).fetchWithRetry + + const promise = fetchWithRetry('https://api.example.com', {}) + await vi.advanceTimersByTimeAsync(100) + + const result = await promise + expect(result.status).toBe(200) + expect(mockFetch).toHaveBeenCalledTimes(2) + }) + }) + + describe('withRetry', () => { + it('returns result on first successful attempt', async () => { + const mockFn = vi.fn().mockResolvedValue('success') + + const module = await import('@/lib/contract') + const withRetry = (module as any).withRetry + + const result = await withRetry(mockFn) + expect(result).toBe('success') + expect(mockFn).toHaveBeenCalledTimes(1) + }) + + it('retries on retryable errors and succeeds after transient failure', async () => { + const mockFn = vi + .fn() + .mockRejectedValueOnce(new TypeError('Network failure')) + .mockResolvedValueOnce('success') + + const module = await import('@/lib/contract') + const withRetry = (module as any).withRetry + + const promise = withRetry(mockFn) + await vi.advanceTimersByTimeAsync(100) + + const result = await promise + expect(result).toBe('success') + expect(mockFn).toHaveBeenCalledTimes(2) + }) + + it('retries multiple times on 503 errors', async () => { + const error503 = { status: 503 } + const mockFn = vi + .fn() + .mockRejectedValueOnce(error503) + .mockRejectedValueOnce(error503) + .mockResolvedValueOnce('success') + + const module = await import('@/lib/contract') + const withRetry = (module as any).withRetry + + const promise = withRetry(mockFn) + await vi.advanceTimersByTimeAsync(100) + await vi.advanceTimersByTimeAsync(200) + + const result = await promise + expect(result).toBe('success') + expect(mockFn).toHaveBeenCalledTimes(3) + }) + + it('throws immediately on non-retryable errors', async () => { + const nonRetryableError = new Error('Bad request') + const mockFn = vi.fn().mockRejectedValue(nonRetryableError) + + const module = await import('@/lib/contract') + const withRetry = (module as any).withRetry + + await expect(withRetry(mockFn)).rejects.toThrow('Bad request') + expect(mockFn).toHaveBeenCalledTimes(1) + }) + + it('throws after maximum retries on retryable errors', async () => { + const retryableError = { status: 503 } + const mockFn = vi.fn().mockRejectedValue(retryableError) + + const module = await import('@/lib/contract') + const withRetry = (module as any).withRetry + + const promise = withRetry(mockFn) + await vi.advanceTimersByTimeAsync(100) + await vi.advanceTimersByTimeAsync(200) + + await expect(promise).rejects.toEqual(retryableError) + expect(mockFn).toHaveBeenCalledTimes(3) + }) + + it('respects retry delays using fake timers', async () => { + const mockFn = vi + .fn() + .mockRejectedValueOnce(new TypeError('Network failure')) + .mockResolvedValueOnce('success') + + const module = await import('@/lib/contract') + const withRetry = (module as any).withRetry + + const promise = withRetry(mockFn) + + // Before advancing timers, only first attempt should have happened + expect(mockFn).toHaveBeenCalledTimes(1) + + // Advance past the first retry delay (100ms) + await vi.advanceTimersByTimeAsync(100) + + const result = await promise + expect(result).toBe('success') + expect(mockFn).toHaveBeenCalledTimes(2) + }) + }) +})