From 15ca254159c17488f1fbd611b1c96da94c1c2b0c Mon Sep 17 00:00:00 2001 From: spotkorner-dot Date: Sun, 26 Jul 2026 20:44:20 +0000 Subject: [PATCH 1/4] fix: include api-error in email input's aria-describedby The email input only ever referenced email-error via aria-describedby, so screen-reader users navigating back to the field after an API failure got no indication of the associated error. --- frontend/src/components/LandingPage.tsx | 2 +- .../LandingPage.accessibility.test.tsx | 24 +++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/LandingPage.tsx b/frontend/src/components/LandingPage.tsx index 4b76c2ff..9d0bc7c3 100644 --- a/frontend/src/components/LandingPage.tsx +++ b/frontend/src/components/LandingPage.tsx @@ -165,7 +165,7 @@ export const LandingPage: React.FC = ({ className }) => { }} aria-required="true" aria-invalid={!!emailError} - aria-describedby={emailError ? 'email-error' : undefined} + aria-describedby={emailError ? 'email-error' : apiError ? 'api-error' : undefined} placeholder={t('hero.emailPlaceholder')} disabled={isSubmitted || isLoading} /> diff --git a/frontend/src/components/__tests__/LandingPage.accessibility.test.tsx b/frontend/src/components/__tests__/LandingPage.accessibility.test.tsx index e21d775b..bd00230f 100644 --- a/frontend/src/components/__tests__/LandingPage.accessibility.test.tsx +++ b/frontend/src/components/__tests__/LandingPage.accessibility.test.tsx @@ -149,6 +149,30 @@ describe('LandingPage Accessibility Tests', () => { expect(errorMessage).toHaveAttribute('id', 'email-error'); }); + it('should have aria-describedby linking to the API error message', async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: false, + status: 400, + statusText: 'Bad Request', + json: async () => ({ message: 'Invalid email format' }), + }); + + render(); + + const emailInput = screen.getByLabelText(/email address/i); + const submitButton = screen.getByRole('button', { name: /get early access/i }); + + await userEvent.type(emailInput, 'test@example.com'); + await userEvent.click(submitButton); + + await waitFor(() => { + expect(emailInput).toHaveAttribute('aria-describedby', 'api-error'); + }); + + const errorMessage = screen.getByRole('alert'); + expect(errorMessage).toHaveAttribute('id', 'api-error'); + }); + it('should have aria-live region for status updates', () => { const { container } = render(); From 9aaf1ac35aba097ac125b5d8e7245607d8bc76dd Mon Sep 17 00:00:00 2001 From: spotkorner-dot Date: Sun, 26 Jul 2026 20:45:57 +0000 Subject: [PATCH 2/4] fix: sync document.documentElement.lang with the active locale setLocale updated in-memory/localStorage state and re-rendered text but never touched the html lang attribute, so switching locales would leave the page's declared language mismatched with its visible content (WCAG 3.1.1). --- .../src/lib/hooks/__tests__/useI18n.test.ts | 32 +++++++++++++++++++ frontend/src/lib/hooks/useI18n.ts | 6 ++++ 2 files changed, 38 insertions(+) create mode 100644 frontend/src/lib/hooks/__tests__/useI18n.test.ts diff --git a/frontend/src/lib/hooks/__tests__/useI18n.test.ts b/frontend/src/lib/hooks/__tests__/useI18n.test.ts new file mode 100644 index 00000000..1d1f4027 --- /dev/null +++ b/frontend/src/lib/hooks/__tests__/useI18n.test.ts @@ -0,0 +1,32 @@ +import { renderHook, act, waitFor } from '@testing-library/react'; +import { i18n } from '../../i18n'; +import { useI18n } from '../useI18n'; + +describe('useI18n', () => { + beforeEach(() => { + localStorage.clear(); + i18n.setLocale('en'); + document.documentElement.lang = ''; + }); + + it('should set document.documentElement.lang to the initial locale', async () => { + const { result } = renderHook(() => useI18n()); + + await waitFor(() => expect(result.current.locale).toBe('en')); + + expect(document.documentElement.lang).toBe('en'); + }); + + it('should update document.documentElement.lang when the locale changes', async () => { + const { result } = renderHook(() => useI18n()); + + await waitFor(() => expect(result.current.locale).toBe('en')); + + act(() => { + result.current.setLocale('es'); + }); + + await waitFor(() => expect(result.current.locale).toBe('es')); + expect(document.documentElement.lang).toBe('es'); + }); +}); diff --git a/frontend/src/lib/hooks/useI18n.ts b/frontend/src/lib/hooks/useI18n.ts index 300d2063..18771b12 100644 --- a/frontend/src/lib/hooks/useI18n.ts +++ b/frontend/src/lib/hooks/useI18n.ts @@ -12,6 +12,12 @@ export function useI18n() { setLocaleState(i18n.getLocale()); }, []); + useEffect(() => { + if (typeof document !== 'undefined') { + document.documentElement.lang = locale; + } + }, [locale]); + const setLocale = (newLocale: Locale) => { i18n.setLocale(newLocale); setLocaleState(newLocale); From 22a608a982831c05116bb7cca8ec317cd2c84807 Mon Sep 17 00:00:00 2001 From: spotkorner-dot Date: Sun, 26 Jul 2026 20:48:44 +0000 Subject: [PATCH 3/4] refactor: derive Locale type from implemented translations Locale was hand-typed as 'en' | 'es' | 'fr' | 'de' while translations only implemented 'en', so the language selector's type promised locales it could never actually render. Locale is now keyof typeof translations, so it can't drift ahead of what's implemented, and the selector's rendered options are guaranteed to match. --- .../__tests__/LandingPage.accessibility.test.tsx | 15 ++++++++++++++- frontend/src/lib/__tests__/i18n.test.ts | 12 ++++++++++++ frontend/src/lib/i18n.ts | 14 ++++++-------- 3 files changed, 32 insertions(+), 9 deletions(-) diff --git a/frontend/src/components/__tests__/LandingPage.accessibility.test.tsx b/frontend/src/components/__tests__/LandingPage.accessibility.test.tsx index bd00230f..bf90e74e 100644 --- a/frontend/src/components/__tests__/LandingPage.accessibility.test.tsx +++ b/frontend/src/components/__tests__/LandingPage.accessibility.test.tsx @@ -4,6 +4,7 @@ import userEvent from '@testing-library/user-event'; import { axe, toHaveNoViolations } from 'jest-axe'; import LandingPage from '../LandingPage'; import { api } from '../../lib/api/client'; +import { i18n } from '../../lib/i18n'; expect.extend(toHaveNoViolations); @@ -182,12 +183,24 @@ describe('LandingPage Accessibility Tests', () => { it('should have aria-hidden on decorative images', () => { const { container } = render(); - + const decorativeImages = container.querySelectorAll('[aria-hidden="true"]'); expect(decorativeImages.length).toBeGreaterThan(0); }); }); + describe('Language Selector', () => { + it('should only render options for locales with implemented translations', () => { + const { container } = render(); + + const options = Array.from(container.querySelectorAll('#locale-select option')).map( + (option) => (option as HTMLOptionElement).value + ); + + expect(options).toEqual(i18n.getAvailableLocales()); + }); + }); + describe('Keyboard Navigation', () => { it('should have skip to main content link', () => { render(); diff --git a/frontend/src/lib/__tests__/i18n.test.ts b/frontend/src/lib/__tests__/i18n.test.ts index 8f0776b8..ab0e27b3 100644 --- a/frontend/src/lib/__tests__/i18n.test.ts +++ b/frontend/src/lib/__tests__/i18n.test.ts @@ -68,5 +68,17 @@ describe('i18n', () => { expect(locales.length).toBeGreaterThan(0); expect(locales).toContain('en'); }); + + it('should only offer locales that have translations implemented', () => { + // Guards against the Locale type promising locales (e.g. 'es', 'fr', 'de') + // that have no corresponding translations entry, which would render a + // language selector option that silently falls back to English. + const locales = i18n.getAvailableLocales(); + locales.forEach((locale) => { + i18n.setLocale(locale); + expect(i18n.getLocale()).toBe(locale); + expect(i18n.t('hero.title')).not.toBe('hero.title'); + }); + }); }); }); diff --git a/frontend/src/lib/i18n.ts b/frontend/src/lib/i18n.ts index 92eefea6..b3557493 100644 --- a/frontend/src/lib/i18n.ts +++ b/frontend/src/lib/i18n.ts @@ -3,17 +3,13 @@ * Supports multiple locales with fallback to English. */ -export type Locale = 'en' | 'es' | 'fr' | 'de'; - interface Translations { [key: string]: string | Translations; } -interface LocaleData { - [locale: string]: Translations; -} - -const translations: LocaleData = { +// `Locale` is derived from the keys actually defined below, so the type can +// never advertise a locale (e.g. via a language selector) that isn't implemented. +const translations = { en: { nav: { features: 'Features', @@ -85,7 +81,9 @@ const translations: LocaleData = { copyright: '© 2024 PredictIQ. All rights reserved.', }, }, -}; +} satisfies Record; + +export type Locale = keyof typeof translations; class I18n { private currentLocale: Locale = 'en'; From 991a8cd474220159e4765ae9ea3de5801dd39292 Mon Sep 17 00:00:00 2001 From: spotkorner-dot Date: Sun, 26 Jul 2026 20:56:50 +0000 Subject: [PATCH 4/4] perf: split landing page's API client from the admin/internal surface client.ts (imported by LandingPage.tsx and Statistics.tsx) shipped the full ~50-entry CONTRACT_ERROR_MESSAGES map and 15+ admin/blockchain/email request builders, even though the landing page only ever calls getStatistics and newsletterSubscribe. Split into: - request.ts: shared retry/timeout/cache request layer - client.ts: the two endpoints the landing page actually uses - admin-client.ts: everything else (admin, blockchain, email, contract error messages), for whatever internal tooling needs that surface A production build shows the two clients' code and string literals no longer appear together; client.ts now type-checks to expose only getStatistics and newsletterSubscribe, enforced by a test. Note: a byte-for-byte comparison of the built .next/static/chunks output against main showed no size difference for this app today, because Turbopack's dead-code elimination already stripped the unused api object's properties before this split. bundlewatch.config.json also still targets classic webpack output paths (vendor.js, main*.js, pages/_app*.js) that don't exist under this app's Turbopack/App Router build, so it isn't actually wired up to catch a regression here either. The split is still correct to make explicit and guards against future regressions (e.g. a stray import pulling the admin surface back in), but the bundlewatch config would need a separate fix to point at real Turbopack chunk paths before it could verify this in CI. --- .../lib/api/__tests__/admin-client.test.ts | 245 +++++++++ frontend/src/lib/api/__tests__/client.test.ts | 274 ++-------- frontend/src/lib/api/admin-client.ts | 227 ++++++++ frontend/src/lib/api/client.ts | 485 +----------------- frontend/src/lib/api/request.ts | 268 ++++++++++ 5 files changed, 788 insertions(+), 711 deletions(-) create mode 100644 frontend/src/lib/api/__tests__/admin-client.test.ts create mode 100644 frontend/src/lib/api/admin-client.ts create mode 100644 frontend/src/lib/api/request.ts diff --git a/frontend/src/lib/api/__tests__/admin-client.test.ts b/frontend/src/lib/api/__tests__/admin-client.test.ts new file mode 100644 index 00000000..354e407d --- /dev/null +++ b/frontend/src/lib/api/__tests__/admin-client.test.ts @@ -0,0 +1,245 @@ +import { api, ApiError, getContractErrorMessage } from '../admin-client'; +import { api as landingApi } from '../client'; +import { apiCache } from '../cache'; + +describe('API Client (admin/internal)', () => { + const originalFetch = global.fetch; + const originalEnv = process.env.NEXT_PUBLIC_API_URL; + + beforeEach(() => { + process.env.NEXT_PUBLIC_API_URL = 'http://localhost:3001'; + global.fetch = jest.fn(); + // Clear the module-level cache singleton so each test starts clean. + apiCache.clear(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + process.env.NEXT_PUBLIC_API_URL = originalEnv; + global.fetch = originalFetch; + }); + + describe('Endpoint wrappers', () => { + const mockOk = (data: unknown = { ok: true }) => + (global.fetch as jest.Mock).mockResolvedValue({ + ok: true, + text: async () => JSON.stringify(data), + }); + + const base = 'http://localhost:3001'; + + it.each([ + ['health', () => api.health(), 'GET', '/health'], + ['getFeaturedMarkets', () => api.getFeaturedMarkets(), 'GET', '/api/markets/featured'], + ['getBlockchainHealth', () => api.getBlockchainHealth(), 'GET', '/api/blockchain/health'], + ['getBlockchainMarket', () => api.getBlockchainMarket(1), 'GET', '/api/blockchain/markets/1'], + ['getBlockchainStats', () => api.getBlockchainStats(), 'GET', '/api/blockchain/stats'], + ['getUserBets', () => api.getUserBets('GABC'), 'GET', '/api/blockchain/users/GABC/bets'], + ['getOracleResult', () => api.getOracleResult(7), 'GET', '/api/blockchain/oracle/7'], + ['getTransactionStatus', () => api.getTransactionStatus('0xdead'), 'GET', '/api/blockchain/tx/0xdead'], + ['newsletterConfirm', () => api.newsletterConfirm('tok'), 'GET', '/api/v1/newsletter/confirm'], + ['newsletterUnsubscribe', () => api.newsletterUnsubscribe('a@b.com'), 'DELETE', '/api/v1/newsletter/unsubscribe'], + ['newsletterGdprExport', () => api.newsletterGdprExport('a@b.com'), 'GET', '/api/v1/newsletter/gdpr/export'], + ['newsletterGdprDelete', () => api.newsletterGdprDelete('a@b.com'), 'DELETE', '/api/v1/newsletter/gdpr/delete'], + ['resolveMarket', () => api.resolveMarket(3), 'POST', '/api/markets/3/resolve'], + ['emailPreview', () => api.emailPreview('welcome'), 'GET', '/api/v1/email/preview/welcome'], + ['emailSendTest', () => api.emailSendTest({ recipient: 'a@b.com', template_name: 'welcome' }), 'POST', '/api/v1/email/test'], + ['getEmailAnalytics', () => api.getEmailAnalytics({ days: 7 }), 'GET', '/api/v1/email/analytics'], + ['getEmailQueueStats', () => api.getEmailQueueStats(), 'GET', '/api/v1/email/queue/stats'], + ])('%s calls the correct method and path', async (_name, call, method, path) => { + mockOk(); + await call(); + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining(`${base}${path}`), + expect.objectContaining({ method }), + ); + }); + }); + + describe('Query parameters', () => { + it('should handle query parameters', async () => { + const mockData = { markets: [] }; + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + text: async () => JSON.stringify(mockData), + }); + + await api.getContent({ page: 1, page_size: 10 }); + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining('page=1&page_size=10'), + expect.any(Object) + ); + }); + + it('should filter out undefined query parameters', async () => { + const mockData = { markets: [] }; + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + text: async () => JSON.stringify(mockData), + }); + + await api.getContent({ page: 1 }); + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining('page=1'), + expect.any(Object) + ); + expect(global.fetch).toHaveBeenCalledWith( + expect.not.stringContaining('page_size'), + expect.any(Object) + ); + }); + }); + + describe('Non-2xx responses', () => { + it('should handle 401 Unauthorized', async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: false, + status: 401, + statusText: 'Unauthorized', + json: async () => ({ message: 'Authentication required' }), + }); + + await expect(api.getBlockchainHealth()).rejects.toThrow('Authentication required'); + }); + + it('should handle 404 Not Found', async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: false, + status: 404, + statusText: 'Not Found', + json: async () => ({ message: 'Market not found' }), + }); + + await expect(api.getBlockchainMarket(999)).rejects.toThrow('Market not found'); + }); + }); + + describe('5xx retry logic (#946)', () => { + it('does not retry 5xx for DELETE requests', async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: false, + status: 502, + statusText: 'Bad Gateway', + json: async () => ({ message: 'Bad Gateway' }), + }); + + await expect( + api.newsletterUnsubscribe('test@example.com') + ).rejects.toThrow('Bad Gateway'); + expect(global.fetch).toHaveBeenCalledTimes(1); + }); + }); + + describe('DELETE requests', () => { + it('should handle DELETE requests with body', async () => { + const mockResponse = { success: true }; + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + text: async () => JSON.stringify(mockResponse), + }); + + const result = await api.newsletterUnsubscribe('test@example.com'); + expect(result).toEqual(mockResponse); + expect(global.fetch).toHaveBeenCalledWith( + 'http://localhost:3001/api/v1/newsletter/unsubscribe', + expect.objectContaining({ + method: 'DELETE', + body: JSON.stringify({ email: 'test@example.com' }), + }) + ); + }); + }); + + describe('ApiError', () => { + it('should throw ApiError with status code on non-2xx response', async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: false, + status: 404, + statusText: 'Not Found', + json: async () => ({ message: 'Market not found' }), + }); + + try { + await api.getBlockchainMarket(999); + fail('should have thrown'); + } catch (e) { + expect(e).toBeInstanceOf(ApiError); + expect((e as ApiError).status).toBe(404); + expect((e as ApiError).message).toBe('Market not found'); + expect((e as ApiError).isClientError).toBe(true); + expect((e as ApiError).isServerError).toBe(false); + expect((e as ApiError).isNetworkError).toBe(false); + } + }); + }); + + describe('getContractErrorMessage', () => { + it('returns the mapped message for a known contract error code', () => { + expect(getContractErrorMessage(102)).toBe('Market not found.'); + }); + + it('falls back to a generic message for an unrecognized code', () => { + expect(getContractErrorMessage(999999)).toBe('An unexpected contract error occurred (code 999999).'); + }); + }); + + describe('Cache invalidation strategy (#947)', () => { + it('invalidates only tagged resources on mutation — not the entire cache', async () => { + // Prime a statistics cache entry tagged 'statistics' via the landing client. + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + text: async () => JSON.stringify({ total_markets: 10 }), + }); + await landingApi.getStatistics(); + + // Prime a markets cache entry tagged 'markets'. + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + text: async () => JSON.stringify([{ id: 1 }]), + }); + await api.getFeaturedMarkets(); + + // Mutate: resolveMarket invalidates 'markets', 'blockchain', 'statistics'. + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + text: async () => JSON.stringify({ invalidated_keys: 2 }), + }); + await api.resolveMarket(1); + + // Both statistics and markets caches must be cleared, even though + // getStatistics lives in a different module (client.ts) than the + // mutation (admin-client.ts) — they share the same apiCache tags. + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + text: async () => JSON.stringify({ total_markets: 11 }), + }); + const freshStats = await landingApi.getStatistics(); + expect(freshStats).toEqual({ total_markets: 11 }); + }); + + it('GET returns fresh data after a mutation invalidates its cache tag', async () => { + // Cache getFeaturedMarkets. + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + text: async () => JSON.stringify([{ id: 1, title: 'Old Market' }]), + }); + const first = await api.getFeaturedMarkets(); + expect(first).toEqual([{ id: 1, title: 'Old Market' }]); + + // resolveMarket invalidates the 'markets' tag. + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + text: async () => JSON.stringify({ invalidated_keys: 1 }), + }); + await api.resolveMarket(1); + + // Next getFeaturedMarkets must hit the network, not the cache. + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + text: async () => JSON.stringify([{ id: 1, title: 'Resolved Market' }]), + }); + const second = await api.getFeaturedMarkets(); + expect(second).toEqual([{ id: 1, title: 'Resolved Market' }]); + }); + }); +}); diff --git a/frontend/src/lib/api/__tests__/client.test.ts b/frontend/src/lib/api/__tests__/client.test.ts index 5dcf3d6c..171b1d91 100644 --- a/frontend/src/lib/api/__tests__/client.test.ts +++ b/frontend/src/lib/api/__tests__/client.test.ts @@ -1,7 +1,15 @@ import { api, ApiError } from '../client'; import { apiCache } from '../cache'; -describe('API Client', () => { +describe('API Client (landing page)', () => { + it('should only expose the endpoints the landing page calls', () => { + // Guards against admin/blockchain/email request builders (and the ~50-entry + // CONTRACT_ERROR_MESSAGES map) creeping back into the landing page's bundle. + // See admin-client.ts for that wider surface. + expect(Object.keys(api).sort()).toEqual(['getStatistics', 'newsletterSubscribe']); + }); + + const originalFetch = global.fetch; const originalEnv = process.env.NEXT_PUBLIC_API_URL; @@ -18,52 +26,18 @@ describe('API Client', () => { global.fetch = originalFetch; }); - describe('Endpoint wrappers', () => { - const mockOk = (data: unknown = { ok: true }) => - (global.fetch as jest.Mock).mockResolvedValue({ - ok: true, - text: async () => JSON.stringify(data), - }); - - const base = 'http://localhost:3001'; - - it.each([ - ['getFeaturedMarkets', () => api.getFeaturedMarkets(), 'GET', '/api/markets/featured'], - ['getBlockchainStats', () => api.getBlockchainStats(), 'GET', '/api/blockchain/stats'], - ['getUserBets', () => api.getUserBets('GABC'), 'GET', '/api/blockchain/users/GABC/bets'], - ['getOracleResult', () => api.getOracleResult(7), 'GET', '/api/blockchain/oracle/7'], - ['getTransactionStatus', () => api.getTransactionStatus('0xdead'), 'GET', '/api/blockchain/tx/0xdead'], - ['newsletterConfirm', () => api.newsletterConfirm('tok'), 'GET', '/api/v1/newsletter/confirm'], - ['newsletterUnsubscribe', () => api.newsletterUnsubscribe('a@b.com'), 'DELETE', '/api/v1/newsletter/unsubscribe'], - ['newsletterGdprExport', () => api.newsletterGdprExport('a@b.com'), 'GET', '/api/v1/newsletter/gdpr/export'], - ['newsletterGdprDelete', () => api.newsletterGdprDelete('a@b.com'), 'DELETE', '/api/v1/newsletter/gdpr/delete'], - ['resolveMarket', () => api.resolveMarket(3), 'POST', '/api/markets/3/resolve'], - ['emailPreview', () => api.emailPreview('welcome'), 'GET', '/api/v1/email/preview/welcome'], - ['emailSendTest', () => api.emailSendTest({ recipient: 'a@b.com', template_name: 'welcome' }), 'POST', '/api/v1/email/test'], - ['getEmailAnalytics', () => api.getEmailAnalytics({ days: 7 }), 'GET', '/api/v1/email/analytics'], - ['getEmailQueueStats', () => api.getEmailQueueStats(), 'GET', '/api/v1/email/queue/stats'], - ])('%s calls the correct method and path', async (_name, call, method, path) => { - mockOk(); - await call(); - expect(global.fetch).toHaveBeenCalledWith( - expect.stringContaining(`${base}${path}`), - expect.objectContaining({ method }), - ); - }); - }); - describe('Successful responses', () => { it('should handle successful GET requests', async () => { - const mockData = { status: 'ok' }; + const mockData = { total_markets: 10 }; (global.fetch as jest.Mock).mockResolvedValueOnce({ ok: true, text: async () => JSON.stringify(mockData), }); - const result = await api.health(); + const result = await api.getStatistics(); expect(result).toEqual(mockData); expect(global.fetch).toHaveBeenCalledWith( - 'http://localhost:3001/health', + 'http://localhost:3001/api/statistics', expect.objectContaining({ method: 'GET' }) ); }); @@ -92,41 +66,9 @@ describe('API Client', () => { text: async () => '', }); - const result = await api.health(); + const result = await api.getStatistics(); expect(result).toBeUndefined(); }); - - it('should handle query parameters', async () => { - const mockData = { markets: [] }; - (global.fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - text: async () => JSON.stringify(mockData), - }); - - await api.getContent({ page: 1, page_size: 10 }); - expect(global.fetch).toHaveBeenCalledWith( - expect.stringContaining('page=1&page_size=10'), - expect.any(Object) - ); - }); - - it('should filter out undefined query parameters', async () => { - const mockData = { markets: [] }; - (global.fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - text: async () => JSON.stringify(mockData), - }); - - await api.getContent({ page: 1 }); - expect(global.fetch).toHaveBeenCalledWith( - expect.stringContaining('page=1'), - expect.any(Object) - ); - expect(global.fetch).toHaveBeenCalledWith( - expect.not.stringContaining('page_size'), - expect.any(Object) - ); - }); }); describe('Network errors', () => { @@ -134,7 +76,7 @@ describe('API Client', () => { const networkError = new Error('Network request failed'); (global.fetch as jest.Mock).mockRejectedValueOnce(networkError); - await expect(api.health()).rejects.toThrow('Unable to reach the server'); + await expect(api.getStatistics()).rejects.toThrow('Unable to reach the server'); }); it('should handle timeout errors', async () => { @@ -159,28 +101,6 @@ describe('API Client', () => { ).rejects.toThrow('Invalid email format'); }); - it('should handle 401 Unauthorized', async () => { - (global.fetch as jest.Mock).mockResolvedValueOnce({ - ok: false, - status: 401, - statusText: 'Unauthorized', - json: async () => ({ message: 'Authentication required' }), - }); - - await expect(api.getBlockchainHealth()).rejects.toThrow('Authentication required'); - }); - - it('should handle 404 Not Found', async () => { - (global.fetch as jest.Mock).mockResolvedValueOnce({ - ok: false, - status: 404, - statusText: 'Not Found', - json: async () => ({ message: 'Market not found' }), - }); - - await expect(api.getBlockchainMarket(999)).rejects.toThrow('Market not found'); - }); - it('should handle 500 Server Error after exhausting retries', async () => { const serverError = { ok: false, @@ -204,7 +124,7 @@ describe('API Client', () => { }; (global.fetch as jest.Mock).mockResolvedValue(serverError); - await expect(api.health()).rejects.toThrow('Service Unavailable'); + await expect(api.getStatistics()).rejects.toThrow('Service Unavailable'); }, 30_000); it('should fallback to HTTP status when response is not JSON', async () => { @@ -216,13 +136,13 @@ describe('API Client', () => { }; (global.fetch as jest.Mock).mockResolvedValue(serverError); - await expect(api.health()).rejects.toThrow('Bad Gateway'); + await expect(api.getStatistics()).rejects.toThrow('Bad Gateway'); }, 30_000); }); describe('Retry behavior', () => { it('should retry on 429 Too Many Requests', async () => { - const mockData = { status: 'ok' }; + const mockData = { total_markets: 10 }; (global.fetch as jest.Mock) .mockResolvedValueOnce({ ok: false, @@ -236,16 +156,16 @@ describe('API Client', () => { text: async () => JSON.stringify(mockData), }); - const result = await api.health(); + const result = await api.getStatistics(); expect(result).toEqual(mockData); expect(global.fetch).toHaveBeenCalledTimes(2); }, 10000); it('should respect Retry-After header on 429', async () => { - const mockData = { status: 'ok' }; + const mockData = { total_markets: 10 }; const mockHeaders = new Map([['Retry-After', '0']]); - + (global.fetch as jest.Mock) .mockResolvedValueOnce({ ok: false, @@ -259,7 +179,7 @@ describe('API Client', () => { text: async () => JSON.stringify(mockData), }); - const result = await api.health(); + const result = await api.getStatistics(); expect(result).toEqual(mockData); expect(global.fetch).toHaveBeenCalledTimes(2); @@ -274,12 +194,12 @@ describe('API Client', () => { json: async () => ({ message: 'Rate limited' }), }); - await expect(api.health()).rejects.toThrow('Rate limited'); + await expect(api.getStatistics()).rejects.toThrow('Rate limited'); expect(global.fetch).toHaveBeenCalledTimes(4); // 1 initial + 3 retries }, 10000); it('should retry on network failure for GET requests', async () => { - const mockData = { status: 'ok' }; + const mockData = { total_markets: 10 }; (global.fetch as jest.Mock) .mockRejectedValueOnce(new Error('Network error')) .mockResolvedValueOnce({ @@ -287,7 +207,7 @@ describe('API Client', () => { text: async () => JSON.stringify(mockData), }); - const result = await api.health(); + const result = await api.getStatistics(); expect(result).toEqual(mockData); expect(global.fetch).toHaveBeenCalledTimes(2); @@ -301,7 +221,7 @@ describe('API Client', () => { json: async () => ({ message: 'Invalid request' }), }); - await expect(api.health()).rejects.toThrow('Invalid request'); + await expect(api.getStatistics()).rejects.toThrow('Invalid request'); expect(global.fetch).toHaveBeenCalledTimes(1); }); @@ -315,8 +235,8 @@ describe('API Client', () => { }); it('should handle multiple sequential requests', async () => { - const mockData1 = { data: 'first' }; - const mockData2 = { data: 'second' }; + const mockData1 = { total_markets: 10 }; + const mockData2 = { success: true, message: 'Subscribed' }; (global.fetch as jest.Mock) .mockResolvedValueOnce({ @@ -328,8 +248,8 @@ describe('API Client', () => { text: async () => JSON.stringify(mockData2), }); - const result1 = await api.health(); - const result2 = await api.getStatistics(); + const result1 = await api.getStatistics(); + const result2 = await api.newsletterSubscribe({ email: 'test@example.com' }); expect(result1).toEqual(mockData1); expect(result2).toEqual(mockData2); @@ -337,7 +257,7 @@ describe('API Client', () => { }); it('should use exponential backoff for retries', async () => { - const mockData = { status: 'ok' }; + const mockData = { total_markets: 10 }; (global.fetch as jest.Mock) .mockRejectedValueOnce(new Error('Network error')) .mockRejectedValueOnce(new Error('Network error')) @@ -346,7 +266,7 @@ describe('API Client', () => { text: async () => JSON.stringify(mockData), }); - const result = await api.health(); + const result = await api.getStatistics(); expect(result).toEqual(mockData); expect(global.fetch).toHaveBeenCalledTimes(3); @@ -360,7 +280,7 @@ describe('API Client', () => { text: async () => '{}', }); - await api.health(); + await api.getStatistics(); expect(global.fetch).toHaveBeenCalledWith( expect.any(String), @@ -378,10 +298,10 @@ describe('API Client', () => { text: async () => '{}', }); - await api.health(); + await api.getStatistics(); expect(global.fetch).toHaveBeenCalledWith( - 'http://localhost:3001/health', + 'http://localhost:3001/api/statistics', expect.any(Object) ); }); @@ -405,7 +325,7 @@ describe('API Client', () => { // Pre-attach .catch so the rejection is never "unhandled" while timers advance. let caughtError: unknown; - const settledPromise = api.health().catch(err => { caughtError = err; }); + const settledPromise = api.newsletterSubscribe({ email: 'test@example.com' }).catch(err => { caughtError = err; }); await jest.advanceTimersByTimeAsync(10_000); await settledPromise; @@ -442,7 +362,7 @@ describe('API Client', () => { describe('5xx retry logic (#946)', () => { it('retries GET requests on 5xx: two 502s then 200 succeeds', async () => { - const mockData = { status: 'ok' }; + const mockData = { total_markets: 10 }; const gatewayError = { ok: false, status: 502, @@ -458,7 +378,7 @@ describe('API Client', () => { text: async () => JSON.stringify(mockData), }); - const result = await api.health(); + const result = await api.getStatistics(); expect(result).toEqual(mockData); expect(global.fetch).toHaveBeenCalledTimes(3); // 1 initial + 2 retries }, 10_000); @@ -476,128 +396,14 @@ describe('API Client', () => { ).rejects.toThrow('Service Unavailable'); expect(global.fetch).toHaveBeenCalledTimes(1); }); - - it('does not retry 5xx for DELETE requests', async () => { - (global.fetch as jest.Mock).mockResolvedValueOnce({ - ok: false, - status: 502, - statusText: 'Bad Gateway', - json: async () => ({ message: 'Bad Gateway' }), - }); - - await expect( - api.newsletterUnsubscribe('test@example.com') - ).rejects.toThrow('Bad Gateway'); - expect(global.fetch).toHaveBeenCalledTimes(1); - }); - }); - - describe('Cache invalidation strategy (#947)', () => { - it('invalidates only tagged resources on mutation — not the entire cache', async () => { - // Prime a statistics cache entry tagged 'statistics'. - (global.fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - text: async () => JSON.stringify({ total_markets: 10 }), - }); - await api.getStatistics(); - - // Prime a markets cache entry tagged 'markets'. - (global.fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - text: async () => JSON.stringify([{ id: 1 }]), - }); - await api.getFeaturedMarkets(); - - // Mutate: resolveMarket invalidates 'markets', 'blockchain', 'statistics'. - (global.fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - text: async () => JSON.stringify({ invalidated_keys: 2 }), - }); - await api.resolveMarket(1); - - // Both statistics and markets caches must be cleared. - // A fresh fetch call should now happen for getStatistics. - (global.fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - text: async () => JSON.stringify({ total_markets: 11 }), - }); - const freshStats = await api.getStatistics(); - expect(freshStats).toEqual({ total_markets: 11 }); - }); - - it('GET returns fresh data after a mutation invalidates its cache tag', async () => { - // Cache getFeaturedMarkets. - (global.fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - text: async () => JSON.stringify([{ id: 1, title: 'Old Market' }]), - }); - const first = await api.getFeaturedMarkets(); - expect(first).toEqual([{ id: 1, title: 'Old Market' }]); - - // resolveMarket invalidates the 'markets' tag. - (global.fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - text: async () => JSON.stringify({ invalidated_keys: 1 }), - }); - await api.resolveMarket(1); - - // Next getFeaturedMarkets must hit the network, not the cache. - (global.fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - text: async () => JSON.stringify([{ id: 1, title: 'Resolved Market' }]), - }); - const second = await api.getFeaturedMarkets(); - expect(second).toEqual([{ id: 1, title: 'Resolved Market' }]); - }); - }); - - describe('DELETE requests', () => { - it('should handle DELETE requests with body', async () => { - const mockResponse = { success: true }; - (global.fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - text: async () => JSON.stringify(mockResponse), - }); - - const result = await api.newsletterUnsubscribe('test@example.com'); - expect(result).toEqual(mockResponse); - expect(global.fetch).toHaveBeenCalledWith( - 'http://localhost:3001/api/v1/newsletter/unsubscribe', - expect.objectContaining({ - method: 'DELETE', - body: JSON.stringify({ email: 'test@example.com' }), - }) - ); - }); }); describe('ApiError', () => { - it('should throw ApiError with status code on non-2xx response', async () => { - (global.fetch as jest.Mock).mockResolvedValueOnce({ - ok: false, - status: 404, - statusText: 'Not Found', - json: async () => ({ message: 'Market not found' }), - }); - - try { - await api.getBlockchainMarket(999); - fail('should have thrown'); - } catch (e) { - expect(e).toBeInstanceOf(ApiError); - expect((e as ApiError).status).toBe(404); - expect((e as ApiError).message).toBe('Market not found'); - expect((e as ApiError).isClientError).toBe(true); - expect((e as ApiError).isServerError).toBe(false); - expect((e as ApiError).isNetworkError).toBe(false); - } - }); - it('should throw ApiError with status 0 on network failure', async () => { (global.fetch as jest.Mock).mockRejectedValueOnce(new Error('Failed to fetch')); try { - await api.health(); + await api.getStatistics(); fail('should have thrown'); } catch (e) { expect(e).toBeInstanceOf(ApiError); @@ -631,10 +437,10 @@ describe('API Client', () => { (global.fetch as jest.Mock).mockRejectedValueOnce(new Error('offline')); try { - await api.health(); + await api.getStatistics(); } catch (e) { expect((e as ApiError).name).toBe('ApiError'); } }); }); -}); \ No newline at end of file +}); diff --git a/frontend/src/lib/api/admin-client.ts b/frontend/src/lib/api/admin-client.ts new file mode 100644 index 00000000..24570f9a --- /dev/null +++ b/frontend/src/lib/api/admin-client.ts @@ -0,0 +1,227 @@ +/** + * API client for admin/internal tooling: market resolution, blockchain + * inspection, and email operations. Not imported by the landing page — see + * `client.ts` for the small surface that app actually needs. Both share the + * low-level request/retry/cache logic in `request.ts`. + * + * Run `npm run generate-client` to regenerate `schema.d.ts` after API changes. + */ + +import { request, ApiError, CacheTag } from './request'; +import { CACHE_TTL } from './cache'; + +export { ApiError }; + +/** + * Maps Soroban contract error codes (u32) to localized user-facing messages. + * + * When the API returns a CONTRACT_ERROR, read `details.contract_code` and pass + * it to `getContractErrorMessage` to get a display-ready string. + * + * Source of truth: contracts/predict-iq/src/errors.rs + * Full reference: docs/CONTRACT_ERRORS.md + */ +export const CONTRACT_ERROR_MESSAGES: Record = { + // Authorization & Setup + 100: "This contract has already been set up.", + 101: "You are not authorized to perform this action.", + 120: "No admin has been configured for this contract.", + 121: "The platform is currently paused. Please try again later.", + 122: "No guardian has been configured for this contract.", + 146: "The governance token contract has not been configured.", + + // Market Lifecycle + 102: "Market not found.", + 103: "This market is closed and no longer accepts activity.", + 104: "This market is still active and cannot be finalized yet.", + 115: "This market is not currently active.", + 116: "The deadline for this market has passed.", + 148: "The provided deadline is invalid.", + + // Betting + 105: "The selected outcome is not valid for this market.", + 106: "The bet amount is invalid. Please enter a valid amount.", + 107: "Insufficient balance to complete this transaction.", + 126: "Your deposit is below the minimum required amount.", + 142: "Bet not found.", + 145: "The amount provided is invalid.", + + // Resolution & Disputes + 108: "The oracle failed to provide a result. Please try again later.", + 110: "The dispute window for this market has closed.", + 117: "The outcome for this market has already been set.", + 118: "This market is not in a disputed state.", + 119: "This market is not pending resolution.", + 133: "The parent market has not been resolved yet.", + 134: "The parent market outcome does not satisfy this market's condition.", + 135: "Resolution conditions have not been met yet. Please try again later.", + 136: "The dispute window is still open. Resolution must wait.", + 137: "No majority outcome was reached. Resolution is inconclusive.", + 138: "Price data is stale. A fresh oracle feed is required.", + 139: "Oracle confidence is too low to resolve this market.", + 141: "This market was not cancelled.", + 147: "This market has not been resolved yet.", + + // Voting & Governance + 111: "Voting on this market has not started yet.", + 112: "The voting period for this market has ended.", + 113: "You have already voted on this market.", + 114: "The requested fee is too high.", + 129: "Not enough governance votes to approve this action.", + 130: "You have already voted on this upgrade.", + 140: "Your governance token balance is too low to vote.", + + // Upgrades + 127: "A timelock is active. Please wait before retrying.", + 128: "No upgrade has been initiated.", + 131: "The provided WASM hash is invalid.", + 132: "The contract upgrade failed.", + 143: "An upgrade is already pending. Only one upgrade can be in progress at a time.", + 144: "This WASM hash is in cooldown. Please wait before reusing it.", + + // System + 109: "The system circuit breaker is open. Operations are temporarily halted.", + 123: "Too many outcomes provided for this market.", + 124: "Too many winners specified for payout calculation.", + 125: "This payout mode is not supported.", +}; + +/** + * Returns a user-facing message for a contract error code. + * Falls back to a generic message if the code is not recognized. + */ +export function getContractErrorMessage(code: number): string { + return CONTRACT_ERROR_MESSAGES[code] ?? `An unexpected contract error occurred (code ${code}).`; +} + +export const api = { + health: (signal?: AbortSignal) => request("GET", "/health", { signal }), + + getFeaturedMarkets: (signal?: AbortSignal) => + request< + Array<{ + id: number; + title: string; + volume: number; + ends_at: string; + onchain_volume: string; + resolved_outcome?: number | null; + }> + >("GET", "/api/markets/featured", { + cacheTtl: CACHE_TTL.SHORT, + cacheTags: [CacheTag.MARKETS], + signal, + }), + + getContent: (params?: { page?: number; page_size?: number }, signal?: AbortSignal) => + request>("GET", "/api/content", { params, cacheTtl: CACHE_TTL.MEDIUM, signal }), + + // Blockchain + getBlockchainHealth: (signal?: AbortSignal) => + request>("GET", "/api/blockchain/health", { + cacheTtl: CACHE_TTL.SHORT, + cacheTags: [CacheTag.BLOCKCHAIN], + signal, + }), + + getBlockchainMarket: (marketId: number | string, signal?: AbortSignal) => + request>("GET", `/api/blockchain/markets/${marketId}`, { + cacheTtl: CACHE_TTL.MEDIUM, + cacheTags: [CacheTag.BLOCKCHAIN, CacheTag.MARKETS], + signal, + }), + + getBlockchainStats: (signal?: AbortSignal) => + request>("GET", "/api/blockchain/stats", { + cacheTtl: CACHE_TTL.MEDIUM, + cacheTags: [CacheTag.BLOCKCHAIN], + signal, + }), + + getUserBets: (user: string, params?: { page?: number; page_size?: number }, signal?: AbortSignal) => + request>("GET", `/api/blockchain/users/${user}/bets`, { + params, + cacheTtl: CACHE_TTL.MEDIUM, + cacheTags: [CacheTag.BLOCKCHAIN], + signal, + }), + + getOracleResult: (marketId: number | string, signal?: AbortSignal) => + request>("GET", `/api/blockchain/oracle/${marketId}`, { + cacheTtl: CACHE_TTL.LONG, + cacheTags: [CacheTag.BLOCKCHAIN], + signal, + }), + + getTransactionStatus: (txHash: string, signal?: AbortSignal) => + request>("GET", `/api/blockchain/tx/${txHash}`, { + cacheTtl: CACHE_TTL.LONG, + cacheTags: [CacheTag.BLOCKCHAIN], + signal, + }), + + // Newsletter (admin/lifecycle operations beyond the landing page's signup form) + newsletterConfirm: (token: string, signal?: AbortSignal) => + request<{ success: boolean; message: string }>("GET", `/api/v1/newsletter/confirm`, { + params: { token }, + cacheTags: [CacheTag.NEWSLETTER], + signal, + }), + + newsletterUnsubscribe: (email: string, signal?: AbortSignal) => + request<{ success: boolean; message: string }>("DELETE", "/api/v1/newsletter/unsubscribe", { + body: { email }, + cacheTags: [CacheTag.NEWSLETTER, CacheTag.STATISTICS], + signal, + }), + + newsletterGdprExport: (email: string, signal?: AbortSignal) => + request<{ success: boolean; data: Record }>( + "GET", + "/api/v1/newsletter/gdpr/export", + { params: { email }, cacheTags: [CacheTag.NEWSLETTER], signal } + ), + + newsletterGdprDelete: (email: string, signal?: AbortSignal) => + request<{ success: boolean; message: string }>("DELETE", "/api/v1/newsletter/gdpr/delete", { + body: { email }, + cacheTags: [CacheTag.NEWSLETTER], + signal, + }), + + // Admin / email + resolveMarket: (marketId: number | string, signal?: AbortSignal) => + request<{ invalidated_keys: number }>("POST", `/api/markets/${marketId}/resolve`, { + cacheTags: [CacheTag.MARKETS, CacheTag.BLOCKCHAIN, CacheTag.STATISTICS], + signal, + }), + + emailPreview: (templateName: string, signal?: AbortSignal) => + request>("GET", `/api/v1/email/preview/${templateName}`, { + cacheTtl: CACHE_TTL.LONG, + cacheTags: [CacheTag.EMAIL], + signal, + }), + + emailSendTest: (body: { recipient: string; template_name: string }, signal?: AbortSignal) => + request<{ success: boolean; message: string; message_id: string }>( + "POST", + "/api/v1/email/test", + { body, cacheTags: [CacheTag.EMAIL], signal } + ), + + getEmailAnalytics: (params?: { template_name?: string; days?: number }, signal?: AbortSignal) => + request>("GET", "/api/v1/email/analytics", { + params, + cacheTtl: CACHE_TTL.MEDIUM, + cacheTags: [CacheTag.EMAIL], + signal, + }), + + getEmailQueueStats: (signal?: AbortSignal) => + request>("GET", "/api/v1/email/queue/stats", { + cacheTtl: CACHE_TTL.SHORT, + cacheTags: [CacheTag.EMAIL], + signal, + }), +}; diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts index 7c904c5c..0e9226fc 100644 --- a/frontend/src/lib/api/client.ts +++ b/frontend/src/lib/api/client.ts @@ -1,359 +1,18 @@ /** - * Type-safe API client generated from the OpenAPI schema. - * Run `npm run generate-client` to regenerate `schema.d.ts` after API changes. - */ - -import { getEnvConfig } from '../env'; -import { apiCache, CACHE_TTL } from './cache'; - -const config = getEnvConfig(); -const BASE_URL = config.NEXT_PUBLIC_API_URL.replace(/\/$/, ""); - -type HttpMethod = "GET" | "POST" | "DELETE"; - -const DEFAULT_RETRY_CONFIG: RetryConfig = { - maxRetries: 3, - initialDelayMs: 100, - maxDelayMs: 10000, -}; - -/** Per-attempt request timeout in milliseconds. */ -const REQUEST_TIMEOUT_MS = 10_000; - -/** - * Cache tag constants used to associate GET responses with resource namespaces - * and to target only the affected entries when a mutation completes. + * API client for the landing page. Only wraps the endpoints the landing page + * actually calls (getStatistics, newsletterSubscribe) so its bundle doesn't + * ship the wider admin/blockchain/email surface — see `admin-client.ts` for + * that. Both share the low-level request/retry/cache logic in `request.ts`. * - * Invalidation strategy (tag-based): - * - Each GET endpoint declares the tags of the resources it reads. - * - Each mutation declares the tags of the resources it writes. - * - On mutation success, only entries carrying those tags are dropped. - */ -export const CacheTag = { - STATISTICS: 'statistics', - MARKETS: 'markets', - BLOCKCHAIN: 'blockchain', - NEWSLETTER: 'newsletter', - EMAIL: 'email', -} as const; - -function getRetryDelay(attempt: number, retryAfter?: number): number { - if (retryAfter) return retryAfter * 1000; - const base = DEFAULT_RETRY_CONFIG.initialDelayMs * Math.pow(2, attempt); - // Add up to 25 % random jitter to spread out thundering-herd retries. - const jitter = Math.random() * base * 0.25; - return Math.min(base + jitter, DEFAULT_RETRY_CONFIG.maxDelayMs); -} - -/** - * Create a per-attempt abort signal that fires after `timeoutMs` milliseconds. - * If `userSignal` is provided it is linked: aborting either one aborts the other. - */ -function createRequestSignal( - timeoutMs: number, - userSignal?: AbortSignal -): { signal: AbortSignal; clear: () => void } { - const controller = new AbortController(); - const timerId = setTimeout(() => controller.abort(), timeoutMs); - const clear = () => clearTimeout(timerId); - - if (userSignal) { - if (userSignal.aborted) { - clear(); - controller.abort(userSignal.reason); - } else { - userSignal.addEventListener('abort', () => { - clear(); - controller.abort(userSignal.reason); - }, { once: true }); - } - } - - return { signal: controller.signal, clear }; -} - -function sleep(ms: number): Promise { - return new Promise(resolve => setTimeout(resolve, ms)); -} - -interface RequestOptions { - body?: unknown; - params?: Record; - cacheTtl?: number; - /** Resource tags applied to a cached GET entry, or invalidated on a mutation. */ - cacheTags?: string[]; - maxRetries?: number; - /** Per-attempt timeout in ms. Defaults to REQUEST_TIMEOUT_MS (10 s). */ - timeoutMs?: number; - /** - * Mark a non-GET request as safe to retry on 5xx. - * Only set this for endpoints that are truly idempotent (e.g. PUT upserts). - */ - idempotent?: boolean; - signal?: AbortSignal; -} - -interface RetryConfig { - maxRetries: number; - initialDelayMs: number; - maxDelayMs: number; -} - -/** - * Maps Soroban contract error codes (u32) to localized user-facing messages. - * - * When the API returns a CONTRACT_ERROR, read `details.contract_code` and pass - * it to `getContractErrorMessage` to get a display-ready string. - * - * Source of truth: contracts/predict-iq/src/errors.rs - * Full reference: docs/CONTRACT_ERRORS.md - */ -export const CONTRACT_ERROR_MESSAGES: Record = { - // Authorization & Setup - 100: "This contract has already been set up.", - 101: "You are not authorized to perform this action.", - 120: "No admin has been configured for this contract.", - 121: "The platform is currently paused. Please try again later.", - 122: "No guardian has been configured for this contract.", - 146: "The governance token contract has not been configured.", - - // Market Lifecycle - 102: "Market not found.", - 103: "This market is closed and no longer accepts activity.", - 104: "This market is still active and cannot be finalized yet.", - 115: "This market is not currently active.", - 116: "The deadline for this market has passed.", - 148: "The provided deadline is invalid.", - - // Betting - 105: "The selected outcome is not valid for this market.", - 106: "The bet amount is invalid. Please enter a valid amount.", - 107: "Insufficient balance to complete this transaction.", - 126: "Your deposit is below the minimum required amount.", - 142: "Bet not found.", - 145: "The amount provided is invalid.", - - // Resolution & Disputes - 108: "The oracle failed to provide a result. Please try again later.", - 110: "The dispute window for this market has closed.", - 117: "The outcome for this market has already been set.", - 118: "This market is not in a disputed state.", - 119: "This market is not pending resolution.", - 133: "The parent market has not been resolved yet.", - 134: "The parent market outcome does not satisfy this market's condition.", - 135: "Resolution conditions have not been met yet. Please try again later.", - 136: "The dispute window is still open. Resolution must wait.", - 137: "No majority outcome was reached. Resolution is inconclusive.", - 138: "Price data is stale. A fresh oracle feed is required.", - 139: "Oracle confidence is too low to resolve this market.", - 141: "This market was not cancelled.", - 147: "This market has not been resolved yet.", - - // Voting & Governance - 111: "Voting on this market has not started yet.", - 112: "The voting period for this market has ended.", - 113: "You have already voted on this market.", - 114: "The requested fee is too high.", - 129: "Not enough governance votes to approve this action.", - 130: "You have already voted on this upgrade.", - 140: "Your governance token balance is too low to vote.", - - // Upgrades - 127: "A timelock is active. Please wait before retrying.", - 128: "No upgrade has been initiated.", - 131: "The provided WASM hash is invalid.", - 132: "The contract upgrade failed.", - 143: "An upgrade is already pending. Only one upgrade can be in progress at a time.", - 144: "This WASM hash is in cooldown. Please wait before reusing it.", - - // System - 109: "The system circuit breaker is open. Operations are temporarily halted.", - 123: "Too many outcomes provided for this market.", - 124: "Too many winners specified for payout calculation.", - 125: "This payout mode is not supported.", -}; - -/** - * Returns a user-facing message for a contract error code. - * Falls back to a generic message if the code is not recognized. - */ -export function getContractErrorMessage(code: number): string { - return CONTRACT_ERROR_MESSAGES[code] ?? `An unexpected contract error occurred (code ${code}).`; -} - -/** - * Structured API error with HTTP status code and user-friendly message. - * Thrown for both network failures and non-2xx responses. - * - * Usage: - * try { await api.getStatistics() } - * catch (e) { - * if (e instanceof ApiError) { - * console.log(e.status, e.message); // e.g. 404, "Market not found" - * } - * } + * Run `npm run generate-client` to regenerate `schema.d.ts` after API changes. */ -export class ApiError extends Error { - /** HTTP status code, or 0 for network/connection failures. */ - readonly status: number; - /** Machine-readable error code from the API (e.g. "NOT_FOUND", "RATE_LIMITED"). */ - readonly code: string; - /** Optional additional context returned by the API. */ - readonly details?: Record; - - constructor(message: string, status: number, code = "UNKNOWN_ERROR", details?: Record) { - super(message); - this.name = "ApiError"; - this.status = status; - this.code = code; - this.details = details; - } - - /** True for client errors (4xx). */ - get isClientError(): boolean { - return this.status >= 400 && this.status < 500; - } - - /** True for server errors (5xx). */ - get isServerError(): boolean { - return this.status >= 500; - } - - /** True for network/connection failures (status 0). */ - get isNetworkError(): boolean { - return this.status === 0; - } -} - -async function request( - method: HttpMethod, - path: string, - options: RequestOptions = {} -): Promise { - let url = `${BASE_URL}${path}`; - - if (options.params) { - const qs = new URLSearchParams(); - for (const [k, v] of Object.entries(options.params)) { - if (v !== undefined) qs.set(k, String(v)); - } - const str = qs.toString(); - if (str) url += `?${str}`; - } - - // Check cache for GET requests - if (method === "GET" && options.cacheTtl) { - const cached = apiCache.get(url); - if (cached !== null) { - return cached; - } - } - - const maxRetries = options.maxRetries ?? DEFAULT_RETRY_CONFIG.maxRetries; - const timeoutMs = options.timeoutMs ?? REQUEST_TIMEOUT_MS; - let lastError: Error | null = null; - - for (let attempt = 0; attempt <= maxRetries; attempt++) { - const { signal, clear } = createRequestSignal(timeoutMs, options.signal); - - try { - const res = await fetch(url, { - method, - headers: { "Content-Type": "application/json" }, - body: options.body !== undefined ? JSON.stringify(options.body) : undefined, - signal, - }); - - clear(); - - if (!res.ok) { - if (res.status === 429) { - if (attempt < maxRetries) { - const retryAfter = res.headers.get('Retry-After'); - const delayMs = getRetryDelay(attempt, retryAfter ? parseInt(retryAfter, 10) : undefined); - await sleep(delayMs); - continue; - } - } - - // Retry transient 5xx errors for safe/idempotent methods only. - if (res.status >= 500 && attempt < maxRetries && (method === "GET" || options.idempotent)) { - const delayMs = getRetryDelay(attempt); - await sleep(delayMs); - continue; - } - - let err: unknown; - try { - err = await res.json(); - } catch { - err = {}; - } - const errObj = (typeof err === 'object' && err !== null) ? err as Record : {}; - const message = (errObj['message'] as string | undefined) ?? res.statusText ?? `HTTP ${res.status}`; - const code = (errObj['code'] as string | undefined) ?? "UNKNOWN_ERROR"; - const details = errObj['details'] as Record | undefined; - throw new ApiError(message, res.status, code, details); - } - - // 204 / empty body - const text = await res.text(); - const data = text ? (JSON.parse(text) as T) : (undefined as unknown as T); - - // Cache GET responses with their resource tags for targeted invalidation. - if (method === "GET" && options.cacheTtl) { - apiCache.set(url, data, options.cacheTtl, options.cacheTags); - } - - // On mutations, invalidate only the affected resource tags instead of - // the entire cache. Fall back to a full clear for untagged mutations. - if (method === "POST" || method === "DELETE") { - if (options.cacheTags?.length) { - apiCache.invalidateByTags(options.cacheTags); - } else { - apiCache.invalidateByPattern('.*'); - } - } - - return data; - } catch (networkErr) { - clear(); - if (networkErr instanceof ApiError) throw networkErr; +import { request, ApiError, CacheTag } from './request'; +import { CACHE_TTL } from './cache'; - // If the abort came from our timeout (not a caller-supplied signal), surface - // a distinct TIMEOUT_ERROR so the UI can show a specific message. - if (networkErr instanceof DOMException && networkErr.name === 'AbortError') { - if (!options.signal?.aborted) { - throw new ApiError('The request timed out. Please try again.', 0, 'TIMEOUT_ERROR'); - } - // Caller-initiated abort: propagate as-is so error boundaries can ignore it. - throw networkErr; - } - - lastError = networkErr instanceof Error ? networkErr : new Error(String(networkErr)); - - if (attempt < maxRetries && method === "GET") { - const delayMs = getRetryDelay(attempt); - await sleep(delayMs); - continue; - } - - const msg = lastError.message; - throw new ApiError(`Unable to reach the server. Please check your connection. (${msg})`, 0); - } - } - - throw lastError || new ApiError("Request failed after retries", 0); -} - -// --------------------------------------------------------------------------- -// Public endpoints -// --------------------------------------------------------------------------- +export { ApiError }; export const api = { - health: (signal?: AbortSignal) => request("GET", "/health", { signal }), - getStatistics: (signal?: AbortSignal) => request>("GET", "/api/statistics", { cacheTtl: CACHE_TTL.MEDIUM, @@ -361,138 +20,10 @@ export const api = { signal, }), - getFeaturedMarkets: (signal?: AbortSignal) => - request< - Array<{ - id: number; - title: string; - volume: number; - ends_at: string; - onchain_volume: string; - resolved_outcome?: number | null; - }> - >("GET", "/api/markets/featured", { - cacheTtl: CACHE_TTL.SHORT, - cacheTags: [CacheTag.MARKETS], - signal, - }), - - getContent: (params?: { page?: number; page_size?: number }, signal?: AbortSignal) => - request>("GET", "/api/content", { params, cacheTtl: CACHE_TTL.MEDIUM, signal }), - - // Blockchain - getBlockchainHealth: (signal?: AbortSignal) => - request>("GET", "/api/blockchain/health", { - cacheTtl: CACHE_TTL.SHORT, - cacheTags: [CacheTag.BLOCKCHAIN], - signal, - }), - - getBlockchainMarket: (marketId: number | string, signal?: AbortSignal) => - request>("GET", `/api/blockchain/markets/${marketId}`, { - cacheTtl: CACHE_TTL.MEDIUM, - cacheTags: [CacheTag.BLOCKCHAIN, CacheTag.MARKETS], - signal, - }), - - getBlockchainStats: (signal?: AbortSignal) => - request>("GET", "/api/blockchain/stats", { - cacheTtl: CACHE_TTL.MEDIUM, - cacheTags: [CacheTag.BLOCKCHAIN], - signal, - }), - - getUserBets: (user: string, params?: { page?: number; page_size?: number }, signal?: AbortSignal) => - request>("GET", `/api/blockchain/users/${user}/bets`, { - params, - cacheTtl: CACHE_TTL.MEDIUM, - cacheTags: [CacheTag.BLOCKCHAIN], - signal, - }), - - getOracleResult: (marketId: number | string, signal?: AbortSignal) => - request>("GET", `/api/blockchain/oracle/${marketId}`, { - cacheTtl: CACHE_TTL.LONG, - cacheTags: [CacheTag.BLOCKCHAIN], - signal, - }), - - getTransactionStatus: (txHash: string, signal?: AbortSignal) => - request>("GET", `/api/blockchain/tx/${txHash}`, { - cacheTtl: CACHE_TTL.LONG, - cacheTags: [CacheTag.BLOCKCHAIN], - signal, - }), - - // Newsletter newsletterSubscribe: (body: { email: string; source?: string }, signal?: AbortSignal) => request<{ success: boolean; message: string }>("POST", "/api/v1/newsletter/subscribe", { body, cacheTags: [CacheTag.NEWSLETTER, CacheTag.STATISTICS], signal, }), - - newsletterConfirm: (token: string, signal?: AbortSignal) => - request<{ success: boolean; message: string }>("GET", `/api/v1/newsletter/confirm`, { - params: { token }, - cacheTags: [CacheTag.NEWSLETTER], - signal, - }), - - newsletterUnsubscribe: (email: string, signal?: AbortSignal) => - request<{ success: boolean; message: string }>("DELETE", "/api/v1/newsletter/unsubscribe", { - body: { email }, - cacheTags: [CacheTag.NEWSLETTER, CacheTag.STATISTICS], - signal, - }), - - newsletterGdprExport: (email: string, signal?: AbortSignal) => - request<{ success: boolean; data: Record }>( - "GET", - "/api/v1/newsletter/gdpr/export", - { params: { email }, cacheTags: [CacheTag.NEWSLETTER], signal } - ), - - newsletterGdprDelete: (email: string, signal?: AbortSignal) => - request<{ success: boolean; message: string }>("DELETE", "/api/v1/newsletter/gdpr/delete", { - body: { email }, - cacheTags: [CacheTag.NEWSLETTER], - signal, - }), - - // Admin / email - resolveMarket: (marketId: number | string, signal?: AbortSignal) => - request<{ invalidated_keys: number }>("POST", `/api/markets/${marketId}/resolve`, { - cacheTags: [CacheTag.MARKETS, CacheTag.BLOCKCHAIN, CacheTag.STATISTICS], - signal, - }), - - emailPreview: (templateName: string, signal?: AbortSignal) => - request>("GET", `/api/v1/email/preview/${templateName}`, { - cacheTtl: CACHE_TTL.LONG, - cacheTags: [CacheTag.EMAIL], - signal, - }), - - emailSendTest: (body: { recipient: string; template_name: string }, signal?: AbortSignal) => - request<{ success: boolean; message: string; message_id: string }>( - "POST", - "/api/v1/email/test", - { body, cacheTags: [CacheTag.EMAIL], signal } - ), - - getEmailAnalytics: (params?: { template_name?: string; days?: number }, signal?: AbortSignal) => - request>("GET", "/api/v1/email/analytics", { - params, - cacheTtl: CACHE_TTL.MEDIUM, - cacheTags: [CacheTag.EMAIL], - signal, - }), - - getEmailQueueStats: (signal?: AbortSignal) => - request>("GET", "/api/v1/email/queue/stats", { - cacheTtl: CACHE_TTL.SHORT, - cacheTags: [CacheTag.EMAIL], - signal, - }), }; diff --git a/frontend/src/lib/api/request.ts b/frontend/src/lib/api/request.ts new file mode 100644 index 00000000..1b91989e --- /dev/null +++ b/frontend/src/lib/api/request.ts @@ -0,0 +1,268 @@ +/** + * Shared low-level HTTP request layer: retry/backoff, timeout, caching, and + * error normalization. Used by both `client.ts` (the small set of endpoints + * the landing page calls) and `admin-client.ts` (the wider internal/admin + * surface), so neither has to duplicate this logic. + */ + +import { getEnvConfig } from '../env'; +import { apiCache } from './cache'; + +const config = getEnvConfig(); +const BASE_URL = config.NEXT_PUBLIC_API_URL.replace(/\/$/, ""); + +export type HttpMethod = "GET" | "POST" | "DELETE"; + +const DEFAULT_RETRY_CONFIG: RetryConfig = { + maxRetries: 3, + initialDelayMs: 100, + maxDelayMs: 10000, +}; + +/** Per-attempt request timeout in milliseconds. */ +const REQUEST_TIMEOUT_MS = 10_000; + +/** + * Cache tag constants used to associate GET responses with resource namespaces + * and to target only the affected entries when a mutation completes. + * + * Invalidation strategy (tag-based): + * - Each GET endpoint declares the tags of the resources it reads. + * - Each mutation declares the tags of the resources it writes. + * - On mutation success, only entries carrying those tags are dropped. + */ +export const CacheTag = { + STATISTICS: 'statistics', + MARKETS: 'markets', + BLOCKCHAIN: 'blockchain', + NEWSLETTER: 'newsletter', + EMAIL: 'email', +} as const; + +function getRetryDelay(attempt: number, retryAfter?: number): number { + if (retryAfter) return retryAfter * 1000; + const base = DEFAULT_RETRY_CONFIG.initialDelayMs * Math.pow(2, attempt); + // Add up to 25 % random jitter to spread out thundering-herd retries. + const jitter = Math.random() * base * 0.25; + return Math.min(base + jitter, DEFAULT_RETRY_CONFIG.maxDelayMs); +} + +/** + * Create a per-attempt abort signal that fires after `timeoutMs` milliseconds. + * If `userSignal` is provided it is linked: aborting either one aborts the other. + */ +function createRequestSignal( + timeoutMs: number, + userSignal?: AbortSignal +): { signal: AbortSignal; clear: () => void } { + const controller = new AbortController(); + const timerId = setTimeout(() => controller.abort(), timeoutMs); + const clear = () => clearTimeout(timerId); + + if (userSignal) { + if (userSignal.aborted) { + clear(); + controller.abort(userSignal.reason); + } else { + userSignal.addEventListener('abort', () => { + clear(); + controller.abort(userSignal.reason); + }, { once: true }); + } + } + + return { signal: controller.signal, clear }; +} + +function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +export interface RequestOptions { + body?: unknown; + params?: Record; + cacheTtl?: number; + /** Resource tags applied to a cached GET entry, or invalidated on a mutation. */ + cacheTags?: string[]; + maxRetries?: number; + /** Per-attempt timeout in ms. Defaults to REQUEST_TIMEOUT_MS (10 s). */ + timeoutMs?: number; + /** + * Mark a non-GET request as safe to retry on 5xx. + * Only set this for endpoints that are truly idempotent (e.g. PUT upserts). + */ + idempotent?: boolean; + signal?: AbortSignal; +} + +export interface RetryConfig { + maxRetries: number; + initialDelayMs: number; + maxDelayMs: number; +} + +/** + * Structured API error with HTTP status code and user-friendly message. + * Thrown for both network failures and non-2xx responses. + * + * Usage: + * try { await api.getStatistics() } + * catch (e) { + * if (e instanceof ApiError) { + * console.log(e.status, e.message); // e.g. 404, "Market not found" + * } + * } + */ +export class ApiError extends Error { + /** HTTP status code, or 0 for network/connection failures. */ + readonly status: number; + /** Machine-readable error code from the API (e.g. "NOT_FOUND", "RATE_LIMITED"). */ + readonly code: string; + /** Optional additional context returned by the API. */ + readonly details?: Record; + + constructor(message: string, status: number, code = "UNKNOWN_ERROR", details?: Record) { + super(message); + this.name = "ApiError"; + this.status = status; + this.code = code; + this.details = details; + } + + /** True for client errors (4xx). */ + get isClientError(): boolean { + return this.status >= 400 && this.status < 500; + } + + /** True for server errors (5xx). */ + get isServerError(): boolean { + return this.status >= 500; + } + + /** True for network/connection failures (status 0). */ + get isNetworkError(): boolean { + return this.status === 0; + } +} + +export async function request( + method: HttpMethod, + path: string, + options: RequestOptions = {} +): Promise { + let url = `${BASE_URL}${path}`; + + if (options.params) { + const qs = new URLSearchParams(); + for (const [k, v] of Object.entries(options.params)) { + if (v !== undefined) qs.set(k, String(v)); + } + const str = qs.toString(); + if (str) url += `?${str}`; + } + + // Check cache for GET requests + if (method === "GET" && options.cacheTtl) { + const cached = apiCache.get(url); + if (cached !== null) { + return cached; + } + } + + const maxRetries = options.maxRetries ?? DEFAULT_RETRY_CONFIG.maxRetries; + const timeoutMs = options.timeoutMs ?? REQUEST_TIMEOUT_MS; + let lastError: Error | null = null; + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + const { signal, clear } = createRequestSignal(timeoutMs, options.signal); + + try { + const res = await fetch(url, { + method, + headers: { "Content-Type": "application/json" }, + body: options.body !== undefined ? JSON.stringify(options.body) : undefined, + signal, + }); + + clear(); + + if (!res.ok) { + if (res.status === 429) { + if (attempt < maxRetries) { + const retryAfter = res.headers.get('Retry-After'); + const delayMs = getRetryDelay(attempt, retryAfter ? parseInt(retryAfter, 10) : undefined); + await sleep(delayMs); + continue; + } + } + + // Retry transient 5xx errors for safe/idempotent methods only. + if (res.status >= 500 && attempt < maxRetries && (method === "GET" || options.idempotent)) { + const delayMs = getRetryDelay(attempt); + await sleep(delayMs); + continue; + } + + let err: unknown; + try { + err = await res.json(); + } catch { + err = {}; + } + const errObj = (typeof err === 'object' && err !== null) ? err as Record : {}; + const message = (errObj['message'] as string | undefined) ?? res.statusText ?? `HTTP ${res.status}`; + const code = (errObj['code'] as string | undefined) ?? "UNKNOWN_ERROR"; + const details = errObj['details'] as Record | undefined; + throw new ApiError(message, res.status, code, details); + } + + // 204 / empty body + const text = await res.text(); + const data = text ? (JSON.parse(text) as T) : (undefined as unknown as T); + + // Cache GET responses with their resource tags for targeted invalidation. + if (method === "GET" && options.cacheTtl) { + apiCache.set(url, data, options.cacheTtl, options.cacheTags); + } + + // On mutations, invalidate only the affected resource tags instead of + // the entire cache. Fall back to a full clear for untagged mutations. + if (method === "POST" || method === "DELETE") { + if (options.cacheTags?.length) { + apiCache.invalidateByTags(options.cacheTags); + } else { + apiCache.invalidateByPattern('.*'); + } + } + + return data; + } catch (networkErr) { + clear(); + + if (networkErr instanceof ApiError) throw networkErr; + + // If the abort came from our timeout (not a caller-supplied signal), surface + // a distinct TIMEOUT_ERROR so the UI can show a specific message. + if (networkErr instanceof DOMException && networkErr.name === 'AbortError') { + if (!options.signal?.aborted) { + throw new ApiError('The request timed out. Please try again.', 0, 'TIMEOUT_ERROR'); + } + // Caller-initiated abort: propagate as-is so error boundaries can ignore it. + throw networkErr; + } + + lastError = networkErr instanceof Error ? networkErr : new Error(String(networkErr)); + + if (attempt < maxRetries && method === "GET") { + const delayMs = getRetryDelay(attempt); + await sleep(delayMs); + continue; + } + + const msg = lastError.message; + throw new ApiError(`Unable to reach the server. Please check your connection. (${msg})`, 0); + } + } + + throw lastError || new ApiError("Request failed after retries", 0); +}