Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
2 changes: 1 addition & 1 deletion packages/airnode-feed/config/airnode-feed.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@
],
"ois": [
{
"oisFormat": "2.3.0",
"oisFormat": "3.0.0",
"title": "Nodary",
"version": "0.2.0",
"apiSpecifications": {
Expand Down
4 changes: 2 additions & 2 deletions packages/airnode-feed/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,14 @@
"@api3/airnode-node": "^0.15.0",
"@api3/airnode-validator": "^0.15.0",
"@api3/commons": "^0.13.4",
"@api3/ois": "^2.3.2",
"@api3/ois": "^3.0.0",
"@api3/promise-utils": "^0.4.0",
"axios": "^1.10.0",
"dotenv": "^17.2.0",
"ethers": "^5.8.0",
"express": "^5.1.0",
"lodash": "^4.17.21",
"zod": "^3.25.76"
"zod": "^4.0.5"
},
"devDependencies": {
"@types/express": "^5.0.3",
Expand Down
10 changes: 5 additions & 5 deletions packages/airnode-feed/src/api-requests/data-provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ describe(makeTemplateRequests.name, () => {

jest.useFakeTimers().setSystemTime(new Date('2023-01-20')); // 1674172800

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

expect(response).toStrictEqual(nodaryTemplateResponses);
expect(adapterModule.buildAndExecuteRequest).toHaveBeenCalledTimes(1);
Expand All @@ -36,7 +36,7 @@ describe(makeTemplateRequests.name, () => {
jest.spyOn(logger, 'warn');
jest.spyOn(adapterModule, 'buildAndExecuteRequest').mockRejectedValue(nodaryTemplateRequestError);

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

expect(logger.warn).toHaveBeenCalledTimes(1);
expect(logger.warn).toHaveBeenCalledWith('Failed to make API call.', {
Expand Down Expand Up @@ -89,7 +89,7 @@ describe(makeTemplateRequests.name, () => {
jest.spyOn(stateModule, 'getState').mockReturnValue(state);
jest.mocked(axios).mockRejectedValue(new Error('network error'));

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

expect(axios).toHaveBeenCalledTimes(1);
expect(axios).toHaveBeenCalledWith({
Expand Down Expand Up @@ -141,7 +141,7 @@ describe(makeTemplateRequests.name, () => {

const buildAndExecuteRequestSpy = jest.spyOn(adapterModule, 'buildAndExecuteRequest');

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

expect(axios).toHaveBeenCalledTimes(0);
expect(buildAndExecuteRequestSpy).not.toHaveBeenCalled();
Expand Down Expand Up @@ -181,7 +181,7 @@ describe(makeTemplateRequests.name, () => {

const buildAndExecuteRequestSpy = jest.spyOn(adapterModule, 'buildAndExecuteRequest');

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

expect(axios).toHaveBeenCalledTimes(0);
expect(buildAndExecuteRequestSpy).not.toHaveBeenCalled();
Expand Down
23 changes: 1 addition & 22 deletions packages/airnode-feed/src/api-requests/signed-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,28 +35,7 @@ describe(pushSignedData.name, () => {

expect(response).toStrictEqual([{ success: false }]);
expect(logger.warn).toHaveBeenCalledWith('Failed to parse response from the signed API.', {
errors: new ZodError([
{
code: 'invalid_type',
expected: 'number',
received: 'undefined',
path: ['count'],
message: 'Required',
},
{
code: 'invalid_type',
expected: 'number',
received: 'undefined',
path: ['skipped'],
message: 'Required',
},
{
code: 'unrecognized_keys',
keys: ['strange'],
path: [],
message: "Unrecognized key(s) in object: 'strange'",
},
]),
errors: expect.any(ZodError),
Copy link
Contributor

Choose a reason for hiding this comment

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

I added a new test in schema.test.ts for this.

});
});

Expand Down
4 changes: 2 additions & 2 deletions packages/airnode-feed/src/heartbeat/heartbeat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,10 @@ describe(logHeartbeat.name, () => {
// NOTE: This tests will fail each time the example config changes (except for the nodeVersion). This should be
// quite rare and the test verifies that the heartbeat sends correct data.
const expectedHeartbeat = {
configHash: '0xce9f58b6927572c9cc6e3bb532e6bd54899c8b3a2c15da18ee556599f1b3249f',
configHash: '0x5c1e8b1f41bf76f010c36a89b73ed4ee5afbb214d7118ac3cdb11e1ad5901982',
airnode: '0xbF3137b0a7574563a23a8fC8badC6537F98197CC',
signature:
'0x29e4b58fe8ebac9a7c26de469978707b5d291eac06b76b4e8cca501011360de879d2e3dc498fd84e19d4c4a57b809e0e32f6855c2a834a38167dac5f891f159c1c',
'0x21f9249611d0f320f96ee057c7ba36aad4c5acaf8c10e8d50219aba3e2b714d55c1fd6b4207c5ed78cdeb925b31684cf4d00a2445e9c6665d952686b3db9c0e81c',
stage: 'test',
nodeVersion: '0.7.0',
currentTimestamp: '1674172803',
Expand Down
3 changes: 2 additions & 1 deletion packages/airnode-feed/src/validation/env.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { join } from 'node:path';

import dotenv from 'dotenv';
import { z } from 'zod';

import { type EnvConfig, envConfigSchema } from './schema';

Expand All @@ -13,7 +14,7 @@ export const loadEnv = () => {

const parseResult = envConfigSchema.safeParse(process.env);
if (!parseResult.success) {
throw new Error(`Invalid environment variables:\n, ${JSON.stringify(parseResult.error.format())}`);
throw new Error(`Invalid environment variables: ${z.prettifyError(parseResult.error)}`);
}

env = parseResult.data;
Expand Down
137 changes: 78 additions & 59 deletions packages/airnode-feed/src/validation/schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,25 +3,23 @@ import { join } from 'node:path';

import { interpolateSecretsIntoConfig } from '@api3/commons';
import dotenv from 'dotenv';
import { ZodError } from 'zod';

import { config } from '../../test/fixtures';

import { type Config, configSchema, signedApisSchema } from './schema';
import { type Config, configSchema, signedApiResponseSchema, signedApisSchema } from './schema';

test('validates example config', async () => {
const exampleConfig = JSON.parse(readFileSync(join(__dirname, '../../config/airnode-feed.example.json'), 'utf8'));

// The mnemonic is not interpolated (and thus invalid).
await expect(configSchema.parseAsync(exampleConfig)).rejects.toStrictEqual(
new ZodError([
{
code: 'custom',
message: 'Invalid mnemonic',
path: ['nodeSettings', 'airnodeWalletMnemonic'],
},
])
);
const result = await configSchema.safeParseAsync(exampleConfig);
expect(result.error?.issues).toStrictEqual([
{
code: 'custom',
message: 'Invalid mnemonic',
path: ['nodeSettings', 'airnodeWalletMnemonic'],
},
]);

const exampleSecrets = dotenv.parse(readFileSync(join(__dirname, '../../config/secrets.example.env'), 'utf8'));
await expect(
Expand All @@ -38,32 +36,29 @@ test('ensures nodeVersion matches Airnode feed version', async () => {
},
};

await expect(configSchema.parseAsync(invalidConfig)).rejects.toStrictEqual(
new ZodError([
{
code: 'custom',
message: 'Invalid node version',
path: ['nodeSettings', 'nodeVersion'],
},
])
);
const result = await configSchema.safeParseAsync(invalidConfig);
expect(result.error?.issues).toStrictEqual([
{
code: 'custom',
message: 'Invalid node version',
path: ['nodeSettings', 'nodeVersion'],
},
]);
});

test('ensures signed API names are unique', () => {
expect(() =>
signedApisSchema.parse([
expect(
signedApisSchema.safeParse([
{ name: 'foo', url: 'https://example.com', authToken: null },
{ name: 'foo', url: 'https://example.com', authToken: null },
])
).toThrow(
new ZodError([
{
code: 'custom',
message: 'Signed API names must be unique',
path: ['signedApis'],
},
])
);
]).error?.issues
).toStrictEqual([
{
code: 'custom',
message: 'Signed API names must be unique',
path: ['signedApis'],
},
]);

expect(signedApisSchema.parse([{ name: 'foo', url: 'https://example.com', authToken: null }])).toStrictEqual([
{
Expand All @@ -90,15 +85,14 @@ describe('validateTriggerReferences', () => {
},
};

await expect(configSchema.parseAsync(invalidConfig)).rejects.toStrictEqual(
new ZodError([
{
code: 'custom',
message: `Template "${notFoundTemplateId}" is not defined in the config.templates object`,
path: ['triggers', 'signedApiUpdates', 0, 'templateIds', 0],
},
])
);
const result = await configSchema.safeParseAsync(invalidConfig);
expect(result.error?.issues).toStrictEqual([
{
code: 'custom',
message: `Template "${notFoundTemplateId}" is not defined in the config.templates object`,
path: ['triggers', 'signedApiUpdates', 0, 'templateIds', 0],
},
]);
});

it('validates all templates reference the same endpoint', async () => {
Expand Down Expand Up @@ -150,15 +144,14 @@ describe('validateTriggerReferences', () => {
],
};

await expect(configSchema.parseAsync(invalidConfig)).rejects.toStrictEqual(
new ZodError([
{
code: 'custom',
message: 'The endpoint utilized by each template must be same',
path: ['triggers', 'signedApiUpdates', 0, 'templateIds'],
},
])
);
const result = await configSchema.safeParseAsync(invalidConfig);
expect(result.error?.issues).toStrictEqual([
{
code: 'custom',
message: 'The endpoint utilized by each template must be same',
path: ['triggers', 'signedApiUpdates', 0, 'templateIds'],
},
]);
});

it('validates operation effects are identical', async () => {
Expand All @@ -170,15 +163,14 @@ describe('validateTriggerReferences', () => {
],
};

await expect(configSchema.parseAsync(invalidConfig)).rejects.toStrictEqual(
new ZodError([
{
code: 'custom',
message: 'The endpoint utilized by each template must have the same operation effect',
path: ['triggers', 'signedApiUpdates', 0, 'templateIds'],
},
])
);
const result = await configSchema.safeParseAsync(invalidConfig);
expect(result.error?.issues).toStrictEqual([
{
code: 'custom',
message: 'The endpoint utilized by each template must have the same operation effect',
path: ['triggers', 'signedApiUpdates', 0, 'templateIds'],
},
]);
});

it('skips operation effect validation for skip API call endpoints', async () => {
Expand All @@ -204,3 +196,30 @@ describe('validateTriggerReferences', () => {
await expect(configSchema.parseAsync(skipApiCallConfig)).resolves.toBeDefined();
});
});

describe('signedApiResponseSchema', () => {
it('validates that the expected keys are present', () => {
expect(signedApiResponseSchema.safeParse({ strange: 'some-invalid-response' }).error?.issues).toStrictEqual([
{
code: 'invalid_type',
expected: 'number',
path: ['count'],
message: 'Invalid input: expected number, received undefined',
},
{
code: 'invalid_type',
expected: 'number',
path: ['skipped'],
message: 'Invalid input: expected number, received undefined',
},
{
code: 'unrecognized_keys',
keys: ['strange'],
path: [],
message: 'Unrecognized key: "strange"',
},
]);

expect(() => signedApiResponseSchema.parse({ count: 12, skipped: 3 })).not.toThrow();
});
});
Loading