Files
nuzlocke-tracker/frontend/src/components/Layout.test.tsx
Julian Tabel e8ded9184b
All checks were successful
CI / backend-tests (push) Successful in 32s
CI / frontend-tests (push) Successful in 29s
feat: auth-aware UI and role-based access control (#67)
## Summary

- Add `is_admin` column to users table with Alembic migration and a `require_admin` FastAPI dependency that protects all admin-facing write endpoints (games, pokemon, evolutions, bosses, routes CRUD)
- Expose admin status to frontend via user API and update AuthContext to fetch/store `isAdmin` after login
- Make navigation menu auth-aware (different links for logged-out, logged-in, and admin users) and protect frontend routes with `ProtectedRoute` and `AdminRoute` components, preserving deep-linking through redirects
- Fix test reliability: `drop_all` before `create_all` to clear stale PostgreSQL enums from interrupted test runs
- Fix test auth: add `admin_client` fixture and use valid UUID for mock user so tests pass with new admin-protected endpoints

## Test plan

- [x] All 252 backend tests pass
- [ ] Verify non-admin users cannot access admin write endpoints (games, pokemon, evolutions, bosses CRUD)
- [ ] Verify admin users can access admin endpoints normally
- [ ] Verify navigation shows correct links for logged-out, logged-in, and admin states
- [ ] Verify `/admin/*` routes redirect non-admin users with a toast
- [ ] Verify `/runs/new` and `/genlockes/new` redirect unauthenticated users to login, then back after auth

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Reviewed-on: #67
Co-authored-by: Julian Tabel <juliantabel.jt@gmail.com>
Co-committed-by: Julian Tabel <juliantabel.jt@gmail.com>
2026-03-21 11:44:05 +01:00

111 lines
3.7 KiB
TypeScript

import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter } from 'react-router-dom'
import { Layout } from './Layout'
vi.mock('../hooks/useTheme', () => ({
useTheme: () => ({ theme: 'dark' as const, toggle: vi.fn() }),
}))
const mockUseAuth = vi.fn()
vi.mock('../contexts/AuthContext', () => ({
useAuth: () => mockUseAuth(),
}))
const loggedOutAuth = {
user: null,
session: null,
loading: false,
isAdmin: false,
signInWithEmail: vi.fn(),
signUpWithEmail: vi.fn(),
signInWithGoogle: vi.fn(),
signInWithDiscord: vi.fn(),
signOut: vi.fn(),
}
const adminAuth = {
...loggedOutAuth,
user: { email: 'admin@example.com' },
session: {},
isAdmin: true,
}
function renderLayout(initialPath = '/') {
return render(
<MemoryRouter initialEntries={[initialPath]}>
<Layout />
</MemoryRouter>
)
}
describe('Layout', () => {
describe('when logged out', () => {
beforeEach(() => mockUseAuth.mockReturnValue(loggedOutAuth))
it('renders logged-out navigation links', () => {
renderLayout()
expect(screen.getAllByRole('link', { name: /^home$/i })[0]).toBeInTheDocument()
expect(screen.getAllByRole('link', { name: /^runs$/i })[0]).toBeInTheDocument()
expect(screen.getAllByRole('link', { name: /genlockes/i })[0]).toBeInTheDocument()
expect(screen.getAllByRole('link', { name: /stats/i })[0]).toBeInTheDocument()
})
it('does not show authenticated links', () => {
renderLayout()
expect(screen.queryByRole('link', { name: /new run/i })).not.toBeInTheDocument()
expect(screen.queryByRole('link', { name: /my runs/i })).not.toBeInTheDocument()
expect(screen.queryByRole('link', { name: /admin/i })).not.toBeInTheDocument()
})
it('shows sign-in link', () => {
renderLayout()
expect(screen.getByRole('link', { name: /sign in/i })).toBeInTheDocument()
})
})
describe('when logged in as admin', () => {
beforeEach(() => mockUseAuth.mockReturnValue(adminAuth))
it('renders authenticated navigation links', () => {
renderLayout()
expect(screen.getAllByRole('link', { name: /new run/i })[0]).toBeInTheDocument()
expect(screen.getAllByRole('link', { name: /my runs/i })[0]).toBeInTheDocument()
expect(screen.getAllByRole('link', { name: /genlockes/i })[0]).toBeInTheDocument()
expect(screen.getAllByRole('link', { name: /stats/i })[0]).toBeInTheDocument()
expect(screen.getAllByRole('link', { name: /admin/i })[0]).toBeInTheDocument()
})
it('shows the mobile dropdown when the hamburger is clicked', async () => {
renderLayout()
const hamburger = screen.getByRole('button', { name: /toggle menu/i })
await userEvent.click(hamburger)
expect(screen.getAllByRole('link', { name: /my runs/i }).length).toBeGreaterThan(1)
})
})
it('renders the brand logo link', () => {
mockUseAuth.mockReturnValue(loggedOutAuth)
renderLayout()
expect(screen.getByRole('link', { name: /ant/i })).toBeInTheDocument()
})
it('renders the theme toggle button', () => {
mockUseAuth.mockReturnValue(loggedOutAuth)
renderLayout()
expect(screen.getAllByRole('button', { name: /switch to light mode/i })[0]).toBeInTheDocument()
})
it('initially hides the mobile dropdown menu', () => {
mockUseAuth.mockReturnValue(loggedOutAuth)
renderLayout()
expect(screen.getByRole('button', { name: /toggle menu/i })).toBeInTheDocument()
})
it('renders the footer with PokeDB attribution', () => {
mockUseAuth.mockReturnValue(loggedOutAuth)
renderLayout()
expect(screen.getByRole('link', { name: /pokedb/i })).toBeInTheDocument()
})
})