From e53b690e5087d5ff5029286fe938333ae8420985 Mon Sep 17 00:00:00 2001 From: Samuel Imolorhe Date: Mon, 17 Jun 2024 18:59:59 +0200 Subject: [PATCH] added http handler tests --- packages/altair-core/jest.setup.js | 2 + .../src/request/handlers/http.spec.ts | 336 ++++++++++++++++-- .../altair-core/src/request/handlers/http.ts | 4 +- .../src/request/response-builder.spec.ts | 141 -------- 4 files changed, 320 insertions(+), 163 deletions(-) diff --git a/packages/altair-core/jest.setup.js b/packages/altair-core/jest.setup.js index 8d01aafa07..dd20d4688d 100644 --- a/packages/altair-core/jest.setup.js +++ b/packages/altair-core/jest.setup.js @@ -9,6 +9,7 @@ */ const { TextDecoder, TextEncoder } = require('node:util'); +const { ReadableStream, TransformStream } = require('node:stream/web'); const crypto = require('node:crypto'); const { clearImmediate } = require('node:timers'); @@ -26,6 +27,7 @@ Object.defineProperties(globalThis, { }, TextDecoder: { value: TextDecoder }, TextEncoder: { value: TextEncoder }, + ReadableStream: { value: ReadableStream }, clearImmediate: { value: clearImmediate }, }); diff --git a/packages/altair-core/src/request/handlers/http.spec.ts b/packages/altair-core/src/request/handlers/http.spec.ts index 1fbbd07125..acfb68ecb5 100644 --- a/packages/altair-core/src/request/handlers/http.spec.ts +++ b/packages/altair-core/src/request/handlers/http.spec.ts @@ -7,25 +7,97 @@ import { it, jest, } from '@jest/globals'; -import { HttpResponse, http } from 'msw'; +import { HttpResponse, http, delay } from 'msw'; import { setupServer } from 'msw/node'; -import { GraphQLRequestOptions } from '../types'; +import { GraphQLRequestHandler, GraphQLRequestOptions } from '../types'; import { HttpRequestHandler } from './http'; +import { Observable } from 'rxjs'; +import { QueryResponse } from '../../types/state/query.interfaces'; const server = setupServer( http.post('http://localhost:3000/graphql', () => { + return Response.json({ data: { hello: 'world' } }); + }), + http.post('http://localhost:3000/error', () => { + return Response.error(); + }), + http.post('http://localhost:3000/delay', async () => { + await delay(1000); + return Response.json({ data: { hello: 'world' } }); + }), + http.post('http://localhost:3000/simple-stream', async () => { const encoder = new TextEncoder(); const stream = new ReadableStream({ - start(controller) { - // Encode the string chunks using "TextEncoder". - controller.enqueue(encoder.encode('Brand')); - // controller.enqueue(encoder.encode('New')); - // controller.enqueue(encoder.encode('World')); + async start(controller) { + await delay(10); + controller.enqueue(encoder.encode('{"data":{"hello":"world"}}')); + await delay(10); + controller.enqueue(encoder.encode('{"data":{"bye":"longer"}}')); + await delay(10); + controller.enqueue(encoder.encode('{"data":{"rest":"afva"}}')); + await delay(10); controller.close(); }, }); + return new Response(stream, { - headers: { 'Content-Type': 'text/plain' }, + headers: { + 'Content-Type': 'application/json', + }, + }); + }), + http.post('http://localhost:3000/error-stream', async () => { + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + async start(controller) { + await delay(10); + controller.enqueue(encoder.encode('{"data":{"hello":"world"}}')); + await delay(10); + controller.error('random stream error'); + await delay(10); + controller.enqueue(encoder.encode('{"data":{"bye":"longer"}}')); + await delay(10); + controller.close(); + }, + }); + + return new Response(stream, { + headers: { + 'Content-Type': 'application/json', + }, + }); + }), + http.post('http://localhost:3000/multipart-stream', async () => { + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + async start(controller) { + await delay(10); + controller.enqueue( + encoder.encode( + `---\r\nContent-Type: application/json; charset=utf-8\r\nContent-Length: 108\r\n\r\n{"data":{"hello":"Hello world","alphabet":[],"fastField":"This field resolves fast! ⚡️"},"hasNext":true}\r\n---\r\nContent-Type: application/json; charset=utf-8\r\nContent-Length: 70\r\n\r\n{"incremental":[{"items":["a"],"path":["alphabet",0]}],"hasNext":true}\r\n---\r\nContent-Type: application/json; charset=utf-8\r\nContent-Length: 70\r\n\r\n{"incremental":[{"items":["b"],"path":["alphabet",1]}],"hasNext":true}\r\n---` + ) + ); + await delay(10); + controller.enqueue( + encoder.encode( + '\r\nContent-Type: application/json; charset=utf-8\r\nContent-Length: 70\r\n\r\n{"incremental":[{"items":["c"],"path":["alphabet",2]}],"hasNext":true}\r\n---' + ) + ); + await delay(10); + controller.enqueue( + encoder.encode( + '\r\nContent-Type: application/json; charset=utf-8\r\nContent-Length: 70\r\n\r\n{"incremental":[{"items":["d"],"path":["alphabet",3]}],"hasNext":true}\r\n---' + ) + ); + await delay(10); + controller.close(); + }, + }); + + return new Response(stream, { + headers: { + 'Content-Type': 'multipart/mixed; boundary=-', + }, }); }) ); @@ -34,10 +106,31 @@ beforeAll(() => server.listen()); afterEach(() => server.resetHandlers()); afterAll(() => server.close()); +const testObserver = (o: Observable) => { + const values: unknown[] = []; + let s: any; + + return new Promise((resolve, reject) => { + s = o.subscribe({ + next: (value) => { + values.push(value); + }, + error: (err) => { + s.unsubscribe(); + return reject(err); + }, + complete: () => { + s.unsubscribe(); + return resolve(values); + }, + }); + }).finally(() => { + s?.unsubscribe(); + }); +}; + describe('HTTP handler', () => { - // for some reason the readable stream is not returning done as true so we never get to the end of the stream - it.skip('should properly handle normal successful HTTP requests', () => { - // Test code here + it('should properly handle normal successful HTTP requests', async () => { const request: GraphQLRequestOptions = { url: 'http://localhost:3000/graphql', method: 'POST', @@ -54,16 +147,219 @@ describe('HTTP handler', () => { selectedOperation: 'hello', }; - const http = new HttpRequestHandler(); - const observer = { - next: jest.fn(), - error: jest.fn(), - complete: jest.fn(), + const http: GraphQLRequestHandler = new HttpRequestHandler(); + const res = await testObserver(http.handle(request)); + + expect(res).toEqual([ + expect.objectContaining({ + ok: true, + data: '{"data":{"hello":"world"}}', + headers: expect.any(Object), + status: 200, + url: 'http://localhost:3000/graphql', + requestStartTimestamp: expect.any(Number), + requestEndTimestamp: expect.any(Number), + resopnseTimeMs: expect.any(Number), + }), + ]); + }); + + it('should properly handle failed HTTP requests', async () => { + const request: GraphQLRequestOptions = { + url: 'http://localhost:3000/error', + method: 'POST', + additionalParams: { + testData: [ + { + hello: 'world', + }, + ], + }, + headers: [], + query: 'query { hello }', + variables: {}, + selectedOperation: 'hello', }; - http.handle(request).subscribe(observer); - expect(observer.next).toHaveBeenCalledWith({}); - expect(observer.error).not.toHaveBeenCalled(); - expect(observer.complete).toHaveBeenCalled(); + const http: GraphQLRequestHandler = new HttpRequestHandler(); + + expect(testObserver(http.handle(request))).rejects.toThrow(); + }); + + it('should properly handle aborting the request', () => { + const request: GraphQLRequestOptions = { + url: 'http://localhost:3000/delay', + method: 'POST', + additionalParams: { + testData: [ + { + hello: 'world', + }, + ], + }, + headers: [], + query: 'query { hello }', + variables: {}, + selectedOperation: 'hello', + }; + + const http: GraphQLRequestHandler = new HttpRequestHandler(); + const res = http.handle(request); + + res.subscribe().unsubscribe(); + }); + + it('should properly handle streamed responses', async () => { + const request: GraphQLRequestOptions = { + url: 'http://localhost:3000/simple-stream', + method: 'POST', + additionalParams: { + testData: [ + { + hello: 'world', + }, + ], + }, + headers: [], + query: 'query { hello }', + variables: {}, + selectedOperation: 'hello', + }; + + const http: GraphQLRequestHandler = new HttpRequestHandler(); + const res = await testObserver(http.handle(request)); + + expect(res).toEqual([ + expect.objectContaining({ + ok: true, + data: '{"data":{"hello":"world"}}', + headers: expect.any(Object), + status: 200, + url: 'http://localhost:3000/simple-stream', + requestStartTimestamp: expect.any(Number), + requestEndTimestamp: expect.any(Number), + resopnseTimeMs: expect.any(Number), + }), + expect.objectContaining({ + ok: true, + data: '{"data":{"bye":"longer"}}', + headers: expect.any(Object), + status: 200, + url: 'http://localhost:3000/simple-stream', + requestStartTimestamp: expect.any(Number), + requestEndTimestamp: expect.any(Number), + resopnseTimeMs: expect.any(Number), + }), + expect.objectContaining({ + ok: true, + data: '{"data":{"rest":"afva"}}', + headers: expect.any(Object), + status: 200, + url: 'http://localhost:3000/simple-stream', + requestStartTimestamp: expect.any(Number), + requestEndTimestamp: expect.any(Number), + resopnseTimeMs: expect.any(Number), + }), + ]); + }); + + it('should properly handle streamed responses with errors', async () => { + const request: GraphQLRequestOptions = { + url: 'http://localhost:3000/error-stream', + method: 'POST', + additionalParams: { + testData: [ + { + hello: 'world', + }, + ], + }, + headers: [], + query: 'query { hello }', + variables: {}, + selectedOperation: 'hello', + }; + + const http: GraphQLRequestHandler = new HttpRequestHandler(); + try { + await testObserver(http.handle(request)); + expect(true).toBe(false); // it should not get here + } catch (err) { + expect(err).toBe('random stream error'); + } + }); + + it('should properly handle multipart streamed responses', async () => { + const request: GraphQLRequestOptions = { + url: 'http://localhost:3000/multipart-stream', + method: 'POST', + additionalParams: { + testData: [ + { + hello: 'world', + }, + ], + }, + headers: [], + query: 'query { hello }', + variables: {}, + selectedOperation: 'hello', + }; + + const http: GraphQLRequestHandler = new HttpRequestHandler(); + const res = await testObserver(http.handle(request)); + + expect(res).toEqual([ + expect.objectContaining({ + ok: true, + data: '{"data":{"hello":"Hello world","alphabet":[],"fastField":"This field resolves fast! ⚡️"},"hasNext":true}', + headers: expect.any(Object), + status: 200, + url: 'http://localhost:3000/multipart-stream', + requestStartTimestamp: expect.any(Number), + requestEndTimestamp: expect.any(Number), + resopnseTimeMs: expect.any(Number), + }), + expect.objectContaining({ + ok: true, + data: '{"incremental":[{"items":["a"],"path":["alphabet",0]}],"hasNext":true}', + headers: expect.any(Object), + status: 200, + url: 'http://localhost:3000/multipart-stream', + requestStartTimestamp: expect.any(Number), + requestEndTimestamp: expect.any(Number), + resopnseTimeMs: expect.any(Number), + }), + expect.objectContaining({ + ok: true, + data: '{"incremental":[{"items":["b"],"path":["alphabet",1]}],"hasNext":true}', + headers: expect.any(Object), + status: 200, + url: 'http://localhost:3000/multipart-stream', + requestStartTimestamp: expect.any(Number), + requestEndTimestamp: expect.any(Number), + resopnseTimeMs: expect.any(Number), + }), + expect.objectContaining({ + ok: true, + data: '{"incremental":[{"items":["c"],"path":["alphabet",2]}],"hasNext":true}', + headers: expect.any(Object), + status: 200, + url: 'http://localhost:3000/multipart-stream', + requestStartTimestamp: expect.any(Number), + requestEndTimestamp: expect.any(Number), + resopnseTimeMs: expect.any(Number), + }), + expect.objectContaining({ + ok: true, + data: '{"incremental":[{"items":["d"],"path":["alphabet",3]}],"hasNext":true}', + headers: expect.any(Object), + status: 200, + url: 'http://localhost:3000/multipart-stream', + requestStartTimestamp: expect.any(Number), + requestEndTimestamp: expect.any(Number), + resopnseTimeMs: expect.any(Number), + }), + ]); }); }); diff --git a/packages/altair-core/src/request/handlers/http.ts b/packages/altair-core/src/request/handlers/http.ts index 66f5067c8e..3760d0bf31 100644 --- a/packages/altair-core/src/request/handlers/http.ts +++ b/packages/altair-core/src/request/handlers/http.ts @@ -76,7 +76,7 @@ export class HttpRequestHandler implements GraphQLRequestHandler { } // Handle the response from meros - from(merosResponse).subscribe({ + return from(merosResponse).subscribe({ next: (chunk) => { this.emitChunk( response, @@ -103,7 +103,7 @@ export class HttpRequestHandler implements GraphQLRequestHandler { }); }) .catch((error) => { - if (error.name === 'AbortError') { + if (error?.name === 'AbortError') { // Request was aborted observer.complete(); return; diff --git a/packages/altair-core/src/request/response-builder.spec.ts b/packages/altair-core/src/request/response-builder.spec.ts index 6bcd69f7f3..6f123a3e1b 100644 --- a/packages/altair-core/src/request/response-builder.spec.ts +++ b/packages/altair-core/src/request/response-builder.spec.ts @@ -2,147 +2,6 @@ import { describe, expect, it } from '@jest/globals'; import { buildResponse } from './response-builder'; import { QueryResponse } from '../types/state/query.interfaces'; import { MultiResponseStrategy } from './types'; -const multipartChunks: QueryResponse[] = [ - { - content: - '---\r\nContent-Type: application/json; charset=utf-8\r\nContent-Length: 108\r\n\r\n{"data":{"hello":"Hello world","alphabet":[],"fastField":"This field resolves fast! ⚡️"},"hasNext":true}\r\n---\r\nContent-Type: application/json; charset=utf-8\r\nContent-Length: 70\r\n\r\n{"incremental":[{"items":["a"],"path":["alphabet",0]}],"hasNext":true}\r\n---\r\nContent-Type: application/json; charset=utf-8\r\nContent-Length: 70\r\n\r\n{"incremental":[{"items":["b"],"path":["alphabet",1]}],"hasNext":true}\r\n---', - timestamp: 1718252802585, - }, - { - content: - '\r\nContent-Type: application/json; charset=utf-8\r\nContent-Length: 70\r\n\r\n{"incremental":[{"items":["c"],"path":["alphabet",2]}],"hasNext":true}\r\n---', - timestamp: 1718252802585, - }, - { - content: - '\r\nContent-Type: application/json; charset=utf-8\r\nContent-Length: 70\r\n\r\n{"incremental":[{"items":["d"],"path":["alphabet",3]}],"hasNext":true}\r\n---', - timestamp: 1718252802585, - }, - { - content: - '\r\nContent-Type: application/json; charset=utf-8\r\nContent-Length: 70\r\n\r\n{"incremental":[{"items":["e"],"path":["alphabet",4]}],"hasNext":true}\r\n---', - timestamp: 1718252802585, - }, - { - content: - '\r\nContent-Type: application/json; charset=utf-8\r\nContent-Length: 111\r\n\r\n{"incremental":[{"data":{"slowField":"This field resolves slowly after 5000ms ⏳"},"path":[]}],"hasNext":true}\r\n---', - timestamp: 1718252802585, - }, - { - content: - '\r\nContent-Type: application/json; charset=utf-8\r\nContent-Length: 70\r\n\r\n{"incremental":[{"items":["f"],"path":["alphabet",5]}],"hasNext":true}\r\n---', - timestamp: 1718252802585, - }, - { - content: - '\r\nContent-Type: application/json; charset=utf-8\r\nContent-Length: 70\r\n\r\n{"incremental":[{"items":["g"],"path":["alphabet",6]}],"hasNext":true}\r\n---', - timestamp: 1718252802585, - }, - { - content: - '\r\nContent-Type: application/json; charset=utf-8\r\nContent-Length: 70\r\n\r\n{"incremental":[{"items":["h"],"path":["alphabet",7]}],"hasNext":true}\r\n---', - timestamp: 1718252802585, - }, - { - content: - '\r\nContent-Type: application/json; charset=utf-8\r\nContent-Length: 70\r\n\r\n{"incremental":[{"items":["i"],"path":["alphabet",8]}],"hasNext":true}\r\n---', - timestamp: 1718252802585, - }, - { - content: - '\r\nContent-Type: application/json; charset=utf-8\r\nContent-Length: 70\r\n\r\n{"incremental":[{"items":["j"],"path":["alphabet",9]}],"hasNext":true}\r\n---', - timestamp: 1718252802585, - }, - { - content: - '\r\nContent-Type: application/json; charset=utf-8\r\nContent-Length: 71\r\n\r\n{"incremental":[{"items":["k"],"path":["alphabet",10]}],"hasNext":true}\r\n---', - timestamp: 1718252802585, - }, - { - content: - '\r\nContent-Type: application/json; charset=utf-8\r\nContent-Length: 71\r\n\r\n{"incremental":[{"items":["l"],"path":["alphabet",11]}],"hasNext":true}\r\n---', - timestamp: 1718252802585, - }, - { - content: - '\r\nContent-Type: application/json; charset=utf-8\r\nContent-Length: 71\r\n\r\n{"incremental":[{"items":["m"],"path":["alphabet",12]}],"hasNext":true}\r\n---', - timestamp: 1718252802585, - }, - { - content: - '\r\nContent-Type: application/json; charset=utf-8\r\nContent-Length: 71\r\n\r\n{"incremental":[{"items":["n"],"path":["alphabet",13]}],"hasNext":true}\r\n---', - timestamp: 1718252802585, - }, - { - content: - '\r\nContent-Type: application/json; charset=utf-8\r\nContent-Length: 71\r\n\r\n{"incremental":[{"items":["o"],"path":["alphabet",14]}],"hasNext":true}\r\n---', - timestamp: 1718252802585, - }, - { - content: - '\r\nContent-Type: application/json; charset=utf-8\r\nContent-Length: 71\r\n\r\n{"incremental":[{"items":["p"],"path":["alphabet",15]}],"hasNext":true}\r\n---', - timestamp: 1718252802585, - }, - { - content: - '\r\nContent-Type: application/json; charset=utf-8\r\nContent-Length: 71\r\n\r\n{"incremental":[{"items":["q"],"path":["alphabet",16]}],"hasNext":true}\r\n---', - timestamp: 1718252802585, - }, - { - content: - '\r\nContent-Type: application/json; charset=utf-8\r\nContent-Length: 71\r\n\r\n{"incremental":[{"items":["r"],"path":["alphabet",17]}],"hasNext":true}\r\n---', - timestamp: 1718252802585, - }, - { - content: - '\r\nContent-Type: application/json; charset=utf-8\r\nContent-Length: 71\r\n\r\n{"incremental":[{"items":["s"],"path":["alphabet",18]}],"hasNext":true}\r\n---', - timestamp: 1718252802585, - }, - { - content: - '\r\nContent-Type: application/json; charset=utf-8\r\nContent-Length: 71\r\n\r\n{"incremental":[{"items":["t"],"path":["alphabet",19]}],"hasNext":true}\r\n---', - timestamp: 1718252802585, - }, - { - content: - '\r\nContent-Type: application/json; charset=utf-8\r\nContent-Length: 71\r\n\r\n{"incremental":[{"items":["u"],"path":["alphabet",20]}],"hasNext":true}\r\n---', - timestamp: 1718252802585, - }, - { - content: - '\r\nContent-Type: application/json; charset=utf-8\r\nContent-Length: 71\r\n\r\n{"incremental":[{"items":["v"],"path":["alphabet",21]}],"hasNext":true}\r\n---', - timestamp: 1718252802585, - }, - { - content: - '\r\nContent-Type: application/json; charset=utf-8\r\nContent-Length: 71\r\n\r\n{"incremental":[{"items":["w"],"path":["alphabet",22]}],"hasNext":true}\r\n---', - timestamp: 1718252802585, - }, - { - content: - '\r\nContent-Type: application/json; charset=utf-8\r\nContent-Length: 71\r\n\r\n{"incremental":[{"items":["x"],"path":["alphabet",23]}],"hasNext":true}\r\n---', - timestamp: 1718252802585, - }, - { - content: - '\r\nContent-Type: application/json; charset=utf-8\r\nContent-Length: 71\r\n\r\n{"incremental":[{"items":["y"],"path":["alphabet",24]}],"hasNext":true}\r\n---', - timestamp: 1718252802585, - }, - { - content: - '\r\nContent-Type: application/json; charset=utf-8\r\nContent-Length: 71\r\n\r\n{"incremental":[{"items":["z"],"path":["alphabet",25]}],"hasNext":true}\r\n---', - timestamp: 1718252802585, - }, - { - content: - '\r\nContent-Type: application/json; charset=utf-8\r\nContent-Length: 17\r\n\r\n{"hasNext":false}\r\n---', - timestamp: 1718252802585, - }, - { - content: '--\r\n', - timestamp: 1718252802585, - }, -]; const responseChunks: QueryResponse[] = [ {