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..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); @@ -149,6 +150,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(); @@ -158,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/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); +} 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); 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';