Skip to content

Commit ae5260f

Browse files
committed
integration tests
1 parent 944ee2e commit ae5260f

File tree

5 files changed

+279
-64
lines changed

5 files changed

+279
-64
lines changed

examples/fastify/__integration-tests__/fastify.spec.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,4 +251,66 @@ data"
251251
},
252252
});
253253
});
254+
it('request cancelation', async () => {
255+
const slowFieldResolverInvoked = createDeferred();
256+
const slowFieldResolverCanceled = createDeferred();
257+
const address = await app.listen({
258+
port: 0,
259+
});
260+
261+
// we work with logger statements to detect when the slow field resolver is invoked and when it is canceled
262+
const loggerOverwrite = (part: unknown) => {
263+
if (part === 'Slow resolver invoked resolved') {
264+
slowFieldResolverInvoked.resolve();
265+
}
266+
if (part === 'Slow field got cancelled') {
267+
slowFieldResolverCanceled.resolve();
268+
}
269+
};
270+
271+
const info = app.log.info;
272+
app.log.info = loggerOverwrite;
273+
274+
try {
275+
const abortController = new AbortController();
276+
const response$ = fetch(`${address}/graphql`, {
277+
method: 'POST',
278+
headers: {
279+
'content-type': 'application/json',
280+
},
281+
body: JSON.stringify({
282+
query: /* GraphQL */ `
283+
query {
284+
slow {
285+
field
286+
}
287+
}
288+
`,
289+
}),
290+
signal: abortController.signal,
291+
});
292+
293+
await slowFieldResolverInvoked.promise;
294+
abortController.abort();
295+
await expect(response$).rejects.toMatchInlineSnapshot(`DOMException {}`);
296+
await slowFieldResolverCanceled.promise;
297+
} finally {
298+
app.log.info = info;
299+
}
300+
});
254301
});
302+
303+
type Deferred<T = void> = {
304+
resolve: (value: T) => void;
305+
reject: (value: unknown) => void;
306+
promise: Promise<T>;
307+
};
308+
309+
function createDeferred<T = void>(): Deferred<T> {
310+
const d = {} as Deferred<T>;
311+
d.promise = new Promise<T>((resolve, reject) => {
312+
d.resolve = resolve;
313+
d.reject = reject;
314+
});
315+
return d;
316+
}

examples/fastify/src/app.ts

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export function buildApp(logging = true) {
2222
type Query {
2323
hello: String
2424
isFastify: Boolean
25+
slow: Nested
2526
}
2627
type Mutation {
2728
hello: String
@@ -30,15 +31,36 @@ export function buildApp(logging = true) {
3031
type Subscription {
3132
countdown(from: Int!, interval: Int!): Int!
3233
}
34+
35+
type Nested {
36+
field: String
37+
}
3338
`,
3439
resolvers: {
3540
Query: {
3641
hello: () => 'world',
3742
isFastify: (_, __, context) => !!context.req && !!context.reply,
43+
async slow(_, __, context) {
44+
context.req.log.info('Slow resolver invoked resolved');
45+
await new Promise<void>((res, reject) => {
46+
const timeout = setTimeout(() => {
47+
context.req.log.info('Slow field resolved');
48+
res();
49+
}, 1000);
50+
51+
context.request.signal.addEventListener('abort', () => {
52+
context.req.log.info('Slow field got cancelled');
53+
clearTimeout(timeout);
54+
reject(context.request.signal.reason);
55+
});
56+
});
57+
58+
return {};
59+
},
3860
},
3961
Mutation: {
4062
hello: () => 'world',
41-
getFileName: (root, { file }: { file: File }) => file.name,
63+
getFileName: (_, { file }: { file: File }) => file.name,
4264
},
4365
Subscription: {
4466
countdown: {
@@ -50,6 +72,11 @@ export function buildApp(logging = true) {
5072
},
5173
},
5274
},
75+
Nested: {
76+
field(_, __, context) {
77+
context.req.log.info('Nested resolver called');
78+
},
79+
},
5380
},
5481
}),
5582
// Integrate Fastify Logger to Yoga
@@ -69,7 +96,7 @@ export function buildApp(logging = true) {
6996
},
7097
});
7198

72-
app.addContentTypeParser('multipart/form-data', {}, (req, payload, done) => done(null));
99+
app.addContentTypeParser('multipart/form-data', {}, (_req, _payload, done) => done(null));
73100

74101
app.route({
75102
url: graphQLServer.graphqlEndpoint,
Lines changed: 179 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,10 @@
1-
import { createServer, IncomingMessage, Server, ServerResponse, STATUS_CODES } from 'node:http';
1+
import { createServer, IncomingMessage, ServerResponse, STATUS_CODES } from 'node:http';
22
import { AddressInfo } from 'node:net';
33
import { fetch } from '@whatwg-node/fetch';
4-
import { createGraphQLError, createSchema, createYoga } from '../src/index.js';
4+
import { createGraphQLError, createSchema, createYoga, Plugin } from '../src/index.js';
55

66
describe('node-http', () => {
7-
let server: Server;
8-
let port: number;
9-
10-
beforeAll(async () => {
7+
it('should expose Node req and res objects in the context', async () => {
118
const yoga = createYoga<{
129
req: IncomingMessage;
1310
res: ServerResponse;
@@ -16,12 +13,43 @@ describe('node-http', () => {
1613
typeDefs: /* GraphQL */ `
1714
type Query {
1815
isNode: Boolean!
19-
throw(status: Int): Int!
2016
}
2117
`,
2218
resolvers: {
2319
Query: {
2420
isNode: (_, __, { req, res }) => !!(req && res),
21+
},
22+
},
23+
}),
24+
});
25+
const server = createServer(yoga);
26+
await new Promise<void>(resolve => server.listen(0, resolve));
27+
const port = (server.address() as AddressInfo).port;
28+
29+
try {
30+
const response = await fetch(`http://localhost:${port}/graphql?query=query{isNode}`);
31+
expect(response.status).toBe(200);
32+
const body = await response.json();
33+
expect(body.errors).toBeUndefined();
34+
expect(body.data.isNode).toBe(true);
35+
} finally {
36+
await new Promise<void>(resolve => server.close(() => resolve()));
37+
}
38+
});
39+
40+
it('should set status text by status code', async () => {
41+
const yoga = createYoga<{
42+
req: IncomingMessage;
43+
res: ServerResponse;
44+
}>({
45+
schema: createSchema({
46+
typeDefs: /* GraphQL */ `
47+
type Query {
48+
throw(status: Int): Int!
49+
}
50+
`,
51+
resolvers: {
52+
Query: {
2553
throw(_, { status }) {
2654
throw createGraphQLError('Test', {
2755
extensions: {
@@ -36,26 +64,10 @@ describe('node-http', () => {
3664
}),
3765
logging: false,
3866
});
39-
server = createServer(yoga);
67+
const server = createServer(yoga);
4068
await new Promise<void>(resolve => server.listen(0, resolve));
41-
port = (server.address() as AddressInfo).port;
42-
});
43-
44-
afterAll(async () => {
45-
if (server) {
46-
await new Promise<void>(resolve => server.close(() => resolve()));
47-
}
48-
});
49-
50-
it('should expose Node req and res objects in the context', async () => {
51-
const response = await fetch(`http://localhost:${port}/graphql?query=query{isNode}`);
52-
expect(response.status).toBe(200);
53-
const body = await response.json();
54-
expect(body.errors).toBeUndefined();
55-
expect(body.data.isNode).toBe(true);
56-
});
69+
const port = (server.address() as AddressInfo).port;
5770

58-
it('should set status text by status code', async () => {
5971
for (const statusCodeStr in STATUS_CODES) {
6072
const status = Number(statusCodeStr);
6173
if (status < 200) continue;
@@ -77,4 +89,146 @@ describe('node-http', () => {
7789
expect(response.statusText).toBe(STATUS_CODES[status]);
7890
}
7991
});
92+
93+
it('request cancellation causes signal passed to executor to be aborted', async () => {
94+
const d = createDeferred();
95+
const didAbortD = createDeferred();
96+
97+
const plugin: Plugin = {
98+
onExecute(ctx) {
99+
ctx.setExecuteFn(async function execute(params) {
100+
d.resolve();
101+
102+
return new Promise((_, rej) => {
103+
// @ts-expect-error Signal is not documented yet...
104+
params.signal.addEventListener('abort', () => {
105+
didAbortD.resolve();
106+
rej(new DOMException('The operation was aborted', 'AbortError'));
107+
});
108+
});
109+
});
110+
},
111+
};
112+
const yoga = createYoga({
113+
schema: createSchema({
114+
typeDefs: /* GraphQL */ `
115+
type Query {
116+
hi: String!
117+
}
118+
`,
119+
}),
120+
plugins: [plugin],
121+
});
122+
const server = createServer(yoga);
123+
await new Promise<void>(resolve => server.listen(0, resolve));
124+
const port = (server.address() as AddressInfo).port;
125+
try {
126+
const controller = new AbortController();
127+
const response$ = fetch(`http://localhost:${port}/graphql`, {
128+
method: 'POST',
129+
headers: {
130+
'content-type': 'application/json',
131+
},
132+
body: JSON.stringify({
133+
query: /* GraphQL */ `
134+
query {
135+
hi
136+
}
137+
`,
138+
}),
139+
signal: controller.signal,
140+
});
141+
await d.promise;
142+
controller.abort();
143+
await expect(response$).rejects.toThrow('The operation was aborted');
144+
await didAbortD.promise;
145+
} finally {
146+
await new Promise<void>(resolve => server.close(() => resolve()));
147+
}
148+
});
149+
150+
it('request cancellation causes no more resolvers being invoked', async () => {
151+
const didInvokeSlowResolverD = createDeferred();
152+
153+
const didCancelD = createDeferred();
154+
155+
let didInvokedNestedField = false;
156+
const yoga = createYoga({
157+
schema: createSchema({
158+
typeDefs: /* GraphQL */ `
159+
type Query {
160+
slow: Nested!
161+
}
162+
163+
type Nested {
164+
field: String!
165+
}
166+
`,
167+
resolvers: {
168+
Query: {
169+
async slow() {
170+
didInvokeSlowResolverD.resolve();
171+
await didCancelD.promise;
172+
return {};
173+
},
174+
},
175+
Nested: {
176+
field() {
177+
didInvokedNestedField = true;
178+
return 'test';
179+
},
180+
},
181+
},
182+
}),
183+
});
184+
const server = createServer(yoga);
185+
await new Promise<void>(resolve => server.listen(0, resolve));
186+
const port = (server.address() as AddressInfo).port;
187+
try {
188+
const controller = new AbortController();
189+
const response$ = fetch(`http://localhost:${port}/graphql`, {
190+
method: 'POST',
191+
headers: {
192+
'content-type': 'application/json',
193+
},
194+
body: JSON.stringify({
195+
query: /* GraphQL */ `
196+
query {
197+
slow {
198+
field
199+
}
200+
}
201+
`,
202+
}),
203+
signal: controller.signal,
204+
});
205+
206+
await didInvokeSlowResolverD.promise;
207+
controller.abort();
208+
await expect(response$).rejects.toThrow('The operation was aborted');
209+
// wait a few milliseconds to ensure server-side cancellation logic runs
210+
await new Promise<void>(resolve => setTimeout(resolve, 10));
211+
didCancelD.resolve();
212+
// wait a few milliseconds to allow the nested field resolver to run (if cancellation logic is incorrect)
213+
await new Promise<void>(resolve => setTimeout(resolve, 10));
214+
expect(didInvokedNestedField).toBe(false);
215+
} finally {
216+
await new Promise<void>(resolve => server.close(() => resolve()));
217+
}
218+
});
80219
});
220+
221+
type Deferred<T = void> = {
222+
resolve: (value: T) => void;
223+
reject: (value: unknown) => void;
224+
promise: Promise<T>;
225+
};
226+
227+
function createDeferred<T = void>(): Deferred<T> {
228+
const d = {} as Deferred<T>;
229+
d.promise = new Promise<T>((resolve, reject) => {
230+
d.resolve = resolve;
231+
d.reject = reject;
232+
});
233+
return d;
234+
}

packages/graphql-yoga/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@
5656
"@graphql-yoga/logger": "^2.0.0",
5757
"@graphql-yoga/subscription": "^5.0.0",
5858
"@whatwg-node/fetch": "^0.9.17",
59-
"@whatwg-node/server": "^0.9.30",
59+
"@whatwg-node/server": "^0.9.31",
6060
"dset": "^3.1.1",
6161
"lru-cache": "^10.0.0",
6262
"tslib": "^2.5.2"

0 commit comments

Comments
 (0)