mocks.ts 13 KB

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