@znight/graphql 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,700 @@
1
+ /**
2
+ * The driver conformance suite (RFC 0009 §5.4, §12): the same scenarios against the built-in
3
+ * transport and every route-mounted driver, on the HTTP adapters given, through the whole
4
+ * `HttpPlugin` pipeline (`executeOperation()`, `subscribeOperation()`). Exported from
5
+ * `@znight/graphql/testing` as `describeDriverConformance()` for third-party drivers, and
6
+ * framework-neutral: the caller passes its `describe` / `it` / `beforeAll` / `afterAll` (`bun:test`,
7
+ * Vitest, Jest, `node:test`), the assertions are `node:assert`'s.
8
+ *
9
+ * The application is hand-written metadata (what the compiler generates): a catalog with a
10
+ * mutation publishing an event, a field resolved through a loader, a request-scoped session read by
11
+ * a field guard and by a resolver, a result mapper, an error to mask, subscriptions (a PubSub topic,
12
+ * a finite and an endless generator), an HTTP guard on the endpoint, introspection off and limits.
13
+ */
14
+ // biome-ignore-all lint/suspicious/useExpect: a framework-neutral suite, asserting with node:assert
15
+ import type { CanActivate, HttpAdapter, HttpContext } from '@znight/http'
16
+ import type { GraphQLDriver } from './driver.ts'
17
+ import type { GraphQLResolverBinding, GraphQLResolverOperation, GraphQLTypeDefinition } from './metadata.ts'
18
+ import type { PubSub } from './pubsub.ts'
19
+ import type { OperationResponse, OperationResult, OperationStream } from './testing.ts'
20
+ import type { BatchLoad, GraphQLContext, GraphQLResultMapper } from './types.ts'
21
+
22
+ import { deepStrictEqual, ok, strictEqual } from 'node:assert/strict'
23
+ import { HttpPlugin, NotFoundException } from '@znight/http'
24
+ import { defineTestMetadata, TestApp, testToken } from '@znight/testing'
25
+
26
+ import { GraphQLPlugin } from './plugin.ts'
27
+ import { executeOperation, subscribeOperation } from './testing.ts'
28
+
29
+ /** The test functions of the framework running the suite. */
30
+ export interface ConformanceTestApi {
31
+ describe(name: string, body: () => void): unknown
32
+ it(name: string, body: () => Promise<void>): unknown
33
+ beforeAll(body: () => Promise<void>): unknown
34
+ afterAll(body: () => Promise<void>): unknown
35
+ }
36
+
37
+ /** Options of {@link describeDriverConformance}. */
38
+ export interface DriverConformanceOptions {
39
+ /**
40
+ * A new driver for each application the suite boots (a driver serves one application at a
41
+ * time); `undefined` for the built-in transport.
42
+ */
43
+ readonly driver: () => GraphQLDriver | undefined
44
+ /** `describe`, `it`, `beforeAll` and `afterAll` of the test framework. */
45
+ readonly test: ConformanceTestApi
46
+ /** The adapters to run on, by name (`{ node: () => nodeAdapter(), hono: () => honoAdapter() }`). Default: `HttpPlugin`'s. */
47
+ readonly adapters?: Readonly<Record<string, () => HttpAdapter>>
48
+ /** Whether the driver serves subscriptions over SSE (`subscribeOperation()`). Default `true`. */
49
+ readonly subscriptions?: boolean
50
+ }
51
+
52
+ /**
53
+ * Describes the conformance scenarios of a GraphQL driver (RFC 0009 §12): queries, mutations,
54
+ * field guards and HTTP guards, loaders batching, request-scoped providers, result mappers,
55
+ * masking, introspection, limits, the `graphql:operation` hook, `POST` bodies other than JSON and
56
+ * mutations over `GET` refused (CSRF) and, over SSE, subscriptions (events, completion, client
57
+ * abort, `maxActive`, shutdown whatever the plugin order).
58
+ *
59
+ * ```ts
60
+ * import { describeDriverConformance } from '@znight/graphql/testing'
61
+ * import { yogaDriver } from '@znight/graphql/yoga'
62
+ * import { afterAll, beforeAll, describe, it } from 'bun:test'
63
+ *
64
+ * describeDriverConformance({ driver: () => yogaDriver(), test: { describe, it, beforeAll, afterAll } })
65
+ * ```
66
+ */
67
+ export function describeDriverConformance(options: DriverConformanceOptions): void {
68
+ const { test } = options
69
+ const adapters = Object.entries(options.adapters ?? { default: undefined })
70
+ const name = options.driver()?.name ?? 'builtin'
71
+ test.describe(`GraphQL driver conformance: ${name}`, () => {
72
+ for (const [adapterName, adapter] of adapters) {
73
+ test.describe(`${adapterName} adapter`, () => {
74
+ describeOperations(options, name, adapter)
75
+ if (options.subscriptions !== false) describeSubscriptions(options, adapter)
76
+ })
77
+ }
78
+ })
79
+ }
80
+
81
+ // ------------------------------------------------------------------------------------------------
82
+ // the application
83
+
84
+ interface Book {
85
+ readonly id: string
86
+ readonly title: string
87
+ readonly authorId: string
88
+ }
89
+
90
+ interface Author {
91
+ readonly id: string
92
+ readonly name: string
93
+ }
94
+
95
+ interface Events {
96
+ bookAdded: Book
97
+ }
98
+
99
+ /** What the resolvers observed, per application. */
100
+ interface Observed {
101
+ readonly batches: (readonly string[])[]
102
+ readonly ended: string[]
103
+ sessions: number
104
+ }
105
+
106
+ const OBSERVED = Symbol('conformance.observed')
107
+
108
+ class Catalog {
109
+ readonly #pubsub: PubSub<Events>
110
+ readonly #books: Book[] = [
111
+ { id: '1', title: 'Dune', authorId: 'herbert' },
112
+ { id: '2', title: 'Hyperion', authorId: 'simmons' },
113
+ { id: '3', title: 'Children of Dune', authorId: 'herbert' },
114
+ ]
115
+ readonly [OBSERVED]: Observed = { batches: [], ended: [], sessions: 0 }
116
+
117
+ constructor(pubsub: PubSub<Events>) {
118
+ this.#pubsub = pubsub
119
+ }
120
+
121
+ books(): readonly Book[] {
122
+ return this.#books
123
+ }
124
+
125
+ book(args: { id: string }): Book {
126
+ const book = this.#books.find(candidate => candidate.id === args.id)
127
+ if (book === undefined) throw new NotFoundException(`No book ${args.id}`)
128
+ return book
129
+ }
130
+
131
+ secret(): string {
132
+ throw new Error('postgres://admin:secret@db')
133
+ }
134
+
135
+ async addBook(args: { title: string }): Promise<Book> {
136
+ const book = { id: String(this.#books.length + 1), title: args.title, authorId: 'herbert' }
137
+ this.#books.push(book)
138
+ await this.#pubsub.publish('bookAdded', book)
139
+ return book
140
+ }
141
+
142
+ bookAdded(ctx: GraphQLContext): AsyncIterableIterator<Book> {
143
+ return this.#pubsub.subscribe('bookAdded', { signal: ctx.signal })
144
+ }
145
+
146
+ async *countdown(args: { from: number }): AsyncGenerator<number> {
147
+ for (let n = args.from; n > 0; n--) yield await Promise.resolve(n)
148
+ }
149
+
150
+ async *forever(ctx: GraphQLContext): AsyncGenerator<number> {
151
+ const signal = ctx.signal
152
+ try {
153
+ for (let n = 1; !signal.aborted; n++) {
154
+ yield n
155
+ await new Promise<void>(resolve => {
156
+ const timer = setTimeout(resolve, 10)
157
+ signal.addEventListener(
158
+ 'abort',
159
+ () => {
160
+ clearTimeout(timer)
161
+ resolve()
162
+ },
163
+ { once: true },
164
+ )
165
+ })
166
+ }
167
+ } finally {
168
+ this[OBSERVED].ended.push('forever')
169
+ }
170
+ }
171
+ }
172
+
173
+ class AuthorsById implements BatchLoad<string, Author> {
174
+ readonly #catalog: Catalog
175
+
176
+ constructor(catalog: Catalog) {
177
+ this.#catalog = catalog
178
+ }
179
+
180
+ load(keys: readonly string[]): Promise<readonly Author[]> {
181
+ this.#catalog[OBSERVED].batches.push([...keys])
182
+ return Promise.resolve(keys.map(id => ({ id, name: id === 'herbert' ? 'Frank Herbert' : 'Dan Simmons' })))
183
+ }
184
+ }
185
+
186
+ /** Request-scoped: one per HTTP request, shared by the guard and the resolver of an operation. */
187
+ class Session {
188
+ readonly id: number
189
+ readonly user: string | undefined
190
+
191
+ constructor(http: HttpContext, catalog: Catalog) {
192
+ this.id = ++catalog[OBSERVED].sessions
193
+ this.user = http.header('x-user')
194
+ }
195
+ }
196
+
197
+ class Viewer {
198
+ readonly #session: Session
199
+
200
+ constructor(session: Session) {
201
+ this.#session = session
202
+ }
203
+
204
+ me(): { id: number; user: string | null } {
205
+ return { id: this.#session.id, user: this.#session.user ?? null }
206
+ }
207
+ }
208
+
209
+ /** A field guard: editors only, read from the request-scoped session. */
210
+ class EditorGuard {
211
+ readonly #session: Session
212
+
213
+ constructor(session: Session) {
214
+ this.#session = session
215
+ }
216
+
217
+ canResolve(): boolean {
218
+ return this.#session.user === 'editor'
219
+ }
220
+ }
221
+
222
+ /** An HTTP guard on the endpoint (`http.guards`). */
223
+ class Gate implements CanActivate {
224
+ canActivate(ctx: HttpContext): boolean {
225
+ return ctx.header('x-block') !== 'yes'
226
+ }
227
+ }
228
+
229
+ /** Errors as data (RFC 0008): a missing book is a `NotFound` of `BookResult`. */
230
+ class Rejections implements GraphQLResultMapper {
231
+ static readonly types: readonly string[] = ['NotFound']
232
+
233
+ toResult(error: unknown): unknown {
234
+ return error instanceof NotFoundException ? { __typename: 'NotFound', message: error.message } : undefined
235
+ }
236
+ }
237
+
238
+ type Invoke = (resolver: never, parent: never, args: never, ctx: never) => unknown
239
+
240
+ /** The binding of a resolver method (what the compiler generates for `@Query()`, `@ResolveField()`...). */
241
+ function resolver(
242
+ cls: { readonly name: string },
243
+ method: string,
244
+ operation: GraphQLResolverOperation,
245
+ invoke: Invoke,
246
+ extra: Partial<GraphQLResolverBinding> = {},
247
+ ): GraphQLResolverBinding {
248
+ return {
249
+ token: testToken(cls as never),
250
+ method,
251
+ operation,
252
+ guards: [],
253
+ meta: {},
254
+ invoke: invoke as unknown as GraphQLResolverBinding['invoke'],
255
+ ...extra,
256
+ }
257
+ }
258
+
259
+ const ID = [{ name: 'id', type: 'ID!' }]
260
+
261
+ const TYPES: readonly GraphQLTypeDefinition[] = [
262
+ { kind: 'object', name: 'Author', fields: [{ name: 'name', type: 'String!' }] },
263
+ {
264
+ kind: 'object',
265
+ name: 'Book',
266
+ fields: [
267
+ { name: 'id', type: 'ID!' },
268
+ { name: 'title', type: 'String!' },
269
+ {
270
+ name: 'author',
271
+ type: 'Author!',
272
+ resolver: resolver(
273
+ Catalog,
274
+ 'books',
275
+ 'field',
276
+ ((_r: Catalog, parent: Book, _a: unknown, ctx: GraphQLContext) =>
277
+ ctx.loader(testToken(AuthorsById)).load(parent.authorId)) as Invoke,
278
+ { loaders: [testToken(AuthorsById)] },
279
+ ),
280
+ },
281
+ ],
282
+ },
283
+ { kind: 'object', name: 'NotFound', fields: [{ name: 'message', type: 'String!' }] },
284
+ { kind: 'union', name: 'BookResult', members: ['Book', 'NotFound'], discriminator: { by: 'typename' } },
285
+ {
286
+ kind: 'object',
287
+ name: 'Me',
288
+ fields: [
289
+ { name: 'id', type: 'Int!' },
290
+ { name: 'user', type: 'String' },
291
+ ],
292
+ },
293
+ {
294
+ kind: 'object',
295
+ name: 'Query',
296
+ fields: [
297
+ {
298
+ name: 'books',
299
+ type: '[Book!]!',
300
+ resolver: resolver(Catalog, 'books', 'query', ((r: Catalog) => r.books()) as Invoke),
301
+ },
302
+ {
303
+ name: 'book',
304
+ type: 'BookResult!',
305
+ args: ID,
306
+ resolver: resolver(Catalog, 'book', 'query', ((r: Catalog, _p: unknown, a: { id: string }) => ({
307
+ __typename: 'Book',
308
+ ...r.book(a),
309
+ })) as Invoke),
310
+ },
311
+ {
312
+ name: 'secret',
313
+ type: 'String',
314
+ resolver: resolver(Catalog, 'secret', 'query', ((r: Catalog) => r.secret()) as Invoke),
315
+ },
316
+ {
317
+ name: 'me',
318
+ type: 'Me!',
319
+ resolver: resolver(Viewer, 'me', 'query', ((r: Viewer) => r.me()) as Invoke),
320
+ },
321
+ ],
322
+ },
323
+ {
324
+ kind: 'object',
325
+ name: 'Mutation',
326
+ fields: [
327
+ {
328
+ name: 'addBook',
329
+ type: 'Book!',
330
+ args: [{ name: 'title', type: 'String!' }],
331
+ resolver: resolver(
332
+ Catalog,
333
+ 'addBook',
334
+ 'mutation',
335
+ ((r: Catalog, _p: unknown, a: { title: string }) => r.addBook(a)) as Invoke,
336
+ { guards: [testToken(EditorGuard)] },
337
+ ),
338
+ },
339
+ ],
340
+ },
341
+ {
342
+ kind: 'object',
343
+ name: 'Subscription',
344
+ fields: [
345
+ {
346
+ name: 'bookAdded',
347
+ type: 'Book!',
348
+ resolver: resolver(Catalog, 'bookAdded', 'subscription', ((
349
+ r: Catalog,
350
+ _p: unknown,
351
+ _a: unknown,
352
+ c: GraphQLContext,
353
+ ) => r.bookAdded(c)) as Invoke),
354
+ },
355
+ {
356
+ name: 'countdown',
357
+ type: 'Int!',
358
+ args: [{ name: 'from', type: 'Int!' }],
359
+ resolver: resolver(Catalog, 'countdown', 'subscription', ((r: Catalog, _p: unknown, a: { from: number }) =>
360
+ r.countdown(a)) as Invoke),
361
+ },
362
+ {
363
+ name: 'forever',
364
+ type: 'Int!',
365
+ resolver: resolver(Catalog, 'forever', 'subscription', ((
366
+ r: Catalog,
367
+ _p: unknown,
368
+ _a: unknown,
369
+ c: GraphQLContext,
370
+ ) => r.forever(c)) as Invoke),
371
+ },
372
+ ],
373
+ },
374
+ ]
375
+
376
+ function conformanceMetadata() {
377
+ return defineTestMetadata({
378
+ providers: [
379
+ { class: Catalog, deps: ['@znight/graphql#PubSub'] },
380
+ { class: AuthorsById, deps: [Catalog] },
381
+ { class: Session, scope: 'request', deps: ['@znight/http#HttpContext', Catalog] },
382
+ { class: Viewer, scope: 'request', deps: [Session] },
383
+ { class: EditorGuard, scope: 'request', deps: [Session] },
384
+ Gate,
385
+ Rejections,
386
+ ],
387
+ externals: [
388
+ { token: '@znight/graphql#GraphQLContext', scope: 'request', providedBy: 'GraphQLPlugin' },
389
+ { token: '@znight/graphql#PubSub', providedBy: 'GraphQLPlugin' },
390
+ { token: '@znight/graphql#GraphQLPlugin', providedBy: 'GraphQLPlugin' },
391
+ { token: '@znight/http#HttpContext', scope: 'request', providedBy: 'HttpPlugin' },
392
+ { token: '@znight/http#RequestId', scope: 'request', providedBy: 'HttpPlugin' },
393
+ ],
394
+ extensions: {
395
+ graphql: {
396
+ version: 1,
397
+ schemaHash: 'c0nf0rmance00000',
398
+ roots: { query: 'Query', mutation: 'Mutation', subscription: 'Subscription' },
399
+ types: [...TYPES].sort((a, b) => (a.name < b.name ? -1 : 1)),
400
+ },
401
+ },
402
+ })
403
+ }
404
+
405
+ /** The drain timeout of `HttpPlugin` in the suite: a shutdown waiting for it is a failure. */
406
+ const DRAIN_TIMEOUT_MS = 5_000
407
+
408
+ /**
409
+ * Boots the conformance application on `adapter` with `driver`, `GraphQLPlugin` after `HttpPlugin`
410
+ * (or before it, `graphqlFirst`).
411
+ */
412
+ function boot(
413
+ options: DriverConformanceOptions,
414
+ adapter: (() => HttpAdapter) | undefined,
415
+ graphqlFirst = false,
416
+ ): Promise<TestApp> {
417
+ const driver = options.driver()
418
+ const http = HttpPlugin.configure({
419
+ port: 0,
420
+ host: '127.0.0.1',
421
+ drainTimeoutMs: DRAIN_TIMEOUT_MS,
422
+ ...(adapter === undefined ? {} : { adapter: adapter() }),
423
+ })
424
+ const graphql = GraphQLPlugin.configure({
425
+ introspection: false,
426
+ resultMappers: [Rejections],
427
+ limits: { maxAliases: 3, maxDepth: 4 },
428
+ http: { guards: [Gate] },
429
+ subscriptions: { maxActive: 2 },
430
+ ...(driver === undefined ? {} : { driver }),
431
+ })
432
+ return TestApp.create({
433
+ metadata: conformanceMetadata(),
434
+ plugins: graphqlFirst ? [graphql, http] : [http, graphql],
435
+ logger: { debug() {}, info() {}, warn() {}, error() {} },
436
+ })
437
+ }
438
+
439
+ function observed(app: TestApp): Observed {
440
+ return (app.get(testToken(Catalog) as never) as Catalog)[OBSERVED]
441
+ }
442
+
443
+ function graphql(app: TestApp): GraphQLPlugin {
444
+ return app.app.getPlugin('graphql') as GraphQLPlugin
445
+ }
446
+
447
+ async function until(condition: () => boolean, what: string, timeoutMs = 2_000): Promise<void> {
448
+ const started = Date.now()
449
+ while (!condition()) {
450
+ if (Date.now() - started > timeoutMs) throw new Error(`conformance: ${what} did not happen within ${timeoutMs} ms`)
451
+ await new Promise(resolve => setTimeout(resolve, 5))
452
+ }
453
+ }
454
+
455
+ function codes(result: OperationResult): readonly unknown[] {
456
+ return (result.errors ?? []).map(error => error.extensions?.['code'])
457
+ }
458
+
459
+ function result(response: OperationResponse): OperationResult {
460
+ return {
461
+ ...('data' in response ? { data: response.data } : {}),
462
+ ...(response.errors === undefined ? {} : { errors: response.errors }),
463
+ }
464
+ }
465
+
466
+ // ------------------------------------------------------------------------------------------------
467
+ // the scenarios
468
+
469
+ function describeOperations(
470
+ options: DriverConformanceOptions,
471
+ driverName: string,
472
+ adapter: (() => HttpAdapter) | undefined,
473
+ ): void {
474
+ const { test } = options
475
+ let app: TestApp
476
+ const events: { driver: string; transport: string; operationName: string | undefined }[] = []
477
+
478
+ test.beforeAll(async () => {
479
+ app = await boot(options, adapter)
480
+ app.app.hooks.on('graphql:operation', event => {
481
+ events.push({ driver: event.driver, transport: event.transport, operationName: event.operationName })
482
+ })
483
+ })
484
+ test.afterAll(async () => {
485
+ await app?.close()
486
+ })
487
+
488
+ test.it('executes queries, with field resolvers batched through a loader', async () => {
489
+ const response = await executeOperation(app, { query: '{ books { title author { name } } }' })
490
+ strictEqual(response.status, 200)
491
+ deepStrictEqual(result(response), {
492
+ data: {
493
+ books: [
494
+ { title: 'Dune', author: { name: 'Frank Herbert' } },
495
+ { title: 'Hyperion', author: { name: 'Dan Simmons' } },
496
+ { title: 'Children of Dune', author: { name: 'Frank Herbert' } },
497
+ ],
498
+ },
499
+ })
500
+ deepStrictEqual(observed(app).batches.at(-1), ['herbert', 'simmons'])
501
+ })
502
+
503
+ test.it('executes mutations behind a field guard reading a request-scoped provider', async () => {
504
+ const denied = await executeOperation(app, { query: 'mutation { addBook(title: "Ilium") { title } }' })
505
+ strictEqual(denied.status, 200)
506
+ deepStrictEqual(denied.data, null)
507
+ deepStrictEqual(codes(denied), ['FORBIDDEN'])
508
+ const added = await executeOperation(app, {
509
+ query: 'mutation Add($title: String!) { addBook(title: $title) { title author { name } } }',
510
+ variables: { title: 'Ilium' },
511
+ headers: { 'x-user': 'editor' },
512
+ })
513
+ deepStrictEqual(result(added), { data: { addBook: { title: 'Ilium', author: { name: 'Frank Herbert' } } } })
514
+ })
515
+
516
+ test.it('creates request-scoped providers once per request', async () => {
517
+ const first = await executeOperation<{ a: { id: number }; b: { id: number; user: string } }>(app, {
518
+ query: '{ a: me { id } b: me { id user } }',
519
+ headers: { 'x-user': 'ada' },
520
+ })
521
+ const second = await executeOperation<{ me: { id: number } }>(app, { query: '{ me { id } }' })
522
+ ok(first.data !== undefined && first.data !== null && second.data !== undefined && second.data !== null)
523
+ strictEqual(first.data.a.id, first.data.b.id)
524
+ strictEqual(first.data.b.user, 'ada')
525
+ ok(second.data.me.id > first.data.a.id)
526
+ })
527
+
528
+ test.it('turns errors into data with result mappers, and masks unexpected errors', async () => {
529
+ const missing = await executeOperation(app, {
530
+ query: '{ book(id: "404") { __typename ... on NotFound { message } ... on Book { title } } }',
531
+ })
532
+ deepStrictEqual(result(missing), { data: { book: { __typename: 'NotFound', message: 'No book 404' } } })
533
+ const secret = await executeOperation(app, { query: '{ secret }' })
534
+ strictEqual(secret.status, 200)
535
+ deepStrictEqual(result(secret), {
536
+ data: { secret: null },
537
+ errors: [
538
+ {
539
+ message: 'Unexpected error.',
540
+ locations: [{ line: 1, column: 3 }],
541
+ path: ['secret'],
542
+ extensions: { code: 'INTERNAL_SERVER_ERROR' },
543
+ },
544
+ ],
545
+ })
546
+ ok(!secret.body.includes('postgres'), 'the original error leaked')
547
+ })
548
+
549
+ test.it('refuses introspection, documents past the limits and invalid documents (400)', async () => {
550
+ for (const [query, code] of [
551
+ ['{ __schema { queryType { name } } }', 'GRAPHQL_VALIDATION_FAILED'],
552
+ ['{ a: me { id } b: me { id } c: me { id } d: me { id } }', 'QUERY_TOO_COMPLEX'],
553
+ ['{ books { author { name } author2: author { name } } }', undefined],
554
+ ['{ nope }', 'GRAPHQL_VALIDATION_FAILED'],
555
+ ['{ books', 'GRAPHQL_PARSE_FAILED'],
556
+ ] as const) {
557
+ const response = await executeOperation(app, { query })
558
+ if (code === undefined) {
559
+ strictEqual(response.status, 200, query)
560
+ continue
561
+ }
562
+ strictEqual(response.status, 400, query)
563
+ strictEqual(response.data, undefined, query)
564
+ deepStrictEqual([...new Set(codes(response))], [code], query)
565
+ }
566
+ })
567
+
568
+ test.it('runs behind the HTTP guards of the endpoint', async () => {
569
+ const blocked = await executeOperation(app, { query: '{ books { title } }', headers: { 'x-block': 'yes' } })
570
+ strictEqual(blocked.status, 403)
571
+ strictEqual(blocked.data, undefined)
572
+ })
573
+
574
+ test.it('reports every executed operation to the graphql:operation hook, with the driver', async () => {
575
+ events.length = 0
576
+ await executeOperation(app, { query: 'query Named { books { id } }' })
577
+ deepStrictEqual(events, [{ driver: driverName, transport: 'http', operationName: 'Named' }])
578
+ })
579
+
580
+ test.it('refuses POST bodies other than JSON (415): a cross-site form never runs an operation', async () => {
581
+ const mutation = 'mutation { addBook(title: "Forged") { id } }'
582
+ const before = await executeOperation<{ books: unknown[] }>(app, { query: '{ books { id } }' })
583
+ for (const [type, body] of [
584
+ ['application/x-www-form-urlencoded', `query=${encodeURIComponent(mutation)}`],
585
+ [
586
+ 'multipart/form-data; boundary=x',
587
+ `--x\r\ncontent-disposition: form-data; name="query"\r\n\r\n${mutation}\r\n--x--\r\n`,
588
+ ],
589
+ ['text/plain', JSON.stringify({ query: mutation })],
590
+ ['application/graphql', mutation],
591
+ ] as const) {
592
+ const response = await app.inject({
593
+ method: 'POST',
594
+ url: '/graphql',
595
+ headers: { 'content-type': type, 'x-user': 'editor' },
596
+ body,
597
+ })
598
+ strictEqual(response.status, 415, type)
599
+ deepStrictEqual(
600
+ response.json(),
601
+ {
602
+ errors: [
603
+ {
604
+ message: 'Send GraphQL requests as application/json (content-type: application/json)',
605
+ extensions: { code: 'BAD_REQUEST' },
606
+ },
607
+ ],
608
+ },
609
+ type,
610
+ )
611
+ }
612
+ const after = await executeOperation<{ books: unknown[] }>(app, { query: '{ books { id } }' })
613
+ strictEqual(after.data?.books.length, before.data?.books.length)
614
+ })
615
+
616
+ test.it('refuses mutations over GET (405, allow: POST)', async () => {
617
+ const before = await executeOperation<{ books: unknown[] }>(app, { query: '{ books { id } }' })
618
+ const response = await app.inject({
619
+ method: 'GET',
620
+ url: '/graphql',
621
+ query: { query: 'mutation { addBook(title: "Fetched") { id } }' },
622
+ headers: { 'x-user': 'editor' },
623
+ })
624
+ strictEqual(response.status, 405)
625
+ strictEqual(response.headers['allow'], 'POST')
626
+ const after = await executeOperation<{ books: unknown[] }>(app, { query: '{ books { id } }' })
627
+ strictEqual(after.data?.books.length, before.data?.books.length)
628
+ })
629
+ }
630
+
631
+ function describeSubscriptions(options: DriverConformanceOptions, adapter: (() => HttpAdapter) | undefined): void {
632
+ const { test } = options
633
+ let app: TestApp
634
+
635
+ test.beforeAll(async () => {
636
+ app = await boot(options, adapter)
637
+ })
638
+ test.afterAll(async () => {
639
+ await app?.close()
640
+ })
641
+
642
+ test.it('streams the events of a subscription until the client leaves', async () => {
643
+ const events = await subscribeOperation(app, { query: 'subscription { bookAdded { title } }' })
644
+ strictEqual(events.status, 200)
645
+ await executeOperation(app, {
646
+ query: 'mutation { addBook(title: "Endymion") { id } }',
647
+ headers: { 'x-user': 'editor' },
648
+ })
649
+ deepStrictEqual((await events.next()).value, { data: { bookAdded: { title: 'Endymion' } } })
650
+ await events.return()
651
+ await until(() => graphql(app).activeSubscriptions === 0, 'the end of the subscription')
652
+ })
653
+
654
+ test.it('completes a finite subscription', async () => {
655
+ const events = await subscribeOperation(app, { query: 'subscription { countdown(from: 2) }' })
656
+ deepStrictEqual(await drain(events), [{ data: { countdown: 2 } }, { data: { countdown: 1 } }])
657
+ })
658
+
659
+ test.it('ends the source when the client aborts, and caps the active subscriptions', async () => {
660
+ const ended = observed(app).ended
661
+ ended.length = 0
662
+ const first = await subscribeOperation(app, { query: 'subscription { forever }' })
663
+ const second = await subscribeOperation(app, { query: 'subscription { forever }' })
664
+ deepStrictEqual((await first.next()).value, { data: { forever: 1 } })
665
+ deepStrictEqual((await second.next()).value, { data: { forever: 1 } })
666
+ const third = await subscribeOperation(app, { query: 'subscription { forever }' })
667
+ const refused = await third.next()
668
+ deepStrictEqual(codes(refused.value ?? {}), ['SERVICE_UNAVAILABLE'])
669
+ await third.return()
670
+ await first.return()
671
+ await second.return()
672
+ await until(() => ended.length === 2, 'the end of both sources')
673
+ await until(() => graphql(app).activeSubscriptions === 0, 'the end of the subscriptions')
674
+ })
675
+
676
+ for (const graphqlFirst of [false, true]) {
677
+ const order = graphqlFirst ? 'GraphQLPlugin before HttpPlugin' : 'HttpPlugin before GraphQLPlugin'
678
+ test.it(`ends the subscriptions when the application stops, without waiting for the drain (${order})`, async () => {
679
+ const stopping = await boot(options, adapter, graphqlFirst)
680
+ const ended = observed(stopping).ended
681
+ const events = await subscribeOperation(stopping, { query: 'subscription { forever }' })
682
+ deepStrictEqual((await events.next()).value, { data: { forever: 1 } })
683
+ // the client keeps reading, as a real one does (the stream ends once read)
684
+ const rest = drain(events)
685
+ const started = Date.now()
686
+ await stopping.close()
687
+ const elapsed = Date.now() - started
688
+ ok(elapsed < DRAIN_TIMEOUT_MS / 2, `the shutdown waited ${elapsed} ms for the drain`)
689
+ await until(() => ended.includes('forever'), 'the end of the source on shutdown')
690
+ await rest
691
+ strictEqual(graphql(stopping).activeSubscriptions, 0)
692
+ })
693
+ }
694
+ }
695
+
696
+ async function drain(events: OperationStream): Promise<OperationResult[]> {
697
+ const results: OperationResult[] = []
698
+ for (let step = await events.next(); step.done !== true; step = await events.next()) results.push(step.value)
699
+ return results
700
+ }