diff --git a/__tests__/components/streams/stream-card-copy-link.test.tsx b/__tests__/components/streams/stream-card-copy-link.test.tsx
new file mode 100644
index 0000000..99ff389
--- /dev/null
+++ b/__tests__/components/streams/stream-card-copy-link.test.tsx
@@ -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) => (
+
+ {children}
+
+ ),
+}))
+
+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 {
+ 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 /app/stream/ to the clipboard', () => {
+ render()
+ 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()
+ 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 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()
+ 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 },
+ )
+ })
+})
diff --git a/__tests__/components/streams/stream-card-menu.test.tsx b/__tests__/components/streams/stream-card-menu.test.tsx
index d03e8d2..9718f5e 100644
--- a/__tests__/components/streams/stream-card-menu.test.tsx
+++ b/__tests__/components/streams/stream-card-menu.test.tsx
@@ -30,6 +30,7 @@ vi.mock('sonner', () => ({ toast: { success: toastSuccess } }))
const hidden = vi.hoisted(() => ({
blocked: new Set(),
+ pinned: new Set(),
hideStream: vi.fn(),
unhideStream: vi.fn(),
blockSender: vi.fn(),
@@ -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,
@@ -81,6 +82,7 @@ function openMenu(id = 'stream-abc') {
beforeEach(() => {
vi.clearAllMocks()
hidden.blocked.clear()
+ hidden.pinned.clear()
})
describe('StreamCard — hide/block menu visibility', () => {
@@ -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()
+ 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()
+ 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')
+ })
+})
diff --git a/__tests__/components/webhook-settings.test.tsx b/__tests__/components/webhook-settings.test.tsx
index 8352130..57fd9c9 100644
--- a/__tests__/components/webhook-settings.test.tsx
+++ b/__tests__/components/webhook-settings.test.tsx
@@ -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['webhooks'],
@@ -32,6 +33,7 @@ const defaultHookState = {
removeWebhook: mockRemoveWebhook,
toggleWebhook: mockToggleWebhook,
testWebhook: mockTestWebhook,
+ resendDelivery: mockResendDelivery,
fireEvent: vi.fn(),
}
@@ -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())
+ })
+})
diff --git a/__tests__/hooks/use-notification-preferences.test.ts b/__tests__/hooks/use-notification-preferences.test.ts
new file mode 100644
index 0000000..97d32da
--- /dev/null
+++ b/__tests__/hooks/use-notification-preferences.test.ts
@@ -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()
+ })
+})