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
86 changes: 86 additions & 0 deletions services/libs/connectors/src/connectors/github/gql.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

import type { Logger } from '@crowd/logging'

import type { ConnectorHttp } from '../../http/client'
import { ProviderContractError } from '../../http/errors'
import { githubGraphql } from './gql'

const log = {
warn: vi.fn(),
error: vi.fn(),
info: vi.fn(),
debug: vi.fn(),
} as unknown as Logger

function httpReturning(responses: unknown[]): ConnectorHttp {
let call = 0
return {
request: vi.fn(async () => responses[Math.min(call++, responses.length - 1)]),
requestCount: () => call,
} as unknown as ConnectorHttp
}

const notFound = {
data: { repository: null },
errors: [
{ type: 'NOT_FOUND', message: "Could not resolve to a Repository with the name 'o/r'." },
],
}
const resolved = { data: { repository: { issue: { comments: { nodes: [] } } } } }

describe('githubGraphql', () => {
beforeEach(() => {
vi.useFakeTimers()
})

afterEach(() => {
vi.useRealTimers()
vi.clearAllMocks()
})

it('retries when every root field is null alongside an error and returns the next good response', async () => {
const http = httpReturning([notFound, resolved])

const pending = githubGraphql(http, 'query', {}, log)
await vi.runAllTimersAsync()

await expect(pending).resolves.toEqual(resolved.data)
expect(http.request).toHaveBeenCalledTimes(2)
})

it('throws a provider contract error once the null-root retries are exhausted', async () => {
const http = httpReturning([notFound])

const pending = githubGraphql(http, 'query', {}, log)
const outcome = pending.catch((err) => err)
await vi.runAllTimersAsync()

await expect(outcome).resolves.toBeInstanceOf(ProviderContractError)
expect(http.request).toHaveBeenCalledTimes(5)
})

it('returns partial data when at least one root field resolved', async () => {
const partial = {
data: { nodes: [null, { id: 'x' }] },
errors: [{ type: 'NOT_FOUND', message: 'Could not resolve to a node' }],
}
const http = httpReturning([partial])

await expect(githubGraphql(http, 'query', {}, log)).resolves.toEqual(partial.data)
expect(http.request).toHaveBeenCalledTimes(1)
})

it('fails fast on a forbidden error without retrying', async () => {
const forbidden = {
data: { repository: null },
errors: [{ type: 'FORBIDDEN', message: 'Resource not accessible by integration' }],
}
const http = httpReturning([forbidden])

await expect(githubGraphql(http, 'query', {}, log)).rejects.toBeInstanceOf(
ProviderContractError,
)
expect(http.request).toHaveBeenCalledTimes(1)
})
})
10 changes: 9 additions & 1 deletion services/libs/connectors/src/connectors/github/gql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@ interface GraphqlEnvelope<T> {
const NO_DATA_MAX_ATTEMPTS = 5
const NO_DATA_BACKOFF_MS = 2000

function hasResolvedRootField(data: unknown): boolean {
return (
typeof data === 'object' &&
data !== null &&
Object.values(data).some((value) => value !== null && value !== undefined)
)
}

export async function githubGraphql<T>(
http: ConnectorHttp,
query: string,
Expand All @@ -39,7 +47,7 @@ export async function githubGraphql<T>(
log.error({ errors: body.errors }, `github graphql errors: ${details}`)
}
}
if (body.data) {
if (body.data && (!body.errors?.length || hasResolvedRootField(body.data))) {
return body.data
}
if (isForbidden || attempt >= NO_DATA_MAX_ATTEMPTS) {
Expand Down
Loading