diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..056d16e --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +# These variables are used for integration tests # +################################################## +SQUARE_BASE_URL=https://connect.squareup.com +SQUARE_SANDBOX_BASE_URL=https://connect.squareupsandbox.com +# https://developer.squareup.com/docs/oauth-api/overview +SQUARE_ACCESS_TOKEN=EAAAE +########################### diff --git a/.github/ISSUE_TEMPLATE/1-bug-report.md b/.github/ISSUE_TEMPLATE/1-bug-report.md new file mode 100644 index 0000000..0b1fdf6 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/1-bug-report.md @@ -0,0 +1,44 @@ +--- +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/1-bug-report.md.REMOVED.git-id b/.github/ISSUE_TEMPLATE/1-bug-report.md.REMOVED.git-id deleted file mode 100644 index 7408299..0000000 --- a/.github/ISSUE_TEMPLATE/1-bug-report.md.REMOVED.git-id +++ /dev/null @@ -1 +0,0 @@ -0b1fdf6841d761dcc84051c549d113e6252c6618 \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d43f973 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,68 @@ +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@v2 + + build: + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [ 14.x, 16.x ] + + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + steps: + - name: Git Checkout + uses: actions/checkout@v2 + with: + fetch-depth: 1 + + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v2 + with: + node-version: ${{ matrix.node-version }} + + - run: node --version + - run: npm --version + + - name: Install npm dependencies + run: npm install --progress=false --loglevel=warn --ignore-scripts + + - name: Lint code + run: npm run lint + + - name: Check publish sdk + run: npm run publish:dev:dry + + - name: Type check + run: npm run typecheck + + - name: Run tests and covarage + run: npm run coverage:ci + 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, '14.') + with: + github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/ci.yml.REMOVED.git-id b/.github/workflows/ci.yml.REMOVED.git-id deleted file mode 100644 index cc36c91..0000000 --- a/.github/workflows/ci.yml.REMOVED.git-id +++ /dev/null @@ -1 +0,0 @@ -d43f973ebc84f24c5c00070164f6d0fdc9bf25db \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..6f291d2 --- /dev/null +++ b/README.md @@ -0,0 +1,117 @@ +[![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 Node.js SDK 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@17.0.0 + +## Usage + +### Simple example + +```typescript +import { SquareClient } from '@goparrot/square-connect-plus'; +import { ListLocationsResponse } from 'square'; + +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.length) { + 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, + }, + configuration: { + timeout: 60_000, + }, + 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 | +| ----------------- | -------- | ------------------------------ | ------------------------------------------------------------------------- | +| customUrl | `String` | `https://connect.squareup.com` | The custom URL against which to resolve every API call's (relative) path. | +| additionalHeaders | `Object` | `{}` | Record | +| timeout | `Number` | `60_000` | The default HTTP timeout for all API calls. | +| squareVersion | `String` | `2021-12-15` | The default square api version for all API calls. | +| environment | `Enum` | `Environment.Production` | The default square enviroment for all API calls. | +| accessToken | `String` | `''` | Scoped access token. | + +### `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/README.md.REMOVED.git-id b/README.md.REMOVED.git-id deleted file mode 100644 index 4d4e40a..0000000 --- a/README.md.REMOVED.git-id +++ /dev/null @@ -1 +0,0 @@ -6f291d2352889febc1ec9f87307fd7f79c5522b8 \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..31f1078 --- /dev/null +++ b/package.json @@ -0,0 +1,122 @@ +{ + "name": "@goparrot/square-connect-plus", + "version": "1.7.0", + "private": false, + "description": "Extends the official Square Node.js SDK library with additional functionality", + "keywords": [ + "node", + "typescript", + "square", + "retry" + ], + "bugs": { + "url": "https://github.com/goparrot/square-connect-plus/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/goparrot/square-connect-plus.git" + }, + "license": "MIT", + "author": "Coroliov Oleg", + "main": "src/index.ts", + "scripts": { + "commit": "git-cz", + "prepare": "husky install", + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "mocha --config=.mocharc.json 'test/**/*spec.ts'", + "test:unit": "mocha --config=.mocharc.json 'test/unit/**/*.spec.ts'", + "test:e2e": "mocha 'test/e2e/**/*.spec.ts'", + "test:ci": "mocha --config=.mocharc.json 'test/**/*.spec.ts' 'test/e2e/**/*.spec.ts'", + "test:integration": "mocha --timeout 15000 'test/integration/**/*.spec.ts'", + "coverage": "nyc npm run test", + "coverage:ci": "nyc npm run test:ci -- --reporter mocha-junit-reporter --reporter-options mochaFile=./coverage/junit.xml", + "prettier": "npm run prettier:base -- '**/**.+(md)'", + "prettier:base": "prettier --ignore-path .eslintignore --write", + "pre-commit": "git add . && run-p typecheck format:staged && run-p lint coverage publish:dev:dry", + "format": "npm run prettier && npm run lint -- --fix", + "format:base": "npm run lint:base -- --fix", + "format:staged": "git add . && lint-staged --allow-empty -q", + "lint": "npm run lint:base -- './**/**.{ts,js,json}'", + "lint:base": "npm run lint:config:check && eslint", + "lint:config:check": "eslint-config-prettier src/index.ts", + "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 .", + "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" + }, + "lint-staged": { + "*.{ts,tsx,js,json}": [ + "npm run format:base" + ], + "*.md": [ + "npm run prettier:base" + ] + }, + "config": { + "commitizen": { + "path": "cz-conventional-changelog" + } + }, + "dependencies": { + "lodash.upperfirst": "^4.3.1" + }, + "devDependencies": { + "@commitlint/cli": "^16.2.1", + "@commitlint/config-conventional": "^16.2.1", + "@goparrot/eslint-config": "^1.0.3", + "@types/chai": "^4.3.0", + "@types/chai-as-promised": "^7.1.5", + "@types/lodash.camelcase": "^4.3.6", + "@types/lodash.snakecase": "^4.1.6", + "@types/lodash.upperfirst": "^4.3.7", + "@types/mocha": "^9.1.0", + "@types/node": "^16.11.24", + "@types/sinon": "^10.0.11", + "@types/square-connect": "^4.20201028.1", + "chai": "^4.3.4", + "chai-as-promised": "^7.1.1", + "commitizen": "^4.2.4", + "conventional-github-releaser": "^3.1.5", + "cz-conventional-changelog": "^3.3.0", + "dotenv-safe": "^8.2.0", + "env-cmd": "^10.1.0", + "husky": "^7.0.4", + "lint-staged": "^12.3.4", + "lodash.camelcase": "4.3.0", + "lodash.snakecase": "4.1.1", + "mocha": "^9.2.0", + "mocha-junit-reporter": "^2.0.2", + "nock": "^13.2.4", + "npm-run-all": "^4.1.5", + "nyc": "^15.1.0", + "prettier": "^2.5.1", + "remark-cli": "^10.0.1", + "remark-frontmatter": "^4.0.1", + "remark-github": "^11.2.2", + "remark-lint-emphasis-marker": "^3.1.1", + "remark-lint-strong-marker": "^3.1.1", + "rimraf": "^3.0.2", + "sinon": "^13.0.1", + "square": "^21.1.0", + "standard-version": "^9.3.2", + "ts-node": "^10.5.0", + "typescript": "^4.5.5", + "utility-types": "^3.10.0", + "uuid": "^8.3.2" + }, + "peerDependencies": { + "lodash.camelcase": "^4.3.0", + "lodash.snakecase": "^4.1.1", + "square": ">=21.1.0", + "uuid": "^8.3.2" + }, + "engines": { + "node": ">=14" + } +} diff --git a/package.json.REMOVED.git-id b/package.json.REMOVED.git-id deleted file mode 100644 index 7c058cf..0000000 --- a/package.json.REMOVED.git-id +++ /dev/null @@ -1 +0,0 @@ -31f107824400c354a99eb52883ae7e4597acda2c \ No newline at end of file diff --git a/src/client/SquareClient.ts b/src/client/SquareClient.ts new file mode 100644 index 0000000..84085f4 --- /dev/null +++ b/src/client/SquareClient.ts @@ -0,0 +1,329 @@ +import upperFirst from 'lodash.upperfirst'; +import type { + ApiResponse, + ApplePayApi, + CardsApi, + CatalogApi, + CheckoutApi, + EmployeesApi, + GiftCardActivitiesApi, + GiftCardsApi, + InventoryApi, + InvoicesApi, + LaborApi, + LocationsApi, + LoyaltyApi, + MerchantsApi, + MobileAuthorizationApi, + OAuthApi, + OrdersApi, + PaymentsApi, + RefundsApi, + TransactionsApi, +} from 'square'; +import { Client, DEFAULT_CONFIGURATION } from 'square'; +import { v4 as uuidv4 } from 'uuid'; +import type { FunctionKeys } from 'utility-types'; +import type { BaseApi } from 'square/dist/api/baseApi'; +import { SquareApiException } from '../exception'; +import type { ISquareClientConfig, ISquareClientDefaultConfig, ISquareClientMergedConfig } from '../interface'; +import type { ILogger } from '../logger'; +import { NullLogger } from '../logger'; +import { exponentialDelay, isRetryableSquareApiException, mergeDeepProps, sleep } from '../utils'; +import { CustomerClientApi } from './CustomerClientApi'; + +type ApiName = { + [key in keyof Client]: Client[key] extends BaseApi ? key : never; +}[keyof Client]; + +export class SquareClient { + #client: Client; + readonly #mergedConfig: ISquareClientMergedConfig; + readonly #defaultConfig: ISquareClientDefaultConfig = { + retry: { + maxRetries: 6, + retryDelay: exponentialDelay, + }, + configuration: DEFAULT_CONFIGURATION, + logContext: { + merchantId: 'unknown', + }, + }; + + constructor(private readonly accessToken: string, config: ISquareClientConfig = {}) { + const { logger, ...configWithoutLogger } = config; + this.#mergedConfig = mergeDeepProps(this.#defaultConfig, configWithoutLogger); + this.#mergedConfig.logger = logger; + } + + /** + * Generate unique idempotency key (format: first char reference-uuid) + * @link https://developer.squareup.com/docs/working-with-apis/idempotency + * Max length 45 + * @return uuidv4 + */ + static generateIdempotencyKey(): string { + return uuidv4(); + } + + getConfig(): ISquareClientMergedConfig { + return this.#mergedConfig; + } + + getOriginClient(): Client { + this.#client = this.#client ?? this.createOriginClient(this.accessToken, this.#mergedConfig); + + return this.#client; + } + + getApplePayApi(retryableMethods: FunctionKeys[] = []): ApplePayApi { + return this.proxy('applePayApi', retryableMethods); + } + + getCatalogApi( + retryableMethods: FunctionKeys[] = [ + 'batchRetrieveCatalogObjects', + 'catalogInfo', + 'listCatalog', + 'retrieveCatalogObject', + 'searchCatalogObjects', + ], + ): CatalogApi { + return this.proxy('catalogApi', retryableMethods); + } + + getCheckoutApi(retryableMethods: FunctionKeys[] = []): CheckoutApi { + return this.proxy('checkoutApi', retryableMethods); + } + + getCustomersApi( + retryableMethods: FunctionKeys[] = ['listCustomers', 'retrieveCustomer', 'searchCustomers', 'deleteCustomerCard'], + ): CustomerClientApi { + return this.proxyWithInstance('customersApi', new CustomerClientApi(this.getOriginClient()), retryableMethods); + } + + getEmployeesApi(retryableMethods: FunctionKeys[] = ['listEmployees', 'retrieveEmployee']): EmployeesApi { + return this.proxy('employeesApi', retryableMethods); + } + + getLoyaltyApi( + retryableMethods: FunctionKeys[] = [ + 'listLoyaltyPrograms', + 'searchLoyaltyEvents', + 'searchLoyaltyAccounts', + 'retrieveLoyaltyAccount', + 'retrieveLoyaltyProgram', + ], + ): LoyaltyApi { + return this.proxy('loyaltyApi', retryableMethods); + } + + getInventoryApi( + retryableMethods: FunctionKeys[] = [ + 'batchRetrieveInventoryChanges', + 'batchRetrieveInventoryCounts', + 'retrieveInventoryAdjustment', + 'retrieveInventoryChanges', + 'retrieveInventoryCount', + 'retrieveInventoryPhysicalCount', + ], + ): InventoryApi { + return this.proxy('inventoryApi', retryableMethods); + } + + getLaborApi( + retryableMethods: FunctionKeys[] = [ + 'getBreakType', + 'getEmployeeWage', + 'getShift', + 'listBreakTypes', + 'listEmployeeWages', + 'listWorkweekConfigs', + 'searchShifts', + ], + ): LaborApi { + return this.proxy('laborApi', retryableMethods); + } + + getLocationsApi(retryableMethods: FunctionKeys[] = ['listLocations']): LocationsApi { + return this.proxy('locationsApi', retryableMethods); + } + + getMerchantsApi(retryableMethods: FunctionKeys[] = ['retrieveMerchant', 'listMerchants']): MerchantsApi { + return this.proxy('merchantsApi', retryableMethods); + } + + getMobileAuthorizationApi(retryableMethods: FunctionKeys[] = []): MobileAuthorizationApi { + return this.proxy('mobileAuthorizationApi', retryableMethods); + } + + getOAuthApi(retryableMethods: FunctionKeys[] = ['obtainToken']): OAuthApi { + return this.proxy('oAuthApi', retryableMethods); + } + + getOrdersApi( + retryableMethods: FunctionKeys[] = ['batchRetrieveOrders', 'searchOrders', 'createOrder', 'payOrder', 'calculateOrder'], + ): OrdersApi { + return this.proxy('ordersApi', retryableMethods); + } + + getPaymentsApi(retryableMethods: FunctionKeys[] = ['getPayment', 'listPayments', 'createPayment', 'cancelPayment']): PaymentsApi { + return this.proxy('paymentsApi', retryableMethods); + } + + getGiftCardsApi( + retryableMethods: FunctionKeys[] = [ + 'listGiftCards', + 'createGiftCard', + 'retrieveGiftCardFromGAN', + 'retrieveGiftCardFromNonce', + 'linkCustomerToGiftCard', + 'unlinkCustomerFromGiftCard', + 'retrieveGiftCard', + ], + ): GiftCardsApi { + return this.proxy('giftCardsApi', retryableMethods); + } + + getGiftCardActivitiesApi( + retryableMethods: FunctionKeys[] = ['listGiftCardActivities', 'createGiftCardActivity'], + ): GiftCardActivitiesApi { + return this.proxy('giftCardActivitiesApi', retryableMethods); + } + + getRefundsApi(retryableMethods: FunctionKeys[] = ['getPaymentRefund', 'listPaymentRefunds', 'refundPayment']): RefundsApi { + return this.proxy('refundsApi', retryableMethods); + } + + getTransactionsApi(retryableMethods: FunctionKeys[] = ['listTransactions', 'retrieveTransaction']): TransactionsApi { + return this.proxy('transactionsApi', retryableMethods); + } + + getCardsApi(retryableMethods: FunctionKeys[] = ['listCards', 'retrieveCard', 'disableCard']): CardsApi { + return this.proxy('cardsApi', retryableMethods); + } + + getInvoiceApi(retryableMethods: FunctionKeys[] = ['listInvoices', 'searchInvoices', 'getInvoice']): InvoicesApi { + return this.proxy('invoicesApi', retryableMethods); + } + + private createOriginClient(accessToken: string, { configuration }: Partial): Client { + return new Client({ ...configuration, accessToken }); + } + + private getLogger(): ILogger { + return this.#mergedConfig.logger ?? (this.#mergedConfig.logger = new NullLogger()); + } + + /** + * @throws SquareApiException + */ + private proxyWithInstance(apiName: T, api: A, retryableMethods: FunctionKeys[]): A { + const stackError: string = new Error().stack?.slice(6) || ''; + + const handler: ProxyHandler = { + get: (target: A, apiMethodName: string): unknown => { + return async (...args: unknown[]): Promise> => { + const requestFn: (...arg: unknown[]) => Promise> = target[apiMethodName].bind(target, ...args); + + try { + return await this.makeRetryable>(apiName, requestFn, apiMethodName, retryableMethods); + } catch (err) { + if (err instanceof Error) { + err.stack += stackError; + } + + throw err; + } + }; + }, + }; + + return new Proxy(api, handler); + } + + /** + * @throws SquareApiException + */ + protected proxy(apiName: T, retryableMethods: FunctionKeys[]): Client[T] { + const api = this.getOriginClient()[apiName]; + + return this.proxyWithInstance(apiName, api, retryableMethods); + } + + private async makeRetryable( + apiName: ApiName, + promiseFn: (...arg: unknown[]) => Promise, + apiMethodName: string, + retryableMethods: (string | number | symbol)[], + ): Promise { + let retries: number = 0; + const { maxRetries, retryCondition = this.retryCondition } = this.#mergedConfig.retry; + const { logContext } = this.#mergedConfig; + const logger = this.getLogger(); + + async function retry(): Promise { + const startedAt = Date.now(); + try { + return await promiseFn(); + } catch (error) { + const finishedAt = Date.now(); + const execTime = finishedAt - startedAt; + const squareException: SquareApiException = new SquareApiException(error, retries, execTime); + + const logMeta = { + ...logContext, + apiName: upperFirst(apiName), + apiMethodName, + startedAt, + finishedAt, + execTime, + retries, + maxRetries, + exception: squareException.toObject(), + }; + + if ([429].includes(squareException.statusCode) || squareException.statusCode >= 500) { + logger.warn('Square api error', logMeta); + } + + if (retryableMethods.includes(apiMethodName) && (await retryCondition(squareException, maxRetries, retries))) { + logger.info('Square api retry', logMeta); + + retries++; + const delay: number = exponentialDelay(retries); + await sleep(delay); + + return retry(); + } + + throw squareException; + } finally { + const finishedAt = Date.now(); + const execTime = finishedAt - startedAt; + logger.info(`Square api request: ${apiMethodName} executed in ${execTime}ms`, { + ...logContext, + apiName: upperFirst(apiName), + apiMethodName, + startedAt, + finishedAt, + execTime, + }); + } + } + + return retry(); + } + + private retryCondition: (error: SquareApiException, maxRetries: number, retries: number) => Promise = async ( + error: SquareApiException, + maxRetries: number, + retries: number, + ) => { + if (isRetryableSquareApiException(error) && maxRetries > retries) { + return true; + } + + throw error; + }; +} diff --git a/src/client/SquareClient.ts.REMOVED.git-id b/src/client/SquareClient.ts.REMOVED.git-id deleted file mode 100644 index c84c9b8..0000000 --- a/src/client/SquareClient.ts.REMOVED.git-id +++ /dev/null @@ -1 +0,0 @@ -84085f49b7dc099a0d2f18b1d0a251227855336b \ No newline at end of file diff --git a/src/exception/SquareApiException.ts b/src/exception/SquareApiException.ts new file mode 100644 index 0000000..29e0c90 --- /dev/null +++ b/src/exception/SquareApiException.ts @@ -0,0 +1,60 @@ +import type { Error as SquareError } from 'square'; +import { ApiError } from 'square'; +import type { ISquareApiException } from './ISquareApiException'; + +export class SquareApiException extends Error implements ISquareApiException { + readonly statusCode: number = 500; + readonly retries: number = 0; + readonly errors: SquareError[] = []; + readonly apiError?: ApiError; + readonly url?: string; + readonly method?: string; + readonly responseTime?: number; + + constructor(error: unknown, retries: number = 0, responseTime?: number) { + super(); + this.message = 'Square API error'; + this.name = this.constructor.name; + this.retries = retries ?? this.retries; + + if (responseTime) { + this.responseTime = responseTime; + } + + /* If timeout < 800ms square api doesn't throw ApiError, but Error */ + if (error instanceof Error) { + this.method = error['config']?.method; + this.url = error['config']?.url; + this.message = error.message; + } + + if (error instanceof ApiError) { + delete error.request.headers?.['authorization']; + + this.apiError = error; + this.errors = error.errors ?? this.errors; + this.message = this.errors[0]?.detail || this.errors[0]?.code || this.message; + this.url = error.request.url; + this.method = error.request.method; + this.statusCode = error.statusCode; + } + } + + toObject(): ISquareApiException { + return { + name: this.name, + message: this.message, + retries: this.retries, + url: this.url, + method: this.method, + statusCode: this.statusCode, + errors: this.errors, + apiError: this.apiError, + responseTime: this.responseTime, + }; + } + + toString(): string { + return JSON.stringify(this.toObject()); + } +} diff --git a/src/exception/SquareApiException.ts.REMOVED.git-id b/src/exception/SquareApiException.ts.REMOVED.git-id deleted file mode 100644 index 9945054..0000000 --- a/src/exception/SquareApiException.ts.REMOVED.git-id +++ /dev/null @@ -1 +0,0 @@ -29e0c90e5f3c5cb6271ee1443fda08c54197c12f \ No newline at end of file diff --git a/src/utils/SquareDataMapper.ts b/src/utils/SquareDataMapper.ts new file mode 100644 index 0000000..dee0978 --- /dev/null +++ b/src/utils/SquareDataMapper.ts @@ -0,0 +1,65 @@ +import snakeCase from 'lodash.snakecase'; +import camelCase from 'lodash.camelcase'; +import type { ObjectLikeType } from '../interface'; +import { isObject } from './common.utils'; +import { recursiveBigIntToNumber, recursiveNumberToBigInt } from './helper'; + +export type IdempotencyFree = Omit; + +export class SquareDataMapper { + /** + * + * @param data + * @param recursiveReplaceBigInToNumber @default true + * @returns {T} + */ + static toOldFormat = (data: ObjectLikeType, recursiveReplaceBigInToNumber: boolean = true): T => { + const object: T = SquareDataMapper.convert(data, snakeCase); + + if (recursiveReplaceBigInToNumber) { + return recursiveBigIntToNumber(object) as T; + } + + return object; + }; + + /** + * @param recursiveReplaceNumberToBigInt @default false + * @returns {T} + */ + static toNewFormat = (data: ObjectLikeType, recursiveReplaceNumberToBigInt: boolean = true): T => { + const object: T = SquareDataMapper.convert(data, camelCase); + + if (recursiveReplaceNumberToBigInt) { + return recursiveNumberToBigInt(object) as T; + } + + return object; + }; + + static idempotencyFree = (data: T): IdempotencyFree => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { idempotency_key, idempotencyKey, ...dataWithoutIdempotenceKey } = data; + + return dataWithoutIdempotenceKey; + }; + + protected static convert = (data: T, transformer: (value: string) => string): O => { + const isUntouchable = (value: T): boolean => value instanceof Date; + const convertObject = (value: T): O => SquareDataMapper.convert(value, transformer); + const convertArray = (value: T[]): O[] => + value.map((val) => { + return SquareDataMapper.convert(val, transformer); + }); + const transformableObject = (value: T): boolean => isObject(value) && !isUntouchable(value); + + return Object.fromEntries( + // @ts-expect-error + Object.entries(data).map(([key, value]) => { + return Array.isArray(value) + ? [transformer(key), convertArray(value)] + : [transformer(key), transformableObject(value) ? convertObject(value) : value]; + }), + ) as O; + }; +} diff --git a/src/utils/SquareDataMapper.ts.REMOVED.git-id b/src/utils/SquareDataMapper.ts.REMOVED.git-id deleted file mode 100644 index ec902e4..0000000 --- a/src/utils/SquareDataMapper.ts.REMOVED.git-id +++ /dev/null @@ -1 +0,0 @@ -dee09786717e80b7a182656df2f2a583dcda8955 \ No newline at end of file diff --git a/src/utils/helper.ts b/src/utils/helper.ts new file mode 100644 index 0000000..3d24dd0 --- /dev/null +++ b/src/utils/helper.ts @@ -0,0 +1,60 @@ +import type { ObjectLikeType } from '../interface'; + +export type ReturnRecursiveBigIntToNumberType = T extends bigint + ? number + : T extends ReadonlyArray | ArrayLike | ObjectLikeType + ? { [key in keyof T]: ReturnRecursiveBigIntToNumberType } + : T; + +export function recursiveBigIntToNumber(body: T): ReturnRecursiveBigIntToNumberType { + if (body === undefined || body === null || !['object', 'bigint'].includes(typeof body)) { + return body as ReturnRecursiveBigIntToNumberType; + } + + if (typeof body === 'bigint') { + return Number(body) as ReturnRecursiveBigIntToNumberType; + } + + for (const [key, value] of Object.entries(body)) { + if (typeof value === 'bigint') { + body[key] = Number(value); + continue; + } + + if (typeof value === 'object') { + body[key] = recursiveBigIntToNumber(value); + } + } + + return body as ReturnRecursiveBigIntToNumberType; +} + +export type ReturnRecursiveNumberToBigIntType = T extends number + ? bigint + : T extends ReadonlyArray | ArrayLike | ObjectLikeType + ? { [key in keyof T]: ReturnRecursiveBigIntToNumberType } + : T; + +/* global BigInt */ +export function recursiveNumberToBigInt(body: T): ReturnRecursiveNumberToBigIntType { + if (body === undefined || body === null || !['object', 'number'].includes(typeof body)) { + return body as ReturnRecursiveNumberToBigIntType; + } + + if (typeof body === 'number') { + return BigInt(body) as ReturnRecursiveNumberToBigIntType; + } + + for (const [key, value] of Object.entries(body)) { + if (typeof value === 'number') { + body[key] = BigInt(value); + continue; + } + + if (typeof value === 'object') { + body[key] = recursiveNumberToBigInt(value); + } + } + + return body as ReturnRecursiveNumberToBigIntType; +} diff --git a/src/utils/helper.ts.REMOVED.git-id b/src/utils/helper.ts.REMOVED.git-id deleted file mode 100644 index bad46c7..0000000 --- a/src/utils/helper.ts.REMOVED.git-id +++ /dev/null @@ -1 +0,0 @@ -3d24dd0fc450356cd6b2708d4bb0a721f781441b \ No newline at end of file diff --git a/test/e2e/client/square-client.spec.ts b/test/e2e/client/square-client.spec.ts new file mode 100644 index 0000000..3c0c295 --- /dev/null +++ b/test/e2e/client/square-client.spec.ts @@ -0,0 +1,157 @@ +import { expect } from 'chai'; +import nock, { cleanAll } from 'nock'; +import { DEFAULT_CONFIGURATION, Environment } from 'square'; +import type { ISquareClientConfig } from '../../../src'; +import { SquareClient, SquareApiException, exponentialDelay } from '../../../src'; + +describe('SquareClient (e2e)', (): void => { + const accessToken: string = 'test'; + const customUrl: string = `${process.env.SQUARE_SANDBOX_BASE_URL || ''}`; + + const config: ISquareClientConfig = { + retry: { + maxRetries: 2, + retryDelay: exponentialDelay, + }, + configuration: { + ...DEFAULT_CONFIGURATION, + environment: Environment.Sandbox, + timeout: 100, + customUrl, + }, + }; + + afterEach((): void => { + cleanAll(); + }); + + it('should NOT retry 501 http status', async (): Promise => { + nock(customUrl).get(/.*/).times(1000).reply(501); + + return new SquareClient(accessToken, config) + .getLocationsApi() + .listLocations() + .should.eventually.be.rejectedWith(SquareApiException) + .and.have.property('retries', 0); + }); + + it('should NOT retry 400 http status', async (): Promise => { + nock(customUrl).get(/.*/).times(1000).reply(400); + + return new SquareClient(accessToken, config) + .getLocationsApi() + .listLocations() + .should.eventually.be.rejectedWith(SquareApiException) + .and.have.property('retries', 0); + }); + + it('should retry 429 http status', async (): Promise => { + nock(customUrl) + .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(SquareApiException, 'fake 429 error') + .and.have.property('retries', config.retry?.maxRetries); + }); + + it('should retry 500 http status', async (): Promise => { + nock(customUrl).get(/.*/).times(1000).reply(500); + + return new SquareClient(accessToken, config) + .getLocationsApi() + .listLocations() + .should.eventually.be.rejectedWith(SquareApiException) + .and.have.property('retries', config.retry?.maxRetries); + }); + + it('should retry 503 http status', async (): Promise => { + nock(customUrl).get(/.*/).times(1000).reply(503); + + return new SquareClient(accessToken, config) + .getLocationsApi() + .listLocations() + .should.eventually.be.rejectedWith(SquareApiException) + .and.have.property('retries', config.retry?.maxRetries); + }); + + it('should retry if request timeout', async (): Promise => { + nock(customUrl) + .get(/.*/) + .times(1000) + .delay({ + head: 3000, + }) + .reply(200, 'OK'); + + return new SquareClient(accessToken, config) + .getLocationsApi() + .listLocations() + .should.eventually.be.rejectedWith(SquareApiException, 'timeout of 100ms exceeded') + .and.have.property('retries', config.retry?.maxRetries); + }); + + it('should retry 500 http method POST', async (): Promise => { + nock(customUrl).post(/.*/).times(1000).reply(503); + + return new SquareClient(accessToken, config) + .getCustomersApi() + .searchCustomers({}) + .should.eventually.be.rejectedWith(SquareApiException) + .and.have.property('retries', config.retry?.maxRetries); + }); + + it('should NOT retry 400 http method POST', async (): Promise => { + nock(customUrl).post(/.*/).times(1000).reply(400); + + return new SquareClient(accessToken, config) + .getCustomersApi() + .searchCustomers({}) + .should.eventually.be.rejectedWith(SquareApiException) + .and.have.property('retries', 0); + }); + + it('should NOT retry if in retry config maxRetries is equal with zero', async (): Promise => { + nock(customUrl).post(/.*/).times(1000).reply(400); + + return new SquareClient(accessToken, { ...config, retry: { maxRetries: 0 } }) + .getCustomersApi() + .searchCustomers({}) + .should.eventually.be.rejectedWith(SquareApiException) + .and.have.property('retries', 0); + }); + it('should NOT retry if in retry config retryCondition \n return false', async (): Promise => { + nock(customUrl).post(/.*/).times(1000).reply(400); + + return new SquareClient(accessToken, { ...config, retry: { maxRetries: 0, retryCondition: async (): Promise => false } }) + .getCustomersApi() + .searchCustomers({}) + .should.eventually.be.rejectedWith(SquareApiException) + .and.have.property('retries', 0); + }); + + it('should NOT retry 200', async (): Promise => { + nock(customUrl).post(/.*/, {}).times(DEFAULT_CONFIGURATION.timeout).reply(200, { id: 123 }); + + return new SquareClient(accessToken, { ...config, configuration: { ...config.configuration, timeout: DEFAULT_CONFIGURATION.timeout } }) + .getCustomersApi() + .searchCustomers({}) + .then((response) => { + response.should.have.property('body', JSON.stringify({ id: 123 })); + }) + .catch(() => { + expect(true).equal(false); + }); + }); +}); diff --git a/test/e2e/client/square-client.spec.ts.REMOVED.git-id b/test/e2e/client/square-client.spec.ts.REMOVED.git-id deleted file mode 100644 index 1ed087f..0000000 --- a/test/e2e/client/square-client.spec.ts.REMOVED.git-id +++ /dev/null @@ -1 +0,0 @@ -3c0c295be532f902a7c787c8000c23f36e56c37a \ No newline at end of file diff --git a/test/integration/client/square-client.spec.ts b/test/integration/client/square-client.spec.ts new file mode 100644 index 0000000..5555436 --- /dev/null +++ b/test/integration/client/square-client.spec.ts @@ -0,0 +1,104 @@ +import type { CalculateOrderResponse, Order, SearchCustomersRequest } from 'square'; +import { DEFAULT_CONFIGURATION, Environment } from 'square'; +import type { ISquareClientConfig } from '../../../src'; +import { NullLogger, recursiveBigIntToNumber, SquareApiException, SquareClient, SquareDataMapper } from '../../../src'; + +describe('SquareClient (integration)', (): void => { + const accessToken: string = `${process.env.SQUARE_ACCESS_TOKEN || ''}`; + const customUrl: string = `${process.env.SQUARE_SANDBOX_BASE_URL || ''}`; + + const config: ISquareClientConfig = { + retry: { + maxRetries: 1, + }, + configuration: { + ...DEFAULT_CONFIGURATION, + customUrl, + environment: Environment.Sandbox, + }, + logger: new NullLogger(), + }; + + describe('#getLocationsApi', (): void => { + it('should retry by timeout', async (): Promise => { + return new SquareClient(accessToken, { + ...config, + configuration: { + ...config.configuration, + timeout: 1, + }, + }) + .getLocationsApi() + .listLocations() + .should.eventually.be.rejectedWith(SquareApiException, 'timeout of 1ms exceeded'); + }); + + it('should retrieve data', async (): Promise => { + return new SquareClient(accessToken, config) + .getLocationsApi() + .listLocations() + .should.eventually.be.fulfilled.and.have.property('result') + .and.have.property('locations'); + }); + }); + + describe('#getCustomersApi', (): void => { + it('should be rejected with ModelError', async (): Promise => { + const query: SearchCustomersRequest = SquareDataMapper.toNewFormat({ + limit: 1, + query: { + sort: { + order: 'WRONG_VALUE', + }, + }, + }); + return new SquareClient(accessToken, config) + .getCustomersApi() + .searchCustomers(query) + .should.eventually.be.rejectedWith(Error, /^`WRONG_VALUE` is not a valid enum value for.*/); + }); + }); + + describe('#getLoyaltyApi', (): void => { + it('should retrieve loyalty program', async (): Promise => { + return new SquareClient(accessToken, config) + .getLoyaltyApi() + .retrieveLoyaltyProgram('main_not_found') + .should.eventually.be.rejectedWith(Error, /^Merchant does not have a loyalty program/); + }); + }); + + describe('#getOrdersApi', (): void => { + it('should calculate order total', async (): Promise => { + const apiClient: SquareClient = new SquareClient(accessToken, { configuration: { ...config.configuration, timeout: 60_000 } }); + const { locations } = (await apiClient.getLocationsApi().listLocations()).result; + + if (!locations?.length) { + locations?.should.have.length(0); + return; + } + + const locationId: string = locations[0].id!; + const orderPayload: Order = SquareDataMapper.toNewFormat( + { + locationId: locationId, + lineItems: [ + { + name: 'Coca-cola', + quantity: '1', + basePriceMoney: { + amount: 100, + currency: 'USD', + }, + }, + ], + }, + false, + ); + + const { order }: CalculateOrderResponse = (await apiClient.getOrdersApi().calculateOrder({ order: orderPayload })).result; + + recursiveBigIntToNumber(order!).totalMoney?.amount?.should.eq(100); + }); + }); +}); diff --git a/test/integration/client/square-client.spec.ts.REMOVED.git-id b/test/integration/client/square-client.spec.ts.REMOVED.git-id deleted file mode 100644 index 859e82a..0000000 --- a/test/integration/client/square-client.spec.ts.REMOVED.git-id +++ /dev/null @@ -1 +0,0 @@ -5555436b7cbae8a324ab5b54082321dd99aecf45 \ No newline at end of file diff --git a/test/unit/client/CustomerClientApi.spec.ts b/test/unit/client/CustomerClientApi.spec.ts new file mode 100644 index 0000000..5c9b6c3 --- /dev/null +++ b/test/unit/client/CustomerClientApi.spec.ts @@ -0,0 +1,105 @@ +import { expect } from 'chai'; +import type { SinonSandbox, SinonStub } from 'sinon'; +import { createSandbox } from 'sinon'; +import type { Customer, CustomerFilter, SearchCustomersRequest } from 'square'; +import type { IFindOrCreateCustomerRequest } from '../../../src'; +import { CustomerClientApi, SquareClientFactory } from '../../../src'; + +describe('CustomerClientApi', (): void => { + let customerClientApi: CustomerClientApi; + let sandbox: SinonSandbox; + + const filter: CustomerFilter = { + phoneNumber: { + exact: '+14043833639', + }, + emailAddress: { + exact: 'test_user@mail.com', + }, + }; + + const searchCustomerReq: SearchCustomersRequest = { + query: { + filter, + sort: { + field: 'CREATED_AT', + order: 'DESC', + }, + }, + }; + + const findOrCreateCustomerRequest: IFindOrCreateCustomerRequest = { + phoneNumber: '+14043833639', + emailAddress: 'test_user@mail.com', + }; + + const customer: Customer = { + id: '60DERXYEFN77V6RSHM0ET6VWW0', + createdAt: '2021-02-11T09:50:46.283Z', + updatedAt: '2021-02-12T10:01:19Z', + givenName: 'Test', + familyName: 'User', + phoneNumber: '+14043833639', + note: '', + referenceId: '000082297466356', + preferences: { + emailUnsubscribed: false, + }, + creationSource: 'THIRD_PARTY', + segmentIds: [], + }; + + const customerWithEmail: Customer = { + ...customer, + emailAddress: 'test_user@mail.com', + }; + + before(() => { + sandbox = createSandbox(); + + customerClientApi = new CustomerClientApi(new SquareClientFactory().create('1111').getOriginClient()); + }); + + beforeEach(() => { + sandbox.reset(); + }); + + describe('findOrCreateCustomer', () => { + let searchCustomersStub: SinonStub; + let createCustomerStub: SinonStub; + + before(() => { + searchCustomersStub = sandbox.stub(customerClientApi, 'searchCustomers'); + createCustomerStub = sandbox.stub(customerClientApi, 'createCustomer'); + }); + + after(() => { + searchCustomersStub.restore(); + createCustomerStub.restore(); + }); + + it('should find customer', async () => { + searchCustomersStub.withArgs(searchCustomerReq).resolves({ result: { customers: [customerWithEmail] } }); + + await expect(customerClientApi.findOrCreateCustomer(findOrCreateCustomerRequest)).to.eventually.be.deep.eq(customerWithEmail); + }); + + it('should not found customer and create customer', async () => { + searchCustomersStub.withArgs(searchCustomerReq).resolves({ result: {} }); + + createCustomerStub.withArgs(findOrCreateCustomerRequest).resolves({ result: { customer: customerWithEmail } }); + + await expect(customerClientApi.findOrCreateCustomer(findOrCreateCustomerRequest)).to.eventually.be.deep.eq(customerWithEmail); + }); + + it('should create customer', async () => { + const fakeFindOrCreateCustomerRequest: IFindOrCreateCustomerRequest = { + phoneNumber: '+14043833639', + }; + + createCustomerStub.withArgs(fakeFindOrCreateCustomerRequest).resolves({ result: { customer } }); + + await expect(customerClientApi.findOrCreateCustomer(fakeFindOrCreateCustomerRequest)).to.eventually.be.deep.eq(customer); + }); + }); +}); diff --git a/test/unit/client/CustomerClientApi.spec.ts.REMOVED.git-id b/test/unit/client/CustomerClientApi.spec.ts.REMOVED.git-id deleted file mode 100644 index bdbff87..0000000 --- a/test/unit/client/CustomerClientApi.spec.ts.REMOVED.git-id +++ /dev/null @@ -1 +0,0 @@ -5c9b6c37acb2c58c8fb62df93739d0f6879fe75f \ No newline at end of file diff --git a/test/unit/client/SquareClient.spec.ts b/test/unit/client/SquareClient.spec.ts new file mode 100644 index 0000000..b288776 --- /dev/null +++ b/test/unit/client/SquareClient.spec.ts @@ -0,0 +1,244 @@ +import { expect } from 'chai'; +import { + ApplePayApi, + CardsApi, + CatalogApi, + CheckoutApi, + Client, + CustomersApi, + DEFAULT_CONFIGURATION, + EmployeesApi, + Environment, + GiftCardActivitiesApi, + GiftCardsApi, + InventoryApi, + InvoicesApi, + LaborApi, + LocationsApi, + LoyaltyApi, + MerchantsApi, + MobileAuthorizationApi, + OAuthApi, + OrdersApi, + PaymentsApi, + RefundsApi, + TransactionsApi, +} from 'square'; +import { describe } from 'mocha'; +import type { ISquareClientConfig } from '../../../src'; +import { exponentialDelay, SquareClient } from '../../../src'; + +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, + }, + configuration: { + ...DEFAULT_CONFIGURATION, + customUrl: basePath, + environment: Environment.Sandbox, + }, + logger: console, + logContext: { + someKey: 'someValue', + merchantId: 'unknown', + }, + }; + + describe('#constructor', (): void => { + it('should be init with accessToken only (default config)', (): void => { + new SquareClient(accessToken).should.be.instanceOf(SquareClient); + }); + + it('should be init with accessToken and config', (): void => { + new SquareClient(accessToken, config).should.be.instanceOf(SquareClient); + }); + }); + + describe('#generateIdempotencyKey', (): void => { + it('should return string', (): void => { + SquareClient.generateIdempotencyKey() + .should.be.match(new RegExp(`\\b[0-9a-f]{8}\\b-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-\\b[0-9a-f]{12}\\b`)) + .and.lengthOf(36); + }); + }); + + describe('#getConfig', (): void => { + it('should return default configuration', (): void => { + new SquareClient(accessToken).getConfig().should.be.deep.eq({ + retry: { + maxRetries: 6, + retryDelay: exponentialDelay, + }, + configuration: { + ...DEFAULT_CONFIGURATION, + }, + logger: undefined, + logContext: { + merchantId: 'unknown', + }, + }); + }); + + it('should return custom configuration', (): void => { + new SquareClient(accessToken, config).getConfig().should.be.deep.eq(config); + }); + }); + + describe('#getOriginClient', (): void => { + it('should return Client with default configuration', (): void => { + const client: Client = new SquareClient(accessToken).getOriginClient(); + // @ts-ignore + const defaultConfig = client._config; + + client.should.be.instanceOf(Client); + defaultConfig.should.have.property('timeout', DEFAULT_CONFIGURATION.timeout); + defaultConfig.should.have.property('squareVersion', DEFAULT_CONFIGURATION.squareVersion); + defaultConfig.should.have.deep.property('additionalHeaders', DEFAULT_CONFIGURATION.additionalHeaders); + defaultConfig.should.have.property('environment', DEFAULT_CONFIGURATION.environment); + defaultConfig.should.have.property('customUrl', DEFAULT_CONFIGURATION.customUrl); + defaultConfig.should.have.property('accessToken', accessToken); + }); + + it('should return Client with custom configuration', (): void => { + const client: Client = new SquareClient(accessToken, config).getOriginClient(); + // @ts-ignore + const defaultConfig = client._config; + + client.should.be.instanceOf(Client); + defaultConfig.should.have.property('timeout', config.configuration?.timeout); + defaultConfig.should.have.deep.property('additionalHeaders', {}); + defaultConfig.should.and.have.property('environment', config.configuration?.environment); + defaultConfig.should.have.property('customUrl', config.configuration?.customUrl); + }); + + it('should return the same object on second call', (): void => { + const squareClient: SquareClient = new SquareClient(accessToken); + squareClient.getOriginClient().should.be.deep.eq(squareClient.getOriginClient()); + }); + }); + + describe('#getApplePayApi', (): void => { + it('should return ApplePayApi', (): void => { + expect(new SquareClient(accessToken).getApplePayApi()).to.be.instanceOf(ApplePayApi); + }); + }); + + describe('#getCatalogApi', (): void => { + it('should return CatalogApi', (): void => { + expect(new SquareClient(accessToken).getCatalogApi()).to.be.instanceOf(CatalogApi); + }); + }); + + describe('#getCheckoutApi', (): void => { + it('should return CheckoutApi', (): void => { + expect(new SquareClient(accessToken).getCheckoutApi()).to.be.instanceOf(CheckoutApi); + }); + }); + + describe('#getCustomersApi', (): void => { + it('should return CustomersApi', (): void => { + expect(new SquareClient(accessToken).getCustomersApi()).to.be.instanceOf(CustomersApi); + }); + }); + + describe('#getLoyaltyApi', (): void => { + it('should return LoyaltyApi', (): void => { + expect(new SquareClient(accessToken).getLoyaltyApi()).to.be.instanceOf(LoyaltyApi); + }); + }); + + describe('#getEmployeesApi', (): void => { + it('should return EmployeesApi', (): void => { + expect(new SquareClient(accessToken).getEmployeesApi()).to.be.instanceOf(EmployeesApi); + }); + }); + + describe('#getInventoryApi', (): void => { + it('should return InventoryApi', (): void => { + expect(new SquareClient(accessToken).getInventoryApi()).to.be.instanceOf(InventoryApi); + }); + }); + + describe('#getLaborApi', (): void => { + it('should return LaborApi', (): void => { + expect(new SquareClient(accessToken).getLaborApi()).to.be.instanceOf(LaborApi); + }); + }); + + describe('#getLocationsApi', (): void => { + it('should return LocationsApi', (): void => { + expect(new SquareClient(accessToken).getLocationsApi()).to.be.instanceOf(LocationsApi); + }); + }); + + describe('#getLocationsApi', (): void => { + it('should return MerchantsApi', (): void => { + expect(new SquareClient(accessToken).getMerchantsApi()).to.be.instanceOf(MerchantsApi); + }); + }); + + describe('#getMobileAuthorizationApi', (): void => { + it('should return MobileAuthorizationApi', (): void => { + expect(new SquareClient(accessToken).getMobileAuthorizationApi()).to.be.instanceOf(MobileAuthorizationApi); + }); + }); + + describe('#getOAuthApi', (): void => { + it('should return OAuthApi', (): void => { + expect(new SquareClient(accessToken).getOAuthApi()).to.be.instanceOf(OAuthApi); + }); + }); + + describe('#getOrdersApi', (): void => { + it('should return OrdersApi', (): void => { + expect(new SquareClient(accessToken).getOrdersApi()).to.be.instanceOf(OrdersApi); + }); + }); + + describe('#getPaymentsApi', (): void => { + it('should return PaymentsApi', (): void => { + expect(new SquareClient(accessToken).getPaymentsApi()).to.be.instanceOf(PaymentsApi); + }); + }); + + describe('#getGiftCardsApi', (): void => { + it('should return GiftCardsApi', (): void => { + expect(new SquareClient(accessToken).getGiftCardsApi()).to.be.instanceOf(GiftCardsApi); + }); + }); + + describe('#getGiftCardActivitiesApi', (): void => { + it('should return GiftCardActivitiesApi', (): void => { + expect(new SquareClient(accessToken).getGiftCardActivitiesApi()).to.be.instanceOf(GiftCardActivitiesApi); + }); + }); + + describe('#getRefundsApi', (): void => { + it('should return RefundsApi', (): void => { + expect(new SquareClient(accessToken).getRefundsApi()).to.be.instanceOf(RefundsApi); + }); + }); + + describe('#getTransactionsApi', (): void => { + it('should return TransactionsApi', (): void => { + expect(new SquareClient(accessToken).getTransactionsApi()).to.be.instanceOf(TransactionsApi); + }); + }); + + describe('#getCardsApi', (): void => { + it('should return CardsApi', (): void => { + expect(new SquareClient(accessToken).getCardsApi()).to.be.instanceOf(CardsApi); + }); + }); + + describe('getInvoiceApi', (): void => { + it('should return getInvoiceApi', (): void => { + expect(new SquareClient(accessToken).getInvoiceApi()).to.be.instanceOf(InvoicesApi); + }); + }); +}); diff --git a/test/unit/client/SquareClient.spec.ts.REMOVED.git-id b/test/unit/client/SquareClient.spec.ts.REMOVED.git-id deleted file mode 100644 index 5e992b0..0000000 --- a/test/unit/client/SquareClient.spec.ts.REMOVED.git-id +++ /dev/null @@ -1 +0,0 @@ -b288776287643051e41182e0950942857615fa2d \ No newline at end of file diff --git a/test/unit/client/SquareClientFactory.spec.ts b/test/unit/client/SquareClientFactory.spec.ts new file mode 100644 index 0000000..104d912 --- /dev/null +++ b/test/unit/client/SquareClientFactory.spec.ts @@ -0,0 +1,183 @@ +import { expect } from 'chai'; +import { + Client, + CustomersApi, + LocationsApi, + OrdersApi, + PaymentsApi, + RefundsApi, + Environment, + DEFAULT_CONFIGURATION, + GiftCardsApi, + GiftCardActivitiesApi, +} from 'square'; +import type { ISquareClientConfig } from '../../../src'; +import { SquareClient, SquareClientFactory, exponentialDelay } from '../../../src'; + +describe('SquareClientFactory (unit)', (): void => { + const accessToken: string = 'test'; + const basePath: string = `${process.env.SQUARE_SANDBOX_BASE_URL || ''}`; + + const config: ISquareClientConfig = { + retry: { + maxRetries: 1, + retryDelay: exponentialDelay, + }, + configuration: { + ...DEFAULT_CONFIGURATION, + timeout: 1000, + customUrl: basePath, + environment: Environment.Sandbox, + }, + logger: console, + logContext: { + someKey: 'someValue', + merchantId: 'unknown', + }, + }; + + describe('#create', (): void => { + it('should be statically init with accessToken only (default config)', (): void => { + SquareClientFactory.create(accessToken).should.be.instanceOf(SquareClient); + }); + + it('should be init with accessToken only (default config)', (): void => { + new SquareClientFactory().create(accessToken).should.be.instanceOf(SquareClient); + }); + + it('should be statically init with accessToken and config', (): void => { + SquareClientFactory.create(accessToken, config).should.be.instanceOf(SquareClient); + }); + + it('should be init with accessToken and config', (): void => { + new SquareClientFactory().create(accessToken, config).should.be.instanceOf(SquareClient); + }); + }); + + describe('#createCustomSquareClient', () => { + it('should be statically init with Test class instance and accessToken (default config)', (): void => { + class Test extends SquareClient {} + SquareClientFactory.createCustomSquareClient(Test, accessToken).should.be.instanceOf(Test); + }); + + it('should be init with Test class instance and accessToken only (default config)', (): void => { + class Test extends SquareClient {} + new SquareClientFactory().createCustomSquareClient(Test, accessToken).should.be.instanceOf(Test); + }); + + it('should be statically init with with Test class instance and accessToken and config', (): void => { + class Test extends SquareClient {} + SquareClientFactory.createCustomSquareClient(Test, accessToken, config).should.be.instanceOf(Test); + }); + + it('should be init with with with Test class instance and accessToken and config', (): void => { + class Test extends SquareClient {} + new SquareClientFactory().createCustomSquareClient(Test, accessToken, config).should.be.instanceOf(Test); + }); + }); + + describe('#generateIdempotencyKey', (): void => { + it('should return string', (): void => { + SquareClient.generateIdempotencyKey() + .should.be.match(new RegExp(`\\b[0-9a-f]{8}\\b-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-\\b[0-9a-f]{12}\\b`)) + .and.lengthOf(36); + }); + }); + + describe('#getConfig', (): void => { + it('should return default configuration', (): void => { + new SquareClient(accessToken).getConfig().should.be.deep.eq({ + retry: { + maxRetries: 6, + retryDelay: exponentialDelay, + }, + configuration: DEFAULT_CONFIGURATION, + logger: undefined, + logContext: { + merchantId: 'unknown', + }, + }); + }); + + it('should return custom configuration', (): void => { + new SquareClient(accessToken, config).getConfig().should.be.deep.eq(config); + }); + }); + + describe('#getOriginClient', (): void => { + it('should return Client with default configuration', (): void => { + const client: Client = new SquareClient(accessToken).getOriginClient(); + // @ts-ignore + const defaultConfig = client._config; + + client.should.be.instanceOf(Client); + defaultConfig.should.have.property('timeout', DEFAULT_CONFIGURATION.timeout); + defaultConfig.should.have.property('squareVersion', DEFAULT_CONFIGURATION.squareVersion); + defaultConfig.should.have.deep.property('additionalHeaders', DEFAULT_CONFIGURATION.additionalHeaders); + defaultConfig.should.have.property('environment', DEFAULT_CONFIGURATION.environment); + defaultConfig.should.have.property('customUrl', DEFAULT_CONFIGURATION.customUrl); + defaultConfig.should.have.property('accessToken', accessToken); + }); + + it('should return Client with custom configuration', (): void => { + const client: Client = new SquareClient(accessToken, config).getOriginClient(); + // @ts-ignore + const defaultConfig = client._config; + + client.should.be.instanceOf(Client); + defaultConfig.should.have.property('timeout', config.configuration?.timeout); + defaultConfig.should.have.property('squareVersion', config.configuration?.squareVersion); + defaultConfig.should.have.deep.property('additionalHeaders', {}); + defaultConfig.should.have.property('environment', config.configuration?.environment); + defaultConfig.should.have.property('customUrl', config.configuration?.customUrl); + defaultConfig.should.have.property('accessToken', accessToken); + }); + + it('should return the same object on second call', (): void => { + const squareClient: SquareClient = new SquareClient(accessToken); + squareClient.getOriginClient().should.be.deep.eq(squareClient.getOriginClient()); + }); + }); + + describe('#getLocationsApi', (): void => { + it('should return LocationsApi', (): void => { + expect(new SquareClient(accessToken).getLocationsApi()).to.be.instanceOf(LocationsApi); + }); + }); + + describe('#getCustomersApi', (): void => { + it('should return CustomersApi', (): void => { + expect(new SquareClient(accessToken).getCustomersApi()).to.be.an.instanceof(CustomersApi); + }); + }); + + describe('#getPaymentsApi', (): void => { + it('should return PaymentsApi', (): void => { + expect(new SquareClient(accessToken).getPaymentsApi()).to.be.instanceOf(PaymentsApi); + }); + }); + + describe('#getGiftCardsApi', (): void => { + it('should return GiftCardsApi', (): void => { + expect(new SquareClient(accessToken).getGiftCardsApi()).to.be.instanceOf(GiftCardsApi); + }); + }); + + describe('#getGiftCardActivitiesApi', (): void => { + it('should return GiftCardActivitiesApi', (): void => { + expect(new SquareClient(accessToken).getGiftCardActivitiesApi()).to.be.instanceOf(GiftCardActivitiesApi); + }); + }); + + describe('#getRefundsApi', (): void => { + it('should return RefundsApi', (): void => { + expect(new SquareClient(accessToken).getRefundsApi()).to.be.instanceOf(RefundsApi); + }); + }); + + describe('#getOrdersApi', (): void => { + it('should return OrdersApi', (): void => { + expect(new SquareClient(accessToken).getOrdersApi()).to.be.instanceOf(OrdersApi); + }); + }); +}); diff --git a/test/unit/client/SquareClientFactory.spec.ts.REMOVED.git-id b/test/unit/client/SquareClientFactory.spec.ts.REMOVED.git-id deleted file mode 100644 index bf3af80..0000000 --- a/test/unit/client/SquareClientFactory.spec.ts.REMOVED.git-id +++ /dev/null @@ -1 +0,0 @@ -104d912772de87ff864821e214c184c91081276e \ No newline at end of file diff --git a/test/unit/exception/SquareApiException.spec.ts b/test/unit/exception/SquareApiException.spec.ts new file mode 100644 index 0000000..7ea60d7 --- /dev/null +++ b/test/unit/exception/SquareApiException.spec.ts @@ -0,0 +1,160 @@ +import { ApiError } from 'square'; +import { SquareApiException } from '../../../src'; + +describe('SquareApiException (unit)', (): void => { + const errors = [ + { + category: 'RATE_LIMIT_ERROR', + code: 'RATE_LIMITED', + detail: 'fake 429 error', + }, + ]; + const response = { + statusCode: 400, + headers: { + 'user-agent': 'Square-TypeScript-SDK/17.0.0', + 'Content-Type': 'application/json', + 'Square-Version': '2021-12-15', + authorization: 'Bearer EAAAEDFfyeoo0XgXi-Dd7ofp_3W6eAcjHMClC3GlQR61xATqdVixe6v4kq21KgnL', + accept: 'application/json', + }, + body: JSON.stringify({ errors }), + }; + + const apiError: ApiError = new ApiError( + { + response, + request: { + method: 'POST', + url: 'https://connect.squareupsandbox.com/v2/customers/search', + headers: response.headers, + }, + }, + '', + ); + describe('#constructor', (): void => { + it('should be ok with apiError', () => { + const squareException: SquareApiException = new SquareApiException(apiError, 0); + + squareException.should.be.instanceOf(SquareApiException); + squareException.should.have.property('statusCode', 400); + squareException.should.have.property('apiError', apiError); + squareException.should.have.property('retries', 0); + squareException.should.have.property('url', apiError.request.url); + squareException.should.have.property('method', apiError.request?.method); + squareException.should.have.property('errors', apiError.errors); + squareException.should.have.property('message', errors?.[0]?.detail); + }); + + it('should be ok with empty data.originError object', (): void => { + // @ts-ignore + const squareException: SquareApiException = new SquareApiException({}); + + squareException.should.be.instanceOf(SquareApiException); + squareException.should.have.property('statusCode', 500); + squareException.should.have.property('retries', 0); + squareException.should.have.property('errors').eql([]); + squareException.should.have.property('message', 'Square API error'); + }); + + it('should be ok with Error', (): void => { + const error = new Error('error message'); + // @ts-ignore + const squareException: SquareApiException = new SquareApiException(error); + + squareException.should.be.instanceOf(SquareApiException); + squareException.should.have.property('statusCode', 500); + squareException.should.have.property('retries', 0); + squareException.should.have.property('errors').eql([]); + squareException.should.have.property('message', 'error message'); + }); + + it('should remove authorization property from request headers', () => { + const squareException: SquareApiException = new SquareApiException(apiError, 0); + + squareException.should.be.instanceOf(SquareApiException); + squareException.apiError?.request.headers?.should.not.have.property('authorization'); + }); + }); + + describe('#toObject', (): void => { + it('should be ok with apiError', () => { + const squareException: SquareApiException = new SquareApiException(apiError, 0); + const plainObject = squareException.toObject(); + + plainObject.should.have.property('statusCode', 400); + plainObject.should.have.property('apiError', apiError); + plainObject.should.have.property('retries', 0); + plainObject.should.have.property('url', apiError.request.url); + plainObject.should.have.property('method', apiError.request?.method); + plainObject.should.have.property('errors', apiError.errors); + plainObject.should.have.property('message', errors?.[0]?.detail); + }); + + it('should be ok with empty data.originError object', (): void => { + // @ts-ignore + const squareException: SquareApiException = new SquareApiException({}); + const plainObject = squareException.toObject(); + + plainObject.should.have.property('statusCode', 500); + plainObject.should.have.property('retries', 0); + plainObject.should.have.property('url').eql(undefined); + plainObject.should.have.property('method').eql(undefined); + plainObject.should.have.property('errors').eql([]); + plainObject.should.have.property('message', 'Square API error'); + }); + + it('should be ok with Error', (): void => { + const error = new Error('error message'); + // @ts-ignore + const squareException: SquareApiException = new SquareApiException(error); + + const plainObject = squareException.toObject(); + + plainObject.should.have.property('statusCode', 500); + plainObject.should.have.property('retries', 0); + plainObject.should.have.property('errors').eql([]); + plainObject.should.have.property('message', 'error message'); + }); + + it("shouldn't have authorization property from request headers", () => { + const squareException: SquareApiException = new SquareApiException(apiError, 0); + const plainObject = squareException.toObject(); + + plainObject.apiError?.request.headers?.should.not.have.property('authorization'); + }); + }); + + describe('#toString', (): void => { + it('should be ok with apiError', () => { + const squareException: SquareApiException = new SquareApiException(apiError, 0); + const plainObject = squareException.toString(); + + plainObject.should.deep.equal(JSON.stringify(squareException.toObject())); + }); + + it('should be ok with empty data.originError object', (): void => { + // @ts-ignore + const squareException: SquareApiException = new SquareApiException({}); + const plainObject = squareException.toString(); + + plainObject.should.deep.equal(JSON.stringify(squareException.toObject())); + }); + + it('should be ok with Error', (): void => { + const error = new Error('error message'); + // @ts-ignore + const squareException: SquareApiException = new SquareApiException(error); + const plainObject = squareException.toString(); + + plainObject.should.deep.equal(JSON.stringify(squareException.toObject())); + }); + + it("shouldn't have authorization property from request headers", () => { + const squareException: SquareApiException = new SquareApiException(apiError, 0); + const plainObject = squareException.toString(); + + plainObject.should.deep.equal(JSON.stringify(squareException.toObject())); + }); + }); +}); diff --git a/test/unit/exception/SquareApiException.spec.ts.REMOVED.git-id b/test/unit/exception/SquareApiException.spec.ts.REMOVED.git-id deleted file mode 100644 index 0db76f1..0000000 --- a/test/unit/exception/SquareApiException.spec.ts.REMOVED.git-id +++ /dev/null @@ -1 +0,0 @@ -7ea60d707698a1cd982687dffa38ae9352e46dbb \ No newline at end of file diff --git a/test/unit/logger/NullLogger.spec.ts b/test/unit/logger/NullLogger.spec.ts new file mode 100644 index 0000000..043a3f6 --- /dev/null +++ b/test/unit/logger/NullLogger.spec.ts @@ -0,0 +1,78 @@ +import type { ILogger } from '../../../src/logger'; +import { 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/logger/NullLogger.spec.ts.REMOVED.git-id b/test/unit/logger/NullLogger.spec.ts.REMOVED.git-id deleted file mode 100644 index 1b6762f..0000000 --- a/test/unit/logger/NullLogger.spec.ts.REMOVED.git-id +++ /dev/null @@ -1 +0,0 @@ -043a3f62fec982c9dc67ff2e9a1afd632daddfb5 \ No newline at end of file diff --git a/test/unit/utils/SquareDataMapper.spec.ts b/test/unit/utils/SquareDataMapper.spec.ts new file mode 100644 index 0000000..13bd64f --- /dev/null +++ b/test/unit/utils/SquareDataMapper.spec.ts @@ -0,0 +1,147 @@ +import * as assert from 'assert'; // Node.js +import type { Order, OrderLineItem, PublishInvoiceRequest } from 'square'; +// eslint-disable-next-line import/no-unresolved +import type { Order as SquareConnectOrder, PublishInvoiceRequest as SquareConnectPublishInvoiceRequest } from 'square-connect'; +import { recursiveNumberToBigInt, SquareDataMapper } from '../../../src'; + +describe('SquareDataMapper', () => { + describe('#toOldFormat', () => { + it('should convert to old format', () => { + const order: Order = { + locationId: 'locationId', + lineItems: [ + { + name: 'Coca-cola', + quantity: '1', + basePriceMoney: { + amount: recursiveNumberToBigInt(100), + currency: 'USD', + }, + }, + ], + }; + + const orderWithOldFormat: SquareConnectOrder = { + location_id: 'locationId', + line_items: [ + { + name: 'Coca-cola', + quantity: '1', + base_price_money: { + amount: 100, + currency: 'USD', + }, + }, + ], + }; + + SquareDataMapper.toOldFormat(order).should.deep.equals(orderWithOldFormat); + }); + + it('should convert to old format preserve big int', () => { + const order: Order = { + locationId: 'locationId', + lineItems: [ + { + name: 'Coca-cola', + quantity: '1', + basePriceMoney: { + amount: recursiveNumberToBigInt(100), + currency: 'USD', + }, + }, + ], + }; + const { line_items, location_id } = SquareDataMapper.toOldFormat(order, false); + location_id!.should.equal(order.locationId); + const { base_price_money, name, quantity } = line_items![0]; + name!.should.be.equal(order.lineItems?.[0]?.name); + quantity.should.be.equal(order.lineItems?.[0]?.quantity); + + assert.strictEqual(base_price_money!.amount, recursiveNumberToBigInt(100)); + }); + }); + + describe('#toNewFormat', () => { + it('should convert to new format', () => { + const order: Order = { + locationId: 'locationId', + lineItems: [ + { + name: 'Coca-cola', + quantity: '1', + basePriceMoney: { + amount: recursiveNumberToBigInt(100), + currency: 'USD', + }, + }, + ], + }; + + const orderWithOldFormat: SquareConnectOrder = { + location_id: 'locationId', + line_items: [ + { + name: 'Coca-cola', + quantity: '1', + base_price_money: { + amount: 100, + currency: 'USD', + }, + }, + ], + }; + + SquareDataMapper.toNewFormat(orderWithOldFormat, false).should.deep.equals({ + ...order, + lineItems: [{ ...order.lineItems![0], basePriceMoney: { ...order.lineItems![0].basePriceMoney, amount: 100 } }], + }); + }); + + it('should convert to new format preserve big int', () => { + const orderWithOldFormat: SquareConnectOrder = { + location_id: 'locationId', + line_items: [ + { + name: 'Coca-cola', + quantity: '1', + base_price_money: { + amount: 100, + currency: 'USD', + }, + }, + ], + }; + + const { lineItems, locationId } = SquareDataMapper.toNewFormat(orderWithOldFormat); + locationId.should.equal(orderWithOldFormat.location_id); + const { basePriceMoney, name, quantity }: OrderLineItem = lineItems![0]; + name!.should.be.equal(orderWithOldFormat.line_items?.[0]?.name); + quantity.should.be.equal(orderWithOldFormat.line_items?.[0]?.quantity); + + assert.strictEqual(basePriceMoney!.amount, recursiveNumberToBigInt(100)); + }); + }); + + describe('#idempotencyFree', () => { + it('should return new object without idempotencyFree', () => { + const publishInvoiceRequest: PublishInvoiceRequest = { + version: 1, + idempotencyKey: '11bf5b37-e0b8-42e0-8dcf-dc8c4aefc000', + }; + SquareDataMapper.idempotencyFree(publishInvoiceRequest).should.deep.equal({ + version: 1, + }); + }); + + it('should return new object without idempotency_key', () => { + const publishInvoiceRequest: SquareConnectPublishInvoiceRequest = { + version: 1, + idempotency_key: '11bf5b37-e0b8-42e0-8dcf-dc8c4aefc000', + }; + SquareDataMapper.idempotencyFree(publishInvoiceRequest).should.deep.equal({ + version: 1, + }); + }); + }); +}); diff --git a/test/unit/utils/SquareDataMapper.spec.ts.REMOVED.git-id b/test/unit/utils/SquareDataMapper.spec.ts.REMOVED.git-id deleted file mode 100644 index bdeffe8..0000000 --- a/test/unit/utils/SquareDataMapper.spec.ts.REMOVED.git-id +++ /dev/null @@ -1 +0,0 @@ -13bd64fce96e9242891f69eff0946268a0751fa3 \ No newline at end of file diff --git a/test/unit/utils/common.utils.spec.ts b/test/unit/utils/common.utils.spec.ts new file mode 100644 index 0000000..875e8c0 --- /dev/null +++ b/test/unit/utils/common.utils.spec.ts @@ -0,0 +1,37 @@ +import { mergeDeepProps } from '../../../src'; + +describe('common.utils (unit)', () => { + describe('#mergeDeepProps', () => { + it('should correctly merge simple object with undefined', () => { + // @ts-ignore + mergeDeepProps({ a: 1 }, undefined).should.be.deep.eq({ a: 1 }); + }); + + it('should correctly merge class props with undefined', () => { + const error: Error = new Error(); + // @ts-ignore + mergeDeepProps(error, undefined).should.be.instanceOf(Error).and.deep.eq(error).and.have.property('name', 'Error'); + }); + + it('should correctly merge 2 empty objects', () => { + mergeDeepProps({}, {}).should.be.deep.eq({}); + }); + + it('should correctly merge 2 simple object props', () => { + 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', () => { + 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', () => { + 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', () => { + const error: Error = new Error(); + mergeDeepProps(error, { name: 'x' }).should.be.instanceOf(Error).and.deep.eq(error).and.have.property('name', 'x'); + }); + }); +}); diff --git a/test/unit/utils/common.utils.spec.ts.REMOVED.git-id b/test/unit/utils/common.utils.spec.ts.REMOVED.git-id deleted file mode 100644 index a3c9039..0000000 --- a/test/unit/utils/common.utils.spec.ts.REMOVED.git-id +++ /dev/null @@ -1 +0,0 @@ -875e8c08af11a12045ea70b908872764fe436ef3 \ No newline at end of file diff --git a/test/unit/utils/helper.spec.ts b/test/unit/utils/helper.spec.ts new file mode 100644 index 0000000..3404d05 --- /dev/null +++ b/test/unit/utils/helper.spec.ts @@ -0,0 +1,133 @@ +import assert from 'assert'; +import { expect } from 'chai'; +import type { Order } from 'square'; +import type { ObjectLikeType } from '../../../src'; +import { recursiveBigIntToNumber, recursiveNumberToBigInt } from '../../../src'; + +describe('helper', () => { + describe('#recursiveBigIntToNumber', () => { + it('should convert bigint to number', () => { + recursiveBigIntToNumber(1n).should.be.equal(1); + }); + + it('should convert all object property with type bigint to number', () => { + const order: Order = { + locationId: 'locationId', + lineItems: [ + { + name: 'Coca-cola', + quantity: '1', + basePriceMoney: { + amount: recursiveNumberToBigInt(100), + currency: 'USD', + }, + }, + ], + }; + + const orderWithConvertedBigintToNumber: Order = { + locationId: 'locationId', + lineItems: [ + { + name: 'Coca-cola', + quantity: '1', + basePriceMoney: { + // @ts-ignore + amount: 100, + currency: 'USD', + }, + }, + ], + }; + + recursiveBigIntToNumber(order).should.be.deep.equal(orderWithConvertedBigintToNumber); + }); + + it('should be ok with undefined', () => { + expect(recursiveBigIntToNumber(undefined)).to.be.undefined; + }); + + it('should be ok with null', () => { + expect(recursiveBigIntToNumber(null)).to.be.null; + }); + + it('should be ok if object include null property', () => { + const order: ObjectLikeType = { + locationId: null, + lineItems: [ + { + name: null, + quantity: '1', + basePriceMoney: { + amount: null, + currency: 'USD', + }, + }, + ], + }; + recursiveBigIntToNumber(order).should.deep.equals(order); + }); + + it('should be ok 0 bigint', () => { + recursiveBigIntToNumber(0n).should.be.equal(0); + }); + }); + + describe('#recursiveNumberToBigInt', () => { + it('should convert number to bigint', () => { + assert.strictEqual(recursiveNumberToBigInt(1), 1n); + }); + + it('should convert all object property with type num to big int', () => { + const object: ObjectLikeType = { + locationId: 1, + lineItems: [ + { + name: 'Coca-cola', + quantity: 2, + basePriceMoney: { + // @ts-ignore + amount: 100, + currency: 'USD', + }, + }, + ], + }; + const { locationId, lineItems } = recursiveNumberToBigInt(object); + const { quantity, basePriceMoney } = lineItems[0]; + + assert.strictEqual(locationId, 1n); + assert.strictEqual(quantity, 2n); + assert.strictEqual(basePriceMoney.amount, 100n); + }); + + it('should be ok with undefined', () => { + expect(recursiveNumberToBigInt(undefined)).to.be.undefined; + }); + + it('should be ok with null', () => { + expect(recursiveNumberToBigInt(null)).to.be.null; + }); + + it('should be ok with 0', () => { + assert.strictEqual(recursiveNumberToBigInt(0), 0n); + }); + + it('should be ok of object include null property', () => { + const order: ObjectLikeType = { + locationId: null, + lineItems: [ + { + name: null, + quantity: '1', + basePriceMoney: { + amount: null, + currency: 'USD', + }, + }, + ], + }; + recursiveNumberToBigInt(order).should.deep.equals(order); + }); + }); +}); diff --git a/test/unit/utils/helper.spec.ts.REMOVED.git-id b/test/unit/utils/helper.spec.ts.REMOVED.git-id deleted file mode 100644 index 00c0591..0000000 --- a/test/unit/utils/helper.spec.ts.REMOVED.git-id +++ /dev/null @@ -1 +0,0 @@ -3404d056991e195fb6349665f5d8f3629278bd3d \ No newline at end of file