diff --git a/frontend/src/lib/api/__tests__/cache.test.ts b/frontend/src/lib/api/__tests__/cache.test.ts index 9635ad1d..129d1f1f 100644 --- a/frontend/src/lib/api/__tests__/cache.test.ts +++ b/frontend/src/lib/api/__tests__/cache.test.ts @@ -81,4 +81,56 @@ describe('apiCache', () => { expect(CACHE_TTL.LONG).toBe(30 * 60 * 1000); }); }); + + describe('tag-based invalidation (#947)', () => { + it('invalidates entries matching any given tag while leaving others intact', () => { + apiCache.set('url/markets/featured', [{ id: 1 }], CACHE_TTL.SHORT, ['markets']); + apiCache.set('url/blockchain/markets/1', { id: 1 }, CACHE_TTL.MEDIUM, ['markets', 'blockchain']); + apiCache.set('url/statistics', { total: 100 }, CACHE_TTL.MEDIUM, ['statistics']); + + apiCache.invalidateByTags(['markets']); + + expect(apiCache.get('url/markets/featured')).toBeNull(); + expect(apiCache.get('url/blockchain/markets/1')).toBeNull(); + // statistics has a different tag and must survive + expect(apiCache.get('url/statistics')).toEqual({ total: 100 }); + }); + + it('invalidates entries carrying multiple tags when any matches', () => { + apiCache.set('url/blockchain/stats', { txs: 50 }, CACHE_TTL.MEDIUM, ['blockchain', 'statistics']); + + apiCache.invalidateByTags(['blockchain']); + + expect(apiCache.get('url/blockchain/stats')).toBeNull(); + }); + + it('does not affect entries without tags', () => { + apiCache.set('url/content', { items: [] }, CACHE_TTL.SHORT); // no tags + + apiCache.invalidateByTags(['markets']); + + expect(apiCache.get('url/content')).toEqual({ items: [] }); + }); + + it('GET returns fresh data after mutation invalidates its cache tag', () => { + // Simulate a cached GET response tagged 'markets'. + apiCache.set('url/markets/featured', [{ id: 1, title: 'Old' }], CACHE_TTL.SHORT, ['markets']); + expect(apiCache.get('url/markets/featured')).toEqual([{ id: 1, title: 'Old' }]); + + // Simulate a mutation that invalidates the 'markets' tag. + apiCache.invalidateByTags(['markets']); + + // Cache miss — the next GET will fetch fresh data from the server. + expect(apiCache.get('url/markets/featured')).toBeNull(); + }); + + it('is a no-op when no entries carry the given tag', () => { + apiCache.set('url/email/analytics', { sent: 10 }, CACHE_TTL.MEDIUM, ['email']); + + // Invalidating an unrelated tag must not remove anything. + apiCache.invalidateByTags(['newsletter']); + + expect(apiCache.get('url/email/analytics')).toEqual({ sent: 10 }); + }); + }); }); diff --git a/frontend/src/lib/api/__tests__/client.test.ts b/frontend/src/lib/api/__tests__/client.test.ts index bb60602a..1ce5817f 100644 --- a/frontend/src/lib/api/__tests__/client.test.ts +++ b/frontend/src/lib/api/__tests__/client.test.ts @@ -1,4 +1,5 @@ import { api, ApiError } from '../client'; +import { apiCache } from '../cache'; describe('API Client', () => { const originalFetch = global.fetch; @@ -7,6 +8,8 @@ describe('API Client', () => { 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(() => { @@ -144,40 +147,43 @@ describe('API Client', () => { await expect(api.getBlockchainMarket(999)).rejects.toThrow('Market not found'); }); - it('should handle 500 Server Error', async () => { - (global.fetch as jest.Mock).mockResolvedValueOnce({ + it('should handle 500 Server Error after exhausting retries', async () => { + const serverError = { ok: false, status: 500, statusText: 'Internal Server Error', json: async () => ({ message: 'Database connection failed' }), - }); + }; + // GET requests retry 5xx up to maxRetries (3) times — mock all attempts. + (global.fetch as jest.Mock).mockResolvedValue(serverError); await expect(api.getStatistics()).rejects.toThrow('Database connection failed'); - }); + expect(global.fetch).toHaveBeenCalledTimes(4); // 1 initial + 3 retries + }, 30_000); it('should fallback to statusText when error response has no message', async () => { - (global.fetch as jest.Mock).mockResolvedValueOnce({ + const serverError = { ok: false, status: 503, statusText: 'Service Unavailable', json: async () => ({}), - }); + }; + (global.fetch as jest.Mock).mockResolvedValue(serverError); await expect(api.health()).rejects.toThrow('Service Unavailable'); - }); + }, 30_000); it('should fallback to HTTP status when response is not JSON', async () => { - (global.fetch as jest.Mock).mockResolvedValueOnce({ + const serverError = { ok: false, status: 502, statusText: 'Bad Gateway', - json: async () => { - throw new Error('Invalid JSON'); - }, - }); + json: async () => { throw new Error('Invalid JSON'); }, + }; + (global.fetch as jest.Mock).mockResolvedValue(serverError); await expect(api.health()).rejects.toThrow('Bad Gateway'); - }); + }, 30_000); }); describe('Retry behavior', () => { @@ -347,6 +353,170 @@ describe('API Client', () => { }); }); + describe('Request timeout (#945)', () => { + afterEach(() => { + jest.useRealTimers(); + }); + + it('fires after 10 seconds and rejects with a distinct TIMEOUT_ERROR', async () => { + jest.useFakeTimers(); + + (global.fetch as jest.Mock).mockImplementation((_url: string, init: RequestInit) => + new Promise((_resolve, reject) => { + (init.signal as AbortSignal).addEventListener('abort', () => + reject(new DOMException('The operation was aborted.', 'AbortError')) + ); + }) + ); + + // Pre-attach .catch so the rejection is never "unhandled" while timers advance. + let caughtError: unknown; + const settledPromise = api.health().catch(err => { caughtError = err; }); + + await jest.advanceTimersByTimeAsync(10_000); + await settledPromise; + + expect(caughtError).toMatchObject({ + name: 'ApiError', + code: 'TIMEOUT_ERROR', + message: expect.stringContaining('timed out'), + }); + }); + + it('surfaces a timeout error distinct from a generic network error', async () => { + jest.useFakeTimers(); + + (global.fetch as jest.Mock).mockImplementation((_url: string, init: RequestInit) => + new Promise((_resolve, reject) => { + (init.signal as AbortSignal).addEventListener('abort', () => + reject(new DOMException('Aborted', 'AbortError')) + ); + }) + ); + + let caughtError: unknown; + const settledPromise = api.getStatistics().catch(err => { caughtError = err; }); + await jest.advanceTimersByTimeAsync(10_000); + await settledPromise; + + expect(caughtError).toBeInstanceOf(ApiError); + expect((caughtError as ApiError).code).toBe('TIMEOUT_ERROR'); + // Must NOT be the generic network message so the UI can branch on it. + expect((caughtError as ApiError).message).not.toContain('Unable to reach the server'); + }); + }); + + describe('5xx retry logic (#946)', () => { + it('retries GET requests on 5xx: two 502s then 200 succeeds', async () => { + const mockData = { status: 'ok' }; + const gatewayError = { + ok: false, + status: 502, + statusText: 'Bad Gateway', + headers: new Map(), + json: async () => ({ message: 'Bad Gateway' }), + }; + (global.fetch as jest.Mock) + .mockResolvedValueOnce(gatewayError) + .mockResolvedValueOnce(gatewayError) + .mockResolvedValueOnce({ + ok: true, + text: async () => JSON.stringify(mockData), + }); + + const result = await api.health(); + expect(result).toEqual(mockData); + expect(global.fetch).toHaveBeenCalledTimes(3); // 1 initial + 2 retries + }, 10_000); + + it('does not retry 5xx for POST requests', async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: false, + status: 503, + statusText: 'Service Unavailable', + json: async () => ({ message: 'Service Unavailable' }), + }); + + await expect( + api.newsletterSubscribe({ email: 'test@example.com' }) + ).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 }; @@ -404,12 +574,14 @@ describe('API Client', () => { }); it('should classify 5xx as server error', async () => { - (global.fetch as jest.Mock).mockResolvedValueOnce({ + const serverError = { ok: false, status: 503, statusText: 'Service Unavailable', json: async () => ({ message: 'Service Unavailable' }), - }); + }; + // GET retries on 5xx — provide enough responses to exhaust retries. + (global.fetch as jest.Mock).mockResolvedValue(serverError); try { await api.getStatistics(); @@ -419,7 +591,7 @@ describe('API Client', () => { expect((e as ApiError).isServerError).toBe(true); expect((e as ApiError).isClientError).toBe(false); } - }); + }, 30_000); it('should have name "ApiError"', async () => { (global.fetch as jest.Mock).mockRejectedValueOnce(new Error('offline')); diff --git a/frontend/src/lib/api/cache.ts b/frontend/src/lib/api/cache.ts index 926448b9..3bc066ec 100644 --- a/frontend/src/lib/api/cache.ts +++ b/frontend/src/lib/api/cache.ts @@ -9,6 +9,7 @@ interface CacheEntry { timestamp: number; ttl: number; stale: boolean; + tags?: readonly string[]; } export interface CacheResult { @@ -52,17 +53,33 @@ class ApiCache { } /** - * Set cache entry with TTL in milliseconds + * Set cache entry with TTL in milliseconds and optional resource tags. + * Tags enable targeted invalidation: use the same tag on related GET entries + * so a single mutation can invalidate exactly the affected resources. */ - set(key: string, data: T, ttlMs: number): void { + set(key: string, data: T, ttlMs: number, tags?: string[]): void { this.cache.set(key, { data, timestamp: Date.now(), ttl: ttlMs, stale: false, + tags, }); } + /** + * Invalidate all cache entries that carry at least one of the given tags. + * Call this after a mutation to drop only the affected resource namespaces + * rather than clearing the entire cache. + */ + invalidateByTags(tags: string[]): void { + for (const [key, entry] of this.cache.entries()) { + if (entry.tags?.some(tag => tags.includes(tag))) { + this.cache.delete(key); + } + } + } + /** * Mark a single cache entry as stale. Called when the API returns an error * for a fresh request so the UI can display the old value with a warning. diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts index 209327ef..7c904c5c 100644 --- a/frontend/src/lib/api/client.ts +++ b/frontend/src/lib/api/client.ts @@ -17,10 +17,59 @@ const DEFAULT_RETRY_CONFIG: RetryConfig = { 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 delay = DEFAULT_RETRY_CONFIG.initialDelayMs * Math.pow(2, attempt); - return Math.min(delay, DEFAULT_RETRY_CONFIG.maxDelayMs); + 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 { @@ -31,7 +80,16 @@ 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; } @@ -192,17 +250,22 @@ async function request( } 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: options.signal, + signal, }); + clear(); + if (!res.ok) { if (res.status === 429) { if (attempt < maxRetries) { @@ -213,6 +276,13 @@ async function request( } } + // 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(); @@ -230,24 +300,39 @@ async function request( const text = await res.text(); const data = text ? (JSON.parse(text) as T) : (undefined as unknown as T); - // Cache GET responses + // Cache GET responses with their resource tags for targeted invalidation. if (method === "GET" && options.cacheTtl) { - apiCache.set(url, data, options.cacheTtl); + apiCache.set(url, data, options.cacheTtl, options.cacheTags); } - // Invalidate cache on mutations + // 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") { - apiCache.invalidateByPattern('.*'); + if (options.cacheTags?.length) { + apiCache.invalidateByTags(options.cacheTags); + } else { + apiCache.invalidateByPattern('.*'); + } } return data; } catch (networkErr) { - if (networkErr instanceof ApiError) { + 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); @@ -269,8 +354,12 @@ async function request( export const api = { health: (signal?: AbortSignal) => request("GET", "/health", { signal }), - getStatistics: (signal?: AbortSignal) => - request>("GET", "/api/statistics", { cacheTtl: CACHE_TTL.MEDIUM, signal }), + getStatistics: (signal?: AbortSignal) => + request>("GET", "/api/statistics", { + cacheTtl: CACHE_TTL.MEDIUM, + cacheTags: [CacheTag.STATISTICS], + signal, + }), getFeaturedMarkets: (signal?: AbortSignal) => request< @@ -282,43 +371,78 @@ export const api = { onchain_volume: string; resolved_outcome?: number | null; }> - >("GET", "/api/markets/featured", { cacheTtl: CACHE_TTL.SHORT, signal }), + >("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, signal }), + 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, signal }), + 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, signal }), + 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, signal }), + 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, signal }), + 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, signal }), + 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, signal }), + 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, }), @@ -326,32 +450,49 @@ export const api = { request<{ success: boolean; data: Record }>( "GET", "/api/v1/newsletter/gdpr/export", - { params: { email }, signal } + { 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`, { signal }), + 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, signal }), + 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, signal } + { 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, signal }), + 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, signal }), + request>("GET", "/api/v1/email/queue/stats", { + cacheTtl: CACHE_TTL.SHORT, + cacheTags: [CacheTag.EMAIL], + signal, + }), }; diff --git a/frontend/src/lib/hooks/__tests__/useAsync.test.ts b/frontend/src/lib/hooks/__tests__/useAsync.test.ts index 8f5dd010..d55db51d 100644 --- a/frontend/src/lib/hooks/__tests__/useAsync.test.ts +++ b/frontend/src/lib/hooks/__tests__/useAsync.test.ts @@ -42,6 +42,30 @@ describe('useAsync', () => { expect(result.current.error).toEqual(mockError); }); + it('sets error state when async function rejects and exposes it in the return value', async () => { + const rejectionError = new Error('promise rejected'); + const mockFn = jest.fn().mockRejectedValue(rejectionError); + const { result } = renderHook(() => useAsync(mockFn, { immediate: true })); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + // error must be accessible from the hook's return value + expect(result.current.error).toBeInstanceOf(Error); + expect(result.current.error?.message).toBe('promise rejected'); + expect(result.current.data).toBeNull(); + }); + + it('normalizes non-Error rejections into an Error object', async () => { + // The hook wraps primitive rejections so callers always receive an Error. + const mockFn = jest.fn().mockRejectedValue('plain string rejection'); + const { result } = renderHook(() => useAsync(mockFn, { immediate: true })); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.error).toBeInstanceOf(Error); + expect(result.current.error?.message).toBe('plain string rejection'); + }); + it('allows manual execution', async () => { const mockData = { manual: 'execution' }; const mockFn = jest.fn().mockResolvedValue(mockData); diff --git a/frontend/src/lib/hooks/useAsync.ts b/frontend/src/lib/hooks/useAsync.ts index b25eb6d6..5b8c2ef3 100644 --- a/frontend/src/lib/hooks/useAsync.ts +++ b/frontend/src/lib/hooks/useAsync.ts @@ -46,7 +46,7 @@ export function useAsync( useEffect(() => { isMountedRef.current = true; if (options.immediate) { - execute(); + void execute(); } return () => {