wip 21
This commit is contained in:
@@ -8,12 +8,13 @@ import {
|
||||
useGetMe,
|
||||
useRegister,
|
||||
} from '../generated/anthoLumeAPIV1';
|
||||
|
||||
interface AuthState {
|
||||
isAuthenticated: boolean;
|
||||
user: { username: string; is_admin: boolean } | null;
|
||||
isCheckingAuth: boolean;
|
||||
}
|
||||
import {
|
||||
type AuthState,
|
||||
getAuthenticatedAuthState,
|
||||
getUnauthenticatedAuthState,
|
||||
resolveAuthStateFromMe,
|
||||
validateAuthMutationResponse,
|
||||
} from './authHelpers';
|
||||
|
||||
interface AuthContextType extends AuthState {
|
||||
login: (_username: string, _password: string) => Promise<void>;
|
||||
@@ -23,12 +24,14 @@ interface AuthContextType extends AuthState {
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
const initialAuthState: AuthState = {
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
isCheckingAuth: true,
|
||||
};
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [authState, setAuthState] = useState<AuthState>({
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
isCheckingAuth: true,
|
||||
});
|
||||
const [authState, setAuthState] = useState<AuthState>(initialAuthState);
|
||||
|
||||
const loginMutation = useLogin();
|
||||
const registerMutation = useRegister();
|
||||
@@ -40,26 +43,14 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
setAuthState(prev => {
|
||||
if (meLoading) {
|
||||
return { ...prev, isCheckingAuth: true };
|
||||
} else if (meData?.data && meData.status === 200) {
|
||||
const userData = 'username' in meData.data ? meData.data : null;
|
||||
return {
|
||||
isAuthenticated: true,
|
||||
user: userData as { username: string; is_admin: boolean } | null,
|
||||
isCheckingAuth: false,
|
||||
};
|
||||
} else if (meError || (meData && meData.status === 401)) {
|
||||
return {
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
isCheckingAuth: false,
|
||||
};
|
||||
}
|
||||
|
||||
return { ...prev, isCheckingAuth: false };
|
||||
});
|
||||
setAuthState(prev =>
|
||||
resolveAuthStateFromMe({
|
||||
meData,
|
||||
meError,
|
||||
meLoading,
|
||||
previousState: prev,
|
||||
})
|
||||
);
|
||||
}, [meData, meError, meLoading]);
|
||||
|
||||
const login = useCallback(
|
||||
@@ -72,29 +63,18 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
},
|
||||
});
|
||||
|
||||
if (response.status !== 200 || !('username' in response.data)) {
|
||||
setAuthState({
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
isCheckingAuth: false,
|
||||
});
|
||||
const user = validateAuthMutationResponse(response, 200);
|
||||
if (!user) {
|
||||
setAuthState(getUnauthenticatedAuthState());
|
||||
throw new Error('Login failed');
|
||||
}
|
||||
|
||||
setAuthState({
|
||||
isAuthenticated: true,
|
||||
user: response.data as { username: string; is_admin: boolean },
|
||||
isCheckingAuth: false,
|
||||
});
|
||||
setAuthState(getAuthenticatedAuthState(user));
|
||||
|
||||
await queryClient.invalidateQueries({ queryKey: getGetMeQueryKey() });
|
||||
navigate('/');
|
||||
} catch (_error) {
|
||||
setAuthState({
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
isCheckingAuth: false,
|
||||
});
|
||||
setAuthState(getUnauthenticatedAuthState());
|
||||
throw new Error('Login failed');
|
||||
}
|
||||
},
|
||||
@@ -111,29 +91,18 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
},
|
||||
});
|
||||
|
||||
if (response.status !== 201 || !('username' in response.data)) {
|
||||
setAuthState({
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
isCheckingAuth: false,
|
||||
});
|
||||
const user = validateAuthMutationResponse(response, 201);
|
||||
if (!user) {
|
||||
setAuthState(getUnauthenticatedAuthState());
|
||||
throw new Error('Registration failed');
|
||||
}
|
||||
|
||||
setAuthState({
|
||||
isAuthenticated: true,
|
||||
user: response.data as { username: string; is_admin: boolean },
|
||||
isCheckingAuth: false,
|
||||
});
|
||||
setAuthState(getAuthenticatedAuthState(user));
|
||||
|
||||
await queryClient.invalidateQueries({ queryKey: getGetMeQueryKey() });
|
||||
navigate('/');
|
||||
} catch (_error) {
|
||||
setAuthState({
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
isCheckingAuth: false,
|
||||
});
|
||||
setAuthState(getUnauthenticatedAuthState());
|
||||
throw new Error('Registration failed');
|
||||
}
|
||||
},
|
||||
@@ -143,11 +112,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const logout = useCallback(() => {
|
||||
logoutMutation.mutate(undefined, {
|
||||
onSuccess: async () => {
|
||||
setAuthState({
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
isCheckingAuth: false,
|
||||
});
|
||||
setAuthState(getUnauthenticatedAuthState());
|
||||
await queryClient.removeQueries({ queryKey: getGetMeQueryKey() });
|
||||
navigate('/login');
|
||||
},
|
||||
|
||||
90
frontend/src/auth/ProtectedRoute.test.tsx
Normal file
90
frontend/src/auth/ProtectedRoute.test.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||
import { ProtectedRoute } from './ProtectedRoute';
|
||||
import { useAuth } from './AuthContext';
|
||||
|
||||
vi.mock('./AuthContext', () => ({
|
||||
useAuth: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockedUseAuth = vi.mocked(useAuth);
|
||||
|
||||
describe('ProtectedRoute', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('shows a loading state while auth is being checked', () => {
|
||||
mockedUseAuth.mockReturnValue({
|
||||
isAuthenticated: false,
|
||||
isCheckingAuth: true,
|
||||
user: null,
|
||||
login: vi.fn(),
|
||||
register: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
});
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/private']}>
|
||||
<ProtectedRoute>
|
||||
<div>Secret</div>
|
||||
</ProtectedRoute>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Loading...')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Secret')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('redirects unauthenticated users to the login page', () => {
|
||||
mockedUseAuth.mockReturnValue({
|
||||
isAuthenticated: false,
|
||||
isCheckingAuth: false,
|
||||
user: null,
|
||||
login: vi.fn(),
|
||||
register: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
});
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/private']}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/private"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<div>Secret</div>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="/login" element={<div>Login Page</div>} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Login Page')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Secret')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders children for authenticated users', () => {
|
||||
mockedUseAuth.mockReturnValue({
|
||||
isAuthenticated: true,
|
||||
isCheckingAuth: false,
|
||||
user: { username: 'evan', is_admin: false },
|
||||
login: vi.fn(),
|
||||
register: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
});
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ProtectedRoute>
|
||||
<div>Secret</div>
|
||||
</ProtectedRoute>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Secret')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
157
frontend/src/auth/authHelpers.test.ts
Normal file
157
frontend/src/auth/authHelpers.test.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
getCheckingAuthState,
|
||||
getUnauthenticatedAuthState,
|
||||
normalizeAuthenticatedUser,
|
||||
resolveAuthStateFromMe,
|
||||
validateAuthMutationResponse,
|
||||
type AuthState,
|
||||
} from './authHelpers';
|
||||
|
||||
const previousState: AuthState = {
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
isCheckingAuth: true,
|
||||
};
|
||||
|
||||
describe('authHelpers', () => {
|
||||
it('normalizes a valid authenticated user payload', () => {
|
||||
expect(normalizeAuthenticatedUser({ username: 'evan', is_admin: true })).toEqual({
|
||||
username: 'evan',
|
||||
is_admin: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects invalid authenticated user payloads', () => {
|
||||
expect(normalizeAuthenticatedUser(null)).toBeNull();
|
||||
expect(normalizeAuthenticatedUser({ username: 'evan' })).toBeNull();
|
||||
expect(normalizeAuthenticatedUser({ username: 123, is_admin: true })).toBeNull();
|
||||
expect(normalizeAuthenticatedUser({ username: 'evan', is_admin: 'yes' })).toBeNull();
|
||||
});
|
||||
|
||||
it('returns a checking state while preserving previous auth information', () => {
|
||||
expect(
|
||||
getCheckingAuthState({
|
||||
isAuthenticated: true,
|
||||
user: { username: 'evan', is_admin: false },
|
||||
isCheckingAuth: false,
|
||||
})
|
||||
).toEqual({
|
||||
isAuthenticated: true,
|
||||
user: { username: 'evan', is_admin: false },
|
||||
isCheckingAuth: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves auth state from a successful /auth/me response', () => {
|
||||
expect(
|
||||
resolveAuthStateFromMe({
|
||||
meData: {
|
||||
status: 200,
|
||||
data: { username: 'evan', is_admin: false },
|
||||
},
|
||||
meError: undefined,
|
||||
meLoading: false,
|
||||
previousState,
|
||||
})
|
||||
).toEqual({
|
||||
isAuthenticated: true,
|
||||
user: { username: 'evan', is_admin: false },
|
||||
isCheckingAuth: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves auth state to unauthenticated on 401 or query error', () => {
|
||||
expect(
|
||||
resolveAuthStateFromMe({
|
||||
meData: {
|
||||
status: 401,
|
||||
},
|
||||
meError: undefined,
|
||||
meLoading: false,
|
||||
previousState,
|
||||
})
|
||||
).toEqual(getUnauthenticatedAuthState());
|
||||
|
||||
expect(
|
||||
resolveAuthStateFromMe({
|
||||
meData: undefined,
|
||||
meError: new Error('failed'),
|
||||
meLoading: false,
|
||||
previousState,
|
||||
})
|
||||
).toEqual(getUnauthenticatedAuthState());
|
||||
});
|
||||
|
||||
it('keeps checking state while /auth/me is still loading', () => {
|
||||
expect(
|
||||
resolveAuthStateFromMe({
|
||||
meData: undefined,
|
||||
meError: undefined,
|
||||
meLoading: true,
|
||||
previousState: {
|
||||
isAuthenticated: true,
|
||||
user: { username: 'evan', is_admin: true },
|
||||
isCheckingAuth: false,
|
||||
},
|
||||
})
|
||||
).toEqual({
|
||||
isAuthenticated: true,
|
||||
user: { username: 'evan', is_admin: true },
|
||||
isCheckingAuth: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the previous state with checking disabled when there is no decisive me result', () => {
|
||||
expect(
|
||||
resolveAuthStateFromMe({
|
||||
meData: {
|
||||
status: 204,
|
||||
},
|
||||
meError: undefined,
|
||||
meLoading: false,
|
||||
previousState: {
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
isCheckingAuth: true,
|
||||
},
|
||||
})
|
||||
).toEqual({
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
isCheckingAuth: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('validates auth mutation responses by expected status and payload shape', () => {
|
||||
expect(
|
||||
validateAuthMutationResponse(
|
||||
{
|
||||
status: 200,
|
||||
data: { username: 'evan', is_admin: false },
|
||||
},
|
||||
200
|
||||
)
|
||||
).toEqual({ username: 'evan', is_admin: false });
|
||||
|
||||
expect(
|
||||
validateAuthMutationResponse(
|
||||
{
|
||||
status: 201,
|
||||
data: { username: 'evan', is_admin: false },
|
||||
},
|
||||
200
|
||||
)
|
||||
).toBeNull();
|
||||
|
||||
expect(
|
||||
validateAuthMutationResponse(
|
||||
{
|
||||
status: 200,
|
||||
data: { username: 'evan' },
|
||||
},
|
||||
200
|
||||
)
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
98
frontend/src/auth/authHelpers.ts
Normal file
98
frontend/src/auth/authHelpers.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
export interface AuthUser {
|
||||
username: string;
|
||||
is_admin: boolean;
|
||||
}
|
||||
|
||||
export interface AuthState {
|
||||
isAuthenticated: boolean;
|
||||
user: AuthUser | null;
|
||||
isCheckingAuth: boolean;
|
||||
}
|
||||
|
||||
interface ResponseLike {
|
||||
status?: number;
|
||||
data?: unknown;
|
||||
}
|
||||
|
||||
export function getUnauthenticatedAuthState(): AuthState {
|
||||
return {
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
isCheckingAuth: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function getCheckingAuthState(previousState?: AuthState): AuthState {
|
||||
return {
|
||||
isAuthenticated: previousState?.isAuthenticated ?? false,
|
||||
user: previousState?.user ?? null,
|
||||
isCheckingAuth: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function getAuthenticatedAuthState(user: AuthUser): AuthState {
|
||||
return {
|
||||
isAuthenticated: true,
|
||||
user,
|
||||
isCheckingAuth: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeAuthenticatedUser(value: unknown): AuthUser | null {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!('username' in value) || typeof value.username !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!('is_admin' in value) || typeof value.is_admin !== 'boolean') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
username: value.username,
|
||||
is_admin: value.is_admin,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveAuthStateFromMe(params: {
|
||||
meData?: ResponseLike;
|
||||
meError?: unknown;
|
||||
meLoading: boolean;
|
||||
previousState: AuthState;
|
||||
}): AuthState {
|
||||
const { meData, meError, meLoading, previousState } = params;
|
||||
|
||||
if (meLoading) {
|
||||
return getCheckingAuthState(previousState);
|
||||
}
|
||||
|
||||
if (meData?.status === 200) {
|
||||
const user = normalizeAuthenticatedUser(meData.data);
|
||||
if (user) {
|
||||
return getAuthenticatedAuthState(user);
|
||||
}
|
||||
}
|
||||
|
||||
if (meError || meData?.status === 401) {
|
||||
return getUnauthenticatedAuthState();
|
||||
}
|
||||
|
||||
return {
|
||||
...previousState,
|
||||
isCheckingAuth: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function validateAuthMutationResponse(
|
||||
response: ResponseLike,
|
||||
expectedStatus: number
|
||||
): AuthUser | null {
|
||||
if (response.status !== expectedStatus) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return normalizeAuthenticatedUser(response.data);
|
||||
}
|
||||
115
frontend/src/auth/authInterceptor.test.ts
Normal file
115
frontend/src/auth/authInterceptor.test.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { setupAuthInterceptors, TOKEN_KEY } from './authInterceptor';
|
||||
|
||||
type RequestConfig = {
|
||||
headers?: Record<string, string>;
|
||||
};
|
||||
|
||||
type ResponseValue = {
|
||||
status?: number;
|
||||
data?: unknown;
|
||||
};
|
||||
|
||||
type ResponseError = {
|
||||
response?: {
|
||||
status?: number;
|
||||
};
|
||||
};
|
||||
|
||||
function createMockAxiosInstance() {
|
||||
let nextRequestId = 1;
|
||||
let nextResponseId = 1;
|
||||
|
||||
const requestHandlers = new Map<
|
||||
number,
|
||||
[(config: RequestConfig) => RequestConfig, (error: unknown) => Promise<never>]
|
||||
>();
|
||||
const responseHandlers = new Map<
|
||||
number,
|
||||
[(response: ResponseValue) => ResponseValue, (error: ResponseError) => Promise<never>]
|
||||
>();
|
||||
|
||||
return {
|
||||
interceptors: {
|
||||
request: {
|
||||
use: vi.fn((fulfilled, rejected) => {
|
||||
const id = nextRequestId++;
|
||||
requestHandlers.set(id, [fulfilled, rejected]);
|
||||
return id;
|
||||
}),
|
||||
eject: vi.fn((id: number) => {
|
||||
requestHandlers.delete(id);
|
||||
}),
|
||||
},
|
||||
response: {
|
||||
use: vi.fn((fulfilled, rejected) => {
|
||||
const id = nextResponseId++;
|
||||
responseHandlers.set(id, [fulfilled, rejected]);
|
||||
return id;
|
||||
}),
|
||||
eject: vi.fn((id: number) => {
|
||||
responseHandlers.delete(id);
|
||||
}),
|
||||
},
|
||||
},
|
||||
getRequestHandler(id = 1) {
|
||||
return requestHandlers.get(id);
|
||||
},
|
||||
getResponseHandler(id = 1) {
|
||||
return responseHandlers.get(id);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('setupAuthInterceptors', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('registers request and response interceptors and adds the auth header when a token exists', () => {
|
||||
const axiosInstance = createMockAxiosInstance();
|
||||
|
||||
setupAuthInterceptors(axiosInstance as never);
|
||||
|
||||
expect(axiosInstance.interceptors.request.use).toHaveBeenCalledTimes(1);
|
||||
expect(axiosInstance.interceptors.response.use).toHaveBeenCalledTimes(1);
|
||||
|
||||
localStorage.setItem(TOKEN_KEY, 'token-123');
|
||||
|
||||
const requestHandler = axiosInstance.getRequestHandler()?.[0];
|
||||
const config: { headers: Record<string, string> } = { headers: {} };
|
||||
const nextConfig = requestHandler?.(config);
|
||||
|
||||
expect(nextConfig).toBe(config);
|
||||
expect(config.headers.Authorization).toBe('Bearer token-123');
|
||||
});
|
||||
|
||||
it('clears the auth token on 401 responses', async () => {
|
||||
const axiosInstance = createMockAxiosInstance();
|
||||
setupAuthInterceptors(axiosInstance as never);
|
||||
|
||||
localStorage.setItem(TOKEN_KEY, 'token-123');
|
||||
|
||||
const responseErrorHandler = axiosInstance.getResponseHandler()?.[1];
|
||||
|
||||
await expect(responseErrorHandler?.({ response: { status: 401 } })).rejects.toEqual({
|
||||
response: { status: 401 },
|
||||
});
|
||||
expect(localStorage.getItem(TOKEN_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it('ejects previous interceptors before installing a new set', () => {
|
||||
const firstInstance = createMockAxiosInstance();
|
||||
const secondInstance = createMockAxiosInstance();
|
||||
|
||||
const cleanup = setupAuthInterceptors(firstInstance as never);
|
||||
setupAuthInterceptors(secondInstance as never);
|
||||
|
||||
expect(firstInstance.interceptors.request.eject).toHaveBeenCalledWith(1);
|
||||
expect(firstInstance.interceptors.response.eject).toHaveBeenCalledWith(1);
|
||||
|
||||
cleanup();
|
||||
expect(firstInstance.interceptors.request.eject).toHaveBeenCalledWith(1);
|
||||
expect(firstInstance.interceptors.response.eject).toHaveBeenCalledWith(1);
|
||||
});
|
||||
});
|
||||
@@ -1,35 +1,46 @@
|
||||
import axios from 'axios';
|
||||
import axios, { type AxiosInstance } from 'axios';
|
||||
|
||||
const TOKEN_KEY = 'antholume_token';
|
||||
|
||||
// Request interceptor to add auth token to requests
|
||||
axios.interceptors.request.use(
|
||||
config => {
|
||||
const token = localStorage.getItem(TOKEN_KEY);
|
||||
if (token && config.headers) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
error => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
let interceptorCleanup: (() => void) | null = null;
|
||||
|
||||
// Response interceptor to handle auth errors
|
||||
axios.interceptors.response.use(
|
||||
response => {
|
||||
return response;
|
||||
},
|
||||
error => {
|
||||
if (error.response?.status === 401) {
|
||||
// Clear token on auth failure
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
// Optionally redirect to login
|
||||
// window.location.href = '/login';
|
||||
}
|
||||
return Promise.reject(error);
|
||||
export function setupAuthInterceptors(axiosInstance: AxiosInstance = axios) {
|
||||
if (interceptorCleanup) {
|
||||
interceptorCleanup();
|
||||
}
|
||||
);
|
||||
|
||||
const requestInterceptorId = axiosInstance.interceptors.request.use(
|
||||
config => {
|
||||
const token = localStorage.getItem(TOKEN_KEY);
|
||||
if (token && config.headers) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
error => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
const responseInterceptorId = axiosInstance.interceptors.response.use(
|
||||
response => {
|
||||
return response;
|
||||
},
|
||||
error => {
|
||||
if (error.response?.status === 401) {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
interceptorCleanup = () => {
|
||||
axiosInstance.interceptors.request.eject(requestInterceptorId);
|
||||
axiosInstance.interceptors.response.eject(responseInterceptorId);
|
||||
};
|
||||
|
||||
return interceptorCleanup;
|
||||
}
|
||||
|
||||
export { TOKEN_KEY };
|
||||
export default axios;
|
||||
|
||||
26
frontend/src/components/LoadingState.tsx
Normal file
26
frontend/src/components/LoadingState.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { LoadingIcon } from '../icons';
|
||||
import { cn } from '../utils/cn';
|
||||
|
||||
interface LoadingStateProps {
|
||||
message?: string;
|
||||
className?: string;
|
||||
iconSize?: number;
|
||||
}
|
||||
|
||||
export function LoadingState({
|
||||
message = 'Loading...',
|
||||
className = '',
|
||||
iconSize = 24,
|
||||
}: LoadingStateProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-center gap-3 text-gray-500 dark:text-gray-400',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<LoadingIcon size={iconSize} className="text-purple-600 dark:text-purple-400" />
|
||||
<span className="text-sm font-medium">{message}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { getSVGGraphData } from './ReadingHistoryGraph';
|
||||
|
||||
// Test data matching Go test exactly
|
||||
// Intentionally exact fixture data for algorithm parity coverage
|
||||
const testInput = [
|
||||
{ date: '2024-01-01', minutes_read: 10 },
|
||||
{ date: '2024-01-02', minutes_read: 90 },
|
||||
@@ -23,7 +23,7 @@ describe('ReadingHistoryGraph', () => {
|
||||
it('should match exactly', () => {
|
||||
const result = getSVGGraphData(testInput, svgWidth, svgHeight);
|
||||
|
||||
// Expected values from Go test
|
||||
// Expected exact algorithm output
|
||||
const expectedBezierPath =
|
||||
'M 50,95 C63,95 80,50 100,50 C120,50 128,73 150,73 C172,73 180,98 200,98 C220,98 230,95 250,95 C270,95 279,98 300,98 C321,98 330,62 350,62 C370,62 380,67 400,67 C420,67 430,73 450,73 C470,73 489,50 500,50';
|
||||
const expectedBezierFill = 'L 500,98 L 50,98 Z';
|
||||
@@ -37,13 +37,13 @@ describe('ReadingHistoryGraph', () => {
|
||||
expect(svgHeight).toBe(expectedHeight);
|
||||
expect(result.Offset).toBe(expectedOffset);
|
||||
|
||||
// Verify line points are integers like Go
|
||||
// Verify line points are integer pixel values
|
||||
result.LinePoints.forEach((p, _i) => {
|
||||
expect(Number.isInteger(p.x)).toBe(true);
|
||||
expect(Number.isInteger(p.y)).toBe(true);
|
||||
});
|
||||
|
||||
// Expected line points from Go calculation:
|
||||
// Expected line points from the current algorithm:
|
||||
// idx 0: itemSize=5, itemY=95, lineX=50
|
||||
// idx 1: itemSize=45, itemY=55, lineX=100
|
||||
// idx 2: itemSize=25, itemY=75, lineX=150
|
||||
|
||||
56
frontend/src/components/Table.test.tsx
Normal file
56
frontend/src/components/Table.test.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { Table, type Column } from './Table';
|
||||
|
||||
interface TestRow {
|
||||
id: string;
|
||||
name: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
const columns: Column<TestRow>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
header: 'Name',
|
||||
},
|
||||
{
|
||||
key: 'role',
|
||||
header: 'Role',
|
||||
},
|
||||
];
|
||||
|
||||
const data: TestRow[] = [
|
||||
{ id: 'user-1', name: 'Ada', role: 'Admin' },
|
||||
{ id: 'user-2', name: 'Grace', role: 'Reader' },
|
||||
];
|
||||
|
||||
describe('Table', () => {
|
||||
it('renders a skeleton table while loading', () => {
|
||||
const { container } = render(<Table columns={columns} data={[]} loading />);
|
||||
|
||||
expect(screen.queryByText('No Results')).not.toBeInTheDocument();
|
||||
expect(container.querySelectorAll('tbody tr')).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('renders the empty state message when there is no data', () => {
|
||||
render(<Table columns={columns} data={[]} emptyMessage="Nothing here" />);
|
||||
|
||||
expect(screen.getByText('Nothing here')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('uses a custom render function for column output', () => {
|
||||
const customColumns: Column<TestRow>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
header: 'Name',
|
||||
render: (_value, row, index) => `${index + 1}. ${row.name.toUpperCase()}`,
|
||||
},
|
||||
];
|
||||
|
||||
render(<Table columns={customColumns} data={data} />);
|
||||
|
||||
expect(screen.getByText('1. ADA')).toBeInTheDocument();
|
||||
expect(screen.getByText('2. GRACE')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
});
|
||||
@@ -17,6 +17,7 @@ export {
|
||||
PageLoader,
|
||||
InlineLoader,
|
||||
} from './Skeleton';
|
||||
export { LoadingState } from './LoadingState';
|
||||
|
||||
// Field components
|
||||
export { Field, FieldLabel, FieldValue, FieldActions } from './Field';
|
||||
|
||||
@@ -8,4 +8,12 @@
|
||||
|
||||
export type GetLogsParams = {
|
||||
filter?: string;
|
||||
/**
|
||||
* @minimum 1
|
||||
*/
|
||||
page?: number;
|
||||
/**
|
||||
* @minimum 1
|
||||
*/
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
@@ -10,4 +10,9 @@ import type { LogEntry } from './logEntry';
|
||||
export interface LogsResponse {
|
||||
logs?: LogEntry[];
|
||||
filter?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
next_page?: number;
|
||||
previous_page?: number;
|
||||
total?: number;
|
||||
}
|
||||
|
||||
69
frontend/src/hooks/useDebounce.test.tsx
Normal file
69
frontend/src/hooks/useDebounce.test.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it, vi, afterEach } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useDebounce } from './useDebounce';
|
||||
|
||||
describe('useDebounce', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('returns the initial value immediately', () => {
|
||||
const { result } = renderHook(({ value, delay }) => useDebounce(value, delay), {
|
||||
initialProps: { value: 'initial', delay: 300 },
|
||||
});
|
||||
|
||||
expect(result.current).toBe('initial');
|
||||
});
|
||||
|
||||
it('delays updates until the debounce interval has passed', () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const { result, rerender } = renderHook(({ value, delay }) => useDebounce(value, delay), {
|
||||
initialProps: { value: 'initial', delay: 300 },
|
||||
});
|
||||
|
||||
rerender({ value: 'updated', delay: 300 });
|
||||
|
||||
expect(result.current).toBe('initial');
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(299);
|
||||
});
|
||||
|
||||
expect(result.current).toBe('initial');
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1);
|
||||
});
|
||||
|
||||
expect(result.current).toBe('updated');
|
||||
});
|
||||
|
||||
it('cancels the previous timer when the value changes again', () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const { result, rerender } = renderHook(({ value, delay }) => useDebounce(value, delay), {
|
||||
initialProps: { value: 'first', delay: 300 },
|
||||
});
|
||||
|
||||
rerender({ value: 'second', delay: 300 });
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(200);
|
||||
});
|
||||
|
||||
rerender({ value: 'third', delay: 300 });
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(100);
|
||||
});
|
||||
|
||||
expect(result.current).toBe('first');
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(200);
|
||||
});
|
||||
|
||||
expect(result.current).toBe('third');
|
||||
});
|
||||
});
|
||||
@@ -2,15 +2,18 @@ import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import axios from 'axios';
|
||||
import { ToastProvider } from './components/ToastContext';
|
||||
import './auth/authInterceptor';
|
||||
import { setupAuthInterceptors } from './auth/authInterceptor';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
|
||||
setupAuthInterceptors(axios);
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
staleTime: 1000 * 60 * 5,
|
||||
retry: 1,
|
||||
},
|
||||
mutations: {
|
||||
|
||||
@@ -1,25 +1,29 @@
|
||||
import { useState, FormEvent } from 'react';
|
||||
import { useState, useEffect, FormEvent } from 'react';
|
||||
import { useGetLogs } from '../generated/anthoLumeAPIV1';
|
||||
import type { LogsResponse } from '../generated/model';
|
||||
import { Button } from '../components/Button';
|
||||
import { SearchIcon } from '../icons';
|
||||
import { LoadingState } from '../components';
|
||||
import { useDebounce } from '../hooks/useDebounce';
|
||||
import { Search2Icon } from '../icons';
|
||||
|
||||
export default function AdminLogsPage() {
|
||||
const [filter, setFilter] = useState('');
|
||||
const [activeFilter, setActiveFilter] = useState('');
|
||||
const debouncedFilter = useDebounce(filter, 300);
|
||||
|
||||
const { data: logsData, isLoading, refetch } = useGetLogs(filter ? { filter } : {});
|
||||
useEffect(() => {
|
||||
setActiveFilter(debouncedFilter);
|
||||
}, [debouncedFilter]);
|
||||
|
||||
const { data: logsData, isLoading } = useGetLogs(activeFilter ? { filter: activeFilter } : {});
|
||||
|
||||
const logs = logsData?.status === 200 ? ((logsData.data as LogsResponse).logs ?? []) : [];
|
||||
|
||||
const handleFilterSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
refetch();
|
||||
setActiveFilter(filter);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="text-gray-500 dark:text-white">Loading...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4 flex grow flex-col gap-2 rounded bg-white p-4 text-gray-500 shadow-lg dark:bg-gray-700 dark:text-white">
|
||||
@@ -27,7 +31,7 @@ export default function AdminLogsPage() {
|
||||
<div className="flex w-full grow flex-col">
|
||||
<div className="relative flex">
|
||||
<span className="inline-flex items-center border-y border-l border-gray-300 bg-white px-3 text-sm text-gray-500 shadow-sm">
|
||||
<SearchIcon size={15} />
|
||||
<Search2Icon size={15} />
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
@@ -50,11 +54,15 @@ export default function AdminLogsPage() {
|
||||
className="flex w-full flex-col-reverse overflow-scroll text-black dark:text-white"
|
||||
style={{ fontFamily: 'monospace' }}
|
||||
>
|
||||
{logs.map((log, index) => (
|
||||
<span key={index} className="whitespace-nowrap hover:whitespace-pre">
|
||||
{typeof log === 'string' ? log : JSON.stringify(log)}
|
||||
</span>
|
||||
))}
|
||||
{isLoading ? (
|
||||
<LoadingState className="min-h-40 w-full" />
|
||||
) : (
|
||||
logs.map((log, index) => (
|
||||
<span key={index} className="whitespace-nowrap hover:whitespace-pre">
|
||||
{typeof log === 'string' ? log : JSON.stringify(log)}
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,8 +2,9 @@ import { useState, FormEvent, useRef, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useGetDocuments, useCreateDocument } from '../generated/anthoLumeAPIV1';
|
||||
import type { Document, DocumentsResponse } from '../generated/model';
|
||||
import { ActivityIcon, DownloadIcon, SearchIcon, UploadIcon } from '../icons';
|
||||
import { ActivityIcon, DownloadIcon, Search2Icon, UploadIcon } from '../icons';
|
||||
import { Button } from '../components/Button';
|
||||
import { LoadingState } from '../components';
|
||||
import { useToasts } from '../components/ToastContext';
|
||||
import { formatDuration } from '../utils/formatters';
|
||||
import { useDebounce } from '../hooks/useDebounce';
|
||||
@@ -136,7 +137,7 @@ export default function DocumentsPage() {
|
||||
<div className="flex w-full grow flex-col">
|
||||
<div className="relative flex">
|
||||
<span className="inline-flex items-center border-y border-l border-gray-300 bg-white px-3 text-sm text-gray-500 shadow-sm">
|
||||
<SearchIcon size={15} />
|
||||
<Search2Icon size={15} />
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
@@ -159,7 +160,7 @@ export default function DocumentsPage() {
|
||||
{/* Document Grid */}
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{isLoading ? (
|
||||
<div className="col-span-full text-center text-gray-500 dark:text-white">Loading...</div>
|
||||
<LoadingState className="col-span-full min-h-48" />
|
||||
) : (
|
||||
docs?.map(doc => <DocumentCard key={doc.id} doc={doc} />)
|
||||
)}
|
||||
|
||||
190
frontend/src/pages/LoginPage.test.tsx
Normal file
190
frontend/src/pages/LoginPage.test.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import LoginPage from './LoginPage';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import { useToasts } from '../components/ToastContext';
|
||||
import { useGetInfo } from '../generated/anthoLumeAPIV1';
|
||||
|
||||
const navigateMock = vi.fn();
|
||||
|
||||
vi.mock('react-router-dom', async importOriginal => {
|
||||
const actual = await importOriginal<typeof import('react-router-dom')>();
|
||||
return {
|
||||
...actual,
|
||||
useNavigate: () => navigateMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../auth/AuthContext', () => ({
|
||||
useAuth: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../components/ToastContext', () => ({
|
||||
useToasts: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../generated/anthoLumeAPIV1', () => ({
|
||||
useGetInfo: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockedUseAuth = vi.mocked(useAuth);
|
||||
const mockedUseToasts = vi.mocked(useToasts);
|
||||
const mockedUseGetInfo = vi.mocked(useGetInfo);
|
||||
|
||||
describe('LoginPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
mockedUseAuth.mockReturnValue({
|
||||
isAuthenticated: false,
|
||||
isCheckingAuth: false,
|
||||
user: null,
|
||||
login: vi.fn().mockResolvedValue(undefined),
|
||||
register: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
});
|
||||
|
||||
mockedUseToasts.mockReturnValue({
|
||||
showToast: vi.fn(),
|
||||
showInfo: vi.fn(),
|
||||
showWarning: vi.fn(),
|
||||
showError: vi.fn(),
|
||||
removeToast: vi.fn(),
|
||||
clearToasts: vi.fn(),
|
||||
});
|
||||
|
||||
mockedUseGetInfo.mockReturnValue({
|
||||
data: {
|
||||
status: 200,
|
||||
data: {
|
||||
registration_enabled: false,
|
||||
},
|
||||
},
|
||||
} as ReturnType<typeof useGetInfo>);
|
||||
});
|
||||
|
||||
it('submits the username and password to login', async () => {
|
||||
const user = userEvent.setup();
|
||||
const loginMock = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
mockedUseAuth.mockReturnValue({
|
||||
isAuthenticated: false,
|
||||
isCheckingAuth: false,
|
||||
user: null,
|
||||
login: loginMock,
|
||||
register: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
});
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<LoginPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
await user.type(screen.getByPlaceholderText('Username'), 'evan');
|
||||
await user.type(screen.getByPlaceholderText('Password'), 'secret');
|
||||
await user.click(screen.getByRole('button', { name: 'Login' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(loginMock).toHaveBeenCalledWith('evan', 'secret');
|
||||
});
|
||||
});
|
||||
|
||||
it('shows a toast error when login fails', async () => {
|
||||
const user = userEvent.setup();
|
||||
const loginMock = vi.fn().mockRejectedValue(new Error('bad credentials'));
|
||||
const showErrorMock = vi.fn();
|
||||
|
||||
mockedUseAuth.mockReturnValue({
|
||||
isAuthenticated: false,
|
||||
isCheckingAuth: false,
|
||||
user: null,
|
||||
login: loginMock,
|
||||
register: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
});
|
||||
|
||||
mockedUseToasts.mockReturnValue({
|
||||
showToast: vi.fn(),
|
||||
showInfo: vi.fn(),
|
||||
showWarning: vi.fn(),
|
||||
showError: showErrorMock,
|
||||
removeToast: vi.fn(),
|
||||
clearToasts: vi.fn(),
|
||||
});
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<LoginPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
await user.type(screen.getByPlaceholderText('Username'), 'evan');
|
||||
await user.type(screen.getByPlaceholderText('Password'), 'wrong');
|
||||
await user.click(screen.getByRole('button', { name: 'Login' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(showErrorMock).toHaveBeenCalledWith('Invalid credentials');
|
||||
});
|
||||
});
|
||||
|
||||
it('redirects when the user is already authenticated', async () => {
|
||||
mockedUseAuth.mockReturnValue({
|
||||
isAuthenticated: true,
|
||||
isCheckingAuth: false,
|
||||
user: { username: 'evan', is_admin: false },
|
||||
login: vi.fn(),
|
||||
register: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
});
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<LoginPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(navigateMock).toHaveBeenCalledWith('/', { replace: true });
|
||||
});
|
||||
});
|
||||
|
||||
it('shows the registration link only when registration is enabled', () => {
|
||||
mockedUseGetInfo.mockReturnValue({
|
||||
data: {
|
||||
status: 200,
|
||||
data: {
|
||||
registration_enabled: true,
|
||||
},
|
||||
},
|
||||
} as ReturnType<typeof useGetInfo>);
|
||||
|
||||
const { rerender } = render(
|
||||
<MemoryRouter>
|
||||
<LoginPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(screen.getByRole('link', { name: 'Register here.' })).toBeInTheDocument();
|
||||
|
||||
mockedUseGetInfo.mockReturnValue({
|
||||
data: {
|
||||
status: 200,
|
||||
data: {
|
||||
registration_enabled: false,
|
||||
},
|
||||
},
|
||||
} as ReturnType<typeof useGetInfo>);
|
||||
|
||||
rerender(
|
||||
<MemoryRouter>
|
||||
<LoginPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(screen.queryByRole('link', { name: 'Register here.' })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -5,57 +5,57 @@ import { Button } from '../components/Button';
|
||||
import { useToasts } from '../components/ToastContext';
|
||||
import { useGetInfo } from '../generated/anthoLumeAPIV1';
|
||||
|
||||
export default function LoginPage() {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
interface LoginPageViewProps {
|
||||
username: string;
|
||||
password: string;
|
||||
isLoading: boolean;
|
||||
registrationEnabled: boolean;
|
||||
onUsernameChange: (value: string) => void;
|
||||
onPasswordChange: (value: string) => void;
|
||||
onSubmit: (e: FormEvent<HTMLFormElement>) => void | Promise<void>;
|
||||
}
|
||||
|
||||
const { login, isAuthenticated, isCheckingAuth } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const { showError } = useToasts();
|
||||
const { data: infoData } = useGetInfo({
|
||||
query: {
|
||||
staleTime: Infinity,
|
||||
},
|
||||
});
|
||||
export function getRegistrationEnabled(infoData: unknown): boolean {
|
||||
if (!infoData || typeof infoData !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const registrationEnabled =
|
||||
infoData && 'data' in infoData && infoData.data && 'registration_enabled' in infoData.data
|
||||
? infoData.data.registration_enabled
|
||||
: false;
|
||||
if (!('data' in infoData) || !infoData.data || typeof infoData.data !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!isCheckingAuth && isAuthenticated) {
|
||||
navigate('/', { replace: true });
|
||||
}
|
||||
}, [isAuthenticated, isCheckingAuth, navigate]);
|
||||
if (
|
||||
!('registration_enabled' in infoData.data) ||
|
||||
typeof infoData.data.registration_enabled !== 'boolean'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
await login(username, password);
|
||||
} catch (_err) {
|
||||
showError('Invalid credentials');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
return infoData.data.registration_enabled;
|
||||
}
|
||||
|
||||
export function LoginPageView({
|
||||
username,
|
||||
password,
|
||||
isLoading,
|
||||
registrationEnabled,
|
||||
onUsernameChange,
|
||||
onPasswordChange,
|
||||
onSubmit,
|
||||
}: LoginPageViewProps) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100 dark:bg-gray-800 dark:text-white">
|
||||
<div className="flex w-full flex-wrap">
|
||||
<div className="flex w-full flex-col md:w-1/2">
|
||||
<div className="my-auto flex flex-col justify-center px-8 pt-8 md:justify-start md:px-24 md:pt-0 lg:px-32">
|
||||
<p className="text-center text-3xl">Welcome.</p>
|
||||
<form className="flex flex-col pt-3 md:pt-8" onSubmit={handleSubmit}>
|
||||
<form className="flex flex-col pt-3 md:pt-8" onSubmit={onSubmit}>
|
||||
<div className="flex flex-col pt-4">
|
||||
<div className="relative flex">
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
onChange={e => onUsernameChange(e.target.value)}
|
||||
className="w-full flex-1 appearance-none rounded-none border border-gray-300 bg-white px-4 py-2 text-base text-gray-700 shadow-sm placeholder:text-gray-400 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-purple-600"
|
||||
placeholder="Username"
|
||||
required
|
||||
@@ -68,7 +68,7 @@ export default function LoginPage() {
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
onChange={e => onPasswordChange(e.target.value)}
|
||||
className="w-full flex-1 appearance-none rounded-none border border-gray-300 bg-white px-4 py-2 text-base text-gray-700 shadow-sm placeholder:text-gray-400 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-purple-600"
|
||||
placeholder="Password"
|
||||
required
|
||||
@@ -111,3 +111,51 @@ export default function LoginPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const { login, isAuthenticated, isCheckingAuth } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const { showError } = useToasts();
|
||||
const { data: infoData } = useGetInfo({
|
||||
query: {
|
||||
staleTime: Infinity,
|
||||
},
|
||||
});
|
||||
|
||||
const registrationEnabled = getRegistrationEnabled(infoData);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isCheckingAuth && isAuthenticated) {
|
||||
navigate('/', { replace: true });
|
||||
}
|
||||
}, [isAuthenticated, isCheckingAuth, navigate]);
|
||||
|
||||
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
await login(username, password);
|
||||
} catch (_err) {
|
||||
showError('Invalid credentials');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<LoginPageView
|
||||
username={username}
|
||||
password={password}
|
||||
isLoading={isLoading}
|
||||
registrationEnabled={registrationEnabled}
|
||||
onUsernameChange={setUsername}
|
||||
onPasswordChange={setPassword}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
199
frontend/src/pages/RegisterPage.test.tsx
Normal file
199
frontend/src/pages/RegisterPage.test.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import RegisterPage from './RegisterPage';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import { useToasts } from '../components/ToastContext';
|
||||
import { useGetInfo } from '../generated/anthoLumeAPIV1';
|
||||
|
||||
const navigateMock = vi.fn();
|
||||
|
||||
vi.mock('react-router-dom', async importOriginal => {
|
||||
const actual = await importOriginal<typeof import('react-router-dom')>();
|
||||
return {
|
||||
...actual,
|
||||
useNavigate: () => navigateMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../auth/AuthContext', () => ({
|
||||
useAuth: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../components/ToastContext', () => ({
|
||||
useToasts: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../generated/anthoLumeAPIV1', () => ({
|
||||
useGetInfo: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockedUseAuth = vi.mocked(useAuth);
|
||||
const mockedUseToasts = vi.mocked(useToasts);
|
||||
const mockedUseGetInfo = vi.mocked(useGetInfo);
|
||||
|
||||
describe('RegisterPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
mockedUseAuth.mockReturnValue({
|
||||
isAuthenticated: false,
|
||||
isCheckingAuth: false,
|
||||
user: null,
|
||||
login: vi.fn(),
|
||||
register: vi.fn().mockResolvedValue(undefined),
|
||||
logout: vi.fn(),
|
||||
});
|
||||
|
||||
mockedUseToasts.mockReturnValue({
|
||||
showToast: vi.fn(),
|
||||
showInfo: vi.fn(),
|
||||
showWarning: vi.fn(),
|
||||
showError: vi.fn(),
|
||||
removeToast: vi.fn(),
|
||||
clearToasts: vi.fn(),
|
||||
});
|
||||
|
||||
mockedUseGetInfo.mockReturnValue({
|
||||
data: {
|
||||
status: 200,
|
||||
data: {
|
||||
registration_enabled: true,
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
} as ReturnType<typeof useGetInfo>);
|
||||
});
|
||||
|
||||
it('submits the username and password to register', async () => {
|
||||
const user = userEvent.setup();
|
||||
const registerMock = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
mockedUseAuth.mockReturnValue({
|
||||
isAuthenticated: false,
|
||||
isCheckingAuth: false,
|
||||
user: null,
|
||||
login: vi.fn(),
|
||||
register: registerMock,
|
||||
logout: vi.fn(),
|
||||
});
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<RegisterPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
await user.type(screen.getByPlaceholderText('Username'), 'evan');
|
||||
await user.type(screen.getByPlaceholderText('Password'), 'secret');
|
||||
await user.click(screen.getByRole('button', { name: 'Register' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(registerMock).toHaveBeenCalledWith('evan', 'secret');
|
||||
});
|
||||
});
|
||||
|
||||
it('shows a registration failed toast when registration fails while enabled', async () => {
|
||||
const user = userEvent.setup();
|
||||
const registerMock = vi.fn().mockRejectedValue(new Error('failed'));
|
||||
const showErrorMock = vi.fn();
|
||||
|
||||
mockedUseAuth.mockReturnValue({
|
||||
isAuthenticated: false,
|
||||
isCheckingAuth: false,
|
||||
user: null,
|
||||
login: vi.fn(),
|
||||
register: registerMock,
|
||||
logout: vi.fn(),
|
||||
});
|
||||
|
||||
mockedUseToasts.mockReturnValue({
|
||||
showToast: vi.fn(),
|
||||
showInfo: vi.fn(),
|
||||
showWarning: vi.fn(),
|
||||
showError: showErrorMock,
|
||||
removeToast: vi.fn(),
|
||||
clearToasts: vi.fn(),
|
||||
});
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<RegisterPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
await user.type(screen.getByPlaceholderText('Username'), 'evan');
|
||||
await user.type(screen.getByPlaceholderText('Password'), 'secret');
|
||||
await user.click(screen.getByRole('button', { name: 'Register' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(showErrorMock).toHaveBeenCalledWith('Registration failed');
|
||||
});
|
||||
});
|
||||
|
||||
it('redirects to home when the user is already authenticated', async () => {
|
||||
mockedUseAuth.mockReturnValue({
|
||||
isAuthenticated: true,
|
||||
isCheckingAuth: false,
|
||||
user: { username: 'evan', is_admin: false },
|
||||
login: vi.fn(),
|
||||
register: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
});
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<RegisterPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(navigateMock).toHaveBeenCalledWith('/', { replace: true });
|
||||
});
|
||||
});
|
||||
|
||||
it('redirects to login when registration is disabled', async () => {
|
||||
mockedUseGetInfo.mockReturnValue({
|
||||
data: {
|
||||
status: 200,
|
||||
data: {
|
||||
registration_enabled: false,
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
} as ReturnType<typeof useGetInfo>);
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<RegisterPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(navigateMock).toHaveBeenCalledWith('/login', { replace: true });
|
||||
});
|
||||
});
|
||||
|
||||
it('disables the form when registration is disabled', () => {
|
||||
mockedUseGetInfo.mockReturnValue({
|
||||
data: {
|
||||
status: 200,
|
||||
data: {
|
||||
registration_enabled: false,
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
} as ReturnType<typeof useGetInfo>);
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<RegisterPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(screen.getByPlaceholderText('Username')).toBeDisabled();
|
||||
expect(screen.getByPlaceholderText('Password')).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: 'Register' })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
131
frontend/src/pages/SearchPage.test.tsx
Normal file
131
frontend/src/pages/SearchPage.test.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen, act, fireEvent } from '@testing-library/react';
|
||||
import SearchPage from './SearchPage';
|
||||
import { useGetSearch } from '../generated/anthoLumeAPIV1';
|
||||
import { GetSearchSource } from '../generated/model/getSearchSource';
|
||||
|
||||
vi.mock('../generated/anthoLumeAPIV1', () => ({
|
||||
useGetSearch: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockedUseGetSearch = vi.mocked(useGetSearch);
|
||||
|
||||
describe('SearchPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockedUseGetSearch.mockReturnValue({
|
||||
data: {
|
||||
status: 200,
|
||||
data: {
|
||||
results: [],
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
} as ReturnType<typeof useGetSearch>);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('keeps the search disabled until a non-empty query is entered', () => {
|
||||
render(<SearchPage />);
|
||||
|
||||
expect(mockedUseGetSearch).toHaveBeenLastCalledWith(
|
||||
{
|
||||
query: '',
|
||||
source: GetSearchSource.LibGen,
|
||||
},
|
||||
{
|
||||
query: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('shows a loading state while results are being fetched', () => {
|
||||
mockedUseGetSearch.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
} as ReturnType<typeof useGetSearch>);
|
||||
|
||||
render(<SearchPage />);
|
||||
|
||||
expect(screen.getByText('Loading...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows an empty state when there are no results', () => {
|
||||
render(<SearchPage />);
|
||||
|
||||
expect(screen.getByText('No Results')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders search results from the generated hook response', () => {
|
||||
mockedUseGetSearch.mockReturnValue({
|
||||
data: {
|
||||
status: 200,
|
||||
data: {
|
||||
results: [
|
||||
{
|
||||
id: 'doc-1',
|
||||
author: 'Ursula Le Guin',
|
||||
title: 'A Wizard of Earthsea',
|
||||
series: 'Earthsea',
|
||||
file_type: 'epub',
|
||||
file_size: '1 MB',
|
||||
upload_date: '2025-01-01',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
} as ReturnType<typeof useGetSearch>);
|
||||
|
||||
render(<SearchPage />);
|
||||
|
||||
expect(screen.getByText('Ursula Le Guin - A Wizard of Earthsea')).toBeInTheDocument();
|
||||
expect(screen.getByText('Earthsea')).toBeInTheDocument();
|
||||
expect(screen.getByText('epub')).toBeInTheDocument();
|
||||
expect(screen.getByText('1 MB')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('updates the generated hook args after the query debounce and source change', () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
render(<SearchPage />);
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Query'), { target: { value: 'dune' } });
|
||||
fireEvent.change(screen.getByRole('combobox'), {
|
||||
target: { value: GetSearchSource.Annas_Archive },
|
||||
});
|
||||
|
||||
expect(mockedUseGetSearch).toHaveBeenLastCalledWith(
|
||||
{
|
||||
query: '',
|
||||
source: GetSearchSource.Annas_Archive,
|
||||
},
|
||||
{
|
||||
query: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(300);
|
||||
});
|
||||
|
||||
expect(mockedUseGetSearch).toHaveBeenLastCalledWith(
|
||||
{
|
||||
query: 'dune',
|
||||
source: GetSearchSource.Annas_Archive,
|
||||
},
|
||||
{
|
||||
query: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,37 +1,65 @@
|
||||
import { useState, FormEvent } from 'react';
|
||||
import { useState, useEffect, FormEvent } from 'react';
|
||||
import { useGetSearch } from '../generated/anthoLumeAPIV1';
|
||||
import { GetSearchSource } from '../generated/model/getSearchSource';
|
||||
import type { SearchItem } from '../generated/model';
|
||||
import { SearchIcon, DownloadIcon, BookIcon } from '../icons';
|
||||
import { Button } from '../components/Button';
|
||||
import { LoadingState } from '../components';
|
||||
import { useDebounce } from '../hooks/useDebounce';
|
||||
import { Search2Icon, DownloadIcon, BookIcon } from '../icons';
|
||||
|
||||
export default function SearchPage() {
|
||||
const [query, setQuery] = useState('');
|
||||
const [source, setSource] = useState<GetSearchSource>(GetSearchSource.LibGen);
|
||||
interface SearchPageViewProps {
|
||||
query: string;
|
||||
source: GetSearchSource;
|
||||
isLoading: boolean;
|
||||
results: SearchItem[];
|
||||
onQueryChange: (value: string) => void;
|
||||
onSourceChange: (value: GetSearchSource) => void;
|
||||
onSubmit: (e: FormEvent<HTMLFormElement>) => void;
|
||||
}
|
||||
|
||||
const { data, isLoading } = useGetSearch({ query, source });
|
||||
const results = data?.status === 200 ? data.data.results : [];
|
||||
export function getSearchResults(data: unknown): SearchItem[] {
|
||||
if (!data || typeof data !== 'object') {
|
||||
return [];
|
||||
}
|
||||
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
// Trigger refetch by updating query
|
||||
};
|
||||
if (!('status' in data) || data.status !== 200) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!('data' in data) || !data.data || typeof data.data !== 'object') {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!('results' in data.data) || !Array.isArray(data.data.results)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return data.data.results as SearchItem[];
|
||||
}
|
||||
|
||||
export function SearchPageView({
|
||||
query,
|
||||
source,
|
||||
isLoading,
|
||||
results,
|
||||
onQueryChange,
|
||||
onSourceChange,
|
||||
onSubmit,
|
||||
}: SearchPageViewProps) {
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-4 md:flex-row">
|
||||
<div className="flex grow flex-col gap-4">
|
||||
{/* Search Form */}
|
||||
<div className="flex grow flex-col gap-2 rounded bg-white p-4 text-gray-500 shadow-lg dark:bg-gray-700 dark:text-white">
|
||||
<form className="flex flex-col gap-4 lg:flex-row" onSubmit={handleSubmit}>
|
||||
<form className="flex flex-col gap-4 lg:flex-row" onSubmit={onSubmit}>
|
||||
<div className="flex w-full grow flex-col">
|
||||
<div className="relative flex">
|
||||
<span className="inline-flex items-center border-y border-l border-gray-300 bg-white px-3 text-sm text-gray-500 shadow-sm">
|
||||
<SearchIcon size={15} />
|
||||
<Search2Icon size={15} />
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
onChange={e => onQueryChange(e.target.value)}
|
||||
className="w-full flex-1 appearance-none rounded-none border border-gray-300 bg-white px-4 py-2 text-base text-gray-700 shadow-sm placeholder:text-gray-400 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-purple-600"
|
||||
placeholder="Query"
|
||||
/>
|
||||
@@ -43,11 +71,11 @@ export default function SearchPage() {
|
||||
</span>
|
||||
<select
|
||||
value={source}
|
||||
onChange={e => setSource(e.target.value as GetSearchSource)}
|
||||
onChange={e => onSourceChange(e.target.value as GetSearchSource)}
|
||||
className="w-full flex-1 appearance-none rounded-none border border-gray-300 bg-white px-4 py-2 text-base text-gray-700 shadow-sm placeholder:text-gray-400 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-purple-600"
|
||||
>
|
||||
<option value="LibGen">Library Genesis</option>
|
||||
<option value="Annas Archive">Annas Archive</option>
|
||||
<option value={GetSearchSource.LibGen}>Library Genesis</option>
|
||||
<option value={GetSearchSource.Annas_Archive}>Annas Archive</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="lg:w-60">
|
||||
@@ -58,7 +86,6 @@ export default function SearchPage() {
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Search Results Table */}
|
||||
<div className="inline-block min-w-full overflow-hidden rounded shadow">
|
||||
<table className="min-w-full bg-white text-sm leading-normal md:text-sm dark:bg-gray-700">
|
||||
<thead className="text-gray-800 dark:text-gray-400">
|
||||
@@ -85,11 +112,11 @@ export default function SearchPage() {
|
||||
{isLoading && (
|
||||
<tr>
|
||||
<td className="p-3 text-center" colSpan={6}>
|
||||
Loading...
|
||||
<LoadingState />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{!isLoading && !results && (
|
||||
{!isLoading && results.length === 0 && (
|
||||
<tr>
|
||||
<td className="p-3 text-center" colSpan={6}>
|
||||
No Results
|
||||
@@ -97,8 +124,7 @@ export default function SearchPage() {
|
||||
</tr>
|
||||
)}
|
||||
{!isLoading &&
|
||||
results &&
|
||||
results.map((item: SearchItem) => (
|
||||
results.map(item => (
|
||||
<tr key={item.id}>
|
||||
<td className="border-b border-gray-200 p-3 text-gray-500 dark:text-gray-500">
|
||||
<button className="hover:text-purple-600" title="Download">
|
||||
@@ -129,3 +155,41 @@ export default function SearchPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SearchPage() {
|
||||
const [query, setQuery] = useState('');
|
||||
const [activeQuery, setActiveQuery] = useState('');
|
||||
const [source, setSource] = useState<GetSearchSource>(GetSearchSource.LibGen);
|
||||
const debouncedQuery = useDebounce(query, 300);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveQuery(debouncedQuery);
|
||||
}, [debouncedQuery]);
|
||||
|
||||
const { data, isLoading } = useGetSearch(
|
||||
{ query: activeQuery, source },
|
||||
{
|
||||
query: {
|
||||
enabled: activeQuery.trim().length > 0,
|
||||
},
|
||||
},
|
||||
);
|
||||
const results = getSearchResults(data);
|
||||
|
||||
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setActiveQuery(query.trim());
|
||||
};
|
||||
|
||||
return (
|
||||
<SearchPageView
|
||||
query={query}
|
||||
source={source}
|
||||
isLoading={isLoading}
|
||||
results={results}
|
||||
onQueryChange={setQuery}
|
||||
onSourceChange={setSource}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
77
frontend/src/test/renderWithProviders.tsx
Normal file
77
frontend/src/test/renderWithProviders.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import type { ReactElement, ReactNode } from 'react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { ToastProvider } from '../components/ToastContext';
|
||||
|
||||
interface RenderWithProvidersOptions {
|
||||
route?: string;
|
||||
queryClient?: QueryClient;
|
||||
withQueryClient?: boolean;
|
||||
withToastProvider?: boolean;
|
||||
}
|
||||
|
||||
interface RenderWithProvidersWrapperProps {
|
||||
children: ReactNode;
|
||||
route: string;
|
||||
queryClient: QueryClient;
|
||||
withQueryClient: boolean;
|
||||
withToastProvider: boolean;
|
||||
}
|
||||
|
||||
function createTestQueryClient() {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
mutations: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function RenderWithProvidersWrapper({
|
||||
children,
|
||||
route,
|
||||
queryClient,
|
||||
withQueryClient,
|
||||
withToastProvider,
|
||||
}: RenderWithProvidersWrapperProps) {
|
||||
let content = <MemoryRouter initialEntries={[route]}>{children}</MemoryRouter>;
|
||||
|
||||
if (withQueryClient) {
|
||||
content = <QueryClientProvider client={queryClient}>{content}</QueryClientProvider>;
|
||||
}
|
||||
|
||||
if (withToastProvider) {
|
||||
content = <ToastProvider>{content}</ToastProvider>;
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
export function renderWithProviders(
|
||||
ui: ReactElement,
|
||||
{
|
||||
route = '/',
|
||||
queryClient = createTestQueryClient(),
|
||||
withQueryClient = true,
|
||||
withToastProvider = false,
|
||||
}: RenderWithProvidersOptions = {}
|
||||
) {
|
||||
return {
|
||||
ui,
|
||||
wrapper: ({ children }: { children: ReactNode }) => (
|
||||
<RenderWithProvidersWrapper
|
||||
route={route}
|
||||
queryClient={queryClient}
|
||||
withQueryClient={withQueryClient}
|
||||
withToastProvider={withToastProvider}
|
||||
>
|
||||
{children}
|
||||
</RenderWithProvidersWrapper>
|
||||
),
|
||||
queryClient,
|
||||
};
|
||||
}
|
||||
7
frontend/src/test/setup.ts
Normal file
7
frontend/src/test/setup.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { afterEach } from 'vitest';
|
||||
import { cleanup } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
48
frontend/src/utils/errors.test.ts
Normal file
48
frontend/src/utils/errors.test.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { getErrorMessage } from './errors';
|
||||
|
||||
describe('getErrorMessage', () => {
|
||||
it('returns Error.message for Error instances', () => {
|
||||
expect(getErrorMessage(new Error('Boom'))).toBe('Boom');
|
||||
});
|
||||
|
||||
it('prefers response.data.message over top-level message', () => {
|
||||
expect(
|
||||
getErrorMessage({
|
||||
message: 'Top-level message',
|
||||
response: {
|
||||
data: {
|
||||
message: 'Response message',
|
||||
},
|
||||
},
|
||||
})
|
||||
).toBe('Response message');
|
||||
});
|
||||
|
||||
it('falls back to top-level message when response.data.message is unavailable', () => {
|
||||
expect(
|
||||
getErrorMessage({
|
||||
message: 'Top-level message',
|
||||
})
|
||||
).toBe('Top-level message');
|
||||
});
|
||||
|
||||
it('uses the fallback for null, empty, and unknown values', () => {
|
||||
expect(getErrorMessage(null, 'Fallback message')).toBe('Fallback message');
|
||||
expect(getErrorMessage(undefined, 'Fallback message')).toBe('Fallback message');
|
||||
expect(getErrorMessage({}, 'Fallback message')).toBe('Fallback message');
|
||||
expect(getErrorMessage({ message: ' ' }, 'Fallback message')).toBe('Fallback message');
|
||||
expect(
|
||||
getErrorMessage(
|
||||
{
|
||||
response: {
|
||||
data: {
|
||||
message: '',
|
||||
},
|
||||
},
|
||||
},
|
||||
'Fallback message'
|
||||
)
|
||||
).toBe('Fallback message');
|
||||
});
|
||||
});
|
||||
@@ -34,12 +34,6 @@ describe('formatNumber', () => {
|
||||
expect(formatNumber(-1500000)).toBe('-1.50M');
|
||||
});
|
||||
|
||||
it('matches Go test cases exactly', () => {
|
||||
expect(formatNumber(0)).toBe('0');
|
||||
expect(formatNumber(19823)).toBe('19.8k');
|
||||
expect(formatNumber(1500000)).toBe('1.50M');
|
||||
expect(formatNumber(-12345)).toBe('-12.3k');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatDuration', () => {
|
||||
@@ -68,9 +62,4 @@ describe('formatDuration', () => {
|
||||
expect(formatDuration(1928371)).toBe('22d 7h 39m 31s');
|
||||
});
|
||||
|
||||
it('matches Go test cases exactly', () => {
|
||||
expect(formatDuration(0)).toBe('N/A');
|
||||
expect(formatDuration(22 * 24 * 60 * 60 + 7 * 60 * 60 + 39 * 60 + 31)).toBe('22d 7h 39m 31s');
|
||||
expect(formatDuration(5 * 60 + 15)).toBe('5m 15s');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user