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:
saravanakumardb1 2026-02-12 22:55:44 -08:00
parent 63c08dbb0a
commit 428e973548
4 changed files with 1028 additions and 11 deletions

View File

@ -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"
}
}

View 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');
});
});

View File

@ -0,0 +1,7 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'jsdom',
},
});

719
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff