cleanup 2
continuous-integration/drone/pr Build is failing

This commit is contained in:
2026-07-03 16:49:50 -04:00
parent c327a3905d
commit 6231befaa8
18 changed files with 179 additions and 281 deletions
-2
View File
@@ -17,8 +17,6 @@
}, },
"dependencies": { "dependencies": {
"@tanstack/react-query": "^5.62.16", "@tanstack/react-query": "^5.62.16",
"ajv": "^8.18.0",
"axios": "^1.13.6",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"epubjs": "^0.3.93", "epubjs": "^0.3.93",
"nosleep.js": "^0.12.0", "nosleep.js": "^0.12.0",
+19 -94
View File
@@ -1,4 +1,4 @@
import { Route, Routes as ReactRoutes } from 'react-router-dom'; import { Outlet, Route, Routes as ReactRoutes } from 'react-router-dom';
import Layout from './components/Layout'; import Layout from './components/Layout';
import HomePage from './pages/HomePage'; import HomePage from './pages/HomePage';
import DocumentsPage from './pages/DocumentsPage'; import DocumentsPage from './pages/DocumentsPage';
@@ -16,108 +16,33 @@ import AdminUsersPage from './pages/AdminUsersPage';
import AdminLogsPage from './pages/AdminLogsPage'; import AdminLogsPage from './pages/AdminLogsPage';
import ReaderPage from './pages/ReaderPage'; import ReaderPage from './pages/ReaderPage';
import { ProtectedRoute } from './auth/ProtectedRoute'; import { ProtectedRoute } from './auth/ProtectedRoute';
import { AdminRoute } from './auth/AdminRoute';
export function Routes() { export function Routes() {
return ( return (
<ReactRoutes> <ReactRoutes>
<Route path="/" element={<Layout />}>
<Route <Route
index path="/"
element={ element={
<ProtectedRoute> <ProtectedRoute>
<HomePage /> <Layout />
</ProtectedRoute> </ProtectedRoute>
} }
/> >
<Route <Route index element={<HomePage />} />
path="documents" <Route path="documents" element={<DocumentsPage />} />
element={ <Route path="documents/:id" element={<DocumentPage />} />
<ProtectedRoute> <Route path="progress" element={<ProgressPage />} />
<DocumentsPage /> <Route path="activity" element={<ActivityPage />} />
</ProtectedRoute> <Route path="search" element={<SearchPage />} />
} <Route path="settings" element={<SettingsPage />} />
/> <Route path="admin" element={<AdminRoute><Outlet /></AdminRoute>}>
<Route <Route index element={<AdminPage />} />
path="documents/:id" <Route path="import" element={<AdminImportPage />} />
element={ <Route path="import-results" element={<AdminImportResultsPage />} />
<ProtectedRoute> <Route path="users" element={<AdminUsersPage />} />
<DocumentPage /> <Route path="logs" element={<AdminLogsPage />} />
</ProtectedRoute> </Route>
}
/>
<Route
path="progress"
element={
<ProtectedRoute>
<ProgressPage />
</ProtectedRoute>
}
/>
<Route
path="activity"
element={
<ProtectedRoute>
<ActivityPage />
</ProtectedRoute>
}
/>
<Route
path="search"
element={
<ProtectedRoute>
<SearchPage />
</ProtectedRoute>
}
/>
<Route
path="settings"
element={
<ProtectedRoute>
<SettingsPage />
</ProtectedRoute>
}
/>
{/* Admin routes */}
<Route
path="admin"
element={
<ProtectedRoute>
<AdminPage />
</ProtectedRoute>
}
/>
<Route
path="admin/import"
element={
<ProtectedRoute>
<AdminImportPage />
</ProtectedRoute>
}
/>
<Route
path="admin/import-results"
element={
<ProtectedRoute>
<AdminImportResultsPage />
</ProtectedRoute>
}
/>
<Route
path="admin/users"
element={
<ProtectedRoute>
<AdminUsersPage />
</ProtectedRoute>
}
/>
<Route
path="admin/logs"
element={
<ProtectedRoute>
<AdminLogsPage />
</ProtectedRoute>
}
/>
</Route> </Route>
<Route <Route
path="/reader/:id" path="/reader/:id"
+68
View File
@@ -0,0 +1,68 @@
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { AdminRoute } from './AdminRoute';
import { useAuth } from './AuthContext';
vi.mock('./AuthContext', () => ({
useAuth: vi.fn(),
}));
const mockedUseAuth = vi.mocked(useAuth);
describe('AdminRoute', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renders children for admin users', () => {
mockedUseAuth.mockReturnValue({
isAuthenticated: true,
isCheckingAuth: false,
user: { username: 'evan', is_admin: true },
login: vi.fn(),
register: vi.fn(),
logout: vi.fn(),
});
render(
<MemoryRouter>
<AdminRoute>
<div>Admin Panel</div>
</AdminRoute>
</MemoryRouter>
);
expect(screen.getByText('Admin Panel')).toBeInTheDocument();
});
it('redirects non-admin users to the home page', () => {
mockedUseAuth.mockReturnValue({
isAuthenticated: true,
isCheckingAuth: false,
user: { username: 'evan', is_admin: false },
login: vi.fn(),
register: vi.fn(),
logout: vi.fn(),
});
render(
<MemoryRouter initialEntries={['/admin']}>
<Routes>
<Route
path="/admin"
element={
<AdminRoute>
<div>Admin Panel</div>
</AdminRoute>
}
/>
<Route path="/" element={<div>Home Page</div>} />
</Routes>
</MemoryRouter>
);
expect(screen.getByText('Home Page')).toBeInTheDocument();
expect(screen.queryByText('Admin Panel')).not.toBeInTheDocument();
});
});
+17
View File
@@ -0,0 +1,17 @@
import { Navigate } from 'react-router-dom';
import { useAuth } from './AuthContext';
interface AdminRouteProps {
children: React.ReactNode;
}
// Role Guard - Runs behind ProtectedRoute, so auth is already resolved and `user` is populated by the time this renders.
export function AdminRoute({ children }: AdminRouteProps) {
const { user } = useAuth();
if (!user?.is_admin) {
return <Navigate to="/" replace />;
}
return children;
}
-11
View File
@@ -1,11 +0,0 @@
import { describe, expect, it } from 'vitest';
import { setupAuthInterceptors } from './authInterceptor';
describe('setupAuthInterceptors', () => {
it('is a no-op when auth is handled by HttpOnly cookies', () => {
const cleanup = setupAuthInterceptors();
expect(typeof cleanup).toBe('function');
expect(() => cleanup()).not.toThrow();
});
});
-3
View File
@@ -1,3 +0,0 @@
export function setupAuthInterceptors() {
return () => {};
}
+1 -1
View File
@@ -10,7 +10,7 @@ type ButtonProps = BaseButtonProps & ButtonHTMLAttributes<HTMLButtonElement>;
const getVariantClasses = (variant: 'default' | 'secondary' = 'default'): string => { const getVariantClasses = (variant: 'default' | 'secondary' = 'default'): string => {
const baseClass = const baseClass =
'h-full w-full px-2 py-1 font-medium transition duration-100 ease-in disabled:cursor-not-allowed disabled:opacity-50'; 'inline-flex items-center justify-center px-4 py-2 font-medium transition duration-100 ease-in disabled:cursor-not-allowed disabled:opacity-50';
if (variant === 'secondary') { if (variant === 'secondary') {
return `${baseClass} bg-content text-content-inverse shadow-md hover:bg-content-muted disabled:hover:bg-content`; return `${baseClass} bg-content text-content-inverse shadow-md hover:bg-content-muted disabled:hover:bg-content`;
+3 -16
View File
@@ -1,6 +1,5 @@
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef } from 'react';
import { Link, useLocation, Outlet, Navigate } from 'react-router-dom'; import { Link, useLocation, Outlet } from 'react-router-dom';
import { useGetMe } from '../generated/anthoLumeAPIV1';
import { useAuth } from '../auth/AuthContext'; import { useAuth } from '../auth/AuthContext';
import { UserIcon, DropdownIcon } from '../icons'; import { UserIcon, DropdownIcon } from '../icons';
import { useTheme } from '../theme/ThemeProvider'; import { useTheme } from '../theme/ThemeProvider';
@@ -12,12 +11,8 @@ const themeModes: ThemeMode[] = ['light', 'dark', 'system'];
export default function Layout() { export default function Layout() {
const location = useLocation(); const location = useLocation();
const { isAuthenticated, user, logout, isCheckingAuth } = useAuth(); const { user, logout } = useAuth();
const { themeMode, setThemeMode } = useTheme(); const { themeMode, setThemeMode } = useTheme();
const { data } = useGetMe(isAuthenticated ? {} : undefined);
const fetchedUser =
data?.status === 200 && data.data && 'username' in data.data ? data.data : null;
const userData = user ?? fetchedUser;
const [isUserDropdownOpen, setIsUserDropdownOpen] = useState(false); const [isUserDropdownOpen, setIsUserDropdownOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null); const dropdownRef = useRef<HTMLDivElement>(null);
@@ -45,14 +40,6 @@ export default function Layout() {
document.title = `AnthoLume - ${currentPageTitle}`; document.title = `AnthoLume - ${currentPageTitle}`;
}, [currentPageTitle]); }, [currentPageTitle]);
if (isCheckingAuth) {
return <div className="text-content-muted">Loading...</div>;
}
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
return ( return (
<div className="min-h-screen bg-canvas"> <div className="min-h-screen bg-canvas">
<div className="flex h-16 w-full items-center justify-between"> <div className="flex h-16 w-full items-center justify-between">
@@ -135,7 +122,7 @@ export default function Layout() {
onClick={() => setIsUserDropdownOpen(!isUserDropdownOpen)} onClick={() => setIsUserDropdownOpen(!isUserDropdownOpen)}
className="flex cursor-pointer items-center gap-2 py-4 text-content-muted" className="flex cursor-pointer items-center gap-2 py-4 text-content-muted"
> >
<span>{userData ? ('username' in userData ? userData.username : 'User') : 'User'}</span> <span>{user?.username ?? 'User'}</span>
<span <span
className="text-content transition-transform duration-200" className="text-content transition-transform duration-200"
style={{ transform: isUserDropdownOpen ? 'rotate(180deg)' : 'rotate(0deg)' }} style={{ transform: isUserDropdownOpen ? 'rotate(180deg)' : 'rotate(0deg)' }}
+1 -18
View File
@@ -29,11 +29,6 @@ interface UseEpubReaderResult {
nextPage: () => Promise<void>; nextPage: () => Promise<void>;
prevPage: () => Promise<void>; prevPage: () => Promise<void>;
goToHref: (href: string) => Promise<void>; goToHref: (href: string) => Promise<void>;
setTheme: (theme: {
colorScheme?: ReaderColorScheme;
fontFamily?: ReaderFontFamily;
fontSize?: number;
}) => Promise<void>;
} }
export function useEpubReader({ export function useEpubReader({
@@ -210,17 +205,6 @@ export function useEpubReader({
await readerRef.current?.displayHref(href); await readerRef.current?.displayHref(href);
}, []); }, []);
const setTheme = useCallback(
async (theme: {
colorScheme?: ReaderColorScheme;
fontFamily?: ReaderFontFamily;
fontSize?: number;
}) => {
await readerRef.current?.applyThemeChange(theme);
},
[]
);
return useMemo( return useMemo(
() => ({ () => ({
viewerRef: setViewerNode, viewerRef: setViewerNode,
@@ -232,8 +216,7 @@ export function useEpubReader({
nextPage, nextPage,
prevPage, prevPage,
goToHref, goToHref,
setTheme,
}), }),
[error, goToHref, isLoading, isReady, nextPage, prevPage, setTheme, stats, toc] [error, goToHref, isLoading, isReady, nextPage, prevPage, stats, toc]
); );
} }
+1 -3
View File
@@ -35,11 +35,9 @@ export default function AdminLogsPage() {
/> />
</div> </div>
</div> </div>
<div className="lg:w-60"> <Button variant="secondary" type="submit" className="w-full lg:w-60">
<Button variant="secondary" type="submit">
Filter Filter
</Button> </Button>
</div>
</form> </form>
</div> </div>
+22 -33
View File
@@ -3,6 +3,7 @@ import { LoadingState } from '../components';
import { useGetAdmin, usePostAdminAction } from '../generated/anthoLumeAPIV1'; import { useGetAdmin, usePostAdminAction } from '../generated/anthoLumeAPIV1';
import { Button } from '../components/Button'; import { Button } from '../components/Button';
import { useToasts } from '../components/ToastContext'; import { useToasts } from '../components/ToastContext';
import { useMutationWithToast } from '../hooks/useMutationWithToast';
import { getErrorMessage } from '../utils/errors'; import { getErrorMessage } from '../utils/errors';
import { streamResponseToFile, backupFilename } from '../utils/download'; import { streamResponseToFile, backupFilename } from '../utils/download';
@@ -15,6 +16,7 @@ export default function AdminPage() {
const { isLoading } = useGetAdmin(); const { isLoading } = useGetAdmin();
const postAdminAction = usePostAdminAction(); const postAdminAction = usePostAdminAction();
const { showInfo, showError, removeToast } = useToasts(); const { showInfo, showError, removeToast } = useToasts();
const toastMutationOptions = useMutationWithToast();
const [backupTypes, setBackupTypes] = useState<BackupTypes>({ const [backupTypes, setBackupTypes] = useState<BackupTypes>({
covers: false, covers: false,
@@ -61,6 +63,7 @@ export default function AdminPage() {
e.preventDefault(); e.preventDefault();
if (!restoreFile) return; if (!restoreFile) return;
// Persistent progress toast - Restore is long-running, so we surface a 'started' toast that is dismissed on completion; the standard toast helper has no notion of a progress indicator.
const startedToastId = showInfo('Restore started', 0); const startedToastId = showInfo('Restore started', 0);
try { try {
@@ -88,20 +91,20 @@ export default function AdminPage() {
const handleMetadataMatch = () => { const handleMetadataMatch = () => {
postAdminAction.mutate( postAdminAction.mutate(
{ data: { action: 'METADATA_MATCH' } }, { data: { action: 'METADATA_MATCH' } },
{ toastMutationOptions({
onSuccess: () => showInfo('Metadata matching started'), success: 'Metadata matching started',
onError: error => showError('Metadata matching failed: ' + getErrorMessage(error)), error: 'Failed to start metadata matching',
} })
); );
}; };
const handleCacheTables = () => { const handleCacheTables = () => {
postAdminAction.mutate( postAdminAction.mutate(
{ data: { action: 'CACHE_TABLES' } }, { data: { action: 'CACHE_TABLES' } },
{ toastMutationOptions({
onSuccess: () => showInfo('Cache tables started'), success: 'Cache tables started',
onError: error => showError('Cache tables failed: ' + getErrorMessage(error)), error: 'Failed to start cache tables',
} })
); );
}; };
@@ -135,8 +138,8 @@ export default function AdminPage() {
<label htmlFor="backup_documents">Documents</label> <label htmlFor="backup_documents">Documents</label>
</div> </div>
</div> </div>
<div className="h-10 w-40"> <div className="w-40">
<Button variant="secondary" type="submit"> <Button variant="secondary" type="submit" className="w-full">
Backup Backup
</Button> </Button>
</div> </div>
@@ -151,8 +154,8 @@ export default function AdminPage() {
className="w-full" className="w-full"
/> />
</div> </div>
<div className="h-10 w-40"> <div className="w-40">
<Button variant="secondary" type="submit" disabled={!restoreFile}> <Button variant="secondary" type="submit" className="w-full" disabled={!restoreFile}>
Restore Restore
</Button> </Button>
</div> </div>
@@ -161,35 +164,21 @@ export default function AdminPage() {
</div> </div>
<div className="flex grow flex-col rounded bg-surface p-4 text-content-muted shadow-lg"> <div className="flex grow flex-col rounded bg-surface p-4 text-content-muted shadow-lg">
<p className="text-lg font-semibold text-content">Tasks</p> <p className="mb-4 text-lg font-semibold text-content">Tasks</p>
<table className="min-w-full bg-surface text-sm text-content"> <ul className="flex flex-col gap-3 text-sm text-content">
<tbody> <li className="flex items-center justify-between gap-4">
<tr>
<td className="pl-0">
<p>Metadata Matching</p> <p>Metadata Matching</p>
</td>
<td className="float-right py-2">
<div className="h-10 w-40 text-base">
<Button variant="secondary" onClick={handleMetadataMatch}> <Button variant="secondary" onClick={handleMetadataMatch}>
Run Run
</Button> </Button>
</div> </li>
</td> <li className="flex items-center justify-between gap-4">
</tr>
<tr>
<td>
<p>Cache Tables</p> <p>Cache Tables</p>
</td>
<td className="float-right py-2">
<div className="h-10 w-40 text-base">
<Button variant="secondary" onClick={handleCacheTables}> <Button variant="secondary" onClick={handleCacheTables}>
Run Run
</Button> </Button>
</div> </li>
</td> </ul>
</tr>
</tbody>
</table>
</div> </div>
</div> </div>
); );
+3 -3
View File
@@ -17,7 +17,7 @@ const iconButtonClassName = 'cursor-pointer text-content-muted hover:text-conten
const popupClassName = const popupClassName =
'rounded bg-surface-strong p-3 text-content shadow-lg transition-all duration-200'; 'rounded bg-surface-strong p-3 text-content shadow-lg transition-all duration-200';
const editInputClassName = const editInputClassName =
'w-full rounded border border-secondary-200 bg-secondary-50 p-2 text-lg font-medium text-content focus:outline-hidden focus:ring-2 focus:ring-secondary-400 dark:border-secondary-700 dark:bg-secondary-900/20 dark:focus:ring-secondary-500'; 'w-full rounded border border-border bg-surface-muted p-2 text-lg font-medium text-content focus:outline-hidden focus:ring-2 focus:ring-primary-600';
interface EditableFieldProps { interface EditableFieldProps {
label: string; label: string;
@@ -92,7 +92,7 @@ function EditableField({
<textarea <textarea
value={draft} value={draft}
onChange={e => setDraft(e.target.value)} onChange={e => setDraft(e.target.value)}
className="h-32 w-full grow rounded border border-secondary-200 bg-secondary-50 p-2 font-medium text-content focus:outline-hidden focus:ring-2 focus:ring-secondary-400 dark:border-secondary-700 dark:bg-secondary-900/20 dark:focus:ring-secondary-500" className="h-32 w-full grow rounded border border-border bg-surface-muted p-2 font-medium text-content focus:outline-hidden focus:ring-2 focus:ring-primary-600"
rows={5} rows={5}
/> />
) : ( ) : (
@@ -162,7 +162,7 @@ export default function DocumentPage() {
{document.filepath && ( {document.filepath && (
<a <a
href={`/reader/${document.id}`} href={`/reader/${document.id}`}
className="z-10 mt-2 w-full rounded bg-secondary-700 py-1 text-center text-sm font-medium text-white hover:bg-secondary-800 focus:outline-hidden focus:ring-4 focus:ring-secondary-300 dark:bg-secondary-600 dark:hover:bg-secondary-700" className="z-10 mt-2 w-full rounded bg-secondary-700 py-1 text-center text-sm font-medium text-secondary-foreground hover:bg-secondary-800 focus:outline-hidden focus:ring-4 focus:ring-secondary-500"
> >
Read Read
</a> </a>
+13 -13
View File
@@ -5,9 +5,9 @@ import type { Document } from '../generated/model';
import { ActivityIcon, DownloadIcon, Search2Icon, UploadIcon } from '../icons'; import { ActivityIcon, DownloadIcon, Search2Icon, UploadIcon } from '../icons';
import { LoadingState, Pagination, TextInput } from '../components'; import { LoadingState, Pagination, TextInput } from '../components';
import { useToasts } from '../components/ToastContext'; import { useToasts } from '../components/ToastContext';
import { useMutationWithToast } from '../hooks/useMutationWithToast';
import { formatDuration } from '../utils/formatters'; import { formatDuration } from '../utils/formatters';
import { useDebouncedState } from '../hooks/useDebouncedState'; import { useDebouncedState } from '../hooks/useDebouncedState';
import { getErrorMessage } from '../utils/errors';
import { import {
getDocumentsViewMode, getDocumentsViewMode,
setDocumentsViewMode, setDocumentsViewMode,
@@ -121,7 +121,8 @@ export default function DocumentsPage() {
const [uploadMode, setUploadMode] = useState(false); const [uploadMode, setUploadMode] = useState(false);
const [viewMode, setViewMode] = useState<DocumentsViewMode>(getDocumentsViewMode); const [viewMode, setViewMode] = useState<DocumentsViewMode>(getDocumentsViewMode);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const { showInfo, showWarning, showError } = useToasts(); const { showWarning } = useToasts();
const toastMutationOptions = useMutationWithToast();
useEffect(() => { useEffect(() => {
setDocumentsViewMode(viewMode); setDocumentsViewMode(viewMode);
@@ -138,7 +139,7 @@ export default function DocumentsPage() {
const previousPage = documentsResponse?.previous_page; const previousPage = documentsResponse?.previous_page;
const nextPage = documentsResponse?.next_page; const nextPage = documentsResponse?.next_page;
const uploadDocument = async (file: File | undefined) => { const uploadDocument = (file: File | undefined) => {
if (!file) return; if (!file) return;
if (!file.name.endsWith('.epub')) { if (!file.name.endsWith('.epub')) {
@@ -146,18 +147,17 @@ export default function DocumentsPage() {
return; return;
} }
try { createMutation.mutate(
await createMutation.mutateAsync({ { data: { document_file: file } },
data: { toastMutationOptions({
document_file: file, success: 'Document uploaded successfully!',
}, error: 'Failed to upload document',
}); onSuccess: () => {
showInfo('Document uploaded successfully!');
setUploadMode(false); setUploadMode(false);
refetch(); refetch();
} catch (error) { },
showError('Failed to upload document: ' + getErrorMessage(error)); })
} );
}; };
const handleCancelUpload = () => { const handleCancelUpload = () => {
-5
View File
@@ -89,11 +89,6 @@ export default function ReaderPage() {
} }
}, [document?.title]); }, [document?.title]);
const { setTheme } = reader;
useEffect(() => {
setTheme({ colorScheme, fontFamily, fontSize });
}, [colorScheme, fontFamily, fontSize, setTheme]);
useEffect(() => { useEffect(() => {
if (isTopBarOpen || isBottomBarOpen) { if (isTopBarOpen || isBottomBarOpen) {
return; return;
+1 -3
View File
@@ -102,11 +102,9 @@ export function SearchPageView({
<option value={GetSearchSource.Annas_Archive}>Annas Archive</option> <option value={GetSearchSource.Annas_Archive}>Annas Archive</option>
</select> </select>
</div> </div>
<div className="lg:w-60"> <Button variant="secondary" type="submit" className="w-full lg:w-60">
<Button variant="secondary" type="submit">
Search Search
</Button> </Button>
</div>
</form> </form>
</div> </div>
+2 -6
View File
@@ -124,11 +124,9 @@ export default function SettingsPage() {
/> />
</div> </div>
</div> </div>
<div className="lg:w-60"> <Button variant="secondary" type="submit" className="w-full lg:w-60">
<Button variant="secondary" type="submit">
Submit Submit
</Button> </Button>
</div>
</form> </form>
</div> </div>
@@ -195,11 +193,9 @@ export default function SettingsPage() {
<option value="Australia/Sydney">Australia/Sydney</option> <option value="Australia/Sydney">Australia/Sydney</option>
</select> </select>
</div> </div>
<div className="lg:w-60"> <Button variant="secondary" type="submit" className="w-full lg:w-60">
<Button variant="secondary" type="submit">
Submit Submit
</Button> </Button>
</div>
</form> </form>
</div> </div>
+2 -31
View File
@@ -6,25 +6,8 @@ describe('getErrorMessage', () => {
expect(getErrorMessage(new Error('Boom'))).toBe('Boom'); expect(getErrorMessage(new Error('Boom'))).toBe('Boom');
}); });
it('prefers response.data.message over top-level message', () => { it('reads the top-level message from API error bodies', () => {
expect( expect(getErrorMessage({ code: 401, message: 'Unauthorized' })).toBe('Unauthorized');
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', () => { it('uses the fallback for null, empty, and unknown values', () => {
@@ -32,17 +15,5 @@ describe('getErrorMessage', () => {
expect(getErrorMessage(undefined, 'Fallback message')).toBe('Fallback message'); expect(getErrorMessage(undefined, 'Fallback message')).toBe('Fallback message');
expect(getErrorMessage({}, 'Fallback message')).toBe('Fallback message'); expect(getErrorMessage({}, 'Fallback message')).toBe('Fallback message');
expect(getErrorMessage({ message: ' ' }, 'Fallback message')).toBe('Fallback message'); expect(getErrorMessage({ message: ' ' }, 'Fallback message')).toBe('Fallback message');
expect(
getErrorMessage(
{
response: {
data: {
message: '',
},
},
},
'Fallback message'
)
).toBe('Fallback message');
}); });
}); });
+4 -17
View File
@@ -3,23 +3,10 @@ export function getErrorMessage(error: unknown, fallback = 'Unknown error'): str
return error.message; return error.message;
} }
if (typeof error === 'object' && error !== null) { if (typeof error === 'object' && error !== null && 'message' in error) {
const errorWithResponse = error as { const { message } = error as { message?: unknown };
message?: unknown; if (typeof message === 'string' && message.trim() !== '') {
response?: { return message;
data?: {
message?: unknown;
};
};
};
const responseMessage = errorWithResponse.response?.data?.message;
if (typeof responseMessage === 'string' && responseMessage.trim() !== '') {
return responseMessage;
}
if (typeof errorWithResponse.message === 'string' && errorWithResponse.message.trim() !== '') {
return errorWithResponse.message;
} }
} }