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
37 changes: 12 additions & 25 deletions .eslintrc.js
Original file line number Diff line number Diff line change
@@ -1,32 +1,19 @@
/* eslint-env node */

module.exports = {
env: {
browser: false,
commonjs: true,
es2021: true,
node: true
},
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'prettier'
],
parserOptions: {
ecmaVersion: 'latest'
},
plugins: ['@typescript-eslint'],
rules: {
'@typescript-eslint/no-non-null-assertion': 0,
'@typescript-eslint/ban-types': 0,
'@typescript-eslint/explicit-module-boundary-types': ['off'],
'@typescript-eslint/no-explicit-any': ['off']
},
overrides: [
{
files: ['*.js'],
files: ['*.ts'],
parser: '@typescript-eslint/parser',
parserOptions: {
ecmaVersion: 'latest',
project: `${__dirname}/tsconfig.json`
},
rules: {
'@typescript-eslint/no-var-requires': 'off'
'max-lines-per-function': ['error', { max: 660, skipBlankLines: true }],
'max-depth': ['error', 6],
complexity: ['error', 20]
}
}
],
ignorePatterns: ['**/build/*']
]
};
26 changes: 26 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
name: CI

on:
push:
branches: [main]
pull_request:
branches: [main]

jobs:
build:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'yarn'

- name: Install dependencies
run: yarn install --frozen-lockfile

- name: Build
run: yarn build
22 changes: 15 additions & 7 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@hyperproof/integration-sdk",
"version": "1.2.0",
"version": "6.0.0-beta",
"description": "Hyperproof Integration SDK",
"license": "MIT",
"repository": {
Expand All @@ -11,20 +11,22 @@
"types": "lib/index.d.ts",
"scripts": {
"build": "tsc && copyfiles -u 1 \"src/**/*.d.ts\" lib",
"lint": "./node_modules/eslint/bin/eslint.js src/**/*.ts"
"lint": "eslint 'src/**/*.{js,ts}' --max-warnings 0",
"test": "jest --config=../../packages/jest-config/lib/jest.config.js"
},
"engines": {
"node": "^16.19.1 || ^18.17.1"
"node": "^22.0.0"
},
"dependencies": {
"@hyperproof/hypersync-models": "6.0.0-beta",
"@js-joda/core": "3.2.0",
"@pollyjs/adapter-node-http": "6.0.6",
"@pollyjs/core": "6.0.6",
"@pollyjs/persister-fs": "6.0.6",
"abort-controller": "3.0.0",
"body-parser": "1.20.3",
"express": "4.21.0",
"form-data": "3.0.0",
"express": "4.21.2",
"form-data": "3.0.4",
"html-entities": "2.5.2",
"http-errors": "2.0.0",
"http-status-codes": "2.3.0",
Expand All @@ -34,18 +36,24 @@
"node-fetch": "2.7.0",
"query-string": "7.1.3",
"superagent": "10.1.0",
"uuid": "10.0.0",
"xss": "1.0.15"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/node-fetch": "^2.6.11",
"@types/jest": "^29.5.4",
"@types/node-fetch": "^2.6.13",
"@types/superagent": "^8.1.9",
"@types/uuid": "^8.3.1",
"@typescript-eslint/eslint-plugin": "8.7.0",
"@typescript-eslint/parser": "8.7.0",
"copyfiles": "^2.4.1",
"eslint": "8.57.1",
"eslint-config-prettier": "^8.5.0",
"eslint": "8.57.1",
"jest-junit": "^12.2.0",
"jest": "^29.6.4",
"prettier": "^2.6.1",
"ts-jest": "^29.1.1",
"ts-node": "8.0.2",
"typescript": "5.5.4"
},
Expand Down
229 changes: 229 additions & 0 deletions src/ApiClient.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
// Import node-fetch module to spy on it
import * as nodeFetch from 'node-fetch';
import { ApiClient } from './ApiClient';
import { HttpMethod } from './models';

/* eslint-disable max-lines-per-function */
import { StatusCodes } from 'http-status-codes';
// Import after mock
import { Response } from 'node-fetch';

jest.mock('./hyperproof-api/Logger');

// Mock only the default export (fetch function)
const mockFetch = jest.spyOn(nodeFetch, 'default') as jest.MockedFunction<
typeof nodeFetch.default
>;

class TestApiClient extends ApiClient {
// Expose protected methods for testing
public async testParseResponseBodyJson(response: Response, url: string) {
return this.parseResponseBodyJson(response, url);
}

public async testGetStatusCodeFromErrorMessage(error?: any) {
return this.getStatusCodeFromErrorMessage(error);
}

public async testHandleNetworkError(err: any) {
return this.handleNetworkError(err);
}

public async testHandleFailedResponse(response: Response, apiUrl: string) {
return this.handleFailedResponse(response, apiUrl);
}

public async testBuildApiUrlAndFetch(params: any) {
return (this as any).buildApiUrlAndFetch(params);
}
}

describe('ApiClient', () => {
let client: TestApiClient;

beforeEach(() => {
client = new TestApiClient({});
});

describe('parseResponseBodyJson', () => {
it('should return JSON when response has valid JSON content', async () => {
const response = new Response(JSON.stringify({ key: 'value' }), {
status: StatusCodes.OK
});

const result = await client.testParseResponseBodyJson(
response,
'http://example.com'
);
expect(result).toEqual({ key: 'value' });
});

it('should return undefined when response is NO_CONTENT', async () => {
const response = new Response(undefined, {
status: StatusCodes.NO_CONTENT
});

const result = await client.testParseResponseBodyJson(
response,
'http://example.com'
);
expect(result).toBeUndefined();
});

it('should return undefined when response body is empty', async () => {
const response = new Response('', {
status: StatusCodes.OK
});

const result = await client.testParseResponseBodyJson(
response,
'http://example.com'
);
expect(result).toBeUndefined();
});

it('should throw error when response content is not valid JSON', async () => {
const response = new Response('Not JSON content', {
status: StatusCodes.OK
});

await expect(
client.testParseResponseBodyJson(response, 'http://example.com')
).rejects.toMatchObject({
status: StatusCodes.INTERNAL_SERVER_ERROR,
message: 'Failed to convert response body to JSON'
});
});
});

describe('getStatusCodeFromErrorMessage', () => {
it('should return INTERNAL_SERVER_ERROR when error is undefined', async () => {
const result = await client.testGetStatusCodeFromErrorMessage(undefined);
expect(result).toBe(StatusCodes.INTERNAL_SERVER_ERROR);
});

it('should return INTERNAL_SERVER_ERROR when error has no code or message', async () => {
const result = await client.testGetStatusCodeFromErrorMessage({});
expect(result).toBe(StatusCodes.INTERNAL_SERVER_ERROR);
});

it('should map ENOTFOUND error to BAD_GATEWAY', async () => {
const error = {
code: 'ENOTFOUND',
message: 'request to https://example.com failed, getaddrinfo ENOTFOUND'
};

const result = await client.testGetStatusCodeFromErrorMessage(error);
expect(result).toBe(StatusCodes.BAD_GATEWAY);
});

it('should return INTERNAL_SERVER_ERROR when no pattern matches', async () => {
const error = {
code: 'UNKNOWN_ERROR',
message: 'Some unknown error occurred'
};

const result = await client.testGetStatusCodeFromErrorMessage(error);
expect(result).toBe(StatusCodes.INTERNAL_SERVER_ERROR);
});
});

describe('handleNetworkError', () => {
it('should preserve status code when error has valid status', async () => {
const error = {
status: StatusCodes.SERVICE_UNAVAILABLE,
message: 'Service unavailable'
};

const result = await client.testHandleNetworkError(error);
expect(result.status).toBe(StatusCodes.SERVICE_UNAVAILABLE);
expect(result.message).toBe('Service unavailable');
});

it('should map ENOTFOUND to BAD_GATEWAY', async () => {
const error = {
code: 'ENOTFOUND',
message:
'request to https://api.example.com failed, getaddrinfo ENOTFOUND'
};

const result = await client.testHandleNetworkError(error);
expect(result.status).toBe(StatusCodes.BAD_GATEWAY);
});
});

describe('handleFailedResponse', () => {
it('should throw HttpError with response details', async () => {
const response = new Response('Bad request error', {
status: StatusCodes.BAD_REQUEST
});

await expect(
client.testHandleFailedResponse(response, 'http://example.com/api')
).rejects.toMatchObject({
status: StatusCodes.BAD_REQUEST
});
});
});

describe('setBaseUrl', () => {
it('should update base URL', () => {
client.setBaseUrl('https://api.newdomain.com');
// No error should be thrown
expect(client).toBeDefined();
});
});

describe('buildApiUrlAndFetch', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('should call handleNetworkError when fetch throws an error', async () => {
const networkError = new Error('Network failure');
mockFetch.mockRejectedValue(networkError);

const handleNetworkErrorSpy = jest.spyOn(
client as any,
'handleNetworkError'
);

await expect(
client.testBuildApiUrlAndFetch({
url: 'http://example.com/api',
method: HttpMethod.GET
})
).rejects.toMatchObject({
status: StatusCodes.INTERNAL_SERVER_ERROR
});

expect(handleNetworkErrorSpy).toHaveBeenCalledWith(networkError);
});

it('should call handleFailedResponse when response is not ok', async () => {
const failedResponse = new Response('Bad request', {
status: StatusCodes.BAD_REQUEST
});
mockFetch.mockResolvedValue(failedResponse);

const handleFailedResponseSpy = jest.spyOn(
client as any,
'handleFailedResponse'
);

await expect(
client.testBuildApiUrlAndFetch({
url: 'http://example.com/api',
method: HttpMethod.GET
})
).rejects.toMatchObject({
status: StatusCodes.BAD_REQUEST
});

expect(handleFailedResponseSpy).toHaveBeenCalledWith(
failedResponse,
'http://example.com/api'
);
});
});
});
Loading