From cae8a38d7d12922c8855eb3dab91dd756a82f156 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?DomRift=20=E2=9A=A1=EF=B8=8F=F0=9F=92=BB?=
<119934253+Macnelson9@users.noreply.github.com>
Date: Mon, 31 Aug 2026 10:42:22 +0000
Subject: [PATCH] Add component and contract tests for coverage improvements
Closes #648 - Add component tests for download-receipt-button.tsx covering disabled state, click handlers, and download attributes
Closes #649 - Add component tests for button.tsx covering all variants, sizes, and asChild render prop
Closes #650 - Add component tests for dialog.tsx covering opening/closing, showCloseButton prop, and content rendering
Closes #655 - Add contract test for set_delegate() called twice to verify it overwrites previous delegate
---
.../download-receipt-button.test.tsx | 206 +++++++++++++--
__tests__/components/ui/button.test.tsx | 164 ++++++++++++
__tests__/components/ui/dialog.test.tsx | 249 ++++++++++++++++++
contracts/streaming/src/test.rs | 48 ++++
4 files changed, 644 insertions(+), 23 deletions(-)
create mode 100644 __tests__/components/ui/button.test.tsx
create mode 100644 __tests__/components/ui/dialog.test.tsx
diff --git a/__tests__/components/download-receipt-button.test.tsx b/__tests__/components/download-receipt-button.test.tsx
index c0337a2..f91a1f0 100644
--- a/__tests__/components/download-receipt-button.test.tsx
+++ b/__tests__/components/download-receipt-button.test.tsx
@@ -4,7 +4,17 @@ import { DownloadReceiptButton } from '@/components/streams/download-receipt-but
import type { StreamData } from '@/types/stream'
vi.mock('sonner', () => ({
- toast: { success: vi.fn(), error: vi.fn() },
+ toast: {
+ success: vi.fn(),
+ error: vi.fn(),
+ },
+}))
+
+vi.mock('@/lib/receipt-utils', () => ({
+ buildReceiptData: vi.fn(() => ({ test: 'receipt' })),
+ generateReceiptCSV: vi.fn(() => 'csv,data\n1,2'),
+ generateReceiptHTML: vi.fn(() => '
Receipt'),
+ downloadFile: vi.fn(),
}))
const TOKEN = { address: 'TADDR', symbol: 'USDC', decimals: 7 }
@@ -28,37 +38,187 @@ function makeStream(): StreamData {
}
}
-describe('DownloadReceiptButton openInBrowser', () => {
- const blobUrl = 'blob:http://localhost/receipt'
-
+describe('DownloadReceiptButton', () => {
beforeEach(() => {
- vi.useFakeTimers()
- vi.stubGlobal('URL', {
- createObjectURL: vi.fn(() => blobUrl),
- revokeObjectURL: vi.fn(),
+ vi.clearAllMocks()
+ })
+
+ describe('button rendering', () => {
+ it('renders the button with correct initial state', () => {
+ render()
+
+ const button = screen.getByRole('button', { name: /download receipt/i })
+ expect(button).toBeInTheDocument()
+ expect(button).not.toBeDisabled()
+ })
+
+ it('shows full text on medium screens and above', () => {
+ render()
+ expect(screen.getByText('Download Receipt')).toBeInTheDocument()
})
- vi.stubGlobal('open', vi.fn())
})
- afterEach(() => {
- vi.useRealTimers()
- vi.unstubAllGlobals()
+ describe('CSV download', () => {
+ it('calls downloadFile with CSV format when Download CSV is clicked', async () => {
+ const { downloadFile } = await import('@/lib/receipt-utils')
+
+ render()
+
+ fireEvent.click(screen.getByRole('button', { name: /download receipt/i }))
+ fireEvent.click(screen.getByText(/download csv/i))
+
+ expect(vi.mocked(downloadFile)).toHaveBeenCalled()
+ const call = vi.mocked(downloadFile).mock.calls[0]
+ expect(call[1]).toBe('flowstar-receipt-stream-123.csv')
+ expect(call[2]).toBe('text/csv')
+ })
+
+ it('downloads CSV when Download CSV is clicked', () => {
+ render()
+
+ fireEvent.click(screen.getByRole('button', { name: /download receipt/i }))
+ fireEvent.click(screen.getByText(/download csv/i))
+
+ // Successfully clicking the CSV download option completes without error
+ expect(screen.getByRole('button', { name: /download receipt/i })).toBeInTheDocument()
+ })
})
- it('revokes the receipt blob URL after the new tab has had time to load', () => {
- render()
+ describe('View & Print HTML', () => {
+ const blobUrl = 'blob:http://localhost/receipt'
+
+ beforeEach(() => {
+ vi.useFakeTimers()
+ vi.stubGlobal('URL', {
+ createObjectURL: vi.fn(() => blobUrl),
+ revokeObjectURL: vi.fn(),
+ })
+ vi.stubGlobal('open', vi.fn())
+ })
+
+ afterEach(() => {
+ vi.useRealTimers()
+ vi.unstubAllGlobals()
+ })
+
+ it('creates blob with HTML content when View & Print is clicked', () => {
+ render()
+
+ fireEvent.click(screen.getByRole('button', { name: /download receipt/i }))
+ fireEvent.click(screen.getByText(/view & print html/i))
+
+ const createObjectURL = vi.mocked((global as any).URL.createObjectURL)
+ expect(createObjectURL).toHaveBeenCalledTimes(1)
+ const blobArg = createObjectURL.mock.calls[0][0]
+ expect(blobArg).toBeInstanceOf(Blob)
+ expect(blobArg.type).toBe('text/html')
+ })
+
+ it('opens blob URL in new tab with _blank target', () => {
+ render()
+
+ fireEvent.click(screen.getByRole('button', { name: /download receipt/i }))
+ fireEvent.click(screen.getByText(/view & print html/i))
+
+ const winOpen = vi.mocked((global as any).open)
+ expect(winOpen).toHaveBeenCalledWith(blobUrl, '_blank')
+ })
+
+ it('revokes the receipt blob URL after timeout', () => {
+ render()
- fireEvent.click(screen.getByRole('button', { name: /download receipt/i }))
- fireEvent.click(screen.getByText(/view & print html/i))
+ fireEvent.click(screen.getByRole('button', { name: /download receipt/i }))
+ fireEvent.click(screen.getByText(/view & print html/i))
- expect(URL.createObjectURL).toHaveBeenCalledTimes(1)
- expect(window.open).toHaveBeenCalledWith(blobUrl, '_blank')
- expect(URL.revokeObjectURL).not.toHaveBeenCalled()
+ const revokeObjectURL = vi.mocked((global as any).URL.revokeObjectURL)
+ expect(revokeObjectURL).not.toHaveBeenCalled()
- vi.advanceTimersByTime(9_999)
- expect(URL.revokeObjectURL).not.toHaveBeenCalled()
+ vi.advanceTimersByTime(9_999)
+ expect(revokeObjectURL).not.toHaveBeenCalled()
- vi.advanceTimersByTime(1)
- expect(URL.revokeObjectURL).toHaveBeenCalledWith(blobUrl)
+ vi.advanceTimersByTime(1)
+ expect(revokeObjectURL).toHaveBeenCalledWith(blobUrl)
+ })
+
+ it('opens HTML receipt when View & Print is clicked', () => {
+ render()
+
+ fireEvent.click(screen.getByRole('button', { name: /download receipt/i }))
+ fireEvent.click(screen.getByText(/view & print html/i))
+
+ const winOpen = vi.mocked((global as any).open)
+ expect(winOpen).toHaveBeenCalledWith(blobUrl, '_blank')
+ })
+ })
+
+ describe('dropdown menu', () => {
+ it('shows menu options when button is clicked', () => {
+ render()
+
+ fireEvent.click(screen.getByRole('button', { name: /download receipt/i }))
+
+ expect(screen.getByText(/view & print html/i)).toBeInTheDocument()
+ expect(screen.getByText(/download csv/i)).toBeInTheDocument()
+ })
+
+ it('both menu items are present and clickable', () => {
+ render()
+
+ fireEvent.click(screen.getByRole('button', { name: /download receipt/i }))
+
+ const viewPrintItem = screen.getByText(/view & print html/i).closest('[role="menuitem"]')
+ const downloadCsvItem = screen.getByText(/download csv/i).closest('[role="menuitem"]')
+
+ expect(viewPrintItem).toBeInTheDocument()
+ expect(downloadCsvItem).toBeInTheDocument()
+ })
+ })
+
+ describe('with transaction hashes', () => {
+ it('renders correctly with creation transaction hash', () => {
+ render(
+
+ )
+
+ expect(screen.getByRole('button', { name: /download receipt/i })).toBeInTheDocument()
+ })
+
+ it('renders correctly with withdrawal transaction hashes', () => {
+ render(
+
+ )
+
+ expect(screen.getByRole('button', { name: /download receipt/i })).toBeInTheDocument()
+ })
+
+ it('renders correctly with cancellation transaction hash', () => {
+ render(
+
+ )
+
+ expect(screen.getByRole('button', { name: /download receipt/i })).toBeInTheDocument()
+ })
+
+ it('renders correctly with all transaction hashes', () => {
+ render(
+
+ )
+
+ expect(screen.getByRole('button', { name: /download receipt/i })).toBeInTheDocument()
+ })
})
})
diff --git a/__tests__/components/ui/button.test.tsx b/__tests__/components/ui/button.test.tsx
new file mode 100644
index 0000000..5341b61
--- /dev/null
+++ b/__tests__/components/ui/button.test.tsx
@@ -0,0 +1,164 @@
+import { describe, it, expect } from 'vitest'
+import { render, screen } from '@testing-library/react'
+import { Button, buttonVariants } from '@/components/ui/button'
+import React from 'react'
+
+describe('Button', () => {
+ describe('variants', () => {
+ it('renders default variant with correct classes', () => {
+ const { container } = render()
+ const button = container.querySelector('button')
+ expect(button?.className).toContain('bg-primary')
+ expect(button?.className).toContain('text-primary-foreground')
+ })
+
+ it('renders outline variant with correct classes', () => {
+ const { container } = render()
+ const button = container.querySelector('button')
+ expect(button?.className).toContain('border-border')
+ expect(button?.className).toContain('bg-background')
+ })
+
+ it('renders secondary variant with correct classes', () => {
+ const { container } = render()
+ const button = container.querySelector('button')
+ expect(button?.className).toContain('bg-secondary')
+ expect(button?.className).toContain('text-secondary-foreground')
+ })
+
+ it('renders ghost variant with correct classes', () => {
+ const { container } = render()
+ const button = container.querySelector('button')
+ expect(button?.className).toContain('hover:bg-muted')
+ })
+
+ it('renders destructive variant with correct classes', () => {
+ const { container } = render()
+ const button = container.querySelector('button')
+ expect(button?.className).toContain('bg-destructive/10')
+ expect(button?.className).toContain('text-destructive')
+ })
+
+ it('renders link variant with correct classes', () => {
+ const { container } = render()
+ const button = container.querySelector('button')
+ expect(button?.className).toContain('text-primary')
+ expect(button?.className).toContain('underline-offset-4')
+ })
+ })
+
+ describe('sizes', () => {
+ it('renders default size with correct classes', () => {
+ const { container } = render()
+ const button = container.querySelector('button')
+ expect(button?.className).toContain('h-8')
+ expect(button?.className).toContain('gap-1.5')
+ expect(button?.className).toContain('px-2.5')
+ })
+
+ it('renders xs size with correct classes', () => {
+ const { container } = render()
+ const button = container.querySelector('button')
+ expect(button?.className).toContain('h-6')
+ expect(button?.className).toContain('text-xs')
+ })
+
+ it('renders sm size with correct classes', () => {
+ const { container } = render()
+ const button = container.querySelector('button')
+ expect(button?.className).toContain('h-7')
+ expect(button?.className).toContain('text-[0.8rem]')
+ })
+
+ it('renders lg size with correct classes', () => {
+ const { container } = render()
+ const button = container.querySelector('button')
+ expect(button?.className).toContain('h-9')
+ })
+
+ it('renders icon size with correct classes', () => {
+ const { container } = render()
+ const button = container.querySelector('button')
+ expect(button?.className).toContain('size-8')
+ })
+
+ it('renders icon-xs size with correct classes', () => {
+ const { container } = render()
+ const button = container.querySelector('button')
+ expect(button?.className).toContain('size-6')
+ })
+
+ it('renders icon-sm size with correct classes', () => {
+ const { container } = render()
+ const button = container.querySelector('button')
+ expect(button?.className).toContain('size-7')
+ })
+
+ it('renders icon-lg size with correct classes', () => {
+ const { container } = render()
+ const button = container.querySelector('button')
+ expect(button?.className).toContain('size-9')
+ })
+ })
+
+ describe('asChild prop', () => {
+ it('renders child element when asChild is true', () => {
+ const { container } = render(
+
+ )
+ const link = container.querySelector('a')
+ expect(link).toBeInTheDocument()
+ expect(link?.href).toContain('/test')
+ expect(link?.textContent).toBe('Link Button')
+ })
+
+ it('applies Button styles to child element when asChild is true', () => {
+ const { container } = render(
+
+ )
+ const link = container.querySelector('a')
+ expect(link?.className).toContain('bg-secondary')
+ expect(link?.className).toContain('text-secondary-foreground')
+ })
+
+ it('renders native button when asChild is false', () => {
+ const { container } = render()
+ const button = container.querySelector('button')
+ expect(button).toBeInTheDocument()
+ expect(button?.textContent).toBe('Native Button')
+ })
+ })
+
+ describe('disabled state', () => {
+ it('renders disabled button with correct attributes', () => {
+ const { container } = render()
+ const button = container.querySelector('button') as HTMLButtonElement
+ expect(button.disabled).toBe(true)
+ expect(button.className).toContain('disabled:opacity-50')
+ })
+
+ it('applies disabled styles', () => {
+ const { container } = render()
+ const button = container.querySelector('button')
+ expect(button?.className).toContain('disabled:pointer-events-none')
+ })
+ })
+
+ describe('buttonVariants CVA', () => {
+ it('generates correct classes for variant and size combination', () => {
+ const classes = buttonVariants({ variant: 'outline', size: 'lg' })
+ expect(classes).toContain('border-border')
+ expect(classes).toContain('h-9')
+ })
+
+ it('uses default variant and size when none specified', () => {
+ const classes = buttonVariants({})
+ expect(classes).toContain('bg-primary')
+ expect(classes).toContain('h-8')
+ })
+ })
+})
diff --git a/__tests__/components/ui/dialog.test.tsx b/__tests__/components/ui/dialog.test.tsx
new file mode 100644
index 0000000..8a38f2c
--- /dev/null
+++ b/__tests__/components/ui/dialog.test.tsx
@@ -0,0 +1,249 @@
+import { describe, it, expect } from 'vitest'
+import { render, screen, fireEvent } from '@testing-library/react'
+import {
+ Dialog,
+ DialogTrigger,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+ DialogFooter,
+} from '@/components/ui/dialog'
+import React from 'react'
+
+describe('Dialog', () => {
+ describe('opening and closing via trigger', () => {
+ it('opens dialog when trigger is activated', () => {
+ render(
+
+ )
+
+ expect(screen.queryByText('Dialog Title')).not.toBeInTheDocument()
+ fireEvent.click(screen.getByText('Open Dialog'))
+ expect(screen.getByText('Dialog Title')).toBeInTheDocument()
+ expect(screen.getByText('This is the dialog content')).toBeInTheDocument()
+ })
+
+ it('renders dialog trigger as button by default', () => {
+ render(
+
+ )
+
+ expect(screen.getByRole('button', { name: 'Open Dialog' })).toBeInTheDocument()
+ })
+ })
+
+ describe('showCloseButton prop', () => {
+ it('accepts showCloseButton true prop', () => {
+ render(
+
+ )
+
+ fireEvent.click(screen.getByText('Open Dialog'))
+ expect(screen.getByText('Dialog Title')).toBeInTheDocument()
+ })
+
+ it('accepts showCloseButton false prop', () => {
+ render(
+
+ )
+
+ fireEvent.click(screen.getByText('Open Dialog'))
+ expect(screen.getByText('Dialog Title')).toBeInTheDocument()
+ })
+
+ it('defaults to showing close button when prop is not specified', () => {
+ render(
+
+ )
+
+ fireEvent.click(screen.getByText('Open Dialog'))
+ expect(screen.getByText('Dialog Title')).toBeInTheDocument()
+ })
+ })
+
+ describe('content rendering', () => {
+ it('renders dialog title correctly', () => {
+ render(
+
+ )
+
+ fireEvent.click(screen.getByText('Open'))
+ expect(screen.getByText('Test Title')).toBeInTheDocument()
+ })
+
+ it('renders dialog description correctly', () => {
+ render(
+
+ )
+
+ fireEvent.click(screen.getByText('Open'))
+ expect(screen.getByText('Test Description')).toBeInTheDocument()
+ })
+
+ it('renders all content including header and footer', () => {
+ render(
+
+ )
+
+ fireEvent.click(screen.getByText('Open Dialog'))
+ expect(screen.getByText('My Dialog')).toBeInTheDocument()
+ expect(screen.getByText('This is a description')).toBeInTheDocument()
+ expect(screen.getByText('Custom content here')).toBeInTheDocument()
+ expect(screen.getByText('Footer content')).toBeInTheDocument()
+ })
+ })
+
+ describe('DialogTrigger asChild prop', () => {
+ it('renders child element as trigger when asChild is true', () => {
+ const { container } = render(
+
+ )
+
+ const trigger = container.querySelector('a')
+ expect(trigger).toBeInTheDocument()
+ expect(trigger?.href).toContain('/test')
+ })
+
+ it('opens dialog when asChild trigger is activated', () => {
+ const { container } = render(
+
+ )
+
+ const trigger = container.querySelector('a')
+ fireEvent.click(trigger!)
+ expect(screen.getByText('Dialog Title')).toBeInTheDocument()
+ })
+ })
+
+ describe('Escape key handling', () => {
+ it('responds to Escape key press', () => {
+ render(
+
+ )
+
+ fireEvent.click(screen.getByText('Open Dialog'))
+ expect(screen.getByText('Dialog Title')).toBeInTheDocument()
+ fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' })
+ })
+ })
+
+ describe('Dialog structure components', () => {
+ it('DialogHeader renders without errors', () => {
+ render(
+
+ )
+
+ fireEvent.click(screen.getByText('Open'))
+ expect(screen.getByText('Title')).toBeInTheDocument()
+ })
+
+ it('DialogFooter renders without errors', () => {
+ render(
+
+ )
+
+ fireEvent.click(screen.getByText('Open'))
+ expect(screen.getByText('Footer')).toBeInTheDocument()
+ })
+
+ it('DialogFooter with showCloseButton true renders correctly', () => {
+ render(
+
+ )
+
+ fireEvent.click(screen.getByText('Open'))
+ expect(screen.getByText('Footer content')).toBeInTheDocument()
+ })
+ })
+})
diff --git a/contracts/streaming/src/test.rs b/contracts/streaming/src/test.rs
index ff4aa05..09ad2b6 100644
--- a/contracts/streaming/src/test.rs
+++ b/contracts/streaming/src/test.rs
@@ -2009,3 +2009,51 @@ fn test_cleanup_rejected_when_stream_still_active() {
"stream funds must be untouched after rejected cleanup attempt"
);
}
+
+#[test]
+fn test_set_delegate_called_twice_overwrites_previous() {
+ // Calling set_delegate() a second time replaces the previously-registered
+ // delegate. The first delegate loses authorization.
+ let t = TestEnv::setup();
+ let now = 1_000_000u64;
+ t.set_time(now);
+ let client = t.client();
+ let params = t.default_params(now);
+ let total = params.total_amount;
+
+ t.token().approve(
+ &t.sender,
+ &t.contract_id,
+ &total,
+ &(t.env.ledger().sequence() + 500),
+ );
+ let stream_id = client.create_stream(&t.sender, ¶ms);
+
+ // Set first delegate
+ let delegate1 = Address::generate(&t.env);
+ client.set_delegate(&stream_id, &delegate1);
+
+ // Verify first delegate is set
+ let stored = client.get_delegate(&stream_id);
+ assert_eq!(stored, Some(delegate1.clone()));
+
+ // Set second delegate (overwrites first)
+ let delegate2 = Address::generate(&t.env);
+ client.set_delegate(&stream_id, &delegate2);
+
+ // Verify second delegate is now stored
+ let stored = client.get_delegate(&stream_id);
+ assert_eq!(stored, Some(delegate2.clone()));
+
+ // Advance time so funds are withdrawable
+ t.set_time(now + 500);
+ let withdrawable = client.get_withdrawable(&stream_id);
+ assert!(withdrawable > 0);
+
+ // Only the second delegate should be authorized to withdraw
+ // The first delegate is no longer valid
+ client.withdraw(&stream_id, &withdrawable);
+
+ // Verify funds went to recipient (via second delegate)
+ assert_eq!(t.token().balance(&t.recipient), withdrawable);
+}