11import { api , ApiError } from '../client' ;
2+ import { apiCache } from '../cache' ;
23
34describe ( 'API Client' , ( ) => {
45 const originalFetch = global . fetch ;
@@ -7,6 +8,8 @@ describe('API Client', () => {
78 beforeEach ( ( ) => {
89 process . env . NEXT_PUBLIC_API_URL = 'http://localhost:3001' ;
910 global . fetch = jest . fn ( ) ;
11+ // Clear the module-level cache singleton so each test starts clean.
12+ apiCache . clear ( ) ;
1013 } ) ;
1114
1215 afterEach ( ( ) => {
@@ -144,40 +147,43 @@ describe('API Client', () => {
144147 await expect ( api . getBlockchainMarket ( 999 ) ) . rejects . toThrow ( 'Market not found' ) ;
145148 } ) ;
146149
147- it ( 'should handle 500 Server Error' , async ( ) => {
148- ( global . fetch as jest . Mock ) . mockResolvedValueOnce ( {
150+ it ( 'should handle 500 Server Error after exhausting retries ' , async ( ) => {
151+ const serverError = {
149152 ok : false ,
150153 status : 500 ,
151154 statusText : 'Internal Server Error' ,
152155 json : async ( ) => ( { message : 'Database connection failed' } ) ,
153- } ) ;
156+ } ;
157+ // GET requests retry 5xx up to maxRetries (3) times — mock all attempts.
158+ ( global . fetch as jest . Mock ) . mockResolvedValue ( serverError ) ;
154159
155160 await expect ( api . getStatistics ( ) ) . rejects . toThrow ( 'Database connection failed' ) ;
156- } ) ;
161+ expect ( global . fetch ) . toHaveBeenCalledTimes ( 4 ) ; // 1 initial + 3 retries
162+ } , 30_000 ) ;
157163
158164 it ( 'should fallback to statusText when error response has no message' , async ( ) => {
159- ( global . fetch as jest . Mock ) . mockResolvedValueOnce ( {
165+ const serverError = {
160166 ok : false ,
161167 status : 503 ,
162168 statusText : 'Service Unavailable' ,
163169 json : async ( ) => ( { } ) ,
164- } ) ;
170+ } ;
171+ ( global . fetch as jest . Mock ) . mockResolvedValue ( serverError ) ;
165172
166173 await expect ( api . health ( ) ) . rejects . toThrow ( 'Service Unavailable' ) ;
167- } ) ;
174+ } , 30_000 ) ;
168175
169176 it ( 'should fallback to HTTP status when response is not JSON' , async ( ) => {
170- ( global . fetch as jest . Mock ) . mockResolvedValueOnce ( {
177+ const serverError = {
171178 ok : false ,
172179 status : 502 ,
173180 statusText : 'Bad Gateway' ,
174- json : async ( ) => {
175- throw new Error ( 'Invalid JSON' ) ;
176- } ,
177- } ) ;
181+ json : async ( ) => { throw new Error ( 'Invalid JSON' ) ; } ,
182+ } ;
183+ ( global . fetch as jest . Mock ) . mockResolvedValue ( serverError ) ;
178184
179185 await expect ( api . health ( ) ) . rejects . toThrow ( 'Bad Gateway' ) ;
180- } ) ;
186+ } , 30_000 ) ;
181187 } ) ;
182188
183189 describe ( 'Retry behavior' , ( ) => {
@@ -347,6 +353,170 @@ describe('API Client', () => {
347353 } ) ;
348354 } ) ;
349355
356+ describe ( 'Request timeout (#945)' , ( ) => {
357+ afterEach ( ( ) => {
358+ jest . useRealTimers ( ) ;
359+ } ) ;
360+
361+ it ( 'fires after 10 seconds and rejects with a distinct TIMEOUT_ERROR' , async ( ) => {
362+ jest . useFakeTimers ( ) ;
363+
364+ ( global . fetch as jest . Mock ) . mockImplementation ( ( _url : string , init : RequestInit ) =>
365+ new Promise ( ( _resolve , reject ) => {
366+ ( init . signal as AbortSignal ) . addEventListener ( 'abort' , ( ) =>
367+ reject ( new DOMException ( 'The operation was aborted.' , 'AbortError' ) )
368+ ) ;
369+ } )
370+ ) ;
371+
372+ // Pre-attach .catch so the rejection is never "unhandled" while timers advance.
373+ let caughtError : unknown ;
374+ const settledPromise = api . health ( ) . catch ( err => { caughtError = err ; } ) ;
375+
376+ await jest . advanceTimersByTimeAsync ( 10_000 ) ;
377+ await settledPromise ;
378+
379+ expect ( caughtError ) . toMatchObject ( {
380+ name : 'ApiError' ,
381+ code : 'TIMEOUT_ERROR' ,
382+ message : expect . stringContaining ( 'timed out' ) ,
383+ } ) ;
384+ } ) ;
385+
386+ it ( 'surfaces a timeout error distinct from a generic network error' , async ( ) => {
387+ jest . useFakeTimers ( ) ;
388+
389+ ( global . fetch as jest . Mock ) . mockImplementation ( ( _url : string , init : RequestInit ) =>
390+ new Promise ( ( _resolve , reject ) => {
391+ ( init . signal as AbortSignal ) . addEventListener ( 'abort' , ( ) =>
392+ reject ( new DOMException ( 'Aborted' , 'AbortError' ) )
393+ ) ;
394+ } )
395+ ) ;
396+
397+ let caughtError : unknown ;
398+ const settledPromise = api . getStatistics ( ) . catch ( err => { caughtError = err ; } ) ;
399+ await jest . advanceTimersByTimeAsync ( 10_000 ) ;
400+ await settledPromise ;
401+
402+ expect ( caughtError ) . toBeInstanceOf ( ApiError ) ;
403+ expect ( ( caughtError as ApiError ) . code ) . toBe ( 'TIMEOUT_ERROR' ) ;
404+ // Must NOT be the generic network message so the UI can branch on it.
405+ expect ( ( caughtError as ApiError ) . message ) . not . toContain ( 'Unable to reach the server' ) ;
406+ } ) ;
407+ } ) ;
408+
409+ describe ( '5xx retry logic (#946)' , ( ) => {
410+ it ( 'retries GET requests on 5xx: two 502s then 200 succeeds' , async ( ) => {
411+ const mockData = { status : 'ok' } ;
412+ const gatewayError = {
413+ ok : false ,
414+ status : 502 ,
415+ statusText : 'Bad Gateway' ,
416+ headers : new Map ( ) ,
417+ json : async ( ) => ( { message : 'Bad Gateway' } ) ,
418+ } ;
419+ ( global . fetch as jest . Mock )
420+ . mockResolvedValueOnce ( gatewayError )
421+ . mockResolvedValueOnce ( gatewayError )
422+ . mockResolvedValueOnce ( {
423+ ok : true ,
424+ text : async ( ) => JSON . stringify ( mockData ) ,
425+ } ) ;
426+
427+ const result = await api . health ( ) ;
428+ expect ( result ) . toEqual ( mockData ) ;
429+ expect ( global . fetch ) . toHaveBeenCalledTimes ( 3 ) ; // 1 initial + 2 retries
430+ } , 10_000 ) ;
431+
432+ it ( 'does not retry 5xx for POST requests' , async ( ) => {
433+ ( global . fetch as jest . Mock ) . mockResolvedValueOnce ( {
434+ ok : false ,
435+ status : 503 ,
436+ statusText : 'Service Unavailable' ,
437+ json : async ( ) => ( { message : 'Service Unavailable' } ) ,
438+ } ) ;
439+
440+ await expect (
441+ api . newsletterSubscribe ( { email : 'test@example.com' } )
442+ ) . rejects . toThrow ( 'Service Unavailable' ) ;
443+ expect ( global . fetch ) . toHaveBeenCalledTimes ( 1 ) ;
444+ } ) ;
445+
446+ it ( 'does not retry 5xx for DELETE requests' , async ( ) => {
447+ ( global . fetch as jest . Mock ) . mockResolvedValueOnce ( {
448+ ok : false ,
449+ status : 502 ,
450+ statusText : 'Bad Gateway' ,
451+ json : async ( ) => ( { message : 'Bad Gateway' } ) ,
452+ } ) ;
453+
454+ await expect (
455+ api . newsletterUnsubscribe ( 'test@example.com' )
456+ ) . rejects . toThrow ( 'Bad Gateway' ) ;
457+ expect ( global . fetch ) . toHaveBeenCalledTimes ( 1 ) ;
458+ } ) ;
459+ } ) ;
460+
461+ describe ( 'Cache invalidation strategy (#947)' , ( ) => {
462+ it ( 'invalidates only tagged resources on mutation — not the entire cache' , async ( ) => {
463+ // Prime a statistics cache entry tagged 'statistics'.
464+ ( global . fetch as jest . Mock ) . mockResolvedValueOnce ( {
465+ ok : true ,
466+ text : async ( ) => JSON . stringify ( { total_markets : 10 } ) ,
467+ } ) ;
468+ await api . getStatistics ( ) ;
469+
470+ // Prime a markets cache entry tagged 'markets'.
471+ ( global . fetch as jest . Mock ) . mockResolvedValueOnce ( {
472+ ok : true ,
473+ text : async ( ) => JSON . stringify ( [ { id : 1 } ] ) ,
474+ } ) ;
475+ await api . getFeaturedMarkets ( ) ;
476+
477+ // Mutate: resolveMarket invalidates 'markets', 'blockchain', 'statistics'.
478+ ( global . fetch as jest . Mock ) . mockResolvedValueOnce ( {
479+ ok : true ,
480+ text : async ( ) => JSON . stringify ( { invalidated_keys : 2 } ) ,
481+ } ) ;
482+ await api . resolveMarket ( 1 ) ;
483+
484+ // Both statistics and markets caches must be cleared.
485+ // A fresh fetch call should now happen for getStatistics.
486+ ( global . fetch as jest . Mock ) . mockResolvedValueOnce ( {
487+ ok : true ,
488+ text : async ( ) => JSON . stringify ( { total_markets : 11 } ) ,
489+ } ) ;
490+ const freshStats = await api . getStatistics ( ) ;
491+ expect ( freshStats ) . toEqual ( { total_markets : 11 } ) ;
492+ } ) ;
493+
494+ it ( 'GET returns fresh data after a mutation invalidates its cache tag' , async ( ) => {
495+ // Cache getFeaturedMarkets.
496+ ( global . fetch as jest . Mock ) . mockResolvedValueOnce ( {
497+ ok : true ,
498+ text : async ( ) => JSON . stringify ( [ { id : 1 , title : 'Old Market' } ] ) ,
499+ } ) ;
500+ const first = await api . getFeaturedMarkets ( ) ;
501+ expect ( first ) . toEqual ( [ { id : 1 , title : 'Old Market' } ] ) ;
502+
503+ // resolveMarket invalidates the 'markets' tag.
504+ ( global . fetch as jest . Mock ) . mockResolvedValueOnce ( {
505+ ok : true ,
506+ text : async ( ) => JSON . stringify ( { invalidated_keys : 1 } ) ,
507+ } ) ;
508+ await api . resolveMarket ( 1 ) ;
509+
510+ // Next getFeaturedMarkets must hit the network, not the cache.
511+ ( global . fetch as jest . Mock ) . mockResolvedValueOnce ( {
512+ ok : true ,
513+ text : async ( ) => JSON . stringify ( [ { id : 1 , title : 'Resolved Market' } ] ) ,
514+ } ) ;
515+ const second = await api . getFeaturedMarkets ( ) ;
516+ expect ( second ) . toEqual ( [ { id : 1 , title : 'Resolved Market' } ] ) ;
517+ } ) ;
518+ } ) ;
519+
350520 describe ( 'DELETE requests' , ( ) => {
351521 it ( 'should handle DELETE requests with body' , async ( ) => {
352522 const mockResponse = { success : true } ;
@@ -404,12 +574,14 @@ describe('API Client', () => {
404574 } ) ;
405575
406576 it ( 'should classify 5xx as server error' , async ( ) => {
407- ( global . fetch as jest . Mock ) . mockResolvedValueOnce ( {
577+ const serverError = {
408578 ok : false ,
409579 status : 503 ,
410580 statusText : 'Service Unavailable' ,
411581 json : async ( ) => ( { message : 'Service Unavailable' } ) ,
412- } ) ;
582+ } ;
583+ // GET retries on 5xx — provide enough responses to exhaust retries.
584+ ( global . fetch as jest . Mock ) . mockResolvedValue ( serverError ) ;
413585
414586 try {
415587 await api . getStatistics ( ) ;
@@ -419,7 +591,7 @@ describe('API Client', () => {
419591 expect ( ( e as ApiError ) . isServerError ) . toBe ( true ) ;
420592 expect ( ( e as ApiError ) . isClientError ) . toBe ( false ) ;
421593 }
422- } ) ;
594+ } , 30_000 ) ;
423595
424596 it ( 'should have name "ApiError"' , async ( ) => {
425597 ( global . fetch as jest . Mock ) . mockRejectedValueOnce ( new Error ( 'offline' ) ) ;
0 commit comments