From 1128fa78de30c832a99733b644066283ca5bfda1 Mon Sep 17 00:00:00 2001 From: King-Boye Date: Sun, 18 Oct 2026 15:47:08 +0100 Subject: [PATCH] test: cover webhook resend, stream card pin/unpin and copy-link, notification preferences MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Vitest/Testing Library coverage for four previously-untested pieces of the app: - handleResend in WebhookSettings (success/failure toasts, calls resendDelivery, button disabled while in flight, only shown for failed deliveries with a stored payload) - handlePinToggle in StreamCard (pinStream/unpinStream, toasts, menu label swap) - handleCopyLink in StreamCard (writes /app/stream/ to the clipboard, does not navigate the card link, check icon shown then reset) - useNotificationPreferences / savePreferences / isNotificationTypeEnabled (defaults, persistence, corrupt-JSON fallback, save-error callback) No production code changes were needed — all four behaviors already exist and work as described in their issues; only test coverage was missing. Closes #919 Closes #918 Closes #916 Closes #915 Co-Authored-By: Claude Sonnet 5 --- .../streams/stream-card-copy-link.test.tsx | 123 ++++++++++++++++++ .../streams/stream-card-menu.test.tsx | 31 ++++- .../components/webhook-settings.test.tsx | 87 +++++++++++++ .../use-notification-preferences.test.ts | 68 ++++++++++ 4 files changed, 308 insertions(+), 1 deletion(-) create mode 100644 __tests__/components/streams/stream-card-copy-link.test.tsx create mode 100644 __tests__/hooks/use-notification-preferences.test.ts 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() + }) +})