diff --git a/nevo_frontend/__tests__/errors.test.ts b/nevo_frontend/__tests__/errors.test.ts new file mode 100644 index 00000000..e4d7e7a3 --- /dev/null +++ b/nevo_frontend/__tests__/errors.test.ts @@ -0,0 +1,63 @@ +import { ApiError } from '@/lib/api-client'; +import { parseApiError } from '@/lib/errors'; + +describe('parseApiError', () => { + it('extracts message from axios response.data.message', () => { + const err = { + isAxiosError: true, + response: { data: { message: 'Invalid credentials' } }, + message: 'Request failed with status code 401', + }; + + expect(parseApiError(err)).toBe('Invalid credentials'); + }); + + it('extracts message from axios response.data.error', () => { + const err = { + isAxiosError: true, + response: { data: { error: 'Pool not found' } }, + message: 'Request failed with status code 404', + }; + + expect(parseApiError(err)).toBe('Pool not found'); + }); + + it('falls back to err.message for axios errors without response payload', () => { + const err = { + isAxiosError: true, + message: 'Network Error', + }; + + expect(parseApiError(err)).toBe('Network Error'); + }); + + it('extracts message from ApiError data.message', () => { + const err = new ApiError(400, 'Bad Request', { + message: 'Title is required', + }); + + expect(parseApiError(err)).toBe('Title is required'); + }); + + it('extracts message from ApiError data.error', () => { + const err = new ApiError(500, 'Internal Server Error', { + error: 'Database unavailable', + }); + + expect(parseApiError(err)).toBe('Database unavailable'); + }); + + it('falls back to ApiError.message when data has no message fields', () => { + const err = new ApiError(403, 'Forbidden'); + + expect(parseApiError(err)).toBe('Forbidden'); + }); + + it('returns fallback message for unknown errors', () => { + expect(parseApiError(null)).toBe('Something went wrong. Please try again.'); + expect(parseApiError(undefined)).toBe( + 'Something went wrong. Please try again.' + ); + expect(parseApiError({})).toBe('Something went wrong. Please try again.'); + }); +}); diff --git a/nevo_frontend/app/login/page.tsx b/nevo_frontend/app/login/page.tsx index 19d4e0e8..5c27fa71 100644 --- a/nevo_frontend/app/login/page.tsx +++ b/nevo_frontend/app/login/page.tsx @@ -5,13 +5,19 @@ import { useRouter, useSearchParams } from 'next/navigation'; import Link from 'next/link'; import { useWalletStore } from '@/src/store/walletStore'; import { apiClient } from '@/lib/api-client'; -import { connect, signWithWallet, getPublicKey } from '@/app/stellar-wallets-kit'; +import { parseApiError } from '@/lib/errors'; +import { + connect, + signWithWallet, + getPublicKey, +} from '@/app/stellar-wallets-kit'; import { Spinner } from '@/components/Spinner'; function LoginPageContent() { const router = useRouter(); const searchParams = useSearchParams(); - const { isAuthenticated, loading, initialize, setAccessToken } = useWalletStore(); + const { isAuthenticated, loading, initialize, setAccessToken } = + useWalletStore(); const [accepted, setAccepted] = useState(false); const [authLoading, setAuthLoading] = useState(false); const [error, setError] = useState(null); @@ -39,25 +45,31 @@ function LoginPageContent() { } // Get challenge nonce - const challenge = await apiClient.get<{ nonce: string }>('/auth/challenge', { - params: { publicKey }, - }); + const challenge = await apiClient.get<{ nonce: string }>( + '/auth/challenge', + { + params: { publicKey }, + } + ); // Sign the nonce const signature = await signWithWallet(challenge.nonce); // Verify and get access token - const authResult = await apiClient.post<{ accessToken: string }>('/auth/verify', { - publicKey, - signature, - message: challenge.nonce, - }); + const authResult = await apiClient.post<{ accessToken: string }>( + '/auth/verify', + { + publicKey, + signature, + message: challenge.nonce, + } + ); // Set the access token in the store setAccessToken(authResult.accessToken); }); } catch (err) { - setError(err instanceof Error ? err.message : 'Authentication failed'); + setError(parseApiError(err)); } finally { setAuthLoading(false); } @@ -110,7 +122,9 @@ function LoginPageContent() { disabled={authLoading} className="w-full rounded-full bg-brand-600 px-6 py-3 text-sm font-medium text-white hover:bg-brand-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed" > - {authLoading ? 'Authenticating...' : 'Connect Wallet & Sign In'} + {authLoading + ? 'Authenticating...' + : 'Connect Wallet & Sign In'} {error && (

diff --git a/nevo_frontend/app/pools/[id]/page.tsx b/nevo_frontend/app/pools/[id]/page.tsx index 605db4b4..ea0c0ab1 100644 --- a/nevo_frontend/app/pools/[id]/page.tsx +++ b/nevo_frontend/app/pools/[id]/page.tsx @@ -126,9 +126,8 @@ export default function PoolDetailPage() { await fetchPool(Number(pool.id)); } catch (err: unknown) { - const error = err as Error; - toast(error.message || 'Failed to close pool', 'error'); - console.error(error); + toast(parseApiError(err), 'error'); + console.error(err); } finally { setIsClosing(false); } diff --git a/nevo_frontend/app/pools/new/page.tsx b/nevo_frontend/app/pools/new/page.tsx index 1d77110c..99194478 100644 --- a/nevo_frontend/app/pools/new/page.tsx +++ b/nevo_frontend/app/pools/new/page.tsx @@ -357,8 +357,7 @@ function CreatePoolPageContent() { setSubmitted(true); } catch (error) { - const err = error as Error; - setErrors({ submit: err?.message || 'Failed to submit transaction.' }); + setErrors({ submit: parseApiError(error) }); } finally { setSubmitting(false); setSubmitStep('idle'); diff --git a/nevo_frontend/app/profile/page.tsx b/nevo_frontend/app/profile/page.tsx index 0ab5fada..a5669e3b 100644 --- a/nevo_frontend/app/profile/page.tsx +++ b/nevo_frontend/app/profile/page.tsx @@ -12,6 +12,7 @@ import { type ApiProfile, type ApiDonation, } from '@/lib/api-client'; +import { parseApiError } from '@/lib/errors'; import { toast } from '@/components/Toast'; interface UserPreferences { @@ -97,9 +98,7 @@ export default function ProfilePage() { toast('Profile updated successfully'); setIsEditingProfile(false); } catch (err) { - const msg = - err instanceof Error ? err.message : 'Failed to update profile'; - toast(msg, 'error'); + toast(parseApiError(err), 'error'); } }; diff --git a/nevo_frontend/components/DonateModal.tsx b/nevo_frontend/components/DonateModal.tsx index ebedce3c..19c819ab 100644 --- a/nevo_frontend/components/DonateModal.tsx +++ b/nevo_frontend/components/DonateModal.tsx @@ -11,6 +11,7 @@ import { Server } from '@stellar/stellar-sdk/rpc'; import { useWalletStore } from '@/src/store/walletStore'; import { useDonationsStore } from '@/src/store/donationsStore'; import { contractService } from '@/lib/contract-service'; +import { parseApiError } from '@/lib/errors'; import type { Pool } from '@/src/store/poolsStore'; import { WalletAddress } from './WalletAddress'; @@ -139,7 +140,7 @@ export function DonateModal({ onDonationSuccess?.(); } catch (err) { console.error(err); - const msg = err instanceof Error ? err.message : 'Transaction failed.'; + const msg = parseApiError(err); if ( msg.toLowerCase().includes('cancel') || msg.toLowerCase().includes('reject') || diff --git a/nevo_frontend/lib/api-client.ts b/nevo_frontend/lib/api-client.ts index e9f4f510..b9d75a21 100644 --- a/nevo_frontend/lib/api-client.ts +++ b/nevo_frontend/lib/api-client.ts @@ -79,7 +79,7 @@ export class ApiClient { constructor( baseURL: string = '', - defaultTimeout: number = 15000, + defaultTimeout: number = 10_000, rateLimit: Partial = DEFAULT_RATE_LIMIT_OPTIONS ) { this.baseURL = baseURL || env.NEXT_PUBLIC_API_BASE_URL; diff --git a/nevo_frontend/lib/errors.ts b/nevo_frontend/lib/errors.ts new file mode 100644 index 00000000..2a014ff5 --- /dev/null +++ b/nevo_frontend/lib/errors.ts @@ -0,0 +1,80 @@ +import { ApiError } from './api-client'; + +const FALLBACK_MESSAGE = 'Something went wrong. Please try again.'; + +interface AxiosErrorResponseData { + message?: unknown; + error?: unknown; +} + +interface AxiosLikeError { + isAxiosError?: boolean; + response?: { + data?: AxiosErrorResponseData; + }; + message?: unknown; +} + +function isAxiosError(err: unknown): err is AxiosLikeError { + if (typeof err !== 'object' || err === null) { + return false; + } + + const candidate = err as AxiosLikeError; + if (candidate.isAxiosError === true) { + return true; + } + + return ( + 'response' in candidate && + typeof candidate.response === 'object' && + candidate.response !== null + ); +} + +function extractString(value: unknown): string | undefined { + return typeof value === 'string' && value.length > 0 ? value : undefined; +} + +function extractFromPayload(payload: unknown): string | undefined { + if (typeof payload !== 'object' || payload === null) { + return undefined; + } + + const record = payload as AxiosErrorResponseData; + return extractString(record.message) ?? extractString(record.error); +} + +export function parseApiError(err: unknown): string { + if (isAxiosError(err)) { + const fromResponse = extractFromPayload(err.response?.data); + if (fromResponse) { + return fromResponse; + } + } + + if (err instanceof ApiError) { + const fromData = extractFromPayload(err.data); + if (fromData) { + return fromData; + } + } + + if (err instanceof Error && err.message) { + return err.message; + } + + if ( + typeof err === 'object' && + err !== null && + 'message' in err && + typeof (err as { message: unknown }).message === 'string' + ) { + const message = (err as { message: string }).message; + if (message.length > 0) { + return message; + } + } + + return FALLBACK_MESSAGE; +} diff --git a/nevo_frontend/src/store/poolsStore.ts b/nevo_frontend/src/store/poolsStore.ts index 8da5d320..11ebe09b 100644 --- a/nevo_frontend/src/store/poolsStore.ts +++ b/nevo_frontend/src/store/poolsStore.ts @@ -1,6 +1,7 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; import { apiClient } from '@/lib/api-client'; +import { parseApiError } from '@/lib/errors'; export type PoolStatus = 'Active' | 'Completed'; export type SortOption = 'newest' | 'most_raised' | 'goal_low'; @@ -80,25 +81,28 @@ export const usePoolsStore = create()( // Merge provided filters with current store filters const currentFilters = get().filters; const mergedFilters = { ...currentFilters, ...filters }; - + // Build params object const params: Record = {}; if (mergedFilters.search) params.search = mergedFilters.search; - if (mergedFilters.categories.length > 0) params.categories = mergedFilters.categories; - if (mergedFilters.statuses.length > 0) params.statuses = mergedFilters.statuses; - if (mergedFilters.minTarget != null) params.minTarget = mergedFilters.minTarget; - if (mergedFilters.maxTarget != null) params.maxTarget = mergedFilters.maxTarget; + if (mergedFilters.categories.length > 0) + params.categories = mergedFilters.categories; + if (mergedFilters.statuses.length > 0) + params.statuses = mergedFilters.statuses; + if (mergedFilters.minTarget != null) + params.minTarget = mergedFilters.minTarget; + if (mergedFilters.maxTarget != null) + params.maxTarget = mergedFilters.maxTarget; if (mergedFilters.dateFrom) params.dateFrom = mergedFilters.dateFrom; if (mergedFilters.dateTo) params.dateTo = mergedFilters.dateTo; if (get().sortBy) params.sortBy = get().sortBy; - + const data = await apiClient.get('/pools', { params }); get().setPools(data); set({ loading: false }); } catch (error) { - const err = error as Error; set({ - error: err?.message || 'Failed to fetch pools', + error: parseApiError(error) || 'Failed to fetch pools', loading: false, }); } @@ -111,9 +115,8 @@ export const usePoolsStore = create()( set({ currentPool: data, poolLoading: false }); return data; } catch (error) { - const err = error as Error; set({ - error: err?.message || 'Failed to fetch pool', + error: parseApiError(error) || 'Failed to fetch pool', poolLoading: false, }); return null;