Files
speckle-server/packages/server/test/graphqlHelper.ts
T
Kristaps Fabians Geikins 1800dbbdbd feat(server): improved subscription testing DX + tests for common subs + new subs (#3554)
* userProjectsUpdated.added test

* multi region support

* userStreamAdded

* commit subs tested

* fix for proj workspace assignment

* undo commitHelper main changes

* disable all ffs mode

* createTestWorkspace support when workspaces arent enabled

* project create fix

* workspace projects updated subscription

* WIP new workspace sub

* updated workspaceCreated

* updated workspaceUpdated

* BE for workspace updated sub

* workspace updated sub

* ts err fix
2024-11-27 15:41:04 +02:00

428 lines
12 KiB
TypeScript

/* eslint-disable @typescript-eslint/no-explicit-any */
import { DocumentNode, FormattedExecutionResult } from 'graphql'
import { GraphQLContext } from '@/modules/shared/helpers/typeHelper'
import { TypedDocumentNode } from '@graphql-typed-document-node/core'
import { buildApolloServer, buildApolloSubscriptionServer } from '@/app'
import { addLoadersToCtx } from '@/modules/shared/middleware'
import { Roles } from '@/modules/core/helpers/mainConstants'
import {
AllScopes,
buildManualPromise,
ManualPromise,
MaybeAsync,
MaybeNullOrUndefined,
Optional,
timeoutAt
} from '@speckle/shared'
import { expect } from 'chai'
import { ApolloServer, GraphQLResponse } from '@apollo/server'
import { getUserFactory } from '@/modules/core/repositories/users'
import { db } from '@/db/knex'
import { pick, set } from 'lodash'
import { isTestEnv } from '@/modules/shared/helpers/envHelper'
import { publish, TestSubscriptions } from '@/modules/shared/utils/subscriptions'
import cryptoRandomString from 'crypto-random-string'
import * as MockSocket from 'mock-socket'
import type ws from 'ws'
import { createAuthTokenForUser } from '@/test/authHelper'
import { SubscriptionClient } from 'subscriptions-transport-ws'
import { WebSocketLink } from '@apollo/client/link/ws'
import { execute } from '@apollo/client/core'
import { PingPongDocument } from '@/test/graphql/generated/graphql'
type TypedGraphqlResponse<R = Record<string, any>> = GraphQLResponse<R>
const getUser = getUserFactory({ db })
export const getResponseResults = <TData = Record<string, unknown>>(
res: GraphQLResponse<TData>
) => {
const body = res.body
if (body.kind === 'incremental') {
return {
data: body.initialResult.data as MaybeNullOrUndefined<TData>,
errors: body.initialResult.errors
}
} else {
return {
data: body.singleResult.data as MaybeNullOrUndefined<TData>,
errors: body.singleResult.errors
}
}
}
export type ExecuteOperationResponse<R extends Record<string, any>> = {
res: TypedGraphqlResponse<R>
} & ReturnType<typeof getResponseResults<R>>
export type ServerAndContext = {
apollo: ApolloServer<GraphQLContext>
context?: MaybeNullOrUndefined<GraphQLContext>
}
export type ExecuteOperationServer = ServerAndContext
/**
* Use this to execute GQL operations from tests against an Apollo instance and get
* a properly typed response
* @deprecated Use `testApolloServer` instead
*/
export async function executeOperation<
R extends Record<string, any> = Record<string, any>,
V extends Record<string, any> = Record<string, any>
>(
apollo: ExecuteOperationServer,
query: DocumentNode,
variables?: V,
context?: GraphQLContext
): Promise<ExecuteOperationResponse<R>> {
const server: ApolloServer<GraphQLContext> = apollo.apollo
const contextValue = context || apollo.context || (await createTestContext())
const res = (await server.executeOperation(
{
query,
variables
},
{ contextValue }
)) as TypedGraphqlResponse<R>
const results = getResponseResults(res)
// Replicate clearing dataloaders after each request
contextValue.loaders.clearAll()
return {
...results,
res
}
}
/**
* Create a test context for a GraphQL request. Optionally override any of the default values.
* By default the context will be unauthenticated
*/
export const createTestContext = async (
ctx?: Partial<GraphQLContext>
): Promise<GraphQLContext> =>
addLoadersToCtx({
auth: false,
userId: undefined,
role: undefined,
token: undefined,
scopes: [],
stream: undefined,
err: undefined,
...(ctx || {})
})
export const createAuthedTestContext = async (
userId: string,
ctxOverrides?: Partial<GraphQLContext>
): Promise<GraphQLContext> =>
addLoadersToCtx({
auth: true,
userId,
role: Roles.Server.User,
token: 'asd',
scopes: AllScopes,
...(ctxOverrides || {})
})
const buildMergedContext = async (params: {
/**
* Base/initial context, if any
*/
baseCtx?: GraphQLContext
/**
* Context overrides to apply at the very end
*/
contextOverrides?: Array<Partial<GraphQLContext>>
/**
* If set, adjust context to be authed w/ all scopes and the actual user role for this user id.
*/
authUserId?: string
}) => {
let baseCtx: GraphQLContext = params.baseCtx || (await createTestContext())
// Init ctx from userId?
if (params?.authUserId) {
const userData = await getUser(params.authUserId, { withRole: true })
const role = userData?.role || Roles.Server.User
const userCtx = await createAuthedTestContext(params.authUserId, { role })
// Apply authed context to base
baseCtx = {
...baseCtx,
...pick(userCtx, ['auth', 'userId', 'role', 'token', 'scopes'])
}
}
// If ctx passed in also - merge them
if (params?.contextOverrides?.length) {
for (const ctx of params.contextOverrides) {
baseCtx = {
...baseCtx,
...ctx
}
}
}
// Apply dataloaders from scratch
baseCtx = await createTestContext(baseCtx)
return baseCtx
}
/**
* Utilities that make it easier to test against an Apollo Server instance
*/
export const testApolloServer = async (params?: {
/**
* Pass in a context to use. If used together with authUserId, the two contexts will be merged w/ these
* overrides taking precedence
*/
context?: Partial<GraphQLContext>
/**
* If set, will create an authed context w/ all scopes and the actual user role for this user id.
* If user doesn't exist yet, will default to the User role
*/
authUserId?: string
}) => {
const baseCtx = await buildMergedContext({
authUserId: params?.authUserId,
contextOverrides: params?.context ? [params.context] : undefined
})
const instance = await buildApolloServer()
/**
* Execute an operation against Apollo and get a properly typed response
*/
const execute = async <
R extends Record<string, any> = Record<string, any>,
V extends Record<string, any> = Record<string, any>
>(
query: TypedDocumentNode<R, V>,
variables: V,
options?: Partial<{
/**
* Override context to use. If used together with authUserId, the two contexts will be merged w/ these
* overrides taking precedence
*/
context?: Partial<GraphQLContext>
/**
* If set, will create an authed context w/ all scopes and the actual user role for this user id.
* If user doesn't exist yet, will default to the User role
*/
authUserId?: string
/**
* Whether to add an assertion that there were no GQL errors
*/
assertNoErrors: boolean
}>
): Promise<ExecuteOperationResponse<R>> => {
const operationCtx =
options?.authUserId || options?.context
? await buildMergedContext({
baseCtx,
authUserId: options?.authUserId,
contextOverrides: [...(options?.context ? [options.context] : [])]
})
: undefined
const ctx = operationCtx || baseCtx
const res = (await instance.executeOperation(
{
query,
variables
},
{ contextValue: ctx }
)) as TypedGraphqlResponse<R>
if (options?.assertNoErrors) {
expect(res).to.not.haveGraphQLErrors()
}
const results = getResponseResults(res)
return {
...results,
res
}
}
return { execute, server: instance }
}
export type TestApolloServer = Awaited<ReturnType<typeof testApolloServer>>
export type ExecuteOperationOptions = Parameters<TestApolloServer['execute']>[2]
/**
* In test env we use a ping sub as a readiness signal for other subscriptions
* (there's no better way, no "is ready" event or anything)
*/
export const startEmittingTestSubs = async () => {
if (!isTestEnv()) return undefined
const intervalMs = 100
const interval = setInterval(async () => {
await publish(TestSubscriptions.Ping, { ping: new Date().toISOString() })
}, intervalMs)
return () => clearInterval(interval)
}
/**
* Utilities for quickly/easily testing GQL subscriptions without having to build real network servers & connections
*/
export const testApolloSubscriptionServer = async () => {
const serverId = cryptoRandomString({ length: 16, type: 'url-safe' })
const serverUrl = `ws://${serverId}.fakeWsServer:1234/graphql`
const mockWsServer = new MockSocket.Server(serverUrl)
set(mockWsServer, 'removeListener', mockWsServer.off.bind(mockWsServer)) // backwards compat w/ subscriptions-transport-ws
const mockWs = MockSocket.WebSocket as unknown as ws.WebSocket
const apolloSubServer = buildApolloSubscriptionServer(mockWsServer)
// weakRef to ensure we dont prevent garbage collection
const clients: WeakRef<SubscriptionClient>[] = []
/**
* Build subscription client. One per user is ideal.
*/
const buildClient = async (params?: {
/**
* Real user id to auth the connection with. If unset, will be unauthenticated
*/
authUserId?: string
}) => {
const { authUserId } = params || {}
const token = authUserId ? await createAuthTokenForUser(authUserId) : undefined
const wsClient = new SubscriptionClient(
serverUrl,
{
reconnect: true,
connectionParams: { headers: token ? { Authorization: `Bearer ${token}` } : {} }
},
mockWs
)
clients.push(new WeakRef(wsClient))
const clientLink = new WebSocketLink(wsClient)
/**
* Subscribe and return a fn for unsubscribing
*/
const subscribe = async <
R extends Record<string, any> = Record<string, any>,
V extends Record<string, any> = Record<string, any>
>(
query: TypedDocumentNode<R, V>,
variables: V,
handler: (res: FormattedExecutionResult<R>) => MaybeAsync<void>
) => {
let msgFlaggedPromise: Optional<ManualPromise<void>> = undefined
const messages: Array<FormattedExecutionResult<R>> = []
const observable = execute(clientLink, {
query,
variables
})
const sub = observable.subscribe((eventData) => {
const res = eventData as FormattedExecutionResult<R>
// Invoke handler
messages.push(res)
handler(res)
// Mark msg received
if (!msgFlaggedPromise) {
msgFlaggedPromise = buildManualPromise<void>()
}
msgFlaggedPromise.resolve()
})
/**
* Unsubscribe from the subscription
*/
const unsub = () => {
sub.unsubscribe()
}
/**
* Wait for a message to come in - it should be near instantenous, but it sometimes might occur in next ticks
* due to the async nature of subscriptions
*/
const waitForMessage = async (
options?: Partial<{
/**
* Max time to wait for the messag
* Defaults to: 200
*/
timeout: number
}>
) => {
const { timeout = 200 } = options || {}
if (!msgFlaggedPromise) {
msgFlaggedPromise = buildManualPromise<void>()
}
await Promise.race([msgFlaggedPromise.promise, timeoutAt(timeout)])
}
const getMessages = () => messages.slice()
return { unsub, waitForMessage, getMessages }
}
/**
* Invoke this after subscribe() calls to ensure that your subscriptions are ready
*/
const waitForReadiness = async () => {
return new Promise<void>(async (resolve, reject) => {
const { unsub } = await subscribe(PingPongDocument, {}, (res) => {
if (!res.data?.ping) {
return reject(new Error('Unexpected ping error'))
}
unsub()
resolve()
})
timeoutAt(5000).catch(reject)
})
}
/**
* Close down the client
*/
const quit = () => {
wsClient.close()
}
return { subscribe, waitForReadiness, quit }
}
/**
* Close down server and all clients
*/
const quit = () => {
for (const client of clients) {
client.deref()?.close()
}
mockWsServer.close()
apolloSubServer.close()
}
return {
buildClient,
quit
}
}
export type TestApolloSubscriptionServer = Awaited<
ReturnType<typeof testApolloSubscriptionServer>
>
export type TestApolloSubscriptionClient = Awaited<
ReturnType<TestApolloSubscriptionServer['buildClient']>
>