banu4prasad commited on
Commit
672a9eb
·
1 Parent(s): 0e4c515

add: new tests

Browse files
backend/tests/test_config.py ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pytest
2
+ from sqlalchemy.exc import ArgumentError
3
+
4
+ from app.core.config import (
5
+ PASSWORD_URL_ENCODING_MESSAGE,
6
+ normalize_database_url,
7
+ )
8
+
9
+
10
+ def test_normalize_database_url_raises_helpful_error_for_malformed_url(monkeypatch):
11
+ malformed_url = "postgresql://user:pass@localhost:5432/db"
12
+
13
+ def fake_make_url(_raw_url):
14
+ raise ArgumentError("malformed URL")
15
+
16
+ monkeypatch.setattr("app.core.config.make_url", fake_make_url)
17
+
18
+ with pytest.raises(ValueError, match="DATABASE_URL appears malformed") as exc_info:
19
+ normalize_database_url(malformed_url)
20
+
21
+ assert str(exc_info.value) == PASSWORD_URL_ENCODING_MESSAGE
frontend/src/components/shared/Skeletons.test.jsx ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { render, cleanup } from '@testing-library/react'
2
+ import { afterEach, describe, expect, it } from 'vitest'
3
+ import '@testing-library/jest-dom/vitest'
4
+
5
+ import {
6
+ LeaderboardSkeleton,
7
+ ResultCardSkeleton,
8
+ ResultSkeleton,
9
+ SkeletonBlock,
10
+ StatCardSkeleton,
11
+ TestCardSkeleton,
12
+ } from './Skeletons'
13
+
14
+ afterEach(() => {
15
+ cleanup()
16
+ })
17
+
18
+ describe('Skeletons', () => {
19
+ it('renders SkeletonBlock with the expected default classes and accessibility marker', () => {
20
+ const { container } = render(<SkeletonBlock className="h-4 w-full" />)
21
+ const block = container.firstChild
22
+
23
+ expect(block).toBeInTheDocument()
24
+ expect(block).toHaveAttribute('aria-hidden', 'true')
25
+ expect(block).toHaveClass('block', 'animate-pulse', 'rounded', 'bg-slate-200/80', 'dark:bg-slate-800', 'h-4', 'w-full')
26
+ })
27
+
28
+ it('renders TestCardSkeleton with its card structure and pulse placeholders', () => {
29
+ const { container } = render(<TestCardSkeleton />)
30
+ const card = container.querySelector('.gate-card')
31
+
32
+ expect(card).toBeInTheDocument()
33
+ expect(card).toHaveClass('p-5', 'flex', 'flex-col', 'gap-3')
34
+ expect(container.querySelectorAll('.animate-pulse')).not.toHaveLength(0)
35
+ })
36
+
37
+ it('renders the other shared skeleton variants without crashing', () => {
38
+ const statResult = render(<StatCardSkeleton />)
39
+ expect(statResult.container.querySelector('.gate-card')).toBeInTheDocument()
40
+
41
+ const leaderboardResult = render(<LeaderboardSkeleton />)
42
+ expect(leaderboardResult.container.querySelectorAll('.animate-pulse').length).toBeGreaterThan(0)
43
+
44
+ const resultCardResult = render(<ResultCardSkeleton />)
45
+ expect(resultCardResult.container.querySelector('.gate-card')).toBeInTheDocument()
46
+
47
+ const resultPageResult = render(<ResultSkeleton />)
48
+ expect(resultPageResult.container.querySelectorAll('.animate-pulse').length).toBeGreaterThan(0)
49
+ })
50
+ })
frontend/src/context/AuthContext.test.jsx ADDED
@@ -0,0 +1,215 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
+ import { render, screen, cleanup, act, waitFor } from '@testing-library/react'
3
+ import userEvent from '@testing-library/user-event'
4
+ import { MemoryRouter } from 'react-router-dom'
5
+ import { AuthProvider, useAuth } from './AuthContext'
6
+
7
+ // ── Mocks ────────────────────────────────────────────────────────
8
+ const mockNavigate = vi.fn()
9
+ vi.mock('react-router-dom', async () => {
10
+ const actual = await vi.importActual('react-router-dom')
11
+ return { ...actual, useNavigate: () => mockNavigate }
12
+ })
13
+
14
+ vi.mock('swr', () => ({ mutate: vi.fn() }))
15
+
16
+ const mockGet = vi.fn()
17
+ const mockPost = vi.fn()
18
+ vi.mock('../api/client', () => ({
19
+ default: { get: (...args) => mockGet(...args), post: (...args) => mockPost(...args) },
20
+ AUTH_UNAUTHORIZED_EVENT: 'gateprep:auth-unauthorized',
21
+ startTokenRefresh: vi.fn(),
22
+ stopTokenRefresh: vi.fn(),
23
+ }))
24
+
25
+ // Helper to consume and display context values
26
+ function AuthConsumer() {
27
+ const { user, loading, sessionExpired, logout, saveUser } = useAuth()
28
+ return (
29
+ <div>
30
+ <span data-testid="loading">{String(loading)}</span>
31
+ <span data-testid="user">{user ? JSON.stringify(user) : 'null'}</span>
32
+ <span data-testid="session-expired">{String(sessionExpired)}</span>
33
+ <button data-testid="logout-btn" onClick={logout}>Logout</button>
34
+ <button data-testid="save-btn" onClick={() => saveUser({ id: 99, email: 'new@test.com', role: 'student', full_name: 'New' })}>Save</button>
35
+ </div>
36
+ )
37
+ }
38
+
39
+ function renderWithRouter(ui) {
40
+ return render(<MemoryRouter>{ui}</MemoryRouter>)
41
+ }
42
+
43
+ beforeEach(() => {
44
+ vi.clearAllMocks()
45
+ })
46
+
47
+ afterEach(() => {
48
+ cleanup()
49
+ })
50
+
51
+ // ── Tests ────────────────────────────────────────────────────────
52
+ describe('AuthProvider', () => {
53
+ it('initialises: fetches /auth/me and sets user on success', async () => {
54
+ const userData = { id: 1, email: 'a@b.com', role: 'admin', full_name: 'Admin' }
55
+ mockGet.mockResolvedValueOnce({ data: userData })
56
+
57
+ renderWithRouter(
58
+ <AuthProvider><AuthConsumer /></AuthProvider>
59
+ )
60
+
61
+ // Initially loading
62
+ expect(screen.getByTestId('loading').textContent).toBe('true')
63
+
64
+ await waitFor(() => {
65
+ expect(screen.getByTestId('loading').textContent).toBe('false')
66
+ })
67
+
68
+ expect(screen.getByTestId('user').textContent).toContain('"email":"a@b.com"')
69
+
70
+ const { startTokenRefresh } = await import('../api/client')
71
+ expect(startTokenRefresh).toHaveBeenCalledWith({ refreshNow: true })
72
+ })
73
+
74
+ it('initialises: sets user to null when /auth/me fails', async () => {
75
+ mockGet.mockRejectedValueOnce(new Error('Unauthorized'))
76
+
77
+ renderWithRouter(
78
+ <AuthProvider><AuthConsumer /></AuthProvider>
79
+ )
80
+
81
+ await waitFor(() => {
82
+ expect(screen.getByTestId('loading').textContent).toBe('false')
83
+ })
84
+
85
+ expect(screen.getByTestId('user').textContent).toBe('null')
86
+ })
87
+
88
+ it('logout: clears user, stops refresh, and navigates to /login', async () => {
89
+ const userData = { id: 1, email: 'a@b.com', role: 'admin', full_name: 'Admin' }
90
+ mockGet.mockResolvedValueOnce({ data: userData })
91
+ mockPost.mockResolvedValueOnce({}) // logout API call
92
+
93
+ renderWithRouter(
94
+ <AuthProvider><AuthConsumer /></AuthProvider>
95
+ )
96
+
97
+ await waitFor(() => {
98
+ expect(screen.getByTestId('loading').textContent).toBe('false')
99
+ })
100
+
101
+ await act(async () => {
102
+ await userEvent.click(screen.getByTestId('logout-btn'))
103
+ })
104
+
105
+ const { stopTokenRefresh } = await import('../api/client')
106
+ expect(stopTokenRefresh).toHaveBeenCalled()
107
+ expect(screen.getByTestId('user').textContent).toBe('null')
108
+ expect(mockNavigate).toHaveBeenCalledWith('/login', { replace: true })
109
+ })
110
+
111
+ it('logout: still clears state even if API call fails', async () => {
112
+ mockGet.mockResolvedValueOnce({ data: { id: 1, email: 'a@b.com', role: 'student', full_name: 'S' } })
113
+ mockPost.mockRejectedValueOnce(new Error('network error'))
114
+
115
+ renderWithRouter(
116
+ <AuthProvider><AuthConsumer /></AuthProvider>
117
+ )
118
+
119
+ await waitFor(() => {
120
+ expect(screen.getByTestId('loading').textContent).toBe('false')
121
+ })
122
+
123
+ await act(async () => {
124
+ await userEvent.click(screen.getByTestId('logout-btn'))
125
+ })
126
+
127
+ // User should still be cleared despite API failure
128
+ expect(screen.getByTestId('user').textContent).toBe('null')
129
+ expect(mockNavigate).toHaveBeenCalledWith('/login', { replace: true })
130
+ })
131
+
132
+ it('saveUser: sets user from provided data and starts token refresh', async () => {
133
+ mockGet.mockRejectedValueOnce(new Error('no session')) // initial load fails
134
+
135
+ renderWithRouter(
136
+ <AuthProvider><AuthConsumer /></AuthProvider>
137
+ )
138
+
139
+ await waitFor(() => {
140
+ expect(screen.getByTestId('loading').textContent).toBe('false')
141
+ })
142
+
143
+ await act(async () => {
144
+ await userEvent.click(screen.getByTestId('save-btn'))
145
+ })
146
+
147
+ expect(screen.getByTestId('user').textContent).toContain('"email":"new@test.com"')
148
+ expect(screen.getByTestId('session-expired').textContent).toBe('false')
149
+
150
+ const { startTokenRefresh } = await import('../api/client')
151
+ expect(startTokenRefresh).toHaveBeenCalled()
152
+ })
153
+
154
+ it('session expired: shows dialog on AUTH_UNAUTHORIZED_EVENT', async () => {
155
+ mockGet.mockResolvedValueOnce({ data: { id: 1, email: 'a@b.com', role: 'admin', full_name: 'A' } })
156
+
157
+ renderWithRouter(
158
+ <AuthProvider><AuthConsumer /></AuthProvider>
159
+ )
160
+
161
+ await waitFor(() => {
162
+ expect(screen.getByTestId('loading').textContent).toBe('false')
163
+ })
164
+
165
+ // Fire the unauthorized event
166
+ act(() => {
167
+ window.dispatchEvent(new CustomEvent('gateprep:auth-unauthorized'))
168
+ })
169
+
170
+ expect(screen.getByTestId('session-expired').textContent).toBe('true')
171
+ expect(screen.getByText('Session expired')).toBeTruthy()
172
+ expect(screen.getByText('Sign in')).toBeTruthy()
173
+ })
174
+
175
+ it('confirmSessionExpired: clicking "Sign in" clears state and navigates to /login', async () => {
176
+ mockGet.mockResolvedValueOnce({ data: { id: 1, email: 'a@b.com', role: 'admin', full_name: 'A' } })
177
+
178
+ renderWithRouter(
179
+ <AuthProvider><AuthConsumer /></AuthProvider>
180
+ )
181
+
182
+ await waitFor(() => {
183
+ expect(screen.getByTestId('loading').textContent).toBe('false')
184
+ })
185
+
186
+ act(() => {
187
+ window.dispatchEvent(new CustomEvent('gateprep:auth-unauthorized'))
188
+ })
189
+
190
+ await act(async () => {
191
+ await userEvent.click(screen.getByText('Sign in'))
192
+ })
193
+
194
+ const { stopTokenRefresh } = await import('../api/client')
195
+ expect(stopTokenRefresh).toHaveBeenCalled()
196
+ expect(screen.getByTestId('user').textContent).toBe('null')
197
+ expect(screen.getByTestId('session-expired').textContent).toBe('false')
198
+ expect(mockNavigate).toHaveBeenCalledWith('/login', expect.objectContaining({ replace: true }))
199
+ })
200
+ })
201
+
202
+ describe('useAuth', () => {
203
+ it('throws if used outside AuthProvider', () => {
204
+ // Suppress React error boundary console noise
205
+ const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
206
+
207
+ function Naked() {
208
+ useAuth()
209
+ return null
210
+ }
211
+
212
+ expect(() => render(<Naked />)).toThrow('useAuth must be used within AuthProvider')
213
+ spy.mockRestore()
214
+ })
215
+ })
frontend/src/context/ThemeContext.test.jsx ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
+ import { render, screen, cleanup, act } from '@testing-library/react'
3
+ import userEvent from '@testing-library/user-event'
4
+ import { ThemeProvider, useTheme } from './ThemeContext'
5
+
6
+ function ThemeConsumer() {
7
+ const { theme, toggle } = useTheme()
8
+ return (
9
+ <div>
10
+ <span data-testid="theme">{theme}</span>
11
+ <button data-testid="toggle-btn" onClick={toggle}>Toggle</button>
12
+ </div>
13
+ )
14
+ }
15
+
16
+ beforeEach(() => {
17
+ localStorage.clear()
18
+ document.documentElement.removeAttribute('data-theme')
19
+ document.documentElement.style.colorScheme = ''
20
+ document.documentElement.classList.remove('dark')
21
+ })
22
+
23
+ afterEach(() => {
24
+ cleanup()
25
+ })
26
+
27
+ describe('ThemeProvider', () => {
28
+ it('defaults to "dark" when localStorage has no saved theme', () => {
29
+ render(<ThemeProvider><ThemeConsumer /></ThemeProvider>)
30
+
31
+ expect(screen.getByTestId('theme').textContent).toBe('dark')
32
+ expect(localStorage.getItem('theme')).toBe('dark')
33
+ expect(document.documentElement.getAttribute('data-theme')).toBe('dark')
34
+ expect(document.documentElement.classList.contains('dark')).toBe(true)
35
+ })
36
+
37
+ it('restores saved theme from localStorage', () => {
38
+ localStorage.setItem('theme', 'light')
39
+
40
+ render(<ThemeProvider><ThemeConsumer /></ThemeProvider>)
41
+
42
+ expect(screen.getByTestId('theme').textContent).toBe('light')
43
+ expect(document.documentElement.getAttribute('data-theme')).toBe('light')
44
+ expect(document.documentElement.classList.contains('dark')).toBe(false)
45
+ })
46
+
47
+ it('toggles from dark to light', async () => {
48
+ render(<ThemeProvider><ThemeConsumer /></ThemeProvider>)
49
+
50
+ expect(screen.getByTestId('theme').textContent).toBe('dark')
51
+
52
+ await act(async () => {
53
+ await userEvent.click(screen.getByTestId('toggle-btn'))
54
+ })
55
+
56
+ expect(screen.getByTestId('theme').textContent).toBe('light')
57
+ expect(localStorage.getItem('theme')).toBe('light')
58
+ expect(document.documentElement.classList.contains('dark')).toBe(false)
59
+ })
60
+
61
+ it('toggles from light back to dark', async () => {
62
+ localStorage.setItem('theme', 'light')
63
+
64
+ render(<ThemeProvider><ThemeConsumer /></ThemeProvider>)
65
+
66
+ await act(async () => {
67
+ await userEvent.click(screen.getByTestId('toggle-btn'))
68
+ })
69
+
70
+ expect(screen.getByTestId('theme').textContent).toBe('dark')
71
+ expect(localStorage.getItem('theme')).toBe('dark')
72
+ expect(document.documentElement.classList.contains('dark')).toBe(true)
73
+ })
74
+
75
+ it('sets colorScheme on the document element', () => {
76
+ render(<ThemeProvider><ThemeConsumer /></ThemeProvider>)
77
+
78
+ expect(document.documentElement.style.colorScheme).toBe('dark')
79
+ })
80
+ })
81
+
82
+ describe('useTheme', () => {
83
+ it('returns null when used outside ThemeProvider', () => {
84
+ // useTheme uses raw useContext without a guard, so it returns null outside provider
85
+ function Naked() {
86
+ const ctx = useTheme()
87
+ return <span data-testid="ctx">{String(ctx)}</span>
88
+ }
89
+
90
+ render(<Naked />)
91
+ expect(screen.getByTestId('ctx').textContent).toBe('null')
92
+ })
93
+ })