1+ import { api } from '@/lib/api' ;
2+ import axios from 'axios' ;
3+
4+ // Mock axios
5+ jest . mock ( 'axios' ) ;
6+ const mockedAxios = axios as jest . Mocked < typeof axios > ;
7+
8+ // Mock window.location
9+ const originalLocation = window . location ;
10+ beforeEach ( ( ) => {
11+ delete ( window as any ) . location ;
12+ window . location = { ...originalLocation , href : '' } ;
13+ localStorage . clear ( ) ;
14+ jest . clearAllMocks ( ) ;
15+
16+ // Reset the refresh state variables (we need to access them, so we'll clear module cache between tests if needed)
17+ jest . resetModules ( ) ;
18+ } ) ;
19+
20+ afterAll ( ( ) => {
21+ window . location = originalLocation ;
22+ } ) ;
23+
24+ describe ( 'API Client' , ( ) => {
25+ describe ( 'Auth Header Injection' , ( ) => {
26+ it ( 'adds Authorization header with access token from localStorage when token exists' , async ( ) => {
27+ // Set up localStorage with a token
28+ const testToken = 'test_access_token_123' ;
29+ localStorage . setItem ( 'accessToken' , testToken ) ;
30+
31+ // Mock the axios instance's get method to resolve
32+ ( mockedAxios . create as jest . Mock ) . mockReturnValue ( {
33+ interceptors : {
34+ request : { use : jest . fn ( ) } ,
35+ response : { use : jest . fn ( ) } ,
36+ } ,
37+ get : jest . fn ( ) . mockResolvedValue ( { data : { } } ) ,
38+ } ) ;
39+
40+ // Import the api after mocking to get the fresh instance
41+ const { api } = require ( '@/lib/api' ) ;
42+ await api . get ( '/test-endpoint' ) ;
43+
44+ // Check that the request was made with the correct Authorization header
45+ expect ( api . get ) . toHaveBeenCalledWith ( expect . any ( String ) , expect . objectContaining ( {
46+ headers : {
47+ 'Content-Type' : 'application/json' ,
48+ 'Authorization' : `Bearer ${ testToken } ` ,
49+ } ,
50+ } ) ) ;
51+ } ) ;
52+
53+ it ( 'does not add Authorization header when no access token exists in localStorage' , async ( ) => {
54+ ( mockedAxios . create as jest . Mock ) . mockReturnValue ( {
55+ interceptors : {
56+ request : { use : jest . fn ( ( config ) => config ) } ,
57+ response : { use : jest . fn ( ) } ,
58+ } ,
59+ get : jest . fn ( ) . mockResolvedValue ( { data : { } } ) ,
60+ } ) ;
61+
62+ const { api } = require ( '@/lib/api' ) ;
63+ await api . get ( '/test-endpoint' ) ;
64+
65+ expect ( api . get ) . toHaveBeenCalledWith ( expect . any ( String ) , expect . objectContaining ( {
66+ headers : {
67+ 'Content-Type' : 'application/json' ,
68+ } ,
69+ } ) ) ;
70+ expect ( api . get ) . not . toHaveBeenCalledWith ( expect . any ( String ) , expect . objectContaining ( {
71+ headers : expect . objectContaining ( {
72+ Authorization : expect . any ( String ) ,
73+ } ) ,
74+ } ) ) ;
75+ } ) ;
76+ } ) ;
77+
78+ describe ( '401 Refresh and Retry Flow' , ( ) => {
79+ it ( 'successfully refreshes token and retries original request on 401' , async ( ) => {
80+ const refreshToken = 'test_refresh_token' ;
81+ const newAccessToken = 'new_access_token_123' ;
82+ const newRefreshToken = 'new_refresh_token_123' ;
83+ localStorage . setItem ( 'accessToken' , 'old_expired_token' ) ;
84+ localStorage . setItem ( 'refreshToken' , refreshToken ) ;
85+
86+ // Mock the refresh endpoint to return new tokens
87+ mockedAxios . post . mockResolvedValueOnce ( {
88+ data : { accessToken : newAccessToken , refreshToken : newRefreshToken } ,
89+ } ) ;
90+
91+ // Create api instance with mock that first returns 401, then succeeds on retry
92+ const mockGet = jest . fn ( )
93+ . mockRejectedValueOnce ( {
94+ response : { status : 401 } ,
95+ config : { } ,
96+ } )
97+ . mockResolvedValueOnce ( { data : { success : true } } ) ;
98+
99+ ( mockedAxios . create as jest . Mock ) . mockReturnValue ( {
100+ interceptors : {
101+ request : { use : jest . fn ( ( config ) => config ) } ,
102+ response : { use : jest . fn ( ( fulfilled , rejected ) => ( { fulfilled, rejected } ) ) } ,
103+ } ,
104+ get : mockGet ,
105+ } ) ;
106+
107+ const { api } = require ( '@/lib/api' ) ;
108+ // We need to manually call the response interceptor to simulate the flow
109+ const responseInterceptor = ( mockedAxios . create as jest . Mock ) . mock . results [ 0 ] . value . interceptors . response . use ;
110+ const error = { response : { status : 401 } , config : { _retry : false } } ;
111+
112+ await expect ( responseInterceptor . rejected ( error ) ) . resolves . toEqual ( { data : { success : true } } ) ;
113+
114+ // Verify refresh was called
115+ expect ( mockedAxios . post ) . toHaveBeenCalledWith (
116+ 'http://localhost:6003/api/auth/refresh' ,
117+ { } ,
118+ expect . objectContaining ( {
119+ headers : { Authorization : `Bearer ${ refreshToken } ` } ,
120+ } )
121+ ) ;
122+
123+ // Verify new tokens were stored
124+ expect ( localStorage . getItem ( 'accessToken' ) ) . toBe ( newAccessToken ) ;
125+ expect ( localStorage . getItem ( 'refreshToken' ) ) . toBe ( newRefreshToken ) ;
126+
127+ // Verify original request was retried with new token
128+ expect ( mockGet ) . toHaveBeenCalledTimes ( 2 ) ;
129+ } ) ;
130+
131+ it ( 'clears tokens and redirects to login when refresh fails' , async ( ) => {
132+ localStorage . setItem ( 'accessToken' , 'expired_token' ) ;
133+ localStorage . setItem ( 'refreshToken' , 'invalid_refresh_token' ) ;
134+
135+ // Mock refresh endpoint to fail
136+ mockedAxios . post . mockRejectedValueOnce ( new Error ( 'Refresh failed' ) ) ;
137+
138+ ( mockedAxios . create as jest . Mock ) . mockReturnValue ( {
139+ interceptors : {
140+ request : { use : jest . fn ( ( config ) => config ) } ,
141+ response : { use : jest . fn ( ( fulfilled , rejected ) => ( { fulfilled, rejected } ) ) } ,
142+ } ,
143+ get : jest . fn ( ) . mockRejectedValue ( {
144+ response : { status : 401 } ,
145+ config : { _retry : false } ,
146+ } ) ,
147+ } ) ;
148+
149+ const { api } = require ( '@/lib/api' ) ;
150+ const responseInterceptor = ( mockedAxios . create as jest . Mock ) . mock . results [ 0 ] . value . interceptors . response . use ;
151+ const error = { response : { status : 401 } , config : { _retry : false } } ;
152+
153+ await expect ( responseInterceptor . rejected ( error ) ) . rejects . toEqual ( error ) ;
154+
155+ // Verify tokens were removed
156+ expect ( localStorage . getItem ( 'accessToken' ) ) . toBeNull ( ) ;
157+ expect ( localStorage . getItem ( 'refreshToken' ) ) . toBeNull ( ) ;
158+
159+ // Verify redirect to login
160+ expect ( window . location . href ) . toBe ( '/login' ) ;
161+ } ) ;
162+
163+ it ( 'only triggers one refresh call when multiple concurrent requests receive 401' , async ( ) => {
164+ const refreshToken = 'test_refresh_token' ;
165+ const newAccessToken = 'new_access_token_123' ;
166+ const newRefreshToken = 'new_refresh_token_123' ;
167+ localStorage . setItem ( 'accessToken' , 'old_expired_token' ) ;
168+ localStorage . setItem ( 'refreshToken' , refreshToken ) ;
169+
170+ // Mock the refresh endpoint - we'll count how many times it's called
171+ const refreshMock = jest . fn ( ) . mockResolvedValue ( {
172+ data : { accessToken : newAccessToken , refreshToken : newRefreshToken } ,
173+ } ) ;
174+ mockedAxios . post . mockImplementation ( refreshMock ) ;
175+
176+ // Create api instance that returns 401 for first call, then succeeds
177+ const mockGet = jest . fn ( )
178+ . mockRejectedValueOnce ( { response : { status : 401 } , config : { _retry : false } } )
179+ . mockRejectedValueOnce ( { response : { status : 401 } , config : { _retry : false } } )
180+ . mockResolvedValue ( { data : { success : true } } ) ;
181+
182+ ( mockedAxios . create as jest . Mock ) . mockReturnValue ( {
183+ interceptors : {
184+ request : { use : jest . fn ( ( config ) => config ) } ,
185+ response : { use : jest . fn ( ( fulfilled , rejected ) => ( { fulfilled, rejected } ) ) } ,
186+ } ,
187+ get : mockGet ,
188+ } ) ;
189+
190+ const { api } = require ( '@/lib/api' ) ;
191+ const responseInterceptor = ( mockedAxios . create as jest . Mock ) . mock . results [ 0 ] . value . interceptors . response . use ;
192+
193+ // Fire off two concurrent requests that both 401
194+ const error1 = { response : { status : 401 } , config : { _retry : false , url : '/endpoint1' } } ;
195+ const error2 = { response : { status : 401 } , config : { _retry : false , url : '/endpoint2' } } ;
196+
197+ await Promise . all ( [
198+ responseInterceptor . rejected ( error1 ) ,
199+ responseInterceptor . rejected ( error2 ) ,
200+ ] ) ;
201+
202+ // Verify refresh was only called once, not twice!
203+ expect ( refreshMock ) . toHaveBeenCalledTimes ( 1 ) ;
204+
205+ // Both requests were retried
206+ expect ( mockGet ) . toHaveBeenCalledTimes ( 4 ) ; // 2 initial failed, 2 successful retries
207+ } ) ;
208+ } ) ;
209+ } ) ;
0 commit comments