|
1 | | -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; |
2 | | -import { describe, expect, it, vi, afterEach } from 'vitest'; |
3 | | -import CertificateGenerationPage from '../page'; |
4 | | -import { apiClient } from '@/lib/api'; |
| 1 | +import { render, screen, waitFor, fireEvent } from '@testing-library/react'; |
| 2 | +import userEvent from '@testing-library/user-event'; |
| 3 | +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; |
| 4 | + |
| 5 | +// Mock recharts — jsdom cannot render SVG canvas; we just need to verify data is passed |
| 6 | +vi.mock('recharts', () => ({ |
| 7 | + BarChart: ({ children, data }: { children: React.ReactNode; data: unknown[] }) => ( |
| 8 | + <div data-testid="bar-chart" data-count={data.length}> |
| 9 | + {children} |
| 10 | + </div> |
| 11 | + ), |
| 12 | + Bar: () => null, |
| 13 | + XAxis: () => null, |
| 14 | + YAxis: () => null, |
| 15 | + CartesianGrid: () => null, |
| 16 | + Tooltip: () => null, |
| 17 | + ResponsiveContainer: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, |
| 18 | +})); |
5 | 19 |
|
6 | 20 | vi.mock('@/lib/api', () => ({ |
7 | 21 | apiClient: { |
8 | 22 | post: vi.fn(), |
9 | 23 | }, |
10 | 24 | })); |
11 | 25 |
|
| 26 | +import { CertificateStats } from '@/components/certificates/CertificateStats'; |
| 27 | +import CertificateGenerationPage from '@/app/certificates/page'; |
| 28 | +import { apiClient } from '@/lib/api'; |
| 29 | + |
12 | 30 | afterEach(() => { |
13 | 31 | vi.clearAllMocks(); |
14 | 32 | }); |
15 | 33 |
|
| 34 | +// --------------------------------------------------------------------------- |
| 35 | +// CertificateStats unit tests |
| 36 | +// --------------------------------------------------------------------------- |
| 37 | + |
| 38 | +describe('CertificateStats', () => { |
| 39 | + const sampleData = [ |
| 40 | + { course: '…abc00001', count: 3 }, |
| 41 | + { course: '…abc00002', count: 1 }, |
| 42 | + ]; |
| 43 | + |
| 44 | + it('renders the section heading', () => { |
| 45 | + render( |
| 46 | + <CertificateStats data={sampleData} totalGenerated={4} distinctCourses={2} />, |
| 47 | + ); |
| 48 | + expect(screen.getByText('Generation Statistics')).toBeInTheDocument(); |
| 49 | + }); |
| 50 | + |
| 51 | + it('displays totalGenerated value', () => { |
| 52 | + render( |
| 53 | + <CertificateStats data={sampleData} totalGenerated={7} distinctCourses={2} />, |
| 54 | + ); |
| 55 | + expect(screen.getByLabelText(/Certificates Generated: 7/i)).toBeInTheDocument(); |
| 56 | + }); |
| 57 | + |
| 58 | + it('displays distinctCourses value', () => { |
| 59 | + render( |
| 60 | + <CertificateStats data={sampleData} totalGenerated={4} distinctCourses={2} />, |
| 61 | + ); |
| 62 | + expect(screen.getByLabelText(/Courses Covered: 2/i)).toBeInTheDocument(); |
| 63 | + }); |
| 64 | + |
| 65 | + it('displays peak course count (max of data)', () => { |
| 66 | + render( |
| 67 | + <CertificateStats data={sampleData} totalGenerated={4} distinctCourses={2} />, |
| 68 | + ); |
| 69 | + // peak = 3 (max of counts 3 and 1) |
| 70 | + expect(screen.getByLabelText(/Peak Course Count: 3/i)).toBeInTheDocument(); |
| 71 | + }); |
| 72 | + |
| 73 | + it('renders the bar chart when data is non-empty', () => { |
| 74 | + render( |
| 75 | + <CertificateStats data={sampleData} totalGenerated={4} distinctCourses={2} />, |
| 76 | + ); |
| 77 | + expect(screen.getByTestId('bar-chart')).toBeInTheDocument(); |
| 78 | + }); |
| 79 | + |
| 80 | + it('passes correct data length to bar chart', () => { |
| 81 | + render( |
| 82 | + <CertificateStats data={sampleData} totalGenerated={4} distinctCourses={2} />, |
| 83 | + ); |
| 84 | + expect(screen.getByTestId('bar-chart')).toHaveAttribute('data-count', '2'); |
| 85 | + }); |
| 86 | + |
| 87 | + it('does not render the bar chart when data is empty', () => { |
| 88 | + render( |
| 89 | + <CertificateStats data={[]} totalGenerated={0} distinctCourses={0} />, |
| 90 | + ); |
| 91 | + expect(screen.queryByTestId('bar-chart')).not.toBeInTheDocument(); |
| 92 | + }); |
| 93 | + |
| 94 | + it('shows 0 for Peak Course Count when data is empty', () => { |
| 95 | + render( |
| 96 | + <CertificateStats data={[]} totalGenerated={0} distinctCourses={0} />, |
| 97 | + ); |
| 98 | + expect(screen.getByLabelText(/Peak Course Count: 0/i)).toBeInTheDocument(); |
| 99 | + }); |
| 100 | + |
| 101 | + it('is accessible: section has an aria-label', () => { |
| 102 | + const { container } = render( |
| 103 | + <CertificateStats data={sampleData} totalGenerated={4} distinctCourses={2} />, |
| 104 | + ); |
| 105 | + const section = container.querySelector('section'); |
| 106 | + expect(section).toHaveAttribute('aria-label', 'Certificate generation statistics'); |
| 107 | + }); |
| 108 | +}); |
| 109 | + |
| 110 | +// --------------------------------------------------------------------------- |
| 111 | +// CertificateGenerationPage integration tests |
| 112 | +// --------------------------------------------------------------------------- |
| 113 | + |
16 | 114 | describe('CertificateGenerationPage', () => { |
17 | | - it('submits certificate generation data and displays a success message', async () => { |
18 | | - vi.mocked(apiClient.post).mockResolvedValue({ certificateId: 'cert-123' }); |
| 115 | + const COURSE_ID = '123e4567-e89b-12d3-a456-426614174000'; |
| 116 | + |
| 117 | + it('submits and shows success message', async () => { |
| 118 | + vi.mocked(apiClient.post).mockResolvedValue({ certificateId: 'cert-abc' }); |
19 | 119 |
|
20 | 120 | render(<CertificateGenerationPage />); |
21 | 121 |
|
22 | 122 | fireEvent.change(screen.getByLabelText(/Course ID/i), { |
23 | | - target: { value: '123e4567-e89b-12d3-a456-426614174000' }, |
| 123 | + target: { value: COURSE_ID }, |
24 | 124 | }); |
25 | 125 | fireEvent.change(screen.getByLabelText(/Student Name/i), { |
26 | 126 | target: { value: 'Jane Doe' }, |
27 | 127 | }); |
28 | | - |
29 | 128 | fireEvent.click(screen.getByRole('button', { name: /Generate certificate/i })); |
30 | 129 |
|
31 | 130 | await waitFor(() => { |
32 | 131 | expect(screen.getByText(/Certificate generated successfully/i)).toBeInTheDocument(); |
33 | 132 | }); |
| 133 | + }); |
| 134 | + |
| 135 | + it('does not show stats panel before any successful generation', () => { |
| 136 | + render(<CertificateGenerationPage />); |
| 137 | + expect(screen.queryByText('Generation Statistics')).not.toBeInTheDocument(); |
| 138 | + }); |
| 139 | + |
| 140 | + it('shows stats panel after a successful generation', async () => { |
| 141 | + vi.mocked(apiClient.post).mockResolvedValue({ certificateId: 'cert-abc' }); |
| 142 | + |
| 143 | + render(<CertificateGenerationPage />); |
34 | 144 |
|
35 | | - expect(apiClient.post).toHaveBeenCalledWith('/api/certificates/generate', { |
36 | | - courseId: '123e4567-e89b-12d3-a456-426614174000', |
37 | | - name: 'Jane Doe', |
| 145 | + fireEvent.change(screen.getByLabelText(/Course ID/i), { |
| 146 | + target: { value: COURSE_ID }, |
38 | 147 | }); |
| 148 | + fireEvent.change(screen.getByLabelText(/Student Name/i), { |
| 149 | + target: { value: 'Jane Doe' }, |
| 150 | + }); |
| 151 | + fireEvent.click(screen.getByRole('button', { name: /Generate certificate/i })); |
| 152 | + |
| 153 | + await waitFor(() => { |
| 154 | + expect(screen.getByText('Generation Statistics')).toBeInTheDocument(); |
| 155 | + }); |
| 156 | + }); |
| 157 | + |
| 158 | + it('increments totalGenerated with each successful submission', async () => { |
| 159 | + vi.mocked(apiClient.post).mockResolvedValue({ certificateId: 'cert-abc' }); |
| 160 | + const user = userEvent.setup(); |
| 161 | + |
| 162 | + render(<CertificateGenerationPage />); |
| 163 | + |
| 164 | + // First submission |
| 165 | + fireEvent.change(screen.getByLabelText(/Course ID/i), { target: { value: COURSE_ID } }); |
| 166 | + fireEvent.change(screen.getByLabelText(/Student Name/i), { target: { value: 'Jane Doe' } }); |
| 167 | + fireEvent.click(screen.getByRole('button', { name: /Generate certificate/i })); |
| 168 | + await waitFor(() => expect(screen.getByText('Generation Statistics')).toBeInTheDocument()); |
| 169 | + |
| 170 | + // Wait for form reset to complete before second fill |
| 171 | + await waitFor(() => |
| 172 | + expect(screen.getByLabelText(/Course ID/i)).toHaveValue(''), |
| 173 | + ); |
| 174 | + |
| 175 | + // Second submission — use userEvent to properly trigger RHF onChange/onBlur |
| 176 | + await user.clear(screen.getByLabelText(/Course ID/i)); |
| 177 | + await user.type(screen.getByLabelText(/Course ID/i), COURSE_ID); |
| 178 | + await user.clear(screen.getByLabelText(/Student Name/i)); |
| 179 | + await user.type(screen.getByLabelText(/Student Name/i), 'John Smith'); |
| 180 | + await user.click(screen.getByRole('button', { name: /Generate certificate/i })); |
| 181 | + fireEvent.click(screen.getByRole('button', { name: /Generate certificate/i })); |
| 182 | + |
| 183 | + // The "Certificates Generated" stat card should show 2 |
| 184 | + await waitFor(() => { |
| 185 | + const statValues = screen.getAllByText('2'); |
| 186 | + expect(statValues.length).toBeGreaterThan(0); |
| 187 | + }); |
| 188 | + }); |
| 189 | + |
| 190 | + it('shows API error message on failure without displaying stats', async () => { |
| 191 | + vi.mocked(apiClient.post).mockRejectedValue(new Error('Server error')); |
| 192 | + |
| 193 | + render(<CertificateGenerationPage />); |
| 194 | + |
| 195 | + fireEvent.change(screen.getByLabelText(/Course ID/i), { |
| 196 | + target: { value: COURSE_ID }, |
| 197 | + }); |
| 198 | + fireEvent.change(screen.getByLabelText(/Student Name/i), { |
| 199 | + target: { value: 'Jane Doe' }, |
| 200 | + }); |
| 201 | + fireEvent.click(screen.getByRole('button', { name: /Generate certificate/i })); |
| 202 | + |
| 203 | + await waitFor(() => { |
| 204 | + expect(screen.getByText('Server error')).toBeInTheDocument(); |
| 205 | + }); |
| 206 | + |
| 207 | + expect(screen.queryByText('Generation Statistics')).not.toBeInTheDocument(); |
39 | 208 | }); |
40 | 209 | }); |
0 commit comments