diff --git a/.commitlintrc.js b/.commitlintrc.js new file mode 100644 index 0000000..4da806b --- /dev/null +++ b/.commitlintrc.js @@ -0,0 +1,8 @@ +module.exports = { + extends: ['@commitlint/config-conventional'], + scopes: [{ name: 'client' }, { name: 'tutorial' }], + scopeOverrides: { + fix: [{ name: 'style' }, { name: 'unit' }, { name: 'e2e' }, { name: 'integration' }], + }, + allowCustomScopes: true, +}; diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..c26529e --- /dev/null +++ b/.editorconfig @@ -0,0 +1,16 @@ +root = true + +[*] +indent_style = space +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true +max_line_length = 160 +indent_size = 4 + +[*.md] +trim_trailing_whitespace = false + +[*.{yml, yaml}] +indent_size = 2 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..3541036 --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +################################################## +# These variables are used for integration tests # +################################################## +SQUARE_BASE_URL = https://connect.squareup.com +SQUARE_SANDBOX_BASE_URL = https://connect.squareupsandbox.com +SQUARE_ACCESS_TOKEN = diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 0000000..724588f --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,94 @@ +/** + * How to install and use + * @link {https://www.arden.nl/setting-up-a-gatsby-js-starter-with-type-script-es-lint-prettier-and-pre-commit-hooks} + * + * Rule documentation + * @link {https://github.com/typescript-eslint/typescript-eslint/blob/master/packages/eslint-plugin/docs/rules/no-var-requires.md} + */ +module.exports = { + root: true, + env: { + browser: true, + node: true, + es6: true, + mocha: true, + }, + parser: '@typescript-eslint/parser', + extends: [ + 'eslint:recommended', + 'plugin:@typescript-eslint/recommended', + 'plugin:@typescript-eslint/recommended-requiring-type-checking', + 'plugin:prettier/recommended', + 'plugin:import/errors', + 'plugin:import/warnings', + 'plugin:import/typescript', + 'prettier', + 'prettier/@typescript-eslint', + ], + settings: { + 'import/parsers': { + '@typescript-eslint/parser': ['.ts', '.tsx'], + }, + }, + parserOptions: { + project: './tsconfig.eslint.json', + }, + plugins: ['@typescript-eslint', 'import', 'prettier'], + rules: { + 'prettier/prettier': 'error', + 'import/no-deprecated': ['error'], + 'import/order': ['error', { groups: ['builtin', 'external', 'parent', 'sibling', 'index'] }], + '@typescript-eslint/explicit-member-accessibility': ['error', { accessibility: 'no-public' }], + '@typescript-eslint/explicit-function-return-type': ['error'], + '@typescript-eslint/interface-name-prefix': 'off', + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-inferrable-types': 'off', + '@typescript-eslint/no-non-null-assertion': 'off', + '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], + '@typescript-eslint/no-empty-interface': 'off', + '@typescript-eslint/no-require-imports': ['error'], + '@typescript-eslint/no-use-before-define': ['error'], + '@typescript-eslint/camelcase': 'off', + '@typescript-eslint/ban-ts-ignore': 'off', + '@typescript-eslint/require-await': 'off', + '@typescript-eslint/promise-function-async': [ + 'error', + { + allowAny: true, + }, + ], + '@typescript-eslint/ban-types': [ + 'error', + { + types: { + Number: { + message: 'Use number instead', + fixWith: 'number', + }, + Function: { + message: 'Use () => void instead', + fixWith: '() => void', + }, + Object: { + message: 'Use object instead', + fixWith: 'object', + }, + String: { + message: 'Use string instead', + fixWith: 'string', + }, + }, + }, + ], + }, + overrides: [ + { + files: ['*.js', '*.jsx'], + rules: { + '@typescript-eslint/no-require-imports': 'off', + '@typescript-eslint/no-var-requires': 'off', + '@typescript-eslint/explicit-function-return-type': 'off', + }, + }, + ], +}; diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..988cdb9 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +* text=auto +* text eol=lf +*.png binary +*.gif binary diff --git a/.github/ISSUE_TEMPLATE/1-bug-report.md b/.github/ISSUE_TEMPLATE/1-bug-report.md new file mode 100644 index 0000000..bff59a2 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/1-bug-report.md @@ -0,0 +1,45 @@ +--- +name: 🐞 Bug Report +about: Report a reproducible bug +title: '' +labels: '' +assignees: '' + +--- + + + +## Describe the bug + + + +## To Reproduce + + + +```typescript +// Example code here +``` + +## Expected behavior + + + +## Environment: + +* Square Connect Plus Version: x.y.z +* Node version: x.y.z + +## Additional context/Screenshots + + diff --git a/.github/ISSUE_TEMPLATE/2-feature-request.md b/.github/ISSUE_TEMPLATE/2-feature-request.md new file mode 100644 index 0000000..3532d51 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/2-feature-request.md @@ -0,0 +1,33 @@ +--- +name: ✨ Feature Request +about: Suggest an idea or feature +title: 'feat: ' +labels: feature +assignees: ruscon + +--- + +## Issue Number: [N/A] + +## Does this PR introduce a breaking change + + [ ] Yes + [ ] No + + + +## Is your feature request related to a problem? Please describe. + + + +## Describe the solution you'd like + + + +## Describe alternatives you've considered + + + +## Additional context + + diff --git a/.github/ISSUE_TEMPLATE/3-documentation.md b/.github/ISSUE_TEMPLATE/3-documentation.md new file mode 100644 index 0000000..8f3ddf3 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/3-documentation.md @@ -0,0 +1,27 @@ +--- +name: πŸ“š Documentation +about: Found a typo or something that isn't crystal clear in our docs? +title: 'docs: ' +labels: documentation +assignees: ruscon + +--- + + + +## Section/Content To Improve + + + +## Suggested Improvement + + + +## Relevant File(s): + + diff --git a/.github/ISSUE_TEMPLATE/4-question.md b/.github/ISSUE_TEMPLATE/4-question.md new file mode 100644 index 0000000..25bcc5d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/4-question.md @@ -0,0 +1,44 @@ +--- +name: πŸ€” Question +about: Get help using Square Connect Plus +title: 'question: ' +labels: question +assignees: '' + +--- + + + +## Environment: + +* Square Connect Plus Version: x.y.z +* Node version: x.y.z + +## Describe the issue + + + +## Example Code + + + +```typescript +// Example code here +``` + +## Expected behavior, if applicable + + + +## Additional context/screenshots + + diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..53b5c75 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,47 @@ + + + + + + + + + + + + +## Summary + + + +## Context + + diff --git a/.github/config.yml b/.github/config.yml new file mode 100644 index 0000000..3015da1 --- /dev/null +++ b/.github/config.yml @@ -0,0 +1,15 @@ +updateDocsComment: > + Thanks for opening this pull request! The maintainers of this repository would appreciate it if you would update some of our documentation based on your changes. + +updateDocsWhiteList: + - bug + - fix + - Backport + - dev + - Update + - WIP + - chore + +updateDocsTargetFiles: + - README + - docs/ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c391a8a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,63 @@ +name: CI + +on: ["push", "pull_request"] + +jobs: + commitlint: + runs-on: ubuntu-latest + + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + steps: + - name: Clone repository + uses: actions/checkout@v2 + with: + fetch-depth: 0 + + - name: Lints Pull Request commits + uses: wagoid/commitlint-github-action@v1 + + build: + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + node-version: [10.x, 12.x, 13.x] + + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + steps: + - name: Clone repository + uses: actions/checkout@v1 + with: + fetch-depth: 1 + + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v1 + with: + node-version: ${{ matrix.node-version }} + + - run: node --version + - run: npm --version + + - name: Install npm dependencies + run: npm i + + - name: Lint code + run: npm run lint + + - name: Run tests + run: npm run coverage:all + env: + SQUARE_BASE_URL: ${{ secrets.SQUARE_BASE_URL }} + SQUARE_SANDBOX_BASE_URL: ${{ secrets.SQUARE_SANDBOX_BASE_URL }} + SQUARE_ACCESS_TOKEN: ${{ secrets.SQUARE_ACCESS_TOKEN }} + + - name: Run Coveralls + uses: coverallsapp/github-action@master + if: startsWith(matrix.node-version, '12.') + with: + github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b070ed4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +# dependencies +dist/ +node_modules/ + +# IDE +/.idea +/.vscode + +# misc +.DS_Store + +# tests +test.ts +/test-reports +/coverage +/.nyc_output +/log +!/bin +.env +package-lock.json diff --git a/.mocharc.json b/.mocharc.json new file mode 100644 index 0000000..9b65af0 --- /dev/null +++ b/.mocharc.json @@ -0,0 +1,15 @@ +{ + "ui": "bdd", + "project": "tsconfig.json", + "check-leaks": true, + "full-trace": true, + "recursive": true, + "exit": true, + "require": [ + "ts-node/register/transpile-only", + "source-map-support/register", + "tsconfig-paths/register", + "dotenv-safe/config", + "./test/common.opts.ts" + ] +} diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..df3c14a --- /dev/null +++ b/.npmrc @@ -0,0 +1,3 @@ +save-exact=false +scripts-prepend-node-path=true +package-lock=false diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..dae199a --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +v12 diff --git a/.nycrc.json b/.nycrc.json new file mode 100644 index 0000000..7d71873 --- /dev/null +++ b/.nycrc.json @@ -0,0 +1,8 @@ +{ + "extension": [".ts"], + "include": ["src/**/*.ts"], + "exclude": ["**/*.d.ts", "src/index.ts", "src/interface/index.ts", "src/test.ts"], + "reporter": ["lcov", "text-summary", "html"], + "cache": false, + "all": true +} diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..736cc1e --- /dev/null +++ b/.prettierignore @@ -0,0 +1,6 @@ +.nyc_output +coverage +dist +node_modules +package-lock.json +src/test.ts diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..b190b4d --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,9 @@ +{ + "tabWidth": 4, + "useTabs": false, + "semi": true, + "singleQuote": true, + "trailingComma": "all", + "arrowParens": "always", + "printWidth": 160 +} diff --git a/.remarkrc b/.remarkrc new file mode 100644 index 0000000..d1b862a --- /dev/null +++ b/.remarkrc @@ -0,0 +1,13 @@ +{ + "settings": { + "emphasis": "*", + "strong": "*", + "bullet": "*" + }, + "plugins": [ + "remark-lint-emphasis-marker", + "remark-lint-strong-marker", + "remark-github", + "remark-frontmatter" + ] +} diff --git a/.versionrc.json b/.versionrc.json new file mode 100644 index 0000000..db321f2 --- /dev/null +++ b/.versionrc.json @@ -0,0 +1,31 @@ +{ + "types": [ + { + "type": "feat", + "section": "Features" + }, + { + "type": "fix", + "section": "Bug Fixes" + }, + { + "type": "test", + "section": "Tests", + "hidden": true + }, + { + "type": "build", + "section": "Build System", + "hidden": true + }, + { + "type": "chore", + "section": "Chore", + "hidden": true + }, + { + "type": "ci", + "hidden": true + } + ] +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..8e489a7 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,17 @@ +# Changelog + +All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. + +### [0.0.3](https://github.com/goparrot/square-connect-plus/compare/v0.0.2...v0.0.3) (2020-03-24) + +### [0.0.2](https://github.com/goparrot/square-connect-plus/compare/v0.0.1...v0.0.2) (2020-01-31) + +### Features + +* add SquareException.toObject() method ([d6e194d](https://github.com/goparrot/square-connect-plus/commit/d6e194dc664b6c94b67f0e3c0d40645b71cf1bd4)) + +### 0.0.1 (2020-01-31) + +### Features + +* **client:** add client with retry mechanism ([ca771bd](https://github.com/goparrot/square-connect-plus/commit/ca771bd74bf0d39f012566d2c19d9af24d030555)) diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..7a099a3 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,27 @@ +## Contributor Code of Conduct + +As contributors and maintainers of this project, we pledge to respect all people +who contribute through reporting issues, posting feature requests, updating +documentation, submitting pull requests or patches, and other activities. + +We are committed to making participation in this project a harassment-free +experience for everyone, regardless of level of experience, gender, gender +identity and expression, sexual orientation, disability, personal appearance, +body size, race, age, or religion. + +Examples of unacceptable behavior by participants include the use of sexual +language or imagery, derogatory comments or personal attacks, trolling, public +or private harassment, insults, or other unprofessional conduct. + +Project maintainers have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct. Project maintainers who do not follow the +Code of Conduct may be removed from the project team. + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported by opening an issue or contacting one or more of the project +maintainers. + +This Code of Conduct is adapted from the [Contributor +Covenant](http:contributor-covenant.org), version 1.0.0, available at + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..27af91f --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,32 @@ +# Contributing + +First of all, **thank you** for contributing, **you are awesome**! + +Here are a few rules to follow in order to ease code reviews, and discussions before +maintainers accept and merge your work. + +You MUST follow the [TypeScript](https://www.typescriptlang.org/docs/home.html) standards. If you don't know about any of them, you +should really read the recommendations. + +Check your node version `node -v`. +You MUST have `">=10.13"`, because of . + +You MUST run the `npm run coverage` command. + +You MUST write (or update) unit tests. + +You MUST run the `npm run commit` hook to add a new commit. + +You SHOULD write documentation. + +Please, write [commit messages that make +sense](http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html), +and [rebase your branch](http://git-scm.com/book/en/Git-Branching-Rebasing) +before submitting your Pull Request. + +One may ask you to [squash your +commits](http://gitready.com/advanced/2009/02/10/squashing-commits-with-rebase.html) +too. This is used to "clean" your Pull Request before merging it (we don't want +commits such as `fix tests`, `fix 2`, `fix 3`, etc.). + +Thank you! diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..be7581f --- /dev/null +++ b/LICENSE @@ -0,0 +1,22 @@ +(The MIT License) + +Copyright (c) 2017 Kamil MyΕ›liwiec + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md index 04332de..05c4d1e 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,116 @@ -# square-connect-plus -Typescript library which extends the official Square Connect APIs library with additional functionality +[![Build Status](https://github.com/goparrot/square-connect-plus/workflows/CI/badge.svg?branch=master)](https://github.com/goparrot/square-connect-plus/actions?query=branch%3Amaster+event%3Apush+workflow%3ACI) +[![Coverage Status](https://coveralls.io/repos/github/goparrot/square-connect-plus/badge.svg?branch=master)](https://coveralls.io/github/goparrot/square-connect-plus?branch=master) +[![NPM version](https://img.shields.io/npm/v/@goparrot/square-connect-plus)](https://www.npmjs.com/package/@goparrot/square-connect-plus) +[![Greenkeeper badge](https://badges.greenkeeper.io/goparrot/square-connect-plus.svg)](https://greenkeeper.io/) +[![Commitizen friendly](https://img.shields.io/badge/commitizen-friendly-brightgreen.svg)](http://commitizen.github.io/cz-cli/) +[![Conventional Commits](https://img.shields.io/badge/Conventional%20Commits-1.0.0-yellow.svg)](https://conventionalcommits.org) + +# Square Connect Plus + +**Square Connect Plus** is a Typescript library which extends the official Square Connect APIs library with additional functionality. +The library does not modify request and response payload. + +* [Installation](#installation) +* [Usage](#usage) +* [Versioning](#versioning) +* [Contributing](#contributing) +* [Unit Tests](#unit-tests) +* [Background](#background) +* [License](#license) + +## Installation + + $ npm i @goparrot/square-connect-plus square-connect + +## Usage + +### Simple example + +```typescript +import { SquareClient } from '@goparrot/square-connect-plus'; +import { ListLocationsResponse } from 'square-connect'; + +const accessToken: string = `${process.env.SQUARE_ACCESS_TOKEN}`; +const squareClient: SquareClient = new SquareClient(accessToken); + +(async () => { + try { + const listLocationsResponse: ListLocationsResponse = await squareClient.getLocationsApi().listLocations(); + if (listLocationsResponse.errors) { + throw new Error(`cant fetch locations`); + } + + console.info('locations', listLocationsResponse.locations); + } catch (error) { + console.error(error); + // or error as string with stack + request and response payload + // console.error(`${error.stack}\npayload: ${error.toString()}`); + } +})(); +``` + +### Advanced example + +```typescript +import { SquareClient, exponentialDelay, retryCondition } from '@goparrot/square-connect-plus'; + +const accessToken: string = `${process.env.SQUARE_ACCESS_TOKEN}`; +const squareClient: SquareClient = new SquareClient(accessToken, { + retry: { + maxRetries: 10, + }, + originClient: { + timeout: 10000, + }, + logger: console, +}); +``` + +## Available Options + +### `retry` Options + +| Name | Type | Default | Description | +| -------------- | ---------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| maxRetries | `Number` | `6` | The number of times to retry before failing. | +| retryCondition | `Function` | `retryCondition` | A callback to further control if a request should be retried. By default, the built-in `retryCondition` function is used. | +| retryDelay | `Function` | `exponentialDelay` | A callback to further control the delay between retried requests. By default, the built-in `exponentialDelay` function is used ([Exponential Backoff](https://developers.google.com/analytics/devguides/reporting/core/v3/errors#backoff)). | + +### `originClient` Options + +A set of possible settings for the original library. + +| Name | Type | Default | Description | +| -------------- | --------- | ------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------- | +| basePath | `String` | `https://connect.squareup.com` | The base URL against which to resolve every API call's (relative) path. | +| defaultHeaders | `Array` | `{ 'User-Agent': 'Square-Connect-Javascript/3.20200226.0' }` | The default HTTP headers to be included for all API calls. | +| timeout | `Number` | `15000` | The default HTTP timeout for all API calls. | +| cache | `Boolean` | `true` | If set to false an additional timestamp parameter is added to all API GET calls to prevent browser caching. | +| enableCookies | `Boolean` | `false` | If set to true, the client will save the cookies from each server response, and return them in the next request. | + +### `logger` Option + +By default, the built-in `NullLogger` class is used. +You can use any logger that fits the built-in `ILogger` interface + +## Versioning + +Square Connect Plus follows [Semantic Versioning](http://semver.org/). + +## Contributing + +See [`CONTRIBUTING`](https://github.com/goparrot/square-connect-plus/blob/master/CONTRIBUTING.md#contributing) file. + +## Unit Tests + +In order to run the test suite, install the development dependencies: + + $ npm i + +Then, run the following command: + + $ npm run coverage + +## License + +Square Connect Plus is [MIT licensed](LICENSE). diff --git a/bin/prepublish.ts b/bin/prepublish.ts new file mode 100644 index 0000000..b22a384 --- /dev/null +++ b/bin/prepublish.ts @@ -0,0 +1,21 @@ +import { writeFileSync, copyFileSync } from 'fs'; +import originPackage from '../package.json'; + +const distPackage: Record = originPackage; +distPackage.module = './index.js'; +distPackage.main = './index.js'; +distPackage.types = './index.d.ts'; +delete distPackage.scripts; +delete distPackage.devDependencies; +delete distPackage.config; +delete distPackage.husky; +delete distPackage.files; +delete distPackage.directories; +delete distPackage['lint-staged']; + +writeFileSync('./dist/package.json', JSON.stringify(distPackage, null, ' ')); + +const copyFiles = ['README.md']; +for (const file of copyFiles) { + copyFileSync(`./${file}`, `./dist/${file}`); +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..1ef6693 --- /dev/null +++ b/package.json @@ -0,0 +1,121 @@ +{ + "name": "@goparrot/square-connect-plus", + "description": "Extends the official Square Connect APIs Javascript library with additional functionality", + "version": "0.0.3", + "author": "Coroliov Oleg", + "license": "MIT", + "private": false, + "bugs": { + "url": "https://github.com/goparrot/square-connect-plus/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/goparrot/square-connect-plus.git" + }, + "keywords": [ + "node", + "typescript", + "square-connect", + "retry" + ], + "engines": { + "node": ">=10" + }, + "main": "src/index.ts", + "husky": { + "hooks": { + "commit-msg": "commitlint -E HUSKY_GIT_PARAMS && npm run pre-commit", + "post-commit": "git update-index --again" + } + }, + "lint-staged": { + "*.{ts,tsx,json,js,jsx}": [ + "npm run format:base" + ] + }, + "config": { + "commitizen": { + "path": "cz-conventional-changelog" + } + }, + "scripts": { + "commit": "git-cz", + "test": "mocha 'test/unit/**/*.spec.ts' 'test/e2e/**/*.spec.ts'", + "test:all": "mocha --timeout 15000 'test/**/*.spec.ts'", + "test:unit": "mocha 'test/unit/**/*.spec.ts'", + "test:e2e": "mocha 'test/e2e/**/*.spec.ts'", + "test:integration": "mocha --timeout 15000 'test/integration/**/*.spec.ts'", + "coverage": "nyc npm test", + "coverage:all": "nyc npm run test:all", + "lint": "npm run lint:base -- '.'", + "lint:base": "npm run lint:config:check && eslint --ignore-path .gitignore --ext .ts,.tsx,.json,.js,.jsx", + "lint:config:check": "eslint --print-config src/index.ts | eslint-config-prettier-check", + "format": "npm run format:base -- '.'", + "format:base": "npm run lint:base -- --fix", + "format:staged": "git add . && lint-staged", + "build": "rimraf dist && tsc -p tsconfig.build.json", + "remark": "remark README.md CHANGELOG.md CONTRIBUTING.md CODE_OF_CONDUCT.md .github/ -o -f -q && git add .", + "pre-commit": "git add . && npm run format:staged && npm run remark && npm run lint && npm run coverage:all && npm run build", + "changelog": "conventional-changelog -p angular -i CHANGELOG.md -s -r 0 && remark CHANGELOG.md -o -f -q && git add CHANGELOG.md", + "prepublishOnly": "echo \"use 'npm run publish'\" && exit 1", + "publish": "npm run build && ts-node -T bin/prepublish.ts && npm publish dist", + "publish:dev": "npm run publish --tag dev", + "publish:dev:dry": "npm run publish:dev --dry-run", + "version": "echo \"use 'npm run release'\" && exit 1", + "release": "standard-version && git push && git push --tags && npm run publish && npm run github-release", + "release:dry": "npm run publish:dev:dry && standard-version --dry-run", + "github-release": "env-cmd conventional-github-releaser -p angular" + }, + "peerDependencies": { + "square-connect": "^3.20200226.0" + }, + "dependencies": {}, + "devDependencies": { + "@commitlint/cli": "^8.3.5", + "@commitlint/config-conventional": "^8.3.4", + "@commitlint/travis-cli": "^8.2.0", + "@types/chai": "^4.2.11", + "@types/chai-as-promised": "^7.1.2", + "@types/mocha": "^7.0.2", + "@types/node": "^13.9.3", + "@types/sinon": "^7.5.2", + "@types/square-connect": "^2.20190814.3", + "@types/superagent": "^4.1.7", + "@typescript-eslint/eslint-plugin": "^2.25.0", + "@typescript-eslint/parser": "^3.0.0", + "chai": "^4.2.0", + "chai-as-promised": "^7.1.1", + "commitizen": "^4.0.0", + "conventional-changelog-cli": "^2.0.27", + "conventional-github-releaser": "^3.1.3", + "cz-conventional-changelog": "^3.1.0", + "dotenv-safe": "^8.2.0", + "env-cmd": "^10.1.0", + "eslint": "^6.8.0", + "eslint-config-prettier": "^6.10.1", + "eslint-import-resolver-typescript": "^2.0.0", + "eslint-plugin-import": "^2.20.1", + "eslint-plugin-prettier": "^3.1.2", + "husky": "^4.2.3", + "lint-staged": "^10.0.9", + "mocha": "^7.1.1", + "mocha-junit-reporter": "^1.23.0", + "nock": "^12.0.3", + "nyc": "^15.0.0", + "prettier": "^2.0.2", + "remark-cli": "^7.0.0", + "remark-frontmatter": "^1.3.3", + "remark-github": "^9.0.0", + "remark-lint-emphasis-marker": "^1.0.3", + "remark-lint-strong-marker": "^1.0.3", + "rimraf": "^3.0.2", + "sinon": "^9.0.1", + "source-map-support": "^0.5.16", + "square-connect": "^3.20200226.0", + "standard-version": "^7.0.0", + "superagent": "^5.2.2", + "ts-node": "^8.8.1", + "tsconfig-paths": "^3.9.0", + "typescript": "^3.8.3" + } +} diff --git a/src/client/index.ts b/src/client/index.ts new file mode 100644 index 0000000..5adabda --- /dev/null +++ b/src/client/index.ts @@ -0,0 +1,2 @@ +export * from './square-client'; +export * from './square-client-factory'; diff --git a/src/client/square-client-factory.ts b/src/client/square-client-factory.ts new file mode 100644 index 0000000..d6972e3 --- /dev/null +++ b/src/client/square-client-factory.ts @@ -0,0 +1,12 @@ +import { ISquareClientConfig } from '../interface'; +import { SquareClient } from './square-client'; + +export class SquareClientFactory { + static create(accessToken: string, config: ISquareClientConfig = {}): SquareClient { + return new SquareClient(accessToken, config); + } + + create(accessToken: string, config: ISquareClientConfig = {}): SquareClient { + return SquareClientFactory.create(accessToken, config); + } +} diff --git a/src/client/square-client.ts b/src/client/square-client.ts new file mode 100644 index 0000000..c9b1142 --- /dev/null +++ b/src/client/square-client.ts @@ -0,0 +1,201 @@ +import { + ApiClient, + ApplePayApi, + CatalogApi, + CheckoutApi, + CustomersApi, + EmployeesApi, + InventoryApi, + LaborApi, + LocationsApi, + MobileAuthorizationApi, + OAuthApi, + OrdersApi, + PaymentsApi, + RefundsApi, + TransactionsApi, +} from 'square-connect'; +import { ISquareClientConfig, ISquareClientDefaultConfig, ISquareClientMergedConfig } from '../interface'; +import { ILogger, NullLogger } from '../logger'; +import { exponentialDelay, makeRetryable, mergeDeepProps, retryCondition } from '../utils'; + +export class SquareClient { + private originApiClient: ApiClient; + private readonly config: ISquareClientMergedConfig; + private readonly defaultConfig: ISquareClientDefaultConfig = { + retry: { + maxRetries: 6, + retryDelay: exponentialDelay, + }, + originClient: { + timeout: 15000, + }, + }; + + constructor(private readonly accessToken: string, config: ISquareClientConfig = {}) { + this.config = mergeDeepProps(this.defaultConfig, config); + this.config.logger = config.logger; + } + + /** + * Generate unique idempotency key (format: reference-timestampWithMilliseconds) + */ + static generateIdempotencyKey(reference: string): string { + return [reference, Date.now().toString()].join('-'); + } + + getConfig(): ISquareClientMergedConfig { + return this.config; + } + + getOriginApiClient(): ApiClient { + return this.originApiClient ?? (this.originApiClient = this.createOriginApiClient(this.accessToken, this.config)); + } + + getApplePayApi(): ApplePayApi { + const retryableMethods: string[] = []; + + return this.proxify(new ApplePayApi(this.getOriginApiClient()), retryableMethods); + } + + getCatalogApi(): CatalogApi { + const retryableMethods: string[] = ['batchRetrieveCatalogObjects', 'catalogInfo', 'listCatalog', 'retrieveCatalogObject', 'searchCatalogObjects']; + + return this.proxify(new CatalogApi(this.getOriginApiClient()), retryableMethods); + } + + getCheckoutApi(): CheckoutApi { + const retryableMethods: string[] = []; + + return this.proxify(new CheckoutApi(this.getOriginApiClient()), retryableMethods); + } + + getCustomersApi(): CustomersApi { + // createCustomerCard should not be retryable (#GP-2400) + const retryableMethods: string[] = ['listCustomers', 'retrieveCustomer', 'searchCustomers', 'deleteCustomerCard']; + + return this.proxify(new CustomersApi(this.getOriginApiClient()), retryableMethods); + } + + getEmployeesApi(): EmployeesApi { + const retryableMethods: string[] = ['listEmployees', 'retrieveEmployee']; + + return this.proxify(new EmployeesApi(this.getOriginApiClient()), retryableMethods); + } + + getInventoryApi(): InventoryApi { + const retryableMethods: string[] = [ + 'batchRetrieveInventoryChanges', + 'batchRetrieveInventoryCounts', + 'retrieveInventoryAdjustment', + 'retrieveInventoryChanges', + 'retrieveInventoryCount', + 'retrieveInventoryPhysicalCount', + ]; + + return this.proxify(new InventoryApi(this.getOriginApiClient()), retryableMethods); + } + + getLaborApi(): LaborApi { + const retryableMethods: string[] = [ + 'getBreakType', + 'getEmployeeWage', + 'getShift', + 'listBreakTypes', + 'listEmployeeWages', + 'listWorkweekConfigs', + 'searchShifts', + ]; + + return this.proxify(new LaborApi(this.getOriginApiClient()), retryableMethods); + } + + getLocationsApi(): LocationsApi { + const retryableMethods: string[] = ['listLocations']; + + return this.proxify(new LocationsApi(this.getOriginApiClient()), retryableMethods); + } + + getMobileAuthorizationApi(): MobileAuthorizationApi { + const retryableMethods: string[] = []; + + return this.proxify(new MobileAuthorizationApi(this.getOriginApiClient()), retryableMethods); + } + + getOAuthApi(): OAuthApi { + const retryableMethods: string[] = []; + + return this.proxify(new OAuthApi(this.getOriginApiClient()), retryableMethods); + } + + getOrdersApi(): OrdersApi { + const retryableMethods: string[] = ['batchRetrieveOrders', 'searchOrders', 'createOrder', 'payOrder']; + + return this.proxify(new OrdersApi(this.getOriginApiClient()), retryableMethods); + } + + getPaymentsApi(): PaymentsApi { + const retryableMethods: string[] = ['getPayment', 'listPayments', 'createPayment', 'cancelPayment']; + + return this.proxify(new PaymentsApi(this.getOriginApiClient()), retryableMethods); + } + + getRefundsApi(): RefundsApi { + const retryableMethods: string[] = ['getPaymentRefund', 'listPaymentRefunds', 'refundPayment']; + + return this.proxify(new RefundsApi(this.getOriginApiClient()), retryableMethods); + } + + getTransactionsApi(): TransactionsApi { + const retryableMethods: string[] = ['listRefunds', 'listTransactions', 'retrieveTransaction']; + + return this.proxify(new TransactionsApi(this.getOriginApiClient()), retryableMethods); + } + + private createOriginApiClient(accessToken: string, config: ISquareClientConfig): ApiClient { + const apiClient: ApiClient = new ApiClient(); + apiClient.authentications.oauth2.accessToken = accessToken; + + return mergeDeepProps(apiClient, config.originClient ?? {}); + } + + private getLogger(): ILogger { + return this.config.logger ?? (this.config.logger = new NullLogger()); + } + + private proxify(api: T, retryableMethods: string[]): T { + let globalStack: string = ''; + try { + throw new Error(); + } catch (err) { + globalStack += err.stack.slice(6); // remove "Error:" + } + + const handler: ProxyHandler = { + get: (target: T, apiMethodName: string): any => { + if (!retryableMethods.includes(apiMethodName)) { + return target[apiMethodName]; + } + + return async (...args: any[]): Promise => { + const requestFn: () => any = target[apiMethodName].bind(target, ...args); + + this.getLogger().debug(`Square api request: ${JSON.stringify({ apiMethodName, args })}`); + + try { + return await makeRetryable(requestFn, { + maxRetries: this.config.retry.maxRetries, + retryDelay: this.config.retry.retryDelay, + retryCondition: this.config.retry.retryCondition ?? retryCondition, + }); + } catch (err) { + err.stack += globalStack; + throw err; + } + }; + }, + }; + + return new Proxy(api, handler); + } +} diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000..4234712 --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,9 @@ +import { ErrorCodeType } from 'square-connect'; + +export const retryableErrorCodes: ReadonlyArray = Object.freeze([ + 'RATE_LIMITED', + 'REQUEST_TIMEOUT', + 'GATEWAY_TIMEOUT', + 'SERVICE_UNAVAILABLE', + 'INTERNAL_SERVER_ERROR', +]); diff --git a/src/exception/i-square-exception.ts b/src/exception/i-square-exception.ts new file mode 100644 index 0000000..4391a9e --- /dev/null +++ b/src/exception/i-square-exception.ts @@ -0,0 +1,12 @@ +import { ModelError } from 'square-connect'; + +export interface ISquareException { + name: string; + statusCode: number; + message: string; + apiError: ModelError; + retries?: number; + url?: string; + method?: string; + requestArgs?: any; +} diff --git a/src/exception/index.ts b/src/exception/index.ts new file mode 100644 index 0000000..b2883bc --- /dev/null +++ b/src/exception/index.ts @@ -0,0 +1,2 @@ +export * from './i-square-exception'; +export * from './square-exception'; diff --git a/src/exception/square-exception.ts b/src/exception/square-exception.ts new file mode 100644 index 0000000..a4b6ba2 --- /dev/null +++ b/src/exception/square-exception.ts @@ -0,0 +1,88 @@ +import { ModelError } from 'square-connect'; +import { Response, SuperAgentRequest } from 'superagent'; +import { ISquareException } from './i-square-exception'; + +export class SquareException extends Error implements ISquareException { + statusCode: number; + apiError: ModelError; + retries?: number; + url?: string; + method?: string; + requestArgs?: any; + + constructor( + data?: { retries?: number; url?: string; method?: string; statusCode?: number; apiError?: ModelError; requestArgs?: any }, + originError?: Error, + ) { + super(); + + this.name = this.constructor.name; + + this.retries = data?.retries || 0; + this.url = data?.url; + this.method = data?.method?.toUpperCase(); + this.statusCode = data?.statusCode || 500; + this.apiError = data?.apiError || { category: 'API_ERROR', code: 'SERVICE_UNAVAILABLE', detail: 'Square API error' }; + this.message = this.apiError.detail ?? this.apiError.code ?? originError?.message ?? 'Square API error'; + this.requestArgs = data?.requestArgs; + + // Error.captureStackTrace(this); + } + + static createFromSuperAgentError( + error: Error & { response?: Response & { request?: SuperAgentRequest }; code?: string }, + retries: number, + ): SquareException { + const response: (Response & { request?: SuperAgentRequest }) | undefined = error?.response; + const request: SuperAgentRequest | undefined = response?.request; + + if (response) { + // console.dir(request, {depth: undefined}); + // console.log(JSON.parse(JSON.stringify(response))); + return new SquareException( + { + retries, + url: request?.url, + statusCode: response?.status, + method: request?.method, + apiError: response?.body?.errors?.[0], + // @ts-ignore + requestArgs: request?._data, + }, + error, + ); + } else if ('ECONNABORTED' === error.code) { + return new SquareException({ + retries, + statusCode: 500, + apiError: { category: 'API_ERROR', code: 'GATEWAY_TIMEOUT', detail: 'Square API timeout' }, + }); + } + + return new SquareException( + { + retries, + statusCode: 500, + apiError: { category: 'API_ERROR', code: 'SERVICE_UNAVAILABLE', detail: 'Square API error' }, + }, + error, + ); + } + + toObject(): ISquareException { + return { + name: this.name, + message: this.message, + retries: this.retries, + url: this.url, + method: this.method, + statusCode: this.statusCode, + requestArgs: this.requestArgs, + apiError: this.apiError, + }; + } + + toString(): string { + return JSON.stringify(this.toObject()); + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..650c8fc --- /dev/null +++ b/src/index.ts @@ -0,0 +1,6 @@ +export * from './exception'; +export * from './interface'; +export * from './logger'; +export * from './utils'; +export * from './client'; +export * from './constants'; diff --git a/src/interface/i-square-api-config.ts b/src/interface/i-square-api-config.ts new file mode 100644 index 0000000..27d5afa --- /dev/null +++ b/src/interface/i-square-api-config.ts @@ -0,0 +1,33 @@ +import { ILogger } from '../logger'; +import { IRetriesOptions } from '../utils'; + +export interface ISquareClientConfig { + retry?: Partial; + originClient?: { + /** + * The base URL against which to resolve every API call's (relative) path. + */ + basePath?: string; + + /** + * The default HTTP headers to be included for all API calls. + */ + defaultHeaders?: { [key: string]: string }; + + /** + * The default HTTP timeout for all API calls. + */ + timeout?: number; + + /** + * If set to false an additional timestamp parameter is added to all API GET calls to prevent browser caching. + */ + cache?: boolean; + + /** + * If set to true, the client will save the cookies from each server response, and return them in the next request. + */ + enableCookies?: boolean; + }; + logger?: ILogger; +} diff --git a/src/interface/i-square-api-default-config.ts b/src/interface/i-square-api-default-config.ts new file mode 100644 index 0000000..43e8b91 --- /dev/null +++ b/src/interface/i-square-api-default-config.ts @@ -0,0 +1,9 @@ +import { ISquareClientConfig } from './i-square-api-config'; +import { ISquareClientDefaultRetryConfig } from './i-square-api-default-retry-config'; + +export interface ISquareClientDefaultConfig extends ISquareClientConfig { + retry: ISquareClientDefaultRetryConfig; + originClient: { + timeout: number; + }; +} diff --git a/src/interface/i-square-api-default-retry-config.ts b/src/interface/i-square-api-default-retry-config.ts new file mode 100644 index 0000000..8aee8fc --- /dev/null +++ b/src/interface/i-square-api-default-retry-config.ts @@ -0,0 +1,6 @@ +import { IRetriesOptions } from '../utils'; + +export interface ISquareClientDefaultRetryConfig extends Partial { + maxRetries: number; + retryDelay: (retryCount: number) => number; +} diff --git a/src/interface/i-square-api-merged-config.ts b/src/interface/i-square-api-merged-config.ts new file mode 100644 index 0000000..b0c5405 --- /dev/null +++ b/src/interface/i-square-api-merged-config.ts @@ -0,0 +1,3 @@ +import { ISquareClientDefaultConfig } from './i-square-api-default-config'; + +export interface ISquareClientMergedConfig extends ISquareClientDefaultConfig {} diff --git a/src/interface/index.ts b/src/interface/index.ts new file mode 100644 index 0000000..6d69679 --- /dev/null +++ b/src/interface/index.ts @@ -0,0 +1,4 @@ +export * from './i-square-api-config'; +export * from './i-square-api-default-retry-config'; +export * from './i-square-api-default-config'; +export * from './i-square-api-merged-config'; diff --git a/src/logger/i-logger.ts b/src/logger/i-logger.ts new file mode 100644 index 0000000..52b7c47 --- /dev/null +++ b/src/logger/i-logger.ts @@ -0,0 +1,6 @@ +export interface ILogger { + debug(message: string, meta?: any): any; + info(message: string, meta?: any): any; + warn(message: string, meta?: any): any; + error(message: string, meta?: any): any; +} diff --git a/src/logger/index.ts b/src/logger/index.ts new file mode 100644 index 0000000..6d95c9c --- /dev/null +++ b/src/logger/index.ts @@ -0,0 +1,2 @@ +export * from './null-logger'; +export * from './i-logger'; diff --git a/src/logger/null-logger.ts b/src/logger/null-logger.ts new file mode 100644 index 0000000..06bf610 --- /dev/null +++ b/src/logger/null-logger.ts @@ -0,0 +1,19 @@ +import { ILogger } from './i-logger'; + +export class NullLogger implements ILogger { + debug(): any { + // + } + + info(): any { + // + } + + warn(): any { + // + } + + error(): any { + // + } +} diff --git a/src/utils/common.utils.ts b/src/utils/common.utils.ts new file mode 100644 index 0000000..e1ed6d7 --- /dev/null +++ b/src/utils/common.utils.ts @@ -0,0 +1,42 @@ +export async function sleep(timeout: number): Promise { + return new Promise((resolve: any): any => setTimeout(resolve, timeout)); +} + +/** + * Simple object check. + * @returns {boolean} + */ +export function isObject(item?: T): item is T { + return !!item && 'object' === typeof item && !Array.isArray(item); +} + +/** + * @link {https://stackoverflow.com/a/34749873/3408246} + * Deep merge props of two objects + */ +export function mergeDeepProps(target: T, ...sources: S[]): T { + if (!sources.length) return target; + const source: S | undefined = sources.shift(); + + if (isObject(target) && isObject(source)) { + for (const key in source) { + if (isObject(source[key])) { + // @ts-ignore + if (!target[key]) { + Object.assign(target, { [key]: {} }); + } + + mergeDeepProps( + // @ts-ignore + target[key], + // @ts-ignore + source[key], + ); + } else { + Object.assign(target, { [key]: source[key] }); + } + } + } + + return mergeDeepProps(target, ...sources); +} diff --git a/src/utils/index.ts b/src/utils/index.ts new file mode 100644 index 0000000..d5f66ed --- /dev/null +++ b/src/utils/index.ts @@ -0,0 +1,2 @@ +export * from './common.utils'; +export * from './retry.utils'; diff --git a/src/utils/retry.utils.ts b/src/utils/retry.utils.ts new file mode 100644 index 0000000..35b1c7f --- /dev/null +++ b/src/utils/retry.utils.ts @@ -0,0 +1,67 @@ +import { retryableErrorCodes } from '../constants'; +import { SquareException } from '../exception'; +import { sleep } from './common.utils'; + +export interface IRetriesOptions { + /** @default 3 */ + maxRetries: number; + /** @default exponentialDelay */ + retryDelay: (retryCount: number) => number; + retryCondition: (error: SquareException, maxRetries: number, retries: number) => Promise; +} + +/** + * @return {number} - delay in milliseconds + */ +export function exponentialDelay(retryNumber: number): number { + const delay: number = Math.pow(2, retryNumber) * 100; + const randomSum: number = delay * 0.2 * Math.random(); // 0-20% of the delay + + return delay + randomSum; +} + +/** + * add the ability to retry the request + */ +export async function makeRetryable(promiseFn: (...arg: any[]) => Promise, params: IRetriesOptions): Promise { + let retries: number = 0; + + async function retry(): Promise { + try { + return await promiseFn(); + } catch (error) { + const squareException: SquareException = SquareException.createFromSuperAgentError(error, retries); + + if (await params.retryCondition(squareException, params.maxRetries, retries)) { + retries++; + const delay: number = exponentialDelay(retries); + await sleep(delay); + + return retry(); + } + + throw squareException; + } + } + + return retry(); +} + +export function isRetryableException(error: Error): boolean { + if (!(error instanceof SquareException)) { + return false; + } + + const isRetryableResponseStatusCode: boolean = 429 === error.statusCode || (error.statusCode >= 500 && 501 !== error.statusCode); + const isIdempotentRequestMethod: boolean = ['GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE'].includes(error.method?.toUpperCase() || ''); + + return isRetryableResponseStatusCode && (isIdempotentRequestMethod || retryableErrorCodes.includes(error.apiError.code)); +} + +export async function retryCondition(error: SquareException, maxRetries: number, retries: number): Promise { + if (isRetryableException(error) && maxRetries > retries) { + return true; + } + + throw error; +} diff --git a/test/common.opts.ts b/test/common.opts.ts new file mode 100644 index 0000000..59bfe15 --- /dev/null +++ b/test/common.opts.ts @@ -0,0 +1,7 @@ +import * as chai from 'chai'; +// @ts-ignore +import chaiAsPromised from 'chai-as-promised'; + +chai.use(chaiAsPromised); +// @ts-ignore +global.should = chai.should(); diff --git a/test/e2e/client/square-client.spec.ts b/test/e2e/client/square-client.spec.ts new file mode 100644 index 0000000..967e9af --- /dev/null +++ b/test/e2e/client/square-client.spec.ts @@ -0,0 +1,104 @@ +import nock from 'nock'; +import { SquareClient } from '../../../src/client'; +import { SquareException } from '../../../src/exception'; +import { ISquareClientConfig } from '../../../src/interface'; +import { exponentialDelay } from '../../../src/utils'; + +describe('SquareClient (e2e)', (): void => { + const accessToken: string = 'test'; + const basePath: string = `${process.env.SQUARE_SANDBOX_BASE_URL}`; + + const config: ISquareClientConfig = { + retry: { + maxRetries: 2, + retryDelay: exponentialDelay, + }, + originClient: { + timeout: 100, + cache: false, + enableCookies: false, + basePath, + }, + }; + + afterEach((): void => { + nock.cleanAll(); + }); + + it('should NOT retry 501 http status', async (): Promise => { + nock(basePath).get(/.*/).times(1000).reply(501); + + return new SquareClient(accessToken, config) + .getLocationsApi() + .listLocations() + .should.eventually.be.rejectedWith(SquareException) + .and.have.property('retries', 0); + }); + + it('should NOT retry 400 http status', async (): Promise => { + nock(basePath).get(/.*/).times(1000).reply(400); + + return new SquareClient(accessToken, config) + .getLocationsApi() + .listLocations() + .should.eventually.be.rejectedWith(SquareException) + .and.have.property('retries', 0); + }); + + it('should retry 429 http status', async (): Promise => { + nock(basePath) + .get(/.*/) + .times(1000) + .reply(429, { + errors: [ + { + category: 'RATE_LIMIT_ERROR', + code: 'RATE_LIMITED', + detail: 'fake 429 error', + }, + ], + }); + + return new SquareClient(accessToken, config) + .getLocationsApi() + .listLocations() + .should.eventually.be.rejectedWith(SquareException, 'fake 429 error') + .and.have.property('retries', config.retry?.maxRetries); + }); + + it('should retry 500 http status', async (): Promise => { + nock(basePath).get(/.*/).times(1000).reply(500); + + return new SquareClient(accessToken, config) + .getLocationsApi() + .listLocations() + .should.eventually.be.rejectedWith(SquareException) + .and.have.property('retries', config.retry?.maxRetries); + }); + + it('should retry 503 http status', async (): Promise => { + nock(basePath).get(/.*/).times(1000).reply(503); + + return new SquareClient(accessToken, config) + .getLocationsApi() + .listLocations() + .should.eventually.be.rejectedWith(SquareException) + .and.have.property('retries', config.retry?.maxRetries); + }); + + it('should retry if request timeout', async (): Promise => { + nock(basePath) + .get(/.*/) + .times(1000) + .delay({ + head: 3000, + }) + .reply(200, 'OK'); + + return new SquareClient(accessToken, config) + .getLocationsApi() + .listLocations() + .should.eventually.be.rejectedWith(SquareException, 'Square API timeout') + .and.have.property('retries', config.retry?.maxRetries); + }); +}); diff --git a/test/integration/client/square-client.spec.ts b/test/integration/client/square-client.spec.ts new file mode 100644 index 0000000..4d48c8c --- /dev/null +++ b/test/integration/client/square-client.spec.ts @@ -0,0 +1,57 @@ +import { SearchCustomersRequest } from 'square-connect'; +import { SquareClient } from '../../../src/client'; +import { SquareException } from '../../../src/exception'; +import { ISquareClientConfig } from '../../../src/interface'; + +describe('SquareClient (integration)', (): void => { + const accessToken: string = `${process.env.SQUARE_ACCESS_TOKEN}`; + const basePath: string = `${process.env.SQUARE_BASE_URL}`; + + const config: ISquareClientConfig = { + retry: { + maxRetries: 1, + }, + originClient: { + timeout: 10000, + basePath, + }, + }; + + describe('#getLocationsApi', (): void => { + it('should retry by timeout', async (): Promise => { + return new SquareClient(accessToken, { + ...config, + ...{ + originClient: { + timeout: 1, + }, + }, + }) + .getLocationsApi() + .listLocations() + .should.eventually.be.rejectedWith(SquareException, 'Square API timeout'); + }); + + it('should retrieve data', async (): Promise => { + return new SquareClient(accessToken, config).getLocationsApi().listLocations().should.eventually.be.fulfilled.and.have.property('locations'); + }); + }); + + describe('#getCustomersApi', (): void => { + it('should be rejected with ModelError', async (): Promise => { + const query: SearchCustomersRequest = { + limit: 1, + query: { + sort: { + order: 'WRONG_VALUE' as any, + }, + }, + }; + + return new SquareClient(accessToken, config) + .getCustomersApi() + .searchCustomers(query) + .should.eventually.be.rejectedWith(Error, '`WRONG_VALUE` is not a valid enum value for `query.sort.order`.'); + }); + }); +}); diff --git a/test/unit/client/square-client-factory.spec.ts b/test/unit/client/square-client-factory.spec.ts new file mode 100644 index 0000000..7e586a4 --- /dev/null +++ b/test/unit/client/square-client-factory.spec.ts @@ -0,0 +1,127 @@ +import { ApiClient, CustomersApi, LocationsApi, OrdersApi, PaymentsApi, RefundsApi } from 'square-connect'; +import { SquareClient, SquareClientFactory } from '../../../src/client'; +import { ISquareClientConfig } from '../../../src/interface'; +import { exponentialDelay } from '../../../src/utils'; + +describe('SquareClientFactory (unit)', (): void => { + const accessToken: string = 'test'; + + const config: ISquareClientConfig = { + retry: { + maxRetries: 1, + retryDelay: exponentialDelay, + }, + originClient: { + timeout: 1000, + cache: false, + enableCookies: true, + basePath: 'https://connect.squareupsandbox.com', + }, + logger: console, + }; + + describe('#create', (): void => { + it('should be statically init with accessToken only (default config)', async (): Promise => { + return SquareClientFactory.create(accessToken).should.be.instanceOf(SquareClient); + }); + + it('should be init with accessToken only (default config)', async (): Promise => { + return new SquareClientFactory().create(accessToken).should.be.instanceOf(SquareClient); + }); + + it('should be statically init with accessToken and config', async (): Promise => { + return SquareClientFactory.create(accessToken, config).should.be.instanceOf(SquareClient); + }); + + it('should be init with accessToken and config', async (): Promise => { + return new SquareClientFactory().create(accessToken, config).should.be.instanceOf(SquareClient); + }); + }); + + describe('#generateIdempotencyKey', (): void => { + it('should return string', async (): Promise => { + const reference: string = 'test'; + return SquareClient.generateIdempotencyKey(reference) + .should.be.match(new RegExp(`^${reference}-.*`)) + .and.lengthOf(18); + }); + }); + + describe('#getConfig', (): void => { + it('should return default configuration', async (): Promise => { + return new SquareClient(accessToken).getConfig().should.be.deep.eq({ + retry: { + maxRetries: 6, + retryDelay: exponentialDelay, + }, + originClient: { + timeout: 15000, + }, + logger: undefined, + }); + }); + + it('should return custom configuration', async (): Promise => { + return new SquareClient(accessToken, config).getConfig().should.be.deep.eq(config); + }); + }); + + describe('#getOriginApiClient', (): void => { + it('should return ApiClient with default configuration', async (): Promise => { + const apiClient: ApiClient = new SquareClient(accessToken).getOriginApiClient(); + + apiClient.should.be.instanceOf(ApiClient); + apiClient.should.have.property('timeout', 15000); + apiClient.should.have.property('cache', true); + apiClient.should.have.property('enableCookies', false); + apiClient.should.have.property('defaultHeaders').and.should.be.a('object'); + return apiClient.should.and.have.property('basePath', 'https://connect.squareup.com'); + }); + + it('should return ApiClient with custom configuration', async (): Promise => { + const apiClient: ApiClient = new SquareClient(accessToken, config).getOriginApiClient(); + + apiClient.should.be.instanceOf(ApiClient); + apiClient.should.have.property('timeout', config.originClient?.timeout); + apiClient.should.have.property('cache', config.originClient?.cache); + apiClient.should.have.property('enableCookies', config.originClient?.enableCookies); + apiClient.should.have.property('defaultHeaders').and.should.be.a('object'); + return apiClient.should.and.have.property('basePath', config.originClient?.basePath); + }); + + it('should return the same object on second call', async (): Promise => { + const squareClient: SquareClient = new SquareClient(accessToken); + return squareClient.getOriginApiClient().should.be.deep.eq(squareClient.getOriginApiClient()); + }); + }); + + describe('#getLocationsApi', (): void => { + it('should return LocationsApi', async (): Promise => { + return new SquareClient(accessToken).getLocationsApi().should.be.instanceOf(LocationsApi); + }); + }); + + describe('#getCustomersApi', (): void => { + it('should return CustomersApi', async (): Promise => { + return new SquareClient(accessToken).getCustomersApi().should.be.instanceOf(CustomersApi); + }); + }); + + describe('#getPaymentsApi', (): void => { + it('should return PaymentsApi', async (): Promise => { + return new SquareClient(accessToken).getPaymentsApi().should.be.instanceOf(PaymentsApi); + }); + }); + + describe('#getRefundsApi', (): void => { + it('should return RefundsApi', async (): Promise => { + return new SquareClient(accessToken).getRefundsApi().should.be.instanceOf(RefundsApi); + }); + }); + + describe('#getOrdersApi', (): void => { + it('should return OrdersApi', async (): Promise => { + return new SquareClient(accessToken).getOrdersApi().should.be.instanceOf(OrdersApi); + }); + }); +}); diff --git a/test/unit/client/square-client.spec.ts b/test/unit/client/square-client.spec.ts new file mode 100644 index 0000000..f59cd23 --- /dev/null +++ b/test/unit/client/square-client.spec.ts @@ -0,0 +1,190 @@ +import { + ApiClient, + ApplePayApi, + CatalogApi, + CheckoutApi, + CustomersApi, + EmployeesApi, + InventoryApi, + LaborApi, + LocationsApi, + MobileAuthorizationApi, + OAuthApi, + OrdersApi, + PaymentsApi, + RefundsApi, + TransactionsApi, +} from 'square-connect'; +import { SquareClient } from '../../../src/client'; +import { ISquareClientConfig } from '../../../src/interface'; +import { exponentialDelay } from '../../../src/utils'; + +describe('SquareClient (unit)', (): void => { + const accessToken: string = 'test'; + const basePath: string = `${process.env.SQUARE_SANDBOX_BASE_URL}`; + + const config: ISquareClientConfig = { + retry: { + maxRetries: 1, + retryDelay: exponentialDelay, + }, + originClient: { + timeout: 1000, + cache: false, + enableCookies: false, + basePath, + }, + logger: console, + }; + + describe('#constructor', (): void => { + it('should be init with accessToken only (default config)', async (): Promise => { + return new SquareClient(accessToken).should.be.instanceOf(SquareClient); + }); + + it('should be init with accessToken and config', async (): Promise => { + return new SquareClient(accessToken, config).should.be.instanceOf(SquareClient); + }); + }); + + describe('#generateIdempotencyKey', (): void => { + it('should return string', async (): Promise => { + const reference: string = 'test'; + return SquareClient.generateIdempotencyKey(reference) + .should.be.match(new RegExp(`^${reference}-.*`)) + .and.lengthOf(18); + }); + }); + + describe('#getConfig', (): void => { + it('should return default configuration', async (): Promise => { + return new SquareClient(accessToken).getConfig().should.be.deep.eq({ + retry: { + maxRetries: 6, + retryDelay: exponentialDelay, + }, + originClient: { + timeout: 15000, + }, + logger: undefined, + }); + }); + + it('should return custom configuration', async (): Promise => { + return new SquareClient(accessToken, config).getConfig().should.be.deep.eq(config); + }); + }); + + describe('#getOriginApiClient', (): void => { + it('should return ApiClient with default configuration', async (): Promise => { + const apiClient: ApiClient = new SquareClient(accessToken).getOriginApiClient(); + + apiClient.should.be.instanceOf(ApiClient); + apiClient.should.have.property('timeout', 15000); + apiClient.should.have.property('cache', true); + apiClient.should.have.property('enableCookies', false); + apiClient.should.have.property('defaultHeaders').and.should.be.a('object'); + return apiClient.should.and.have.property('basePath', 'https://connect.squareup.com'); + }); + + it('should return ApiClient with custom configuration', async (): Promise => { + const apiClient: ApiClient = new SquareClient(accessToken, config).getOriginApiClient(); + + apiClient.should.be.instanceOf(ApiClient); + apiClient.should.have.property('timeout', config.originClient?.timeout); + apiClient.should.have.property('cache', config.originClient?.cache); + apiClient.should.have.property('enableCookies', config.originClient?.enableCookies); + apiClient.should.have.property('defaultHeaders').and.should.be.a('object'); + return apiClient.should.and.have.property('basePath', config.originClient?.basePath); + }); + + it('should return the same object on second call', async (): Promise => { + const squareClient: SquareClient = new SquareClient(accessToken); + return squareClient.getOriginApiClient().should.be.deep.eq(squareClient.getOriginApiClient()); + }); + }); + + describe('#getApplePayApi', (): void => { + it('should return ApplePayApi', async (): Promise => { + return new SquareClient(accessToken).getApplePayApi().should.be.instanceOf(ApplePayApi); + }); + }); + + describe('#getCatalogApi', (): void => { + it('should return CatalogApi', async (): Promise => { + return new SquareClient(accessToken).getCatalogApi().should.be.instanceOf(CatalogApi); + }); + }); + + describe('#getCheckoutApi', (): void => { + it('should return CheckoutApi', async (): Promise => { + return new SquareClient(accessToken).getCheckoutApi().should.be.instanceOf(CheckoutApi); + }); + }); + + describe('#getCustomersApi', (): void => { + it('should return CustomersApi', async (): Promise => { + return new SquareClient(accessToken).getCustomersApi().should.be.instanceOf(CustomersApi); + }); + }); + + describe('#getEmployeesApi', (): void => { + it('should return EmployeesApi', async (): Promise => { + return new SquareClient(accessToken).getEmployeesApi().should.be.instanceOf(EmployeesApi); + }); + }); + + describe('#getInventoryApi', (): void => { + it('should return InventoryApi', async (): Promise => { + return new SquareClient(accessToken).getInventoryApi().should.be.instanceOf(InventoryApi); + }); + }); + + describe('#getLaborApi', (): void => { + it('should return LaborApi', async (): Promise => { + return new SquareClient(accessToken).getLaborApi().should.be.instanceOf(LaborApi); + }); + }); + + describe('#getLocationsApi', (): void => { + it('should return LocationsApi', async (): Promise => { + return new SquareClient(accessToken).getLocationsApi().should.be.instanceOf(LocationsApi); + }); + }); + + describe('#getMobileAuthorizationApi', (): void => { + it('should return MobileAuthorizationApi', async (): Promise => { + return new SquareClient(accessToken).getMobileAuthorizationApi().should.be.instanceOf(MobileAuthorizationApi); + }); + }); + + describe('#getOAuthApi', (): void => { + it('should return OAuthApi', async (): Promise => { + return new SquareClient(accessToken).getOAuthApi().should.be.instanceOf(OAuthApi); + }); + }); + + describe('#getOrdersApi', (): void => { + it('should return OrdersApi', async (): Promise => { + return new SquareClient(accessToken).getOrdersApi().should.be.instanceOf(OrdersApi); + }); + }); + + describe('#getPaymentsApi', (): void => { + it('should return PaymentsApi', async (): Promise => { + return new SquareClient(accessToken).getPaymentsApi().should.be.instanceOf(PaymentsApi); + }); + }); + + describe('#getRefundsApi', (): void => { + it('should return RefundsApi', async (): Promise => { + return new SquareClient(accessToken).getRefundsApi().should.be.instanceOf(RefundsApi); + }); + }); + + describe('#getTransactionsApi', (): void => { + it('should return TransactionsApi', async (): Promise => { + return new SquareClient(accessToken).getTransactionsApi().should.be.instanceOf(TransactionsApi); + }); + }); +}); diff --git a/test/unit/exception/square-exception.spec.ts b/test/unit/exception/square-exception.spec.ts new file mode 100644 index 0000000..c87088f --- /dev/null +++ b/test/unit/exception/square-exception.spec.ts @@ -0,0 +1,195 @@ +import { SquareException } from '../../../src/exception'; + +class SuperAgentError extends Error { + constructor(message: string, readonly response?: any, readonly code?: string) { + super(message); + } +} + +describe('SquareException (unit)', (): void => { + describe('#constructor', (): void => { + it('should be ok without args', async (): Promise => { + const squareException: SquareException = new SquareException(); + + squareException.should.be.instanceOf(SquareException); + squareException.should.have.property('message', 'Square API error'); + squareException.should.have.property('retries', 0); + squareException.should.have.property('statusCode', 500); + return squareException.should.have.property('apiError').and.eql({ category: 'API_ERROR', code: 'SERVICE_UNAVAILABLE', detail: 'Square API error' }); + }); + + it('should be ok with empty data.originError object', async (): Promise => { + // @ts-ignore + const squareException: SquareException = new SquareException({ apiError: {} }); + + squareException.should.be.instanceOf(SquareException); + squareException.should.have.property('message', 'Square API error'); + squareException.should.have.property('retries', 0); + squareException.should.have.property('statusCode', 500); + return squareException.should.have.property('apiError').and.eql({}); + }); + + it('should be ok with empty data.originError object and originError', async (): Promise => { + // @ts-ignore + const squareException: SquareException = new SquareException({ apiError: {} }, new Error('error message')); + + squareException.should.be.instanceOf(SquareException); + squareException.should.have.property('message', 'error message'); + squareException.should.have.property('retries', 0); + squareException.should.have.property('statusCode', 500); + return squareException.should.have.property('apiError').and.eql({}); + }); + }); + + describe('#createFromSuperAgentError', (): void => { + it('should be ok with Error', async (): Promise => { + const error: Error = new Error('test message'); + const squareException: SquareException = SquareException.createFromSuperAgentError(error, 0); + + squareException.should.be.instanceOf(SquareException); + squareException.should.have.property('message', 'Square API error'); + squareException.should.have.property('retries', 0); + squareException.should.have.property('statusCode', 500); + return squareException.should.have.property('apiError').and.eql({ category: 'API_ERROR', code: 'SERVICE_UNAVAILABLE', detail: 'Square API error' }); + }); + + it('should be ok with Error with ECONNABORTED error code', async (): Promise => { + const error: SuperAgentError = new SuperAgentError('test message', undefined, 'ECONNABORTED'); + + const squareException: SquareException = SquareException.createFromSuperAgentError(error, 1); + + squareException.should.be.instanceOf(SquareException); + squareException.should.have.property('message', 'Square API timeout'); + squareException.should.have.property('retries', 1); + squareException.should.have.property('statusCode', 500); + squareException.should.have.property('url', undefined); + squareException.should.have.property('method', undefined); + return squareException.should.have.property('apiError').and.eql({ + category: 'API_ERROR', + code: 'GATEWAY_TIMEOUT', + detail: 'Square API timeout', + }); + }); + + it('should be ok with Error with empty response object', async (): Promise => { + const error: SuperAgentError = new SuperAgentError('test message', {}); + + const squareException: SquareException = SquareException.createFromSuperAgentError(error, 1); + + squareException.should.be.instanceOf(SquareException); + squareException.should.have.property('message', 'Square API error'); + squareException.should.have.property('retries', 1); + squareException.should.have.property('statusCode', 500); + squareException.should.have.property('url', undefined); + squareException.should.have.property('method', undefined); + return squareException.should.have.property('apiError').and.eql({ + category: 'API_ERROR', + code: 'SERVICE_UNAVAILABLE', + detail: 'Square API error', + }); + }); + + it('should be ok with Error with response status', async (): Promise => { + const error: SuperAgentError = new SuperAgentError('test message', { + status: 429, + }); + + const squareException: SquareException = SquareException.createFromSuperAgentError(error, 1); + + squareException.should.be.instanceOf(SquareException); + squareException.should.have.property('message', 'Square API error'); + squareException.should.have.property('retries', 1); + squareException.should.have.property('statusCode', 429); + squareException.should.have.property('url', undefined); + squareException.should.have.property('method', undefined); + return squareException.should.have.property('apiError').and.eql({ + category: 'API_ERROR', + code: 'SERVICE_UNAVAILABLE', + detail: 'Square API error', + }); + }); + + it('should be ok with Error with response body', async (): Promise => { + const error: SuperAgentError = new SuperAgentError('test message', { + status: 429, + body: {}, + }); + + const squareException: SquareException = SquareException.createFromSuperAgentError(error, 1); + + squareException.should.be.instanceOf(SquareException); + squareException.should.have.property('message', 'Square API error'); + squareException.should.have.property('retries', 1); + squareException.should.have.property('statusCode', 429); + squareException.should.have.property('url', undefined); + squareException.should.have.property('method', undefined); + return squareException.should.have.property('apiError').and.eql({ + category: 'API_ERROR', + code: 'SERVICE_UNAVAILABLE', + detail: 'Square API error', + }); + }); + + it('should be ok with Error with response body.errors', async (): Promise => { + const error: SuperAgentError = new SuperAgentError('test message', { + status: 429, + body: { + errors: [ + { + category: 'RATE_LIMIT_ERROR', + code: 'RATE_LIMITED', + detail: 'fake 429 error', + }, + ], + }, + }); + + const squareException: SquareException = SquareException.createFromSuperAgentError(error, 1); + + squareException.should.be.instanceOf(SquareException); + squareException.should.have.property('message', 'fake 429 error'); + squareException.should.have.property('retries', 1); + squareException.should.have.property('statusCode', 429); + squareException.should.have.property('url', undefined); + squareException.should.have.property('method', undefined); + return squareException.should.have.property('apiError').and.eql({ + category: 'RATE_LIMIT_ERROR', + code: 'RATE_LIMITED', + detail: 'fake 429 error', + }); + }); + + it('should be ok with Error + response and request properties ', async (): Promise => { + const error: SuperAgentError = new SuperAgentError('test message', { + status: 429, + body: { + errors: [ + { + category: 'RATE_LIMIT_ERROR', + code: 'RATE_LIMITED', + detail: 'fake 429 error', + }, + ], + }, + request: { + url: 'url', + method: 'get', + }, + }); + + const squareException: SquareException = SquareException.createFromSuperAgentError(error, 1); + + squareException.should.be.instanceOf(SquareException); + squareException.should.have.property('message', 'fake 429 error'); + squareException.should.have.property('retries', 1); + squareException.should.have.property('statusCode', 429); + squareException.should.have.property('url', 'url'); + squareException.should.have.property('method', 'GET'); + return squareException.should.have.property('apiError').and.eql({ + category: 'RATE_LIMIT_ERROR', + code: 'RATE_LIMITED', + detail: 'fake 429 error', + }); + }); + }); +}); diff --git a/test/unit/index.spec.ts b/test/unit/index.spec.ts new file mode 100644 index 0000000..6faa584 --- /dev/null +++ b/test/unit/index.spec.ts @@ -0,0 +1 @@ +export * from '../../src'; diff --git a/test/unit/logger/null-logger.spec.ts b/test/unit/logger/null-logger.spec.ts new file mode 100644 index 0000000..2ac01b2 --- /dev/null +++ b/test/unit/logger/null-logger.spec.ts @@ -0,0 +1,77 @@ +import { ILogger, NullLogger } from '../../../src/logger'; + +describe('NullLogger (unit)', (): void => { + describe('#constructor', (): void => { + it('should be init without args', async (): Promise => { + return new NullLogger().should.be.instanceOf(NullLogger); + }); + }); + + describe('#debug', (): void => { + it('should have debug method', async (): Promise => { + const logger: ILogger = new NullLogger(); + return logger.debug.should.be.a('function'); + }); + + it('should be ok', async (): Promise => { + const logger: ILogger = new NullLogger(); + try { + logger.debug('test'); + return true.should.be.true; + } catch (e) { + return true.should.be.false; + } + }); + }); + + describe('#info', (): void => { + it('should have debug method', async (): Promise => { + const logger: ILogger = new NullLogger(); + return logger.info.should.be.a('function'); + }); + + it('should be ok', async (): Promise => { + const logger: ILogger = new NullLogger(); + try { + logger.info('test'); + return true.should.be.true; + } catch (e) { + return true.should.be.false; + } + }); + }); + + describe('#warn', (): void => { + it('should have warn method', async (): Promise => { + const logger: ILogger = new NullLogger(); + return logger.warn.should.be.a('function'); + }); + + it('should be ok', async (): Promise => { + const logger: ILogger = new NullLogger(); + try { + logger.warn('test'); + return true.should.be.true; + } catch (e) { + return true.should.be.false; + } + }); + }); + + describe('#error', (): void => { + it('should have error method', async (): Promise => { + const logger: ILogger = new NullLogger(); + return logger.error.should.be.a('function'); + }); + + it('should be ok', async (): Promise => { + const logger: ILogger = new NullLogger(); + try { + logger.error('test'); + return true.should.be.true; + } catch (e) { + return true.should.be.false; + } + }); + }); +}); diff --git a/test/unit/utils/common.utils.spec.ts b/test/unit/utils/common.utils.spec.ts new file mode 100644 index 0000000..8bfdedb --- /dev/null +++ b/test/unit/utils/common.utils.spec.ts @@ -0,0 +1,38 @@ +import { mergeDeepProps } from '../../../src/utils'; + +describe('common.utils (unit)', (): void => { + describe('#mergeDeepProps', (): void => { + it('should correctly merge simple object with undefined', async (): Promise => { + return mergeDeepProps({ a: 1 }, undefined as any).should.be.deep.eq({ a: 1 }); + }); + + it('should correctly merge class props with undefined', async (): Promise => { + const error: Error = new Error(); + return mergeDeepProps(error, undefined as any) + .should.be.instanceOf(Error) + .and.deep.eq(error) + .and.have.property('name', 'Error'); + }); + + it('should correctly merge 2 empty objects', async (): Promise => { + return mergeDeepProps({}, {}).should.be.deep.eq({}); + }); + + it('should correctly merge 2 simple object props', async (): Promise => { + return mergeDeepProps({ a: 1, b: 1, c: 1 }, { b: 2, c: 2, e: 2 }).should.be.deep.eq({ a: 1, b: 2, c: 2, e: 2 }); + }); + + it('should correctly merge 3 simple objects props', async (): Promise => { + return mergeDeepProps({ a: 1, b: 1, c: 1 }, { b: 2, c: 2, e: 2 }, { b: 3, c: 3, d: 3 }).should.be.deep.eq({ a: 1, b: 3, c: 3, e: 2, d: 3 }); + }); + + it('should correctly merge 2 complex object props', async (): Promise => { + return mergeDeepProps({ a: { b: 1 }, c: 1, d: 1 }, { a: { b: 2 }, c: 2, e: 2 }).should.be.deep.eq({ a: { b: 2 }, c: 2, d: 1, e: 2 }); + }); + + it('should correctly merge class props with simple object', async (): Promise => { + const error: Error = new Error(); + return mergeDeepProps(error, { name: 'x' }).should.be.instanceOf(Error).and.deep.eq(error).and.have.property('name', 'x'); + }); + }); +}); diff --git a/test/unit/utils/retry.utils.spec.ts b/test/unit/utils/retry.utils.spec.ts new file mode 100644 index 0000000..b1596d8 --- /dev/null +++ b/test/unit/utils/retry.utils.spec.ts @@ -0,0 +1,38 @@ +import { exponentialDelay, isRetryableException, makeRetryable } from '../../../src/utils'; + +describe('retry.utils (unit)', (): void => { + describe('#exponentialDelay', (): void => { + it('should return >= 200 and <= 250', async (): Promise => { + return exponentialDelay(1).should.be.gte(200).and.lte(240); + }); + + it('should return >= 400 and <= 500', async (): Promise => { + return exponentialDelay(2).should.be.gte(400).and.lte(480); + }); + + it('should return >= 800 and <= 1200', async (): Promise => { + return exponentialDelay(3).should.be.gte(800).and.lte(1000); + }); + }); + + describe('#makeRetryable', (): void => { + it('should throw SquareException', async (): Promise => { + return makeRetryable( + async (): Promise => { + throw new Error('test error'); + }, + { + maxRetries: 1, + retryDelay: exponentialDelay, + retryCondition: async (_error: Error, maxRetries: number, retries: number): Promise => maxRetries > retries, + }, + ).should.be.rejectedWith(Error, 'Square API error'); + }); + }); + + describe('#isRetryableException', (): void => { + it('should return false if Error is not instanceof SquareException', async (): Promise => { + return isRetryableException(new Error()).should.be.eq(false); + }); + }); +}); diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..2653248 --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": true, + "sourceMap": true, + "incremental": false + }, + "include": ["src"], + "exclude": ["src/test.ts"] +} diff --git a/tsconfig.eslint.json b/tsconfig.eslint.json new file mode 100644 index 0000000..3004417 --- /dev/null +++ b/tsconfig.eslint.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "include": ["src/**/*", "test/**/*", "bin/**/*", "*.json"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..89fdc51 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "module": "commonjs", + "esModuleInterop": true, + "target": "es6", + "strict": true, + "strictPropertyInitialization": false, + "strictNullChecks": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "skipLibCheck": false, + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "moduleResolution": "node", + "resolveJsonModule": true, + "sourceMap": false, + "declaration": false, + "noImplicitAny": false, + "removeComments": false, + "noLib": false, + "allowSyntheticDefaultImports": true, + "outDir": "dist", + "baseUrl": "src", + "lib": ["es2017"] + }, + "include": ["src", "test"] +}