diff --git a/.github/workflows/lighthouse.yml b/.github/workflows/lighthouse.yml index 5391a3a..564121c 100644 --- a/.github/workflows/lighthouse.yml +++ b/.github/workflows/lighthouse.yml @@ -21,6 +21,7 @@ jobs: - run: npm run build env: NEXT_PUBLIC_STREAM_CONTRACT_ID_TESTNET: ${{ secrets.NEXT_PUBLIC_STREAM_CONTRACT_ID_TESTNET }} + NEXT_PUBLIC_STREAM_CONTRACT_ID_MAINNET: ${{ secrets.NEXT_PUBLIC_STREAM_CONTRACT_ID_MAINNET }} - name: Start Next.js server run: npm run start & diff --git a/__tests__/hooks/use-activity-feed.test.ts b/__tests__/hooks/use-activity-feed.test.ts new file mode 100644 index 0000000..944c3d3 --- /dev/null +++ b/__tests__/hooks/use-activity-feed.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect, vi } from 'vitest' +import { renderHook, act } from '@testing-library/react' +import type { StreamData } from '@/types/stream' + +const mockUseStreams = vi.hoisted(() => vi.fn()) + +vi.mock('@/hooks/use-streams', () => ({ useStreams: mockUseStreams })) + +import { useActivityFeed } from '@/hooks/use-activity-feed' + +const TOKEN = { address: 'CUSDC', symbol: 'USDC', decimals: 7 } + +const makeStream = (overrides: Partial = {}): StreamData => ({ + id: '1', + sender: 'GSENDER', + recipient: 'GRECIPIENT', + token: TOKEN, + depositedAmount: 1000n, + withdrawnAmount: 0n, + startTime: 0n, + endTime: 9999999999n, + cliffTime: 0n, + cliffAmount: 0n, + amountPerSecond: 1n, + linearAmount: 1000n, + duration: 9999999999n, + cancelled: false, + ...overrides, +}) + +describe('useActivityFeed', () => { + it('returns no events when walletAddress is null', () => { + mockUseStreams.mockReturnValue({ all: [makeStream()] }) + const { result } = renderHook(() => useActivityFeed(null)) + expect(result.current.events).toEqual([]) + expect(result.current.total).toBe(0) + }) + + it('derives a created event for each stream', () => { + mockUseStreams.mockReturnValue({ all: [makeStream()] }) + const { result } = renderHook(() => useActivityFeed('GSENDER')) + expect(result.current.events.some((e) => e.type === 'stream.created')).toBe(true) + expect(result.current.events[0].role).toBe('sent') + }) + + it('marks role as received when wallet is the recipient', () => { + mockUseStreams.mockReturnValue({ all: [makeStream()] }) + const { result } = renderHook(() => useActivityFeed('GRECIPIENT')) + expect(result.current.events[0].role).toBe('received') + }) + + it('adds a withdrawal event when withdrawnAmount > 0', () => { + mockUseStreams.mockReturnValue({ + all: [makeStream({ withdrawnAmount: 500n })], + }) + const { result } = renderHook(() => useActivityFeed('GSENDER')) + expect(result.current.events.some((e) => e.type === 'stream.withdrawal')).toBe(true) + }) + + it('adds a cancelled event when the stream is cancelled', () => { + mockUseStreams.mockReturnValue({ + all: [makeStream({ cancelled: true })], + }) + const { result } = renderHook(() => useActivityFeed('GSENDER')) + expect(result.current.events.some((e) => e.type === 'stream.cancelled')).toBe(true) + }) + + it('filters events by eventType', () => { + mockUseStreams.mockReturnValue({ + all: [makeStream({ withdrawnAmount: 500n })], + }) + const { result } = renderHook(() => useActivityFeed('GSENDER')) + act(() => { + result.current.setFilter({ eventType: 'stream.withdrawal', role: 'all' }) + }) + expect(result.current.events.every((e) => e.type === 'stream.withdrawal')).toBe(true) + }) + + it('resets to page 1 when the filter changes', () => { + mockUseStreams.mockReturnValue({ all: [makeStream()] }) + const { result } = renderHook(() => useActivityFeed('GSENDER')) + act(() => { + result.current.loadMore() + }) + act(() => { + result.current.setFilter({ eventType: 'all', role: 'received' }) + }) + expect(result.current.events).toEqual([]) + }) +}) diff --git a/__tests__/hooks/use-form-draft.test.ts b/__tests__/hooks/use-form-draft.test.ts new file mode 100644 index 0000000..988a0be --- /dev/null +++ b/__tests__/hooks/use-form-draft.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { renderHook, act } from '@testing-library/react' +import { useFormDraft, clearExpiredDrafts } from '@/hooks/use-form-draft' + +describe('useFormDraft', () => { + beforeEach(() => { + localStorage.clear() + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('auto-saves the value after the debounce window', () => { + const onChange = vi.fn() + const { rerender } = renderHook( + ({ value }) => useFormDraft('test-key', value, onChange), + { initialProps: { value: { foo: 'bar' } } }, + ) + rerender({ value: { foo: 'baz' } }) + + act(() => { + vi.advanceTimersByTime(500) + }) + + const raw = localStorage.getItem('flowstar_draft_test-key') + expect(raw).not.toBeNull() + expect(JSON.parse(raw!).data).toEqual({ foo: 'baz' }) + }) + + it('does not save when disabled', () => { + const onChange = vi.fn() + renderHook(() => useFormDraft('disabled-key', { foo: 'bar' }, onChange, false)) + act(() => { + vi.advanceTimersByTime(1000) + }) + expect(localStorage.getItem('flowstar_draft_disabled-key')).toBeNull() + }) + + it('restore calls onChange with the saved draft', () => { + localStorage.setItem( + 'flowstar_draft_restore-key', + JSON.stringify({ data: { foo: 'restored' }, savedAt: Date.now() }), + ) + const onChange = vi.fn() + const { result } = renderHook(() => + useFormDraft('restore-key', { foo: 'bar' }, onChange), + ) + act(() => { + result.current.restore() + }) + expect(onChange).toHaveBeenCalledWith({ foo: 'restored' }) + }) + + it('loadDraft returns null for an expired draft and clears storage', () => { + localStorage.setItem( + 'flowstar_draft_expired-key', + JSON.stringify({ data: { foo: 'old' }, savedAt: Date.now() - 25 * 60 * 60 * 1000 }), + ) + const onChange = vi.fn() + const { result } = renderHook(() => + useFormDraft('expired-key', { foo: 'bar' }, onChange), + ) + expect(result.current.loadDraft()).toBeNull() + expect(localStorage.getItem('flowstar_draft_expired-key')).toBeNull() + }) + + it('discard removes the stored draft', () => { + localStorage.setItem( + 'flowstar_draft_discard-key', + JSON.stringify({ data: { foo: 'bar' }, savedAt: Date.now() }), + ) + const onChange = vi.fn() + const { result } = renderHook(() => + useFormDraft('discard-key', { foo: 'bar' }, onChange), + ) + act(() => { + result.current.discard() + }) + expect(localStorage.getItem('flowstar_draft_discard-key')).toBeNull() + }) +}) + +describe('clearExpiredDrafts', () => { + beforeEach(() => { + localStorage.clear() + }) + + it('removes only expired draft entries', () => { + localStorage.setItem( + 'flowstar_draft_fresh', + JSON.stringify({ data: {}, savedAt: Date.now() }), + ) + localStorage.setItem( + 'flowstar_draft_old', + JSON.stringify({ data: {}, savedAt: Date.now() - 25 * 60 * 60 * 1000 }), + ) + clearExpiredDrafts() + expect(localStorage.getItem('flowstar_draft_fresh')).not.toBeNull() + expect(localStorage.getItem('flowstar_draft_old')).toBeNull() + }) +}) diff --git a/__tests__/hooks/use-show-usd.test.ts b/__tests__/hooks/use-show-usd.test.ts new file mode 100644 index 0000000..60fde4f --- /dev/null +++ b/__tests__/hooks/use-show-usd.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { renderHook, act } from '@testing-library/react' +import { useShowUsd } from '@/hooks/use-show-usd' + +describe('useShowUsd', () => { + beforeEach(() => { + localStorage.clear() + }) + + it('defaults to showing USD when nothing is stored', () => { + const { result } = renderHook(() => useShowUsd()) + expect(result.current[0]).toBe(true) + }) + + it('reads a previously stored false value', () => { + localStorage.setItem('flowstar-show-usd', 'false') + const { result } = renderHook(() => useShowUsd()) + expect(result.current[0]).toBe(false) + }) + + it('toggle updates state and persists to localStorage', () => { + const { result } = renderHook(() => useShowUsd()) + act(() => { + result.current[1](false) + }) + expect(result.current[0]).toBe(false) + expect(localStorage.getItem('flowstar-show-usd')).toBe('false') + }) +}) diff --git a/__tests__/hooks/use-stream-history.test.ts b/__tests__/hooks/use-stream-history.test.ts new file mode 100644 index 0000000..53158a2 --- /dev/null +++ b/__tests__/hooks/use-stream-history.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { renderHook, waitFor } from '@testing-library/react' + +vi.mock('@/components/providers/network-provider', () => ({ + useNetwork: vi.fn(() => ({ + network: 'testnet', + config: { rpcUrl: 'https://rpc.testnet.example', streamContractId: 'CCONTRACT' }, + })), +})) + +import { useStreamHistory } from '@/hooks/use-stream-history' + +const originalFetch = global.fetch + +describe('useStreamHistory', () => { + beforeEach(() => { + global.fetch = vi.fn() + }) + + afterEach(() => { + global.fetch = originalFetch + }) + + 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() + }) + + it('loads and decodes events from the RPC response', async () => { + vi.mocked(global.fetch).mockResolvedValue({ + ok: true, + json: async () => ({ + result: { + events: [ + { + type: 'contract', + ledger: 100, + ledgerClosedAt: new Date().toISOString(), + 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') + }) + + 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('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) + }) +}) diff --git a/__tests__/hooks/use-token-price.test.ts b/__tests__/hooks/use-token-price.test.ts new file mode 100644 index 0000000..01af681 --- /dev/null +++ b/__tests__/hooks/use-token-price.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { renderHook, waitFor } from '@testing-library/react' +import { useTokenPrice, usePortfolioValue, formatUsd } from '@/hooks/use-token-price' +import type { StreamData } from '@/types/stream' + +const originalFetch = global.fetch + +describe('formatUsd', () => { + it('formats sub-dollar values with 4 decimals', () => { + expect(formatUsd(0.1234)).toBe('$0.1234') + }) + + it('formats normal values with 2 decimals', () => { + expect(formatUsd(12.3)).toBe('$12.30') + }) + + it('formats large values with thousands separators', () => { + expect(formatUsd(12345.678)).toBe('$12,345.68') + }) +}) + +describe('useTokenPrice', () => { + beforeEach(() => { + global.fetch = vi.fn() + }) + + afterEach(() => { + global.fetch = originalFetch + }) + + it('returns a fixed $1 price for stablecoins without fetching', async () => { + const { result } = renderHook(() => useTokenPrice('USDC')) + await waitFor(() => { + expect(result.current.usdPrice).toBe(1) + }) + expect(global.fetch).not.toHaveBeenCalled() + }) + + it('returns null price for unknown, non-XLM symbols', async () => { + const { result } = renderHook(() => useTokenPrice('SOME_UNKNOWN_TOKEN')) + await waitFor(() => { + expect(result.current.usdPrice).toBeNull() + expect(result.current.loading).toBe(false) + }) + }) + + it('fetches and returns the XLM price', async () => { + vi.mocked(global.fetch).mockResolvedValue({ + ok: true, + json: async () => ({ price: 0.42 }), + } as Response) + const { result } = renderHook(() => useTokenPrice('XLM')) + await waitFor(() => { + expect(result.current.usdPrice).toBe(0.42) + expect(result.current.loading).toBe(false) + }) + }) +}) + +describe('usePortfolioValue', () => { + beforeEach(() => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ price: 0.5 }), + } as Response) + }) + + afterEach(() => { + global.fetch = originalFetch + }) + + it('returns totalUsd=0 for an empty stream list', async () => { + const { result } = renderHook(() => usePortfolioValue([])) + expect(result.current.totalUsd).toBe(0) + expect(result.current.loading).toBe(false) + }) + + it('sums locked USDC value across streams', async () => { + const streams: StreamData[] = [ + { + id: '1', + sender: 'GSENDER', + recipient: 'GRECIPIENT', + token: { address: 'CUSDC', symbol: 'USDC', decimals: 7 }, + depositedAmount: 10_000_0000000n, + withdrawnAmount: 0n, + startTime: 0n, + endTime: 9999999999n, + cliffTime: 0n, + cliffAmount: 0n, + amountPerSecond: 1n, + linearAmount: 10_000_0000000n, + duration: 9999999999n, + cancelled: false, + }, + ] + const { result } = renderHook(() => usePortfolioValue(streams)) + await waitFor(() => { + expect(result.current.totalUsd).toBe(10_000) + }) + }) +}) diff --git a/__tests__/hooks/use-wallet.test.ts b/__tests__/hooks/use-wallet.test.ts new file mode 100644 index 0000000..4eb06d7 --- /dev/null +++ b/__tests__/hooks/use-wallet.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect, vi } from 'vitest' +import { renderHook } from '@testing-library/react' + +const mockContextValue = { + address: 'GSENDER', + isConnected: true, + connect: vi.fn(), + disconnect: vi.fn(), +} + +const mockUseWalletContext = vi.hoisted(() => vi.fn()) + +vi.mock('@/components/providers/wallet-provider', () => ({ + useWalletContext: mockUseWalletContext, +})) + +import { useWallet } from '@/hooks/use-wallet' + +describe('useWallet', () => { + it('returns the value from useWalletContext', () => { + mockUseWalletContext.mockReturnValue(mockContextValue) + const { result } = renderHook(() => useWallet()) + expect(result.current).toBe(mockContextValue) + }) + + it('reflects disconnected state from the provider', () => { + mockUseWalletContext.mockReturnValue({ + address: null, + isConnected: false, + connect: vi.fn(), + disconnect: vi.fn(), + }) + const { result } = renderHook(() => useWallet()) + expect(result.current.isConnected).toBe(false) + expect(result.current.address).toBeNull() + }) +}) diff --git a/__tests__/hooks/use-webhooks.test.ts b/__tests__/hooks/use-webhooks.test.ts new file mode 100644 index 0000000..3f54cb8 --- /dev/null +++ b/__tests__/hooks/use-webhooks.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { renderHook, act, waitFor } from '@testing-library/react' +import { useWebhooks } from '@/hooks/use-webhooks' + +const originalFetch = global.fetch + +describe('useWebhooks', () => { + beforeEach(() => { + localStorage.clear() + global.fetch = vi.fn() + }) + + afterEach(() => { + global.fetch = originalFetch + }) + + it('starts empty when nothing is in storage', async () => { + const { result } = renderHook(() => useWebhooks()) + await waitFor(() => { + expect(result.current.webhooks).toEqual([]) + expect(result.current.history).toEqual([]) + }) + }) + + it('adds a webhook and persists it to localStorage', async () => { + const { result } = renderHook(() => useWebhooks()) + await act(async () => { + result.current.addWebhook('https://example.com/hook', ['stream.created']) + }) + expect(result.current.webhooks).toHaveLength(1) + expect(result.current.webhooks[0].url).toBe('https://example.com/hook') + expect(result.current.webhooks[0].enabled).toBe(true) + expect(JSON.parse(localStorage.getItem('flowstar_webhooks') ?? '[]')).toHaveLength(1) + }) + + it('removes a webhook by id', async () => { + const { result } = renderHook(() => useWebhooks()) + await act(async () => { + result.current.addWebhook('https://example.com/hook', ['stream.created']) + }) + const id = result.current.webhooks[0].id + await act(async () => { + result.current.removeWebhook(id) + }) + expect(result.current.webhooks).toHaveLength(0) + }) + + it('toggles a webhook enabled state', async () => { + const { result } = renderHook(() => useWebhooks()) + await act(async () => { + result.current.addWebhook('https://example.com/hook', ['stream.created']) + }) + const id = result.current.webhooks[0].id + await act(async () => { + result.current.toggleWebhook(id) + }) + expect(result.current.webhooks[0].enabled).toBe(false) + }) + + it('testWebhook returns false for an unknown id', async () => { + const { result } = renderHook(() => useWebhooks()) + let ok: boolean | undefined + await act(async () => { + ok = await result.current.testWebhook('does-not-exist') + }) + expect(ok).toBe(false) + }) + + it('testWebhook returns true when the delivery succeeds', async () => { + vi.mocked(global.fetch).mockResolvedValue({ ok: true, status: 200 } as Response) + const { result } = renderHook(() => useWebhooks()) + await act(async () => { + result.current.addWebhook('https://example.com/hook', ['stream.created']) + }) + const id = result.current.webhooks[0].id + let ok: boolean | undefined + await act(async () => { + ok = await result.current.testWebhook(id) + }) + expect(ok).toBe(true) + }) + + it('fireEvent only delivers to enabled webhooks subscribed to the event type', async () => { + vi.mocked(global.fetch).mockResolvedValue({ ok: true, status: 200 } as Response) + const { result } = renderHook(() => useWebhooks()) + await act(async () => { + result.current.addWebhook('https://example.com/hook', ['stream.withdrawal']) + }) + await act(async () => { + await result.current.fireEvent('stream.created', { stream_id: 1 }) + }) + expect(global.fetch).not.toHaveBeenCalled() + expect(result.current.history).toHaveLength(0) + }) +}) diff --git a/__tests__/hooks/useBulkActions.test.ts b/__tests__/hooks/useBulkActions.test.ts new file mode 100644 index 0000000..a5db6f4 --- /dev/null +++ b/__tests__/hooks/useBulkActions.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect, vi } from 'vitest' +import { renderHook, act } from '@testing-library/react' +import { useBulkActions } from '@/hooks/useBulkActions' + +describe('useBulkActions', () => { + it('starts idle with empty progress and results', () => { + const { result } = renderHook(() => useBulkActions()) + expect(result.current.status).toBe('idle') + expect(result.current.progress).toEqual({ done: 0, total: 0 }) + expect(result.current.results).toEqual([]) + }) + + it('runs an action over every id and reports success', async () => { + const action = vi.fn().mockResolvedValue(undefined) + const { result } = renderHook(() => useBulkActions()) + await act(async () => { + await result.current.runBulk(['1', '2', '3'], action) + }) + expect(action).toHaveBeenCalledTimes(3) + expect(result.current.status).toBe('done') + expect(result.current.progress).toEqual({ done: 3, total: 3 }) + expect(result.current.succeeded).toBe(3) + expect(result.current.failed).toBe(0) + }) + + it('tracks failures separately from successes', async () => { + const action = vi + .fn() + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error('boom')) + const { result } = renderHook(() => useBulkActions()) + await act(async () => { + await result.current.runBulk(['1', '2'], action) + }) + expect(result.current.succeeded).toBe(1) + expect(result.current.failed).toBe(1) + expect(result.current.results.find((r) => r.id === '2')?.error).toBe('boom') + }) +}) diff --git a/__tests__/hooks/useBulkSelect.test.ts b/__tests__/hooks/useBulkSelect.test.ts new file mode 100644 index 0000000..7362b4e --- /dev/null +++ b/__tests__/hooks/useBulkSelect.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect } from 'vitest' +import { renderHook, act } from '@testing-library/react' +import { useBulkSelect } from '@/hooks/useBulkSelect' + +const items = [{ id: '1' }, { id: '2' }, { id: '3' }] + +describe('useBulkSelect', () => { + it('starts with nothing selected', () => { + const { result } = renderHook(() => useBulkSelect(items)) + expect(result.current.someSelected).toBe(false) + expect(result.current.allSelected).toBe(false) + expect(result.current.selectedItems).toEqual([]) + }) + + it('toggle adds and removes an id', () => { + const { result } = renderHook(() => useBulkSelect(items)) + act(() => result.current.toggle('1')) + expect(result.current.selected.has('1')).toBe(true) + expect(result.current.selectedItems).toEqual([{ id: '1' }]) + act(() => result.current.toggle('1')) + expect(result.current.selected.has('1')).toBe(false) + }) + + it('toggleAll selects everything when nothing is fully selected', () => { + const { result } = renderHook(() => useBulkSelect(items)) + act(() => result.current.toggleAll()) + expect(result.current.allSelected).toBe(true) + expect(result.current.selectedItems).toHaveLength(3) + }) + + it('toggleAll clears selection when everything is already selected', () => { + const { result } = renderHook(() => useBulkSelect(items)) + act(() => result.current.toggleAll()) + act(() => result.current.toggleAll()) + expect(result.current.allSelected).toBe(false) + expect(result.current.selected.size).toBe(0) + }) + + it('clear empties the selection', () => { + const { result } = renderHook(() => useBulkSelect(items)) + act(() => result.current.toggle('1')) + act(() => result.current.clear()) + expect(result.current.someSelected).toBe(false) + }) +}) diff --git a/__tests__/hooks/useNotifications.test.ts b/__tests__/hooks/useNotifications.test.ts new file mode 100644 index 0000000..9855119 --- /dev/null +++ b/__tests__/hooks/useNotifications.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { renderHook, act } from '@testing-library/react' +import { useNotifications } from '@/hooks/useNotifications' + +class MockNotification { + static permission = 'granted' + static requestPermission = vi.fn().mockResolvedValue('granted') + constructor(public title: string, public options?: NotificationOptions) {} +} + +describe('useNotifications', () => { + beforeEach(() => { + localStorage.clear() + vi.stubGlobal('Notification', MockNotification) + }) + + it('starts with no notifications and default preferences enabled', () => { + const { result } = renderHook(() => useNotifications()) + expect(result.current.notifications).toEqual([]) + expect(result.current.unreadCount).toBe(0) + expect(result.current.prefs.stream_received).toBe(true) + }) + + it('adds a notification and increments unreadCount', () => { + const { result } = renderHook(() => useNotifications()) + act(() => { + result.current.addNotification({ + type: 'stream_received', + message: 'You received a stream', + streamId: '1', + }) + }) + expect(result.current.notifications).toHaveLength(1) + expect(result.current.unreadCount).toBe(1) + }) + + it('does not add a notification when the type preference is disabled', () => { + const { result } = renderHook(() => useNotifications()) + act(() => { + result.current.updatePref('stream_received', false) + }) + act(() => { + result.current.addNotification({ + type: 'stream_received', + message: 'You received a stream', + streamId: '1', + }) + }) + expect(result.current.notifications).toHaveLength(0) + }) + + it('markAllRead marks every notification as read', () => { + const { result } = renderHook(() => useNotifications()) + act(() => { + result.current.addNotification({ + type: 'cliff_reached', + message: 'Cliff reached', + streamId: '1', + }) + }) + act(() => { + result.current.markAllRead() + }) + expect(result.current.unreadCount).toBe(0) + expect(result.current.notifications.every((n) => n.read)).toBe(true) + }) + + it('updatePref persists the preference to localStorage', () => { + const { result } = renderHook(() => useNotifications()) + act(() => { + result.current.updatePref('topup_received', false) + }) + const stored = JSON.parse(localStorage.getItem('flowstar_notif_prefs') ?? '{}') + expect(stored.topup_received).toBe(false) + }) +}) diff --git a/lib/stream-utils.ts b/lib/stream-utils.ts index 908f632..7eab451 100644 --- a/lib/stream-utils.ts +++ b/lib/stream-utils.ts @@ -200,7 +200,7 @@ export function formatTimeRemaining( const parts: string[] = [] if (days) parts.push(`${days}d`) if (hours || days) parts.push(`${hours}h`) - if (!days) parts.push(`${minutes}m`) + if (!days && (minutes || hours)) parts.push(`${minutes}m`) if (!days && !hours) parts.push(`${seconds}s`) return parts.join(' ') }