-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(api): implement API request utility and test suite
- 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
Showing
2 changed files
with
236 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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'); | ||
} | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
}); | ||
}); |