diff --git a/src/infrastructure/graphql/fetchGraphqlClient.ts b/src/infrastructure/graphql/fetchGraphqlClient.ts index 519e90b7..073996ce 100644 --- a/src/infrastructure/graphql/fetchGraphqlClient.ts +++ b/src/infrastructure/graphql/fetchGraphqlClient.ts @@ -48,25 +48,41 @@ export class FetchGraphqlClient implements GraphqlClient { }), }); - return response.json().then(result => { - const {data, errors} = result as GraphqlResponseBody; + const {data, errors} = await FetchGraphqlClient.parseBody(response); - if (errors !== undefined) { - throw new ApiError( - errors[0].message.replace(/"/g, '`'), - errors.map( - ({extensions}) => ({ - ...extensions, - detail: extensions.detail?.replace(/"/g, '`'), - }), - ), - ); - } + if (errors !== undefined) { + throw new ApiError( + errors[0].message.replace(/"/g, '`'), + errors.map( + ({extensions}) => ({ + ...extensions, + detail: extensions.detail?.replace(/"/g, '`'), + }), + ), + ); + } - return { - data: data, - headers: response.headers, - }; - }); + return { + data: data, + headers: response.headers, + }; + } + + private static async parseBody(response: Response): Promise> { + const body = await response.text(); + + let payload: unknown; + + try { + payload = JSON.parse(body); + } catch { + payload = null; + } + + if (typeof payload !== 'object' || payload === null) { + throw new ApiError(body); + } + + return payload as GraphqlResponseBody; } } diff --git a/test/infrastructure/graphql/fetchGraphqlClient.test.ts b/test/infrastructure/graphql/fetchGraphqlClient.test.ts new file mode 100644 index 00000000..1a6b3236 --- /dev/null +++ b/test/infrastructure/graphql/fetchGraphqlClient.test.ts @@ -0,0 +1,129 @@ +import {TypedDocumentString} from '@/infrastructure/graphql/schema/graphql'; +import {FetchGraphqlClient} from '@/infrastructure/graphql/fetchGraphqlClient'; +import {ApiError, ProblemType} from '@/application/api/error'; + +describe('A fetch GraphQL client', () => { + const endpoint = new URL('https://example.com/graphql'); + + type TestResult = { + greeting: string, + }; + + const query = new TypedDocumentString>('query Test { greeting }'); + + it('should return the data from a valid payload', async () => { + jest.spyOn(globalThis, 'fetch').mockResolvedValue( + Response.json({ + data: { + greeting: 'Hello', + }, + }), + ); + + const client = new FetchGraphqlClient({endpoint: endpoint}); + + await expect(client.execute(query)).resolves.toEqual({ + headers: expect.any(Headers), + data: { + greeting: 'Hello', + }, + }); + }); + + it('should use the token provider and send the token', async () => { + const fetchMock = jest.spyOn(globalThis, 'fetch').mockResolvedValue( + Response.json({ + data: { + greeting: 'Hello', + }, + }), + ); + + const getToken = jest.fn().mockResolvedValue('token-value'); + + const client = new FetchGraphqlClient({ + endpoint: endpoint, + tokenProvider: {getToken: getToken}, + }); + + await client.execute(query); + + expect(getToken).toHaveBeenCalled(); + expect(fetchMock).toHaveBeenCalledWith( + endpoint, + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer token-value', + }), + }), + ); + }); + + type MalformedBodyScenario = { + body: string, + status: number, + }; + + it.each<[string, MalformedBodyScenario]>( + Object.entries({ + 'report a gateway error as it came': { + body: 'upstream connect error or disconnect/reset before headers. ' + + 'reset reason: connection termination', + status: 503, + }, + 'report an HTML error page as it came': { + body: '

502 Bad Gateway

', + status: 502, + }, + 'report an empty body as it came': { + body: '', + status: 500, + }, + 'report a payload that is not an object as it came': { + body: 'null', + status: 200, + }, + }), + )('should %s', async (_, scenario) => { + const {body, status} = scenario; + + jest.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(body, {status: status})); + + const client = new FetchGraphqlClient({endpoint: endpoint}); + + const error = await client.execute(query).catch(reason => reason); + + expect(error).toBeInstanceOf(ApiError); + expect(error.message).toBe(body); + }); + + it('should report the errors of a non-successful response', async () => { + jest.spyOn(globalThis, 'fetch').mockResolvedValue( + Response.json( + { + errors: [ + { + message: 'Invalid input', + extensions: { + type: ProblemType.INVALID_INPUT, + title: 'Invalid input', + detail: 'Cannot query field "greeting" on type "Query".', + status: 400, + }, + }, + ], + }, + {status: 400}, + ), + ); + + const client = new FetchGraphqlClient({endpoint: endpoint}); + + const error = await client.execute(query).catch(reason => reason); + + expect(error).toBeInstanceOf(ApiError); + expect(error.message).toBe('Invalid input'); + expect(error.isErrorType(ProblemType.INVALID_INPUT)).toBe(true); + expect(error.problems[0].detail).toBe('Cannot query field `greeting` on type `Query`.'); + }); +});