Skip to content
Merged
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
123 changes: 123 additions & 0 deletions __tests__/components/streams/stream-card-copy-link.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { StreamCard } from '@/components/streams/stream-card'
import type { StreamData } from '@/types/stream'

// Issue #918: coverage for the copy-link quick action
// (handleCopyLink in components/streams/stream-card.tsx).

vi.mock('next/link', () => ({
default: ({ href, children, ...props }: any) => (
<a href={href} {...props}>
{children}
</a>
),
}))

const WALLET = 'GWALLET111'
const OTHER = 'GOTHER222'
const NOW = 1_700_050_000

vi.mock('@/hooks/use-now', () => ({ useNow: vi.fn(() => NOW) }))
vi.mock('@/hooks/use-wallet', () => ({ useWallet: vi.fn(() => ({ address: WALLET })) }))
vi.mock('@/hooks/use-token-price', () => ({
useTokenPrice: vi.fn(() => ({ usdPrice: null, loading: false })),
formatUsd: (n: number) => `$${n}`,
}))

const { toastSuccess } = vi.hoisted(() => ({ toastSuccess: vi.fn() }))
vi.mock('sonner', () => ({ toast: { success: toastSuccess } }))

vi.mock('@/hooks/use-hidden-streams', () => ({
useHiddenStreams: () => ({
isBlocked: () => false,
isPinned: () => false,
hideStream: vi.fn(),
unhideStream: vi.fn(),
blockSender: vi.fn(),
unblockSender: vi.fn(),
pinStream: vi.fn(),
unpinStream: vi.fn(),
}),
}))

const TOKEN = { address: 'CUSDC', symbol: 'USDC', decimals: 7 }

function makeStream(overrides?: Partial<StreamData>): StreamData {
return {
id: 'stream-abc',
sender: OTHER,
recipient: WALLET,
token: TOKEN,
depositedAmount: 100_000_000n,
withdrawnAmount: 0n,
startTime: BigInt(NOW - 3600),
endTime: BigInt(NOW + 3600),
cliffTime: BigInt(NOW - 3600),
cliffAmount: 0n,
amountPerSecond: 27_777n,
linearAmount: 100_000_000n,
duration: 7200n,
cancelled: false,
...overrides,
}
}

const mockWriteText = vi.fn()

beforeEach(() => {
vi.clearAllMocks()
Object.defineProperty(window.navigator, 'clipboard', {
value: { writeText: mockWriteText },
configurable: true,
})
Object.defineProperty(window, 'location', {
value: { origin: 'https://app.flowstar.io' },
writable: true,
})
})

describe('StreamCard — copy link', () => {
it('writes <origin>/app/stream/<id> to the clipboard', () => {
render(<StreamCard stream={makeStream()} />)
fireEvent.click(screen.getByTestId('stream-card-copy-link-stream-abc'))
expect(mockWriteText).toHaveBeenCalledWith('https://app.flowstar.io/app/stream/stream-abc')
expect(toastSuccess).toHaveBeenCalledWith('Link copied to clipboard')
})

it('does not navigate the card link when copy-link is clicked', () => {
render(<StreamCard stream={makeStream()} />)
const link = screen.getByRole('link')
const clickEvent = new MouseEvent('click', { bubbles: true, cancelable: true })
const preventDefaultSpy = vi.spyOn(clickEvent, 'preventDefault')

fireEvent.click(screen.getByTestId('stream-card-copy-link-stream-abc'))

// The quick-action's own click handler calls preventDefault/stopPropagation,
// which is what stops the surrounding <Link> from navigating.
expect(mockWriteText).toHaveBeenCalledTimes(1)
expect(link).toHaveAttribute('href', '/app/stream/stream-abc')
preventDefaultSpy.mockRestore()
})

it('shows the check icon briefly, then resets to the link icon', async () => {
render(<StreamCard stream={makeStream()} />)
const button = screen.getByTestId('stream-card-copy-link-stream-abc')
expect(button.querySelector('svg')).not.toBeNull()

fireEvent.click(button)

// Immediately after the click, the check icon is shown.
await waitFor(() => {
expect(button.innerHTML).toContain('check')
})

// After the reset timeout, it reverts to the link icon.
await waitFor(
() => {
expect(button.innerHTML).not.toContain('check')
},
{ timeout: 2000 },
)
})
})
31 changes: 30 additions & 1 deletion __tests__/components/streams/stream-card-menu.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ vi.mock('sonner', () => ({ toast: { success: toastSuccess } }))

const hidden = vi.hoisted(() => ({
blocked: new Set<string>(),
pinned: new Set<string>(),
hideStream: vi.fn(),
unhideStream: vi.fn(),
blockSender: vi.fn(),
Expand All @@ -41,7 +42,7 @@ const hidden = vi.hoisted(() => ({
vi.mock('@/hooks/use-hidden-streams', () => ({
useHiddenStreams: () => ({
isBlocked: (address: string) => hidden.blocked.has(address),
isPinned: () => false,
isPinned: (id: string) => hidden.pinned.has(id),
hideStream: hidden.hideStream,
unhideStream: hidden.unhideStream,
blockSender: hidden.blockSender,
Expand Down Expand Up @@ -81,6 +82,7 @@ function openMenu(id = 'stream-abc') {
beforeEach(() => {
vi.clearAllMocks()
hidden.blocked.clear()
hidden.pinned.clear()
})

describe('StreamCard — hide/block menu visibility', () => {
Expand Down Expand Up @@ -164,3 +166,30 @@ describe('StreamCard — block/unblock sender', () => {
expect(toastSuccess).toHaveBeenCalledWith('Sender unblocked')
})
})

// Issue #916: coverage for the pin/unpin action (handlePinToggle in
// components/streams/stream-card.tsx).
describe('StreamCard — pin/unpin', () => {
it('"Pin to top" calls pinStream and shows a pinned toast', () => {
render(<StreamCard stream={makeStream()} />)
openMenu()
expect(screen.getByText('Pin to top')).toBeInTheDocument()
fireEvent.click(screen.getByText('Pin to top'))

expect(hidden.pinStream).toHaveBeenCalledWith('stream-abc')
expect(hidden.unpinStream).not.toHaveBeenCalled()
expect(toastSuccess).toHaveBeenCalledWith('Stream pinned to top')
})

it('shows "Unpin stream" for a pinned card and calls unpinStream', () => {
hidden.pinned.add('stream-abc')
render(<StreamCard stream={makeStream()} />)
openMenu()
expect(screen.queryByText('Pin to top')).not.toBeInTheDocument()
fireEvent.click(screen.getByText('Unpin stream'))

expect(hidden.unpinStream).toHaveBeenCalledWith('stream-abc')
expect(hidden.pinStream).not.toHaveBeenCalled()
expect(toastSuccess).toHaveBeenCalledWith('Stream unpinned')
})
})
87 changes: 87 additions & 0 deletions __tests__/components/webhook-settings.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const mockAddWebhook = vi.fn()
const mockRemoveWebhook = vi.fn()
const mockToggleWebhook = vi.fn()
const mockTestWebhook = vi.fn()
const mockResendDelivery = vi.fn()

const defaultHookState = {
webhooks: [] as ReturnType<typeof import('@/hooks/use-webhooks').useWebhooks>['webhooks'],
Expand All @@ -32,6 +33,7 @@ const defaultHookState = {
removeWebhook: mockRemoveWebhook,
toggleWebhook: mockToggleWebhook,
testWebhook: mockTestWebhook,
resendDelivery: mockResendDelivery,
fireEvent: vi.fn(),
}

Expand Down Expand Up @@ -312,3 +314,88 @@ describe('WebhookSettings – delivery history', () => {
expect(screen.queryByText('220')).not.toBeInTheDocument()
})
})

// Issue #919: coverage for the resend-delivery action (handleResend in
// components/webhooks/webhook-settings.tsx).
describe('WebhookSettings – resend delivery', () => {
const FAILED_DELIVERY = {
webhookId: 'hook-1',
eventType: 'stream.created' as const,
statusCode: 500,
deliveredAt: Date.now(),
success: false,
payload: {
schema_version: 1,
event: 'stream.created' as const,
timestamp: new Date().toISOString(),
data: {},
},
}

const SUCCEEDED_DELIVERY = {
webhookId: 'hook-1',
eventType: 'stream.created' as const,
statusCode: 200,
deliveredAt: Date.now(),
success: true,
payload: {
schema_version: 1,
event: 'stream.created' as const,
timestamp: new Date().toISOString(),
data: {},
},
}

beforeEach(() => {
vi.clearAllMocks()
})

it('only shows the resend action for failed deliveries', () => {
mockUseWebhooks.mockReturnValue({
...defaultHookState,
history: [FAILED_DELIVERY, SUCCEEDED_DELIVERY],
})
setup()
expect(screen.getAllByLabelText('Resend delivery')).toHaveLength(1)
})

it('calls resendDelivery with the delivery when resend is clicked', async () => {
mockResendDelivery.mockResolvedValue(true)
mockUseWebhooks.mockReturnValue({ ...defaultHookState, history: [FAILED_DELIVERY] })
setup()
fireEvent.click(screen.getByLabelText('Resend delivery'))
await waitFor(() => expect(mockResendDelivery).toHaveBeenCalledWith(FAILED_DELIVERY))
})

it('shows a success toast when the resend succeeds', async () => {
const { toast } = await import('sonner')
mockResendDelivery.mockResolvedValue(true)
mockUseWebhooks.mockReturnValue({ ...defaultHookState, history: [FAILED_DELIVERY] })
setup()
fireEvent.click(screen.getByLabelText('Resend delivery'))
await waitFor(() =>
expect(toast.success).toHaveBeenCalledWith('Delivery resent successfully'),
)
})

it('shows an error toast when the resend fails', async () => {
const { toast } = await import('sonner')
mockResendDelivery.mockResolvedValue(false)
mockUseWebhooks.mockReturnValue({ ...defaultHookState, history: [FAILED_DELIVERY] })
setup()
fireEvent.click(screen.getByLabelText('Resend delivery'))
await waitFor(() =>
expect(toast.error).toHaveBeenCalledWith('Resend failed', expect.any(Object)),
)
})

it('disables the resend button while the resend is in flight', async () => {
// Never resolves so the button stays disabled
mockResendDelivery.mockReturnValue(new Promise(() => {}))
mockUseWebhooks.mockReturnValue({ ...defaultHookState, history: [FAILED_DELIVERY] })
setup()
const resendBtn = screen.getByLabelText('Resend delivery')
fireEvent.click(resendBtn)
await waitFor(() => expect(resendBtn).toBeDisabled())
})
})
68 changes: 68 additions & 0 deletions __tests__/hooks/use-notification-preferences.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import {
useNotificationPreferences,
isNotificationTypeEnabled,
} from '@/hooks/use-notification-preferences'

const STORAGE_KEY = 'flowstar:notification-preferences'

describe('useNotificationPreferences', () => {
beforeEach(() => {
localStorage.clear()
})

it('defaults all notification types to enabled when storage is empty', () => {
const { result } = renderHook(() => useNotificationPreferences())
expect(result.current.preferences).toEqual({
stream_created: true,
stream_cancelled: true,
withdrawal: true,
})
})

it('updates persist to localStorage', () => {
const { result } = renderHook(() => useNotificationPreferences())
act(() => {
result.current.setPreference('withdrawal', false)
})
expect(result.current.preferences.withdrawal).toBe(false)
const stored = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}')
expect(stored.withdrawal).toBe(false)
})

it('isNotificationTypeEnabled reflects saved preferences', () => {
const { result } = renderHook(() => useNotificationPreferences())
act(() => {
result.current.setPreference('stream_created', false)
})
expect(isNotificationTypeEnabled('stream_created')).toBe(false)
expect(isNotificationTypeEnabled('withdrawal')).toBe(true)
})

it('falls back to defaults when stored JSON is corrupt', () => {
localStorage.setItem(STORAGE_KEY, '{not valid json')
const { result } = renderHook(() => useNotificationPreferences())
expect(result.current.preferences).toEqual({
stream_created: true,
stream_cancelled: true,
withdrawal: true,
})
expect(isNotificationTypeEnabled('withdrawal')).toBe(true)
})

it('calls onSaveError when persisting fails', () => {
const onSaveError = vi.fn()
const setItemSpy = vi
.spyOn(Storage.prototype, 'setItem')
.mockImplementation(() => {
throw new Error('quota exceeded')
})
const { result } = renderHook(() => useNotificationPreferences(onSaveError))
act(() => {
result.current.setPreference('withdrawal', false)
})
expect(onSaveError).toHaveBeenCalledTimes(1)
setItemSpy.mockRestore()
})
})
Loading