Skip to content

Commit

Permalink
Add unit tests for api-requests modules (#44)
Browse files Browse the repository at this point in the history
  • Loading branch information
Siegrift authored Sep 27, 2023
1 parent 084aadf commit 9a9dbaf
Show file tree
Hide file tree
Showing 11 changed files with 426 additions and 43 deletions.
38 changes: 38 additions & 0 deletions packages/data-pusher/src/api-requests/data-provider.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { api as nodeApiModule } from '@api3/airnode-node';
import { makeTemplateRequests } from './data-provider';
import * as stateModule from '../state';
import * as loggerModule from '../logger';
import {
config,
createMockedLogger,
nodaryTemplateRequestErrorResponse,
nodaryTemplateRequestResponseData,
nodaryTemplateResponses,
} from '../../test/fixtures';

describe(makeTemplateRequests.name, () => {
it('makes a single template request for multiple beacons', async () => {
const state = stateModule.getInitialState(config);
jest.spyOn(stateModule, 'getState').mockReturnValue(state);
const logger = createMockedLogger();
jest.spyOn(loggerModule, 'getLogger').mockReturnValue(logger);
jest.spyOn(nodeApiModule, 'performApiCall').mockResolvedValue([[], nodaryTemplateRequestResponseData]);

const response = await makeTemplateRequests(config.triggers.signedApiUpdates[0]!);

expect(response).toEqual(nodaryTemplateResponses);
});

it('handles request failure', async () => {
const state = stateModule.getInitialState(config);
jest.spyOn(stateModule, 'getState').mockReturnValue(state);
const logger = createMockedLogger();
jest.spyOn(loggerModule, 'getLogger').mockReturnValue(logger);
jest.spyOn(nodeApiModule, 'performApiCall').mockRejectedValue(nodaryTemplateRequestErrorResponse);

await expect(makeTemplateRequests(config.triggers.signedApiUpdates[0]!)).rejects.toEqual({
errorMessage: 'Invalid API key',
success: false,
});
});
});
15 changes: 7 additions & 8 deletions packages/data-pusher/src/api-requests/data-provider.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,30 @@
import * as abi from '@api3/airnode-abi';
import * as node from '@api3/airnode-node';
import { isNil } from 'lodash';
import { isNil, pick } from 'lodash';
import { getState } from '../state';
import { preProcessApiSpecifications } from '../unexported-airnode-features/api-specification-processing';
import { SignedApiUpdate, TemplateId } from '../validation/schema';
import { getLogger } from '../logger';

type TemplateResponse = [TemplateId, node.HttpGatewayApiCallSuccessResponse];
type TemplateResponses = TemplateResponse[];
export type TemplateResponse = [TemplateId, node.HttpGatewayApiCallSuccessResponse];

export const callApi = async (payload: node.ApiCallPayload) => {
getLogger().debug('Preprocessing API call payload', { aggregateApiCall: payload.aggregatedApiCall });
getLogger().debug('Preprocessing API call payload', pick(payload.aggregatedApiCall, ['endpointName', 'oisTitle']));
const processedPayload = await preProcessApiSpecifications(payload);
getLogger().debug('Performing API call', { aggregateApiCall: payload.aggregatedApiCall });
getLogger().debug('Performing API call', { processedPayload: processedPayload });
return node.api.performApiCall(processedPayload);
};

export const makeTemplateRequests = async (signedApiUpdate: SignedApiUpdate): Promise<TemplateResponses> => {
export const makeTemplateRequests = async (signedApiUpdate: SignedApiUpdate): Promise<TemplateResponse[]> => {
const {
config: { beacons, endpoints, templates, ois, apiCredentials },
apiLimiters,
} = getState();
getLogger().debug('Making template requests', signedApiUpdate);
const { beaconIds } = signedApiUpdate;

// Because each beacon have same operation, just take first one as operational template
// See the function validateTriggerReferences in validation.ts
// Because each beacon has the same operation, just take first one as operational template. See validation.ts for
// details.
const operationTemplateId = beacons[beaconIds[0]!]!.templateId;
const operationTemplate = templates[operationTemplateId]!;

Expand Down
110 changes: 110 additions & 0 deletions packages/data-pusher/src/api-requests/signed-api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import axios from 'axios';
import { ZodError } from 'zod';
import { postSignedApiData, signTemplateResponses } from './signed-api';
import {
config,
createMockedLogger,
signedApiResponse,
nodarySignedTemplateResponses,
nodaryTemplateResponses,
} from '../../test/fixtures';
import * as loggerModule from '../logger';
import * as stateModule from '../state';

describe(signTemplateResponses.name, () => {
it('signs template responses', async () => {
const state = stateModule.getInitialState(config);
jest.spyOn(stateModule, 'getState').mockReturnValue(state);
const logger = createMockedLogger();
jest.spyOn(loggerModule, 'getLogger').mockReturnValue(logger);
jest.useFakeTimers().setSystemTime(new Date('2023-01-20'));

const signedTemplateResponses = await signTemplateResponses(nodaryTemplateResponses);

expect(signedTemplateResponses).toEqual(nodarySignedTemplateResponses);
});

afterEach(() => {
jest.useRealTimers();
});
});

describe(postSignedApiData.name, () => {
it('posts data to central api', async () => {
const state = stateModule.getInitialState(config);
// Assumes the template responses are for unique template IDs (which is true in the test fixtures).
state.templateValues = Object.fromEntries(
nodarySignedTemplateResponses.map(([templateId, signedData]) => {
const dataQueue = new stateModule.DelayedSignedDataQueue();
dataQueue.put(signedData);
return [templateId, dataQueue];
})
);
jest.spyOn(stateModule, 'getState').mockReturnValue(state);
const logger = createMockedLogger();
jest.spyOn(loggerModule, 'getLogger').mockReturnValue(logger);
jest.spyOn(axios, 'post').mockResolvedValue(signedApiResponse);

const response = await postSignedApiData(config.triggers.signedApiUpdates[0]!);

expect(response).toEqual({ count: 3, success: true });
});

it('handles invalid response from signed API', async () => {
const state = stateModule.getInitialState(config);
// Assumes the template responses are for unique template IDs (which is true in the test fixtures).
state.templateValues = Object.fromEntries(
nodarySignedTemplateResponses.map(([templateId, signedData]) => {
const dataQueue = new stateModule.DelayedSignedDataQueue();
dataQueue.put(signedData);
return [templateId, dataQueue];
})
);
jest.spyOn(stateModule, 'getState').mockReturnValue(state);
const logger = createMockedLogger();
jest.spyOn(loggerModule, 'getLogger').mockReturnValue(logger);
jest.spyOn(axios, 'post').mockResolvedValue({ youHaveNotThoughAboutThisDidYou: 'yes-I-did' });

const response = await postSignedApiData(config.triggers.signedApiUpdates[0]!);

expect(response).toEqual({ success: false });
expect(logger.warn).toHaveBeenCalledWith('Failed to parse response from the signed API.', {
errors: new ZodError([
{
code: 'invalid_type',
expected: 'object',
received: 'undefined',
path: [],
message: 'Required',
},
]),
signedApiName: 'localhost',
updateDelay: 5,
});
});

it('handles request failure', async () => {
const state = stateModule.getInitialState(config);
// Assumes the template responses are for unique template IDs (which is true in the test fixtures).
state.templateValues = Object.fromEntries(
nodarySignedTemplateResponses.map(([templateId, signedData]) => {
const dataQueue = new stateModule.DelayedSignedDataQueue();
dataQueue.put(signedData);
return [templateId, dataQueue];
})
);
jest.spyOn(stateModule, 'getState').mockReturnValue(state);
const logger = createMockedLogger();
jest.spyOn(loggerModule, 'getLogger').mockReturnValue(logger);
jest.spyOn(axios, 'post').mockRejectedValue('simulated-network-error');

const response = await postSignedApiData(config.triggers.signedApiUpdates[0]!);

expect(response).toEqual({ success: false });
expect(logger.warn).toHaveBeenCalledWith('Failed to make update signed API request.', {
axiosResponse: undefined,
signedApiName: 'localhost',
updateDelay: 5,
});
});
});
47 changes: 29 additions & 18 deletions packages/data-pusher/src/api-requests/signed-api.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,26 @@
import * as node from '@api3/airnode-node';
import { go } from '@api3/promise-utils';
import axios, { AxiosError } from 'axios';
import { isEmpty, isNil } from 'lodash';
import { ethers } from 'ethers';
import { TemplateResponse } from './data-provider';
import { getLogger } from '../logger';
import { getState } from '../state';
import { SignedApiNameUpdateDelayGroup } from '../update-signed-api';
import { SignedApiPayload, SignedData, TemplateId } from '../validation/schema';
import { SignedApiPayload, SignedData, TemplateId, signedApiResponseSchema } from '../validation/schema';
import { signWithTemplateId } from '../utils';

type TemplateResponse = [TemplateId, node.HttpGatewayApiCallSuccessResponse];
type TemplateResponses = TemplateResponse[];
type SignedResponse = [TemplateId, SignedData];
export type SignedResponse = [TemplateId, SignedData];

export const postSignedApiData = async (group: SignedApiNameUpdateDelayGroup) => {
const {
config: { beacons, signedApis },
templateValues,
} = getState();
const { providerName, beaconIds, updateDelay } = group;
const logContext = { providerName, updateDelay };
const { signedApiName, beaconIds, updateDelay } = group;
const logContext = { signedApiName, updateDelay };
getLogger().debug('Posting signed API data.', { group, ...logContext });

const provider = signedApis.find((a) => a.name === providerName)!;
const provider = signedApis.find((a) => a.name === signedApiName)!;

const batchPayloadOrNull = beaconIds.map((beaconId): SignedApiPayload | null => {
const { templateId, airnode } = beacons[beaconId]!;
Expand All @@ -34,10 +32,11 @@ export const postSignedApiData = async (group: SignedApiNameUpdateDelayGroup) =>
const batchPayload = batchPayloadOrNull.filter((payload): payload is SignedApiPayload => !isNil(payload));

if (isEmpty(batchPayload)) {
getLogger().debug('No batch payload found to post skipping.', logContext);
return;
getLogger().debug('No batch payload found to post. Skipping.', logContext);
return { success: true, count: 0 };
}
const goRes = await go<Promise<{ count: number }>, AxiosError>(async () => {
const goAxiosRequest = await go<Promise<unknown>, AxiosError>(async () => {
getLogger().debug('Posting batch payload.', { ...logContext, batchPayload });
const axiosResponse = await axios.post(provider.url, batchPayload, {
headers: {
'Content-Type': 'application/json',
Expand All @@ -46,19 +45,31 @@ export const postSignedApiData = async (group: SignedApiNameUpdateDelayGroup) =>

return axiosResponse.data;
});

if (!goRes.success) {
if (!goAxiosRequest.success) {
getLogger().warn(
`Failed to post payload to update signed API.`,
`Failed to make update signed API request.`,
// See: https://axios-http.com/docs/handling_errors
{ ...logContext, axiosResponse: goRes.error.response }
{ ...logContext, axiosResponse: goAxiosRequest.error.response }
);
return;
return { success: false };
}
getLogger().info(`Pushed signed data updates to the pool.`, { ...logContext, count: goRes.data.count });

getLogger().debug('Parsing response from the signed API.', { ...logContext, axiosResponse: goAxiosRequest.data });
const parsedResponse = signedApiResponseSchema.safeParse(goAxiosRequest.data);
if (!parsedResponse.success) {
getLogger().warn('Failed to parse response from the signed API.', {
...logContext,
errors: parsedResponse.error,
});
return { success: false };
}

const count = parsedResponse.data.count;
getLogger().info(`Pushed signed data updates to the signed API.`, { ...logContext, count });
return { success: true, count };
};

export const signTemplateResponses = async (templateResponses: TemplateResponses) => {
export const signTemplateResponses = async (templateResponses: TemplateResponse[]) => {
getLogger().debug('Signing template responses', { templateResponses });

const signPromises = templateResponses.map(async ([templateId, response]) => {
Expand Down
2 changes: 0 additions & 2 deletions packages/data-pusher/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,13 @@ import { loadConfig } from './validation/config';
import { initiateFetchingBeaconData } from './fetch-beacon-data';
import { initiateUpdatingSignedApi } from './update-signed-api';
import { initializeState } from './state';
import { initializeWallet } from './wallets';
import { initializeLogger } from './logger';

export async function main() {
const config = await loadConfig();
initializeLogger(config);
initializeState(config);

initializeWallet();
initiateFetchingBeaconData();
initiateUpdatingSignedApi();
}
Expand Down
3 changes: 2 additions & 1 deletion packages/data-pusher/src/state.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Bottleneck from 'bottleneck';
import { ethers } from 'ethers';
import { Config, SignedData, TemplateId } from './validation/schema';
import { DIRECT_GATEWAY_MAX_CONCURRENCY_DEFAULT, DIRECT_GATEWAY_MIN_TIME_DEFAULT_MS } from './constants';
import { deriveEndpointId, getRandomId } from './utils';
Expand Down Expand Up @@ -76,7 +77,7 @@ export const getInitialState = (config: Config) => {
config,
templateValues: buildTemplateStorages(config),
apiLimiters: buildApiLimiters(config),
walletPrivateKey: '',
walletPrivateKey: ethers.Wallet.fromMnemonic(config.walletMnemonic).privateKey,
sponsorWalletsPrivateKey: {},
};
};
Expand Down
6 changes: 3 additions & 3 deletions packages/data-pusher/src/update-signed-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { postSignedApiData } from './api-requests/signed-api';
type SignedApiUpdateDelayBeaconIdsMap = Record<string, Record<number, BeaconId[]>>;

export type SignedApiNameUpdateDelayGroup = {
providerName: string;
signedApiName: string;
beaconIds: BeaconId[];
updateDelay: number;
};
Expand Down Expand Up @@ -38,9 +38,9 @@ export const initiateUpdatingSignedApi = async () => {

const signedApiUpdateDelayGroups: SignedApiNameUpdateDelayGroup[] = Object.entries(
signedApiUpdateDelayBeaconIdsMap
).flatMap(([providerName, updateDelayBeaconIds]) =>
).flatMap(([signedApiName, updateDelayBeaconIds]) =>
Object.entries(updateDelayBeaconIds).map(([updateDelay, beaconIds]) => ({
providerName,
signedApiName,
updateDelay: parseInt(updateDelay),
beaconIds,
}))
Expand Down
2 changes: 1 addition & 1 deletion packages/data-pusher/src/validation/schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,5 @@ import { configSchema } from './schema';
it('validates example config', async () => {
const config = JSON.parse(readFileSync(join(__dirname, '../../config/pusher.example.json'), 'utf8'));

await expect(configSchema.parseAsync(config)).resolves.not.toThrow();
await expect(configSchema.parseAsync(config)).resolves.toEqual(expect.any(Object));
});
8 changes: 8 additions & 0 deletions packages/data-pusher/src/validation/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -360,3 +360,11 @@ export type RateLimitingConfig = z.infer<typeof rateLimitingSchema>;
export type ApisCredentials = z.infer<typeof apisCredentialsSchema>;

export const secretsSchema = z.record(z.string());

export const signedApiResponseSchema = z
.object({
count: z.number(),
})
.strict();

export type SignedApiResponse = z.infer<typeof signedApiResponseSchema>;
10 changes: 0 additions & 10 deletions packages/data-pusher/src/wallets.ts

This file was deleted.

Loading

0 comments on commit 9a9dbaf

Please sign in to comment.