356 lines
10 KiB
TypeScript
356 lines
10 KiB
TypeScript
/**
|
|
* TDD Tests for RouteGuard Component
|
|
*
|
|
* These tests verify the RouteGuard component logic following TDD principles.
|
|
* Note: These are integration tests that verify the component behavior.
|
|
*/
|
|
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import { render, screen, waitFor } from '@testing-library/react';
|
|
import { RouteGuard } from './RouteGuard';
|
|
import { useAuth } from '@/lib/auth/AuthContext';
|
|
import { useRouter } from 'next/navigation';
|
|
import type { AuthContextValue } from '@/lib/auth/AuthContext';
|
|
import type { SessionViewModel } from '@/lib/view-models/SessionViewModel';
|
|
|
|
// Mock dependencies
|
|
vi.mock('@/lib/auth/AuthContext');
|
|
vi.mock('next/navigation');
|
|
|
|
// Mock SessionViewModel factory
|
|
function createMockSession(overrides: Partial<SessionViewModel> = {}): SessionViewModel {
|
|
const baseSession = {
|
|
isAuthenticated: true,
|
|
userId: 'user-123',
|
|
email: 'test@example.com',
|
|
displayName: 'Test User',
|
|
role: undefined,
|
|
};
|
|
|
|
// Handle the case where overrides might have a user object
|
|
// (for backward compatibility with existing test patterns)
|
|
if (overrides.user) {
|
|
const { user, ...rest } = overrides;
|
|
return {
|
|
...baseSession,
|
|
...rest,
|
|
userId: user.userId || baseSession.userId,
|
|
email: user.email || baseSession.email,
|
|
displayName: user.displayName || baseSession.displayName,
|
|
role: user.role,
|
|
};
|
|
}
|
|
|
|
return {
|
|
...baseSession,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
// Mock AuthContext factory
|
|
function createMockAuthContext(overrides: Partial<AuthContextValue> = {}): AuthContextValue {
|
|
return {
|
|
session: null,
|
|
loading: false,
|
|
login: vi.fn(),
|
|
logout: vi.fn(),
|
|
refreshSession: vi.fn(),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe('RouteGuard', () => {
|
|
const mockUseAuth = vi.mocked(useAuth);
|
|
const mockUseRouter = vi.mocked(useRouter);
|
|
|
|
let mockRouter: { push: ReturnType<typeof vi.fn> };
|
|
|
|
beforeEach(() => {
|
|
mockRouter = { push: vi.fn() };
|
|
mockUseRouter.mockReturnValue(mockRouter as any);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
describe('Authentication State', () => {
|
|
it('should render children when user is authenticated', async () => {
|
|
const mockAuthContext = createMockAuthContext({
|
|
session: createMockSession(),
|
|
loading: false,
|
|
});
|
|
mockUseAuth.mockReturnValue(mockAuthContext);
|
|
|
|
render(
|
|
<RouteGuard>
|
|
<div data-testid="protected-content">Protected Content</div>
|
|
</RouteGuard>
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('protected-content')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('should show loading state when auth context is loading', () => {
|
|
const mockAuthContext = createMockAuthContext({
|
|
session: null,
|
|
loading: true,
|
|
});
|
|
mockUseAuth.mockReturnValue(mockAuthContext);
|
|
|
|
render(
|
|
<RouteGuard>
|
|
<div data-testid="protected-content">Protected Content</div>
|
|
</RouteGuard>
|
|
);
|
|
|
|
// Should show loading state, not children
|
|
expect(screen.queryByTestId('protected-content')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('should redirect when user is not authenticated', async () => {
|
|
const mockAuthContext = createMockAuthContext({
|
|
session: null,
|
|
loading: false,
|
|
});
|
|
mockUseAuth.mockReturnValue(mockAuthContext);
|
|
|
|
render(
|
|
<RouteGuard>
|
|
<div data-testid="protected-content">Protected Content</div>
|
|
</RouteGuard>
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(mockRouter.push).toHaveBeenCalledWith('/auth/login');
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Custom Configuration', () => {
|
|
it('should use custom redirect path when specified', async () => {
|
|
const mockAuthContext = createMockAuthContext({
|
|
session: null,
|
|
loading: false,
|
|
});
|
|
mockUseAuth.mockReturnValue(mockAuthContext);
|
|
|
|
render(
|
|
<RouteGuard config={{ unauthorizedRedirectPath: '/custom-login' }}>
|
|
<div data-testid="protected-content">Protected Content</div>
|
|
</RouteGuard>
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(mockRouter.push).toHaveBeenCalledWith('/custom-login');
|
|
});
|
|
});
|
|
|
|
it('should not redirect when redirectOnUnauthorized is false', async () => {
|
|
const mockAuthContext = createMockAuthContext({
|
|
session: null,
|
|
loading: false,
|
|
});
|
|
mockUseAuth.mockReturnValue(mockAuthContext);
|
|
|
|
render(
|
|
<RouteGuard config={{ redirectOnUnauthorized: false }}>
|
|
<div data-testid="protected-content">Protected Content</div>
|
|
</RouteGuard>
|
|
);
|
|
|
|
// Wait for any potential redirects
|
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
|
|
expect(mockRouter.push).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should show unauthorized component when redirect is disabled', async () => {
|
|
const mockAuthContext = createMockAuthContext({
|
|
session: null,
|
|
loading: false,
|
|
});
|
|
mockUseAuth.mockReturnValue(mockAuthContext);
|
|
|
|
const unauthorizedComponent = <div data-testid="unauthorized">Access Denied</div>;
|
|
|
|
render(
|
|
<RouteGuard
|
|
config={{ redirectOnUnauthorized: false }}
|
|
unauthorizedComponent={unauthorizedComponent}
|
|
>
|
|
<div data-testid="protected-content">Protected Content</div>
|
|
</RouteGuard>
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('unauthorized')).toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Custom Loading Component', () => {
|
|
it('should show custom loading component when specified', () => {
|
|
const mockAuthContext = createMockAuthContext({
|
|
session: null,
|
|
loading: true,
|
|
});
|
|
mockUseAuth.mockReturnValue(mockAuthContext);
|
|
|
|
const loadingComponent = <div data-testid="custom-loading">Custom Loading...</div>;
|
|
|
|
render(
|
|
<RouteGuard loadingComponent={loadingComponent}>
|
|
<div data-testid="protected-content">Protected Content</div>
|
|
</RouteGuard>
|
|
);
|
|
|
|
expect(screen.getByTestId('custom-loading')).toBeInTheDocument();
|
|
expect(screen.queryByTestId('protected-content')).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('Role-Based Access', () => {
|
|
it('should allow access when user has required role', async () => {
|
|
const mockAuthContext = createMockAuthContext({
|
|
session: createMockSession({
|
|
user: {
|
|
userId: 'user-123',
|
|
email: 'admin@example.com',
|
|
displayName: 'Admin User',
|
|
role: 'admin',
|
|
},
|
|
}),
|
|
loading: false,
|
|
});
|
|
mockUseAuth.mockReturnValue(mockAuthContext);
|
|
|
|
render(
|
|
<RouteGuard config={{ requiredRoles: ['admin'] }}>
|
|
<div data-testid="protected-content">Protected Content</div>
|
|
</RouteGuard>
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('protected-content')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('should redirect when user lacks required role', async () => {
|
|
const mockAuthContext = createMockAuthContext({
|
|
session: createMockSession({
|
|
user: {
|
|
userId: 'user-123',
|
|
email: 'user@example.com',
|
|
displayName: 'Regular User',
|
|
role: 'user',
|
|
},
|
|
}),
|
|
loading: false,
|
|
});
|
|
mockUseAuth.mockReturnValue(mockAuthContext);
|
|
|
|
render(
|
|
<RouteGuard config={{ requiredRoles: ['admin'] }}>
|
|
<div data-testid="protected-content">Protected Content</div>
|
|
</RouteGuard>
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(mockRouter.push).toHaveBeenCalledWith('/auth/login');
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Edge Cases', () => {
|
|
it('should handle undefined session gracefully', async () => {
|
|
const mockAuthContext = createMockAuthContext({
|
|
session: undefined as any,
|
|
loading: false,
|
|
});
|
|
mockUseAuth.mockReturnValue(mockAuthContext);
|
|
|
|
render(
|
|
<RouteGuard>
|
|
<div data-testid="protected-content">Protected Content</div>
|
|
</RouteGuard>
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(mockRouter.push).toHaveBeenCalledWith('/auth/login');
|
|
});
|
|
});
|
|
|
|
it('should handle empty required roles array', async () => {
|
|
const mockAuthContext = createMockAuthContext({
|
|
session: createMockSession(),
|
|
loading: false,
|
|
});
|
|
mockUseAuth.mockReturnValue(mockAuthContext);
|
|
|
|
render(
|
|
<RouteGuard config={{ requiredRoles: [] }}>
|
|
<div data-testid="protected-content">Protected Content</div>
|
|
</RouteGuard>
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('protected-content')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('should handle rapid session state changes', async () => {
|
|
const mockAuthContext = createMockAuthContext({
|
|
session: null,
|
|
loading: true,
|
|
});
|
|
mockUseAuth.mockReturnValue(mockAuthContext);
|
|
|
|
const { rerender } = render(
|
|
<RouteGuard>
|
|
<div data-testid="protected-content">Protected Content</div>
|
|
</RouteGuard>
|
|
);
|
|
|
|
// Simulate session becoming available
|
|
mockAuthContext.session = createMockSession();
|
|
mockAuthContext.loading = false;
|
|
|
|
rerender(
|
|
<RouteGuard>
|
|
<div data-testid="protected-content">Protected Content</div>
|
|
</RouteGuard>
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('protected-content')).toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Redirect Timing', () => {
|
|
it('should wait before redirecting (500ms delay)', async () => {
|
|
const mockAuthContext = createMockAuthContext({
|
|
session: null,
|
|
loading: false,
|
|
});
|
|
mockUseAuth.mockReturnValue(mockAuthContext);
|
|
|
|
render(
|
|
<RouteGuard>
|
|
<div data-testid="protected-content">Protected Content</div>
|
|
</RouteGuard>
|
|
);
|
|
|
|
// Should not redirect immediately
|
|
expect(mockRouter.push).not.toHaveBeenCalled();
|
|
|
|
// Wait for the delay
|
|
await waitFor(() => {
|
|
expect(mockRouter.push).toHaveBeenCalledWith('/auth/login');
|
|
}, { timeout: 1000 });
|
|
});
|
|
});
|
|
}); |