Skip to content

Commit 50327ed

Browse files
Merge pull request #75 from Uyoxy/feat/frontend-test-coverage
test: implement frontend test coverage enforcement with CI integratio…
2 parents 1c9b37c + 2f505a9 commit 50327ed

File tree

4 files changed

+260
-0
lines changed

4 files changed

+260
-0
lines changed
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
2+
import React, { useState } from 'react'
3+
import { describe, it, expect, vi, beforeEach } from 'vitest'
4+
import { renderWithProviders, screen, waitFor } from '@/testing'
5+
import { createMockUser, asyncMock, asyncErrorMock } from '@/testing/utils/mocks'
6+
7+
// ── Service mock ──────────────────────────────────────────────────────────
8+
const mockSendTip = vi.fn()
9+
10+
vi.mock('@/services/tipService', () => ({
11+
sendTip: (...args: unknown[]) => mockSendTip(...args),
12+
}))
13+
14+
// ── Inline minimal TipForm (replace with real import) ─────────────────────
15+
interface TipFormProps {
16+
recipient: ReturnType<typeof createMockUser>
17+
}
18+
19+
function TipForm({ recipient }: TipFormProps) {
20+
const [amount, setAmount] = useState('')
21+
const [loading, setLoading] = useState(false)
22+
const [error, setError] = useState<string | null>(null)
23+
const [success, setSuccess] = useState(false)
24+
25+
async function handleSubmit(e: React.FormEvent) {
26+
e.preventDefault()
27+
setError(null)
28+
29+
if (!amount || isNaN(Number(amount)) || Number(amount) <= 0) {
30+
setError('Please enter a valid tip amount.')
31+
return
32+
}
33+
34+
setLoading(true)
35+
try {
36+
await mockSendTip({ recipientId: recipient.id, amount: Number(amount) })
37+
setSuccess(true)
38+
} catch (err: unknown) {
39+
setError(err instanceof Error ? err.message : 'Transaction failed.')
40+
} finally {
41+
setLoading(false)
42+
}
43+
}
44+
45+
if (success) {
46+
return <p data-testid="success-msg">Tip sent successfully! 🎉</p>
47+
}
48+
49+
return (
50+
<form onSubmit={handleSubmit} data-testid="tip-form">
51+
<label htmlFor="tip-amount">Tip {recipient.name}</label>
52+
<input
53+
id="tip-amount"
54+
type="number"
55+
step="0.001"
56+
min="0"
57+
value={amount}
58+
onChange={(e) => setAmount(e.target.value)}
59+
placeholder="0.01 ETH"
60+
data-testid="tip-amount-input"
61+
/>
62+
{error && <p role="alert" data-testid="tip-error">{error}</p>}
63+
<button type="submit" disabled={loading} data-testid="tip-submit">
64+
{loading ? 'Sending…' : 'Send Tip'}
65+
</button>
66+
</form>
67+
)
68+
}
69+
// ─────────────────────────────────────────────────────────────────────────
70+
71+
describe('TipForm', () => {
72+
const recipient = createMockUser({ id: 'user-99', name: 'Alice' })
73+
74+
beforeEach(() => {
75+
vi.clearAllMocks()
76+
})
77+
78+
it('renders form with recipient name', () => {
79+
renderWithProviders(<TipForm recipient={recipient} />)
80+
expect(screen.getByLabelText(/Tip Alice/i)).toBeInTheDocument()
81+
expect(screen.getByTestId('tip-submit')).toHaveTextContent('Send Tip')
82+
})
83+
84+
it('shows validation error for empty amount', async () => {
85+
const { user } = renderWithProviders(<TipForm recipient={recipient} />)
86+
await user.click(screen.getByTestId('tip-submit'))
87+
expect(screen.getByTestId('tip-error')).toHaveTextContent(/valid tip amount/i)
88+
expect(mockSendTip).not.toHaveBeenCalled()
89+
})
90+
91+
it('shows validation error for zero amount', async () => {
92+
const { user } = renderWithProviders(<TipForm recipient={recipient} />)
93+
await user.type(screen.getByTestId('tip-amount-input'), '0')
94+
await user.click(screen.getByTestId('tip-submit'))
95+
expect(screen.getByTestId('tip-error')).toBeInTheDocument()
96+
})
97+
98+
it('shows loading state while sending tip', async () => {
99+
mockSendTip.mockImplementation(() => new Promise(() => {})) // never resolves
100+
const { user } = renderWithProviders(<TipForm recipient={recipient} />)
101+
await user.type(screen.getByTestId('tip-amount-input'), '0.01')
102+
await user.click(screen.getByTestId('tip-submit'))
103+
expect(screen.getByTestId('tip-submit')).toBeDisabled()
104+
expect(screen.getByTestId('tip-submit')).toHaveTextContent('Sending…')
105+
})
106+
107+
it('shows success message after successful tip', async () => {
108+
mockSendTip.mockResolvedValueOnce({ txHash: '0xabc' })
109+
const { user } = renderWithProviders(<TipForm recipient={recipient} />)
110+
await user.type(screen.getByTestId('tip-amount-input'), '0.05')
111+
await user.click(screen.getByTestId('tip-submit'))
112+
await waitFor(() =>
113+
expect(screen.getByTestId('success-msg')).toBeInTheDocument(),
114+
)
115+
})
116+
117+
it('shows error message when tip transaction fails', async () => {
118+
mockSendTip.mockRejectedValueOnce(new Error('Insufficient funds'))
119+
const { user } = renderWithProviders(<TipForm recipient={recipient} />)
120+
await user.type(screen.getByTestId('tip-amount-input'), '999')
121+
await user.click(screen.getByTestId('tip-submit'))
122+
await waitFor(() =>
123+
expect(screen.getByTestId('tip-error')).toHaveTextContent(/Insufficient funds/i),
124+
)
125+
})
126+
})

src/testing/setup.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import '@testing-library/jest-dom'
2+
import { cleanup } from '@testing-library/react'
3+
import { afterEach, vi, beforeAll } from 'vitest'
4+
5+
// Cleanup after each test
6+
afterEach(() => {
7+
cleanup()
8+
})
9+
10+
// ─── Next.js App Router mocks ──────────────────────────────────────────────
11+
vi.mock('next/navigation', () => ({
12+
useRouter: () => ({
13+
push: vi.fn(),
14+
replace: vi.fn(),
15+
prefetch: vi.fn(),
16+
back: vi.fn(),
17+
forward: vi.fn(),
18+
refresh: vi.fn(),
19+
}),
20+
useSearchParams: () => new URLSearchParams(),
21+
usePathname: () => '/',
22+
useParams: () => ({}),
23+
redirect: vi.fn(),
24+
notFound: vi.fn(),
25+
}))
26+
27+
vi.mock('next/image', () => ({
28+
default: ({ src, alt, ...props }: React.ImgHTMLAttributes<HTMLImageElement> & { src: string; alt: string }) => {
29+
// eslint-disable-next-line @next/next/no-img-element
30+
return <img src={src} alt={alt} {...props} />
31+
},
32+
}))
33+
34+
vi.mock('next/link', () => ({
35+
default: ({ children, href, ...props }: React.AnchorHTMLAttributes<HTMLAnchorElement> & { href: string; children: React.ReactNode }) => (
36+
<a href={href} {...props}>{children}</a>
37+
),
38+
}))
39+
40+
// ─── Browser API stubs ──────────────────────────────────────────────────────
41+
beforeAll(() => {
42+
// IntersectionObserver
43+
globalThis.IntersectionObserver = vi.fn().mockImplementation(() => ({
44+
observe: vi.fn(),
45+
unobserve: vi.fn(),
46+
disconnect: vi.fn(),
47+
}))
48+
49+
// ResizeObserver
50+
globalThis.ResizeObserver = vi.fn().mockImplementation(() => ({
51+
observe: vi.fn(),
52+
unobserve: vi.fn(),
53+
disconnect: vi.fn(),
54+
}))
55+
56+
// matchMedia
57+
Object.defineProperty(window, 'matchMedia', {
58+
writable: true,
59+
value: vi.fn().mockImplementation((query: string) => ({
60+
matches: false,
61+
media: query,
62+
onchange: null,
63+
addListener: vi.fn(),
64+
removeListener: vi.fn(),
65+
addEventListener: vi.fn(),
66+
removeEventListener: vi.fn(),
67+
dispatchEvent: vi.fn(),
68+
})),
69+
})
70+
71+
// scrollTo
72+
window.scrollTo = vi.fn()
73+
})

src/testing/utils/mocks.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { vi } from 'vitest'
2+
3+
/** Wallet mock for tipping/connection flows */
4+
export const mockWallet = {
5+
address: '0xABCDEF1234567890',
6+
isConnected: true,
7+
connect: vi.fn().mockResolvedValue(undefined),
8+
disconnect: vi.fn().mockResolvedValue(undefined),
9+
balance: '1.5',
10+
}
11+
12+
export const mockDisconnectedWallet = {
13+
...mockWallet,
14+
address: null,
15+
isConnected: false,
16+
}
17+
18+
/** Generic async handler that resolves successfully */
19+
export const mockSuccessHandler = vi.fn().mockResolvedValue({ success: true })
20+
21+
/** Generic async handler that rejects */
22+
export const mockErrorHandler = vi.fn().mockRejectedValue(new Error('Something went wrong'))
23+
24+
/** Mock user */
25+
export const mockUser = {
26+
id: 'user-123',
27+
name: 'Test User',
28+
email: 'test@teachlink.com',
29+
avatar: '/avatar.png',
30+
}
31+
32+
/** Reset all mocks between tests */
33+
export function resetMocks() {
34+
vi.clearAllMocks()
35+
}

src/testing/utils/render.tsx

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import React, { ReactElement } from 'react'
2+
import { render, RenderOptions } from '@testing-library/react'
3+
import userEvent from '@testing-library/user-event'
4+
5+
/**
6+
* Wrap with all your app providers here (Redux store, Context, etc.)
7+
* Add/remove providers to match your actual app setup.
8+
*/
9+
function AllProviders({ children }: { children: React.ReactNode }) {
10+
return (
11+
<>
12+
{/* Example: <StoreProvider><ThemeProvider>{children}</ThemeProvider></StoreProvider> */}
13+
{children}
14+
</>
15+
)
16+
}
17+
18+
function customRender(ui: ReactElement, options?: Omit<RenderOptions, 'wrapper'>) {
19+
return {
20+
user: userEvent.setup(),
21+
...render(ui, { wrapper: AllProviders, ...options }),
22+
}
23+
}
24+
25+
export * from '@testing-library/react'
26+
export { customRender as render }

0 commit comments

Comments
 (0)