From a21ea67219f7403ff13eba285542682b7821ab49 Mon Sep 17 00:00:00 2001 From: Sam Curry Date: Fri, 8 May 2026 14:46:28 +0800 Subject: [PATCH 1/2] feat(testing): add /testing submodule with buildSubscribeOperation Mirrors the buildExecuteOperation helper from @makerx/graphql-apollo-server/testing for subscription operations. Calls graphql.subscribe directly against the schema, bypassing the graphql-ws transport so tests can drive context inputs (JWT payloads, etc.) without standing up a websocket server. Bump to 3.1.0 for the new export. --- .tstoolkitrc.ts | 1 + README.md | 98 ++++++++++++++++++++++++++++++ package-lock.json | 16 ++++- package.json | 3 +- rollup.config.ts | 2 +- src/testing/index.ts | 1 + src/testing/subscribe-operation.ts | 53 ++++++++++++++++ 7 files changed, 169 insertions(+), 5 deletions(-) create mode 100644 src/testing/index.ts create mode 100644 src/testing/subscribe-operation.ts diff --git a/.tstoolkitrc.ts b/.tstoolkitrc.ts index df46d0a..b4bcb7b 100644 --- a/.tstoolkitrc.ts +++ b/.tstoolkitrc.ts @@ -10,6 +10,7 @@ const config: TsToolkitConfig = { '.': 'index.ts', './shield': 'shield.ts', './subscriptions': 'subscriptions/index.ts', + './testing': 'testing/index.ts', }, }, } diff --git a/README.md b/README.md index 5267249..73040aa 100644 --- a/README.md +++ b/README.md @@ -405,6 +405,104 @@ The returned value is both an `AsyncIterator` and `AsyncIterable`, so it can be extractAnonymousOperationName('subscription { onUpdate { id } }') // 'subscription onUpdate' ``` +## GraphQL test helpers + +The `@makerx/graphql-core/testing` submodule provides helpers for running GraphQL operations directly against a schema, without spinning up an HTTP or websocket server. + +Bypassing the transport layer supports complete control over JWT payloads and other operation context inputs required to set up complex test scenarios. + +This complements the `@makerx/graphql-apollo-server/testing` module, which exports `buildExecuteOperation` for running queries and mutations directly against an `ApolloServer` instance. Subscriptions normally flow over websockets via [graphql-ws](https://the-guild.dev/graphql/ws), so they need their own helper that calls `graphql.subscribe` directly against the schema. + +### buildSubscribeOperation + +`buildSubscribeOperation` accepts a `GraphQLSchema` (or async schema factory) and a context creation function and returns a `subscribeOperation` function which: + +- is strongly typed to the GraphQL context +- accepts `TypedDocumentNode` subscriptions to provide strong operation typing +- forwards any additional arguments to the supplied context creation function +- returns the subscription's `AsyncIterableIterator>` so tests can consume events via `for await` or manual `next()` calls +- throws if the subscription fails to produce an iterator (e.g. a validation error), surfacing the underlying errors + +The shape of the context creation function — and the JWT/user factories used to drive it — depends on your GraphQL implementation, so the example below illustrates one common pattern using [Vitest test contexts](https://vitest.dev/guide/test-context), matching the [`buildExecuteOperation`](https://github.com/MakerXStudio/graphql-apollo-server#apollo-server-test-helpers) examples for queries and mutations. + +#### Vitest GraphQL context example + +Note: extend auth context (e.g. `buildJwt`, `buildUserJwt`) and other context as required — see the [graphql-apollo-server test helper docs](https://github.com/MakerXStudio/graphql-apollo-server#vitest-auth-context-example) for a complete auth fixture example. + +`test/graphql.ts` + +```ts +import { buildSubscribeOperation } from '@makerx/graphql-core/testing' +import { test as baseTest } from './auth' + +const createContext = async (jwtPayload?: JwtPayload): Promise => { + const user = await findUpdateOrCreateUser(jwtPayload, randomUUID()) + const baseContext: BaseContext = { user, logger, requestInfo, started: Date.now() } + const extraContext = await augmentContext(baseContext) + return { ...baseContext, ...extraContext } +} + +export const test = baseTest + .extend('schema', { scope: 'worker' }, async ({}) => createSchema()) + .extend('backgroundJobs', { scope: 'worker' }, backgroundJobsFixture) + .extend('executeOperation', { scope: 'worker' }, async ({ schema }, { onCleanup }) => { + const server = new ApolloServer({ schema }) + onCleanup(() => server.stop()) + return buildExecuteOperation(server, createContext) + }) + .extend('subscribeOperation', { scope: 'worker' }, async ({ schema }) => { + return buildSubscribeOperation(schema, createContext) + }) +``` + +#### job-status.subscription.test.ts + +The test below uses the `graphql` template-literal tag from [GraphQL-Codegen](https://the-guild.dev/graphql/codegen/docs/getting-started) to produce strongly typed operations. + +```ts +import { describe, expect } from 'vitest' +import { graphql } from './gql' +import { test } from './graphql' + +const jobStatusSubscription = graphql(` + subscription JobStatus($jobId: ID!) { + jobStatus(jobId: $jobId) { + jobId + status + } + } +`) + +describe('jobStatus subscription operation', () => { + test('anonymous subscribes fail', async ({ subscribeOperation }) => { + await expect(subscribeOperation({ subscription: jobStatusSubscription, variables: { jobId: 'job-1' } })).rejects.toThrow( + /Not authenticated/, + ) + }) + + test('authenticated subscribers receive events until terminal status', async ({ subscribeOperation, buildUserJwt }) => { + const jwt = buildUserJwt() + const jobId = randomUUID() + + const iterator = await subscribeOperation({ subscription: jobStatusSubscription, variables: { jobId } }, jwt) + + // app-specific: trigger the events your subscription resolves over + // await addJob('demo', { demoPayload: 'subscription-test' }, { jobId }) + + const events: JobStatusSubscription[] = [] + for await (const result of iterator) { + if (result.data) events.push(result.data) + } + + expect(events.at(-1)?.jobStatus.status).toBe('Completed') + }) +}) +``` + +The first argument is a `TypedSubscribeRequest` (`subscription`, `variables`, `operationName`); any further arguments are forwarded verbatim to the context creation function — in the example above, the optional `JwtPayload` produced by `buildUserJwt()`. + +When pairing this helper with [`wrapSubscriptionIterator`](#wrapsubscriptioniterator), `eventIsFinal` will close the subscription once a terminal event arrives, ending the `for await` loop above without manual cleanup. + ## \*Express peer dependency ApolloServer v3 standardises on the Express request representation. diff --git a/package-lock.json b/package-lock.json index b23fa21..d58357f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@makerx/graphql-core", - "version": "3.0.1", + "version": "3.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@makerx/graphql-core", - "version": "3.0.1", + "version": "3.1.0", "license": "MIT", "devDependencies": { "@arethetypeswrong/cli": "^0.18.2", @@ -51,6 +51,7 @@ "node": ">=20.0" }, "peerDependencies": { + "@graphql-typed-document-node/core": ">=3.2.0", "@makerx/node-common": ">=1", "es-toolkit": ">=1", "express": ">=4", @@ -1110,6 +1111,16 @@ "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, + "node_modules/@graphql-typed-document-node/core": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@graphql-typed-document-node/core/-/core-3.2.0.tgz", + "integrity": "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==", + "license": "MIT", + "peer": true, + "peerDependencies": { + "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -4506,7 +4517,6 @@ "version": "16.13.2", "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.13.2.tgz", "integrity": "sha512-5bJ+nf/UCpAjHM8i06fl7eLyVC9iuNAjm9qzkiu2ZGhM0VscSvS6WDPfAwkdkBuoXGM9FJSbKl6wylMwP9Ktig==", - "dev": true, "license": "MIT", "engines": { "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" diff --git a/package.json b/package.json index 2e36619..7021310 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@makerx/graphql-core", - "version": "3.0.1", + "version": "3.1.0", "private": false, "description": "A set of core GraphQL utilities that MakerX uses to build GraphQL APIs", "author": "MakerX", @@ -36,6 +36,7 @@ "test:ci": "vitest run --coverage --reporter junit --outputFile test-results.xml" }, "peerDependencies": { + "@graphql-typed-document-node/core": ">=3.2.0", "@makerx/node-common": ">=1", "es-toolkit": ">=1", "express": ">=4", diff --git a/rollup.config.ts b/rollup.config.ts index a3f8f08..1302941 100644 --- a/rollup.config.ts +++ b/rollup.config.ts @@ -6,7 +6,7 @@ import { isAbsolute } from 'node:path' import type { RollupOptions } from 'rollup' const config: RollupOptions = { - input: ['src/index.ts', 'src/shield.ts', 'src/subscriptions/index.ts'], + input: ['src/index.ts', 'src/shield.ts', 'src/subscriptions/index.ts', 'src/testing/index.ts'], output: [ { dir: 'dist', diff --git a/src/testing/index.ts b/src/testing/index.ts new file mode 100644 index 0000000..6674da4 --- /dev/null +++ b/src/testing/index.ts @@ -0,0 +1 @@ +export * from './subscribe-operation' diff --git a/src/testing/subscribe-operation.ts b/src/testing/subscribe-operation.ts new file mode 100644 index 0000000..ad0c655 --- /dev/null +++ b/src/testing/subscribe-operation.ts @@ -0,0 +1,53 @@ +import type { TypedDocumentNode } from '@graphql-typed-document-node/core' +import { parse, subscribe, type DocumentNode, type ExecutionResult, type GraphQLSchema } from 'graphql' +import type { AnyGraphqlContext } from '../context' + +export type VariableValues = { [key: string]: any } + +export type TypedSubscribeRequest, TVariables extends VariableValues = VariableValues> = { + subscription: string | DocumentNode | TypedDocumentNode + variables?: TVariables + operationName?: string +} + +/** + * Returns a `subscribeOperation` test helper for the provided schema and context creation function. + * + * The returned function runs a subscription operation directly against the schema using `graphql.subscribe`, + * bypassing the websocket / `graphql-ws` transport. This gives tests complete control over JWT payloads + * and other context inputs, the same way `buildExecuteOperation` does for queries and mutations in + * `@makerx/graphql-apollo-server/testing`. + * + * The returned function: + * - is strongly typed to the GraphQL context + * - accepts `TypedDocumentNode` subscriptions to provide strong operation typing + * - forwards any additional arguments to the supplied context creation function + * - returns the subscription's `AsyncIterableIterator>` so tests can consume events via `for await` or manual `next()` calls + * - throws if the subscription fails to produce an iterator (e.g. a validation error), surfacing the underlying errors + * + * @param schema The executable `GraphQLSchema`, or a factory that resolves one (useful when schema construction is async). + * @param createContext A context creation function. Any args supplied after the request are forwarded to it, + * so test fixtures can drive auth/user state per-call (e.g. `(jwtPayload?) => Promise`). + */ +export function buildSubscribeOperation Promise>( + schema: GraphQLSchema | (() => Promise), + createContext: TContextFunction, +) { + return async function subscribeOperation, TVariables extends VariableValues = VariableValues>( + { subscription, variables, operationName }: TypedSubscribeRequest, + ...createContextArgs: Parameters + ): Promise>> { + const resolvedSchema = typeof schema === 'function' ? await schema() : schema + const document = typeof subscription === 'string' ? parse(subscription) : subscription + const contextValue = await createContext(...createContextArgs) + const result = await subscribe({ + schema: resolvedSchema, + document, + contextValue, + variableValues: variables as VariableValues | undefined, + operationName, + }) + if (Symbol.asyncIterator in result) return result as AsyncIterableIterator> + throw new Error(`Subscription did not produce an iterator: ${JSON.stringify((result as ExecutionResult).errors)}`) + } +} From cfe6b6847734b159f7d2afe40175725478849cbc Mon Sep 17 00:00:00 2001 From: Sam Curry Date: Fri, 8 May 2026 14:50:12 +0800 Subject: [PATCH 2/2] more indicative graphql test context import --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 73040aa..0f3a923 100644 --- a/README.md +++ b/README.md @@ -462,7 +462,7 @@ The test below uses the `graphql` template-literal tag from [GraphQL-Codegen](ht ```ts import { describe, expect } from 'vitest' import { graphql } from './gql' -import { test } from './graphql' +import { test } from '../../../test/graphql' const jobStatusSubscription = graphql(` subscription JobStatus($jobId: ID!) {