Skip to content

Commit c19d29a

Browse files
authored
Merge pull request #323 from christabel888/fix/issues-291-295-302-303
Fix sub-minute countdown, hook test coverage, e2e CI, and lighthouse env var
2 parents 4793612 + 0cd949f commit c19d29a

12 files changed

Lines changed: 698 additions & 1 deletion

.github/workflows/lighthouse.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ jobs:
2121
- run: npm run build
2222
env:
2323
NEXT_PUBLIC_STREAM_CONTRACT_ID_TESTNET: ${{ secrets.NEXT_PUBLIC_STREAM_CONTRACT_ID_TESTNET }}
24+
NEXT_PUBLIC_STREAM_CONTRACT_ID_MAINNET: ${{ secrets.NEXT_PUBLIC_STREAM_CONTRACT_ID_MAINNET }}
2425

2526
- name: Start Next.js server
2627
run: npm run start &
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { describe, it, expect, vi } from 'vitest'
2+
import { renderHook, act } from '@testing-library/react'
3+
import type { StreamData } from '@/types/stream'
4+
5+
const mockUseStreams = vi.hoisted(() => vi.fn())
6+
7+
vi.mock('@/hooks/use-streams', () => ({ useStreams: mockUseStreams }))
8+
9+
import { useActivityFeed } from '@/hooks/use-activity-feed'
10+
11+
const TOKEN = { address: 'CUSDC', symbol: 'USDC', decimals: 7 }
12+
13+
const makeStream = (overrides: Partial<StreamData> = {}): StreamData => ({
14+
id: '1',
15+
sender: 'GSENDER',
16+
recipient: 'GRECIPIENT',
17+
token: TOKEN,
18+
depositedAmount: 1000n,
19+
withdrawnAmount: 0n,
20+
startTime: 0n,
21+
endTime: 9999999999n,
22+
cliffTime: 0n,
23+
cliffAmount: 0n,
24+
amountPerSecond: 1n,
25+
linearAmount: 1000n,
26+
duration: 9999999999n,
27+
cancelled: false,
28+
...overrides,
29+
})
30+
31+
describe('useActivityFeed', () => {
32+
it('returns no events when walletAddress is null', () => {
33+
mockUseStreams.mockReturnValue({ all: [makeStream()] })
34+
const { result } = renderHook(() => useActivityFeed(null))
35+
expect(result.current.events).toEqual([])
36+
expect(result.current.total).toBe(0)
37+
})
38+
39+
it('derives a created event for each stream', () => {
40+
mockUseStreams.mockReturnValue({ all: [makeStream()] })
41+
const { result } = renderHook(() => useActivityFeed('GSENDER'))
42+
expect(result.current.events.some((e) => e.type === 'stream.created')).toBe(true)
43+
expect(result.current.events[0].role).toBe('sent')
44+
})
45+
46+
it('marks role as received when wallet is the recipient', () => {
47+
mockUseStreams.mockReturnValue({ all: [makeStream()] })
48+
const { result } = renderHook(() => useActivityFeed('GRECIPIENT'))
49+
expect(result.current.events[0].role).toBe('received')
50+
})
51+
52+
it('adds a withdrawal event when withdrawnAmount > 0', () => {
53+
mockUseStreams.mockReturnValue({
54+
all: [makeStream({ withdrawnAmount: 500n })],
55+
})
56+
const { result } = renderHook(() => useActivityFeed('GSENDER'))
57+
expect(result.current.events.some((e) => e.type === 'stream.withdrawal')).toBe(true)
58+
})
59+
60+
it('adds a cancelled event when the stream is cancelled', () => {
61+
mockUseStreams.mockReturnValue({
62+
all: [makeStream({ cancelled: true })],
63+
})
64+
const { result } = renderHook(() => useActivityFeed('GSENDER'))
65+
expect(result.current.events.some((e) => e.type === 'stream.cancelled')).toBe(true)
66+
})
67+
68+
it('filters events by eventType', () => {
69+
mockUseStreams.mockReturnValue({
70+
all: [makeStream({ withdrawnAmount: 500n })],
71+
})
72+
const { result } = renderHook(() => useActivityFeed('GSENDER'))
73+
act(() => {
74+
result.current.setFilter({ eventType: 'stream.withdrawal', role: 'all' })
75+
})
76+
expect(result.current.events.every((e) => e.type === 'stream.withdrawal')).toBe(true)
77+
})
78+
79+
it('resets to page 1 when the filter changes', () => {
80+
mockUseStreams.mockReturnValue({ all: [makeStream()] })
81+
const { result } = renderHook(() => useActivityFeed('GSENDER'))
82+
act(() => {
83+
result.current.loadMore()
84+
})
85+
act(() => {
86+
result.current.setFilter({ eventType: 'all', role: 'received' })
87+
})
88+
expect(result.current.events).toEqual([])
89+
})
90+
})
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2+
import { renderHook, act } from '@testing-library/react'
3+
import { useFormDraft, clearExpiredDrafts } from '@/hooks/use-form-draft'
4+
5+
describe('useFormDraft', () => {
6+
beforeEach(() => {
7+
localStorage.clear()
8+
vi.useFakeTimers()
9+
})
10+
11+
afterEach(() => {
12+
vi.useRealTimers()
13+
})
14+
15+
it('auto-saves the value after the debounce window', () => {
16+
const onChange = vi.fn()
17+
const { rerender } = renderHook(
18+
({ value }) => useFormDraft('test-key', value, onChange),
19+
{ initialProps: { value: { foo: 'bar' } } },
20+
)
21+
rerender({ value: { foo: 'baz' } })
22+
23+
act(() => {
24+
vi.advanceTimersByTime(500)
25+
})
26+
27+
const raw = localStorage.getItem('flowstar_draft_test-key')
28+
expect(raw).not.toBeNull()
29+
expect(JSON.parse(raw!).data).toEqual({ foo: 'baz' })
30+
})
31+
32+
it('does not save when disabled', () => {
33+
const onChange = vi.fn()
34+
renderHook(() => useFormDraft('disabled-key', { foo: 'bar' }, onChange, false))
35+
act(() => {
36+
vi.advanceTimersByTime(1000)
37+
})
38+
expect(localStorage.getItem('flowstar_draft_disabled-key')).toBeNull()
39+
})
40+
41+
it('restore calls onChange with the saved draft', () => {
42+
localStorage.setItem(
43+
'flowstar_draft_restore-key',
44+
JSON.stringify({ data: { foo: 'restored' }, savedAt: Date.now() }),
45+
)
46+
const onChange = vi.fn()
47+
const { result } = renderHook(() =>
48+
useFormDraft('restore-key', { foo: 'bar' }, onChange),
49+
)
50+
act(() => {
51+
result.current.restore()
52+
})
53+
expect(onChange).toHaveBeenCalledWith({ foo: 'restored' })
54+
})
55+
56+
it('loadDraft returns null for an expired draft and clears storage', () => {
57+
localStorage.setItem(
58+
'flowstar_draft_expired-key',
59+
JSON.stringify({ data: { foo: 'old' }, savedAt: Date.now() - 25 * 60 * 60 * 1000 }),
60+
)
61+
const onChange = vi.fn()
62+
const { result } = renderHook(() =>
63+
useFormDraft('expired-key', { foo: 'bar' }, onChange),
64+
)
65+
expect(result.current.loadDraft()).toBeNull()
66+
expect(localStorage.getItem('flowstar_draft_expired-key')).toBeNull()
67+
})
68+
69+
it('discard removes the stored draft', () => {
70+
localStorage.setItem(
71+
'flowstar_draft_discard-key',
72+
JSON.stringify({ data: { foo: 'bar' }, savedAt: Date.now() }),
73+
)
74+
const onChange = vi.fn()
75+
const { result } = renderHook(() =>
76+
useFormDraft('discard-key', { foo: 'bar' }, onChange),
77+
)
78+
act(() => {
79+
result.current.discard()
80+
})
81+
expect(localStorage.getItem('flowstar_draft_discard-key')).toBeNull()
82+
})
83+
})
84+
85+
describe('clearExpiredDrafts', () => {
86+
beforeEach(() => {
87+
localStorage.clear()
88+
})
89+
90+
it('removes only expired draft entries', () => {
91+
localStorage.setItem(
92+
'flowstar_draft_fresh',
93+
JSON.stringify({ data: {}, savedAt: Date.now() }),
94+
)
95+
localStorage.setItem(
96+
'flowstar_draft_old',
97+
JSON.stringify({ data: {}, savedAt: Date.now() - 25 * 60 * 60 * 1000 }),
98+
)
99+
clearExpiredDrafts()
100+
expect(localStorage.getItem('flowstar_draft_fresh')).not.toBeNull()
101+
expect(localStorage.getItem('flowstar_draft_old')).toBeNull()
102+
})
103+
})
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { describe, it, expect, beforeEach } from 'vitest'
2+
import { renderHook, act } from '@testing-library/react'
3+
import { useShowUsd } from '@/hooks/use-show-usd'
4+
5+
describe('useShowUsd', () => {
6+
beforeEach(() => {
7+
localStorage.clear()
8+
})
9+
10+
it('defaults to showing USD when nothing is stored', () => {
11+
const { result } = renderHook(() => useShowUsd())
12+
expect(result.current[0]).toBe(true)
13+
})
14+
15+
it('reads a previously stored false value', () => {
16+
localStorage.setItem('flowstar-show-usd', 'false')
17+
const { result } = renderHook(() => useShowUsd())
18+
expect(result.current[0]).toBe(false)
19+
})
20+
21+
it('toggle updates state and persists to localStorage', () => {
22+
const { result } = renderHook(() => useShowUsd())
23+
act(() => {
24+
result.current[1](false)
25+
})
26+
expect(result.current[0]).toBe(false)
27+
expect(localStorage.getItem('flowstar-show-usd')).toBe('false')
28+
})
29+
})
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2+
import { renderHook, waitFor } from '@testing-library/react'
3+
4+
vi.mock('@/components/providers/network-provider', () => ({
5+
useNetwork: vi.fn(() => ({
6+
network: 'testnet',
7+
config: { rpcUrl: 'https://rpc.testnet.example', streamContractId: 'CCONTRACT' },
8+
})),
9+
}))
10+
11+
import { useStreamHistory } from '@/hooks/use-stream-history'
12+
13+
const originalFetch = global.fetch
14+
15+
describe('useStreamHistory', () => {
16+
beforeEach(() => {
17+
global.fetch = vi.fn()
18+
})
19+
20+
afterEach(() => {
21+
global.fetch = originalFetch
22+
})
23+
24+
it('does nothing when streamId is empty', async () => {
25+
const { result } = renderHook(() => useStreamHistory(''))
26+
await waitFor(() => {
27+
expect(result.current.loading).toBe(false)
28+
})
29+
expect(result.current.events).toEqual([])
30+
expect(global.fetch).not.toHaveBeenCalled()
31+
})
32+
33+
it('loads and decodes events from the RPC response', async () => {
34+
vi.mocked(global.fetch).mockResolvedValue({
35+
ok: true,
36+
json: async () => ({
37+
result: {
38+
events: [
39+
{
40+
type: 'contract',
41+
ledger: 100,
42+
ledgerClosedAt: new Date().toISOString(),
43+
txHash: 'abc123',
44+
topic: ['stream_created'],
45+
value: { xdr: '' },
46+
},
47+
],
48+
},
49+
}),
50+
} as Response)
51+
52+
const { result } = renderHook(() => useStreamHistory('1'))
53+
await waitFor(() => {
54+
expect(result.current.loading).toBe(false)
55+
})
56+
expect(result.current.events).toHaveLength(1)
57+
expect(result.current.events[0].type).toBe('created')
58+
})
59+
60+
it('falls back to an empty event list on RPC failure', async () => {
61+
vi.mocked(global.fetch).mockRejectedValue(new Error('network down'))
62+
const { result } = renderHook(() => useStreamHistory('1'))
63+
await waitFor(() => {
64+
expect(result.current.loading).toBe(false)
65+
})
66+
expect(result.current.events).toEqual([])
67+
})
68+
69+
it('refetch re-triggers the load', async () => {
70+
vi.mocked(global.fetch).mockResolvedValue({
71+
ok: true,
72+
json: async () => ({ result: { events: [] } }),
73+
} as Response)
74+
const { result } = renderHook(() => useStreamHistory('1'))
75+
await waitFor(() => expect(result.current.loading).toBe(false))
76+
const callsBefore = vi.mocked(global.fetch).mock.calls.length
77+
await result.current.refetch()
78+
expect(vi.mocked(global.fetch).mock.calls.length).toBeGreaterThan(callsBefore)
79+
})
80+
})

0 commit comments

Comments
 (0)