Skip to content

Commit

Permalink
Maintenance: Graphql automocker covers more situations
Browse files Browse the repository at this point in the history
  • Loading branch information
sheremet-va committed Oct 26, 2023
1 parent dc3b52b commit 74d5303
Show file tree
Hide file tree
Showing 5 changed files with 190 additions and 30 deletions.
57 changes: 57 additions & 0 deletions app/frontend/tests/graphql/builders/__tests__/queries.ts
Expand Up @@ -52,6 +52,63 @@ export const TestAvatarDocument = gql`
}
`

export interface TestTicketArticlesMultipleQuery {
description: {
edges: {
node: {
id: string
bodyWithUrls: string
}
}[]
}
articles: {
totalCount: number
edges: {
node: {
id: string
bodyWithUrls: string
}
cursor: string
}[]
pageInfo: {
endCursor: string
startCursor: string
hasPreviousPage: boolean
}
}
}

export const TestTicketArticlesMultiple = gql`
query ticketArticles($ticketId: ID!, $beforeCursor: String) {
description: ticketArticles(ticket: { ticketId: $ticketId }, first: 1) {
edges {
node {
id
bodyWithUrls
}
}
}
articles: ticketArticles(
ticket: { ticketId: $ticketId }
before: $beforeCursor
) {
totalCount
edges {
node {
id
bodyWithUrls
}
cursor
}
pageInfo {
endCursor
startCursor
hasPreviousPage
}
}
}
`

export const TestUserDocument = gql`
query user($userId: ID) {
user(user: { userId: $userId }) {
Expand Down
22 changes: 22 additions & 0 deletions app/frontend/tests/graphql/builders/__tests__/query-calls.spec.ts
Expand Up @@ -13,11 +13,13 @@ import {
import {
TestAutocompleteArrayFirstLevel,
TestAvatarDocument,
TestTicketArticlesMultiple,
TestUserDocument,
} from './queries.ts'
import type {
TestAutocompleteArrayFirstLevelQuery,
TestAvatarQuery,
TestTicketArticlesMultipleQuery,
TestUserQuery,
TestUserQueryVariables,
} from './queries.ts'
Expand Down Expand Up @@ -183,4 +185,24 @@ describe('calling queries with mocked data works correctly', () => {
).toBe(mocked?.autocompleteSearchObjectAttributeExternalDataSource.length)
expect(mocked).toMatchObject(data!)
})

it('query that references itself correctly returns data', async () => {
const handler = getQueryHandler<TestTicketArticlesMultipleQuery>(
TestTicketArticlesMultiple,
)

const { data, error } = await handler.query()
const { data: mock } = handler.getMockedData()

expect(error).toBeUndefined()
expect(data).toHaveProperty(
'description.edges.0.node.bodyWithUrls',
mock.description.edges[0].node.bodyWithUrls,
)
expect(data).toHaveProperty('articles.totalCount', mock.articles.totalCount)
expect(data).toHaveProperty(
'articles.edges.0.node.bodyWithUrls',
mock.articles.edges[0].node.bodyWithUrls,
)
})
})
79 changes: 62 additions & 17 deletions app/frontend/tests/graphql/builders/index.ts
Expand Up @@ -9,7 +9,12 @@ import {
getIdFromGraphQLId,
} from '#shared/graphql/utils.ts'

import { Kind, type DocumentNode, OperationTypeNode } from 'graphql'
import {
Kind,
type DocumentNode,
OperationTypeNode,
type FieldNode,
} from 'graphql'
import { createRequire } from 'node:module'
import type { DeepPartial, DeepRequired } from '#shared/types/utils.ts'
import { uniqBy } from 'lodash-es'
Expand Down Expand Up @@ -342,6 +347,8 @@ const buildObjectFromInformation = (
return builtList
}

// we always generate full object because it might be reused later
// in another query with more parameters
const generateObject = (
parent: Record<string, any> | undefined,
definition: SchemaObjectType,
Expand Down Expand Up @@ -387,8 +394,11 @@ const generateObject = (
}
const needUpdateTotalCount =
type.endsWith('Connection') && !('totalCount' in value)
definition.fields!.forEach((field) => {
const { name } = field
const buildField = (
field: SchemaObjectField,
node: FieldNode | null,
name: string,
) => {
// ignore null and undefined
if (name in value && value[name] == null) {
return
Expand All @@ -413,7 +423,8 @@ const generateObject = (
if (meta.cached && name === 'id') {
storedObjects.set(value.id, value)
}
})
}
definition.fields!.forEach((field) => buildField(field, null, field.name))
if (needUpdateTotalCount) {
value.totalCount = value.edges.length
}
Expand Down Expand Up @@ -465,7 +476,7 @@ const generateGqlValue = (
parent: Record<string, any> | undefined,
fieldName: string,
typeDefinition: SchemaType,
defaults: Record<string, any> | undefined,
defaults: Record<string, any> | null | undefined,
meta: ResolversMeta,
) => {
if (defaults === null) return null
Expand Down Expand Up @@ -511,25 +522,59 @@ export const mockOperation = (
if (definition.kind !== Kind.OPERATION_DEFINITION) {
throw new Error(`${(definition as any).name} is not an operation`)
}
const { operation, name } = definition
const { operation, name, selectionSet } = definition
const operationName = name!.value!
const operationType = getOperationDefinition(operation, operationName)
const query: any = { __typename: queriesTypes[operation] }
results.set(document, query)
const rootName = operationType.name
logger.log(`[MOCKER] mocking "${rootName}" ${operation}`)

query[rootName] = buildObjectFromInformation(
query,
rootName,
getFieldInformation(operationType.type),
defaults?.[rootName],
{
document,
variables,
cached: true,
},
)
const information = getFieldInformation(operationType.type)

if (selectionSet.selections.length === 1) {
const selection = selectionSet.selections[0]
if (selection.kind !== Kind.FIELD) {
throw new Error(
`unsupported selection kind ${selectionSet.selections[0].kind}`,
)
}
if (selection.name.value !== rootName) {
throw new Error(
`unsupported selection name ${selection.name.value} (${operation} is ${operationType.name})`,
)
}
query[rootName] = buildObjectFromInformation(
query,
rootName,
information,
defaults?.[rootName],
{
document,
variables,
cached: true,
},
)
} else {
selectionSet.selections.forEach((selection) => {
if (selection.kind !== Kind.FIELD) {
throw new Error(`unsupported selection kind ${selection.kind}`)
}
const operationType = getOperationDefinition(operation, operationName)
const fieldName = selection.alias?.value || selection.name.value
query[fieldName] = buildObjectFromInformation(
query,
rootName,
getFieldInformation(operationType.type),
defaults?.[rootName],
{
document,
variables,
cached: true,
},
)
})
}

return query
}
58 changes: 45 additions & 13 deletions app/frontend/tests/graphql/builders/mocks.ts
Expand Up @@ -9,6 +9,8 @@ import {
type FieldNode,
type OperationDefinitionNode,
type TypeNode,
type SelectionNode,
type FragmentDefinitionNode,
} from 'graphql'
import { waitForNextTick } from '#tests/support/utils.ts'
import {
Expand Down Expand Up @@ -75,6 +77,7 @@ const requestToKey = (query: DocumentNode) => {

const stripQueryData = (
definition: DefinitionNode | FieldNode,
fragments: FragmentDefinitionNode[],
resultData: any,
newData: any = {},
// eslint-disable-next-line sonarjs/cognitive-complexity
Expand All @@ -92,8 +95,28 @@ const stripQueryData = (
}

const name = definition.name!.value
definition.selectionSet?.selections.forEach((node) => {
if (node.kind === Kind.INLINE_FRAGMENT) return
const processNode = (node: SelectionNode) => {
if (node.kind === Kind.INLINE_FRAGMENT) {
const condition = node.typeCondition
if (!condition || condition.kind !== Kind.NAMED_TYPE) {
throw new Error('Unknown type condition!')
}
const typename = condition.name.value
if (resultData.__typename === typename) {
node.selectionSet.selections.forEach(processNode)
}
return
}
if (node.kind === Kind.FRAGMENT_SPREAD) {
const fragment = fragments.find(
(fragment) => fragment.name.value === node.name.value,
)
if (fragment) {
fragment.selectionSet.selections.forEach(processNode)
}
return
}

const fieldName =
'alias' in node && node.alias ? node.alias?.value : node.name!.value
if (!fieldName) {
Expand All @@ -103,15 +126,21 @@ const stripQueryData = (
if ('selectionSet' in node && node.selectionSet) {
if (Array.isArray(resultValue)) {
newData[fieldName] = resultValue.map((item) =>
stripQueryData(node, item, newData[name]),
stripQueryData(node, fragments, item, newData[name]),
)
} else {
newData[fieldName] = stripQueryData(node, resultValue, newData[name])
newData[fieldName] = stripQueryData(
node,
fragments,
resultValue,
newData[name],
)
}
} else {
newData[fieldName] = resultValue
}
})
}
definition.selectionSet?.selections.forEach(processNode)

return newData
}
Expand Down Expand Up @@ -250,14 +279,17 @@ class MockLink extends ApolloLink {
if (definition.kind !== Kind.OPERATION_DEFINITION) {
return null
}
const fragments = query.definitions.filter(
(def) => def.kind === Kind.FRAGMENT_DEFINITION,
) as FragmentDefinitionNode[]
const queryKey = requestToKey(query)
return new Observable((observer) => {
const { operation } = definition
if (operation === OperationTypeNode.SUBSCRIPTION) {
const handler: TestSubscriptionHandler = {
async trigger(defaults) {
const resultValue = mockOperation(query, variables, defaults)
const data = stripQueryData(definition, resultValue)
const data = stripQueryData(definition, fragments, resultValue)
observer.next({ data })
await waitForNextTick(true)
return resultValue
Expand All @@ -273,19 +305,19 @@ class MockLink extends ApolloLink {
mockSubscriptionHanlders.set(queryKey, handler)
return noop
}

try {
const defaults = getQueryDefaults(queryKey, definition, variables)
const returnResult = mockOperation(query, variables, defaults)
let result = { data: returnResult }
const result = { data: returnResult }
mockResults.set(queryKey, result)
const calls = mockCalls.get(queryKey) || []
calls.push({ document: query, result: returnResult, variables })
calls.push({ document: query, result: result.data, variables })
mockCalls.set(queryKey, calls)
if (operation === OperationTypeNode.MUTATION) {
result = { data: stripQueryData(definition, result.data) }
}
observer.next(cloneDeep(result))
observer.next(
cloneDeep({
data: stripQueryData(definition, fragments, result.data),
}),
)
observer.complete()
} catch (e) {
console.error(e)
Expand Down
4 changes: 4 additions & 0 deletions app/frontend/tests/vitest.setup.ts
Expand Up @@ -7,8 +7,12 @@ import * as matchers from 'vitest-axe/matchers'
import { expect } from 'vitest'
import 'vitest-axe/extend-expect'
import { ServiceWorkerHelper } from '#shared/utils/testSw.ts'
import { loadErrorMessages, loadDevMessages } from '@apollo/client/dev'
import * as assertions from './support/assertions/index.ts'

loadDevMessages()
loadErrorMessages()

vi.hoisted(() => {
globalThis.__ = (source) => {
return source
Expand Down

0 comments on commit 74d5303

Please sign in to comment.