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": {
|
"dependencies": {
|
||||||
"@bytelyst/api-client": "workspace:*"
|
"@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