Skip to content

Commit 59254be

Browse files
committed
test(web): add tests for OAuthCallback and ProtectedRoute
1 parent 59ac004 commit 59254be

2 files changed

Lines changed: 196 additions & 0 deletions

File tree

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { cleanup, render, screen } from '@testing-library/react'
2+
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
3+
4+
// Mock react-router before any imports that use it
5+
const mockNavigate = vi.fn()
6+
let mockSearchParams = new URLSearchParams()
7+
vi.mock('react-router', () => ({
8+
useNavigate: () => mockNavigate,
9+
useSearchParams: () => [mockSearchParams],
10+
}))
11+
12+
// Mock apiFetch
13+
const mockApiFetch = vi.fn()
14+
vi.mock('../../shared/api/client', () => ({
15+
apiFetch: (...args: unknown[]) => mockApiFetch(...args),
16+
}))
17+
18+
// Import after mocks
19+
import OAuthCallback from './OAuthCallback'
20+
import { useAuthStore } from './store'
21+
22+
beforeEach(() => {
23+
vi.clearAllMocks()
24+
useAuthStore.setState({
25+
accessToken: null,
26+
user: null,
27+
isAuthenticated: false,
28+
returnUrl: null,
29+
})
30+
sessionStorage.clear()
31+
})
32+
33+
afterEach(() => {
34+
cleanup()
35+
})
36+
37+
describe('OAuthCallback', () => {
38+
test('redirects to / when no access_token in params', () => {
39+
mockSearchParams = new URLSearchParams()
40+
render(<OAuthCallback />)
41+
42+
expect(mockNavigate).toHaveBeenCalledWith('/', { replace: true })
43+
})
44+
45+
test('shows authenticating message', () => {
46+
mockSearchParams = new URLSearchParams('access_token=test-token')
47+
mockApiFetch.mockResolvedValue({ ok: true, json: () => Promise.resolve({ id: '1', github_login: 'testuser' }) })
48+
render(<OAuthCallback />)
49+
50+
expect(screen.getByText('Authenticating...')).toBeTruthy()
51+
})
52+
53+
test('calls login and fetches user on valid token', async () => {
54+
mockSearchParams = new URLSearchParams('access_token=test-token')
55+
const fakeUser = { id: '1', github_id: 123, github_login: 'testuser', email: null, avatar_url: null, created_at: '2026-01-01' }
56+
mockApiFetch.mockResolvedValue({ ok: true, json: () => Promise.resolve(fakeUser) })
57+
58+
render(<OAuthCallback />)
59+
60+
// Wait for async effects
61+
await vi.waitFor(() => {
62+
expect(mockApiFetch).toHaveBeenCalledWith('/api/v1/auth/me')
63+
})
64+
65+
await vi.waitFor(() => {
66+
expect(mockNavigate).toHaveBeenCalledWith('/', { replace: true })
67+
})
68+
69+
expect(useAuthStore.getState().isAuthenticated).toBe(true)
70+
expect(useAuthStore.getState().accessToken).toBe('test-token')
71+
})
72+
73+
test('navigates to returnUrl from sessionStorage after login', async () => {
74+
sessionStorage.setItem('helprs.returnUrl', '/installations/123/settings')
75+
mockSearchParams = new URLSearchParams('access_token=test-token')
76+
const fakeUser = { id: '1', github_id: 123, github_login: 'testuser', email: null, avatar_url: null, created_at: '2026-01-01' }
77+
mockApiFetch.mockResolvedValue({ ok: true, json: () => Promise.resolve(fakeUser) })
78+
79+
render(<OAuthCallback />)
80+
81+
await vi.waitFor(() => {
82+
expect(mockNavigate).toHaveBeenCalledWith('/installations/123/settings', { replace: true })
83+
})
84+
})
85+
86+
test('redirects to / on fetch failure', async () => {
87+
mockSearchParams = new URLSearchParams('access_token=bad-token')
88+
mockApiFetch.mockResolvedValue({ ok: false })
89+
90+
render(<OAuthCallback />)
91+
92+
await vi.waitFor(() => {
93+
expect(mockNavigate).toHaveBeenCalledWith('/', { replace: true })
94+
})
95+
})
96+
97+
test('redirects to / on network error', async () => {
98+
mockSearchParams = new URLSearchParams('access_token=test-token')
99+
mockApiFetch.mockRejectedValue(new Error('Network error'))
100+
101+
render(<OAuthCallback />)
102+
103+
await vi.waitFor(() => {
104+
expect(mockNavigate).toHaveBeenCalledWith('/', { replace: true })
105+
})
106+
})
107+
})
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { cleanup, render, screen } from '@testing-library/react'
2+
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
3+
import { useAuthStore } from './store'
4+
5+
// Capture window.location.href assignments
6+
const locationHrefSpy = vi.fn()
7+
const originalLocation = window.location
8+
9+
beforeEach(() => {
10+
vi.clearAllMocks()
11+
sessionStorage.clear()
12+
// Mock window.location
13+
Object.defineProperty(window, 'location', {
14+
value: { ...originalLocation, pathname: '/installations/42/settings', search: '', href: '' },
15+
writable: true,
16+
configurable: true,
17+
})
18+
Object.defineProperty(window.location, 'href', {
19+
set: locationHrefSpy,
20+
get: () => '',
21+
configurable: true,
22+
})
23+
})
24+
25+
afterEach(() => {
26+
cleanup()
27+
Object.defineProperty(window, 'location', { value: originalLocation, writable: true, configurable: true })
28+
})
29+
30+
// Import after setup
31+
import ProtectedRoute, { RETURN_URL_STORAGE_KEY } from './ProtectedRoute'
32+
33+
describe('ProtectedRoute', () => {
34+
test('renders children when authenticated', () => {
35+
useAuthStore.setState({ isAuthenticated: true, accessToken: 'token' })
36+
37+
render(
38+
<ProtectedRoute>
39+
<div data-testid="protected-content">Secret content</div>
40+
</ProtectedRoute>
41+
)
42+
43+
expect(screen.getByTestId('protected-content')).toBeTruthy()
44+
expect(screen.getByText('Secret content')).toBeTruthy()
45+
})
46+
47+
test('shows redirect message when not authenticated', () => {
48+
useAuthStore.setState({ isAuthenticated: false, accessToken: null })
49+
50+
render(
51+
<ProtectedRoute>
52+
<div>Secret content</div>
53+
</ProtectedRoute>
54+
)
55+
56+
expect(screen.getByText('Redirecting to login...')).toBeTruthy()
57+
expect(screen.queryByText('Secret content')).toBeNull()
58+
})
59+
60+
test('redirects to GitHub OAuth when not authenticated', () => {
61+
useAuthStore.setState({ isAuthenticated: false, accessToken: null })
62+
63+
render(
64+
<ProtectedRoute>
65+
<div>Secret content</div>
66+
</ProtectedRoute>
67+
)
68+
69+
expect(locationHrefSpy).toHaveBeenCalledWith(
70+
expect.stringContaining('/api/v1/auth/github')
71+
)
72+
})
73+
74+
test('persists returnUrl to sessionStorage when not authenticated', () => {
75+
useAuthStore.setState({ isAuthenticated: false, accessToken: null })
76+
77+
render(
78+
<ProtectedRoute>
79+
<div>Secret content</div>
80+
</ProtectedRoute>
81+
)
82+
83+
expect(sessionStorage.getItem(RETURN_URL_STORAGE_KEY)).toBe('/installations/42/settings')
84+
})
85+
86+
test('exports RETURN_URL_STORAGE_KEY constant', () => {
87+
expect(RETURN_URL_STORAGE_KEY).toBe('helprs.returnUrl')
88+
})
89+
})

0 commit comments

Comments
 (0)