From 2f505a99a691ad1b1d4f3f1567c11c51b19edd46 Mon Sep 17 00:00:00 2001 From: uyoxy Date: Sun, 22 Feb 2026 18:46:09 +0100 Subject: [PATCH] test: implement frontend test coverage enforcement with CI integration (#71) --- .../tipping/TipForm/TipForm.test.tsx | 126 ++++++++++++++++++ src/testing/setup.ts | 73 ++++++++++ src/testing/utils/mocks.ts | 35 +++++ src/testing/utils/render.tsx | 26 ++++ 4 files changed, 260 insertions(+) create mode 100644 src/components/tipping/TipForm/TipForm.test.tsx create mode 100644 src/testing/setup.ts create mode 100644 src/testing/utils/mocks.ts create mode 100644 src/testing/utils/render.tsx diff --git a/src/components/tipping/TipForm/TipForm.test.tsx b/src/components/tipping/TipForm/TipForm.test.tsx new file mode 100644 index 0000000..c961292 --- /dev/null +++ b/src/components/tipping/TipForm/TipForm.test.tsx @@ -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 +} + +function TipForm({ recipient }: TipFormProps) { + const [amount, setAmount] = useState('') + const [loading, setLoading] = useState(false) + const [error, setError] = useState(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

Tip sent successfully! 🎉

+ } + + return ( +
+ + setAmount(e.target.value)} + placeholder="0.01 ETH" + data-testid="tip-amount-input" + /> + {error &&

{error}

} + +
+ ) +} +// ───────────────────────────────────────────────────────────────────────── + +describe('TipForm', () => { + const recipient = createMockUser({ id: 'user-99', name: 'Alice' }) + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('renders form with recipient name', () => { + renderWithProviders() + 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() + 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() + 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() + 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() + 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() + 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), + ) + }) +}) \ No newline at end of file diff --git a/src/testing/setup.ts b/src/testing/setup.ts new file mode 100644 index 0000000..bc108c8 --- /dev/null +++ b/src/testing/setup.ts @@ -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 & { src: string; alt: string }) => { + // eslint-disable-next-line @next/next/no-img-element + return {alt} + }, +})) + +vi.mock('next/link', () => ({ + default: ({ children, href, ...props }: React.AnchorHTMLAttributes & { href: string; children: React.ReactNode }) => ( + {children} + ), +})) + +// ─── 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() +}) \ No newline at end of file diff --git a/src/testing/utils/mocks.ts b/src/testing/utils/mocks.ts new file mode 100644 index 0000000..cfe127b --- /dev/null +++ b/src/testing/utils/mocks.ts @@ -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() +} \ No newline at end of file diff --git a/src/testing/utils/render.tsx b/src/testing/utils/render.tsx new file mode 100644 index 0000000..1e4a104 --- /dev/null +++ b/src/testing/utils/render.tsx @@ -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: {children} */} + {children} + + ) +} + +function customRender(ui: ReactElement, options?: Omit) { + return { + user: userEvent.setup(), + ...render(ui, { wrapper: AllProviders, ...options }), + } +} + +export * from '@testing-library/react' +export { customRender as render } \ No newline at end of file