Skip to content

Commit

Permalink
Write more unit tests and refactor code
Browse files Browse the repository at this point in the history
  • Loading branch information
nushydude committed Jul 2, 2024
1 parent ccb8c24 commit e2f8503
Show file tree
Hide file tree
Showing 9 changed files with 250 additions and 43 deletions.
24 changes: 13 additions & 11 deletions src/utils/fetchAccessTokenUsingRefreshToken.js
Original file line number Diff line number Diff line change
@@ -1,21 +1,23 @@
import { appConfig } from '../config';

export const fetchAccessTokenUsingRefreshToken = async (refreshToken) => {
export const fetchAccessTokenUsingRefreshToken = async (
refreshToken: string,
): Promise<string> => {
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;
};
50 changes: 50 additions & 0 deletions src/utils/fetchAccessTokenUsingRefreshToken.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
3 changes: 2 additions & 1 deletion src/utils/fetchSymbols.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export const fetchSymbols = async (): Promise<Array<string>> => {
return parsedResponse.symbols;
} catch (error) {
Sentry.captureException(error);
return DEFAULT_SYMBOLS;
}

return DEFAULT_SYMBOLS;
};
101 changes: 101 additions & 0 deletions src/utils/fetchWithToken.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
39 changes: 16 additions & 23 deletions src/utils/fetchWithToken.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import { isTokenExpiringSoon } from './isTokenExpiringSoon';

interface FetchWithTokenParams {
url: RequestInfo | URL;
options: RequestInit | undefined;
options?: RequestInit;
accessToken: string | null;
refreshAccessToken: () => Promise<string>;
setAccessToken?: (accessToken: string) => void;
Expand All @@ -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;
Expand Down
34 changes: 34 additions & 0 deletions src/utils/getCookieValue.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
8 changes: 2 additions & 6 deletions src/utils/getCookieValue.ts
Original file line number Diff line number Diff line change
@@ -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;
};
7 changes: 5 additions & 2 deletions src/utils/isTokenExpiringSoon.ts
Original file line number Diff line number Diff line change
@@ -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({
Expand All @@ -12,7 +13,7 @@ export const isTokenExpiringSoon = (
): boolean => {
try {
const decodedPayload = jwtDecode<JwtPayload>(jwt);

// Validate the decoded payload
const parsedPayload = jwtPayloadSchema.parse(decodedPayload);

Expand All @@ -24,6 +25,8 @@ export const isTokenExpiringSoon = (

return Date.now() >= exp * 1000 - threshold;
} catch (e) {
return true;
Sentry.captureException(e);
}

return true;
};
27 changes: 27 additions & 0 deletions src/utils/nonNullable.test.ts
Original file line number Diff line number Diff line change
@@ -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]);
});
});

0 comments on commit e2f8503

Please sign in to comment.