diff --git a/src/utils/fetchAccessTokenUsingRefreshToken.js b/src/utils/fetchAccessTokenUsingRefreshToken.js index 03b74de..0a12187 100644 --- a/src/utils/fetchAccessTokenUsingRefreshToken.js +++ b/src/utils/fetchAccessTokenUsingRefreshToken.js @@ -1,21 +1,23 @@ import { appConfig } from '../config'; -export const fetchAccessTokenUsingRefreshToken = async (refreshToken) => { +export const fetchAccessTokenUsingRefreshToken = async ( + refreshToken: string, +): Promise => { if (!refreshToken) { throw new Error('No refresh token available'); } - return fetch(`${appConfig.API_URI}/api/auth/refresh`, { + const response = await fetch(`${appConfig.API_URI}/api/auth/refresh`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ refreshToken }), - }) - .then((response) => response.json()) - .then((data) => { - if (data.accessToken) { - return data.accessToken; - } else { - throw new Error('No access token available'); - } - }); + }); + + const data = await response.json(); + + if (!data.accessToken) { + throw new Error('Could not generate access token using refresh token'); + } + + return data.accessToken; }; diff --git a/src/utils/fetchAccessTokenUsingRefreshToken.test.ts b/src/utils/fetchAccessTokenUsingRefreshToken.test.ts new file mode 100644 index 0000000..44e86c7 --- /dev/null +++ b/src/utils/fetchAccessTokenUsingRefreshToken.test.ts @@ -0,0 +1,50 @@ +import { fetchAccessTokenUsingRefreshToken } from './fetchAccessTokenUsingRefreshToken'; + +jest.mock('../config', () => ({ + appConfig: { + API_URI: 'https://example.com', + }, +})); + +describe('fetchAccessTokenUsingRefreshToken', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should throw an error if no refresh token is provided', async () => { + await expect(fetchAccessTokenUsingRefreshToken('')).rejects.toThrow( + 'No refresh token available', + ); + }); + + it('should return the access token if the request is successful', async () => { + const mockAccessToken = 'new-access-token'; + global.fetch = jest.fn().mockResolvedValue({ + json: jest.fn().mockResolvedValue({ accessToken: mockAccessToken }), + } as any); + + const result = await fetchAccessTokenUsingRefreshToken( + 'valid-refresh-token', + ); + + expect(result).toBe(mockAccessToken); + }); + + it('should throw an error if the access token is not returned', async () => { + global.fetch = jest.fn().mockResolvedValue({ + json: jest.fn().mockResolvedValue({}), + } as any); + + await expect( + fetchAccessTokenUsingRefreshToken('valid-refresh-token'), + ).rejects.toThrow('Could not generate access token using refresh token'); + }); + + it('should throw an error if the fetch call fails', async () => { + global.fetch = jest.fn().mockRejectedValue(new Error('Network error')); + + await expect( + fetchAccessTokenUsingRefreshToken('valid-refresh-token'), + ).rejects.toThrow('Network error'); + }); +}); diff --git a/src/utils/fetchSymbols.ts b/src/utils/fetchSymbols.ts index 352014a..27ac18f 100644 --- a/src/utils/fetchSymbols.ts +++ b/src/utils/fetchSymbols.ts @@ -19,6 +19,7 @@ export const fetchSymbols = async (): Promise> => { return parsedResponse.symbols; } catch (error) { Sentry.captureException(error); - return DEFAULT_SYMBOLS; } + + return DEFAULT_SYMBOLS; }; diff --git a/src/utils/fetchWithToken.test.ts b/src/utils/fetchWithToken.test.ts new file mode 100644 index 0000000..f529dcb --- /dev/null +++ b/src/utils/fetchWithToken.test.ts @@ -0,0 +1,101 @@ +import { fetchWithToken } from './fetchWithToken'; + +describe('fetchWithToken', () => { + const mockUrl = 'https://api.example.com/data'; + const mockOptions = { method: 'GET' }; + + let mockFetch: jest.Mock; + let mockRefreshAccessToken: jest.Mock; + let mockSetAccessToken: jest.Mock; + + beforeEach(() => { + mockFetch = jest.fn(); + global.fetch = mockFetch; + + mockRefreshAccessToken = jest.fn(); + mockSetAccessToken = jest.fn(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should make a request with the access token', async () => { + mockFetch.mockResolvedValueOnce({ status: 200 } as Response); + + const response = await fetchWithToken({ + url: mockUrl, + options: mockOptions, + accessToken: 'initial-token', + refreshAccessToken: mockRefreshAccessToken, + }); + + expect(mockFetch).toHaveBeenCalledWith(mockUrl, { + ...mockOptions, + headers: { Authorization: 'Bearer initial-token' }, + }); + expect(response.status).toBe(200); + }); + + it('should refresh the access token and retry the request if the first request is unauthorized', async () => { + mockFetch + .mockResolvedValueOnce({ status: 401 } as Response) + .mockResolvedValueOnce({ status: 200 } as Response); + mockRefreshAccessToken.mockResolvedValueOnce('new-token'); + + const response = await fetchWithToken({ + url: mockUrl, + options: mockOptions, + accessToken: 'initial-token', + refreshAccessToken: mockRefreshAccessToken, + setAccessToken: mockSetAccessToken, + }); + + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(mockFetch).toHaveBeenNthCalledWith(1, mockUrl, { + ...mockOptions, + headers: { Authorization: 'Bearer initial-token' }, + }); + expect(mockFetch).toHaveBeenNthCalledWith(2, mockUrl, { + ...mockOptions, + headers: { Authorization: 'Bearer new-token' }, + }); + expect(mockRefreshAccessToken).toHaveBeenCalled(); + expect(mockSetAccessToken).toHaveBeenCalledWith('new-token'); + expect(response.status).toBe(200); + }); + + it('should throw an error if the refresh token process fails', async () => { + mockFetch.mockResolvedValueOnce({ status: 401 } as Response); + mockRefreshAccessToken.mockRejectedValueOnce( + new Error('Refresh token failed'), + ); + + await expect( + fetchWithToken({ + url: mockUrl, + options: mockOptions, + accessToken: 'initial-token', + refreshAccessToken: mockRefreshAccessToken, + }), + ).rejects.toThrow('Refresh token failed'); + + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(mockRefreshAccessToken).toHaveBeenCalled(); + }); + + it('should use default options if none are provided', async () => { + mockFetch.mockResolvedValueOnce({ status: 200 } as Response); + + const response = await fetchWithToken({ + url: mockUrl, + accessToken: 'initial-token', + refreshAccessToken: mockRefreshAccessToken, + }); + + expect(mockFetch).toHaveBeenCalledWith(mockUrl, { + headers: { Authorization: 'Bearer initial-token' }, + }); + expect(response.status).toBe(200); + }); +}); diff --git a/src/utils/fetchWithToken.ts b/src/utils/fetchWithToken.ts index 470f135..498053c 100644 --- a/src/utils/fetchWithToken.ts +++ b/src/utils/fetchWithToken.ts @@ -1,8 +1,6 @@ -import { isTokenExpiringSoon } from './isTokenExpiringSoon'; - interface FetchWithTokenParams { url: RequestInfo | URL; - options: RequestInit | undefined; + options?: RequestInit; accessToken: string | null; refreshAccessToken: () => Promise; setAccessToken?: (accessToken: string) => void; @@ -15,36 +13,31 @@ export const fetchWithToken = async ({ refreshAccessToken, setAccessToken, }: FetchWithTokenParams) => { - let newAccessToken = accessToken; - - // Check if the token is expiring soon. - // If it is, refresh the access token and set the new token. - if (accessToken && isTokenExpiringSoon(accessToken)) { - newAccessToken = await refreshAccessToken(); - } - - const headers = { - ...options.headers, - // Add the Authorization header to the request - ...(accessToken ? { Authorization: `Bearer ${newAccessToken}` } : {}), + const getHeaders = (accessToken: string | null) => { + return { + ...options.headers, + // Add the Authorization header to the request + ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}), + }; }; - const response = await fetch(url, { ...options, headers }); + let response = await fetch(url, { + ...options, + headers: getHeaders(accessToken), + }); if (response.status === 401) { // If the response is 401 Unauthorized, refresh the access token and try again. - newAccessToken = await refreshAccessToken(); + const newAccessToken = await refreshAccessToken(); if (setAccessToken) { setAccessToken(newAccessToken); } - const headers = { - ...options.headers, - ...(accessToken ? { Authorization: `Bearer ${newAccessToken}` } : {}), - }; - - return fetch(url, { ...options, headers }); + response = await fetch(url, { + ...options, + headers: getHeaders(newAccessToken), + }); } return response; diff --git a/src/utils/getCookieValue.test.ts b/src/utils/getCookieValue.test.ts new file mode 100644 index 0000000..21b0be1 --- /dev/null +++ b/src/utils/getCookieValue.test.ts @@ -0,0 +1,34 @@ +import { getCookieValue } from './getCookieValue'; + +describe('getCookieValue', () => { + beforeEach(() => { + // Clear document.cookie before each test + document.cookie = ''; + }); + + it('should return the value of the specified cookie', () => { + document.cookie = 'testCookie=testValue'; + expect(getCookieValue('testCookie')).toBe('testValue'); + }); + + it('should return null if the specified cookie does not exist', () => { + expect(getCookieValue('nonExistentCookie')).toBeNull(); + }); + + it('should return the correct value if multiple cookies are present', () => { + document.cookie = 'cookie1=value1'; + document.cookie = 'cookie2=value2'; + expect(getCookieValue('cookie1')).toBe('value1'); + expect(getCookieValue('cookie2')).toBe('value2'); + }); + + it('should handle cookies with spaces around the name', () => { + document.cookie = ' testCookieWithSpaces = testValue '; + expect(getCookieValue('testCookieWithSpaces')).toBe('testValue'); + }); + + it('should return null if the cookie value is empty', () => { + document.cookie = 'emptyCookie='; + expect(getCookieValue('emptyCookie')).toBe(null); + }); +}); diff --git a/src/utils/getCookieValue.ts b/src/utils/getCookieValue.ts index 1b2e63b..701a834 100644 --- a/src/utils/getCookieValue.ts +++ b/src/utils/getCookieValue.ts @@ -1,8 +1,4 @@ -export const getCookieValue = (name: string) => { +export const getCookieValue = (name: string): string | null => { const match = document.cookie.match(new RegExp('(^| )' + name + '=([^;]+)')); - if (match) { - return match[2]; - } - - return null; + return match ? match[2] : null; }; diff --git a/src/utils/isTokenExpiringSoon.ts b/src/utils/isTokenExpiringSoon.ts index 00227ca..4b73381 100644 --- a/src/utils/isTokenExpiringSoon.ts +++ b/src/utils/isTokenExpiringSoon.ts @@ -1,5 +1,6 @@ import { jwtDecode, JwtPayload } from 'jwt-decode'; import { z } from 'zod'; +import * as Sentry from '@sentry/react'; // Define the Zod schema for the JWT payload const jwtPayloadSchema = z.object({ @@ -12,7 +13,7 @@ export const isTokenExpiringSoon = ( ): boolean => { try { const decodedPayload = jwtDecode(jwt); - + // Validate the decoded payload const parsedPayload = jwtPayloadSchema.parse(decodedPayload); @@ -24,6 +25,8 @@ export const isTokenExpiringSoon = ( return Date.now() >= exp * 1000 - threshold; } catch (e) { - return true; + Sentry.captureException(e); } + + return true; }; diff --git a/src/utils/nonNullable.test.ts b/src/utils/nonNullable.test.ts new file mode 100644 index 0000000..04af252 --- /dev/null +++ b/src/utils/nonNullable.test.ts @@ -0,0 +1,27 @@ +import { nonNullable } from './nonNullable'; + +describe('nonNullable', () => { + it('should return false for null', () => { + expect(nonNullable(null)).toBe(false); + }); + + it('should return false for undefined', () => { + expect(nonNullable(undefined)).toBe(false); + }); + + it('should return true for a non-null value', () => { + expect(nonNullable(0)).toBe(true); + expect(nonNullable('')).toBe(true); + expect(nonNullable(false)).toBe(true); + expect(nonNullable([])).toBe(true); + expect(nonNullable({})).toBe(true); + expect(nonNullable('string')).toBe(true); + expect(nonNullable(123)).toBe(true); + }); + + it('should filter out null and undefined values from an array', () => { + const array = [1, null, 2, undefined, 3]; + const filteredArray = array.filter(nonNullable); + expect(filteredArray).toEqual([1, 2, 3]); + }); +});