diff --git a/src/ui/components/ErrorBoundary/ErrorBoundary.tsx b/src/ui/components/ErrorBoundary/ErrorBoundary.tsx new file mode 100644 index 000000000..d13b05821 --- /dev/null +++ b/src/ui/components/ErrorBoundary/ErrorBoundary.tsx @@ -0,0 +1,176 @@ +import React, { Component, ErrorInfo, PropsWithChildren, ReactNode, useState } from 'react'; +import Paper from '@material-ui/core/Paper'; +import Typography from '@material-ui/core/Typography'; +import Button from '@material-ui/core/Button'; +import Collapse from '@material-ui/core/Collapse'; +import { makeStyles } from '@material-ui/core/styles'; + +const IS_DEV = process.env.NODE_ENV !== 'production'; + +const useStyles = makeStyles((theme) => ({ + wrapper: { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + height: '100%', + minHeight: '60vh', + padding: theme.spacing(2), + }, + root: { + padding: theme.spacing(4), + borderLeft: `4px solid ${theme.palette.error.main}`, + maxWidth: 560, + width: '100%', + }, + title: { + color: theme.palette.error.main, + marginBottom: theme.spacing(1), + }, + message: { + marginBottom: theme.spacing(2), + color: theme.palette.text.secondary, + }, + hint: { + marginBottom: theme.spacing(2), + color: theme.palette.text.secondary, + fontStyle: 'italic', + }, + actions: { + display: 'flex', + gap: theme.spacing(1), + alignItems: 'center', + marginBottom: theme.spacing(1), + }, + stack: { + marginTop: theme.spacing(2), + padding: theme.spacing(2), + backgroundColor: theme.palette.grey[100], + borderRadius: theme.shape.borderRadius, + overflowX: 'auto', + fontSize: '0.75rem', + fontFamily: 'monospace', + whiteSpace: 'pre-wrap', + wordBreak: 'break-word', + }, + devBadge: { + display: 'inline-block', + marginBottom: theme.spacing(2), + padding: '2px 8px', + backgroundColor: theme.palette.warning.main, + color: theme.palette.warning.contrastText, + borderRadius: theme.shape.borderRadius, + fontSize: '0.7rem', + fontWeight: 700, + letterSpacing: '0.05em', + textTransform: 'uppercase', + }, +})); + +const ProdFallback = ({ reset }: { reset: () => void }) => { + const classes = useStyles(); + return ( +
+ + + Something went wrong + + + An unexpected error occurred. Please try again — if the problem persists, contact your + administrator. + +
+ + +
+
+
+ ); +}; + +const DevFallback = ({ + error, + name, + reset, +}: { + error: Error; + name?: string; + reset: () => void; +}) => { + const classes = useStyles(); + const [showDetails, setShowDetails] = useState(false); + const context = name ? ` in ${name}` : ''; + + return ( +
+ +
dev
+ + Something went wrong{context} + + + {error.message} + +
+ + {error.stack && ( + + )} +
+ {error.stack && ( + +
{error.stack}
+
+ )} +
+
+ ); +}; + +type Props = PropsWithChildren<{ + name?: string; + fallback?: (error: Error, reset: () => void) => ReactNode; + onError?: (error: Error, errorInfo: ErrorInfo) => void; +}>; + +type State = { error: Error | undefined }; + +export class ErrorBoundary extends Component { + state: State = { error: undefined }; + + static getDerivedStateFromError(error: Error): State { + return { error }; + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo) { + this.props.onError?.(error, errorInfo); + if (IS_DEV) { + console.error('ErrorBoundary caught:', error, errorInfo); + } + } + + reset = () => this.setState({ error: undefined }); + + render() { + const { error } = this.state; + const { children, fallback, name } = this.props; + + if (error) { + if (fallback) return fallback(error, this.reset); + return IS_DEV ? ( + + ) : ( + + ); + } + + return children; + } +} diff --git a/src/ui/layouts/Dashboard.tsx b/src/ui/layouts/Dashboard.tsx index 3666a2bd1..96a28ad66 100644 --- a/src/ui/layouts/Dashboard.tsx +++ b/src/ui/layouts/Dashboard.tsx @@ -13,6 +13,7 @@ import { UserContext } from '../context'; import { getUser } from '../services/user'; import { Route as RouteType } from '../types'; import { PublicUser } from '../../db/types'; +import { ErrorBoundary } from '../components/ErrorBoundary/ErrorBoundary'; interface DashboardProps { [key: string]: any; @@ -95,16 +96,18 @@ const Dashboard: React.FC = ({ ...rest }) => { />
- {isMapRoute() ? ( -
{switchRoutes}
- ) : ( - <> -
-
{switchRoutes}
-
-
- - )} + + {isMapRoute() ? ( +
{switchRoutes}
+ ) : ( + <> +
+
{switchRoutes}
+
+
+ + )} +
diff --git a/src/ui/services/auth.ts b/src/ui/services/auth.ts index f9f4346c5..1111d920b 100644 --- a/src/ui/services/auth.ts +++ b/src/ui/services/auth.ts @@ -11,6 +11,8 @@ interface AxiosConfig { }; } +const IS_DEV = process.env.NODE_ENV !== 'production'; + /** * Gets the current user's information */ @@ -20,10 +22,17 @@ export const getUserInfo = async (): Promise => { const response = await fetch(`${baseUrl}/api/auth/profile`, { credentials: 'include', // Sends cookies }); - if (!response.ok) throw new Error(`Failed to fetch user info: ${response.statusText}`); + if (!response.ok) { + if (response.status === 401) { + return null; + } + throw new Error(`Failed to fetch user info: ${response.statusText}`); + } return await response.json(); } catch (error) { - console.error('Error fetching user info:', error); + if (IS_DEV) { + console.warn('Error fetching user info:', error); + } return null; } }; diff --git a/src/ui/services/errors.ts b/src/ui/services/errors.ts new file mode 100644 index 000000000..19bddb562 --- /dev/null +++ b/src/ui/services/errors.ts @@ -0,0 +1,37 @@ +export interface ServiceResult { + success: boolean; + status?: number; + message?: string; + data?: T; +} + +export const getServiceError = ( + error: any, + fallbackMessage: string, +): { status?: number; message: string } => { + const status = error?.response?.status; + const responseMessage = error?.response?.data?.message; + const message = + typeof responseMessage === 'string' && responseMessage.trim().length > 0 + ? responseMessage + : status + ? `Unknown error occurred, response code: ${status}` + : error?.message || fallbackMessage; + return { status, message }; +}; + +export const formatErrorMessage = ( + prefix: string, + status: number | undefined, + message: string, +): string => `${prefix}: ${status ? `${status} ` : ''}${message}`; + +export const errorResult = (error: any, fallbackMessage: string): ServiceResult => { + const { status, message } = getServiceError(error, fallbackMessage); + return { success: false, status, message }; +}; + +export const successResult = (data?: T): ServiceResult => ({ + success: true, + ...(data !== undefined && { data }), +}); diff --git a/src/ui/services/git-push.ts b/src/ui/services/git-push.ts index 05b526b98..ae08592d7 100644 --- a/src/ui/services/git-push.ts +++ b/src/ui/services/git-push.ts @@ -1,82 +1,56 @@ import axios from 'axios'; -import { getAxiosConfig, processAuthError } from './auth'; -import { getBaseUrl, getApiV1BaseUrl } from './apiConfig'; +import { getAxiosConfig } from './auth'; +import { getApiV1BaseUrl } from './apiConfig'; import { Action, Step } from '../../proxy/actions'; import { PushActionView } from '../types'; +import { ServiceResult, errorResult, successResult } from './errors'; -const getPush = async ( - id: string, - setIsLoading: (isLoading: boolean) => void, - setPush: (push: PushActionView) => void, - setAuth: (auth: boolean) => void, - setIsError: (isError: boolean) => void, -): Promise => { +const getPush = async (id: string): Promise> => { const apiV1Base = await getApiV1BaseUrl(); const url = `${apiV1Base}/push/${id}`; - setIsLoading(true); try { const response = await axios(url, getAxiosConfig()); - const data: Action & { diff?: Step } = response.data; - data.diff = data.steps.find((x: Step) => x.stepName === 'diff'); - setPush(data as PushActionView); + const data: Action = response.data; + const actionView: PushActionView = { + ...data, + diff: data.steps.find((x: Step) => x.stepName === 'diff')!, + }; + return successResult(actionView); } catch (error: any) { - if (error.response?.status === 401) setAuth(false); - else setIsError(true); - } finally { - setIsLoading(false); + return errorResult(error, 'Failed to load push'); } }; const getPushes = async ( - setIsLoading: (isLoading: boolean) => void, - setPushes: (pushes: PushActionView[]) => void, - setAuth: (auth: boolean) => void, - setIsError: (isError: boolean) => void, - setErrorMessage: (errorMessage: string) => void, query = { blocked: true, canceled: false, authorised: false, rejected: false, }, -): Promise => { +): Promise> => { const apiV1Base = await getApiV1BaseUrl(); const url = new URL(`${apiV1Base}/push`); url.search = new URLSearchParams(query as any).toString(); - setIsLoading(true); - try { const response = await axios(url.toString(), getAxiosConfig()); - setPushes(response.data as PushActionView[]); + return successResult(response.data as unknown as PushActionView[]); } catch (error: any) { - setIsError(true); - - if (error.response?.status === 401) { - setAuth(false); - setErrorMessage(processAuthError(error)); - } else { - const message = error.response?.data?.message || error.message; - setErrorMessage(`Error fetching pushes: ${message}`); - } - } finally { - setIsLoading(false); + return errorResult(error, 'Failed to load pushes'); } }; const authorisePush = async ( id: string, - setMessage: (message: string) => void, - setUserAllowedToApprove: (userAllowedToApprove: boolean) => void, attestation: Array<{ label: string; checked: boolean }>, -): Promise => { +): Promise => { const apiV1Base = await getApiV1BaseUrl(); const url = `${apiV1Base}/push/${id}/authorise`; - let errorMsg = ''; - let isUserAllowedToApprove = true; - await axios - .post( + + try { + await axios.post( url, { params: { @@ -84,51 +58,35 @@ const authorisePush = async ( }, }, getAxiosConfig(), - ) - .catch((error: any) => { - if (error.response && error.response.status === 401) { - errorMsg = 'You are not authorised to approve...'; - isUserAllowedToApprove = false; - } - }); - setMessage(errorMsg); - setUserAllowedToApprove(isUserAllowedToApprove); + ); + return successResult(); + } catch (error: any) { + return errorResult(error, 'Failed to approve push request'); + } }; -const rejectPush = async ( - id: string, - setMessage: (message: string) => void, - setUserAllowedToReject: (userAllowedToReject: boolean) => void, - reason?: string, -): Promise => { +const rejectPush = async (id: string, reason?: string): Promise => { const apiV1Base = await getApiV1BaseUrl(); const url = `${apiV1Base}/push/${id}/reject`; - let errorMsg = ''; - let isUserAllowedToReject = true; - await axios.post(url, { reason }, getAxiosConfig()).catch((error: any) => { - if (error.response && error.response.status === 401) { - errorMsg = 'You are not authorised to reject...'; - isUserAllowedToReject = false; - } - }); - setMessage(errorMsg); - setUserAllowedToReject(isUserAllowedToReject); + + try { + await axios.post(url, { reason }, getAxiosConfig()); + return successResult(); + } catch (error: any) { + return errorResult(error, 'Failed to reject push request'); + } }; -const cancelPush = async ( - id: string, - setAuth: (auth: boolean) => void, - setIsError: (isError: boolean) => void, -): Promise => { +const cancelPush = async (id: string): Promise => { const apiV1Base = await getApiV1BaseUrl(); const url = `${apiV1Base}/push/${id}/cancel`; - await axios.post(url, {}, getAxiosConfig()).catch((error: any) => { - if (error.response && error.response.status === 401) { - setAuth(false); - } else { - setIsError(true); - } - }); + + try { + await axios.post(url, {}, getAxiosConfig()); + return successResult(); + } catch (error: any) { + return errorResult(error, 'Failed to cancel push request'); + } }; export { getPush, getPushes, authorisePush, rejectPush, cancelPush }; diff --git a/src/ui/services/repo.ts b/src/ui/services/repo.ts index 8aa883d39..9b5a36323 100644 --- a/src/ui/services/repo.ts +++ b/src/ui/services/repo.ts @@ -1,8 +1,9 @@ import axios from 'axios'; -import { getAxiosConfig, processAuthError } from './auth.js'; +import { getAxiosConfig } from './auth.js'; import { Repo } from '../../db/types'; import { RepoView } from '../types'; import { getApiV1BaseUrl } from './apiConfig'; +import { ServiceResult, getServiceError, errorResult, successResult } from './errors'; const canAddUser = async (repoId: string, user: string, action: string) => { const apiV1Base = await getApiV1BaseUrl(); @@ -18,7 +19,8 @@ const canAddUser = async (repoId: string, user: string, action: string) => { } }) .catch((error: any) => { - throw error; + const { message } = getServiceError(error, 'Failed to validate repo permissions'); + throw new Error(message); }); }; @@ -30,83 +32,44 @@ class DupUserValidationError extends Error { } const getRepos = async ( - setIsLoading: (isLoading: boolean) => void, - setRepos: (repos: RepoView[]) => void, - setAuth: (auth: boolean) => void, - setIsError: (isError: boolean) => void, - setErrorMessage: (errorMessage: string) => void, query: Record = {}, -): Promise => { +): Promise> => { const apiV1Base = await getApiV1BaseUrl(); const url = new URL(`${apiV1Base}/repo`); url.search = new URLSearchParams(query as any).toString(); - setIsLoading(true); - await axios(url.toString(), getAxiosConfig()) - .then((response) => { - const sortedRepos = response.data.sort((a: RepoView, b: RepoView) => - a.name.localeCompare(b.name), - ); - setRepos(sortedRepos); - }) - .catch((error: any) => { - setIsError(true); - if (error.response && error.response.status === 401) { - setAuth(false); - setErrorMessage(processAuthError(error)); - } else { - setErrorMessage(`Error fetching repos: ${error.response.data.message}`); - } - }) - .finally(() => { - setIsLoading(false); - }); + + try { + const response = await axios(url.toString(), getAxiosConfig()); + const sortedRepos = response.data.sort((a: RepoView, b: RepoView) => + a.name.localeCompare(b.name), + ); + return successResult(sortedRepos); + } catch (error: any) { + return errorResult(error, 'Failed to load repositories'); + } }; -const getRepo = async ( - setIsLoading: (isLoading: boolean) => void, - setRepo: (repo: RepoView) => void, - setAuth: (auth: boolean) => void, - setIsError: (isError: boolean) => void, - id: string, -): Promise => { +const getRepo = async (id: string): Promise> => { const apiV1Base = await getApiV1BaseUrl(); const url = new URL(`${apiV1Base}/repo/${id}`); - setIsLoading(true); - await axios(url.toString(), getAxiosConfig()) - .then((response) => { - const repo = response.data; - setRepo(repo); - }) - .catch((error: any) => { - if (error.response && error.response.status === 401) { - setAuth(false); - } else { - setIsError(true); - } - }) - .finally(() => { - setIsLoading(false); - }); + + try { + const response = await axios(url.toString(), getAxiosConfig()); + return successResult(response.data); + } catch (error: any) { + return errorResult(error, 'Failed to load repository'); + } }; -const addRepo = async ( - repo: RepoView, -): Promise<{ success: boolean; message?: string; repo: RepoView | null }> => { +const addRepo = async (repo: RepoView): Promise> => { const apiV1Base = await getApiV1BaseUrl(); const url = new URL(`${apiV1Base}/repo`); try { const response = await axios.post(url.toString(), repo, getAxiosConfig()); - return { - success: true, - repo: response.data, - }; + return successResult(response.data); } catch (error: any) { - return { - success: false, - message: error.response?.data?.message || error.message, - repo: null, - }; + return errorResult(error, 'Failed to add repository'); } }; @@ -117,8 +80,9 @@ const addUser = async (repoId: string, user: string, action: string): Promise { - console.log(error.response.data.message); - throw error; + const { message } = getServiceError(error, 'Failed to add user'); + console.log(message); + throw new Error(message); }); } else { console.log('Duplicate user can not be added'); @@ -131,8 +95,9 @@ const deleteUser = async (user: string, repoId: string, action: string): Promise const url = new URL(`${apiV1Base}/repo/${repoId}/user/${action}/${user}`); await axios.delete(url.toString(), getAxiosConfig()).catch((error: any) => { - console.log(error.response.data.message); - throw error; + const { message } = getServiceError(error, 'Failed to remove user'); + console.log(message); + throw new Error(message); }); }; @@ -141,8 +106,9 @@ const deleteRepo = async (repoId: string): Promise => { const url = new URL(`${apiV1Base}/repo/${repoId}/delete`); await axios.delete(url.toString(), getAxiosConfig()).catch((error: any) => { - console.log(error.response.data.message); - throw error; + const { message } = getServiceError(error, 'Failed to delete repository'); + console.log(message); + throw new Error(message); }); }; diff --git a/src/ui/services/user.ts b/src/ui/services/user.ts index 40c0394b5..39b066f2c 100644 --- a/src/ui/services/user.ts +++ b/src/ui/services/user.ts @@ -2,6 +2,7 @@ import axios, { AxiosError, AxiosResponse } from 'axios'; import { getAxiosConfig, processAuthError } from './auth'; import { PublicUser } from '../../db/types'; import { getBaseUrl, getApiV1BaseUrl } from './apiConfig'; +import { getServiceError, formatErrorMessage } from './errors'; type SetStateCallback = (value: T | ((prevValue: T) => T)) => void; @@ -27,14 +28,12 @@ const getUser = async ( setUser?.(user); setIsLoading?.(false); } catch (error) { - const axiosError = error as AxiosError; - const status = axiosError.response?.status; + const { status, message } = getServiceError(error, 'Unknown error'); if (status === 401) { setAuth?.(false); - setErrorMessage?.(processAuthError(axiosError)); + setErrorMessage?.(processAuthError(error as AxiosError)); } else { - const msg = (axiosError.response?.data as any)?.message ?? 'Unknown error'; - setErrorMessage?.(`Error fetching user: ${status} ${msg}`); + setErrorMessage?.(formatErrorMessage('Error fetching user', status, message)); } setIsLoading?.(false); } @@ -56,14 +55,12 @@ const getUsers = async ( ); setUsers(response.data); } catch (error) { - const axiosError = error as AxiosError; - const status = axiosError.response?.status; + const { status, message } = getServiceError(error, 'Unknown error'); if (status === 401) { setAuth(false); - setErrorMessage(processAuthError(axiosError)); + setErrorMessage(processAuthError(error as AxiosError)); } else { - const msg = (axiosError.response?.data as any)?.message ?? 'Unknown error'; - setErrorMessage(`Error fetching users: ${status} ${msg}`); + setErrorMessage(formatErrorMessage('Error fetching users', status, message)); } } finally { setIsLoading(false); @@ -79,10 +76,8 @@ const updateUser = async ( const baseUrl = await getBaseUrl(); await axios.post(`${baseUrl}/api/auth/gitAccount`, user, getAxiosConfig()); } catch (error) { - const axiosError = error as AxiosError; - const status = axiosError.response?.status; - const msg = (axiosError.response?.data as any)?.message ?? 'Unknown error'; - setErrorMessage(`Error updating user: ${status} ${msg}`); + const { status, message } = getServiceError(error, 'Unknown error'); + setErrorMessage(formatErrorMessage('Error updating user', status, message)); setIsLoading(false); } }; diff --git a/src/ui/types.ts b/src/ui/types.ts index 342208d56..2ccde6877 100644 --- a/src/ui/types.ts +++ b/src/ui/types.ts @@ -4,7 +4,18 @@ import { Repo } from '../db/types'; import { Attestation } from '../proxy/processors/types'; import { Question } from '../config/generated/config'; -export interface PushActionView extends Action { +type ActionMethods = + | 'addStep' + | 'getLastStep' + | 'setCommit' + | 'setBranch' + | 'setMessage' + | 'setAllowPush' + | 'setAutoApproval' + | 'setAutoRejection' + | 'continue'; + +export interface PushActionView extends Omit { diff: Step; } diff --git a/src/ui/views/PushDetails/PushDetails.tsx b/src/ui/views/PushDetails/PushDetails.tsx index 4892dc345..05392958d 100644 --- a/src/ui/views/PushDetails/PushDetails.tsx +++ b/src/ui/views/PushDetails/PushDetails.tsx @@ -21,6 +21,7 @@ import TableHead from '@material-ui/core/TableHead'; import TableRow from '@material-ui/core/TableRow'; import TableCell from '@material-ui/core/TableCell'; import { getPush, authorisePush, rejectPush, cancelPush } from '../../services/git-push'; +import type { ServiceResult } from '../../services/errors'; import { CheckCircle, Visibility, Cancel, Block } from '@material-ui/icons'; import Snackbar from '@material-ui/core/Snackbar'; import { PushActionView } from '../../types'; @@ -30,54 +31,72 @@ import { generateEmailLink, getGitProvider } from '../../utils'; const Dashboard: React.FC = () => { const { id } = useParams<{ id: string }>(); const [push, setPush] = useState(null); - const [, setAuth] = useState(true); const [isLoading, setIsLoading] = useState(true); const [isError, setIsError] = useState(false); const [message, setMessage] = useState(''); const [attestation, setAttestation] = useState(false); const navigate = useNavigate(); - let isUserAllowedToApprove = true; - let isUserAllowedToReject = true; - - const setUserAllowedToApprove = (userAllowedToApprove: boolean) => { - isUserAllowedToApprove = userAllowedToApprove; - }; - - const setUserAllowedToReject = (userAllowedToReject: boolean) => { - isUserAllowedToReject = userAllowedToReject; + const handleActionFailure = (result: ServiceResult) => { + if (result.status === 401) { + navigate('/login', { replace: true }); + return; + } + setMessage(result.message || 'Something went wrong...'); }; useEffect(() => { - if (id) { - getPush(id, setIsLoading, setPush, setAuth, setIsError); - } + if (!id) return; + const load = async () => { + setIsLoading(true); + const result = await getPush(id); + if (result.success && result.data) { + setPush(result.data); + } else if (result.status === 401) { + setIsLoading(false); + navigate('/login', { replace: true }); + return; + } else { + setIsError(true); + setMessage(result.message || 'Something went wrong...'); + } + setIsLoading(false); + }; + load(); }, [id]); const authorise = async (attestationData: Array<{ label: string; checked: boolean }>) => { if (!id) return; - await authorisePush(id, setMessage, setUserAllowedToApprove, attestationData); - if (isUserAllowedToApprove) { + const result = await authorisePush(id, attestationData); + if (result.success) { navigate('/dashboard/push/'); + return; } + handleActionFailure(result); }; const reject = async (reason: string) => { if (!id) return; - await rejectPush(id, setMessage, setUserAllowedToReject, reason); - if (isUserAllowedToReject) { + const result = await rejectPush(id, reason); + if (result.success) { navigate('/dashboard/push/'); + return; } + handleActionFailure(result); }; const cancel = async () => { if (!id) return; - await cancelPush(id, setAuth, setIsError); - navigate(`/dashboard/push/`); + const result = await cancelPush(id); + if (result.success) { + navigate(`/dashboard/push/`); + return; + } + handleActionFailure(result); }; if (isLoading) return
Loading...
; - if (isError) return
Something went wrong ...
; + if (isError) throw new Error(message || 'Something went wrong ...'); if (!push) return
No push data found
; let headerData: { title: string; color: CardHeaderColor } = { diff --git a/src/ui/views/PushRequests/components/PushesTable.tsx b/src/ui/views/PushRequests/components/PushesTable.tsx index 88052c300..4e174be07 100644 --- a/src/ui/views/PushRequests/components/PushesTable.tsx +++ b/src/ui/views/PushRequests/components/PushesTable.tsx @@ -30,9 +30,7 @@ const PushesTable: React.FC = (props) => { const [pushes, setPushes] = useState([]); const [filteredData, setFilteredData] = useState([]); const [isLoading, setIsLoading] = useState(false); - const [, setIsError] = useState(false); const navigate = useNavigate(); - const [, setAuth] = useState(true); const [currentPage, setCurrentPage] = useState(1); const itemsPerPage = 5; const [searchTerm, setSearchTerm] = useState(''); @@ -49,7 +47,21 @@ const PushesTable: React.FC = (props) => { if (props.rejected !== undefined) query.rejected = props.rejected; if (props.error !== undefined) query.error = props.error; - getPushes(setIsLoading, setPushes, setAuth, setIsError, props.handleError, query); + const load = async () => { + setIsLoading(true); + const result = await getPushes(query); + if (result.success && result.data) { + setPushes(result.data); + } else if (result.status === 401) { + setIsLoading(false); + navigate('/login', { replace: true }); + return; + } else if (props.handleError) { + props.handleError(result.message || 'Failed to load pushes'); + } + setIsLoading(false); + }; + load(); }, [props]); useEffect(() => { diff --git a/src/ui/views/RepoDetails/RepoDetails.tsx b/src/ui/views/RepoDetails/RepoDetails.tsx index c5c1c2ccb..0b5250030 100644 --- a/src/ui/views/RepoDetails/RepoDetails.tsx +++ b/src/ui/views/RepoDetails/RepoDetails.tsx @@ -27,6 +27,7 @@ import { RepoView, SCMRepositoryMetadata } from '../../types'; import { UserContextType } from '../../context'; import UserLink from '../../components/UserLink/UserLink'; import DeleteRepoDialog from './Components/DeleteRepoDialog'; +import Danger from '../../components/Typography/Danger'; const useStyles = makeStyles((theme) => ({ root: { @@ -45,17 +46,31 @@ const RepoDetails: React.FC = () => { const classes = useStyles(); const [repo, setRepo] = useState(null); const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false); - const [, setAuth] = useState(true); const [isLoading, setIsLoading] = useState(true); const [isError, setIsError] = useState(false); + const [errorMessage, setErrorMessage] = useState(''); const [remoteRepoData, setRemoteRepoData] = useState(null); const { user } = useContext(UserContext); const { id: repoId } = useParams<{ id: string }>(); useEffect(() => { - if (repoId) { - getRepo(setIsLoading, setRepo, setAuth, setIsError, repoId); - } + if (!repoId) return; + const load = async () => { + setIsLoading(true); + const result = await getRepo(repoId); + if (result.success && result.data) { + setRepo(result.data); + } else if (result.status === 401) { + setIsLoading(false); + navigate('/login', { replace: true }); + return; + } else { + setIsError(true); + setErrorMessage(result.message || 'Something went wrong...'); + } + setIsLoading(false); + }; + load(); }, [repoId]); useEffect(() => { @@ -66,23 +81,50 @@ const RepoDetails: React.FC = () => { const removeUser = async (userToRemove: string, action: 'authorise' | 'push') => { if (!repoId) return; - await deleteUser(userToRemove, repoId, action); - getRepo(setIsLoading, setRepo, setAuth, setIsError, repoId); + try { + await deleteUser(userToRemove, repoId, action); + } catch (err: any) { + setIsError(true); + setErrorMessage(err.message || 'Failed to remove user'); + return; + } + const result = await getRepo(repoId); + if (result.success && result.data) { + setRepo(result.data); + } else if (result.status === 401) { + navigate('/login', { replace: true }); + } else { + setIsError(true); + setErrorMessage(result.message || 'Failed to refresh repository data'); + } }; const removeRepository = async (id: string) => { - await deleteRepo(id); + try { + await deleteRepo(id); + } catch (err: any) { + setIsError(true); + setErrorMessage(err.message || 'Failed to delete repository'); + return; + } navigate('/dashboard/repo', { replace: true }); }; - const refresh = () => { - if (repoId) { - getRepo(setIsLoading, setRepo, setAuth, setIsError, repoId); + const refresh = async () => { + if (!repoId) return; + const result = await getRepo(repoId); + if (result.success && result.data) { + setRepo(result.data); + } else if (result.status === 401) { + navigate('/login', { replace: true }); + } else { + setIsError(true); + setErrorMessage(result.message || 'Failed to refresh repository data'); } }; if (isLoading) return
Loading...
; - if (isError) return
Something went wrong ...
; + if (isError) return {errorMessage || 'Something went wrong ...'}; if (!repo) return
No repository data found
; const { url: remoteUrl, proxyURL } = repo || {}; diff --git a/src/ui/views/RepoList/Components/NewRepo.tsx b/src/ui/views/RepoList/Components/NewRepo.tsx index e29f8244f..f5f8ef4dc 100644 --- a/src/ui/views/RepoList/Components/NewRepo.tsx +++ b/src/ui/views/RepoList/Components/NewRepo.tsx @@ -85,8 +85,8 @@ const AddRepositoryDialog: React.FC = ({ open, onClose } const result = await addRepo(repo); - if (result.success && result.repo) { - handleSuccess(result.repo); + if (result.success && result.data) { + handleSuccess(result.data); handleClose(); } else { setError(result.message || 'Failed to add repository'); diff --git a/src/ui/views/RepoList/Components/Repositories.tsx b/src/ui/views/RepoList/Components/Repositories.tsx index a72cd2fc5..5c7ca5ea6 100644 --- a/src/ui/views/RepoList/Components/Repositories.tsx +++ b/src/ui/views/RepoList/Components/Repositories.tsx @@ -37,7 +37,6 @@ export default function Repositories(): React.ReactElement { const classes = useStyles(); const [repos, setRepos] = useState([]); const [filteredRepos, setFilteredRepos] = useState([]); - const [, setAuth] = useState(true); const [isLoading, setIsLoading] = useState(false); const [isError, setIsError] = useState(false); const [errorMessage, setErrorMessage] = useState(''); @@ -49,16 +48,23 @@ export default function Repositories(): React.ReactElement { navigate(`/dashboard/repo/${repoId}`, { replace: true }); useEffect(() => { - getRepos( - setIsLoading, - (repos: RepoView[]) => { - setRepos(repos); - setFilteredRepos(repos); - }, - setAuth, - setIsError, - setErrorMessage, - ); + const load = async () => { + setIsLoading(true); + const result = await getRepos(); + if (result.success && result.data) { + setRepos(result.data); + setFilteredRepos(result.data); + } else if (result.status === 401) { + setIsLoading(false); + navigate('/login', { replace: true }); + return; + } else { + setIsError(true); + setErrorMessage(result.message || 'Failed to load repositories'); + } + setIsLoading(false); + }; + load(); }, []); const refresh = async (repo: RepoView): Promise => { diff --git a/src/ui/views/User/UserProfile.tsx b/src/ui/views/User/UserProfile.tsx index 49b9b6053..bfd54286e 100644 --- a/src/ui/views/User/UserProfile.tsx +++ b/src/ui/views/User/UserProfile.tsx @@ -16,7 +16,6 @@ import { LogoGithubIcon } from '@primer/octicons-react'; import CloseRounded from '@material-ui/icons/CloseRounded'; import { Check, Save } from '@material-ui/icons'; import { TextField, Theme } from '@material-ui/core'; -import Danger from '../../components/Typography/Danger'; const useStyles = makeStyles((theme: Theme) => ({ root: { @@ -58,7 +57,7 @@ export default function UserProfile(): React.ReactElement { return ; } - if (errorMessage) return {errorMessage}; + if (errorMessage) throw new Error(errorMessage); if (!user) return
No user data available
; diff --git a/test/ui/errors.test.ts b/test/ui/errors.test.ts new file mode 100644 index 000000000..f5a101721 --- /dev/null +++ b/test/ui/errors.test.ts @@ -0,0 +1,223 @@ +import { describe, expect, it } from 'vitest'; +import { + getServiceError, + formatErrorMessage, + errorResult, + successResult, +} from '../../src/ui/services/errors'; + +describe('errors utility functions', () => { + describe('getServiceError', () => { + it('extracts status and message from axios error response', () => { + const error = { + response: { + status: 404, + data: { + message: 'Not found', + }, + }, + }; + + const result = getServiceError(error, 'Fallback message'); + + expect(result).toEqual({ + status: 404, + message: 'Not found', + }); + }); + + it('uses error.message when response message is not available', () => { + const error = { + message: 'Network error', + }; + + const result = getServiceError(error, 'Fallback message'); + + expect(result).toEqual({ + status: undefined, + message: 'Network error', + }); + }); + + it('uses fallback message when no message is available', () => { + const error = {}; + + const result = getServiceError(error, 'Fallback message'); + + expect(result).toEqual({ + status: undefined, + message: 'Fallback message', + }); + }); + + it('uses response code when response message is empty', () => { + const error = { + response: { + status: 500, + data: { + message: ' ', + }, + }, + message: 'Server error', + }; + + const result = getServiceError(error, 'Fallback message'); + + expect(result).toEqual({ + status: 500, + message: 'Unknown error occurred, response code: 500', + }); + }); + + it('uses response code when response message is non-string', () => { + const error = { + response: { + status: 400, + data: { + message: { error: 'Bad request' }, + }, + }, + message: 'Bad request error', + }; + + const result = getServiceError(error, 'Fallback message'); + + expect(result).toEqual({ + status: 400, + message: 'Unknown error occurred, response code: 400', + }); + }); + }); + + describe('formatErrorMessage', () => { + it('formats message with status code', () => { + const result = formatErrorMessage('Error loading data', 404, 'Not found'); + + expect(result).toBe('Error loading data: 404 Not found'); + }); + + it('formats message without status code', () => { + const result = formatErrorMessage('Error loading data', undefined, 'Network error'); + + expect(result).toBe('Error loading data: Network error'); + }); + + it('handles status code 0', () => { + const result = formatErrorMessage('Error', 0, 'Connection refused'); + + expect(result).toBe('Error: Connection refused'); + }); + }); + + describe('errorResult', () => { + it('creates error result from axios error', () => { + const error = { + response: { + status: 403, + data: { + message: 'Forbidden', + }, + }, + }; + + const result = errorResult(error, 'Failed to access resource'); + + expect(result).toEqual({ + success: false, + status: 403, + message: 'Forbidden', + }); + }); + + it('creates error result with fallback message', () => { + const error = {}; + + const result = errorResult(error, 'Something went wrong'); + + expect(result).toEqual({ + success: false, + status: undefined, + message: 'Something went wrong', + }); + }); + + it('preserves type parameter', () => { + const error = { + message: 'Error', + }; + + const result = errorResult<{ data: string }>(error, 'Failed'); + + expect(result).toEqual({ + success: false, + status: undefined, + message: 'Error', + }); + }); + }); + + describe('successResult', () => { + it('creates success result without data', () => { + const result = successResult(); + + expect(result).toEqual({ + success: true, + }); + }); + + it('creates success result with data', () => { + const data = { id: '123', name: 'test' }; + const result = successResult(data); + + expect(result).toEqual({ + success: true, + data: { id: '123', name: 'test' }, + }); + }); + + it('creates success result with null data', () => { + const result = successResult(null); + + expect(result).toEqual({ + success: true, + data: null, + }); + }); + + it('creates success result with 0 as data', () => { + const result = successResult(0); + + expect(result).toEqual({ + success: true, + data: 0, + }); + }); + + it('creates success result with false as data', () => { + const result = successResult(false); + + expect(result).toEqual({ + success: true, + data: false, + }); + }); + + it('creates success result with empty string as data', () => { + const result = successResult(''); + + expect(result).toEqual({ + success: true, + data: '', + }); + }); + + it('does not include data key when undefined', () => { + const result = successResult(undefined); + + expect(result).toEqual({ + success: true, + }); + expect('data' in result).toBe(false); + }); + }); +}); diff --git a/test/ui/git-push.test.ts b/test/ui/git-push.test.ts new file mode 100644 index 000000000..51d558489 --- /dev/null +++ b/test/ui/git-push.test.ts @@ -0,0 +1,273 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + getPush, + getPushes, + authorisePush, + cancelPush, + rejectPush, +} from '../../src/ui/services/git-push'; + +const { axiosMock } = vi.hoisted(() => { + const axiosFn = vi.fn() as any; + axiosFn.post = vi.fn(); + + return { + axiosMock: axiosFn, + }; +}); + +vi.mock('axios', () => ({ + default: axiosMock, +})); + +vi.mock('../../src/ui/services/apiConfig', () => ({ + getApiV1BaseUrl: vi.fn(async () => 'http://localhost:8080/api/v1'), +})); + +vi.mock('../../src/ui/services/auth', () => ({ + getAxiosConfig: vi.fn(() => ({ + withCredentials: true, + headers: {}, + })), + processAuthError: vi.fn(), +})); + +describe('git-push service', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('getPush', () => { + it('returns push data with diff step on success', async () => { + const pushData = { + id: 'push-123', + steps: [ + { stepName: 'diff', data: 'some diff' }, + { stepName: 'validate', data: 'validation data' }, + ], + }; + + axiosMock.mockResolvedValue({ data: pushData }); + + const result = await getPush('push-123'); + + expect(result.success).toBe(true); + expect(result.data).toEqual({ + ...pushData, + diff: { stepName: 'diff', data: 'some diff' }, + }); + expect(axiosMock).toHaveBeenCalledWith( + 'http://localhost:8080/api/v1/push/push-123', + expect.any(Object), + ); + }); + + it('returns error result when getPush fails', async () => { + axiosMock.mockRejectedValue({ + response: { + status: 404, + data: { + message: 'Push not found', + }, + }, + }); + + const result = await getPush('push-123'); + + expect(result).toEqual({ + success: false, + status: 404, + message: 'Push not found', + }); + }); + + it('uses fallback message when error has no response data', async () => { + axiosMock.mockRejectedValue(new Error('Network error')); + + const result = await getPush('push-123'); + + expect(result).toEqual({ + success: false, + status: undefined, + message: 'Network error', + }); + }); + }); + + describe('getPushes', () => { + it('returns array of pushes on success with default query', async () => { + const pushesData = [ + { id: 'push-1', steps: [] }, + { id: 'push-2', steps: [] }, + ]; + + axiosMock.mockResolvedValue({ data: pushesData }); + + const result = await getPushes(); + + expect(result.success).toBe(true); + expect(result.data).toEqual(pushesData); + expect(axiosMock).toHaveBeenCalledWith( + 'http://localhost:8080/api/v1/push?blocked=true&canceled=false&authorised=false&rejected=false', + expect.any(Object), + ); + }); + + it('returns array of pushes with custom query params', async () => { + const pushesData = [{ id: 'push-1', steps: [] }]; + + axiosMock.mockResolvedValue({ data: pushesData }); + + const result = await getPushes({ + blocked: false, + canceled: true, + authorised: true, + rejected: false, + }); + + expect(result.success).toBe(true); + expect(result.data).toEqual(pushesData); + expect(axiosMock).toHaveBeenCalledWith( + 'http://localhost:8080/api/v1/push?blocked=false&canceled=true&authorised=true&rejected=false', + expect.any(Object), + ); + }); + + it('returns error result when getPushes fails', async () => { + axiosMock.mockRejectedValue({ + response: { + status: 500, + data: { + message: 'Internal server error', + }, + }, + }); + + const result = await getPushes(); + + expect(result).toEqual({ + success: false, + status: 500, + message: 'Internal server error', + }); + }); + }); + + describe('authorisePush', () => { + it('returns success for authorise action', async () => { + axiosMock.post.mockResolvedValue({ data: {} } as any); + + const result = await authorisePush('push-123', [{ label: 'LGTM', checked: true }]); + + expect(result).toEqual({ success: true }); + expect(axiosMock.post).toHaveBeenCalledWith( + 'http://localhost:8080/api/v1/push/push-123/authorise', + { + params: { + attestation: [{ label: 'LGTM', checked: true }], + }, + }, + expect.any(Object), + ); + }); + + it('returns backend not-logged-in message for authorise 401 errors', async () => { + axiosMock.post.mockRejectedValue({ + response: { + status: 401, + data: { + message: 'Not logged in', + }, + }, + }); + + const result = await authorisePush('push-123', []); + + expect(result).toEqual({ + success: false, + status: 401, + message: 'Not logged in', + }); + }); + }); + + describe('rejectPush', () => { + it('returns success for reject action', async () => { + axiosMock.post.mockResolvedValue({ data: {} } as any); + + const result = await rejectPush('push-456'); + + expect(result).toEqual({ success: true }); + expect(axiosMock.post).toHaveBeenCalledWith( + 'http://localhost:8080/api/v1/push/push-456/reject', + { reason: undefined }, + expect.any(Object), + ); + }); + + it('returns backend message for reject 403 errors', async () => { + axiosMock.post.mockRejectedValue({ + response: { + status: 403, + data: { + message: 'User alice is not authorised to reject changes on this project', + }, + }, + }); + + const result = await rejectPush('push-456'); + + expect(result).toEqual({ + success: false, + status: 403, + message: 'User alice is not authorised to reject changes on this project', + }); + }); + + it('falls back to thrown error message when response payload is missing', async () => { + axiosMock.post.mockRejectedValue(new Error('network timeout')); + + const result = await rejectPush('push-999'); + + expect(result).toEqual({ + success: false, + status: undefined, + message: 'network timeout', + }); + }); + }); + + describe('cancelPush', () => { + it('returns success for cancel action', async () => { + axiosMock.post.mockResolvedValue({ data: {} } as any); + + const result = await cancelPush('push-789'); + + expect(result).toEqual({ success: true }); + expect(axiosMock.post).toHaveBeenCalledWith( + 'http://localhost:8080/api/v1/push/push-789/cancel', + {}, + expect.any(Object), + ); + }); + + it('returns backend message for cancel 401 errors', async () => { + axiosMock.post.mockRejectedValue({ + response: { + status: 401, + data: { + message: 'Not logged in', + }, + }, + }); + + const result = await cancelPush('push-789'); + + expect(result).toEqual({ + success: false, + status: 401, + message: 'Not logged in', + }); + }); + }); +}); diff --git a/test/ui/repo.test.ts b/test/ui/repo.test.ts new file mode 100644 index 000000000..9f176e54a --- /dev/null +++ b/test/ui/repo.test.ts @@ -0,0 +1,380 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + addUser, + deleteUser, + getRepo, + getRepos, + addRepo, + deleteRepo, +} from '../../src/ui/services/repo'; + +const { axiosMock } = vi.hoisted(() => { + const axiosFn = vi.fn() as any; + axiosFn.get = vi.fn(); + axiosFn.post = vi.fn(); + axiosFn.patch = vi.fn(); + axiosFn.delete = vi.fn(); + + return { + axiosMock: axiosFn, + }; +}); + +vi.mock('axios', () => ({ + default: axiosMock, +})); + +vi.mock('../../src/ui/services/auth.js', () => ({ + getAxiosConfig: vi.fn(() => ({ + withCredentials: true, + headers: {}, + })), + processAuthError: vi.fn(), +})); + +vi.mock('../../src/ui/services/apiConfig', () => ({ + getApiV1BaseUrl: vi.fn(async () => 'http://localhost:8080/api/v1'), +})); + +describe('repo service error handling', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns data on successful getRepo', async () => { + const repoData = { + name: 'test-repo', + project: 'org', + url: 'https://example.com/org/test-repo.git', + }; + axiosMock.mockResolvedValue({ data: repoData }); + + const result = await getRepo('repo-1'); + + expect(result).toEqual({ success: true, data: repoData }); + }); + + it('returns error result when getRepo fails with non-401 status', async () => { + axiosMock.mockRejectedValue({ + response: { + status: 403, + data: { + message: 'User alice not authorised on this repository', + }, + }, + }); + + const result = await getRepo('repo-1'); + + expect(result).toEqual({ + success: false, + status: 403, + message: 'User alice not authorised on this repository', + }); + }); + + it('returns error result when getRepo fails with 401 status', async () => { + axiosMock.mockRejectedValue({ + response: { + status: 401, + data: { + message: 'Not logged in', + }, + }, + }); + + const result = await getRepo('repo-1'); + + expect(result).toEqual({ + success: false, + status: 401, + message: 'Not logged in', + }); + }); + + it('returns fallback message when response payload is missing', async () => { + axiosMock.mockRejectedValue(new Error('network timeout')); + + const result = await getRepo('repo-1'); + + expect(result).toEqual({ + success: false, + status: undefined, + message: 'network timeout', + }); + }); + + it('throws backend message when addUser patch request fails', async () => { + axiosMock.get.mockResolvedValue({ + data: { + users: { + canAuthorise: [], + canPush: [], + }, + }, + }); + + axiosMock.patch.mockRejectedValue({ + response: { + status: 404, + data: { + message: 'User bob not found', + }, + }, + }); + + await expect(addUser('repo-1', 'bob', 'authorise')).rejects.toThrow('User bob not found'); + }); +}); + +describe('repo service additional functions', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('getRepos', () => { + it('returns sorted repos on success', async () => { + const reposData = [ + { name: 'zebra-repo', project: 'org', url: 'https://example.com/org/zebra-repo.git' }, + { name: 'alpha-repo', project: 'org', url: 'https://example.com/org/alpha-repo.git' }, + ]; + + axiosMock.mockResolvedValue({ data: reposData }); + + const result = await getRepos(); + + expect(result.success).toBe(true); + expect(result.data).toEqual([ + { name: 'alpha-repo', project: 'org', url: 'https://example.com/org/alpha-repo.git' }, + { name: 'zebra-repo', project: 'org', url: 'https://example.com/org/zebra-repo.git' }, + ]); + }); + + it('passes query parameters correctly', async () => { + axiosMock.mockResolvedValue({ data: [] }); + + await getRepos({ active: true }); + + expect(axiosMock).toHaveBeenCalledWith( + 'http://localhost:8080/api/v1/repo?active=true', + expect.any(Object), + ); + }); + + it('returns error result when getRepos fails', async () => { + axiosMock.mockRejectedValue({ + response: { + status: 500, + data: { + message: 'Database connection failed', + }, + }, + }); + + const result = await getRepos(); + + expect(result).toEqual({ + success: false, + status: 500, + message: 'Database connection failed', + }); + }); + + it('uses fallback message when error has no response data', async () => { + axiosMock.mockRejectedValue(new Error('Connection timeout')); + + const result = await getRepos(); + + expect(result).toEqual({ + success: false, + status: undefined, + message: 'Connection timeout', + }); + }); + }); + + describe('addRepo', () => { + it('returns created repo on success', async () => { + const newRepo = { + name: 'new-repo', + project: 'org', + url: 'https://example.com/org/new-repo.git', + }; + + axiosMock.post.mockResolvedValue({ data: { ...newRepo, id: 'repo-123' } }); + + const result = await addRepo(newRepo as any); + + expect(result.success).toBe(true); + expect(result.data).toEqual({ ...newRepo, id: 'repo-123' }); + expect(axiosMock.post).toHaveBeenCalledWith( + 'http://localhost:8080/api/v1/repo', + newRepo, + expect.any(Object), + ); + }); + + it('returns error result when addRepo fails', async () => { + const newRepo = { + name: 'duplicate-repo', + project: 'org', + url: 'https://example.com/org/duplicate-repo.git', + }; + + axiosMock.post.mockRejectedValue({ + response: { + status: 409, + data: { + message: 'Repository already exists', + }, + }, + }); + + const result = await addRepo(newRepo as any); + + expect(result).toEqual({ + success: false, + status: 409, + message: 'Repository already exists', + }); + }); + }); + + describe('addUser', () => { + it('successfully adds user when not duplicate', async () => { + axiosMock.get.mockResolvedValue({ + data: { + users: { + canAuthorise: ['alice'], + canPush: ['bob'], + }, + }, + }); + + axiosMock.patch.mockResolvedValue({ data: {} }); + + await expect(addUser('repo-1', 'charlie', 'authorise')).resolves.toBeUndefined(); + + expect(axiosMock.patch).toHaveBeenCalledWith( + 'http://localhost:8080/api/v1/repo/repo-1/user/authorise', + { username: 'charlie' }, + expect.any(Object), + ); + }); + + it('throws DupUserValidationError when user already has the role', async () => { + axiosMock.get.mockResolvedValue({ + data: { + users: { + canAuthorise: ['alice'], + canPush: ['bob'], + }, + }, + }); + + await expect(addUser('repo-1', 'alice', 'authorise')).rejects.toThrow( + 'Duplicate user can not be added', + ); + + expect(axiosMock.patch).not.toHaveBeenCalled(); + }); + + it('checks canPush list for push action', async () => { + axiosMock.get.mockResolvedValue({ + data: { + users: { + canAuthorise: [], + canPush: ['bob'], + }, + }, + }); + + await expect(addUser('repo-1', 'bob', 'push')).rejects.toThrow( + 'Duplicate user can not be added', + ); + }); + + it('throws error from canAddUser validation failure', async () => { + axiosMock.get.mockRejectedValue({ + response: { + status: 404, + data: { + message: 'Repository not found', + }, + }, + }); + + await expect(addUser('repo-1', 'charlie', 'authorise')).rejects.toThrow( + 'Repository not found', + ); + }); + }); + + describe('deleteUser', () => { + it('successfully deletes user', async () => { + axiosMock.delete.mockResolvedValue({ data: {} }); + + await expect(deleteUser('alice', 'repo-1', 'authorise')).resolves.toBeUndefined(); + + expect(axiosMock.delete).toHaveBeenCalledWith( + 'http://localhost:8080/api/v1/repo/repo-1/user/authorise/alice', + expect.any(Object), + ); + }); + + it('throws error when deleteUser fails', async () => { + axiosMock.delete.mockRejectedValue({ + response: { + status: 404, + data: { + message: 'User not found in repository', + }, + }, + }); + + await expect(deleteUser('charlie', 'repo-1', 'authorise')).rejects.toThrow( + 'User not found in repository', + ); + }); + + it('throws fallback message when error has no response data', async () => { + axiosMock.delete.mockRejectedValue(new Error('Network error')); + + await expect(deleteUser('alice', 'repo-1', 'push')).rejects.toThrow('Network error'); + }); + }); + + describe('deleteRepo', () => { + it('successfully deletes repository', async () => { + axiosMock.delete.mockResolvedValue({ data: {} }); + + await expect(deleteRepo('repo-1')).resolves.toBeUndefined(); + + expect(axiosMock.delete).toHaveBeenCalledWith( + 'http://localhost:8080/api/v1/repo/repo-1/delete', + expect.any(Object), + ); + }); + + it('throws error when deleteRepo fails', async () => { + axiosMock.delete.mockRejectedValue({ + response: { + status: 403, + data: { + message: 'Insufficient permissions to delete repository', + }, + }, + }); + + await expect(deleteRepo('repo-1')).rejects.toThrow( + 'Insufficient permissions to delete repository', + ); + }); + + it('throws fallback message when error has no response data', async () => { + axiosMock.delete.mockRejectedValue(new Error('Connection refused')); + + await expect(deleteRepo('repo-1')).rejects.toThrow('Connection refused'); + }); + }); +}); diff --git a/test/ui/user.test.ts b/test/ui/user.test.ts new file mode 100644 index 000000000..3cbb236c4 --- /dev/null +++ b/test/ui/user.test.ts @@ -0,0 +1,265 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { getUser, getUsers, updateUser } from '../../src/ui/services/user'; + +const { axiosMock } = vi.hoisted(() => { + const axiosFn = vi.fn() as any; + axiosFn.post = vi.fn(); + + return { + axiosMock: axiosFn, + }; +}); + +vi.mock('axios', () => ({ + default: axiosMock, +})); + +vi.mock('../../src/ui/services/apiConfig', () => ({ + getBaseUrl: vi.fn(async () => 'http://localhost:8080'), + getApiV1BaseUrl: vi.fn(async () => 'http://localhost:8080/api/v1'), +})); + +vi.mock('../../src/ui/services/auth', () => ({ + getAxiosConfig: vi.fn(() => ({ + withCredentials: true, + headers: {}, + })), + processAuthError: vi.fn((error) => `Auth error: ${error?.response?.data?.message || 'Unknown'}`), +})); + +describe('user service', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('getUser', () => { + it('fetches current user profile when no id is provided', async () => { + const userData = { id: 'user-1', username: 'alice', email: 'alice@example.com' }; + const setUser = vi.fn(); + const setIsLoading = vi.fn(); + + axiosMock.mockResolvedValue({ data: userData }); + + await getUser(setIsLoading, setUser); + + expect(axiosMock).toHaveBeenCalledWith( + 'http://localhost:8080/api/auth/profile', + expect.any(Object), + ); + expect(setUser).toHaveBeenCalledWith(userData); + expect(setIsLoading).toHaveBeenCalledWith(false); + }); + + it('fetches specific user when id is provided', async () => { + const userData = { id: 'user-2', username: 'bob', email: 'bob@example.com' }; + const setUser = vi.fn(); + const setIsLoading = vi.fn(); + + axiosMock.mockResolvedValue({ data: userData }); + + await getUser(setIsLoading, setUser, undefined, undefined, 'user-2'); + + expect(axiosMock).toHaveBeenCalledWith( + 'http://localhost:8080/api/v1/user/user-2', + expect.any(Object), + ); + expect(setUser).toHaveBeenCalledWith(userData); + expect(setIsLoading).toHaveBeenCalledWith(false); + }); + + it('handles 401 auth errors', async () => { + const setAuth = vi.fn(); + const setErrorMessage = vi.fn(); + const setIsLoading = vi.fn(); + + axiosMock.mockRejectedValue({ + response: { + status: 401, + data: { + message: 'Session expired', + }, + }, + }); + + await getUser(setIsLoading, undefined, setAuth, setErrorMessage); + + expect(setAuth).toHaveBeenCalledWith(false); + expect(setErrorMessage).toHaveBeenCalledWith('Auth error: Session expired'); + expect(setIsLoading).toHaveBeenCalledWith(false); + }); + + it('handles non-401 errors with formatted message', async () => { + const setErrorMessage = vi.fn(); + const setIsLoading = vi.fn(); + + axiosMock.mockRejectedValue({ + response: { + status: 404, + data: { + message: 'User not found', + }, + }, + }); + + await getUser(setIsLoading, undefined, undefined, setErrorMessage); + + expect(setErrorMessage).toHaveBeenCalledWith('Error fetching user: 404 User not found'); + expect(setIsLoading).toHaveBeenCalledWith(false); + }); + + it('handles errors without status code', async () => { + const setErrorMessage = vi.fn(); + const setIsLoading = vi.fn(); + + axiosMock.mockRejectedValue(new Error('Network timeout')); + + await getUser(setIsLoading, undefined, undefined, setErrorMessage); + + expect(setErrorMessage).toHaveBeenCalledWith('Error fetching user: Network timeout'); + expect(setIsLoading).toHaveBeenCalledWith(false); + }); + + it('works with minimal callbacks provided', async () => { + const userData = { id: 'user-1', username: 'alice', email: 'alice@example.com' }; + + axiosMock.mockResolvedValue({ data: userData }); + + await expect(getUser()).resolves.toBeUndefined(); + }); + }); + + describe('getUsers', () => { + it('fetches all users successfully', async () => { + const usersData = [ + { id: 'user-1', username: 'alice', email: 'alice@example.com' }, + { id: 'user-2', username: 'bob', email: 'bob@example.com' }, + ]; + const setUsers = vi.fn(); + const setIsLoading = vi.fn(); + const setAuth = vi.fn(); + const setErrorMessage = vi.fn(); + + axiosMock.mockResolvedValue({ data: usersData }); + + await getUsers(setIsLoading, setUsers, setAuth, setErrorMessage); + + expect(axiosMock).toHaveBeenCalledWith( + 'http://localhost:8080/api/v1/user', + expect.any(Object), + ); + expect(setIsLoading).toHaveBeenCalledWith(true); + expect(setUsers).toHaveBeenCalledWith(usersData); + expect(setIsLoading).toHaveBeenCalledWith(false); + }); + + it('handles 401 errors', async () => { + const setUsers = vi.fn(); + const setIsLoading = vi.fn(); + const setAuth = vi.fn(); + const setErrorMessage = vi.fn(); + + axiosMock.mockRejectedValue({ + response: { + status: 401, + data: { + message: 'Not authenticated', + }, + }, + }); + + await getUsers(setIsLoading, setUsers, setAuth, setErrorMessage); + + expect(setAuth).toHaveBeenCalledWith(false); + expect(setErrorMessage).toHaveBeenCalledWith('Auth error: Not authenticated'); + expect(setIsLoading).toHaveBeenCalledWith(false); + }); + + it('handles non-401 errors', async () => { + const setUsers = vi.fn(); + const setIsLoading = vi.fn(); + const setAuth = vi.fn(); + const setErrorMessage = vi.fn(); + + axiosMock.mockRejectedValue({ + response: { + status: 500, + data: { + message: 'Database error', + }, + }, + }); + + await getUsers(setIsLoading, setUsers, setAuth, setErrorMessage); + + expect(setErrorMessage).toHaveBeenCalledWith('Error fetching users: 500 Database error'); + expect(setIsLoading).toHaveBeenCalledWith(false); + }); + + it('sets loading to false even when error occurs', async () => { + const setUsers = vi.fn(); + const setIsLoading = vi.fn(); + const setAuth = vi.fn(); + const setErrorMessage = vi.fn(); + + axiosMock.mockRejectedValue(new Error('Network error')); + + await getUsers(setIsLoading, setUsers, setAuth, setErrorMessage); + + expect(setIsLoading).toHaveBeenCalledWith(true); + expect(setIsLoading).toHaveBeenCalledWith(false); + }); + }); + + describe('updateUser', () => { + it('successfully updates user', async () => { + const userData = { id: 'user-1', username: 'alice', email: 'alice@example.com' }; + const setErrorMessage = vi.fn(); + const setIsLoading = vi.fn(); + + axiosMock.post.mockResolvedValue({ data: {} }); + + await updateUser(userData as any, setErrorMessage, setIsLoading); + + expect(axiosMock.post).toHaveBeenCalledWith( + 'http://localhost:8080/api/auth/gitAccount', + userData, + expect.any(Object), + ); + expect(setErrorMessage).not.toHaveBeenCalled(); + expect(setIsLoading).not.toHaveBeenCalled(); + }); + + it('handles update errors', async () => { + const userData = { id: 'user-1', username: 'alice', email: 'alice@example.com' }; + const setErrorMessage = vi.fn(); + const setIsLoading = vi.fn(); + + axiosMock.post.mockRejectedValue({ + response: { + status: 400, + data: { + message: 'Invalid email format', + }, + }, + }); + + await updateUser(userData as any, setErrorMessage, setIsLoading); + + expect(setErrorMessage).toHaveBeenCalledWith('Error updating user: 400 Invalid email format'); + expect(setIsLoading).toHaveBeenCalledWith(false); + }); + + it('handles errors without status code', async () => { + const userData = { id: 'user-1', username: 'alice', email: 'alice@example.com' }; + const setErrorMessage = vi.fn(); + const setIsLoading = vi.fn(); + + axiosMock.post.mockRejectedValue(new Error('Connection failed')); + + await updateUser(userData as any, setErrorMessage, setIsLoading); + + expect(setErrorMessage).toHaveBeenCalledWith('Error updating user: Connection failed'); + expect(setIsLoading).toHaveBeenCalledWith(false); + }); + }); +});