Skip to content

Commit fd60dca

Browse files
agusmdevclaude
andcommitted
refactor(frontend): add component tests, fix login type contract, improve test teardown
- Add render-based tests for AuthFormShell, ErrorBoundary, and Navigation (14 new passing tests) - Fix login() signature from (AuthSessionResponse) to (token: string) — was accepting full session but only consuming response.id - Add clearAllAuthListeners() to auth test afterEach blocks to prevent listener leaks on mid-test failure - Update useAuthSubmit to pass result.id to login() instead of full session object - Update useAuthSubmit.test.ts to assert login receives the token string Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 2b491a7 commit fd60dca

8 files changed

Lines changed: 189 additions & 9 deletions

File tree

template/frontend/scorecard.png

-4.93 KB
Loading
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { render, screen } from '@testing-library/react'
2+
import { AuthFormShell } from './AuthFormShell'
3+
4+
describe('AuthFormShell', () => {
5+
it('renders title and description', () => {
6+
render(
7+
<AuthFormShell title="Sign In" description="Enter your credentials" footer={null}>
8+
<div>form content</div>
9+
</AuthFormShell>
10+
)
11+
12+
expect(screen.getByText('Sign In')).toBeInTheDocument()
13+
expect(screen.getByText('Enter your credentials')).toBeInTheDocument()
14+
})
15+
16+
it('renders children', () => {
17+
render(
18+
<AuthFormShell title="Title" description="Desc" footer={null}>
19+
<input data-testid="form-input" />
20+
</AuthFormShell>
21+
)
22+
23+
expect(screen.getByTestId('form-input')).toBeInTheDocument()
24+
})
25+
26+
it('renders footer slot', () => {
27+
render(
28+
<AuthFormShell
29+
title="Title"
30+
description="Desc"
31+
footer={<a href="/register">Create account</a>}
32+
>
33+
<div />
34+
</AuthFormShell>
35+
)
36+
37+
expect(screen.getByRole('link', { name: 'Create account' })).toBeInTheDocument()
38+
})
39+
})
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { render, screen, fireEvent } from '@testing-library/react'
2+
import { vi } from 'vitest'
3+
import { ErrorBoundary } from './ErrorBoundary'
4+
5+
function Bomb({ shouldThrow }: { shouldThrow: boolean }) {
6+
if (shouldThrow) throw new Error('Test error message')
7+
return <div>All good</div>
8+
}
9+
10+
// Suppress React's error boundary console output in tests
11+
const consoleError = console.error
12+
beforeEach(() => {
13+
console.error = vi.fn()
14+
})
15+
afterEach(() => {
16+
console.error = consoleError
17+
})
18+
19+
describe('ErrorBoundary', () => {
20+
it('renders children when no error', () => {
21+
render(
22+
<ErrorBoundary>
23+
<div data-testid="child">content</div>
24+
</ErrorBoundary>
25+
)
26+
27+
expect(screen.getByTestId('child')).toBeInTheDocument()
28+
})
29+
30+
it('renders error UI when a child throws', () => {
31+
render(
32+
<ErrorBoundary>
33+
<Bomb shouldThrow />
34+
</ErrorBoundary>
35+
)
36+
37+
expect(screen.getByText('Something went wrong')).toBeInTheDocument()
38+
expect(screen.getByText('Test error message')).toBeInTheDocument()
39+
})
40+
41+
it('shows Try again and Copy error details buttons on error', () => {
42+
render(
43+
<ErrorBoundary>
44+
<Bomb shouldThrow />
45+
</ErrorBoundary>
46+
)
47+
48+
expect(screen.getByRole('button', { name: 'Try again' })).toBeInTheDocument()
49+
expect(screen.getByRole('button', { name: /Copy error details/i })).toBeInTheDocument()
50+
})
51+
52+
it('calls onError prop when a child throws', () => {
53+
const onError = vi.fn()
54+
55+
render(
56+
<ErrorBoundary onError={onError}>
57+
<Bomb shouldThrow />
58+
</ErrorBoundary>
59+
)
60+
61+
expect(onError).toHaveBeenCalledWith(
62+
expect.objectContaining({ message: 'Test error message' }),
63+
expect.objectContaining({ componentStack: expect.any(String) })
64+
)
65+
})
66+
67+
it('Try again button clears error state (click does not throw)', () => {
68+
render(
69+
<ErrorBoundary>
70+
<Bomb shouldThrow />
71+
</ErrorBoundary>
72+
)
73+
74+
expect(screen.getByText('Something went wrong')).toBeInTheDocument()
75+
76+
// Clicking Try again should not itself throw — it clears internal state
77+
expect(() => fireEvent.click(screen.getByRole('button', { name: 'Try again' }))).not.toThrow()
78+
})
79+
})
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { render, screen, fireEvent } from '@testing-library/react'
2+
import { vi } from 'vitest'
3+
import { Navigation } from './Navigation'
4+
5+
const mockLogout = vi.fn()
6+
7+
vi.mock('@/contexts/AuthContext', () => ({
8+
useAuth: vi.fn(),
9+
}))
10+
11+
import { useAuth } from '@/contexts/AuthContext'
12+
const mockUseAuth = useAuth as ReturnType<typeof vi.fn>
13+
14+
describe('Navigation (unauthenticated)', () => {
15+
beforeEach(() => {
16+
mockUseAuth.mockReturnValue({ isAuthenticated: false, logout: mockLogout })
17+
})
18+
19+
it('renders Login and Sign Up links', () => {
20+
render(<Navigation />)
21+
expect(screen.getByRole('link', { name: 'Login' })).toBeInTheDocument()
22+
expect(screen.getByRole('link', { name: 'Sign Up' })).toBeInTheDocument()
23+
})
24+
25+
it('does not render Logout button', () => {
26+
render(<Navigation />)
27+
expect(screen.queryByRole('button', { name: 'Logout' })).not.toBeInTheDocument()
28+
})
29+
30+
it('does not render Items link', () => {
31+
render(<Navigation />)
32+
expect(screen.queryByRole('link', { name: 'Items' })).not.toBeInTheDocument()
33+
})
34+
})
35+
36+
describe('Navigation (authenticated)', () => {
37+
beforeEach(() => {
38+
mockLogout.mockReset()
39+
mockUseAuth.mockReturnValue({ isAuthenticated: true, logout: mockLogout })
40+
})
41+
42+
it('renders Logout button and Items link', () => {
43+
render(<Navigation />)
44+
expect(screen.getByRole('button', { name: 'Logout' })).toBeInTheDocument()
45+
expect(screen.getByRole('link', { name: 'Items' })).toBeInTheDocument()
46+
})
47+
48+
it('does not render Login or Sign Up when authenticated', () => {
49+
render(<Navigation />)
50+
expect(screen.queryByRole('link', { name: 'Login' })).not.toBeInTheDocument()
51+
expect(screen.queryByRole('link', { name: 'Sign Up' })).not.toBeInTheDocument()
52+
})
53+
54+
it('calls logout when Logout button is clicked', () => {
55+
render(<Navigation />)
56+
fireEvent.click(screen.getByRole('button', { name: 'Logout' }))
57+
expect(mockLogout).toHaveBeenCalledTimes(1)
58+
})
59+
})

template/frontend/src/contexts/AuthContext.test.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
clearAuthToken,
1717
subscribeToAuthChanges,
1818
isAuthenticated,
19+
clearAllAuthListeners,
1920
} from '@/lib/auth'
2021
import { createAuthChangeHandler } from './AuthContext'
2122

@@ -27,6 +28,7 @@ function makeQueryClient() {
2728

2829
describe('AuthContext: login path', () => {
2930
beforeEach(() => localStorage.clear())
31+
afterEach(() => clearAllAuthListeners())
3032

3133
it('stores token on login', () => {
3234
setAuthToken('login-token')
@@ -45,6 +47,7 @@ describe('AuthContext: login path', () => {
4547

4648
describe('AuthContext: logout path', () => {
4749
beforeEach(() => localStorage.clear())
50+
afterEach(() => clearAllAuthListeners())
4851

4952
it('clears token and marks unauthenticated', () => {
5053
setAuthToken('active-token')
@@ -81,7 +84,7 @@ describe('AuthContext: logout path', () => {
8184

8285
describe('AuthContext: 401 path', () => {
8386
beforeEach(() => localStorage.clear())
84-
afterEach(() => vi.unstubAllGlobals())
87+
afterEach(() => { vi.unstubAllGlobals(); clearAllAuthListeners() })
8588

8689
it('api-client 401 triggers auth change notification', async () => {
8790
const { api } = await import('@/lib/api-client')
@@ -120,6 +123,7 @@ describe('AuthContext: 401 path', () => {
120123

121124
describe('createAuthChangeHandler', () => {
122125
beforeEach(() => localStorage.clear())
126+
afterEach(() => clearAllAuthListeners())
123127

124128
it('calls navigate({ to: /login }) when token is cleared', () => {
125129
const navigate = vi.fn()

template/frontend/src/contexts/AuthContext.tsx

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,12 @@ import type { QueryClient } from '@tanstack/react-query'
44
import { getAuthToken, setAuthToken, clearAuthToken, subscribeToAuthChanges } from '@/lib/auth'
55
import { api } from '@/lib/api-client'
66
import { API } from '@/lib/api-endpoints'
7-
import type { AuthSessionResponse } from '@/types/auth'
87

98
interface AuthContextValue {
109
isAuthenticated: boolean
1110
token: string | null
1211
/** Sync — sets the auth token and updates state. Do not await. */
13-
login: (response: AuthSessionResponse) => void
12+
login: (token: string) => void
1413
/** Async — calls the backend logout endpoint then clears local state. Must be awaited. */
1514
logout: () => Promise<void>
1615
}
@@ -54,8 +53,8 @@ export function AuthProvider({ children, queryClient }: AuthProviderProps) {
5453
)
5554
}, [queryClient, router])
5655

57-
const login = useCallback((response: AuthSessionResponse) => {
58-
setAuthToken(response.id)
56+
const login = useCallback((token: string) => {
57+
setAuthToken(token)
5958
}, [])
6059

6160
const logout = useCallback(async () => {

template/frontend/src/hooks/useAuthSubmit.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,14 +32,14 @@ describe('executeAuthSubmit', () => {
3232
vi.unstubAllGlobals()
3333
})
3434

35-
it('calls login() with the API response on success', async () => {
35+
it('calls login() with the session token on success', async () => {
3636
mockFetch(200, mockSession)
3737

3838
await executeAuthSubmit('/auth/login', { email: 'a@b.com', password: 'pw' }, {
3939
login, navigate, successMessage, errorMessage, redirect,
4040
})
4141

42-
expect(login).toHaveBeenCalledWith(mockSession)
42+
expect(login).toHaveBeenCalledWith(mockSession.id)
4343
})
4444

4545
it('calls navigate() with the redirect option on success', async () => {

template/frontend/src/hooks/useAuthSubmit.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ export async function executeAuthSubmit(
1414
endpoint: string,
1515
payload: Record<string, unknown>,
1616
deps: {
17-
login: (result: AuthSessionResponse) => void
17+
login: (token: string) => void
1818
navigate: (opts: NavigateOptions) => void
1919
successMessage: string
2020
errorMessage: string
@@ -23,7 +23,7 @@ export async function executeAuthSubmit(
2323
): Promise<void> {
2424
try {
2525
const result = await api.post<AuthSessionResponse>(endpoint, payload)
26-
deps.login(result)
26+
deps.login(result.id)
2727
toast.success(deps.successMessage)
2828
deps.navigate(deps.redirect)
2929
} catch (err) {

0 commit comments

Comments
 (0)