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
126 changes: 126 additions & 0 deletions src/components/tipping/TipForm/TipForm.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@

import React, { useState } from 'react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { renderWithProviders, screen, waitFor } from '@/testing'
import { createMockUser, asyncMock, asyncErrorMock } from '@/testing/utils/mocks'

// ── Service mock ──────────────────────────────────────────────────────────
const mockSendTip = vi.fn()

vi.mock('@/services/tipService', () => ({
sendTip: (...args: unknown[]) => mockSendTip(...args),
}))

// ── Inline minimal TipForm (replace with real import) ─────────────────────
interface TipFormProps {
recipient: ReturnType<typeof createMockUser>
}

function TipForm({ recipient }: TipFormProps) {
const [amount, setAmount] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState(false)

async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError(null)

if (!amount || isNaN(Number(amount)) || Number(amount) <= 0) {
setError('Please enter a valid tip amount.')
return
}

setLoading(true)
try {
await mockSendTip({ recipientId: recipient.id, amount: Number(amount) })
setSuccess(true)
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Transaction failed.')
} finally {
setLoading(false)
}
}

if (success) {
return <p data-testid="success-msg">Tip sent successfully! 🎉</p>
}

return (
<form onSubmit={handleSubmit} data-testid="tip-form">
<label htmlFor="tip-amount">Tip {recipient.name}</label>
<input
id="tip-amount"
type="number"
step="0.001"
min="0"
value={amount}
onChange={(e) => setAmount(e.target.value)}
placeholder="0.01 ETH"
data-testid="tip-amount-input"
/>
{error && <p role="alert" data-testid="tip-error">{error}</p>}
<button type="submit" disabled={loading} data-testid="tip-submit">
{loading ? 'Sending…' : 'Send Tip'}
</button>
</form>
)
}
// ─────────────────────────────────────────────────────────────────────────

describe('TipForm', () => {
const recipient = createMockUser({ id: 'user-99', name: 'Alice' })

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

it('renders form with recipient name', () => {
renderWithProviders(<TipForm recipient={recipient} />)
expect(screen.getByLabelText(/Tip Alice/i)).toBeInTheDocument()
expect(screen.getByTestId('tip-submit')).toHaveTextContent('Send Tip')
})

it('shows validation error for empty amount', async () => {
const { user } = renderWithProviders(<TipForm recipient={recipient} />)
await user.click(screen.getByTestId('tip-submit'))
expect(screen.getByTestId('tip-error')).toHaveTextContent(/valid tip amount/i)
expect(mockSendTip).not.toHaveBeenCalled()
})

it('shows validation error for zero amount', async () => {
const { user } = renderWithProviders(<TipForm recipient={recipient} />)
await user.type(screen.getByTestId('tip-amount-input'), '0')
await user.click(screen.getByTestId('tip-submit'))
expect(screen.getByTestId('tip-error')).toBeInTheDocument()
})

it('shows loading state while sending tip', async () => {
mockSendTip.mockImplementation(() => new Promise(() => {})) // never resolves
const { user } = renderWithProviders(<TipForm recipient={recipient} />)
await user.type(screen.getByTestId('tip-amount-input'), '0.01')
await user.click(screen.getByTestId('tip-submit'))
expect(screen.getByTestId('tip-submit')).toBeDisabled()
expect(screen.getByTestId('tip-submit')).toHaveTextContent('Sending…')
})

it('shows success message after successful tip', async () => {
mockSendTip.mockResolvedValueOnce({ txHash: '0xabc' })
const { user } = renderWithProviders(<TipForm recipient={recipient} />)
await user.type(screen.getByTestId('tip-amount-input'), '0.05')
await user.click(screen.getByTestId('tip-submit'))
await waitFor(() =>
expect(screen.getByTestId('success-msg')).toBeInTheDocument(),
)
})

it('shows error message when tip transaction fails', async () => {
mockSendTip.mockRejectedValueOnce(new Error('Insufficient funds'))
const { user } = renderWithProviders(<TipForm recipient={recipient} />)
await user.type(screen.getByTestId('tip-amount-input'), '999')
await user.click(screen.getByTestId('tip-submit'))
await waitFor(() =>
expect(screen.getByTestId('tip-error')).toHaveTextContent(/Insufficient funds/i),
)
})
})
73 changes: 73 additions & 0 deletions src/testing/setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import '@testing-library/jest-dom'
import { cleanup } from '@testing-library/react'
import { afterEach, vi, beforeAll } from 'vitest'

// Cleanup after each test
afterEach(() => {
cleanup()
})

// ─── Next.js App Router mocks ──────────────────────────────────────────────
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: vi.fn(),
replace: vi.fn(),
prefetch: vi.fn(),
back: vi.fn(),
forward: vi.fn(),
refresh: vi.fn(),
}),
useSearchParams: () => new URLSearchParams(),
usePathname: () => '/',
useParams: () => ({}),
redirect: vi.fn(),
notFound: vi.fn(),
}))

vi.mock('next/image', () => ({
default: ({ src, alt, ...props }: React.ImgHTMLAttributes<HTMLImageElement> & { src: string; alt: string }) => {
// eslint-disable-next-line @next/next/no-img-element
return <img src={src} alt={alt} {...props} />
},
}))

vi.mock('next/link', () => ({
default: ({ children, href, ...props }: React.AnchorHTMLAttributes<HTMLAnchorElement> & { href: string; children: React.ReactNode }) => (
<a href={href} {...props}>{children}</a>
),
}))

// ─── Browser API stubs ──────────────────────────────────────────────────────
beforeAll(() => {
// IntersectionObserver
globalThis.IntersectionObserver = vi.fn().mockImplementation(() => ({
observe: vi.fn(),
unobserve: vi.fn(),
disconnect: vi.fn(),
}))

// ResizeObserver
globalThis.ResizeObserver = vi.fn().mockImplementation(() => ({
observe: vi.fn(),
unobserve: vi.fn(),
disconnect: vi.fn(),
}))

// matchMedia
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
})

// scrollTo
window.scrollTo = vi.fn()
})
35 changes: 35 additions & 0 deletions src/testing/utils/mocks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { vi } from 'vitest'

/** Wallet mock for tipping/connection flows */
export const mockWallet = {
address: '0xABCDEF1234567890',
isConnected: true,
connect: vi.fn().mockResolvedValue(undefined),
disconnect: vi.fn().mockResolvedValue(undefined),
balance: '1.5',
}

export const mockDisconnectedWallet = {
...mockWallet,
address: null,
isConnected: false,
}

/** Generic async handler that resolves successfully */
export const mockSuccessHandler = vi.fn().mockResolvedValue({ success: true })

/** Generic async handler that rejects */
export const mockErrorHandler = vi.fn().mockRejectedValue(new Error('Something went wrong'))

/** Mock user */
export const mockUser = {
id: 'user-123',
name: 'Test User',
email: 'test@teachlink.com',
avatar: '/avatar.png',
}

/** Reset all mocks between tests */
export function resetMocks() {
vi.clearAllMocks()
}
26 changes: 26 additions & 0 deletions src/testing/utils/render.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import React, { ReactElement } from 'react'
import { render, RenderOptions } from '@testing-library/react'
import userEvent from '@testing-library/user-event'

/**
* Wrap with all your app providers here (Redux store, Context, etc.)
* Add/remove providers to match your actual app setup.
*/
function AllProviders({ children }: { children: React.ReactNode }) {
return (
<>
{/* Example: <StoreProvider><ThemeProvider>{children}</ThemeProvider></StoreProvider> */}
{children}
</>
)
}

function customRender(ui: ReactElement, options?: Omit<RenderOptions, 'wrapper'>) {
return {
user: userEvent.setup(),
...render(ui, { wrapper: AllProviders, ...options }),
}
}

export * from '@testing-library/react'
export { customRender as render }
Loading