diff --git a/.eslintrc.yaml b/.eslintrc.yaml deleted file mode 100644 index e20bce79..00000000 --- a/.eslintrc.yaml +++ /dev/null @@ -1,16 +0,0 @@ -env: - es6: true - mocha: true - node: true -extends: - - eslint:recommended - - plugin:prettier/recommended -parserOptions: - ecmaVersion: latest -rules: - no-var: error - prefer-const: error - quotes: - - error - - single - - avoidEscape: true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 05d4214c..d8c35590 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,17 +14,20 @@ jobs: - 12 - 14 - 16 - - 17 + - 18 + - 20 + - 22 steps: - - uses: actions/checkout@v2 - - uses: actions/setup-node@v2 + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 with: node-version: ${{ matrix.node }} - run: npm install - run: npm run lint - if: matrix.node == 16 + if: matrix.node == 22 - run: npm test - - uses: coverallsapp/github-action@1.1.3 - if: matrix.node == 16 + - run: npm run test:types + - uses: coverallsapp/github-action@v2 + if: matrix.node == 22 with: github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.lintstagedrc.json b/.lintstagedrc.json index 3c3f5b6b..9fef63c9 100644 --- a/.lintstagedrc.json +++ b/.lintstagedrc.json @@ -1,4 +1,4 @@ { "*.js": "eslint --fix", - "*.{json,md,ts,yml,yaml}": "prettier --write" + "*.{js,json,md,ts,yml,yaml}": "prettier --write" } diff --git a/README.md b/README.md index bae14b6c..fffa84af 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,10 @@ Creates a new `Shopify` instance. [OAuth 2.0][oauth] access token. This option is mutually exclusive with the `apiKey` and `password` options. If you are looking for a premade solution to obtain an access token, take a look at the [shopify-token][] module. +- `agent` - Optional - An object that is passed as the `agent` option to `got`. + This allows to use a proxy server. See + [Got documentation](https://github.com/sindresorhus/got/tree/v11.8.6?tab=readme-ov-file#proxies) + for more details. - `apiVersion` - Optional - A string to specify the [Shopify API version][api-versioning] to use for requests. Defaults to the oldest supported stable version. @@ -52,7 +56,19 @@ Creates a new `Shopify` instance. bucket size. For example `{ calls: 2, interval: 1000, bucketSize: 35 }` specifies a limit of 2 requests per second with a burst of 35 requests. When set to `true` requests are limited as specified in the above example. Defaults - to `false`. + to `false`. Mutually exclusive with the `maxRetries` option. +- `hooks` - Optional - A list of `got` + [request hooks](https://github.com/sindresorhus/got/tree/v11.8.6#hooks) to + attach to all outgoing requests, like `beforeRetry`, `afterResponse`, etc. + Hooks should be provided in the same format that Got expects them and will + receive the same arguments Got passes unchanged. +- `maxRetries` - Optional - The number of times to attempt to make the request + to Shopify before giving up. Defaults to `0`, which means no automatic + retries. If set to a value greater than `0`, `shopify-api-node` will make up + to that many retries. `shopify-api-node` will respect the `Retry-After` header + for requests to the REST API, and the throttled cost data for requests to the + GraphQL API, and retry the request after that time has elapsed. Mutually + exclusive with the `autoLimit` option. - `parseJson` - Optional - The function used to parse JSON. The function is passed a single argument. This option allows the use of a custom JSON parser that might be needed to properly handle long integer IDs. Defaults to @@ -220,7 +236,7 @@ shopify.metafield .create({ key: 'warehouse', value: 25, - value_type: 'integer', + type: 'integer', namespace: 'inventory', owner_resource: 'product', owner_id: 632910392 @@ -256,6 +272,35 @@ parameters needed to fetch the next and previous page of results. This feature is only available on version 2.24.0 and above. +## Shopify rate limit avoidance + +`shopify-api-node` has two optional mechanisms for avoiding requests failing +with `429 Rate Limit Exceeded` errors from Shopify. + +The `autoLimit` option implements a client side leaky bucket algorithm for +delaying requests until Shopify is likely to accept them. When `autoLimit` is +on, each `Shopify` instance will track how many requests have been made, and +delay sending subsequent requests if the rate limit has been exceeded. +`autoLimit` is very efficient because it almost entirely avoids sending requests +which will return 429 errors, but, it does not coordinate between multiple +`Shopify` instances or across multiple processes. If you're using +`shopify-api-node` in many different processes, `autoLimit` will not correctly +avoid 429 errors. + +The `maxRetries` option implements a retry based strategy for getting requests +to Shopify, where when a 429 error occurs, the request is automatically retried +after waiting. Shopify usually replies with a `Retry-After` header indicating to +the client when the rate limit is available, and so `shopify-api-node` will wait +that long before retrying. If you are using `shopify-api-node` in many different +processes, they will all be competing to use the same rate limit shopify +enforces, so there is no guarantee that retrying after the `Retry-After` header +delay will work. It is recommended to set `maxRetries` to a high value like `10` +if you are making many concurrent requests in many processes to ensure each +request is retried for long enough to succeed. + +`autoLimit` and `maxRetries` can't be used simultaneously. Both are off by +default. + ## Available resources and methods - accessScope @@ -381,6 +426,8 @@ This feature is only available on version 2.24.0 and above. - `get(id[, params])` - `list([params])` - `update(id, params)` +- deprecatedApiCall + - `list()` - discountCode - `create(priceRuleId, params)` - `delete(priceRuleId, id)` @@ -395,6 +442,12 @@ This feature is only available on version 2.24.0 and above. - dispute - `get(id)` - `list([params])` +- disputeEvidence + - `get(disputeId)` + - `update(disputeId, params)` +- disputeFileUpload + - `create(disputeId, params)` + - `delete(disputeId, id)` - draftOrder - `complete(id[, params])` - `count()` @@ -410,6 +463,7 @@ This feature is only available on version 2.24.0 and above. - `list([params])` - fulfillment - `cancel(orderId, id)` + - `cancelV2(id)` - `complete(orderId, id)` - `count(orderId[, params)` - `create(orderId, params)` @@ -426,12 +480,17 @@ This feature is only available on version 2.24.0 and above. - `list(orderId, fulfillmentId[, params])` - `update(orderId, fulfillmentId, id, params)` - fulfillmentOrder - - `cancel(id, params)` + - `cancel(id)` - `close(id[, message])` + - `fulfillments(id)` - `get(id)` + - `hold(id, params)` - `list([params])` - `locationsForMove(id)` - `move(id, locationId)` + - `releaseHold(id)` + - `reschedule(id, deadline)` + - `setFulfillmentOrdersDeadline(params)` - fulfillmentRequest - `accept(fulfillmentOrderId[, message])` - `create(fulfillmentOrderId, params)` @@ -450,7 +509,7 @@ This feature is only available on version 2.24.0 and above. - `list([params])` - `search(params)` - `update(id, params)` -- [giftCardAdjustment](https://help.shopify.com/en/api/reference/plus/gift_card_adjustment) +- [giftCardAdjustment](https://shopify.dev/api/admin-rest/2022-04/resources/gift-card-adjustment) - `create(giftCardId, params)` - `get(giftCardId, id)` - `list(giftCardId)` @@ -545,7 +604,7 @@ This feature is only available on version 2.24.0 and above. - `get(productId)` - `list([params])` - `productIds([params])` -- [productResourceFeedback](https://help.shopify.com/en/api/reference/sales-channels/productresourcefeedback) +- [productResourceFeedback](https://shopify.dev/api/admin-rest/2022-04/resources/product-resourcefeedback) - `create(productId[, params])` - `list(productId)` - productVariant @@ -641,8 +700,8 @@ This feature is only available on version 2.24.0 and above. - `list([params])` - `update(id, params)` -where `params` is a plain JavaScript object. See -https://help.shopify.com/api/reference?ref=microapps for parameters details. +where `params` is a plain JavaScript object. See the [Rest Admin API +reference][reading-api-docs] for parameters details. ## GraphQL @@ -672,10 +731,31 @@ shopify .catch((err) => console.error(err)); ``` +## Hooks + +`shopify-api-node` supports being passed hooks which are called by `got` (the +underlying HTTP library) during the request lifecycle. + +For example, we can log every error that is encountered when using the +`maxRetries` option: + +```js +const shopify = new Shopify({ + shopName: 'your-shop-name', + accessToken: 'your-oauth-token', + maxRetries: 3, + // Pass the `beforeRetry` hook down to Got. + hooks: { + beforeRetry: [(options, error, retryCount) => console.error(error)] + } +}); +``` + +For more information on the available `got` hooks, see the +[`got` v11 hooks documentation](https://github.com/sindresorhus/got/tree/v11#hooks). + ## Become a master of the Shopify ecosystem by: -- [Becoming a Shopify App Developer][becoming-a-shopify-app-developer] -- [Checking out the roots][checking-out-the-roots] - [Talking To Other Masters][talking-to-other-masters] - [Reading API Docs][reading-api-docs] - [Learning from others][learning-from-others] @@ -689,9 +769,6 @@ shopify (add yours!) - [Sample Node Express app by Shopify][sample-node-express-app-by-shopify] -- [Wholesaler][wholesaler] -- [Wholesaler & Customer Pricing][wholesaler-customer-pricing] -- [Wholesaler PRO][wholesaler-pro] - [Youtube Traffic][youtube-traffic] - [Shipatron][shipatron] - [UPC Code Manager][upc-code-manager] @@ -699,9 +776,7 @@ shopify ## Supported by: -[microapps][microapps] - -Used in our live products: [MoonMail][moonmail] & [MONEI][monei] +[MONEI][monei] ## License @@ -712,39 +787,25 @@ Used in our live products: [MoonMail][moonmail] & [MONEI][monei] [npm-shopify-api-node-badge]: https://img.shields.io/npm/v/shopify-api-node.svg [npm-shopify-api-node]: https://www.npmjs.com/package/shopify-api-node [ci-shopify-api-node-badge]: - https://img.shields.io/github/workflow/status/MONEI/Shopify-api-node/CI/master?label=CI + https://img.shields.io/github/actions/workflow/status/MONEI/Shopify-api-node/ci.yml?branch=master&label=CI [ci-shopify-api-node]: https://github.com/MONEI/Shopify-api-node/actions?query=workflow%3ACI+branch%3Amaster [coverage-shopify-api-node-badge]: https://img.shields.io/coveralls/MONEI/Shopify-api-node/master.svg [coverage-shopify-api-node]: https://coveralls.io/github/MONEI/Shopify-api-node [generate-private-app-credentials]: - https://help.shopify.com/api/guides/api-credentials#generate-private-app-credentials?ref=microapps -[oauth]: https://help.shopify.com/api/guides/authentication/oauth?ref=microapps + https://shopify.dev/apps/auth/basic-http#step-2-generate-api-credentials +[oauth]: https://shopify.dev/apps/auth/oauth [shopify-token]: https://github.com/lpinca/shopify-token -[api-call-limit]: - https://help.shopify.com/api/guides/api-call-limit/?ref=microapps -[api-versioning]: https://help.shopify.com/en/api/versioning -[becoming-a-shopify-app-developer]: - https://app.shopify.com/services/partners/signup?ref=microapps -[checking-out-the-roots]: https://help.shopify.com/api/guides?ref=microapps -[talking-to-other-masters]: - https://ecommerce.shopify.com/c/shopify-apps?ref=microapps -[reading-api-docs]: https://help.shopify.com/api/reference/?ref=microapps +[api-call-limit]: https://shopify.dev/api/usage/rate-limits +[api-versioning]: https://shopify.dev/api/usage/versioning +[talking-to-other-masters]: https://community.shopify.com/ +[reading-api-docs]: https://shopify.dev/api/admin-rest [learning-from-others]: https://stackoverflow.com/questions/tagged/shopify -[paginated-rest-results]: - https://help.shopify.com/en/api/guides/paginated-rest-results +[paginated-rest-results]: https://shopify.dev/api/usage/pagination-rest [polaris]: https://polaris.shopify.com/?ref=microapps -[microapps]: - http://microapps.com/?utm_source=shopify-api-node-module-repo-readme&utm_medium=click&utm_campaign=github -[moonmail]: - https://moonmail.io/?utm_source=shopify-api-node-module-repo-readme&utm_medium=click&utm_campaign=github [monei]: - https://monei.net/?utm_source=shopify-api-node-module-repo-readme&utm_medium=click&utm_campaign=github -[wholesaler]: https://apps.shopify.com/wholesaler?ref=microapps -[wholesaler-customer-pricing]: - https://apps.shopify.com/wholesaler-pro-1?ref=microapps -[wholesaler-pro]: https://apps.shopify.com/wholesaler-pro-2?ref=microapps + https://monei.com/?utm_source=shopify-api-node-module-repo-readme&utm_medium=click&utm_campaign=github [youtube-traffic]: https://apps.shopify.com/youtube-traffic?ref=microapps [shipatron]: https://shipatron.io [upc-code-manager]: https://apps.shopify.com/upc-code-manager-1 diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 00000000..282fab54 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,24 @@ +'use strict'; + +const pluginPrettierRecommended = require('eslint-plugin-prettier/recommended'); +const globals = require('globals'); +const js = require('@eslint/js'); + +module.exports = [ + js.configs.recommended, + { + ignores: ['coverage/', 'node_modules/'], + languageOptions: { + ecmaVersion: 'latest', + globals: { + ...globals.mocha, + ...globals.node + } + }, + rules: { + 'no-var': 'error', + 'prefer-const': 'error' + } + }, + pluginPrettierRecommended +]; diff --git a/index.js b/index.js index 1dd6872c..12acaade 100644 --- a/index.js +++ b/index.js @@ -9,6 +9,22 @@ const url = require('url'); const pkg = require('./package'); const resources = require('./resources'); +const retryableErrorCodes = new Set([ + 'ETIMEDOUT', + 'ECONNRESET', + 'EADDRINUSE', + 'ECONNREFUSED', + 'EPIPE', + 'ENOTFOUND', + 'ENETUNREACH', + 'EAI_AGAIN' +]); +const retryableErrorCodesArray = Array.from(retryableErrorCodes); + +const retryableStatusCodesArray = [ + 408, 413, 429, 500, 502, 503, 504, 521, 522, 524 +]; + /** * Creates a Shopify instance. * @@ -25,6 +41,8 @@ const resources = require('./resources'); * @param {Function} [options.parseJson] The function used to parse JSON * @param {Function} [options.stringifyJson] The function used to serialize to * JSON + * @param {Number} [options.maxRetries] Maximum number of automatic request + * retries * @constructor * @public */ @@ -34,7 +52,8 @@ function Shopify(options) { !options || !options.shopName || (!options.accessToken && (!options.apiKey || !options.password)) || - (options.accessToken && (options.apiKey || options.password)) + (options.accessToken && (options.apiKey || options.password)) || + (options.autoLimit && options.maxRetries) ) { throw new Error('Missing or invalid options'); } @@ -44,6 +63,7 @@ function Shopify(options) { parseJson: JSON.parse, stringifyJson: JSON.stringify, timeout: 60000, + maxRetries: 0, ...options }; @@ -129,66 +149,85 @@ Shopify.prototype.updateLimits = function updateLimits(header) { */ Shopify.prototype.request = function request(uri, method, key, data, headers) { const options = { + agent: this.options.agent, headers: { ...headers, ...this.baseHeaders }, - stringifyJson: this.options.stringifyJson, + method, parseJson: this.options.parseJson, - timeout: this.options.timeout, responseType: 'json', - retry: 0, - method + stringifyJson: this.options.stringifyJson, + timeout: this.options.timeout + }; + + const afterResponse = (res) => { + this.updateLimits(res.headers['x-shopify-shop-api-call-limit']); + return res; }; + if (this.options.hooks) { + options.hooks = { ...this.options.hooks }; + options.hooks.afterResponse = [afterResponse]; + + if (this.options.hooks.afterResponse) { + options.hooks.afterResponse.push(...this.options.hooks.afterResponse); + } + } else { + options.hooks = { afterResponse: [afterResponse] }; + } + if (data) { options.json = key ? { [key]: data } : data; } - return got(uri, options).then( - (res) => { - const body = res.body; + if (this.options.maxRetries > 0) { + options.retry = { + calculateDelay, + errorCodes: retryableErrorCodesArray, + limit: this.options.maxRetries, + // Don't clamp Shopify `Retry-After` header values too low. + maxRetryAfter: Infinity, + methods: [method], + statusCodes: retryableStatusCodesArray + }; + } else { + options.retry = 0; + } - this.updateLimits(res.headers['x-shopify-shop-api-call-limit']); + return got(uri, options).then((res) => { + const body = res.body; - if (res.statusCode === 202 && res.headers['location']) { - const retryAfter = res.headers['retry-after'] * 1000 || 0; - const { pathname, search } = url.parse(res.headers['location']); + if (res.statusCode === 202 && res.headers['location']) { + const retryAfter = res.headers['retry-after'] * 1000 || 0; + const { pathname, search } = url.parse(res.headers['location']); - return delay(retryAfter).then(() => { - const uri = { pathname, ...this.baseUrl }; + return delay(retryAfter).then(() => { + const uri = { pathname, ...this.baseUrl }; - if (search) uri.search = search; + if (search) uri.search = search; - return this.request(uri, 'GET', key); - }); - } - - const data = key ? body[key] : body || {}; + return this.request(uri, 'GET', key); + }); + } - if (res.headers.link) { - const link = parseLinkHeader(res.headers.link); + const data = key ? body[key] : body || {}; - if (link.next) { - Object.defineProperties(data, { - nextPageParameters: { value: link.next.query } - }); - } + if (res.headers.link) { + const link = parseLinkHeader(res.headers.link); - if (link.previous) { - Object.defineProperties(data, { - previousPageParameters: { value: link.previous.query } - }); - } + if (link.next) { + Object.defineProperties(data, { + nextPageParameters: { value: link.next.query } + }); } - return data; - }, - (err) => { - this.updateLimits( - err.response && err.response.headers['x-shopify-shop-api-call-limit'] - ); - - return Promise.reject(err); + if (link.previous) { + Object.defineProperties(data, { + previousPageParameters: { value: link.previous.query } + }); + } } - ); + + return data; + }); }; /** @@ -234,43 +273,99 @@ Shopify.prototype.graphql = function graphql(data, variables) { pathname += '/graphql.json'; const uri = { pathname, ...this.baseUrl }; - const json = variables !== undefined && variables !== null; const options = { + agent: this.options.agent, + body: this.options.stringifyJson({ query: data, variables }), headers: { ...this.baseHeaders, - 'Content-Type': json ? 'application/json' : 'application/graphql' + 'Content-Type': 'application/json' }, + method: 'POST', parseJson: this.options.parseJson, - timeout: this.options.timeout, responseType: 'json', - retry: 0, - method: 'POST', - body: json ? this.options.stringifyJson({ query: data, variables }) : data + timeout: this.options.timeout }; - return got(uri, options).then((res) => { - if (res.body.extensions && res.body.extensions.cost) { + const updateGqlLimits = (res) => { + if (res.body && res.body.extensions && res.body.extensions.cost) { this.updateGraphqlLimits(res.body.extensions.cost); } - if (res.body.errors) { - const first = res.body.errors[0]; - const err = new Error(first.message); + return res; + }; - err.locations = first.locations; - err.path = first.path; - err.extensions = first.extensions; - err.response = res; + const maybeError = (res) => { + if (res.body && Array.isArray(res.body.errors)) { + throw new Error(res.body.errors[0].message); + } - throw err; + return res; + }; + + if (this.options.hooks) { + options.hooks = { ...this.options.hooks }; + options.hooks.afterResponse = [updateGqlLimits]; + options.hooks.beforeError = [decorateError]; + + if (this.options.hooks.afterResponse) { + options.hooks.afterResponse.push(...this.options.hooks.afterResponse); } - return res.body.data || {}; - }); + options.hooks.afterResponse.push(maybeError); + + if (this.options.hooks.beforeError) { + options.hooks.beforeError.push(...this.options.hooks.beforeError); + } + } else { + options.hooks = { + afterResponse: [updateGqlLimits, maybeError], + beforeError: [decorateError] + }; + } + + if (this.options.maxRetries > 0) { + options.retry = { + calculateDelay, + errorCodes: retryableErrorCodesArray, + limit: this.options.maxRetries, + // Don't clamp Shopify `Retry-After` header values too low. + maxRetryAfter: Infinity, + methods: ['POST'], + statusCodes: retryableStatusCodesArray + }; + } else { + options.retry = 0; + } + + return got(uri, options).then(responseData); }; resources.registerAll(Shopify); +/** + * Decorates an `Error` object with details from GraphQL errors in the response + * body. + * + * @param {Error} error The error to decorate + * @return {Error} The decorated error + * @private + */ +function decorateError(error) { + if ( + error.response && + error.response.body && + Array.isArray(error.response.body.errors) + ) { + const first = error.response.body.errors[0]; + + error.locations = first.locations; + error.path = first.path; + error.extensions = first.extensions; + } + + return error; +} + /** * Returns a promise that resolves after a given amount of time. * @@ -282,6 +377,59 @@ function delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } +/** + * Given an error from Got, see if Shopify told us how long to wait before + * retrying. + * + * @param {Object} retryObject Got's input for the retry logic + * @return {Number} The duration in ms + * @private + **/ +function calculateDelay(retryObject) { + const { error, computedValue } = retryObject; + const response = error.response; + + // Detect GraphQL request throttling. + if ( + response && + response.statusCode === 200 && + response.body && + typeof response.body === 'object' && + Array.isArray(response.body.errors) && + response.body.errors[0].extensions && + response.body.errors[0].extensions.code == 'THROTTLED' + ) { + const costData = response.body.extensions.cost; + + return ( + ((costData.requestedQueryCost - + costData.throttleStatus.currentlyAvailable) / + costData.throttleStatus.restoreRate) * + 1000 + ); + } + + // Stop retrying if the attempt limit has been reached or the request is not + // retryable. + if (computedValue === 0) { + return 0; + } + + // For simplicity, retry network connectivity issues after a hardcoded 1s. + if (retryableErrorCodes.has(error.code)) { + return 1000; + } + + if (response.headers && response.headers['retry-after']) { + return response.headers['retry-after'] * 1000 || computedValue; + } + + // Arbitrary 2 seconds, in case we get a 429 without a `Retry-After` + // response header, or 4xx/5xx series error that matches the Got retry + // defaults. + return 2 * 1000; +} + /** * Parses the `Link` header into an object. * @@ -313,4 +461,15 @@ function reducer(acc, cur) { return acc; } +/** + * Returns the data of a GraphQL response object. + * + * @param {Response} res Got response object + * @return {Object} The data + * @private + */ +function responseData(res) { + return res.body.data; +} + module.exports = Shopify; diff --git a/mixins/shopify-payments-child.js b/mixins/shopify-payments-child.js new file mode 100644 index 00000000..5045818a --- /dev/null +++ b/mixins/shopify-payments-child.js @@ -0,0 +1,43 @@ +'use strict'; + +const qs = require('qs'); + +/** + * This provides methods used by the Shopify Payments resources. It's not meant + * to be used directly. + * + * @mixin + */ +const shopifyPaymentsChild = { + /** + * Builds the request URL. + * + * @param {Number} parentId Parent record ID + * @param {Number|String} [id] Record ID + * @param {Object} [query] Query parameters + * @return {Object} URL object + * @private + */ + buildUrl(parentId, id, query) { + id || id === 0 || (id = ''); + + let pathname = '/admin'; + + if (this.shopify.options.apiVersion) { + pathname += `/api/${this.shopify.options.apiVersion}`; + } + + pathname += `/shopify_payments/${this.parentName}/${parentId}/${this.name}/${id}`; + pathname = pathname.replace(/\/+/g, '/').replace(/\/$/, '') + '.json'; + + const url = { pathname, ...this.shopify.baseUrl }; + + if (query) { + url.search = '?' + qs.stringify(query, { arrayFormat: 'brackets' }); + } + + return url; + } +}; + +module.exports = shopifyPaymentsChild; diff --git a/package.json b/package.json index 10653a12..bbd540e7 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,9 @@ { "name": "shopify-api-node", - "version": "3.8.1", + "version": "3.15.0", "description": "Shopify API bindings for Node.js", "main": "index.js", + "types": "types/index.d.ts", "directories": { "test": "test" }, @@ -13,7 +14,7 @@ "resources", "mixins", "index.js", - "index.d.ts" + "types/index.d.ts" ], "dependencies": { "got": "^11.1.4", @@ -24,20 +25,23 @@ "devDependencies": { "c8": "^7.3.0", "chai": "^4.1.2", - "eslint": "^8.0.0", - "eslint-config-prettier": "^8.1.0", - "eslint-plugin-prettier": "^4.0.0", - "husky": "^7.0.2", + "eslint": "^9.2.0", + "eslint-config-prettier": "^10.1.1", + "eslint-plugin-prettier": "^5.0.0", + "globals": "^16.0.0", + "husky": "^8.0.1", "json-bigint": "^1.0.0", - "lint-staged": "^12.1.2", + "lint-staged": "^15.2.0", "mocha": "^8.0.1", "nock": "^13.0.1", - "prettier": "^2.0.2" + "prettier": "^3.0.0", + "tsd": "^0.27.0" }, "scripts": { "test": "c8 --reporter=lcov --reporter=text mocha", + "test:types": "tsd", "watch": "mocha -w", - "lint": "eslint --ignore-path .gitignore . && prettier --check --ignore-path .gitignore \"**/*.{json,md,ts,yaml,yml}\"", + "lint": "eslint . && prettier --check --ignore-path .gitignore \"**/*.{json,md,ts,yaml,yml}\"", "prepare": "husky install" }, "repository": "MONEI/Shopify-api-node", diff --git a/resources/deprecated-api-call.js b/resources/deprecated-api-call.js new file mode 100644 index 00000000..10ad20b2 --- /dev/null +++ b/resources/deprecated-api-call.js @@ -0,0 +1,23 @@ +'use strict'; + +const assign = require('lodash/assign'); +const pick = require('lodash/pick'); + +const base = require('../mixins/base'); + +/** + * Creates a DeprecatedApiCall instance. + * + * @param {Shopify} shopify Reference to the Shopify instance + * @constructor + * @public + */ +function DeprecatedApiCall(shopify) { + this.shopify = shopify; + + this.name = 'deprecated_api_calls'; +} + +assign(DeprecatedApiCall.prototype, pick(base, ['buildUrl', 'list'])); + +module.exports = DeprecatedApiCall; diff --git a/resources/dispute-evidence.js b/resources/dispute-evidence.js new file mode 100644 index 00000000..f17e6ec3 --- /dev/null +++ b/resources/dispute-evidence.js @@ -0,0 +1,45 @@ +'use strict'; + +const shopifyPaymentsChild = require('../mixins/shopify-payments-child'); +const baseChild = require('../mixins/base-child'); + +/** + * Creates a DisputeEvidence instance. + * + * @param {Shopify} shopify Reference to the Shopify instance + * @constructor + * @public + */ +function DisputeEvidence(shopify) { + this.shopify = shopify; + + this.parentName = 'disputes'; + this.name = 'dispute_evidences'; + this.key = 'dispute_evidence'; +} + +DisputeEvidence.prototype.buildUrl = shopifyPaymentsChild.buildUrl; + +/** + * Returns the dispute evidence associated with the dispute ID + * + * @param {Number} disputeId The dispute ID + * @param {Object} [params] Query parameters + * @return {Promise} Promise that resolves with the result + */ +DisputeEvidence.prototype.get = function get(disputeId, params) { + return baseChild.get.call(this, disputeId, undefined, params); +}; + +/** + * Updates the dispute evidence associated with dispute ID + * + * @param {Number} disputeId The dispute ID + * @param {Object} params An object containing the refund refusal explanation + * @return {Promise} Promise that resolves with the result + */ +DisputeEvidence.prototype.update = function update(disputeId, params) { + return baseChild.update.call(this, disputeId, undefined, params); +}; + +module.exports = DisputeEvidence; diff --git a/resources/dispute-file-upload.js b/resources/dispute-file-upload.js new file mode 100644 index 00000000..15d9a93c --- /dev/null +++ b/resources/dispute-file-upload.js @@ -0,0 +1,27 @@ +'use strict'; + +const assign = require('lodash/assign'); +const pick = require('lodash/pick'); + +const shopifyPaymentsChild = require('../mixins/shopify-payments-child'); +const baseChild = require('../mixins/base-child'); + +/** + * Creates a DisputeFileUpload instance. + * + * @param {Shopify} shopify Reference to the Shopify instance + * @constructor + * @public + */ +function DisputeFileUpload(shopify) { + this.shopify = shopify; + + this.parentName = 'disputes'; + this.name = 'dispute_file_uploads'; + this.key = 'dispute_file_upload'; +} + +assign(DisputeFileUpload.prototype, pick(baseChild, ['create', 'delete'])); +DisputeFileUpload.prototype.buildUrl = shopifyPaymentsChild.buildUrl; + +module.exports = DisputeFileUpload; diff --git a/resources/fulfillment-order.js b/resources/fulfillment-order.js index 14808381..730f620e 100644 --- a/resources/fulfillment-order.js +++ b/resources/fulfillment-order.js @@ -41,13 +41,14 @@ FulfillmentOrder.prototype.list = function list(params) { * Marks a fulfillment order as cancelled. * * @param {Number} id Fulfillment order ID - * @param {Object} params Fulfillment order properties * @return {Promise} Promise that resolves with the result * @public */ -FulfillmentOrder.prototype.cancel = function cancel(id, params) { +FulfillmentOrder.prototype.cancel = function cancel(id) { const url = this.buildUrl(`${id}/cancel`); - return this.shopify.request(url, 'POST', this.key, params); + return this.shopify + .request(url, 'POST', undefined, {}) + .then((body) => body[this.key]); }; /** @@ -100,4 +101,81 @@ FulfillmentOrder.prototype.locationsForMove = function locationsForMove(id) { return this.shopify.request(url, 'GET', 'locations_for_move'); }; +/** + * Sets the latest date and time by which the fulfillment orders need to be + * fulfilled. + * + * @param {Object} params An object containing the new fulfillment deadline and + * and the IDs of the fulfillment orders for which the deadline is being set + * @return {Promise} Promise that resolves with the result + * @public + */ +FulfillmentOrder.prototype.setFulfillmentOrdersDeadline = + function setFulfillmentOrdersDeadline(params) { + const url = this.buildUrl('set_fulfillment_orders_deadline'); + return this.shopify.request(url, 'POST', undefined, params); + }; + +/** + * Lists fulfillments for a fulfillment order. + * + * @param {Number} id Fulfillment order ID + * @return {Promise} Promise that resolves with the result + * @public + */ +FulfillmentOrder.prototype.fulfillments = function fulfillments(id) { + const url = this.buildUrl(`${id}/fulfillments`); + return this.shopify.request(url, 'GET', 'fulfillments'); +}; + +/** + * Halts all fulfillment work on a fulfillment order with status `OPEN` and + * changes the status of the fulfillment order to `ON_HOLD`. + * + * @param {Number} id Fulfillment Order ID + * @param {Object} params An object containing the reason for the fulfillment + hold and additional optional information + * @return {Promise} Promise that resolves with the result + * @public + */ +FulfillmentOrder.prototype.hold = function hold(id, params) { + const url = this.buildUrl(`${id}/hold`); + return this.shopify + .request(url, 'POST', undefined, { fulfillment_hold: params }) + .then((body) => body[this.key]); +}; + +/** + * Release the fulfillment hold on a fulfillment order and changes the status of + * the fulfillment order to `OPEN` or `SCHEDULED`. + * + * @param {Number} id Fulfillment Order ID + * @return {Promise} Promise that resolves with the result + * @public + */ +FulfillmentOrder.prototype.releaseHold = function releaseHold(id) { + const url = this.buildUrl(`${id}/release_hold`); + return this.shopify + .request(url, 'POST', undefined, {}) + .then((body) => body[this.key]); +}; + +/** + * Reschedules a scheduled fulfillment order. Updates the value of the + * `fulfill_at field` on a scheduled fulfillment order. The fulfillment order + * will be marked as ready for fulfillment at this date and time. + * + * @param {Number} id Fulfillment Order ID + * @param {String} deadline The new fulfillment deadline of the fulfillment + * order + * @return {Promise} Promise that resolves with the result + * @public + */ +FulfillmentOrder.prototype.reschedule = function reschedule(id, deadline) { + const url = this.buildUrl(`${id}/reschedule`); + return this.shopify.request(url, 'POST', this.key, { + new_fulfill_at: deadline + }); +}; + module.exports = FulfillmentOrder; diff --git a/resources/fulfillment.js b/resources/fulfillment.js index ff1768d5..fa94b437 100644 --- a/resources/fulfillment.js +++ b/resources/fulfillment.js @@ -68,6 +68,20 @@ Fulfillment.prototype.cancel = function cancel(orderId, id) { .then((body) => body[this.key]); }; +/** + * Cancels a fulfillment. + * + * @param {Number} id Fulfillment ID + * @return {Promise} Promise that resolves with the result + * @public + */ +Fulfillment.prototype.cancelV2 = function cancelV2(id) { + const url = base.buildUrl.call(this, `${id}/cancel`); + return this.shopify + .request(url, 'POST', undefined, {}) + .then((body) => body[this.key]); +}; + /** * Creates a fulfillment for one or many fulfillment orders. The fulfillment * orders are associated with the same order and are assigned to the same diff --git a/resources/index.js b/resources/index.js index 2295f6ab..83e43352 100644 --- a/resources/index.js +++ b/resources/index.js @@ -1,73 +1,76 @@ 'use strict'; const map = { - accessScope: 'access-scope', - apiPermission: 'api-permission', - applicationCharge: 'application-charge', - applicationCredit: 'application-credit', - article: 'article', - asset: 'asset', - balance: 'balance', - blog: 'blog', - cancellationRequest: 'cancellation-request', - carrierService: 'carrier-service', - checkout: 'checkout', - collect: 'collect', - collection: 'collection', - collectionListing: 'collection-listing', - comment: 'comment', - country: 'country', - currency: 'currency', - customCollection: 'custom-collection', - customer: 'customer', - customerAddress: 'customer-address', - customerSavedSearch: 'customer-saved-search', - discountCode: 'discount-code', - discountCodeCreationJob: 'discount-code-creation-job', - dispute: 'dispute', - draftOrder: 'draft-order', - event: 'event', - fulfillment: 'fulfillment', - fulfillmentEvent: 'fulfillment-event', - fulfillmentOrder: 'fulfillment-order', - fulfillmentRequest: 'fulfillment-request', - fulfillmentService: 'fulfillment-service', - giftCard: 'gift-card', - giftCardAdjustment: 'gift-card-adjustment', - inventoryItem: 'inventory-item', - inventoryLevel: 'inventory-level', - location: 'location', - marketingEvent: 'marketing-event', - metafield: 'metafield', - order: 'order', - orderRisk: 'order-risk', - page: 'page', - payment: 'payment', - payout: 'payout', - policy: 'policy', - priceRule: 'price-rule', - product: 'product', - productImage: 'product-image', - productListing: 'product-listing', - productResourceFeedback: 'product-resource-feedback', - productVariant: 'product-variant', - province: 'province', - recurringApplicationCharge: 'recurring-application-charge', - redirect: 'redirect', - refund: 'refund', - report: 'report', - resourceFeedback: 'resource-feedback', - scriptTag: 'script-tag', - shippingZone: 'shipping-zone', - shop: 'shop', - smartCollection: 'smart-collection', - storefrontAccessToken: 'storefront-access-token', - tenderTransaction: 'tender-transaction', - theme: 'theme', - transaction: 'transaction', - usageCharge: 'usage-charge', - user: 'user', - webhook: 'webhook' + accessScope: () => require('./access-scope'), + apiPermission: () => require('./api-permission'), + applicationCharge: () => require('./application-charge'), + applicationCredit: () => require('./application-credit'), + article: () => require('./article'), + asset: () => require('./asset'), + balance: () => require('./balance'), + blog: () => require('./blog'), + cancellationRequest: () => require('./cancellation-request'), + carrierService: () => require('./carrier-service'), + checkout: () => require('./checkout'), + collect: () => require('./collect'), + collection: () => require('./collection'), + collectionListing: () => require('./collection-listing'), + comment: () => require('./comment'), + country: () => require('./country'), + currency: () => require('./currency'), + customCollection: () => require('./custom-collection'), + customer: () => require('./customer'), + customerAddress: () => require('./customer-address'), + customerSavedSearch: () => require('./customer-saved-search'), + deprecatedApiCall: () => require('./deprecated-api-call'), + discountCode: () => require('./discount-code'), + discountCodeCreationJob: () => require('./discount-code-creation-job'), + dispute: () => require('./dispute'), + disputeEvidence: () => require('./dispute-evidence'), + disputeFileUpload: () => require('./dispute-file-upload'), + draftOrder: () => require('./draft-order'), + event: () => require('./event'), + fulfillment: () => require('./fulfillment'), + fulfillmentEvent: () => require('./fulfillment-event'), + fulfillmentOrder: () => require('./fulfillment-order'), + fulfillmentRequest: () => require('./fulfillment-request'), + fulfillmentService: () => require('./fulfillment-service'), + giftCard: () => require('./gift-card'), + giftCardAdjustment: () => require('./gift-card-adjustment'), + inventoryItem: () => require('./inventory-item'), + inventoryLevel: () => require('./inventory-level'), + location: () => require('./location'), + marketingEvent: () => require('./marketing-event'), + metafield: () => require('./metafield'), + order: () => require('./order'), + orderRisk: () => require('./order-risk'), + page: () => require('./page'), + payment: () => require('./payment'), + payout: () => require('./payout'), + policy: () => require('./policy'), + priceRule: () => require('./price-rule'), + product: () => require('./product'), + productImage: () => require('./product-image'), + productListing: () => require('./product-listing'), + productResourceFeedback: () => require('./product-resource-feedback'), + productVariant: () => require('./product-variant'), + province: () => require('./province'), + recurringApplicationCharge: () => require('./recurring-application-charge'), + redirect: () => require('./redirect'), + refund: () => require('./refund'), + report: () => require('./report'), + resourceFeedback: () => require('./resource-feedback'), + scriptTag: () => require('./script-tag'), + shippingZone: () => require('./shipping-zone'), + shop: () => require('./shop'), + smartCollection: () => require('./smart-collection'), + storefrontAccessToken: () => require('./storefront-access-token'), + tenderTransaction: () => require('./tender-transaction'), + theme: () => require('./theme'), + transaction: () => require('./transaction'), + usageCharge: () => require('./usage-charge'), + user: () => require('./user'), + webhook: () => require('./webhook') }; /** @@ -77,18 +80,18 @@ const map = { * @private */ function registerAll(Shopify) { - Object.keys(map).forEach((prop) => { - Object.defineProperty(Shopify.prototype, prop, { + Object.entries(map).forEach(([resourceName, importResource]) => { + Object.defineProperty(Shopify.prototype, resourceName, { configurable: true, get: function get() { - const resource = require(`./${map[prop]}`); + const resource = importResource(); - return Object.defineProperty(this, prop, { + return Object.defineProperty(this, resourceName, { value: new resource(this) - })[prop]; + })[resourceName]; }, set: function set(value) { - Object.defineProperty(this, prop, { value }); + Object.defineProperty(this, resourceName, { value }); } }); }); diff --git a/test/access-scope.test.js b/test/access-scope.test.js index f2a9a6ff..a16832d9 100644 --- a/test/access-scope.test.js +++ b/test/access-scope.test.js @@ -9,7 +9,7 @@ describe('Shopify#accessScope', () => { const shopify = common.shopify; const scope = common.scope; - afterEach(() => expect(scope.isDone()).to.be.true); + afterEach(() => expect(scope.pendingMocks()).to.deep.equal([])); it('gets a list of access scopes associated to the access token', () => { const output = fixtures.res.list; diff --git a/test/api-permission.test.js b/test/api-permission.test.js index 1fa95f5a..2178c99f 100644 --- a/test/api-permission.test.js +++ b/test/api-permission.test.js @@ -8,7 +8,7 @@ describe('Shopify#apiPermission', () => { const shopify = common.shopify; const scope = common.scope; - afterEach(() => expect(scope.isDone()).to.be.true); + afterEach(() => expect(scope.pendingMocks()).to.deep.equal([])); it('deletes an api permission', () => { scope.delete('/admin/api_permissions/current.json').reply(200); diff --git a/test/application-charge.test.js b/test/application-charge.test.js index cfb386a7..f7a014c7 100644 --- a/test/application-charge.test.js +++ b/test/application-charge.test.js @@ -9,7 +9,7 @@ describe('Shopify#applicationCharge', () => { const shopify = common.shopify; const scope = common.scope; - afterEach(() => expect(scope.isDone()).to.be.true); + afterEach(() => expect(scope.pendingMocks()).to.deep.equal([])); it('create a new one-time application charge', () => { const input = fixtures.req.create; diff --git a/test/application-credit.test.js b/test/application-credit.test.js index 9cf6400c..d3140f00 100644 --- a/test/application-credit.test.js +++ b/test/application-credit.test.js @@ -9,7 +9,7 @@ describe('Shopify#applicationCredit', () => { const shopify = common.shopify; const scope = common.scope; - afterEach(() => expect(scope.isDone()).to.be.true); + afterEach(() => expect(scope.pendingMocks()).to.deep.equal([])); it('creates an application credit', () => { const input = fixtures.req.create; diff --git a/test/article.test.js b/test/article.test.js index aa724564..158a8d7a 100644 --- a/test/article.test.js +++ b/test/article.test.js @@ -9,7 +9,7 @@ describe('Shopify#article', () => { const shopify = common.shopify; const scope = common.scope; - afterEach(() => expect(scope.isDone()).to.be.true); + afterEach(() => expect(scope.pendingMocks()).to.deep.equal([])); it('gets a list of all articles from a certain blog (1/2)', () => { const output = fixtures.res.list; diff --git a/test/asset.test.js b/test/asset.test.js index 7dd9244f..1a78e4ca 100644 --- a/test/asset.test.js +++ b/test/asset.test.js @@ -10,7 +10,7 @@ describe('Shopify#asset', () => { const shopify = common.shopify; const scope = common.scope; - afterEach(() => expect(scope.isDone()).to.be.true); + afterEach(() => expect(scope.pendingMocks()).to.deep.equal([])); it('gets a list of all theme assets (1/2)', () => { const output = fixtures.res.list; diff --git a/test/balance.test.js b/test/balance.test.js index 9e109e86..e93d25e2 100644 --- a/test/balance.test.js +++ b/test/balance.test.js @@ -9,7 +9,7 @@ describe('Shopify#balance', () => { const shopify = common.shopify; const scope = common.scope; - afterEach(() => expect(scope.isDone()).to.be.true); + afterEach(() => expect(scope.pendingMocks()).to.deep.equal([])); it('gets the current balance', () => { const output = fixtures.res.list; diff --git a/test/blog.test.js b/test/blog.test.js index f9442f55..cb38755b 100644 --- a/test/blog.test.js +++ b/test/blog.test.js @@ -9,7 +9,7 @@ describe('Shopify#blog', () => { const shopify = common.shopify; const scope = common.scope; - afterEach(() => expect(scope.isDone()).to.be.true); + afterEach(() => expect(scope.pendingMocks()).to.deep.equal([])); it('gets a list of all blogs (1/2)', () => { const output = fixtures.res.list; diff --git a/test/cancellation-request.test.js b/test/cancellation-request.test.js index 6af58163..dc9f0ee4 100644 --- a/test/cancellation-request.test.js +++ b/test/cancellation-request.test.js @@ -9,7 +9,7 @@ describe('Shopify#cancellation-request', () => { const shopify = common.shopify; const scope = common.scope; - afterEach(() => expect(scope.isDone()).to.be.true); + afterEach(() => expect(scope.pendingMocks()).to.deep.equal([])); it('sends a cancellation request (1/2)', () => { const output = fixtures.res.create; diff --git a/test/carrier-service.test.js b/test/carrier-service.test.js index de976986..f2559913 100644 --- a/test/carrier-service.test.js +++ b/test/carrier-service.test.js @@ -9,7 +9,7 @@ describe('Shopify#carrierService', () => { const shopify = common.shopify; const scope = common.scope; - afterEach(() => expect(scope.isDone()).to.be.true); + afterEach(() => expect(scope.pendingMocks()).to.deep.equal([])); it('creates a carrier service', () => { const input = fixtures.req.create; diff --git a/test/checkout.test.js b/test/checkout.test.js index 9cc4c70b..4fa33f7f 100644 --- a/test/checkout.test.js +++ b/test/checkout.test.js @@ -9,7 +9,7 @@ describe('Shopify#checkout', () => { const shopify = common.shopify; const scope = common.scope; - afterEach(() => expect(scope.isDone()).to.be.true); + afterEach(() => expect(scope.pendingMocks()).to.deep.equal([])); it('gets a count of all checkout (1/2)', () => { scope.get('/admin/checkouts/count.json').reply(200, { count: 5 }); diff --git a/test/collect.test.js b/test/collect.test.js index 38aa248e..6b3e0ca8 100644 --- a/test/collect.test.js +++ b/test/collect.test.js @@ -9,7 +9,7 @@ describe('Shopify#collect', () => { const shopify = common.shopify; const scope = common.scope; - afterEach(() => expect(scope.isDone()).to.be.true); + afterEach(() => expect(scope.pendingMocks()).to.deep.equal([])); it('adds a product to collection', () => { const input = fixtures.req.create; diff --git a/test/collection-listing.test.js b/test/collection-listing.test.js index 1b698327..eafc6147 100644 --- a/test/collection-listing.test.js +++ b/test/collection-listing.test.js @@ -9,7 +9,7 @@ describe('Shopify#collectionListing', () => { const shopify = common.shopify; const scope = common.scope; - afterEach(() => expect(scope.isDone()).to.be.true); + afterEach(() => expect(scope.pendingMocks()).to.deep.equal([])); it('gets collection listings published to an application (1/2)', () => { const output = fixtures.res.list; diff --git a/test/collection.test.js b/test/collection.test.js index b225f6ad..4ef7ad0a 100644 --- a/test/collection.test.js +++ b/test/collection.test.js @@ -9,7 +9,7 @@ describe('Shopify#collection', () => { const shopify = common.shopify; const scope = common.scope; - afterEach(() => expect(scope.isDone()).to.be.true); + afterEach(() => expect(scope.pendingMocks()).to.deep.equal([])); it('gets a collection by its ID (1/2)', () => { const output = fixtures.res.get; diff --git a/test/comment.test.js b/test/comment.test.js index ee708e1a..d1b22385 100644 --- a/test/comment.test.js +++ b/test/comment.test.js @@ -9,7 +9,7 @@ describe('Shopify#comment', () => { const shopify = common.shopify; const scope = common.scope; - afterEach(() => expect(scope.isDone()).to.be.true); + afterEach(() => expect(scope.pendingMocks()).to.deep.equal([])); it('gets a list of all comments (1/2)', () => { const output = fixtures.res.list; diff --git a/test/common.js b/test/common.js index da097024..860bfdbd 100644 --- a/test/common.js +++ b/test/common.js @@ -17,6 +17,11 @@ const shopifyWithPresentmentOption = new Shopify({ presentmentPrices: true, shopName }); +const shopifyWithRetries = new Shopify({ + accessToken, + shopName, + maxRetries: 3 +}); const scope = nock(`https://${shopName}.myshopify.com`, { reqheaders: { @@ -37,8 +42,42 @@ const presentmentApiScope = nock(`https://${shopName}.myshopify.com`, { badheaders: ['Authorization'] }); +/** + * Add a working (200 status code) mock for the REST Admin API. + * + * @param {Scope} scope An instance of the `Scope` class of the nock module. + * @public + */ +function addWorkingRESTRequestMock(scope) { + scope.get('/admin/shop.json').reply(200, { + shop: { + id: 1, + name: 'My Cool Test Shop' + } + }); +} + +/** + * Add a working (200 status code) mock for the GraphQL Admin API. + * + * @param {Scope} scope An instance of the `Scope` class of the nock module. + * @public + */ +function addWorkingGraphQLRequestMock(scope) { + scope.post('/admin/api/graphql.json').reply(200, { + data: { + shop: { + id: 1, + name: 'My Cool Test Shop' + } + } + }); +} + module.exports = { accessToken, + addWorkingGraphQLRequestMock, + addWorkingRESTRequestMock, apiKey, apiVersion, password, @@ -46,5 +85,6 @@ module.exports = { scope, shopify, shopifyWithPresentmentOption, + shopifyWithRetries, shopName }; diff --git a/test/country.test.js b/test/country.test.js index f173e559..45587939 100644 --- a/test/country.test.js +++ b/test/country.test.js @@ -9,7 +9,7 @@ describe('Shopify#country', () => { const shopify = common.shopify; const scope = common.scope; - afterEach(() => expect(scope.isDone()).to.be.true); + afterEach(() => expect(scope.pendingMocks()).to.deep.equal([])); it('gets a list of all countries (1/2)', () => { const output = fixtures.res.list; diff --git a/test/currency.test.js b/test/currency.test.js index a22d5732..4042d09b 100644 --- a/test/currency.test.js +++ b/test/currency.test.js @@ -9,7 +9,7 @@ describe('Shopify#currency', () => { const shopify = common.shopify; const scope = common.scope; - afterEach(() => expect(scope.isDone()).to.be.true); + afterEach(() => expect(scope.pendingMocks()).to.deep.equal([])); it('gets a list of currencies enabled on a shop', () => { const output = fixtures.res.list; diff --git a/test/custom-collection.test.js b/test/custom-collection.test.js index e3f2c611..034f4b13 100644 --- a/test/custom-collection.test.js +++ b/test/custom-collection.test.js @@ -9,7 +9,7 @@ describe('Shopify#customCollection', () => { const shopify = common.shopify; const scope = common.scope; - afterEach(() => expect(scope.isDone()).to.be.true); + afterEach(() => expect(scope.pendingMocks()).to.deep.equal([])); it('gets a list of all custom collections (1/2)', () => { const output = fixtures.res.list; diff --git a/test/customer-address.test.js b/test/customer-address.test.js index a9f6a1c4..2101ffcd 100644 --- a/test/customer-address.test.js +++ b/test/customer-address.test.js @@ -10,7 +10,7 @@ describe('Shopify#customerAddress', () => { const shopify = common.shopify; const scope = common.scope; - afterEach(() => expect(scope.isDone()).to.be.true); + afterEach(() => expect(scope.pendingMocks()).to.deep.equal([])); it('gets a list of all addresses from a customer (1/2)', () => { const output = fixtures.res.list; diff --git a/test/customer-saved-search.test.js b/test/customer-saved-search.test.js index b4df13ca..dbb2b1f4 100644 --- a/test/customer-saved-search.test.js +++ b/test/customer-saved-search.test.js @@ -9,7 +9,7 @@ describe('Shopify#customerSavedSearch', () => { const shopify = common.shopify; const scope = common.scope; - afterEach(() => expect(scope.isDone()).to.be.true); + afterEach(() => expect(scope.pendingMocks()).to.deep.equal([])); it('gets a list of all customers saved searches (1/2)', () => { const output = fixtures.res.list; diff --git a/test/customer.test.js b/test/customer.test.js index 9ce527a2..e2fac835 100644 --- a/test/customer.test.js +++ b/test/customer.test.js @@ -10,7 +10,7 @@ describe('Shopify#customer', () => { const shopify = common.shopify; const scope = common.scope; - afterEach(() => expect(scope.isDone()).to.be.true); + afterEach(() => expect(scope.pendingMocks()).to.deep.equal([])); it('gets a list of all customers (1/2)', () => { const output = fixtures.res.list; diff --git a/test/deprecated-api-call.test.js b/test/deprecated-api-call.test.js new file mode 100644 index 00000000..82e83e6c --- /dev/null +++ b/test/deprecated-api-call.test.js @@ -0,0 +1,23 @@ +describe('Shopify#deprecatedApiCall', () => { + 'use strict'; + + const expect = require('chai').expect; + + const fixtures = require('./fixtures/deprecated-api-call'); + const common = require('./common'); + + const shopify = common.shopify; + const scope = common.scope; + + afterEach(() => expect(scope.pendingMocks()).to.deep.equal([])); + + it('gets a list of deprecated API calls', () => { + const output = fixtures.res.list; + + scope.get('/admin/deprecated_api_calls.json').reply(200, output); + + return shopify.deprecatedApiCall + .list() + .then((data) => expect(data).to.deep.equal(output.deprecated_api_calls)); + }); +}); diff --git a/test/discount-code-creation-job.test.js b/test/discount-code-creation-job.test.js index de6cfb26..637ed68e 100644 --- a/test/discount-code-creation-job.test.js +++ b/test/discount-code-creation-job.test.js @@ -9,7 +9,7 @@ describe('Shopify#discountCodeCreationJob', () => { const shopify = common.shopify; const scope = common.scope; - afterEach(() => expect(scope.isDone()).to.be.true); + afterEach(() => expect(scope.pendingMocks()).to.deep.equal([])); it('creates a new discount code creation job', () => { const input = fixtures.req.create; diff --git a/test/discount-code.test.js b/test/discount-code.test.js index c6995f38..55db6700 100644 --- a/test/discount-code.test.js +++ b/test/discount-code.test.js @@ -9,7 +9,7 @@ describe('Shopify#discountCode', () => { const shopify = common.shopify; const scope = common.scope; - afterEach(() => expect(scope.isDone()).to.be.true); + afterEach(() => expect(scope.pendingMocks()).to.deep.equal([])); it('creates a new discount code', () => { const input = fixtures.req.create; diff --git a/test/dispute-evidence.test.js b/test/dispute-evidence.test.js new file mode 100644 index 00000000..eb630fb7 --- /dev/null +++ b/test/dispute-evidence.test.js @@ -0,0 +1,73 @@ +describe('Shopify#disputeEvidence', () => { + 'use strict'; + + const expect = require('chai').expect; + + const fixtures = require('./fixtures/dispute-evidence'); + const common = require('./common'); + const Shopify = require('..'); + + const accessToken = common.accessToken; + const scope = common.scope; + const shopify = common.shopify; + const shopName = common.shopName; + + afterEach(() => expect(scope.pendingMocks()).to.deep.equal([])); + + it('get the dispute evidence associated with the dispute ID (1/2)', () => { + const output = fixtures.res.get; + + scope + .get('/admin/shopify_payments/disputes/598735659/dispute_evidences.json') + .reply(200, output); + + return shopify.disputeEvidence + .get(598735659) + .then((data) => expect(data).to.deep.equal(output.dispute_evidence)); + }); + + it('get the dispute evidence associated with the dispute ID (2/2)', () => { + const output = fixtures.res.get; + const params = { foo: 'bar' }; + + scope + .get('/admin/shopify_payments/disputes/598735659/dispute_evidences.json') + .query(params) + .reply(200, output); + + return shopify.disputeEvidence + .get(598735659, params) + .then((data) => expect(data).to.deep.equal(output.dispute_evidence)); + }); + + it('updates the dispute evidence associated with dispute ID', () => { + const input = fixtures.req.update; + const output = fixtures.res.update; + + scope + .put( + '/admin/shopify_payments/disputes/598735659/dispute_evidences.json', + input + ) + .reply(200, output); + + return shopify.disputeEvidence + .update(598735659, input.dispute_evidence) + .then((data) => expect(data).to.deep.equal(output.dispute_evidence)); + }); + + it('injects the api version to the request path if provided', () => { + const output = fixtures.res.get; + const apiVersion = '2022-07'; + const shopify = new Shopify({ shopName, accessToken, apiVersion }); + const pathname = + `/admin/api/${apiVersion}/shopify_payments` + + '/disputes/598735659/dispute_evidences.json'; + + scope.get(pathname).reply(200, output); + + return shopify.disputeEvidence + .get(598735659) + .then((data) => expect(data).to.deep.equal(output.dispute_evidence)); + }); +}); diff --git a/test/dispute-file-upload.test.js b/test/dispute-file-upload.test.js new file mode 100644 index 00000000..682eb40a --- /dev/null +++ b/test/dispute-file-upload.test.js @@ -0,0 +1,41 @@ +describe('Shopify#disputeFileUpload', () => { + 'use strict'; + + const expect = require('chai').expect; + + const fixtures = require('./fixtures/dispute-file-upload'); + const common = require('./common'); + + const shopify = common.shopify; + const scope = common.scope; + + afterEach(() => expect(scope.pendingMocks()).to.deep.equal([])); + + it('uploads a file to a dispute', () => { + const input = fixtures.req.create; + const output = fixtures.res.create; + + scope + .post( + '/admin/shopify_payments/disputes/598735659/dispute_file_uploads.json', + input + ) + .reply(200, output); + + return shopify.disputeFileUpload + .create(598735659, input.dispute_file_upload) + .then((data) => expect(data).to.deep.equal(output.dispute_file_upload)); + }); + + it('deletes a dispute evidence file', () => { + const pathname = + '/admin/shopify_payments' + + '/disputes/598735659/dispute_file_uploads/799719586.json'; + + scope.delete(pathname).reply(200); + + return shopify.disputeFileUpload + .delete(598735659, 799719586) + .then((data) => expect(data).to.deep.equal({})); + }); +}); diff --git a/test/dispute.test.js b/test/dispute.test.js index f81cbefe..856dbe0a 100644 --- a/test/dispute.test.js +++ b/test/dispute.test.js @@ -9,7 +9,7 @@ describe('Shopify#dispute', () => { const shopify = common.shopify; const scope = common.scope; - afterEach(() => expect(scope.isDone()).to.be.true); + afterEach(() => expect(scope.pendingMocks()).to.deep.equal([])); it('gets a list disputes (1/2)', () => { const output = fixtures.res.list; diff --git a/test/draft-order.test.js b/test/draft-order.test.js index 508325a5..5f8b6bbd 100644 --- a/test/draft-order.test.js +++ b/test/draft-order.test.js @@ -9,7 +9,7 @@ describe('Shopify#draftOrder', () => { const shopify = common.shopify; const scope = common.scope; - afterEach(() => expect(scope.isDone()).to.be.true); + afterEach(() => expect(scope.pendingMocks()).to.deep.equal([])); it('gets a list of draft orders (1/2)', () => { const output = fixtures.res.list; diff --git a/test/event.test.js b/test/event.test.js index 0ef02ea9..41c6dbb2 100644 --- a/test/event.test.js +++ b/test/event.test.js @@ -9,7 +9,7 @@ describe('Shopify#event', () => { const shopify = common.shopify; const scope = common.scope; - afterEach(() => expect(scope.isDone()).to.be.true); + afterEach(() => expect(scope.pendingMocks()).to.deep.equal([])); it('gets a list of events (1/2)', () => { const output = fixtures.res.list; diff --git a/test/fixtures/deprecated-api-call/index.js b/test/fixtures/deprecated-api-call/index.js new file mode 100644 index 00000000..38c8b447 --- /dev/null +++ b/test/fixtures/deprecated-api-call/index.js @@ -0,0 +1,3 @@ +'use strict'; + +exports.res = require('./res'); diff --git a/test/fixtures/deprecated-api-call/res/index.js b/test/fixtures/deprecated-api-call/res/index.js new file mode 100644 index 00000000..c157715b --- /dev/null +++ b/test/fixtures/deprecated-api-call/res/index.js @@ -0,0 +1,3 @@ +'use strict'; + +exports.list = require('./list'); diff --git a/test/fixtures/deprecated-api-call/res/list.json b/test/fixtures/deprecated-api-call/res/list.json new file mode 100644 index 00000000..8af307be --- /dev/null +++ b/test/fixtures/deprecated-api-call/res/list.json @@ -0,0 +1,15 @@ +{ + "data_updated_at": "2020-10-13T00:15:30Z", + "deprecated_api_calls": [ + { + "api_type": "REST", + "description": "The page filter has been removed from multiple endpoints. Use cursor-based pagination instead.", + "documentation_url": "https://shopify.dev/tutorials/make-paginated-requests-to-rest-admin-api", + "endpoint": "Product", + "last_call_at": "2020-06-12T03:46:18Z", + "migration_deadline": "2020-07-02T13:00:00Z", + "graphql_schema_name": null, + "version": "2019-07" + } + ] +} diff --git a/test/fixtures/dispute-evidence/index.js b/test/fixtures/dispute-evidence/index.js new file mode 100644 index 00000000..dd1de7d4 --- /dev/null +++ b/test/fixtures/dispute-evidence/index.js @@ -0,0 +1,4 @@ +'use strict'; + +exports.req = require('./req'); +exports.res = require('./res'); diff --git a/test/fixtures/dispute-evidence/req/index.js b/test/fixtures/dispute-evidence/req/index.js new file mode 100644 index 00000000..01a36fc6 --- /dev/null +++ b/test/fixtures/dispute-evidence/req/index.js @@ -0,0 +1,3 @@ +'use strict'; + +exports.update = require('./update'); diff --git a/test/fixtures/dispute-evidence/req/update.json b/test/fixtures/dispute-evidence/req/update.json new file mode 100644 index 00000000..262bbc2e --- /dev/null +++ b/test/fixtures/dispute-evidence/req/update.json @@ -0,0 +1,5 @@ +{ + "dispute_evidence": { + "refund_refusal_explanation": "Product must have receipt of proof of purchase" + } +} diff --git a/test/fixtures/dispute-evidence/res/get.json b/test/fixtures/dispute-evidence/res/get.json new file mode 100644 index 00000000..7c693ff0 --- /dev/null +++ b/test/fixtures/dispute-evidence/res/get.json @@ -0,0 +1,62 @@ +{ + "dispute_evidence": { + "id": 819974671, + "payments_dispute_id": 598735659, + "access_activity_log": null, + "billing_address": { + "id": 867402159, + "address1": "123 Amoebobacterieae St", + "address2": "", + "city": "Ottawa", + "province": "Ontario", + "province_code": "ON", + "country": "Canada", + "country_code": "CA", + "zip": "K2P0V6" + }, + "cancellation_policy_disclosure": null, + "cancellation_rebuttal": null, + "customer_email_address": "example@email.com", + "customer_first_name": "Kermit", + "customer_last_name": "the Frog", + "product_description": "Product name: Draft\nTitle: 151cm\nPrice: $10.00\nQuantity: 1\nProduct Description: good board", + "refund_policy_disclosure": null, + "refund_refusal_explanation": null, + "shipping_address": { + "id": 867402159, + "address1": "123 Amoebobacterieae St", + "address2": "", + "city": "Ottawa", + "province": "Ontario", + "province_code": "ON", + "country": "Canada", + "country_code": "CA", + "zip": "K2P0V6" + }, + "uncategorized_text": "Sample uncategorized text", + "created_at": "2022-06-07T14:30:15-04:00", + "updated_at": "2022-06-07T14:30:33-04:00", + "submitted_by_merchant_on": null, + "fulfillments": [ + { + "shipping_carrier": "UPS", + "shipping_tracking_number": "1234", + "shipping_date": "2017-01-01" + }, + { + "shipping_carrier": "FedEx", + "shipping_tracking_number": "4321", + "shipping_date": "2017-01-02" + } + ], + "dispute_evidence_files": { + "cancellation_policy_file_id": null, + "customer_communication_file_id": 539650252, + "customer_signature_file_id": 799719586, + "refund_policy_file_id": null, + "service_documentation_file_id": null, + "shipping_documentation_file_id": 799719586, + "uncategorized_file_id": 567271523 + } + } +} diff --git a/test/fixtures/dispute-evidence/res/index.js b/test/fixtures/dispute-evidence/res/index.js new file mode 100644 index 00000000..2e3b6b9a --- /dev/null +++ b/test/fixtures/dispute-evidence/res/index.js @@ -0,0 +1,4 @@ +'use strict'; + +exports.get = require('./get'); +exports.update = require('./update'); diff --git a/test/fixtures/dispute-evidence/res/update.json b/test/fixtures/dispute-evidence/res/update.json new file mode 100644 index 00000000..8af370a2 --- /dev/null +++ b/test/fixtures/dispute-evidence/res/update.json @@ -0,0 +1,62 @@ +{ + "dispute_evidence": { + "id": 819974671, + "payments_dispute_id": 598735659, + "access_activity_log": null, + "billing_address": { + "id": 867402159, + "address1": "123 Amoebobacterieae St", + "address2": "", + "city": "Ottawa", + "province": "Ontario", + "province_code": "ON", + "country": "Canada", + "country_code": "CA", + "zip": "K2P0V6" + }, + "cancellation_policy_disclosure": null, + "cancellation_rebuttal": null, + "customer_email_address": "example@email.com", + "customer_first_name": "Kermit", + "customer_last_name": "the Frog", + "product_description": "Product name: Draft\nTitle: 151cm\nPrice: $10.00\nQuantity: 1\nProduct Description: good board", + "refund_policy_disclosure": null, + "refund_refusal_explanation": "Product must have receipt of proof of purchase", + "shipping_address": { + "id": 867402159, + "address1": "123 Amoebobacterieae St", + "address2": "", + "city": "Ottawa", + "province": "Ontario", + "province_code": "ON", + "country": "Canada", + "country_code": "CA", + "zip": "K2P0V6" + }, + "uncategorized_text": "Sample uncategorized text", + "created_at": "2022-06-07T14:30:15-04:00", + "updated_at": "2022-06-07T14:30:42-04:00", + "submitted_by_merchant_on": null, + "fulfillments": [ + { + "shipping_carrier": "UPS", + "shipping_tracking_number": "1234", + "shipping_date": "2017-01-01" + }, + { + "shipping_carrier": "FedEx", + "shipping_tracking_number": "4321", + "shipping_date": "2017-01-02" + } + ], + "dispute_evidence_files": { + "cancellation_policy_file_id": null, + "customer_communication_file_id": 539650252, + "customer_signature_file_id": 799719586, + "refund_policy_file_id": null, + "service_documentation_file_id": null, + "shipping_documentation_file_id": 799719586, + "uncategorized_file_id": 567271523 + } + } +} diff --git a/test/fixtures/dispute-file-upload/index.js b/test/fixtures/dispute-file-upload/index.js new file mode 100644 index 00000000..dd1de7d4 --- /dev/null +++ b/test/fixtures/dispute-file-upload/index.js @@ -0,0 +1,4 @@ +'use strict'; + +exports.req = require('./req'); +exports.res = require('./res'); diff --git a/test/fixtures/dispute-file-upload/req/create.json b/test/fixtures/dispute-file-upload/req/create.json new file mode 100644 index 00000000..ff674538 --- /dev/null +++ b/test/fixtures/dispute-file-upload/req/create.json @@ -0,0 +1,8 @@ +{ + "dispute_file_upload": { + "document_type": "uncategorized_file", + "filename": "test.pdf", + "mimetype": "application/pdf", + "data": "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ZW5kc3RyZWFtCmVuZG9iagoyMSAwIG9iagoxNTk1MgplbmRvYmoKMjIgMCBvYmoKKExvcmVtIElwc3VtKQplbmRvYmoKMjMgMCBvYmoKKE1hYyBPUyBYIDEwLjEwIFF1YXJ0eiBQREZDb250ZXh0KQplbmRvYmoKMjQgMCBvYmoKKFNob3BpZnkgSW5jKQplbmRvYmoKMjUgMCBvYmoKKCkKZW5kb2JqCjI2IDAgb2JqCihXb3JkKQplbmRvYmoKMjcgMCBvYmoKKEQ6MjAxNDExMjUyMTM4NDlaMDAnMDAnKQplbmRvYmoKMjggMCBvYmoKKCkKZW5kb2JqCjI5IDAgb2JqClsgKCkgXQplbmRvYmoKMSAwIG9iago8PCAvVGl0bGUgMjIgMCBSIC9BdXRob3IgMjQgMCBSIC9TdWJqZWN0IDI1IDAgUiAvUHJvZHVjZXIgMjMgMCBSIC9DcmVhdG9yCjI2IDAgUiAvQ3JlYXRpb25EYXRlIDI3IDAgUiAvTW9kRGF0ZSAyNyAwIFIgL0tleXdvcmRzIDI4IDAgUiAvQUFQTDpLZXl3b3JkcwoyOSAwIFIgPj4KZW5kb2JqCnhyZWYKMCAzMAowMDAwMDAwMDAwIDY1NTM1IGYgCjAwMDAwMzM1NzYgMDAwMDAgbiAKMDAwMDAwNjg4NSAwMDAwMCBuIAowMDAwMDA5ODcwIDAwMDAwIG4gCjAwMDAwMDAwMjIgMDAwMDAgbiAKMDAwMDAwNjg2NSAwMDAwMCBuIAowMDAwMDA2OTg5IDAwMDAwIG4gCjAwMDAwMDk4MzQgMDAwMDAgbiAKMDAwMDAxNjU4NyAwMDAwMCBuIAowMDAwMDAwMDAwIDAwMDAwIG4gCjAwMDAwMTAwMDMgMDAwMDAgbiAKMDAwMDAwNzA5OCAwMDAwMCBuIAowMDAwMDA5ODEzIDAwMDAwIG4gCjAwMDAwMDk5NTMgMDAwMDAgbiAKMDAwMDAxMDQ5NSAwMDAwMCBuIAowMDAwMDEwMTY2IDAwMDAwIG4gCjAwMDAwMTA0NzUgMDAwMDAgbiAKMDAwMDAxMDczMyAwMDAwMCBuIAowMDAwMDE2NTY2IDAwMDAwIG4gCjAwMDAwMTcwMTQgMDAwMDAgbiAKMDAwMDAxNzI3NCAwMDAwMCBuIAowMDAwMDMzMzE3IDAwMDAwIG4gCjAwMDAwMzMzMzkgMDAwMDAgbiAKMDAwMDAzMzM2OSAwMDAwMCBuIAowMDAwMDMzNDIwIDAwMDAwIG4gCjAwMDAwMzM0NTAgMDAwMDAgbiAKMDAwMDAzMzQ2OSAwMDAwMCBuIAowMDAwMDMzNDkyIDAwMDAwIG4gCjAwMDAwMzM1MzQgMDAwMDAgbiAKMDAwMDAzMzU1MyAwMDAwMCBuIAp0cmFpbGVyCjw8IC9TaXplIDMwIC9Sb290IDEzIDAgUiAvSW5mbyAxIDAgUiAvSUQgWyA8YWFkYjdhZjU5ZjIxNWE0Y2U4Y2QzZWIwYzFiNmNjNTc+CjxhYWRiN2FmNTlmMjE1YTRjZThjZDNlYjBjMWI2Y2M1Nz4gXSA+PgpzdGFydHhyZWYKMzM3NTEKJSVFT0YK" + } +} diff --git a/test/fixtures/dispute-file-upload/req/index.js b/test/fixtures/dispute-file-upload/req/index.js new file mode 100644 index 00000000..c1005703 --- /dev/null +++ b/test/fixtures/dispute-file-upload/req/index.js @@ -0,0 +1,3 @@ +'use strict'; + +exports.create = require('./create'); diff --git a/test/fixtures/dispute-file-upload/res/create.json b/test/fixtures/dispute-file-upload/res/create.json new file mode 100644 index 00000000..2dbd5bce --- /dev/null +++ b/test/fixtures/dispute-file-upload/res/create.json @@ -0,0 +1,15 @@ +{ + "dispute_file_upload": { + "id": 799719588, + "shop_id": 220006451, + "file_size": 34509, + "file_type": "application/pdf", + "original_filename": "test.pdf", + "filename": "fe287b11-cd9e-4281-bed6-7039adf27e71.pdf", + "created_at": "2022-06-07T12:06:05-04:00", + "updated_at": "2022-06-07T12:06:05-04:00", + "dispute_evidence_id": 819974671, + "dispute_evidence_type": "uncategorized_file", + "url": "https://storage.googleapis.com/shopify-gcs-test/s/files/1/0002/2000/6451/payments_file_uploads/fe287b11-cd9e-4281-bed6-7039adf27e71.pdf?X-Goog-Algorithm=GOOG4-RSA-SHA256&X-Goog-Credential=shopify-core-gcs-test%40shopify-tiers.iam.gserviceaccount.com%2F20200912%2Fauto%2Fstorage%2Fgoog4_request&X-Goog-Date=2022-06-07T16:06:06Z&X-Goog-Expires=604800&X-Goog-SignedHeaders=host&response-content-disposition=&X-Goog-Signature=87811646776a232917997f6a4595871d9123629da45d35b2b915e7210eececff55a58147e79ac636861617cc21b5a2f6e5ea6547d9a20e9503e64caca84c9043ecccbf27d20142118017c60d2418edb7896c53eb466335fca9153d8d73c15645962eace819a3588301d55bcbc5b4ecf2225561de914b104e2069daa400e741a59a306d094f7b147c0ae2215f8203e4ce20a907c983a2e4be074c584725bd1c9e399f633499531160dac25b34054527795ef5ea48a4ac1d392585d4bc0976d62cab57e18d33547c9110599f39368983e4c882757900bac1eddcb41b87d4d26269bfe01b0161ef0a81088a0e4d6596380f4d3d37e4e72a8c5fefc6bc07551efb59" + } +} diff --git a/test/fixtures/dispute-file-upload/res/get.json b/test/fixtures/dispute-file-upload/res/get.json new file mode 100644 index 00000000..7c693ff0 --- /dev/null +++ b/test/fixtures/dispute-file-upload/res/get.json @@ -0,0 +1,62 @@ +{ + "dispute_evidence": { + "id": 819974671, + "payments_dispute_id": 598735659, + "access_activity_log": null, + "billing_address": { + "id": 867402159, + "address1": "123 Amoebobacterieae St", + "address2": "", + "city": "Ottawa", + "province": "Ontario", + "province_code": "ON", + "country": "Canada", + "country_code": "CA", + "zip": "K2P0V6" + }, + "cancellation_policy_disclosure": null, + "cancellation_rebuttal": null, + "customer_email_address": "example@email.com", + "customer_first_name": "Kermit", + "customer_last_name": "the Frog", + "product_description": "Product name: Draft\nTitle: 151cm\nPrice: $10.00\nQuantity: 1\nProduct Description: good board", + "refund_policy_disclosure": null, + "refund_refusal_explanation": null, + "shipping_address": { + "id": 867402159, + "address1": "123 Amoebobacterieae St", + "address2": "", + "city": "Ottawa", + "province": "Ontario", + "province_code": "ON", + "country": "Canada", + "country_code": "CA", + "zip": "K2P0V6" + }, + "uncategorized_text": "Sample uncategorized text", + "created_at": "2022-06-07T14:30:15-04:00", + "updated_at": "2022-06-07T14:30:33-04:00", + "submitted_by_merchant_on": null, + "fulfillments": [ + { + "shipping_carrier": "UPS", + "shipping_tracking_number": "1234", + "shipping_date": "2017-01-01" + }, + { + "shipping_carrier": "FedEx", + "shipping_tracking_number": "4321", + "shipping_date": "2017-01-02" + } + ], + "dispute_evidence_files": { + "cancellation_policy_file_id": null, + "customer_communication_file_id": 539650252, + "customer_signature_file_id": 799719586, + "refund_policy_file_id": null, + "service_documentation_file_id": null, + "shipping_documentation_file_id": 799719586, + "uncategorized_file_id": 567271523 + } + } +} diff --git a/test/fixtures/dispute-file-upload/res/index.js b/test/fixtures/dispute-file-upload/res/index.js new file mode 100644 index 00000000..c1005703 --- /dev/null +++ b/test/fixtures/dispute-file-upload/res/index.js @@ -0,0 +1,3 @@ +'use strict'; + +exports.create = require('./create'); diff --git a/test/fixtures/fulfillment-order/req/hold.json b/test/fixtures/fulfillment-order/req/hold.json new file mode 100644 index 00000000..d7ee489d --- /dev/null +++ b/test/fixtures/fulfillment-order/req/hold.json @@ -0,0 +1,7 @@ +{ + "fulfillment_hold": { + "reason": "inventory_out_of_stock", + "reason_notes": "Not enough inventory to complete this work.", + "fulfillment_order_line_items": [{ "id": 1058737493, "quantity": 1 }] + } +} diff --git a/test/fixtures/fulfillment-order/req/index.js b/test/fixtures/fulfillment-order/req/index.js index cb386990..6dc7beab 100644 --- a/test/fixtures/fulfillment-order/req/index.js +++ b/test/fixtures/fulfillment-order/req/index.js @@ -1,5 +1,7 @@ 'use strict'; -exports.cancel = require('./cancel'); exports.close = require('./close'); +exports.hold = require('./hold'); exports.move = require('./move'); +exports.reschedule = require('./reschedule'); +exports.setFulfillmentOrdersDeadline = require('./set-fulfillment-orders-deadline'); diff --git a/test/fixtures/fulfillment-order/req/reschedule.json b/test/fixtures/fulfillment-order/req/reschedule.json new file mode 100644 index 00000000..cced9fcc --- /dev/null +++ b/test/fixtures/fulfillment-order/req/reschedule.json @@ -0,0 +1 @@ +{ "fulfillment_order": { "new_fulfill_at": "2025-08-24 10:26 UTC" } } diff --git a/test/fixtures/fulfillment-order/req/set-fulfillment-orders-deadline.json b/test/fixtures/fulfillment-order/req/set-fulfillment-orders-deadline.json new file mode 100644 index 00000000..085f9942 --- /dev/null +++ b/test/fixtures/fulfillment-order/req/set-fulfillment-orders-deadline.json @@ -0,0 +1,4 @@ +{ + "fulfillment_order_ids": [1046000789], + "fulfillment_deadline": "2021-05-26T10:00:00-04:00" +} diff --git a/test/fixtures/fulfillment-order/res/cancel.json b/test/fixtures/fulfillment-order/res/cancel.json index bf0a2aa4..2175ede0 100644 --- a/test/fixtures/fulfillment-order/res/cancel.json +++ b/test/fixtures/fulfillment-order/res/cancel.json @@ -1,45 +1,40 @@ { "fulfillment_order": { - "id": 1025578640, - "shop_id": 690933842, + "id": 1046000791, + "shop_id": 548380009, "order_id": 450789469, - "assigned_location_id": 48752903, - "fulfillment_service_handle": "mars-fulfillment", + "assigned_location_id": 24826418, "request_status": "submitted", "status": "closed", + "fulfill_at": null, "supported_actions": [], "destination": { - "id": 1025578634, + "id": 1046000791, "address1": "Chestnut Street 92", "address2": "", "city": "Louisville", "company": null, "country": "United States", - "email": "bob.norman@hostmail.com", + "email": "bob.norman@mail.example.com", "first_name": "Bob", "last_name": "Norman", - "phone": "555-625-1199", + "phone": "+1(502)-459-2181", "province": "Kentucky", "zip": "40202" }, - "line_items": [ - { - "id": 1025578653, - "shop_id": 690933842, - "fulfillment_order_id": 1025578640, - "quantity": 1, - "line_item_id": 518995019, - "inventory_item_id": 49148385, - "fulfillable_quantity": 1, - "variant_id": 49148385 - } - ], + "line_items": [], + "international_duties": null, + "fulfillment_holds": [], + "fulfill_by": null, + "created_at": "2024-07-24T06:26:35-04:00", + "updated_at": "2024-07-24T06:26:36-04:00", + "delivery_method": null, "assigned_location": { "address1": null, "address2": null, "city": null, "country_code": "DE", - "location_id": 48752903, + "location_id": 24826418, "name": "Apple Api Shipwire", "phone": null, "province": null, @@ -48,33 +43,33 @@ "merchant_requests": [] }, "replacement_fulfillment_order": { - "id": 1025578641, - "shop_id": 690933842, + "id": 1046000792, + "shop_id": 548380009, "order_id": 450789469, - "assigned_location_id": 48752903, - "fulfillment_service_handle": "mars-fulfillment", + "assigned_location_id": 24826418, "request_status": "unsubmitted", "status": "open", - "supported_actions": ["request_fulfillment", "create_fulfillment"], + "fulfill_at": null, + "supported_actions": ["request_fulfillment", "hold"], "destination": { - "id": 1025578635, + "id": 1046000792, "address1": "Chestnut Street 92", "address2": "", "city": "Louisville", "company": null, "country": "United States", - "email": "bob.norman@hostmail.com", + "email": "bob.norman@mail.example.com", "first_name": "Bob", "last_name": "Norman", - "phone": "555-625-1199", + "phone": "+1(502)-459-2181", "province": "Kentucky", "zip": "40202" }, "line_items": [ { - "id": 1025578654, - "shop_id": 690933842, - "fulfillment_order_id": 1025578641, + "id": 1058737495, + "shop_id": 548380009, + "fulfillment_order_id": 1046000792, "quantity": 1, "line_item_id": 518995019, "inventory_item_id": 49148385, @@ -82,12 +77,18 @@ "variant_id": 49148385 } ], + "international_duties": null, + "fulfillment_holds": [], + "fulfill_by": null, + "created_at": "2024-07-24T06:26:36-04:00", + "updated_at": "2024-07-24T06:26:36-04:00", + "delivery_method": null, "assigned_location": { "address1": null, "address2": null, "city": null, "country_code": "DE", - "location_id": 48752903, + "location_id": 24826418, "name": "Apple Api Shipwire", "phone": null, "province": null, diff --git a/test/fixtures/fulfillment-order/res/fulfillments.json b/test/fixtures/fulfillment-order/res/fulfillments.json new file mode 100644 index 00000000..0bef6d39 --- /dev/null +++ b/test/fixtures/fulfillment-order/res/fulfillments.json @@ -0,0 +1,73 @@ +{ + "fulfillments": [ + { + "id": 1069019901, + "order_id": 450789469, + "status": "success", + "created_at": "2022-10-21T08:56:20-04:00", + "service": "manual", + "updated_at": "2022-10-21T08:56:20-04:00", + "tracking_company": "UPS", + "shipment_status": null, + "location_id": 24826418, + "line_items": [ + { + "id": 1071823190, + "variant_id": 43729076, + "title": "Draft", + "quantity": 1, + "sku": "draft-151", + "variant_title": "151cm", + "vendor": null, + "fulfillment_service": "manual", + "product_id": 108828309, + "requires_shipping": true, + "taxable": true, + "gift_card": false, + "name": "Draft - 151cm", + "variant_inventory_management": null, + "properties": [], + "product_exists": true, + "fulfillable_quantity": 1, + "grams": 0, + "price": "10.00", + "total_discount": "0.00", + "fulfillment_status": "fulfilled", + "price_set": { + "shop_money": { + "amount": "10.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "10.00", + "currency_code": "USD" + } + }, + "total_discount_set": { + "shop_money": { + "amount": "0.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "0.00", + "currency_code": "USD" + } + }, + "discount_allocations": [], + "duties": [], + "admin_graphql_api_id": "gid://shopify/LineItem/1071823190", + "tax_lines": [] + } + ], + "tracking_number": "#☠1☢\n---\n4321\n", + "tracking_numbers": ["#☠1☢\n---\n4321\n"], + "tracking_url": "https://www.ups.com/WebTracking?loc=en_US&requester=ST&trackNums=#☠1☢---4321", + "tracking_urls": [ + "https://www.ups.com/WebTracking?loc=en_US&requester=ST&trackNums=#☠1☢---4321" + ], + "receipt": {}, + "name": "#1001.1", + "admin_graphql_api_id": "gid://shopify/Fulfillment/1069019901" + } + ] +} diff --git a/test/fixtures/fulfillment-order/res/hold.json b/test/fixtures/fulfillment-order/res/hold.json new file mode 100644 index 00000000..31844af5 --- /dev/null +++ b/test/fixtures/fulfillment-order/res/hold.json @@ -0,0 +1,61 @@ +{ + "fulfillment_order": { + "id": 1046000789, + "shop_id": 548380009, + "order_id": 450789469, + "assigned_location_id": 24826418, + "request_status": "unsubmitted", + "status": "on_hold", + "fulfill_at": null, + "supported_actions": ["release_hold"], + "destination": { + "id": 1046000789, + "address1": "Chestnut Street 92", + "address2": "", + "city": "Louisville", + "company": null, + "country": "United States", + "email": "bob.norman@mail.example.com", + "first_name": "Bob", + "last_name": "Norman", + "phone": "+1(502)-459-2181", + "province": "Kentucky", + "zip": "40202" + }, + "line_items": [ + { + "id": 1058737493, + "shop_id": 548380009, + "fulfillment_order_id": 1046000789, + "quantity": 1, + "line_item_id": 518995019, + "inventory_item_id": 49148385, + "fulfillable_quantity": 1, + "variant_id": 49148385 + } + ], + "international_duties": null, + "fulfillment_holds": [ + { + "reason": "inventory_out_of_stock", + "reason_notes": "Not enough inventory to complete this work." + } + ], + "fulfill_by": null, + "created_at": "2024-07-24T06:26:32-04:00", + "updated_at": "2024-07-24T06:26:33-04:00", + "delivery_method": null, + "assigned_location": { + "address1": null, + "address2": null, + "city": null, + "country_code": "DE", + "location_id": 24826418, + "name": "Apple Api Shipwire", + "phone": null, + "province": null, + "zip": null + }, + "merchant_requests": [] + } +} diff --git a/test/fixtures/fulfillment-order/res/index.js b/test/fixtures/fulfillment-order/res/index.js index 5f4be4f1..b2cc035e 100644 --- a/test/fixtures/fulfillment-order/res/index.js +++ b/test/fixtures/fulfillment-order/res/index.js @@ -1,8 +1,12 @@ 'use strict'; -exports.locationsForMove = require('./locations-for-move'); exports.cancel = require('./cancel'); exports.close = require('./close'); +exports.fulfillments = require('./fulfillments'); +exports.get = require('./get'); +exports.hold = require('./hold'); exports.list = require('./list'); +exports.locationsForMove = require('./locations-for-move'); exports.move = require('./move'); -exports.get = require('./get'); +exports.releaseHold = require('./release-hold'); +exports.reschedule = require('./reschedule'); diff --git a/test/fixtures/fulfillment-order/req/cancel.json b/test/fixtures/fulfillment-order/res/release-hold.json similarity index 58% rename from test/fixtures/fulfillment-order/req/cancel.json rename to test/fixtures/fulfillment-order/res/release-hold.json index 25931b7a..a8915b5b 100644 --- a/test/fixtures/fulfillment-order/req/cancel.json +++ b/test/fixtures/fulfillment-order/res/release-hold.json @@ -1,50 +1,56 @@ { "fulfillment_order": { - "id": 1025578640, - "shop_id": 690933842, + "id": 1046000790, + "shop_id": 548380009, "order_id": 450789469, - "assigned_location_id": 48752903, - "fulfillment_service_handle": "mars-fulfillment", + "assigned_location_id": 24826418, "request_status": "submitted", "status": "open", + "fulfill_at": null, "supported_actions": ["cancel_fulfillment_order"], "destination": { - "id": 1025578634, + "id": 1046000790, "address1": "Chestnut Street 92", "address2": "", "city": "Louisville", "company": null, "country": "United States", - "email": "bob.norman@hostmail.com", + "email": "bob.norman@mail.example.com", "first_name": "Bob", "last_name": "Norman", - "phone": "555-625-1199", + "phone": "+1(502)-459-2181", "province": "Kentucky", "zip": "40202" }, - "line_items": [ - { - "id": 1025578653, - "shop_id": 690933842, - "fulfillment_order_id": 1025578640, - "quantity": 1, - "line_item_id": 518995019, - "inventory_item_id": 49148385, - "fulfillable_quantity": 1, - "variant_id": 49148385 - } - ], - "assigned_location": { + "origin": { "address1": null, "address2": null, "city": null, "country_code": "DE", - "location_id": 48752903, + "location_id": 24826418, "name": "Apple Api Shipwire", "phone": null, "province": null, "zip": null }, - "merchant_requests": [] + "line_items": [ + { + "id": 1058737494, + "shop_id": 548380009, + "fulfillment_order_id": 1046000790, + "quantity": 1, + "line_item_id": 518995019, + "inventory_item_id": 49148385, + "fulfillable_quantity": 1, + "variant_id": 49148385 + } + ], + "outgoing_requests": [], + "international_duties": null, + "fulfillment_holds": [], + "fulfill_by": null, + "created_at": "2024-07-24T06:26:33-04:00", + "updated_at": "2024-07-24T06:26:34-04:00", + "delivery_method": null } } diff --git a/test/fixtures/fulfillment-order/res/reschedule.json b/test/fixtures/fulfillment-order/res/reschedule.json new file mode 100644 index 00000000..b67c40a3 --- /dev/null +++ b/test/fixtures/fulfillment-order/res/reschedule.json @@ -0,0 +1,56 @@ +{ + "fulfillment_order": { + "id": 1046000788, + "shop_id": 548380009, + "order_id": 450789469, + "assigned_location_id": 24826418, + "request_status": "unsubmitted", + "status": "scheduled", + "fulfill_at": "2025-08-24T06:26:00-04:00", + "supported_actions": ["mark_as_open"], + "destination": { + "id": 1046000788, + "address1": "Chestnut Street 92", + "address2": "", + "city": "Louisville", + "company": null, + "country": "United States", + "email": "bob.norman@mail.example.com", + "first_name": "Bob", + "last_name": "Norman", + "phone": "+1(502)-459-2181", + "province": "Kentucky", + "zip": "40202" + }, + "line_items": [ + { + "id": 1058737492, + "shop_id": 548380009, + "fulfillment_order_id": 1046000788, + "quantity": 1, + "line_item_id": 518995019, + "inventory_item_id": 49148385, + "fulfillable_quantity": 1, + "variant_id": 49148385 + } + ], + "international_duties": null, + "fulfillment_holds": [], + "fulfill_by": null, + "created_at": "2024-07-24T06:26:30-04:00", + "updated_at": "2024-07-24T06:26:32-04:00", + "delivery_method": null, + "assigned_location": { + "address1": null, + "address2": null, + "city": null, + "country_code": "DE", + "location_id": 24826418, + "name": "Apple Api Shipwire", + "phone": null, + "province": null, + "zip": null + }, + "merchant_requests": [] + } +} diff --git a/test/fulfillment-event.test.js b/test/fulfillment-event.test.js index ab4665d4..b1513252 100644 --- a/test/fulfillment-event.test.js +++ b/test/fulfillment-event.test.js @@ -13,7 +13,7 @@ describe('Shopify#fulfillmentEvent', () => { const shopify = common.shopify; const shopName = common.shopName; - afterEach(() => expect(scope.isDone()).to.be.true); + afterEach(() => expect(scope.pendingMocks()).to.deep.equal([])); it('gets a list of all fulfillments events for a fulfillment (1/2)', () => { const output = fixtures.res.list; diff --git a/test/fulfillment-order.test.js b/test/fulfillment-order.test.js index 93d30946..f376f807 100644 --- a/test/fulfillment-order.test.js +++ b/test/fulfillment-order.test.js @@ -9,7 +9,7 @@ describe('Shopify#fulfillmentOrder', () => { const shopify = common.shopify; const scope = common.scope; - afterEach(() => expect(scope.isDone()).to.be.true); + afterEach(() => expect(scope.pendingMocks()).to.deep.equal([])); it('gets a list of fulfillment orders on a shop for a specific app', () => { const output = fixtures.res.list; @@ -40,15 +40,14 @@ describe('Shopify#fulfillmentOrder', () => { }); it('cancels a fulfillment order', () => { - const input = fixtures.req.cancel; const output = fixtures.res.cancel; scope - .post('/admin/fulfillment_orders/1025578640/cancel.json', input) + .post('/admin/fulfillment_orders/1046000791/cancel.json', {}) .reply(200, output); return shopify.fulfillmentOrder - .cancel(1025578640, input.fulfillment_order) + .cancel(1046000791) .then((data) => expect(data).to.deep.equal(output.fulfillment_order)); }); @@ -105,4 +104,75 @@ describe('Shopify#fulfillmentOrder', () => { expect(data).to.deep.equal(output.original_fulfillment_order); }); }); + + it('reschedules the fulfill_at time of a scheduled fulfillment order', () => { + const input = fixtures.req.setFulfillmentOrdersDeadline; + + scope + .post( + '/admin/fulfillment_orders/set_fulfillment_orders_deadline.json', + input + ) + .reply(200, {}); + + return shopify.fulfillmentOrder + .setFulfillmentOrdersDeadline(input) + .then((data) => { + expect(data).to.deep.equal({}); + }); + }); + + it('retrieves fulfillments associated with a fulfillment order', () => { + const output = fixtures.res.fulfillments; + + scope + .get('/admin/fulfillment_orders/1046000823/fulfillments.json') + .reply(200, output); + + return shopify.fulfillmentOrder + .fulfillments(1046000823) + .then((data) => expect(data).to.deep.equal(output.fulfillments)); + }); + + it('applies a fulfillment hold on an open fulfillment order', () => { + const input = fixtures.req.hold; + const output = fixtures.res.hold; + + scope + .post('/admin/fulfillment_orders/1046000789/hold.json', input) + .reply(200, output); + + return shopify.fulfillmentOrder + .hold(1046000789, input.fulfillment_hold) + .then((data) => { + expect(data).to.deep.equal(output.fulfillment_order); + }); + }); + + it('releases the fulfillment hold on a fulfillment order', () => { + const output = fixtures.res.releaseHold; + + scope + .post('/admin/fulfillment_orders/1046000790/release_hold.json', {}) + .reply(200, output); + + return shopify.fulfillmentOrder.releaseHold(1046000790).then((data) => { + expect(data).to.deep.equal(output.fulfillment_order); + }); + }); + + it('reschedules the fulfill_at time of a scheduled fulfillment order', () => { + const input = fixtures.req.reschedule; + const output = fixtures.res.reschedule; + + scope + .post('/admin/fulfillment_orders/1046000788/reschedule.json', input) + .reply(200, output); + + return shopify.fulfillmentOrder + .reschedule(1046000788, '2025-08-24 10:26 UTC') + .then((data) => { + expect(data).to.deep.equal(output.fulfillment_order); + }); + }); }); diff --git a/test/fulfillment-request.test.js b/test/fulfillment-request.test.js index 880b0c01..cef713cd 100644 --- a/test/fulfillment-request.test.js +++ b/test/fulfillment-request.test.js @@ -9,7 +9,7 @@ describe('Shopify#fulfillment-request', () => { const shopify = common.shopify; const scope = common.scope; - afterEach(() => expect(scope.isDone()).to.be.true); + afterEach(() => expect(scope.pendingMocks()).to.deep.equal([])); it('sends a fulfillment request', () => { const input = fixtures.req.create; diff --git a/test/fulfillment-service.test.js b/test/fulfillment-service.test.js index 4cd474a2..ad2d1299 100644 --- a/test/fulfillment-service.test.js +++ b/test/fulfillment-service.test.js @@ -9,7 +9,7 @@ describe('Shopify#fulfillmentService', () => { const shopify = common.shopify; const scope = common.scope; - afterEach(() => expect(scope.isDone()).to.be.true); + afterEach(() => expect(scope.pendingMocks()).to.deep.equal([])); it('gets a list of all fulfillment services', () => { const output = fixtures.res.list; diff --git a/test/fulfillment.test.js b/test/fulfillment.test.js index 177a4a6d..fcbea076 100644 --- a/test/fulfillment.test.js +++ b/test/fulfillment.test.js @@ -13,7 +13,7 @@ describe('Shopify#fulfillment', () => { const shopify = common.shopify; const shopName = common.shopName; - afterEach(() => expect(scope.isDone()).to.be.true); + afterEach(() => expect(scope.pendingMocks()).to.deep.equal([])); it('gets a list of all fulfillments for an order (1/2)', () => { const output = fixtures.res.list; @@ -142,7 +142,7 @@ describe('Shopify#fulfillment', () => { .then((data) => expect(data).to.deep.equal(output.fulfillment)); }); - it('cancels a pending fulfillment', () => { + it('cancels a fulfillment for a specific order ID', () => { const output = fixtures.res.cancel; scope @@ -154,6 +154,18 @@ describe('Shopify#fulfillment', () => { .then((data) => expect(data).to.deep.equal(output.fulfillment)); }); + it('cancels a fulfillment', () => { + const output = fixtures.res.cancel; + + scope + .post('/admin/fulfillments/255858046/cancel.json', {}) + .reply(200, output); + + return shopify.fulfillment + .cancelV2(255858046) + .then((data) => expect(data).to.deep.equal(output.fulfillment)); + }); + it('injects the api version to the request path if provided', () => { scope .get(`/admin/api/${apiVersion}/orders/450789469/fulfillments/count.json`) diff --git a/test/gift-card-adjustment.test.js b/test/gift-card-adjustment.test.js index e30b68d9..f2006d4e 100644 --- a/test/gift-card-adjustment.test.js +++ b/test/gift-card-adjustment.test.js @@ -9,7 +9,7 @@ describe('Shopify#giftCardAdjustment', () => { const shopify = common.shopify; const scope = common.scope; - afterEach(() => expect(scope.isDone()).to.be.true); + afterEach(() => expect(scope.pendingMocks()).to.deep.equal([])); it('gets a list of all gift card adjustments', () => { const output = fixtures.res.list; diff --git a/test/gift-card.test.js b/test/gift-card.test.js index 15c9e298..7af809db 100644 --- a/test/gift-card.test.js +++ b/test/gift-card.test.js @@ -9,7 +9,7 @@ describe('Shopify#giftCard', () => { const shopify = common.shopify; const scope = common.scope; - afterEach(() => expect(scope.isDone()).to.be.true); + afterEach(() => expect(scope.pendingMocks()).to.deep.equal([])); it('gets a list of all gift cards (1/2)', () => { const output = fixtures.res.list; diff --git a/test/inventory-item.test.js b/test/inventory-item.test.js index e0762cd4..3fb9e5b0 100644 --- a/test/inventory-item.test.js +++ b/test/inventory-item.test.js @@ -10,7 +10,7 @@ describe('Shopify#inventoryItem', () => { const shopify = common.shopify; const scope = common.scope; - afterEach(() => expect(scope.isDone()).to.be.true); + afterEach(() => expect(scope.pendingMocks()).to.deep.equal([])); it('gets a list of inventory items', () => { const query = { ids: '808950810,39072856,457924702' }; diff --git a/test/inventory-level.test.js b/test/inventory-level.test.js index f4cca492..3de9de80 100644 --- a/test/inventory-level.test.js +++ b/test/inventory-level.test.js @@ -10,7 +10,7 @@ describe('Shopify#inventoryLevel', () => { const shopify = common.shopify; const scope = common.scope; - afterEach(() => expect(scope.isDone()).to.be.true); + afterEach(() => expect(scope.pendingMocks()).to.deep.equal([])); it('gets a list of inventory levels', () => { const query = { inventory_item_ids: 808950810 }; diff --git a/test/location.test.js b/test/location.test.js index 0586f1e2..b9d83cff 100644 --- a/test/location.test.js +++ b/test/location.test.js @@ -9,7 +9,7 @@ describe('Shopify#location', () => { const shopify = common.shopify; const scope = common.scope; - afterEach(() => expect(scope.isDone()).to.be.true); + afterEach(() => expect(scope.pendingMocks()).to.deep.equal([])); it('gets a list of all locations', () => { const output = fixtures.res.list; diff --git a/test/marketing-event.test.js b/test/marketing-event.test.js index 68854461..cf2c779f 100644 --- a/test/marketing-event.test.js +++ b/test/marketing-event.test.js @@ -9,7 +9,7 @@ describe('Shopify#marketingEvent', () => { const shopify = common.shopify; const scope = common.scope; - afterEach(() => expect(scope.isDone()).to.be.true); + afterEach(() => expect(scope.pendingMocks()).to.deep.equal([])); it('gets a list of all marketing events (1/2)', () => { const output = fixtures.res.list; diff --git a/test/metafield.test.js b/test/metafield.test.js index 7e0bd15c..c28a072d 100644 --- a/test/metafield.test.js +++ b/test/metafield.test.js @@ -9,7 +9,7 @@ describe('Shopify#metafield', () => { const shopify = common.shopify; const scope = common.scope; - afterEach(() => expect(scope.isDone()).to.be.true); + afterEach(() => expect(scope.pendingMocks()).to.deep.equal([])); it('gets a list of metafields that belong to a store (1/2)', () => { const output = fixtures.res.list; diff --git a/test/order-risk.test.js b/test/order-risk.test.js index 7ab2c7b4..8fd80dca 100644 --- a/test/order-risk.test.js +++ b/test/order-risk.test.js @@ -9,7 +9,7 @@ describe('Shopify#orderRisk', () => { const shopify = common.shopify; const scope = common.scope; - afterEach(() => expect(scope.isDone()).to.be.true); + afterEach(() => expect(scope.pendingMocks()).to.deep.equal([])); it('create a new order risk for an order', () => { const input = fixtures.req.create; diff --git a/test/order.test.js b/test/order.test.js index c8e1e38e..aa9bd281 100644 --- a/test/order.test.js +++ b/test/order.test.js @@ -9,7 +9,7 @@ describe('Shopify#order', () => { const shopify = common.shopify; const scope = common.scope; - afterEach(() => expect(scope.isDone()).to.be.true); + afterEach(() => expect(scope.pendingMocks()).to.deep.equal([])); it('gets a list of all orders (1/2)', () => { const output = fixtures.res.list; diff --git a/test/page.test.js b/test/page.test.js index e89e38be..688d782c 100644 --- a/test/page.test.js +++ b/test/page.test.js @@ -9,7 +9,7 @@ describe('Shopify#page', () => { const shopify = common.shopify; const scope = common.scope; - afterEach(() => expect(scope.isDone()).to.be.true); + afterEach(() => expect(scope.pendingMocks()).to.deep.equal([])); it('gets a list of all pages (1/2)', () => { const output = fixtures.res.list; diff --git a/test/payment.test.js b/test/payment.test.js index 73492fd2..4438a97b 100644 --- a/test/payment.test.js +++ b/test/payment.test.js @@ -9,7 +9,7 @@ describe('Shopify#payment', () => { const shopify = common.shopify; const scope = common.scope; - afterEach(() => expect(scope.isDone()).to.be.true); + afterEach(() => expect(scope.pendingMocks()).to.deep.equal([])); it('gets a list of payments on a particular checkout', () => { const output = fixtures.res.list; diff --git a/test/payout.test.js b/test/payout.test.js index 0623e614..a97cef39 100644 --- a/test/payout.test.js +++ b/test/payout.test.js @@ -13,7 +13,7 @@ describe('Shopify#payout', () => { const shopify = common.shopify; const shopName = common.shopName; - afterEach(() => expect(scope.isDone()).to.be.true); + afterEach(() => expect(scope.pendingMocks()).to.deep.equal([])); it('gets a list payouts (1/2)', () => { const output = fixtures.res.list; diff --git a/test/policy.test.js b/test/policy.test.js index 10f847b6..8e193444 100644 --- a/test/policy.test.js +++ b/test/policy.test.js @@ -9,7 +9,7 @@ describe('Shopify#policy', () => { const shopify = common.shopify; const scope = common.scope; - afterEach(() => expect(scope.isDone()).to.be.true); + afterEach(() => expect(scope.pendingMocks()).to.deep.equal([])); it('gets a list of all policies (1/2)', () => { const output = fixtures.res.list; diff --git a/test/price-rule.test.js b/test/price-rule.test.js index 856052e5..2bfa6dd1 100644 --- a/test/price-rule.test.js +++ b/test/price-rule.test.js @@ -9,7 +9,7 @@ describe('Shopify#priceRule', () => { const shopify = common.shopify; const scope = common.scope; - afterEach(() => expect(scope.isDone()).to.be.true); + afterEach(() => expect(scope.pendingMocks()).to.deep.equal([])); it('creates a new price rule', () => { const input = fixtures.req.create; diff --git a/test/product-image.test.js b/test/product-image.test.js index fef2c0f0..28f53ddc 100644 --- a/test/product-image.test.js +++ b/test/product-image.test.js @@ -9,7 +9,7 @@ describe('Shopify#productImage', () => { const shopify = common.shopify; const scope = common.scope; - afterEach(() => expect(scope.isDone()).to.be.true); + afterEach(() => expect(scope.pendingMocks()).to.deep.equal([])); it('gets a list of all product images for a product (1/2)', () => { const output = fixtures.res.list; diff --git a/test/product-listing.test.js b/test/product-listing.test.js index c9e11f12..df5b7b7f 100644 --- a/test/product-listing.test.js +++ b/test/product-listing.test.js @@ -12,8 +12,8 @@ describe('Shopify#productListing', () => { const presentmentApiScope = common.presentmentApiScope; afterEach(() => { - expect(presentmentApiScope.isDone()).to.be.true; - expect(standardScope.isDone()).to.be.true; + expect(presentmentApiScope.pendingMocks()).to.deep.equal([]); + expect(standardScope.pendingMocks()).to.deep.equal([]); }); it('gets product listings published to an application (1/4)', () => { diff --git a/test/product-resource-feedback.test.js b/test/product-resource-feedback.test.js index a211a233..8de8ace0 100644 --- a/test/product-resource-feedback.test.js +++ b/test/product-resource-feedback.test.js @@ -9,7 +9,7 @@ describe('Shopify#productResourceFeedback', () => { const shopify = common.shopify; const scope = common.scope; - afterEach(() => expect(scope.isDone()).to.be.true); + afterEach(() => expect(scope.pendingMocks()).to.deep.equal([])); it('creates a resource feedback', () => { const input = fixtures.req.create; diff --git a/test/product-variant.test.js b/test/product-variant.test.js index 76392dec..a667880f 100644 --- a/test/product-variant.test.js +++ b/test/product-variant.test.js @@ -12,8 +12,8 @@ describe('Shopify#productVariant', () => { const presentmentApiScope = common.presentmentApiScope; afterEach(() => { - expect(presentmentApiScope.isDone()).to.be.true; - expect(standardScope.isDone()).to.be.true; + expect(presentmentApiScope.pendingMocks()).to.deep.equal([]); + expect(standardScope.pendingMocks()).to.deep.equal([]); }); it('gets a list of all product variants for a product (1/4)', () => { diff --git a/test/product.test.js b/test/product.test.js index 7357cb0e..43073b98 100644 --- a/test/product.test.js +++ b/test/product.test.js @@ -12,8 +12,8 @@ describe('Shopify#product', () => { const presentmentApiScope = common.presentmentApiScope; afterEach(() => { - expect(presentmentApiScope.isDone()).to.be.true; - expect(standardScope.isDone()).to.be.true; + expect(presentmentApiScope.pendingMocks()).to.deep.equal([]); + expect(standardScope.pendingMocks()).to.deep.equal([]); }); it('gets a list of all products (1/4)', () => { diff --git a/test/province.test.js b/test/province.test.js index 49d72fc3..ae8aaed9 100644 --- a/test/province.test.js +++ b/test/province.test.js @@ -9,7 +9,7 @@ describe('Shopify#province', () => { const shopify = common.shopify; const scope = common.scope; - afterEach(() => expect(scope.isDone()).to.be.true); + afterEach(() => expect(scope.pendingMocks()).to.deep.equal([])); it('gets a list of all provinces for a country (1/2)', () => { const output = fixtures.res.list; diff --git a/test/recurring-application-charge.test.js b/test/recurring-application-charge.test.js index 1922a815..cd62031a 100644 --- a/test/recurring-application-charge.test.js +++ b/test/recurring-application-charge.test.js @@ -9,7 +9,7 @@ describe('Shopify#recurringApplicationCharge', () => { const resource = common.shopify.recurringApplicationCharge; const scope = common.scope; - afterEach(() => expect(scope.isDone()).to.be.true); + afterEach(() => expect(scope.pendingMocks()).to.deep.equal([])); it('creates a recurring application charge', () => { const input = fixtures.req.create; diff --git a/test/redirect.test.js b/test/redirect.test.js index e4790bb9..0fd3114a 100644 --- a/test/redirect.test.js +++ b/test/redirect.test.js @@ -9,7 +9,7 @@ describe('Shopify#redirect', () => { const shopify = common.shopify; const scope = common.scope; - afterEach(() => expect(scope.isDone()).to.be.true); + afterEach(() => expect(scope.pendingMocks()).to.deep.equal([])); it('gets a list of all redirects (1/2)', () => { const output = fixtures.res.list; diff --git a/test/refund.test.js b/test/refund.test.js index 402ea80a..7397d8b1 100644 --- a/test/refund.test.js +++ b/test/refund.test.js @@ -9,7 +9,7 @@ describe('Shopify#refund', () => { const shopify = common.shopify; const scope = common.scope; - afterEach(() => expect(scope.isDone()).to.be.true); + afterEach(() => expect(scope.pendingMocks()).to.deep.equal([])); it('gets a list of refunds for an order (1/2)', () => { const output = fixtures.res.list; diff --git a/test/report.test.js b/test/report.test.js index 457fd6d8..a64841fa 100644 --- a/test/report.test.js +++ b/test/report.test.js @@ -9,7 +9,7 @@ describe('Shopify#report', () => { const shopify = common.shopify; const scope = common.scope; - afterEach(() => expect(scope.isDone()).to.be.true); + afterEach(() => expect(scope.pendingMocks()).to.deep.equal([])); it('gets a list of all reports (1/2)', () => { const output = fixtures.res.list; diff --git a/test/resource-feedback.test.js b/test/resource-feedback.test.js index 38ab356a..4e510350 100644 --- a/test/resource-feedback.test.js +++ b/test/resource-feedback.test.js @@ -9,7 +9,7 @@ describe('Shopify#resourceFeedback', () => { const shopify = common.shopify; const scope = common.scope; - afterEach(() => expect(scope.isDone()).to.be.true); + afterEach(() => expect(scope.pendingMocks()).to.deep.equal([])); it('creates a resource feedback', () => { const input = fixtures.req.create; diff --git a/test/script-tag.test.js b/test/script-tag.test.js index 56b7ae81..6cd6e6a5 100644 --- a/test/script-tag.test.js +++ b/test/script-tag.test.js @@ -9,7 +9,7 @@ describe('Shopify#scriptTag', () => { const shopify = common.shopify; const scope = common.scope; - afterEach(() => expect(scope.isDone()).to.be.true); + afterEach(() => expect(scope.pendingMocks()).to.deep.equal([])); it('gets a list of all script tags (1/2)', () => { const output = fixtures.res.list; diff --git a/test/shipping-zone.test.js b/test/shipping-zone.test.js index 446a9d92..a04018dd 100644 --- a/test/shipping-zone.test.js +++ b/test/shipping-zone.test.js @@ -9,7 +9,7 @@ describe('Shopify#shippingZone', () => { const shopify = common.shopify; const scope = common.scope; - afterEach(() => expect(scope.isDone()).to.be.true); + afterEach(() => expect(scope.pendingMocks()).to.deep.equal([])); it('gets a list of all shipping zones (1/2)', () => { const output = fixtures.res.list; diff --git a/test/shop.test.js b/test/shop.test.js index 8ad7da8f..80b40423 100644 --- a/test/shop.test.js +++ b/test/shop.test.js @@ -13,7 +13,7 @@ describe('Shopify#shop', () => { const shopify = common.shopify; const shopName = common.shopName; - afterEach(() => expect(scope.isDone()).to.be.true); + afterEach(() => expect(scope.pendingMocks()).to.deep.equal([])); it('gets the configuration of the shop (1/2)', () => { const output = fixtures.res.get; diff --git a/test/shopify.test.js b/test/shopify.test.js index 901f161a..5a0a9bed 100644 --- a/test/shopify.test.js +++ b/test/shopify.test.js @@ -22,6 +22,7 @@ describe('Shopify', () => { const apiVersion = common.apiVersion; const password = common.password; const shopify = common.shopify; + const shopifyWithRetries = common.shopifyWithRetries; const shopName = common.shopName; it('exports the constructor', () => { @@ -38,6 +39,9 @@ describe('Shopify', () => { expect(() => new Shopify({ password })).to.throw(Error, msg); expect(() => new Shopify({ accessToken, apiKey })).to.throw(Error, msg); expect(() => new Shopify({ accessToken, password })).to.throw(Error, msg); + expect(() => { + new Shopify({ accessToken, password, maxRetries: 1, autoLimit: true }); + }).to.throw(Error, msg); }); it('makes the new operator optional', () => { @@ -121,9 +125,10 @@ describe('Shopify', () => { describe('Shopify#request', () => { const url = { pathname: '/test', ...shopify.baseUrl }; + const addWorkingRESTRequestMock = common.addWorkingRESTRequestMock; const scope = common.scope; - afterEach(() => expect(nock.isDone()).to.be.true); + afterEach(() => expect(nock.pendingMocks()).to.deep.equal([])); it('returns a RequestError when the request fails', () => { const message = 'Something wrong happened'; @@ -174,9 +179,7 @@ describe('Shopify', () => { }, (err) => { expect(err).to.be.an.instanceof(got.TimeoutError); - expect(err.message).to.include( - "Timeout awaiting 'request' for 100ms" - ); + expect(err.message).to.equal("Timeout awaiting 'request' for 100ms"); } ); }); @@ -529,17 +532,264 @@ describe('Shopify', () => { expect(res).to.deep.equal(data); }); }); - }); - describe('Shopify#graphql', () => { - const scope = nock(`https://${shopName}.myshopify.com`, { - reqheaders: { - 'User-Agent': `${pkg.name}/${pkg.version}`, - 'X-Shopify-Access-Token': accessToken + it('retries 429 errors from Shopify according to the header', () => { + scope + .get('/admin/shop.json') + .reply(429, 'too many requests', { 'Retry-After': '0.5' }) + .get('/admin/shop.json') + .reply(429, 'too many requests', { 'Retry-After': '0.5' }); + + addWorkingRESTRequestMock(scope); + + return shopifyWithRetries.shop.get().then((result) => { + expect(result.name).equal('My Cool Test Shop'); + }); + }); + + it("retries 429 errors from Shopify that don't have a header", () => { + scope + .get('/admin/shop.json') + .reply(429, 'too many requests') + .get('/admin/shop.json') + .reply(429, 'too many requests'); + + addWorkingRESTRequestMock(scope); + + return shopifyWithRetries.shop.get().then((result) => { + expect(result.name).equal('My Cool Test Shop'); + }); + }).timeout(8000); + + it('retries 429 errors from Shopify that have broken header values', () => { + scope + .get('/admin/shop.json') + .reply(429, 'too many requests', { 'Retry-After': 'foobar' }); + + addWorkingRESTRequestMock(scope); + + return shopifyWithRetries.shop.get().then((result) => { + expect(result.name).equal('My Cool Test Shop'); + }); + }); + + it('honors the maxRetries option', () => { + let attempts = 0; + + scope + .get('/admin/shop.json') + .times(4) + .reply(429, () => { + attempts++; + return 'too many requests'; + }); + + return shopifyWithRetries.shop.get().catch((result) => { + expect(result.response.statusCode).equal(429); + expect(attempts).equal(4); + }); + }).timeout(8000); + + it('does not retry 404 errors', () => { + scope.get('/admin/products/10.json').reply(404, { + error: 'not found' + }); + + return shopifyWithRetries.product.get(10).catch((err) => { + expect(err).to.be.an.instanceof(got.HTTPError); + expect(err.message).to.equal('Response code 404 (Not Found)'); + }); + }); + + it('does not retry 422 errors that return an error string', () => { + scope.put('/admin/products/10.json').reply(422, { + error: 'the product was invalid' + }); + + return shopifyWithRetries.product.update(10).catch((err) => { + expect(err).to.be.an.instanceof(got.HTTPError); + expect(err.message).to.equal( + 'Response code 422 (Unprocessable Entity)' + ); + }); + }); + + it('does not retry 422 errors that return an errors array', () => { + scope.put('/admin/products/10.json').reply(422, { + errors: ['the product was invalid'] + }); + + return shopifyWithRetries.product.update(10).catch((err) => { + expect(err).to.be.an.instanceof(got.HTTPError); + expect(err.message).to.equal( + 'Response code 422 (Unprocessable Entity)' + ); + }); + }); + + it('does not retry 422 errors that return an errors object', () => { + scope.put('/admin/products/10.json').reply(422, { + errors: { + title: 'is required' + } + }); + + return shopifyWithRetries.product.update(10).catch((err) => { + expect(err).to.be.an.instanceof(got.HTTPError); + expect(err.message).to.equal( + 'Response code 422 (Unprocessable Entity)' + ); + }); + }); + + it('retries 500 errors from shopify', () => { + scope.get('/admin/shop.json').reply(500, { + error: 'something went wrong' + }); + + addWorkingRESTRequestMock(scope); + + return shopifyWithRetries.shop.get().then((result) => { + expect(result.name).equal('My Cool Test Shop'); + }); + }).timeout(4000); + + it('retries network system level errors immediately', () => { + scope.get('/admin/shop.json').replyWithError({ + message: 'the network is broken', + code: 'ECONNRESET' + }); + + addWorkingRESTRequestMock(scope); + + return shopifyWithRetries.shop.get().then((result) => { + expect(result.name).equal('My Cool Test Shop'); + }); + }); + + it('retries a variety of errors in order', () => { + const shopify = new Shopify({ + accessToken, + shopName, + maxRetries: 5, + timeout: 200 + }); + + scope + .get('/admin/shop.json') + .replyWithError({ + message: 'the network is broken', + code: 'ECONNRESET' + }) + .get('/admin/shop.json') + .reply(429, 'too many requests', { 'Retry-After': '1' }) + .get('/admin/shop.json') + .reply(429, 'too many requests', { 'Retry-After': '0.2' }) + .get('/admin/shop.json') + .reply(500, 'sorry its broken') + .get('/admin/shop.json') + .delay(500) // Longer than API client configured timeout option. + .reply(200, { + shop: { + id: 1, + name: 'My Cool Test Shop' + } + }); + + addWorkingRESTRequestMock(scope); + + return shopify.shop.get().then((result) => { + expect(result.name).equal('My Cool Test Shop'); + }); + }).timeout(10000); + + it('calls hooks passed as options when accessing resources', () => { + function beforeRequest() { + beforeRequest.called = true; + } + + function afterResponse(x) { + afterResponse.called = true; + return x; + } + + const shopify = new Shopify({ + accessToken, + shopName, + hooks: { + beforeRequest: [beforeRequest], + afterResponse: [afterResponse] + } + }); + + addWorkingRESTRequestMock(scope); + + return shopify.shop.get().then(() => { + expect(beforeRequest.called).to.be.true; + expect(afterResponse.called).to.be.true; + }); + }); + + it('calls hooks passed as options when making raw requests', () => { + function beforeRequest() { + beforeRequest.called = true; + } + + function afterResponse(x) { + afterResponse.called = true; + return x; + } + + const shopify = new Shopify({ + accessToken, + shopName, + hooks: { + beforeRequest: [beforeRequest], + afterResponse: [afterResponse] + } + }); + + scope.get('/test').reply(200, {}); + + return shopify.request(url, 'GET').then(() => { + expect(beforeRequest.called).to.be.true; + expect(afterResponse.called).to.be.true; + }); + }); + + it('calls the beforeRetry hook for retried requests', () => { + function beforeRetry() { + beforeRetry.called = true; } + + const shopify = new Shopify({ + accessToken, + shopName, + maxRetries: 3, + hooks: { + beforeRetry: [beforeRetry] + } + }); + + scope.get('/admin/shop.json').replyWithError({ + message: 'the network is broken', + code: 'ECONNRESET' + }); + + addWorkingRESTRequestMock(scope); + + return shopify.shop.get().then((result) => { + expect(beforeRetry.called).to.be.true; + expect(result.name).equal('My Cool Test Shop'); + }); }); + }); - afterEach(() => expect(nock.isDone()).to.be.true); + describe('Shopify#graphql', () => { + const addWorkingGraphQLRequestMock = common.addWorkingGraphQLRequestMock; + const scope = common.scope; + + afterEach(() => expect(nock.pendingMocks()).to.deep.equal([])); it('returns a RequestError when the request fails', () => { const message = 'Something wrong happened'; @@ -634,9 +884,7 @@ describe('Shopify', () => { }, (err) => { expect(err).to.be.an.instanceof(got.TimeoutError); - expect(err.message).to.include( - "Timeout awaiting 'request' for 100ms" - ); + expect(err.message).to.equal("Timeout awaiting 'request' for 100ms"); } ); }); @@ -835,5 +1083,271 @@ describe('Shopify', () => { expect(res).to.deep.equal(data); }); }); + + it('does not retry errors from broken GraphQL queries', () => { + scope.post('/admin/api/graphql.json').reply(200, { + errors: [ + { + message: 'Parse error on "}" (RCURLY) at [4, 1]', + locations: [ + { + line: 4, + column: 1 + } + ] + } + ] + }); + + return shopifyWithRetries.graphql('query { shop ').catch((err) => { + expect(err.message).to.equal('Parse error on "}" (RCURLY) at [4, 1]'); + }); + }); + + it('retries 500 errors from Shopify', () => { + scope.post('/admin/api/graphql.json').reply(500, 'something went wrong'); + + addWorkingGraphQLRequestMock(scope); + + return shopifyWithRetries + .graphql('query { shop { id name } }') + .then((result) => { + expect(result.shop.name).equal('My Cool Test Shop'); + }); + }).timeout(4000); + + it('retries timeout errors from Shopify', () => { + const shopify = new Shopify({ + accessToken, + shopName, + maxRetries: 3, + timeout: 900 + }); + + scope + .post('/admin/api/graphql.json') + .delay(1000) + .reply(500, 'something went wrong'); + + addWorkingGraphQLRequestMock(scope); + + return shopify.graphql('query { shop { id name } }').then((result) => { + expect(result.shop.name).equal('My Cool Test Shop'); + }); + }).timeout(4000); + + it('retries network system level errors immediately', () => { + scope.post('/admin/api/graphql.json').replyWithError({ + message: 'the network is broken', + code: 'ECONNRESET' + }); + + addWorkingGraphQLRequestMock(scope); + + return shopifyWithRetries + .graphql('query { shop { id name } }') + .then((result) => { + expect(result.shop.name).equal('My Cool Test Shop'); + }); + }); + + it('retries GraphQL cost limit exceeded errors', () => { + scope + .post('/admin/api/graphql.json') + .reply(200, { + errors: [ + { + message: 'Throttled', + extensions: { + code: 'THROTTLED', + documentation: 'https://shopify.dev/api/usage/rate-limits' + } + } + ], + extensions: { + cost: { + requestedQueryCost: 732, + actualQueryCost: null, + throttleStatus: { + maximumAvailable: 1000, + currentlyAvailable: 728, + restoreRate: 50 + } + } + } + }) + .post('/admin/api/graphql.json') + .reply(200, { + errors: [ + { + message: 'Throttled', + extensions: { + code: 'THROTTLED', + documentation: 'https://shopify.dev/api/usage/rate-limits' + } + } + ], + extensions: { + cost: { + requestedQueryCost: 732, + actualQueryCost: null, + throttleStatus: { + maximumAvailable: 1000, + currentlyAvailable: 675, + restoreRate: 50 + } + } + } + }); + + addWorkingGraphQLRequestMock(scope); + + return shopifyWithRetries + .graphql('query { shop { id name } }') + .then((result) => { + expect(result.shop.name).equal('My Cool Test Shop'); + }); + }).timeout(3000); + + it('calls hooks passed as options when making graphql requests', () => { + function beforeRequest() { + beforeRequest.called = true; + } + + function afterResponse(x) { + afterResponse.called = true; + return x; + } + + const shopify = new Shopify({ + accessToken, + shopName, + hooks: { + beforeRequest: [beforeRequest], + afterResponse: [afterResponse] + } + }); + + scope.post('/admin/api/graphql.json').reply(200, { + data: { foo: 'bar' } + }); + + return shopify.graphql('query').then(() => { + expect(beforeRequest.called).to.be.true; + expect(afterResponse.called).to.be.true; + }); + }); + + it('runs the afterResponse error hook after the user hooks', () => { + function afterResponse(response) { + afterResponse.errors = response.body.errors; + // + // Prevents errors from being thrown. + // + response.body.errors = undefined; + return response; + } + + const data = { + customers: { + edges: [ + { + node: { + id: 'gid://shopify/Customer/1234567890', + email: null + } + } + ] + } + }; + + const errors = [ + { + message: + 'This app is not approved to use the email field. ' + + 'See https://partners.shopify.com/1/apps/1/customer_data ' + + 'for more details.', + path: ['customers', 'edges', '0', 'node', 'email'], + extensions: { + code: 'ACCESS_DENIED', + documentation: + 'https://partners.shopify.com/1/apps/1/customer_data', + requiredAccess: + 'Shopify approval is required before using the email field.' + } + } + ]; + + scope.post('/admin/api/graphql.json').reply(200, { data, errors }); + + const shopify = new Shopify({ + shopName, + accessToken, + hooks: { + afterResponse: [afterResponse] + } + }); + + return shopify.graphql('query').then((result) => { + expect(afterResponse.errors).to.deep.equal(errors); + expect(result).to.deep.equal(data); + }); + }); + + it('calls the beforeRetry hook for retried requests', () => { + function beforeRetry() { + beforeRetry.called = true; + } + + const shopify = new Shopify({ + accessToken, + shopName, + maxRetries: 3, + hooks: { + beforeRetry: [beforeRetry] + } + }); + + scope.post('/admin/api/graphql.json').replyWithError({ + message: 'the network is broken', + code: 'ECONNRESET' + }); + + addWorkingGraphQLRequestMock(scope); + + return shopify.graphql('query { shop { id name } }').then(() => { + expect(beforeRetry.called).to.be.true; + }); + }); + + it('calls the beforeError hook for errors', () => { + function beforeError(x) { + beforeError.called = true; + return x; + } + + const shopify = new Shopify({ + accessToken, + shopName, + hooks: { + beforeError: [beforeError] + } + }); + + const message = 'Something wrong happened'; + + scope.post('/admin/api/graphql.json').replyWithError(message); + + return shopify.graphql('query { shop { id name } }').then( + () => { + throw new Error('Test invalidation'); + }, + (err) => { + expect(beforeError.called).to.be.true; + expect(err).to.be.an.instanceof(got.RequestError); + expect(err.message).to.equal(message); + } + ); + }); }); }); diff --git a/test/smart-collection.test.js b/test/smart-collection.test.js index ddaed257..419ed29c 100644 --- a/test/smart-collection.test.js +++ b/test/smart-collection.test.js @@ -10,7 +10,7 @@ describe('Shopify#smartCollection', () => { const shopify = common.shopify; const scope = common.scope; - afterEach(() => expect(scope.isDone()).to.be.true); + afterEach(() => expect(scope.pendingMocks()).to.deep.equal([])); it('gets a list of all smart collections (1/2)', () => { const output = fixtures.res.list; diff --git a/test/storefront-access-token.test.js b/test/storefront-access-token.test.js index 35ca33bc..8216f84e 100644 --- a/test/storefront-access-token.test.js +++ b/test/storefront-access-token.test.js @@ -9,7 +9,7 @@ describe('Shopify#storefrontAccessToken', () => { const shopify = common.shopify; const scope = common.scope; - afterEach(() => expect(scope.isDone()).to.be.true); + afterEach(() => expect(scope.pendingMocks()).to.deep.equal([])); it('creates a storefront access token', () => { const input = fixtures.req.create; diff --git a/test/tender-transaction.test.js b/test/tender-transaction.test.js index 4d307ec3..cf829705 100644 --- a/test/tender-transaction.test.js +++ b/test/tender-transaction.test.js @@ -9,7 +9,7 @@ describe('Shopify#tenderTransaction', () => { const shopify = common.shopify; const scope = common.scope; - afterEach(() => expect(scope.isDone()).to.be.true); + afterEach(() => expect(scope.pendingMocks()).to.deep.equal([])); it('gets a list of tender transactions (1/2)', () => { const output = fixtures.res.list; diff --git a/test/theme.test.js b/test/theme.test.js index fb458df5..6b66ea17 100644 --- a/test/theme.test.js +++ b/test/theme.test.js @@ -9,7 +9,7 @@ describe('Shopify#theme', () => { const shopify = common.shopify; const scope = common.scope; - afterEach(() => expect(scope.isDone()).to.be.true); + afterEach(() => expect(scope.pendingMocks()).to.deep.equal([])); it('gets a list of all themes (1/2)', () => { const output = fixtures.res.list; diff --git a/test/transaction.test.js b/test/transaction.test.js index acdcbfe0..2ff82aec 100644 --- a/test/transaction.test.js +++ b/test/transaction.test.js @@ -9,7 +9,7 @@ describe('Shopify#transaction', () => { const shopify = common.shopify; const scope = common.scope; - afterEach(() => expect(scope.isDone()).to.be.true); + afterEach(() => expect(scope.pendingMocks()).to.deep.equal([])); it('gets a list of all transactions for an order (1/2)', () => { const output = fixtures.res.list; diff --git a/test/usage-charge.test.js b/test/usage-charge.test.js index 0553ee92..2088d11b 100644 --- a/test/usage-charge.test.js +++ b/test/usage-charge.test.js @@ -10,7 +10,7 @@ describe('Shopify#usageCharge', () => { const shopify = common.shopify; const scope = common.scope; - afterEach(() => expect(scope.isDone()).to.be.true); + afterEach(() => expect(scope.pendingMocks()).to.deep.equal([])); it('creates a new usage charge', () => { const input = fixtures.req.create; diff --git a/test/user.test.js b/test/user.test.js index 97ff43f5..544a839b 100644 --- a/test/user.test.js +++ b/test/user.test.js @@ -9,7 +9,7 @@ describe('Shopify#user', () => { const shopify = common.shopify; const scope = common.scope; - afterEach(() => expect(scope.isDone()).to.be.true); + afterEach(() => expect(scope.pendingMocks()).to.deep.equal([])); it('gets a list of all users', () => { const output = fixtures.res.list; diff --git a/test/webhook.test.js b/test/webhook.test.js index 39909502..b76b52bc 100644 --- a/test/webhook.test.js +++ b/test/webhook.test.js @@ -10,7 +10,7 @@ describe('Shopify#webhook', () => { const shopify = common.shopify; const scope = common.scope; - afterEach(() => expect(scope.isDone()).to.be.true); + afterEach(() => expect(scope.pendingMocks()).to.deep.equal([])); it('gets a list of all webhooks (1/2)', () => { const output = fixtures.res.list; diff --git a/index.d.ts b/types/index.d.ts similarity index 88% rename from index.d.ts rename to types/index.d.ts index ede727e8..24976d21 100644 --- a/index.d.ts +++ b/types/index.d.ts @@ -1,6 +1,7 @@ -// Type definitions for shopify-api-node 2.10.0 +// Type definitions for shopify-api-node // Project: shopify-api-node // Definitions by: Rich Buggy +import { Hooks, Agents } from 'got'; /*~ This is the module template file for class modules. *~ You should rename it to index.d.ts and place it in a folder with the same name as the module. @@ -250,6 +251,9 @@ declare class Shopify { ) => Promise>; update: (id: number, params: any) => Promise; }; + deprecatedApiCall: { + list: () => Promise; + }; discountCode: { create: ( priceRuleId: number, @@ -283,6 +287,20 @@ declare class Shopify { get: (id: number) => Promise; list: (params?: any) => Promise>; }; + disputeEvidence: { + get: (disputeId: number) => Promise; + update: ( + disputeId: number, + params: Shopify.IUpdateDisputeEvidence + ) => Promise; + }; + disputeFileUpload: { + create: ( + disputeId: number, + params: Shopify.ICreateDisputeFileUpload + ) => Promise; + delete: (id: number) => Promise; + }; draftOrder: { complete: (id: number, params?: any) => Promise; count: () => Promise; @@ -302,6 +320,7 @@ declare class Shopify { }; fulfillment: { cancel: (orderId: number, id: number) => Promise; + cancelV2: (id: number) => Promise; complete: (orderId: number, id: number) => Promise; count: (orderId: number, params?: any) => Promise; create: (orderId: number, params: any) => Promise; @@ -352,18 +371,30 @@ declare class Shopify { ) => Promise; }; fulfillmentOrder: { - cancel: ( - id: number, - params: Shopify.IFulfillmentOrder - ) => Promise; + cancel: (id: number) => Promise; close: (id: number, message?: string) => Promise; + fulfillments: ( + id: number + ) => Promise>; get: (id: number) => Promise; + hold: ( + id: number, + params: Shopify.IFulfillmentHold + ) => Promise; list: (params?: any) => Promise; locationsForMove: (id: number) => Promise; move: ( id: number, locationId: number ) => Promise; + releaseHold: (id: number) => Promise; + reschedule: ( + id: number, + deadline: string + ) => Promise; + setFulfillmentOrdersDeadline: ( + params: Shopify.ISetFulfillmentOrdersDeadline + ) => Promise; }; fulfillmentRequest: { accept: ( @@ -759,19 +790,25 @@ declare namespace Shopify { accessToken: string; apiVersion?: string; autoLimit?: boolean | IAutoLimit; + maxRetries?: number; presentmentPrices?: boolean; shopName: string; timeout?: number; + hooks?: Hooks; + agent?: Agents; } export interface IPrivateShopifyConfig { apiKey: string; apiVersion?: string; autoLimit?: boolean | IAutoLimit; + maxRetries?: number; password: string; presentmentPrices?: boolean; shopName: string; timeout?: number; + hooks?: Hooks; + agent?: Agents; } export interface ICallLimits { @@ -965,32 +1002,32 @@ declare namespace Shopify { } interface ICreateArticle { - author: string; - body_html: string; + author?: string; + body_html?: string; handle?: string; - image?: IBase64Image; + image?: ICreateArticleImage; metafields?: ICreateObjectMetafield[]; published?: boolean; published_at?: string; summary_html?: string | null; tags?: string; template_suffix?: string | null; - title: string; + title?: string; user_id?: number; } interface IUpdateArticle { - author: string; - body_html: string; + author?: string; + body_html?: string; handle?: string; - image?: IBase64Image; + image?: ICreateArticleImage; metafields?: ICreateObjectMetafield[]; published?: boolean; published_at?: string; summary_html?: string | null; tags?: string; template_suffix?: string | null; - title: string; + title?: string; user_id?: number; } @@ -1003,8 +1040,10 @@ declare namespace Shopify { alt: string | null; } - interface IBase64Image { - attachment: string; + interface ICreateArticleImage { + attachment?: string; + src?: string; + alt?: string; } interface IObjectMetafield { @@ -1025,6 +1064,7 @@ declare namespace Shopify { interface IAsset { attachment?: string; + checksum: string; content_type: string; created_at: string; key: string; @@ -1355,8 +1395,15 @@ declare namespace Shopify { type CustomerState = 'declined' | 'disabled' | 'enabled' | 'invited'; + interface IEmailMarketingConsent { + state: string; + opt_in_level: string | null; + consent_updated_at: string; + } + interface ICustomer { - accepts_marketing: boolean; + accepts_marketing?: boolean; + email_marketing_consent?: IEmailMarketingConsent; addresses?: ICustomerAddress[]; created_at: string; currency: string; @@ -1367,7 +1414,7 @@ declare namespace Shopify { last_name: string; metafield?: IObjectMetafield; // From https://help.shopify.com/api/reference/customer but not visible in test API call phone: string; - multipass_identifier: null; + multipass_identifier: string | null; last_order_id: number | null; last_order_name: string | null; note: string | null; @@ -1418,6 +1465,17 @@ declare namespace Shopify { custom_message: string; } + interface IDeprecatedApiCall { + api_type: string; + description: string; + documentation_url: string; + endpoint: string; + graphql_schema_name: string | null; + last_call_at: string; + migration_deadline: string; + version: string; + } + type AllocationMethod = 'across' | 'each' | 'one'; type TargetSelection = 'all' | 'entitled' | 'explicit'; type TargetType = 'line_item' | 'shipping_line'; @@ -1436,6 +1494,12 @@ declare namespace Shopify { value_type: ValueType; } + interface IDiscountAllocation { + amount: string; + amount_set: IMoneySet; + discount_application_index: number; + } + interface IDiscountCode { created_at: string; id: number; @@ -1496,6 +1560,90 @@ declare namespace Shopify { finalized_on: string; } + interface IDisputeEvidenceAddress { + address1: string; + address2: string; + city: string; + country: string; + country_code: string; + id: number; + province: string; + province_code: string; + zip: string; + } + + interface IDisputeEvidenceFulfillment { + shipping_carrier: number; + shipping_date: string; + shipping_tracking_number: string; + } + + interface IDisputeEvidenceFiles { + cancellation_policy_file_id: number | null; + customer_communication_file_id: number | null; + customer_signature_file_id: number | null; + refund_policy_file_id: number | null; + service_documentation_file_id: number | null; + shipping_documentation_file_id: number | null; + uncategorized_file_id: number | null; + } + + interface IDisputeEvidence { + access_activity_log: string | null; + billing_address: IDisputeEvidenceAddress; + cancellation_policy_disclosure: string | null; + cancellation_rebuttal: string | null; + created_at: string; + customer_email_address: string; + customer_first_name: string; + customer_last_name: string; + dispute_evidence_files: IDisputeEvidenceFiles; + fulfillments: IDisputeEvidenceFulfillment[]; + id: number; + payments_dispute_id: number; + product_description: string; + refund_policy_disclosure: string | null; + refund_refusal_explanation: string | null; + shipping_address: IDisputeEvidenceAddress; + submitted_by_merchant_on: string | null; + uncategorized_text: string; + updated_at: string; + } + + interface IUpdateDisputeEvidence { + refund_refusal_explanation: string; + } + + type DisputeFileUploadDisputeEvidenceType = + | 'cancellation_policy_file' + | 'customer_communication_file' + | 'customer_signature_file' + | 'refund_policy_file' + | 'service_documentation_file' + | 'shipping_documentation_file' + | 'uncategorized_file'; + + interface IDisputeFileUpload { + created_at: string; + dispute_evidence_id: number; + dispute_evidence_type: DisputeFileUploadDisputeEvidenceType; + file_size: number; + file_type: string; + filename: string; + id: number; + original_filename: string; + shop_id: number; + updated_at: string; + url: string; + } + + interface ICreateDisputeFileUpload { + document_type: DisputeFileUploadDisputeEvidenceType; + filename: string; + mimetype: string; + data: string; + } + interface IDraftOrderNoteAttribute { name: string; value: string; @@ -1643,6 +1791,7 @@ declare namespace Shopify { total_discount: string; fulfillment_status: IFulfillmentStatus; tax_lines: IFulfillmentLineItemTaxLine[]; + discount_allocations: IDiscountAllocation[]; } interface IFulfillment { @@ -1664,11 +1813,16 @@ declare namespace Shopify { } type FulfillmentEventStatus = + | 'attempted_delivery' | 'confirmed' | 'delivered' | 'failure' | 'in_transit' - | 'out_for_delivery'; + | 'label_printed' + | 'label_purchased' + | 'out_for_delivery' + | 'picked_up' + | 'ready_for_pickup'; interface IFulfillmentEvent { address1: string | null; @@ -1731,7 +1885,9 @@ declare namespace Shopify { | 'closed' | 'in_progress' | 'incomplete' - | 'open'; + | 'open' + | 'on_hold' + | 'scheduled'; type FulfillmentOrderSupportedAction = | 'cancel_fulfillment_order' @@ -1764,6 +1920,22 @@ declare namespace Shopify { zip: string; } + interface IFulfillmentOrderFulfillmentHolds { + reason: string; + reason_notes: string; + } + + interface IFulfillmentOrderInternationalDuties { + incoterm: 'DAP' | 'DDP'; + } + + interface IFulfillmentOrderDeliveryMethod { + id: number; + method_type: 'local' | 'none' | 'pick_up' | 'retail' | 'shipping'; + min_delivery_date_time: string; + max_delivery_date_time: string; + } + interface IFulfillmentOrder { assigned_location: IFulfillmentOrderAssignedLocation; assigned_location_id: number; @@ -1777,6 +1949,9 @@ declare namespace Shopify { shop_id: number; status: FulfillmentOrderStatus; supported_actions: FulfillmentOrderSupportedAction[]; + fulfillment_holds: IFulfillmentOrderFulfillmentHolds[]; + international_duties: IFulfillmentOrderInternationalDuties; + delivery_method: IFulfillmentOrderDeliveryMethod | null; } interface ILocationForMoveLocation { @@ -1790,6 +1965,11 @@ declare namespace Shopify { message: string; } + interface ISetFulfillmentOrdersDeadline { + fulfillment_deadline: string; + fulfillment_order_ids: number[]; + } + interface ICreateFulfillmentRequestFulfillmentOrderLineItem { id: number; quantity: number; @@ -1833,6 +2013,7 @@ declare namespace Shopify { updated_at: string; disabled_at: string; expires_on: string; + initial_value: string; } interface IGiftCardAdjustment { @@ -2027,7 +2208,8 @@ declare namespace Shopify { } interface IOrderCustomer { - accepts_marketing: boolean; + accepts_marketing?: boolean; + email_marketing_consent?: IEmailMarketingConsent; created_at: string; default_address: ICustomerAddress; email: string; @@ -2064,7 +2246,7 @@ declare namespace Shopify { | 'offsite'; interface IOrderDiscountCode { - amount: number; + amount: string; code: string; type: OrderDiscountCodeType; } @@ -2142,6 +2324,7 @@ declare namespace Shopify { } interface IOrderLineItem { + current_quantity?: number; discount_allocations: ILineItemDiscountAllocation[]; fulfillable_quantity: number; fulfillment_service: string; @@ -2178,6 +2361,7 @@ declare namespace Shopify { } interface IOrderShippingLine { + id: number; code: string; discounted_price: string; discounted_price_set: IMoneySet; @@ -2207,8 +2391,11 @@ declare namespace Shopify { cancelled_at: string | null; cart_token: string; client_details: IOrderClientDetails; + checkout_token: string | null; closed_at: string | null; + confirmation_number: string; confirmed: boolean; + contact_email: string | null; created_at: string; currency: string; current_subtotal_price: string; @@ -2269,6 +2456,7 @@ declare namespace Shopify { total_discounts_set: IMoneySet; total_line_items_price: string; total_line_items_price_set: IMoneySet; + total_outstanding: string; total_price: string; total_price_set: IMoneySet; total_shipping_price_set: IMoneySet; @@ -2719,23 +2907,25 @@ declare namespace Shopify { interface IShop { address1: string; address2: string; + checkout_api_supported: boolean; city: string; + cookie_consent_level: string; country: string; country_code: string; country_name: string; + county_taxes: true | null; created_at: string; - county_taxes: string; - customer_email: string | null; currency: string; + customer_email: string; domain: string; eligible_for_card_reader_giveaway: boolean; eligible_for_payments: boolean; - enabled_presentment_currencies: string[]; email: string; + enabled_presentment_currencies: string[]; finances: boolean; force_ssl?: boolean; google_apps_domain: string | null; - google_apps_login_enabled: any | null; + google_apps_login_enabled: true | null; has_discounts: boolean; has_gift_cards: boolean; has_storefront: boolean; @@ -2765,6 +2955,7 @@ declare namespace Shopify { tax_shipping: boolean | null; taxes_included: true | null; timezone: string; + transactional_sms_disabled: boolean; updated_at: string; weight_unit: string; zip: string; @@ -2899,31 +3090,45 @@ declare namespace Shopify { | 'refund' | 'sale' | 'void'; - type TransactionSourceName = 'android' | 'iphone' | 'pos' | 'web'; type TransactionStatus = 'error' | 'failure' | 'pending' | 'success'; + interface IExtendedAuthorizationAttributes { + standard_authorization_expires_at: string; + extended_authorization_expires_at: string; + } + + interface IPaymentsRefund { + status: TransactionStatus; + acquirer_reference_number: string; + } + interface ITransaction { amount: string; - authorization: string; + authorization: string | null; + authorization_expires_at?: string | null; created_at: string; currency: string; - currency_exchange_adjustment: ICurrencyExchangeAdjustment; - device_id: string; - error_code: TransactionErrorCode; + currency_exchange_adjustment?: ICurrencyExchangeAdjustment | null; + device_id: number | null; + error_code: TransactionErrorCode | null; + extended_authorization_attributes?: + | IExtendedAuthorizationAttributes + | Record; gateway: string; id: number; kind: TransactionKind; - location_id: number; - message: string; + location_id: number | null; + message: string | null; order_id: number; - payment_details: IPaymentDetails; - parent_id: number; + parent_id: number | null; + payment_details?: IPaymentDetails; + payments_refund_attributes?: IPaymentsRefund; processed_at: string; receipt: Record; - source_name: TransactionSourceName; + source_name: string; status: TransactionStatus; test: boolean; - user_id: number; + user_id: number | null; } interface IUsageCharge { @@ -2995,6 +3200,7 @@ declare namespace Shopify { export type WebhookTopic = | 'app/uninstalled' + | 'app_subscriptions/update' | 'bulk_operations/finish' | 'carts/create' | 'carts/update' @@ -3007,20 +3213,66 @@ declare namespace Shopify { | 'collections/create' | 'collections/delete' | 'collections/update' + | 'companies/create' + | 'companies/delete' + | 'companies/update' + | 'company_contact_roles/assign' + | 'company_contact_roles/revoke' + | 'company_contacts/create' + | 'company_contacts/delete' + | 'company_contacts/update' + | 'company_locations/create' + | 'company_locations/delete' + | 'company_locations/update' + | 'customer.tags_added' + | 'customer.tags_removed' | 'customer_groups/create' | 'customer_groups/delete' | 'customer_groups/update' + | 'customer_payment_methods/create' + | 'customer_payment_methods/revoke' + | 'customer_payment_methods/update' | 'customers/create' | 'customers/delete' | 'customers/disable' | 'customers/enable' + | 'customers/merge' | 'customers/update' + | 'customers_email_marketing_consent/update' | 'customers_marketing_consent/update' + | 'discounts/create' + | 'discounts/delete' + | 'discounts/redeemcode_added' + | 'discounts/redeemcode_removed' + | 'discounts/update' + | 'disputes/create' + | 'disputes/update' + | 'domains/create' + | 'domains/destroy' + | 'domains/update' | 'draft_orders/create' | 'draft_orders/delete' | 'draft_orders/update' | 'fulfillment_events/create' | 'fulfillment_events/delete' + | 'fulfillment_orders/cancellation_request_accepted' + | 'fulfillment_orders/cancellation_request_rejected' + | 'fulfillment_orders/cancellation_request_submitted' + | 'fulfillment_orders/cancelled' + | 'fulfillment_orders/fulfillment_request_accepted' + | 'fulfillment_orders/fulfillment_request_rejected' + | 'fulfillment_orders/fulfillment_request_submitted' + | 'fulfillment_orders/fulfillment_service_failed_to_complete' + | 'fulfillment_orders/hold_released' + | 'fulfillment_orders/line_items_prepared_for_local_delivery' + | 'fulfillment_orders/line_items_prepared_for_pickup' + | 'fulfillment_orders/merged' + | 'fulfillment_orders/moved' + | 'fulfillment_orders/order_routing_complete' + | 'fulfillment_orders/placed_on_hold' + | 'fulfillment_orders/rescheduled' + | 'fulfillment_orders/scheduled_fulfillment_order_ready' + | 'fulfillment_orders/split' | 'fulfillments/create' | 'fulfillments/update' | 'inventory_items/create' @@ -3029,9 +3281,17 @@ declare namespace Shopify { | 'inventory_levels/connect' | 'inventory_levels/disconnect' | 'inventory_levels/update' + | 'locales/create' + | 'locales/destroy' + | 'locales/update' + | 'locations/activate' | 'locations/create' + | 'locations/deactivate' | 'locations/delete' | 'locations/update' + | 'markets/create' + | 'markets/delete' + | 'markets/update' | 'order_transactions/create' | 'orders/cancelled' | 'orders/create' @@ -3041,16 +3301,25 @@ declare namespace Shopify { | 'orders/paid' | 'orders/partially_fulfilled' | 'orders/updated' + | 'payment_schedules/due' | 'payment_terms/create' | 'payment_terms/delete' | 'payment_terms/update' + | 'product_feeds/full_sync' + | 'product_feeds/incremental_sync' | 'product_listings/add' | 'product_listings/remove' | 'product_listings/update' | 'products/create' | 'products/delete' | 'products/update' + | 'profiles/create' + | 'profiles/delete' + | 'profiles/update' | 'refunds/create' + | 'scheduled_product_listings/add' + | 'scheduled_product_listings/remove' + | 'scheduled_product_listings/update' | 'selling_plan_groups/create' | 'selling_plan_groups/delete' | 'selling_plan_groups/update' @@ -3058,6 +3327,11 @@ declare namespace Shopify { | 'subscription_billing_attempts/challenged' | 'subscription_billing_attempts/failure' | 'subscription_billing_attempts/success' + | 'subscription_billing_cycle_edits/create' + | 'subscription_billing_cycle_edits/delete' + | 'subscription_billing_cycle_edits/update' + | 'subscription_contracts/create' + | 'subscription_contracts/update' | 'tender_transactions/create' | 'themes/create' | 'themes/delete' @@ -3073,6 +3347,7 @@ declare namespace Shopify { format: WebhookFormat; id: number; metafield_namespaces: string[]; + private_metafield_namespaces: string[]; topic: WebhookTopic; updated_at: string; } @@ -3082,6 +3357,7 @@ declare namespace Shopify { fields?: string[]; format?: WebhookFormat; metafield_namespaces?: string[]; + private_metafield_namespaces?: string[]; topic: WebhookTopic; } @@ -3090,9 +3366,11 @@ declare namespace Shopify { fields?: string[]; format?: WebhookFormat; metafield_namespaces?: string[]; + private_metafield_namespaces?: string[]; topic: WebhookTopic; } + // prettier-ignore export type WebhookType = T extends 'app/uninstalled' ? IShop : T extends 'carts/create' @@ -3133,6 +3411,16 @@ declare namespace Shopify { ? ICustomerSavedSearch : T extends 'customer_groups/delete' ? IDeletedItem + : T extends 'discounts/create' + ? IDiscountUpsertWebhook + : T extends 'discounts/delete' + ? IDiscountDeleteWebhook + : T extends 'discounts/redeemcode_added' + ? IDiscountRedeemCodeWebhook + : T extends 'discounts/redeemcode_removed' + ? IDiscountRedeemCodeWebhook + : T extends 'discounts/update' + ? IDiscountUpsertWebhook : T extends 'draft_orders/create' ? IDraftOrder : T extends 'draft_orders/update' @@ -3262,4 +3550,45 @@ declare namespace Shopify { min_delivery_date?: string; max_delivery_date?: string; } + + type DiscountStatus = 'ACTIVE' | 'EXPIRED' | 'SCHEDULED'; + + interface IDiscountUpsertWebhook { + admin_graphql_api_id: string; + title: string; + status: DiscountStatus; + created_at: string; + updated_at: string; + } + + interface IDiscountDeleteWebhook { + admin_graphql_api_id: string; + deleted_at: string; + } + + interface IDiscountRedeemCodeWebhook { + admin_graphql_api_id: string; + redeem_code: { + id: string; + code: string; + }; + updated_at: string; + } + + interface IFulfillmentHoldFulfillmentOrderLineItem { + id: number; + quantity: number; + } + + interface IFulfillmentHold { + reason: + | 'awaiting_payment' + | 'high_risk_of_fraud' + | 'incorrect_address' + | 'inventory_out_of_stock' + | 'other'; + reason_notes?: string; + notify_merchant?: boolean; + fulfillment_order_line_items?: IFulfillmentHoldFulfillmentOrderLineItem[]; + } } diff --git a/types/index.test-d.ts b/types/index.test-d.ts new file mode 100644 index 00000000..cb49e076 --- /dev/null +++ b/types/index.test-d.ts @@ -0,0 +1,59 @@ +import { expectType } from 'tsd'; +import Shopify from '.'; + +// Can be constructed with public access token. +new Shopify({ + shopName: 'my-shopify-store.myshopify.com', + accessToken: '111' +}); + +// Can be constructed with public access token and version. +const client = new Shopify({ + shopName: 'my-shopify-store.myshopify.com', + accessToken: '111', + apiVersion: '2020-01' +}); + +// Can be constructed with Got hooks. +new Shopify({ + shopName: 'my-shopify-store.myshopify.com', + accessToken: '111', + hooks: { + beforeRequest: [ + (options) => { + options.headers['X-Test'] = 'test'; + } + ] + } +}); + +// Accepts the `agent` option. +new Shopify({ + shopName: 'my-shopify-store.myshopify.com', + accessToken: '111', + agent: { + // https: new HttpsProxyAgent({ + // keepAlive: true, + // keepAliveMsecs: 1000, + // maxSockets: 256, + // maxFreeSockets: 256, + // scheduling: 'lifo', + // proxy: 'https://localhost:8080' + // }) + } +}); + +expectType(client.callLimits.remaining); +expectType(client.callLimits.current); +expectType(client.callLimits.max); + +const shop = await client.shop.get(); +expectType(shop.id); +expectType(shop.myshopify_domain); + +const product = await client.product.get(10); +expectType(product.id); +expectType(product.title); + +const result = await client.graphql(`query { shop { id myshopify_domain } }`); +expectType(result);