mocks.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411
  1. // Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/
  2. import {
  3. ApolloClient,
  4. ApolloLink,
  5. Observable,
  6. type FetchResult,
  7. type Operation,
  8. } from '@apollo/client/core'
  9. import {
  10. cloneDeep,
  11. mergeDeep,
  12. removeConnectionDirectiveFromDocument,
  13. } from '@apollo/client/utilities'
  14. import { provideApolloClient } from '@vue/apollo-composable'
  15. import { visit, print } from 'graphql'
  16. import {
  17. Kind,
  18. type DocumentNode,
  19. OperationTypeNode,
  20. GraphQLError,
  21. type DefinitionNode,
  22. type FieldNode,
  23. type OperationDefinitionNode,
  24. type TypeNode,
  25. type SelectionNode,
  26. type FragmentDefinitionNode,
  27. } from 'graphql'
  28. import { noop } from 'lodash-es'
  29. import { waitForNextTick } from '#tests/support/utils.ts'
  30. import createCache from '#shared/server/apollo/cache.ts'
  31. import type { CacheInitializerModules } from '#shared/types/server/apollo/client.ts'
  32. import type { DeepPartial, DeepRequired } from '#shared/types/utils.ts'
  33. import {
  34. getFieldData,
  35. getObjectDefinition,
  36. getOperationDefinition,
  37. mockOperation,
  38. validateOperationVariables,
  39. } from './index.ts'
  40. interface MockCall<T = any> {
  41. document: DocumentNode
  42. result: T
  43. variables: Record<string, any>
  44. }
  45. const mockDefaults = new Map<string, any>()
  46. const mockCalls = new Map<string, MockCall[]>()
  47. const queryStrings = new WeakMap<DocumentNode, string>()
  48. // mutation:login will return query string for mutation login
  49. const namesToQueryKeys = new Map<string, string>()
  50. const stripNames = (query: DocumentNode) => {
  51. return visit(query, {
  52. Field: {
  53. enter(node) {
  54. return node.name.value === '__typename' ? null : undefined
  55. },
  56. },
  57. })
  58. }
  59. const normalize = (query: DocumentNode) => {
  60. const directiveless = removeConnectionDirectiveFromDocument(query)
  61. const stripped = directiveless !== null ? stripNames(directiveless) : null
  62. return stripped === null ? query : stripped
  63. }
  64. const requestToKey = (query: DocumentNode) => {
  65. const cached = queryStrings.get(query)
  66. if (cached) return cached
  67. const operationNode = query.definitions.find(
  68. (node) => node.kind === Kind.OPERATION_DEFINITION,
  69. ) as OperationDefinitionNode
  70. const operationNameKey = `${operationNode.operation}:${
  71. operationNode.name?.value || ''
  72. }`
  73. const normalized = normalize(query)
  74. const queryString = query && print(normalized)
  75. const stringified = JSON.stringify({ query: queryString })
  76. queryStrings.set(query, stringified)
  77. namesToQueryKeys.set(operationNameKey, stringified)
  78. return stringified
  79. }
  80. const stripQueryData = (
  81. definition: DefinitionNode | FieldNode,
  82. fragments: FragmentDefinitionNode[],
  83. resultData: any,
  84. newData: any = {},
  85. // eslint-disable-next-line sonarjs/cognitive-complexity
  86. ) => {
  87. if (!('selectionSet' in definition) || !definition.selectionSet) {
  88. return newData
  89. }
  90. if (typeof newData !== 'object' || newData === null) {
  91. return newData
  92. }
  93. if (typeof newData !== 'object' || resultData === null) {
  94. return resultData
  95. }
  96. const name = definition.name!.value
  97. // eslint-disable-next-line sonarjs/cognitive-complexity
  98. const processNode = (node: SelectionNode) => {
  99. if (node.kind === Kind.INLINE_FRAGMENT) {
  100. const condition = node.typeCondition
  101. if (!condition || condition.kind !== Kind.NAMED_TYPE) {
  102. throw new Error('Unknown type condition!')
  103. }
  104. const typename = condition.name.value
  105. if (resultData.__typename === typename) {
  106. node.selectionSet.selections.forEach(processNode)
  107. }
  108. return
  109. }
  110. if (node.kind === Kind.FRAGMENT_SPREAD) {
  111. const fragment = fragments.find(
  112. (fragment) => fragment.name.value === node.name.value,
  113. )
  114. if (fragment) {
  115. fragment.selectionSet.selections.forEach(processNode)
  116. }
  117. return
  118. }
  119. const fieldName =
  120. 'alias' in node && node.alias ? node.alias?.value : node.name!.value
  121. if (!fieldName) {
  122. return
  123. }
  124. const resultValue = resultData?.[fieldName]
  125. if ('selectionSet' in node && node.selectionSet) {
  126. if (Array.isArray(resultValue)) {
  127. newData[fieldName] = resultValue.map((item) =>
  128. stripQueryData(node, fragments, item, newData[name]),
  129. )
  130. } else {
  131. newData[fieldName] = stripQueryData(
  132. node,
  133. fragments,
  134. resultValue,
  135. newData[name],
  136. )
  137. }
  138. } else {
  139. newData[fieldName] = resultValue ?? null
  140. }
  141. }
  142. definition.selectionSet?.selections.forEach(processNode)
  143. return newData
  144. }
  145. type OperationType = 'query' | 'mutation' | 'subscription'
  146. const getCachedKey = (
  147. documentOrOperation: DocumentNode | OperationType,
  148. operationName?: string,
  149. ) => {
  150. const key =
  151. typeof documentOrOperation === 'string'
  152. ? namesToQueryKeys.get(`${documentOrOperation}:${operationName}`)
  153. : requestToKey(documentOrOperation)
  154. if (!key) {
  155. throw new Error(
  156. `Cannot find key for ${documentOrOperation}:${operationName}. This happens if query was not executed yet or if it was not mocked.`,
  157. )
  158. }
  159. return key
  160. }
  161. export const getGraphQLMockCalls = <T>(
  162. documentOrOperation: DocumentNode | OperationType,
  163. operationName?: keyof T & string,
  164. ): MockCall<DeepRequired<T>>[] => {
  165. return mockCalls.get(getCachedKey(documentOrOperation, operationName)) || []
  166. }
  167. export const waitForGraphQLMockCalls = <T>(
  168. documentOrOperation: DocumentNode | OperationType,
  169. operationName?: keyof T & string,
  170. ): Promise<MockCall<DeepRequired<T>>[]> => {
  171. return vi.waitUntil(() => {
  172. try {
  173. const calls = getGraphQLMockCalls<T>(documentOrOperation, operationName)
  174. return calls.length && calls
  175. } catch {
  176. return false
  177. }
  178. })
  179. }
  180. export type MockDefaultsValue<T, V = Record<string, never>> =
  181. | DeepPartial<T>
  182. | ((variables: V) => DeepPartial<T>)
  183. export const mockGraphQLResult = <
  184. T extends Record<string, any>,
  185. V extends Record<string, any> = Record<string, never>,
  186. >(
  187. document: DocumentNode,
  188. defaults: MockDefaultsValue<T, V>,
  189. ) => {
  190. const key = requestToKey(document)
  191. mockDefaults.set(key, defaults)
  192. return {
  193. updateDefaults: (defaults: MockDefaultsValue<T, V>) => {
  194. mockDefaults.set(key, defaults)
  195. },
  196. waitForCalls: () => waitForGraphQLMockCalls<T>(document),
  197. }
  198. }
  199. export interface TestSubscriptionHandler<T extends Record<string, any> = any> {
  200. /** Ensure that some data will be returned based on the schema */
  201. trigger(defaults?: DeepPartial<T>): Promise<T>
  202. triggerErrors(errors: GraphQLError[]): Promise<void>
  203. error(values?: unknown): void
  204. complete(): void
  205. closed(): boolean
  206. }
  207. const mockSubscriptionHanlders = new Map<
  208. string,
  209. TestSubscriptionHandler<Record<string, any>>
  210. >()
  211. export const getGraphQLSubscriptionHandler = <T extends Record<string, any>>(
  212. documentOrName: DocumentNode | (keyof T & string),
  213. ) => {
  214. const key =
  215. typeof documentOrName === 'string'
  216. ? getCachedKey('subscription', documentOrName)
  217. : getCachedKey(documentOrName)
  218. return mockSubscriptionHanlders.get(key) as TestSubscriptionHandler<T>
  219. }
  220. afterEach(() => {
  221. mockCalls.clear()
  222. mockSubscriptionHanlders.clear()
  223. mockDefaults.clear()
  224. mockCalls.clear()
  225. })
  226. const getInputObjectType = (variableNode: TypeNode): string | null => {
  227. if (variableNode.kind === Kind.NON_NULL_TYPE) {
  228. return getInputObjectType(variableNode.type)
  229. }
  230. if (variableNode.kind === Kind.LIST_TYPE) {
  231. return null
  232. }
  233. return variableNode.name.value
  234. }
  235. // assume "$input" value is the default, but test defaults will take precedence
  236. const getQueryDefaults = (
  237. requestKey: string,
  238. definition: OperationDefinitionNode,
  239. variables: Record<string, any> = {},
  240. ): Record<string, any> => {
  241. let userDefaults = mockDefaults.get(requestKey)
  242. if (typeof userDefaults === 'function') {
  243. userDefaults = userDefaults(variables)
  244. }
  245. if (!variables.input || definition.operation !== OperationTypeNode.MUTATION)
  246. return userDefaults
  247. const inputVariableNode = definition.variableDefinitions?.find((node) => {
  248. return node.variable.name.value === 'input'
  249. })
  250. if (!inputVariableNode) return userDefaults
  251. const objectInputType = getInputObjectType(inputVariableNode.type)
  252. if (!objectInputType || !objectInputType.endsWith('Input'))
  253. return userDefaults
  254. const objectType = objectInputType.slice(0, -5)
  255. const mutationName = definition.name!.value
  256. const mutationDefinition = getOperationDefinition(
  257. definition.operation,
  258. mutationName,
  259. )
  260. // expect object to be in the first level, otherwise we might update the wrong object
  261. const payloadDefinition = getObjectDefinition(mutationDefinition.type.name)
  262. const sameTypeField = payloadDefinition.fields?.find((node) => {
  263. return getFieldData(node.type).name === objectType
  264. })
  265. if (!sameTypeField) return userDefaults
  266. const inputDefaults = {
  267. [mutationName]: {
  268. [sameTypeField.name]: variables.input,
  269. },
  270. }
  271. return mergeDeep(inputDefaults, userDefaults || {})
  272. }
  273. // This link automatically:
  274. // - respects "$typeId" variables, doesn't respect "$typeInternalId" because we need to move away from them
  275. // - mocks queries, respects defaults
  276. // - mocks mutations, respects defaults, but also looks for "$input" variable and updates the object if it's inside the first level
  277. // - mocks result for subscriptions, respects defaults
  278. class MockLink extends ApolloLink {
  279. // eslint-disable-next-line class-methods-use-this
  280. request(operation: Operation): Observable<FetchResult> | null {
  281. const { query, variables } = operation
  282. const definition = query.definitions[0]
  283. if (definition.kind !== Kind.OPERATION_DEFINITION) {
  284. return null
  285. }
  286. const fragments = query.definitions.filter(
  287. (def) => def.kind === Kind.FRAGMENT_DEFINITION,
  288. ) as FragmentDefinitionNode[]
  289. const queryKey = requestToKey(query)
  290. return new Observable((observer) => {
  291. const { operation } = definition
  292. try {
  293. validateOperationVariables(definition, variables)
  294. } catch (err) {
  295. if (operation === OperationTypeNode.QUERY) {
  296. // queries eat the errors, but we want to see them
  297. console.error(err)
  298. }
  299. throw err
  300. }
  301. if (operation === OperationTypeNode.SUBSCRIPTION) {
  302. const handler: TestSubscriptionHandler = {
  303. async trigger(defaults) {
  304. const resultValue = mockOperation(query, variables, defaults)
  305. const data = stripQueryData(definition, fragments, resultValue)
  306. observer.next({ data })
  307. await waitForNextTick(true)
  308. return resultValue
  309. },
  310. async triggerErrors(errors) {
  311. observer.next({ errors })
  312. await waitForNextTick(true)
  313. },
  314. error: observer.error.bind(observer),
  315. complete: observer.complete.bind(observer),
  316. closed: () => observer.closed,
  317. }
  318. mockSubscriptionHanlders.set(queryKey, handler)
  319. return noop
  320. }
  321. try {
  322. const defaults = getQueryDefaults(queryKey, definition, variables)
  323. const returnResult = mockOperation(query, variables, defaults)
  324. const result = { data: returnResult }
  325. const calls = mockCalls.get(queryKey) || []
  326. calls.push({ document: query, result: result.data, variables })
  327. mockCalls.set(queryKey, calls)
  328. observer.next(
  329. cloneDeep({
  330. data: stripQueryData(definition, fragments, result.data),
  331. }),
  332. )
  333. observer.complete()
  334. } catch (e) {
  335. console.error(e)
  336. throw e
  337. }
  338. return noop
  339. })
  340. }
  341. }
  342. // Include both shared and app-specific cache initializer modules.
  343. const cacheInitializerModules: CacheInitializerModules = import.meta.glob(
  344. '../../../**/server/apollo/cache/initializer/*.ts',
  345. { eager: true },
  346. )
  347. const createMockClient = () => {
  348. const link = new MockLink()
  349. const cache = createCache(cacheInitializerModules)
  350. const client = new ApolloClient({
  351. cache,
  352. link,
  353. })
  354. provideApolloClient(client)
  355. return client
  356. }
  357. // this enabled automocking - if this file is not imported somehow, fetch request will throw an error
  358. export const mockedApolloClient = createMockClient()
  359. vi.mock('#shared/server/apollo/client.ts', () => {
  360. return {
  361. clearApolloClientStore: async () => {
  362. await mockedApolloClient.clearStore()
  363. },
  364. getApolloClient: () => {
  365. return mockedApolloClient
  366. },
  367. }
  368. })
  369. afterEach(() => {
  370. mockedApolloClient.clearStore()
  371. })