Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add unit tests for api-requests modules #44

Merged
merged 1 commit into from
Sep 27, 2023
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
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();
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tip: If you want to know what is actually logged, you can do:

(logger as any).debug.mockImplementation(console.log)

which will forward the logs to console log. I used that a bit.

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']));
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The example file is uses pre/post processing to only make a single API call and if you log the full aggregateApiCall you also log parameters (of a single beacon) which is confusing, so I only log endpointName and oisTitle.

const processedPayload = await preProcessApiSpecifications(payload);
getLogger().debug('Performing API call', { aggregateApiCall: payload.aggregatedApiCall });
getLogger().debug('Performing API call', { processedPayload: processedPayload });
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was a bug before - we should have logged the processed payloed.

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';
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This broke the tests, since the getInitialState function essentially returned invalid state (wallet was missing).

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