Skip to content

Commit 1fb23b8

Browse files
Merge pull request #1370 from ummarig/feat/coverage
Feat/coverage
2 parents ac2152c + 3b7a79b commit 1fb23b8

4 files changed

Lines changed: 630 additions & 19 deletions

File tree

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
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

Comments
 (0)