Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .tstoolkitrc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const config: TsToolkitConfig = {
'.': 'index.ts',
'./shield': 'shield.ts',
'./subscriptions': 'subscriptions/index.ts',
'./testing': 'testing/index.ts',
},
},
}
Expand Down
98 changes: 98 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<ExecutionResult<TData>>` 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<GraphQLContext> => {
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<GraphQLContext>({ 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 '../../../test/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.
Expand Down
16 changes: 13 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion rollup.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions src/testing/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './subscribe-operation'
53 changes: 53 additions & 0 deletions src/testing/subscribe-operation.ts
Original file line number Diff line number Diff line change
@@ -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<TData = Record<string, unknown>, TVariables extends VariableValues = VariableValues> = {
subscription: string | DocumentNode | TypedDocumentNode<TData, TVariables>
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<ExecutionResult<TData>>` 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<GraphQLContext>`).
*/
export function buildSubscribeOperation<TContext extends AnyGraphqlContext, TContextFunction extends (...args: any) => Promise<TContext>>(
schema: GraphQLSchema | (() => Promise<GraphQLSchema>),
createContext: TContextFunction,
) {
return async function subscribeOperation<TData = Record<string, unknown>, TVariables extends VariableValues = VariableValues>(
{ subscription, variables, operationName }: TypedSubscribeRequest<TData, TVariables>,
...createContextArgs: Parameters<TContextFunction>
): Promise<AsyncIterableIterator<ExecutionResult<TData>>> {
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<ExecutionResult<TData>>
throw new Error(`Subscription did not produce an iterator: ${JSON.stringify((result as ExecutionResult).errors)}`)
}
}