test(react-auth): add 10 tests with jsdom + React Testing Library
Tests: createAuthProvider factory, AuthProvider rendering, login/logout flows, localStorage persistence, onLoginFallback, useAuth outside provider, custom storage prefix.
This commit is contained in:
parent
63c08dbb0a
commit
428e973548
@ -22,5 +22,14 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@bytelyst/api-client": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"jsdom": "^28.0.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4"
|
||||
}
|
||||
}
|
||||
|
||||
304
packages/react-auth/src/__tests__/react-auth.test.tsx
Normal file
304
packages/react-auth/src/__tests__/react-auth.test.tsx
Normal file
@ -0,0 +1,304 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, act, cleanup } from '@testing-library/react';
|
||||
import { createAuthProvider } from '../index.js';
|
||||
|
||||
// Minimal user type for testing
|
||||
interface TestUser {
|
||||
email: string;
|
||||
name: string;
|
||||
role: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
// Mock fetch globally
|
||||
const mockFetch = vi.fn();
|
||||
globalThis.fetch = mockFetch;
|
||||
|
||||
// localStorage mock
|
||||
const store: Record<string, string> = {};
|
||||
const localStorageMock = {
|
||||
getItem: vi.fn((key: string) => store[key] ?? null),
|
||||
setItem: vi.fn((key: string, value: string) => {
|
||||
store[key] = value;
|
||||
}),
|
||||
removeItem: vi.fn((key: string) => {
|
||||
delete store[key];
|
||||
}),
|
||||
clear: vi.fn(() => {
|
||||
for (const key of Object.keys(store)) delete store[key];
|
||||
}),
|
||||
length: 0,
|
||||
key: vi.fn(),
|
||||
};
|
||||
Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock });
|
||||
|
||||
function createTestAuth(overrides?: Partial<Parameters<typeof createAuthProvider<TestUser>>[0]>) {
|
||||
return createAuthProvider<TestUser>({
|
||||
storagePrefix: 'test',
|
||||
loginEndpoint: '/auth/login',
|
||||
mapLoginResponse: (data: unknown) => {
|
||||
const d = data as { user: TestUser; accessToken: string; refreshToken: string };
|
||||
return { user: d.user, accessToken: d.accessToken, refreshToken: d.refreshToken };
|
||||
},
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
describe('createAuthProvider', () => {
|
||||
beforeEach(() => {
|
||||
cleanup();
|
||||
localStorageMock.clear();
|
||||
vi.clearAllMocks();
|
||||
mockFetch.mockReset();
|
||||
});
|
||||
|
||||
it('returns AuthProvider and useAuth', () => {
|
||||
const result = createTestAuth();
|
||||
expect(result.AuthProvider).toBeDefined();
|
||||
expect(result.useAuth).toBeDefined();
|
||||
expect(typeof result.AuthProvider).toBe('function');
|
||||
expect(typeof result.useAuth).toBe('function');
|
||||
});
|
||||
|
||||
it('renders children', () => {
|
||||
const { AuthProvider } = createTestAuth();
|
||||
render(
|
||||
<AuthProvider>
|
||||
<div data-testid="child">Hello</div>
|
||||
</AuthProvider>
|
||||
);
|
||||
expect(screen.getByTestId('child')).toBeDefined();
|
||||
expect(screen.getByText('Hello')).toBeDefined();
|
||||
});
|
||||
|
||||
it('starts unauthenticated with no stored user', () => {
|
||||
const { AuthProvider, useAuth } = createTestAuth();
|
||||
function Display() {
|
||||
const { user, isAuthenticated, isLoading } = useAuth();
|
||||
return (
|
||||
<div>
|
||||
<span data-testid="auth">{String(isAuthenticated)}</span>
|
||||
<span data-testid="loading">{String(isLoading)}</span>
|
||||
<span data-testid="user">{user ? user.email : 'none'}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
render(
|
||||
<AuthProvider>
|
||||
<Display />
|
||||
</AuthProvider>
|
||||
);
|
||||
expect(screen.getByTestId('auth').textContent).toBe('false');
|
||||
expect(screen.getByTestId('loading').textContent).toBe('false');
|
||||
expect(screen.getByTestId('user').textContent).toBe('none');
|
||||
});
|
||||
|
||||
it('restores user from localStorage on mount', () => {
|
||||
const storedUser: TestUser = { email: 'a@b.com', name: 'Stored', role: 'user' };
|
||||
store['test_auth_user'] = JSON.stringify(storedUser);
|
||||
|
||||
const { AuthProvider, useAuth } = createTestAuth();
|
||||
function Display() {
|
||||
const { user, isAuthenticated } = useAuth();
|
||||
return (
|
||||
<div>
|
||||
<span data-testid="auth">{String(isAuthenticated)}</span>
|
||||
<span data-testid="email">{user?.email ?? 'none'}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
render(
|
||||
<AuthProvider>
|
||||
<Display />
|
||||
</AuthProvider>
|
||||
);
|
||||
expect(screen.getByTestId('auth').textContent).toBe('true');
|
||||
expect(screen.getByTestId('email').textContent).toBe('a@b.com');
|
||||
});
|
||||
|
||||
it('login stores user and tokens on success', async () => {
|
||||
const apiResponse = {
|
||||
user: { email: 'test@example.com', name: 'Test' },
|
||||
accessToken: 'at-123',
|
||||
refreshToken: 'rt-456',
|
||||
};
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => apiResponse,
|
||||
headers: new Headers({ 'content-type': 'application/json' }),
|
||||
status: 200,
|
||||
});
|
||||
|
||||
const { AuthProvider, useAuth } = createTestAuth();
|
||||
let loginFn: (email: string, password: string) => Promise<boolean>;
|
||||
|
||||
function LoginComponent() {
|
||||
const { login, user, isAuthenticated } = useAuth();
|
||||
loginFn = login;
|
||||
return (
|
||||
<div>
|
||||
<span data-testid="auth">{String(isAuthenticated)}</span>
|
||||
<span data-testid="email">{user?.email ?? 'none'}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
render(
|
||||
<AuthProvider>
|
||||
<LoginComponent />
|
||||
</AuthProvider>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('auth').textContent).toBe('false');
|
||||
|
||||
let result: boolean = false;
|
||||
await act(async () => {
|
||||
result = await loginFn!('test@example.com', 'pass123');
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(screen.getByTestId('auth').textContent).toBe('true');
|
||||
expect(screen.getByTestId('email').textContent).toBe('test@example.com');
|
||||
expect(localStorageMock.setItem).toHaveBeenCalledWith(
|
||||
'test_auth_user',
|
||||
expect.stringContaining('test@example.com')
|
||||
);
|
||||
expect(localStorageMock.setItem).toHaveBeenCalledWith('test_access_token', 'at-123');
|
||||
expect(localStorageMock.setItem).toHaveBeenCalledWith('test_refresh_token', 'rt-456');
|
||||
});
|
||||
|
||||
it('login returns false on API failure', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 401,
|
||||
json: async () => ({ error: 'Unauthorized' }),
|
||||
headers: new Headers({ 'content-type': 'application/json' }),
|
||||
});
|
||||
|
||||
const { AuthProvider, useAuth } = createTestAuth();
|
||||
let loginFn: (email: string, password: string) => Promise<boolean>;
|
||||
|
||||
function LoginComponent() {
|
||||
const { login, isAuthenticated } = useAuth();
|
||||
loginFn = login;
|
||||
return <span data-testid="auth">{String(isAuthenticated)}</span>;
|
||||
}
|
||||
|
||||
render(
|
||||
<AuthProvider>
|
||||
<LoginComponent />
|
||||
</AuthProvider>
|
||||
);
|
||||
|
||||
let result: boolean = false;
|
||||
await act(async () => {
|
||||
result = await loginFn!('bad@example.com', 'wrong');
|
||||
});
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(screen.getByTestId('auth').textContent).toBe('false');
|
||||
});
|
||||
|
||||
it('logout clears user and storage', async () => {
|
||||
store['test_auth_user'] = JSON.stringify({ email: 'a@b.com', name: 'A', role: 'admin' });
|
||||
store['test_access_token'] = 'token';
|
||||
store['test_refresh_token'] = 'refresh';
|
||||
|
||||
const onLogout = vi.fn();
|
||||
const { AuthProvider, useAuth } = createTestAuth({ onLogout });
|
||||
let logoutFn: () => void;
|
||||
|
||||
function Component() {
|
||||
const { logout, isAuthenticated } = useAuth();
|
||||
logoutFn = logout;
|
||||
return <span data-testid="auth">{String(isAuthenticated)}</span>;
|
||||
}
|
||||
|
||||
render(
|
||||
<AuthProvider>
|
||||
<Component />
|
||||
</AuthProvider>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('auth').textContent).toBe('true');
|
||||
|
||||
act(() => {
|
||||
logoutFn!();
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('auth').textContent).toBe('false');
|
||||
expect(localStorageMock.removeItem).toHaveBeenCalledWith('test_auth_user');
|
||||
expect(localStorageMock.removeItem).toHaveBeenCalledWith('test_access_token');
|
||||
expect(localStorageMock.removeItem).toHaveBeenCalledWith('test_refresh_token');
|
||||
expect(onLogout).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('useAuth throws outside AuthProvider', () => {
|
||||
const { useAuth } = createTestAuth();
|
||||
function Bad() {
|
||||
useAuth();
|
||||
return null;
|
||||
}
|
||||
expect(() => render(<Bad />)).toThrow('useAuth must be used within an AuthProvider');
|
||||
});
|
||||
|
||||
it('calls onLoginFallback when API fails', async () => {
|
||||
mockFetch.mockRejectedValueOnce(new Error('Network error'));
|
||||
|
||||
const fallbackUser: TestUser = { email: 'mock@test.com', name: 'Mock', role: 'user' };
|
||||
const onLoginFallback = vi.fn().mockResolvedValue({
|
||||
user: fallbackUser,
|
||||
accessToken: 'fallback-at',
|
||||
refreshToken: 'fallback-rt',
|
||||
});
|
||||
|
||||
const { AuthProvider, useAuth } = createTestAuth({ onLoginFallback });
|
||||
let loginFn: (email: string, password: string) => Promise<boolean>;
|
||||
|
||||
function Component() {
|
||||
const { login, user } = useAuth();
|
||||
loginFn = login;
|
||||
return <span data-testid="email">{user?.email ?? 'none'}</span>;
|
||||
}
|
||||
|
||||
render(
|
||||
<AuthProvider>
|
||||
<Component />
|
||||
</AuthProvider>
|
||||
);
|
||||
|
||||
let result = false;
|
||||
await act(async () => {
|
||||
result = await loginFn!('mock@test.com', 'pass');
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(onLoginFallback).toHaveBeenCalledWith('mock@test.com', 'pass', expect.any(String));
|
||||
expect(screen.getByTestId('email').textContent).toBe('mock@test.com');
|
||||
});
|
||||
|
||||
it('uses correct storage prefix for keys', () => {
|
||||
const storedUser: TestUser = { email: 'x@y.com', name: 'X', role: 'viewer' };
|
||||
store['custom_auth_user'] = JSON.stringify(storedUser);
|
||||
|
||||
const { AuthProvider, useAuth } = createAuthProvider<TestUser>({
|
||||
storagePrefix: 'custom',
|
||||
loginEndpoint: '/login',
|
||||
mapLoginResponse: (d: unknown) =>
|
||||
d as { user: TestUser; accessToken: string; refreshToken: string },
|
||||
});
|
||||
|
||||
function Display() {
|
||||
const { user } = useAuth();
|
||||
return <span data-testid="email">{user?.email ?? 'none'}</span>;
|
||||
}
|
||||
|
||||
render(
|
||||
<AuthProvider>
|
||||
<Display />
|
||||
</AuthProvider>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('email').textContent).toBe('x@y.com');
|
||||
});
|
||||
});
|
||||
7
packages/react-auth/vitest.config.ts
Normal file
7
packages/react-auth/vitest.config.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
},
|
||||
});
|
||||
719
pnpm-lock.yaml
generated
719
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user