Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/lighthouse.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 &
Expand Down
90 changes: 90 additions & 0 deletions __tests__/hooks/use-activity-feed.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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([])
})
})
103 changes: 103 additions & 0 deletions __tests__/hooks/use-form-draft.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
29 changes: 29 additions & 0 deletions __tests__/hooks/use-show-usd.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
80 changes: 80 additions & 0 deletions __tests__/hooks/use-stream-history.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
Loading
Loading