Skip to content

Commit

Permalink
feat(api): implement API request utility and test suite
Browse files Browse the repository at this point in the history
- Define `apiRequest` function in `api.ts` with `GET`, `POST`, `PUT`, and `DELETE` support.
- Add tests for `apiRequest`, covering scenarios like:
  - Successful GET and POST requests.
  - Error responses with flash messages.
  - Custom headers handling.
  • Loading branch information
ishaan000 committed Dec 22, 2024
1 parent e78e9c8 commit c37ad42
Show file tree
Hide file tree
Showing 2 changed files with 236 additions and 0 deletions.
74 changes: 74 additions & 0 deletions src/hooks/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
type Method = 'GET' | 'POST' | 'PUT' | 'DELETE';

interface ApiOptions {
method?: Method;
body?: Record<string, unknown>;
headers?: Record<string, string>;
token?: string;
}

interface ApiError extends Error {
flash?: Record<string, unknown>;
statusCode?: number;
}

const API_BASE_URL = (() => {
return process.env.BACKEND_URL || 'http://localhost:3000';
})();

export const apiRequest = async <T>(
url: string,
{ method = 'GET', body, headers = {}, token }: ApiOptions = {}
): Promise<T> => {
const defaultHeaders: Record<string, string> = {
'Content-Type': 'application/json',
};

const authHeaders: Record<string, string> = token
? { Authorization: `Bearer ${token}` }
: {};

try {
const response = await fetch(`${API_BASE_URL}${url}`, {
method,
headers: {
...defaultHeaders,
...authHeaders,
...headers,
},
body: body ? JSON.stringify(body) : undefined,
});

const jsonResponse: T = await response.json();

if (!response.ok) {
console.error('API Error Response:', jsonResponse);

interface JsonResponseWithFlash {
flash?: Record<string, unknown>;
}

const flashMessages =
(jsonResponse as JsonResponseWithFlash)?.flash || {};
const errorMessages = flashMessages as {
error?: string[];
info?: string[];
};
const errorMessage =
errorMessages.error?.[0] || errorMessages.info?.[0] || 'API Error';

const apiError: ApiError = new Error(errorMessage);
apiError.flash = flashMessages;
apiError.statusCode = response.status;
throw apiError;
}

return jsonResponse;
} catch (error) {
if (error instanceof Error && 'flash' in error) {
throw error;
}
console.error('Unexpected API Error:', error);
throw new Error('Unexpected API Error');
}
};
162 changes: 162 additions & 0 deletions tests/app/hooks/api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import { apiRequest } from '../../../src/hooks/api';

describe('apiRequest', () => {
const originalEnv = process.env;
const originalConsoleError = console.error;

beforeAll(() => {
jest.resetModules();
process.env = {
...originalEnv,
BACKEND_URL: 'http://mock-backend-url.com',
};
console.error = jest.fn();
});

afterAll(() => {
process.env = originalEnv;
console.error = originalConsoleError;
});

beforeEach(() => {
global.fetch = jest.fn();
});

afterEach(() => {
jest.clearAllMocks();
});

it('should construct the correct URL using the mocked BACKEND_URL', async () => {
const endpoint = '/test-endpoint';
const mockResponse = { data: 'test data' };

(global.fetch as jest.Mock).mockResolvedValueOnce({
ok: true,
json: async () => mockResponse,
});

const result = await apiRequest(endpoint);

expect(global.fetch).toHaveBeenCalledWith(
'http://mock-backend-url.com/test-endpoint',
{
method: 'GET',
headers: { 'Content-Type': 'application/json' },
body: undefined,
}
);

expect(result).toEqual(mockResponse);
});

it('should throw an error for a failed response with flash messages', async () => {
const endpoint = '/error-endpoint';
const mockErrorResponse = { flash: { error: ['Invalid request'] } };

(global.fetch as jest.Mock).mockResolvedValueOnce({
ok: false,
json: async () => mockErrorResponse,
status: 400,
});

const consoleErrorSpy = jest.spyOn(console, 'error');

await expect(apiRequest(endpoint)).rejects.toThrow(
expect.objectContaining({
message: 'Invalid request',
flash: { error: ['Invalid request'] },
})
);

expect(consoleErrorSpy).toHaveBeenCalledWith(
'API Error Response:',
mockErrorResponse
);

consoleErrorSpy.mockRestore();
});

it('should throw a generic error for an unexpected response structure', async () => {
const endpoint = '/unexpected-error';
const mockErrorResponse = {};

(global.fetch as jest.Mock).mockResolvedValueOnce({
ok: false,
json: async () => mockErrorResponse,
status: 500,
});

const consoleErrorSpy = jest.spyOn(console, 'error');

await expect(apiRequest(endpoint)).rejects.toThrow('API Error');

// Assert that the error was logged
expect(consoleErrorSpy).toHaveBeenCalledWith(
'API Error Response:',
mockErrorResponse
);

consoleErrorSpy.mockRestore();
});

it('should send a POST request with a request body and token', async () => {
const endpoint = '/post-endpoint';
const requestBody = { name: 'Test User' };
const token = 'mock-token';
const mockResponse = { success: true };

(global.fetch as jest.Mock).mockResolvedValueOnce({
ok: true,
json: async () => mockResponse,
});

const result = await apiRequest(endpoint, {
method: 'POST',
body: requestBody,
token,
});

expect(global.fetch).toHaveBeenCalledWith(
'http://mock-backend-url.com/post-endpoint',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(requestBody),
}
);

expect(result).toEqual(mockResponse);
});

it('should handle a request with custom headers', async () => {
const endpoint = '/custom-headers';
const customHeaders = { 'X-Custom-Header': 'CustomValue' };
const mockResponse = { data: 'custom header response' };

(global.fetch as jest.Mock).mockResolvedValueOnce({
ok: true,
json: async () => mockResponse,
});

const result = await apiRequest(endpoint, {
headers: customHeaders,
});

expect(global.fetch).toHaveBeenCalledWith(
'http://mock-backend-url.com/custom-headers',
{
method: 'GET',
headers: {
'Content-Type': 'application/json',
...customHeaders,
},
body: undefined,
}
);

expect(result).toEqual(mockResponse);
});
});

0 comments on commit c37ad42

Please sign in to comment.