renovate/lib/util/github/graphql/datasource-fetcher.spec.ts

442 lines
12 KiB
TypeScript

import AggregateError from 'aggregate-error';
import * as httpMock from '../../../../test/http-mock';
import { mocked, partial } from '../../../../test/util';
import * as _packageCache from '../../../util/cache/package';
import type { GithubGraphqlResponse } from '../../http/github';
import { GithubHttp } from '../../http/github';
import { range } from '../../range';
import {
GithubGraphqlDatasourceFetcher as Datasource,
GithubGraphqlDatasourceFetcher,
} from './datasource-fetcher';
import type {
GithubDatasourceItem,
GithubGraphqlDatasourceAdapter,
GithubGraphqlRepoResponse,
} from './types';
jest.mock('../../../util/cache/package');
const packageCache = mocked(_packageCache);
interface TestAdapterInput {
version: string;
releaseTimestamp: string;
foo: string;
}
interface TestAdapterOutput extends GithubDatasourceItem {
bar: string;
}
const adapter: GithubGraphqlDatasourceAdapter<
TestAdapterInput,
TestAdapterOutput
> = {
key: '_test-namespace',
query: `
items {
pageInfo {
hasNextPage
endCursor
}
nodes {
version
releaseTimestamp
foo
}
}
`,
transform: ({
version,
releaseTimestamp,
foo,
}: TestAdapterInput): TestAdapterOutput | null =>
version && releaseTimestamp && foo
? {
version,
releaseTimestamp,
bar: foo,
}
: null,
};
function resp(
isRepoPrivate: boolean | undefined,
nodes: TestAdapterInput[],
cursor: string | undefined = undefined,
): GithubGraphqlResponse<GithubGraphqlRepoResponse<TestAdapterInput>> {
const data: GithubGraphqlRepoResponse<TestAdapterInput> = {
repository: {
isRepoPrivate,
payload: { nodes },
},
};
if (cursor) {
data.repository.payload.pageInfo = {
endCursor: cursor,
hasNextPage: true,
};
}
return { data };
}
function err(
...messages: string[]
): GithubGraphqlResponse<GithubGraphqlRepoResponse<TestAdapterInput>> {
return {
errors: messages.map((message) => ({ message })),
};
}
async function catchError<T>(cb: () => Promise<T>): Promise<Error> {
try {
await cb();
throw Error('Callback was expected to throw');
} catch (err) {
return err;
}
}
describe('util/github/graphql/datasource-fetcher', () => {
describe('query', () => {
let http = new GithubHttp();
const v1 = '1.0.0';
const t1 = '01-01-2021';
const v2 = '2.0.0';
const t2 = '01-01-2022';
const v3 = '3.0.0';
const t3 = '01-01-2023';
beforeEach(() => {
http = new GithubHttp();
});
it('can perform query and receive result', async () => {
httpMock
.scope('https://api.github.com/')
.post('/graphql')
.reply(
200,
resp(false, [{ version: v1, releaseTimestamp: t1, foo: '1' }]),
);
const res = await Datasource.query(
{ packageName: 'foo/bar' },
http,
adapter,
);
expect(res).toEqual([
{ bar: '1', releaseTimestamp: '01-01-2021', version: '1.0.0' },
]);
});
it('performs query when persistence flag is set and cache is expired', async () => {
packageCache.get.mockResolvedValueOnce(true);
httpMock
.scope('https://api.github.com/')
.post('/graphql')
.reply(
200,
resp(false, [{ version: v1, releaseTimestamp: t1, foo: '1' }]),
);
const res = await Datasource.query(
{ packageName: 'foo/bar' },
http,
adapter,
);
expect(res).toEqual([
{ bar: '1', releaseTimestamp: '01-01-2021', version: '1.0.0' },
]);
});
it('throws on unknown errors', async () => {
httpMock
.scope('https://api.github.com/')
.post('/graphql')
.replyWithError('unknown error');
await expect(() =>
Datasource.query({ packageName: 'foo/bar' }, http, adapter),
).rejects.toThrow('unknown error');
});
it('throws single GraphQL error wrapped into Error', async () => {
httpMock
.scope('https://api.github.com/')
.post('/graphql')
.reply(200, err('single error'));
const res = await catchError(() =>
Datasource.query({ packageName: 'foo/bar' }, http, adapter),
);
expect(res.message).toBe('single error');
expect(res.constructor.name).toBe('Error');
});
it('throws multiple GraphQL errors wrapped into AggregatedError', async () => {
httpMock
.scope('https://api.github.com/')
.post('/graphql')
.reply(200, err('first error', 'second error'));
const res = (await catchError(() =>
Datasource.query({ packageName: 'foo/bar' }, http, adapter),
)) as AggregateError;
expect(res).toBeInstanceOf(AggregateError);
expect([...res]).toEqual([
new Error('first error'),
new Error('second error'),
]);
});
it('throws when neither of data or errors were provided', async () => {
httpMock.scope('https://api.github.com/').post('/graphql').reply(200, {});
await expect(() =>
Datasource.query({ packageName: 'foo/bar' }, http, adapter),
).rejects.toThrow('GitHub GraphQL datasource: failed to obtain data');
});
it('throws when repository field is absent', async () => {
httpMock
.scope('https://api.github.com/')
.post('/graphql')
.reply(200, { data: {} });
await expect(() =>
Datasource.query({ packageName: 'foo/bar' }, http, adapter),
).rejects.toThrow(
'GitHub GraphQL datasource: failed to obtain repository data',
);
});
it('throws when payload field is absent', async () => {
httpMock
.scope('https://api.github.com/')
.post('/graphql')
.reply(200, { data: { repository: {} } });
await expect(() =>
Datasource.query({ packageName: 'foo/bar' }, http, adapter),
).rejects.toThrow(
'GitHub GraphQL datasource: failed to obtain repository payload data',
);
});
it('receives, transforms, and return data', async () => {
httpMock
.scope('https://api.github.com/')
.post('/graphql')
.reply(
200,
resp(false, [
{ version: v3, releaseTimestamp: t3, foo: '3' },
{ version: v2, releaseTimestamp: t2, foo: '2' },
partial<TestAdapterInput>(),
{ version: v1, releaseTimestamp: t1, foo: '1' },
]),
);
const res = await Datasource.query(
{ packageName: 'foo/bar' },
http,
adapter,
);
expect(res).toEqual([
{ version: v3, releaseTimestamp: t3, bar: '3' },
{ version: v2, releaseTimestamp: t2, bar: '2' },
{ version: v1, releaseTimestamp: t1, bar: '1' },
]);
});
it('handles paginated data', async () => {
const page1 = resp(
false,
[{ version: v3, releaseTimestamp: t3, foo: '3' }],
'aaa',
);
const page2 = resp(
false,
[{ version: v2, releaseTimestamp: t2, foo: '2' }],
'bbb',
);
const page3 = resp(false, [
{ version: v1, releaseTimestamp: t1, foo: '1' },
]);
httpMock
.scope('https://api.github.com/')
.post('/graphql')
.reply(200, page1)
.post('/graphql')
.reply(200, page2)
.post('/graphql')
.reply(200, page3);
const res = await Datasource.query(
{ packageName: 'foo/bar' },
http,
adapter,
);
expect(res).toEqual([
{ version: v3, releaseTimestamp: t3, bar: '3' },
{ version: v2, releaseTimestamp: t2, bar: '2' },
{ version: v1, releaseTimestamp: t1, bar: '1' },
]);
});
/**
* See: #16343
*/
describe('Page shrinking', () => {
function generateItems(count: number): TestAdapterInput[] {
const indices = [...range(1, count)].map((x) => `${x}`);
return indices.map((idx) => ({
version: idx,
releaseTimestamp: idx,
foo: idx,
}));
}
function partitionBy<T>(input: T[], count: number): T[][] {
const output: T[][] = [];
for (let idx = 0; idx < input.length; idx += count) {
const slice = input.slice(idx, idx + count);
output.push(slice);
}
return output;
}
function generatePages(
items: TestAdapterInput[],
perPage: number,
): GithubGraphqlResponse<GithubGraphqlRepoResponse<TestAdapterInput>>[] {
const partitions = partitionBy(items, perPage);
const pages = partitions.map((nodes, idx) =>
resp(false, nodes, `page-${idx + 2}`),
);
delete pages[pages.length - 1].data?.repository.payload.pageInfo;
return pages;
}
it('shrinks page from 100 to 50', async () => {
const items = generateItems(150);
const pages = generatePages(items, 50);
const scope = httpMock
.scope('https://api.github.com/')
.post('/graphql')
.reply(200, err('Something went wrong while executing your query.'));
pages.forEach((page) => {
scope.post('/graphql').reply(200, page);
});
const res = await Datasource.query(
{ packageName: 'foo/bar' },
http,
adapter,
);
expect(res).toHaveLength(150);
expect(res).toEqual(items.map(adapter.transform));
expect(httpMock.getTrace()).toMatchObject([
{ body: { variables: { count: 100, cursor: null } } },
{ body: { variables: { count: 50, cursor: null } } },
{ body: { variables: { count: 50, cursor: 'page-2' } } },
{ body: { variables: { count: 50, cursor: 'page-3' } } },
]);
});
it('shrinks page from 50 to 25', async () => {
const items = generateItems(100);
const pages = generatePages(items, 25);
const scope = httpMock
.scope('https://api.github.com/')
.post('/graphql')
.twice()
.reply(200, err('Something went wrong while executing your query.'));
pages.forEach((page) => {
scope.post('/graphql').reply(200, page);
});
const res = await Datasource.query(
{ packageName: 'foo/bar' },
http,
adapter,
);
expect(res).toHaveLength(100);
expect(res).toEqual(items.map(adapter.transform));
expect(httpMock.getTrace()).toMatchObject([
{ body: { variables: { count: 100, cursor: null } } },
{ body: { variables: { count: 50, cursor: null } } },
{ body: { variables: { count: 25, cursor: null } } },
{ body: { variables: { count: 25, cursor: 'page-2' } } },
{ body: { variables: { count: 25, cursor: 'page-3' } } },
{ body: { variables: { count: 25, cursor: 'page-4' } } },
]);
});
it('re-throws if shrinking did not help', async () => {
httpMock
.scope('https://api.github.com/')
.post('/graphql')
.thrice()
.reply(200, err('Something went wrong while executing your query.'));
await expect(
Datasource.query({ packageName: 'foo/bar' }, http, adapter),
).rejects.toThrow('Something went wrong while executing your query.');
expect(httpMock.getTrace()).toMatchObject([
{ body: { variables: { count: 100, cursor: null } } },
{ body: { variables: { count: 50, cursor: null } } },
{ body: { variables: { count: 25, cursor: null } } },
]);
});
});
describe('Cacheable flag', () => {
const data = [
{ version: v3, releaseTimestamp: t3, foo: '3' },
{ version: v2, releaseTimestamp: t2, foo: '2' },
{ version: v1, releaseTimestamp: t1, foo: '1' },
];
it.each`
isPrivate | isPersistent
${undefined} | ${false}
${true} | ${false}
${false} | ${true}
`(
'private=$isPrivate => isPersistent=$isPersistent',
async ({ isPrivate, isPersistent }) => {
httpMock
.scope('https://api.github.com/')
.post('/graphql')
.reply(200, resp(isPrivate, data, undefined));
const instance = new GithubGraphqlDatasourceFetcher(
{ packageName: 'foo/bar' },
http,
adapter,
);
await instance.getItems();
expect(instance).toHaveProperty('isPersistent', isPersistent);
},
);
});
});
});