diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..5656d370 --- /dev/null +++ b/.github/CODE_OF_CONDUCT.md @@ -0,0 +1,73 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as +contributors and maintainers pledge to making participation in our project and +our community a harassment-free experience for everyone, regardless of age, body +size, disability, ethnicity, gender identity and expression, level of experience, +education, socio-economic status, nationality, personal appearance, race, +religion, or sexual identity and orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment +include: + +- Using welcoming and inclusive language +- Being respectful of differing viewpoints and experiences +- Gracefully accepting constructive criticism +- Focusing on what is best for the community +- Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +- The use of sexualized language or imagery and unwelcome sexual attention or + advances +- Trolling, insulting/derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others’ private information, such as a physical or electronic + address, without explicit permission +- Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable +behavior and are expected to take appropriate and fair corrective action in +response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or +reject comments, commits, code, wiki edits, issues, and other contributions +that are not aligned to this Code of Conduct, or to ban temporarily or +permanently any contributor for other behaviors that they deem inappropriate, +threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies both within project spaces and in public spaces +when an individual is representing the project or its community. Examples of +representing a project or community include using an official project e-mail +address, posting via an official social media account, or acting as an appointed +representative at an online or offline event. Representation of a project may be +further defined and clarified by project maintainers. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported by contacting the project team at opensource@shopify.com. All +complaints will be reviewed and investigated and will result in a response that +is deemed necessary and appropriate to the circumstances. The project team is +obligated to maintain confidentiality with regard to the reporter of an incident. +Further details of specific enforcement policies may be posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good +faith may face temporary or permanent repercussions as determined by other +members of the project’s leadership. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, +available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html + +[homepage]: https://www.contributor-covenant.org diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md new file mode 100644 index 00000000..752d42cc --- /dev/null +++ b/.github/ISSUE_TEMPLATE.md @@ -0,0 +1,37 @@ +# Issue summary + +Write a short description of the issue here ↓ + + + +## Expected behavior + +What do you think should happen? + + + +## Actual behavior + +What actually happens? + +Tip: include an error message (in a `
` tag) if your issue is related to an error + + + +## Steps to reproduce the problem + +1. +1. +1. + +## Reduced test case + +The best way to get your bug fixed is to provide a [reduced test case](https://developer.mozilla.org/en-US/docs/Mozilla/QA/Reducing_testcases). + + + +## Specifications + +- `shopify_python_api` version: +- `pyactiveresource` version: +- Shopify API version used (e.g. `'2020-07'`): diff --git a/.github/ISSUE_TEMPLATE/BUG_REPORT.md b/.github/ISSUE_TEMPLATE/BUG_REPORT.md new file mode 100644 index 00000000..5a32bc62 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/BUG_REPORT.md @@ -0,0 +1,39 @@ +--- +name: '🐛 Bug Report' +about: Something isn't working +labels: "Type: Bug 🐛" +--- + +# Issue summary + +Write a short description of the issue here ↓ + + +## Expected behavior + +What do you think should happen? + + +## Actual behavior + +What actually happens? + +Tip: include an error message (in a `
` tag) if your issue is related to an error + + +## Steps to reproduce the problem + +1. +2. +3. + +## Reduced test case + +The best way to get your bug fixed is to provide a [reduced test case](https://developer.mozilla.org/en-US/docs/Mozilla/QA/Reducing_testcases). + + +--- + +## Checklist + +- [ ] I have described this issue in a way that is actionable (if possible) diff --git a/.github/ISSUE_TEMPLATE/ENHANCEMENT.md b/.github/ISSUE_TEMPLATE/ENHANCEMENT.md new file mode 100644 index 00000000..8d333e75 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/ENHANCEMENT.md @@ -0,0 +1,26 @@ +--- +name: '📈 Enhancement' +about: Enhancement to our codebase that isn't a adding or changing a feature +labels: "Type: Enhancement 📈" +--- + +## Overview/summary + +... + +## Motivation + +> What inspired this enhancement? + +... + +### Area + +- [ ] Add any relevant `Area: ` labels to this issue + + +--- + +## Checklist + +- [ ] I have described this enhancement in a way that is actionable (if possible) diff --git a/.github/ISSUE_TEMPLATE/FEATURE_REQUEST.md b/.github/ISSUE_TEMPLATE/FEATURE_REQUEST.md new file mode 100644 index 00000000..ebe76b01 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/FEATURE_REQUEST.md @@ -0,0 +1,30 @@ +--- +name: '🙌 Feature Request' +about: Suggest a new feature, or changes to an existing one +labels: "Type: Feature Request :raised_hands:" +--- + +## Overview + +... + +## Type + +- [ ] New feature +- [ ] Changes to existing features + +## Motivation + +> What inspired this feature request? What problems were you facing? + +... + +### Area + +- [ ] Add any relevant `Area: ` labels to this issue + +--- + +## Checklist + +- [ ] I have described this feature request in a way that is actionable (if possible) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000..0bef098d --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,26 @@ + + +### WHY are these changes introduced? + +Fixes #0000 + + + +### WHAT is this pull request doing? + + + +### Checklist + +- [ ] I have updated the CHANGELOG (if applicable) +- [ ] I have followed the [Shopify Python](https://github.com/Shopify/shopify_python) guide diff --git a/.github/api_update_reminder.md b/.github/api_update_reminder.md new file mode 100644 index 00000000..1e7927ed --- /dev/null +++ b/.github/api_update_reminder.md @@ -0,0 +1,25 @@ +--- +title: A new release of the Shopify API is due soon +labels: automated +--- + +This is an automated reminder for the maintainers that a new Stable release of the Shopify API is due soon, so the library needs to be updated. + +A new library release should be prepared to: +* add the upcoming Stable release +* remove the oldest Stable release which will no longer be supported. + +The PR should be created as a **draft** but not yet be merged. + +The release schedule can be found at https://shopify.dev/concepts/about-apis/versioning + +Review the changelog and consider if anything in the library needs to change: + +https://shopify.dev/changelog + +Test against the upcoming release by using the Release Candidate. + +Another reminder issue will be created on the date of the next release. +When that happens, test again using the now Stable API version, and aim to release an update of the library within one week. + +Thank you! diff --git a/.github/api_update_reminder_on_release.md b/.github/api_update_reminder_on_release.md new file mode 100644 index 00000000..47ddaaf5 --- /dev/null +++ b/.github/api_update_reminder_on_release.md @@ -0,0 +1,19 @@ +--- +title: A new release of the Shopify API occurred +labels: automated +--- + +This is an automated reminder for the maintainers that a new Stable release of the Shopify API is scheduled for today +at 12pm Eastern Time, so a new release of the library is now due. + +A draft PR should already exist for this. + +Review the changelog again and consider if anything in the library needs to change: + +https://shopify.dev/changelog + +Test against the new release by using the Stable version just released. + +Aim to release an update of the library within one week. + +Thank you! diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..4af31a55 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,14 @@ +version: 2 +updates: + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: "daily" + time: "00:00" + timezone: "UTC" + reviewers: + - Shopify/core-build-learn + labels: + - "Pip upgrades" + open-pull-requests-limit: 100 + registries: '*' diff --git a/.github/workflows/api_update_reminder.yml b/.github/workflows/api_update_reminder.yml new file mode 100644 index 00000000..b4cfb456 --- /dev/null +++ b/.github/workflows/api_update_reminder.yml @@ -0,0 +1,16 @@ +on: + workflow_dispatch: ~ + schedule: + - cron: "0 0 1 3,6,9,12 *" # At 00:00 on 1st of March, June, September, and December + +name: API update reminder +jobs: + reminder: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: JasonEtco/create-an-issue@v2.4.0 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + filename: .github/api_update_reminder.md diff --git a/.github/workflows/api_update_reminder_on_release.yml b/.github/workflows/api_update_reminder_on_release.yml new file mode 100644 index 00000000..5e3053f9 --- /dev/null +++ b/.github/workflows/api_update_reminder_on_release.yml @@ -0,0 +1,16 @@ +on: + workflow_dispatch: ~ + schedule: + - cron: "0 0 1 1,4,7,10 *" # At 00:00 on 1st of January, April, July, and October + +name: API update reminder on release +jobs: + reminder: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: JasonEtco/create-an-issue@v2.4.0 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + filename: .github/api_update_reminder_on_release.md diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 00000000..c0cd5ab9 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,30 @@ +name: CI +on: [push, pull_request] +jobs: + build: + runs-on: ubuntu-latest + name: Python ${{ matrix.version }} + strategy: + matrix: + version: ["3.8", "3.9", "3.10", "3.11", "3.12"] + + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.version }} + - name: Install Dependencies + run: | + python -m pip install --upgrade pip + pip install pytest mock pytest-cov setuptools + python setup.py install + pytest --cov=./ --cov-report=xml + - name: Run Tests + run: python -m pytest -v + - name: upload coverage to Codecov + uses: codecov/codecov-action@v1 + with: + name: codecov-umbrella + fail_ci_if_error: false diff --git a/.github/workflows/cla.yml b/.github/workflows/cla.yml new file mode 100644 index 00000000..2c3a4042 --- /dev/null +++ b/.github/workflows/cla.yml @@ -0,0 +1,22 @@ +name: Contributor License Agreement (CLA) + +on: + pull_request_target: + types: [opened, synchronize] + issue_comment: + types: [created] + +jobs: + cla: + runs-on: ubuntu-latest + if: | + (github.event.issue.pull_request + && !github.event.issue.pull_request.merged_at + && contains(github.event.comment.body, 'signed') + ) + || (github.event.pull_request && !github.event.pull_request.merged) + steps: + - uses: Shopify/shopify-cla-action@v1 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + cla-token: ${{ secrets.CLA_TOKEN }} diff --git a/.github/workflows/close-waiting-for-response-issues.yml b/.github/workflows/close-waiting-for-response-issues.yml new file mode 100644 index 00000000..ffd7a382 --- /dev/null +++ b/.github/workflows/close-waiting-for-response-issues.yml @@ -0,0 +1,20 @@ +name: Close Waiting for Response Issues +on: + schedule: + - cron: "30 1 * * *" + workflow_dispatch: +jobs: + check-need-info: + runs-on: ubuntu-latest + steps: + - name: close-issues + uses: actions-cool/issues-helper@v3 + with: + actions: 'close-issues' + token: ${{ secrets.GITHUB_TOKEN }} + labels: 'Waiting for Response' + inactive-day: 7 + body: | + We are closing this issue because we did not hear back regarding additional details we needed to resolve this issue. If the issue persists and you are able to provide the missing clarification we need, feel free to respond and reopen this issue. + + We appreciate your understanding as we try to manage our number of open issues. diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml new file mode 100644 index 00000000..f92848d7 --- /dev/null +++ b/.github/workflows/pre-commit.yml @@ -0,0 +1,17 @@ +name: pre-commit + +on: + pull_request: + push: + branches: [main] + +jobs: + pre-commit: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Setup + uses: actions/setup-python@v4 + - name: Pre-commit + uses: pre-commit/action@v2.0.0 diff --git a/.github/workflows/remove-labels-on-activity.yml b/.github/workflows/remove-labels-on-activity.yml new file mode 100644 index 00000000..948801e7 --- /dev/null +++ b/.github/workflows/remove-labels-on-activity.yml @@ -0,0 +1,15 @@ +name: Remove Stale or Waiting Labels +on: + issue_comment: + types: [created] + workflow_dispatch: +jobs: + remove-labels-on-activity: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: actions-ecosystem/action-remove-labels@v1 + if: contains(github.event.issue.labels.*.name, 'Waiting for Response') + with: + labels: | + Waiting for Response diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml new file mode 100644 index 00000000..2d0a3ec7 --- /dev/null +++ b/.github/workflows/stale.yml @@ -0,0 +1,32 @@ +name: Close inactive issues +on: + schedule: + - cron: "30 1 * * *" + +jobs: + close-issues: + runs-on: ubuntu-latest + permissions: + issues: write + pull-requests: write + steps: + - uses: actions/stale@v5 + with: + days-before-issue-stale: 60 + days-before-issue-close: 14 + stale-issue-label: "Stale" + stale-issue-message: > + This issue is stale because it has been open for 60 days with no activity. It will be closed if no further action occurs in 14 days. + close-issue-message: | + We are closing this issue because it has been inactive for a few months. + This probably means that it is not reproducible or it has been fixed in a newer version. + If it’s an enhancement and hasn’t been taken on since it was submitted, then it seems other issues have taken priority. + + If you still encounter this issue with the latest stable version, please reopen using the issue template. You can also contribute directly by submitting a pull request– see the [CONTRIBUTING.md](https://github.com/Shopify/shopify_python_api/blob/main/CONTRIBUTING.md) file for guidelines + + Thank you! + days-before-pr-stale: -1 + days-before-pr-close: -1 + repo-token: ${{ secrets.GITHUB_TOKEN }} + exempt-issue-labels: "feature request" + close-issue-reason: "not_planned" diff --git a/.gitignore b/.gitignore index ab763e35..dc9d3852 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ tags .eggs .python-version .cache +.tox/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..f3500257 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,16 @@ +# See https://pre-commit.com for more information +# See https://pre-commit.com/hooks.html for more hooks +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: end-of-file-fixer + - id: trailing-whitespace + - repo: https://github.com/psf/black + rev: 24.10.0 + hooks: + - id: black + - repo: https://github.com/PyCQA/pylint + rev: v3.3.3 + hooks: + - id: pylint diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 98323d20..00000000 --- a/.travis.yml +++ /dev/null @@ -1,17 +0,0 @@ -language: python - -python: - - "2.7" - - "3.4" - - "3.5" - -# command to install dependencies -install: "python setup.py install" - -# command to run tests -script: 'python setup.py test' - -notifications: - flowdock: - secure: "YD74L+41DEjv2Bq5fG8vvIsuMG5iu8CHctes3CSfQw0j5SaPcASsKe6twXBCLsGB5IwzgYk7RUEnOsqA6kLqfWChJ3kO4lBCYBKSaGSG84VjwUlMOVG4n0jt7SLiHRjQ6sBhwFhWE64yAZPbdDuYtrUtVbb9Ui+dLUyBab1o7Cw=" - diff --git a/CHANGELOG b/CHANGELOG index 5ed1f32a..e9910c2e 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,185 +1,409 @@ +== Unreleased + +- Remove requirement to provide scopes to Permission URL, as it should be omitted if defined with the TOML file. + +== Version 12.7.0 + +- Remove requirement to use a predefined API version. Now you can use any valid API version string. ([#737](https://github.com/Shopify/shopify_python_api/pull/737)) + +== Version 12.6.0 + +- Update API version with 2024-07 release ([#723](https://github.com/Shopify/shopify_python_api/pull/723)) + +== Version 12.5.0 + +- Remove `cgi` import to avoid triggering a `DeprecationWarning` on Python 3.11. +- Update API version with 2024-04 release.([710](https://github.com/Shopify/shopify_python_api/pull/710)) + +== Version 12.4.0 + +- Update API version with 2023-07, 2023-10, 2024-01 releases ([#694](https://github.com/Shopify/shopify_python_api/pull/694)) + +== Version 12.3.0 + +- Update API version with 2023-04 release ([#649](https://github.com/Shopify/shopify_python_api/pull/649)) + +== Version 12.2.0 + +- Update API version with 2023-01 release ([#631](https://github.com/Shopify/shopify_python_api/pull/631)) + +== Version 12.1.0 + +- Add API version with 2022-10 release + +== Version 12.0.1 + +- Allow up to 10 seconds clock skew to avoid `ImmatureSignatureError` + ([#609](https://github.com/Shopify/shopify_python_api/pull/609)) + +== Version 12.0.0 + +- Update API version with 2022-04 release, remove API version 2021-07 ([#591](https://github.com/Shopify/shopify_python_api/pull/591)) + +== Version 11.0.0 + +- Update API version with 2022-04 release +- remove API version 2020-10, 2021-01, 2021-04 as they are all unsupported as of 2022-04 + +== Version 10.0.0 + +- Update API version with 2022-01 release, remove API version 2020-07 + +== Version 9.0.0 + +- Drop Python 2 support ([#549](https://github.com/Shopify/shopify_python_api/pull/549)) +- Update API version with 2021-10 release, remove API version 2020-04 ([#548](https://github.com/Shopify/shopify_python_api/pull/548)) + +== Version 8.4.2 + +- Update API version with 2021-07 release, remove API version 2020-01 ([#521](https://github.com/Shopify/shopify_python_api/pull/521)) + +== Version 8.4.1 + +- Bug fix: `sanitize_shop_domain` now returns `None` rather than `'none.myshopify.com'` if no `shop_domain` arg is passed in ([#499](https://github.com/Shopify/shopify_python_api/pull/499)) + +== Version 8.4.0 + +- Revert Feature #441 Dynamic API Versioning ([#495](https://github.com/Shopify/shopify_python_api/pull/495)) + +== Version 8.3.1 + +- Fix bug: Add the `shopify/utils` sub-package when building the source distribution ([#493](https://github.com/Shopify/shopify_python_api/pull/493)) + +== Version 8.3.0 + +- Add support for [session tokens](https://shopify.dev/concepts/apps/building-embedded-apps-using-session-tokens) ([#479](https://github.com/Shopify/shopify_python_api/pull/479)) + - Use `session_token.decode_from_header` to obtain a decoded session token from an HTTP Authorization header +- Create a `utils` sub-package with a `shop_url` utility file ([#483](https://github.com/Shopify/shopify_python_api/pull/483)) + - Use `shop_url.sanitize_shop_domain()` to sanitize shop names given as input +- Introduce the `ApiAccess` class to handle access scopes operations for apps + - `Session` class now store access_scopes attributes as `ApiAccess` objects +- Added support for Fulfillment.update_tracking ([#432](https://github.com/Shopify/shopify_python_api/pull/432)) +- Add FulfillmentEvent resource ([#454](https://github.com/Shopify/shopify_python_api/pull/454)) +- Fix for being unable to get the len() of a filter ([#456](https://github.com/Shopify/shopify_python_api/pull/456)) +- Add ApplicationCredit resource ([#457](https://github.com/Shopify/shopify_python_api/pull/457)) +- Add support for retrieving all orders using customer_id ([#466](https://github.com/Shopify/shopify_python_api/pull/466)) + +== Version 8.2.0 + +- [Feature] Add support for Dynamic API Versioning. When the library is initialized, it will now make a request to + Shopify to fetch a list of the available API versions. ([#441](https://github.com/Shopify/shopify_python_api/pull/441)) + +== Version 8.1.0 + +- [Feature] Add support for Shopify Payments resources (#428) + +== Version 8.0.4 + +- Release API version 2020-10 +- Deprecate API version 2019-10 + +== Version 8.0.3 + +- Patch for replacing call to \_build_list() with \_build_collection() in gift_card.py + +== Version 8.0.2 + +- Patch for product updating with variants + +== Version 8.0.1 + +- release api version 2020-07 +- deprecate api version 2019-07 +- Add support for FulfillmentOrder resource (#390) + +== Version 8.0.0 + +- release api version 2020-04 +- deprecate api version 2019-04 + +== Version 7.0.3 + +- bug fix for temporary sessions +- deprecation fix for regexs + +== Version 7.0.2 + +- bug fix for variant updates after the 2019-10 api version + +== Version 7.0.1 + +- bug fix for string interpolation + +== Version 7.0.0 + +- Made no_iter_next default to True on collection so that by default it only + fetches a single page +- Passes kwargs to paginated collections so that attributes can be set with + find() +- Allow case insensitive check for the link header for cursor pagination. + +== Version 6.0.1 + +- Made the collection access more consistent so that there is no confusion + between a collection and a paginated collection + +== Version 6.0.0 + +- Add Cursor pagination support + +== Version 5.1.2 + +- Add version 2020-01 to known ApiVersions. This version will not be usable until October 2019. + +== Version 5.1.1 + +- Fix initializing API with basic auth URL. + +== Version 5.1.0 + +- Added support for GraphQL queries with a GraphQL resource + +== Version 5.0.1 + +- Fixing missing class variable causing exception when creating a session without a token + +== Version 5.0.0 + +- Added support for Shopify API Versioning + +== Version 4.0.0 + +- Added AccessScope resource +- Added ApiPermission resource +- Added User resource +- Added Publication, CollectionPublication and ProductPublication resources +- Added Currency resource +- Added TenderTransaction resource +- Added shopify.Limits class, for retrieving the current status of Shopify rate limiting. +- Added support for Refund.calculate +- Added support for Location.inventory_levels +- Added support for PriceRule batch operations +- Removed `cancel()` method for RecurringApplicationCharge resource (use `destroy()` going forward) +- Fix for handling array query parameters (e.g. `foo[]=1&foo[]=2`) during HMAC calculation +- Fixed Python 3 compatibility with the API console + +== Version 3.1.0 + +- Adds InventoryItem resource +- Adds InventoryLevel resource +- Adds GiftCardAdjustment resource +- Fix to properly handle byte data for Asset.attach() + +== Version 3.0.0 + +- Added CollectListing resource +- Added ResourceFeedback resource +- Added StorefrontAccessToken resource +- Added ProductListing resource +- Removed deprecated ProductSearchEngine resource +- Removed deprecated Discount resource +- Fixed Python3 compatibility issue with `Image.attach_image()` + +== Version 2.6.0 + +- Added support for Marketing Event API through Marketing Event resource + +== Version 2.5.1 + +- Fixed an issue preventing creation of Order Risk resources + +== Version 2.5.0 + +- Added Price Rule and Discount Code resources + +== Version 2.4.0 + +- Add support for report publishing + +== Version 2.3.0 + +- Add support for customer#send_invite + == Version 2.2.0 -* Add support for draft orders + +- Add support for draft orders == Version 2.1.8 -* Added support for `open` method on fulfillments + +- Added support for `open` method on fulfillments == Version 2.1.7 -* Removed all references to the deprecated MD5 `signature` parameter which is no longer provided by Shopify. +- Removed all references to the deprecated MD5 `signature` parameter which is no longer provided by Shopify. == Version 2.1.6 -* Added Refund resource +- Added Refund resource == Version 2.1.5 -* bump pyactiveresource for camelcase bugfix +- bump pyactiveresource for camelcase bugfix == Version 2.1.4 == Version 2.1.3 -* Fixed hmac signature validation for params with delimiters (`&`, `=` or `%`) +- Fixed hmac signature validation for params with delimiters (`&`, `=` or `%`) == Version 2.1.2 -* Fixed an issue with unicode strings in params passed to validate_hmac -* Added shop domain verification when creating a session +- Fixed an issue with unicode strings in params passed to validate_hmac +- Added shop domain verification when creating a session == Version 2.1.1 -* Added Checkout resource -* Updated to pyactiveresource v2.1.1 which includes a test-related bugfix -* Changed OAuth validation from MD5 to HMAC-SHA256 +- Added Checkout resource +- Updated to pyactiveresource v2.1.1 which includes a test-related bugfix +- Changed OAuth validation from MD5 to HMAC-SHA256 == Version 2.1.0 -* Added python 3 compatibility -* Fixed setting the format attribute on carrier and fulfillment services -* Add a specific exception for signature validation failures +- Added python 3 compatibility +- Fixed setting the format attribute on carrier and fulfillment services +- Add a specific exception for signature validation failures == Version 2.0.4 -* Bug fixes -* Added CarrierService resource -* Added Property resource to LineItem +- Bug fixes +- Added CarrierService resource +- Added Property resource to LineItem == Version 2.0.3 -* Add Order Risk resource +- Add Order Risk resource == Version 2.0.2 -* Add access to FulfillmentService endpoint -* Fix some import bugs +- Add access to FulfillmentService endpoint +- Fix some import bugs == Version 2.0.1 -* Package bug fix +- Package bug fix == Version 2.0.0 -* Removed support for legacy auth -* Updated to pyactiveresource v2.0.0 which changes the default form to JSON -* in Session::request_token params is no longer optional, you must pass all the params +- Removed support for legacy auth +- Updated to pyactiveresource v2.0.0 which changes the default form to JSON +- in Session::request_token params is no longer optional, you must pass all the params and the method will now extract the code -* made create_permission_url an instance method, you'll need an instance +- made create_permission_url an instance method, you'll need an instance of session to call this method from now on -* Updated session.request_token -* Updated Session to better match the ShopifyAPI Ruby gem -* Updated the readme to better describe how to use the library -* Added support for CustomerSavedSearch (CustomerGroup is deprecated) +- Updated session.request_token +- Updated Session to better match the ShopifyAPI Ruby gem +- Updated the readme to better describe how to use the library +- Added support for CustomerSavedSearch (CustomerGroup is deprecated) == Version 1.0.7 -* Fix thread local headers to store a copy of the default hash which -prevents activate_session in one thread from affecting other threads. +- Fix thread local headers to store a copy of the default hash which + prevents activate_session in one thread from affecting other threads. == Version 1.0.6 -* Fix deserializing and serializing fulfillments which can now contain -arrays of strings in the tracking_urls attribute. +- Fix deserializing and serializing fulfillments which can now contain + arrays of strings in the tracking_urls attribute. == Version 1.0.5 -* Fix parameter passing for order cancellation. -* Fix Product.price_range method for variants with different prices. +- Fix parameter passing for order cancellation. +- Fix Product.price_range method for variants with different prices. == Version 1.0.4 -* Fixed another bug in Image size methods regex. +- Fixed another bug in Image size methods regex. == Version 1.0.3 -* Fix bug in setting format attribute on Webhook instances. -* Fixed missing slash in return value of Image size methods -* Upgrade pyactiveresource to fix unicode encoding issues +- Fix bug in setting format attribute on Webhook instances. +- Fixed missing slash in return value of Image size methods +- Upgrade pyactiveresource to fix unicode encoding issues == Version 1.0.2 -* Made ShopifyResource.clear_session idempotent. +- Made ShopifyResource.clear_session idempotent. == Version 1.0.1 -* Use the correct redirect parameter in Session.create_permission_url. -Was redirect_url but corrected to redirect_uri. +- Use the correct redirect parameter in Session.create_permission_url. + Was redirect_url but corrected to redirect_uri. == Version 1.0.0 -* Added support for OAuth2. -* ShopifyResource.activate_session must now be used with OAuth2 instead -of setting ShopifyResource.site directly. -* Session.__init__ no longer allows params to be passed in as **params -* Session.__init__ now makes an HTTP request when using OAuth2 if -params are specified -* Session now exposes the access token through the token instance -variable to simplify session saving and resuming +- Added support for OAuth2. +- ShopifyResource.activate_session must now be used with OAuth2 instead + of setting ShopifyResource.site directly. +- Session.**init** no longer allows params to be passed in as \*\*params +- Session.**init** now makes an HTTP request when using OAuth2 if + params are specified +- Session now exposes the access token through the token instance + variable to simplify session saving and resuming == Version 0.4.0 -* Using setup.py no longer requires all dependancies -* More compatiblity fixes for using the latest pyactiveresource -* ShopifyResource.activate_session is not recommended over setting site -directly for forward compatibility with coming OAuth2 changes. +- Using setup.py no longer requires all dependencies +- More compatibility fixes for using the latest pyactiveresource +- ShopifyResource.activate_session is not recommended over setting site + directly for forward compatibility with coming OAuth2 changes. == Version 0.3.1 -* Compatiblity fixes for using latest (unreleased) pyactiveresource +- Compatibility fixes for using latest (unreleased) pyactiveresource == Version 0.3.0 -* Added support for customer search and customer group search. -* Resource erros are cleared on save from previous save attempt. -* Made the library thread-safe using thread-local connections. +- Added support for customer search and customer group search. +- Resource errors are cleared on save from previous save attempt. +- Made the library thread-safe using thread-local connections. == Version 0.2.1 -* Fixed a regression that caused a different connection -object to be created on each resource. +- Fixed a regression that caused a different connection + object to be created on each resource. == Version 0.2.0 -* Made responses available through the connection object. +- Made responses available through the connection object. == Version 0.1.8 -* Added ability to add metafields on customers. +- Added ability to add metafields on customers. == Version 0.1.7 -* Fixed missing theme_id in return value of Asset.find. +- Fixed missing theme_id in return value of Asset.find. == Version 0.1.6 -* Fixed attribute setting on Asset objects -* Strip path from shop_url to get just the shop's domain. +- Fixed attribute setting on Asset objects +- Strip path from shop_url to get just the shop's domain. == Version 0.1.5 -* Fixed Asset.find() -* Fixed Variant.find(id) -* Allow running from source directory with PYTHONPATH=./lib +- Fixed Asset.find() +- Fixed Variant.find(id) +- Allow running from source directory with PYTHONPATH=./lib == Version 0.1.4 -* Fixed a bug in metafields method caused by missing import. -* Prefix options can be specified in the attributes dict on creation -* Allow count method to be used the same way as find +- Fixed a bug in metafields method caused by missing import. +- Prefix options can be specified in the attributes dict on creation +- Allow count method to be used the same way as find == Version 0.1.3 -* Fixed the automatic download of dependancies. -* Updated the README instructions. +- Fixed the automatic download of dependencies. +- Updated the README instructions. == Version 0.1.2 -* Add python 2.5 compatibility +- Add python 2.5 compatibility == Version 0.1.1 -* Make creating a session simpler with django +- Make creating a session simpler with django == Version 0.1.0 -* ported ShopifyAPI from ruby to python +- ported ShopifyAPI from ruby to python diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f28367bd..a08624f8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -3,6 +3,8 @@ Submitting Issues Please open an issue here if you encounter a specific bug with this API client library or if something is documented here https://docs.shopify.com/api but is missing from this package. -General questions about the Shopify API and usage of this package (not neccessarily a bug) should be posted on the [Shopify forums](https://ecommerce.shopify.com/c/shopify-apis-and-technology). +General questions about the Shopify API and usage of this package (not necessarily a bug) should be posted on the [Shopify forums](https://ecommerce.shopify.com/c/shopify-apis-and-technology). + +For compatibility across Python 2 and Python 3, look into [Six](https://six.readthedocs.io/). When in doubt, post on the forum first. You'll likely have your questions answered more quickly if you post there; more people monitor the forum than Github. diff --git a/README.md b/README.md index 559aa039..cadda24e 100644 --- a/README.md +++ b/README.md @@ -1,275 +1,296 @@ -[![Build Status](https://travis-ci.org/Shopify/shopify_python_api.svg?branch=master)](https://travis-ci.org/Shopify/shopify_python_api) -[![PyPI version](https://badge.fury.io/py/shopifyapi.svg)](https://badge.fury.io/py/shopifyapi) - # Shopify API -The ShopifyAPI library allows Python developers to programmatically -access the admin section of stores. +[![Build Status](https://github.com/Shopify/shopify_python_api/workflows/CI/badge.svg)](https://github.com/Shopify/shopify_python_api/actions) +[![PyPI version](https://badge.fury.io/py/ShopifyAPI.svg)](https://badge.fury.io/py/ShopifyAPI) +![Supported Python Versions](https://img.shields.io/badge/python-3.7%20|%203.8%20|%203.9%20|%203.10%20|%203.11%20|%203.12-brightgreen) +[![codecov](https://codecov.io/gh/Shopify/shopify_python_api/branch/main/graph/badge.svg?token=pNTx0TARUx)](https://codecov.io/gh/Shopify/shopify_python_api) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/Shopify/shopify_python_api/blob/main/LICENSE) +[![pre-commit](https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit&logoColor=white)](https://github.com/pre-commit/pre-commit) -The API is accessed using pyactiveresource in order to provide an -interface similar to the -[ruby Shopify API](https://github.com/shopify/shopify_api) gem. -The data itself is sent as XML over HTTP to communicate with Shopify, -which provides a web service that follows the REST principles as -much as possible. +The [Shopify Admin API](https://shopify.dev/docs/admin-api) Python Library ## Usage ### Requirements - -All API usage happens through Shopify applications, created by -either shop owners for their own shops, or by Shopify Partners for -use by other shop owners: - -* Shop owners can create applications for themselves through their -own admin: -* Shopify Partners create applications through their admin: - - -For more information and detailed documentation about the API visit - +You should be signed up as a partner on the [Shopify Partners Dashboard](https://www.shopify.com/partners) so that you can create and manage shopify applications. ### Installation -To easily install or upgrade to the latest release, use -[pip](http://www.pip-installer.org/) +To easily install or upgrade to the latest release, use [pip](http://www.pip-installer.org/). ```shell pip install --upgrade ShopifyAPI ``` -or [easy_install](http://packages.python.org/distribute/easy_install.html) +### Table of Contents + +- [Usage](#usage) + - [Requirements](#requirements) + - [Installation](#installation) + - [Table of Contents](#table-of-contents) + - [Getting Started](#getting-started) + - [Public and Custom Apps](#public-and-custom-apps) + - [Private Apps](#private-apps) + - [With full session](#with-full-session) + - [With temporary session](#with-temporary-session) + - [Billing](#billing) + - [Advanced Usage](#advanced-usage) + - [Prefix options](#prefix-options) + - [Console](#console) + - [GraphQL](#graphql) +- [Using Development Version](#using-development-version) + - [Building and installing dev version](#building-and-installing-dev-version) + - [Running Tests](#running-tests) +- [Relative Cursor Pagination](#relative-cursor-pagination) +- [Set up pre-commit locally \[OPTIONAL\]](#set-up-pre-commit-locally-optional) +- [Limitations](#limitations) +- [Additional Resources](#additional-resources) + - [Sample apps built using this library](#sample-apps-built-using-this-library) -```shell -easy_install -U ShopifyAPI -``` ### Getting Started +#### Public and Custom Apps -ShopifyAPI uses pyactiveresource to communicate with the REST web -service. pyactiveresource has to be configured with a fully authorized -URL of a particular store first. To obtain that URL you can follow -these steps: - -1. First create a new application in either the partners admin or - your store admin. For a private App you'll need the API_KEY and - the PASSWORD otherwise you'll need the API_KEY and SHARED_SECRET. - -2. For a private App you just need to set the base site url as - follows: - - ```python - shop_url = "https://%s:%s@SHOP_NAME.myshopify.com/admin" % (API_KEY, PASSWORD) - shopify.ShopifyResource.set_site(shop_url) - ``` - - That's it you're done, skip to step 6 and start using the API! - For a partner App you will need to supply two parameters to the - Session class before you instantiate it: - - ```python - shopify.Session.setup(api_key=API_KEY, secret=SHARED_SECRET) - ``` - -3. In order to access a shop's data, apps need an access token from that - specific shop. This is a two-stage process. Before interacting with - a shop for the first time an app should redirect the user to the - following URL: - - `GET https://SHOP_NAME.myshopify.com/admin/oauth/authorize` - - with the following parameters: +1. First create a new application in the [Partners Dashboard](https://www.shopify.com/partners), and retrieve your API Key and API Secret Key. +1. We then need to supply these keys to the Shopify Session Class so that it knows how to authenticate. - * ``client_id``– Required – The API key for your app - * ``scope`` – Required – The list of required scopes (explained here: http://docs.shopify.com/api/tutorials/oauth) - * ``redirect_uri`` – Required – The URL where you want to redirect the users after they authorize the client. The complete URL specified here must be identical to one of the Application Redirect URLs set in the App's section of the Partners dashboard. Note: in older applications, this parameter was optional, and redirected to the Application Callback URL when no other value was specified. - * ``state`` – Optional – A randomly selected value provided by your application, which is unique for each authorization request. During the OAuth callback phase, your application must check that this value matches the one you provided during authorization. [This mechanism is important for the security of your application](https://tools.ietf.org/html/rfc6819#section-3.6). + ```python + import shopify - We've added the create_permision_url method to make this easier, first - instantiate your session object: + shopify.Session.setup(api_key=API_KEY, secret=API_SECRET) + ``` +1. In order to access a shop's data, apps need an access token from that specific shop. We need to authenticate with that shop using OAuth, which we can start in the following way: ```python - session = shopify.Session("SHOP_NAME.myshopify.com") + shop_url = "SHOP_NAME.myshopify.com" + api_version = '2024-07' + state = binascii.b2a_hex(os.urandom(15)).decode("utf-8") + redirect_uri = "http://myapp.com/auth/shopify/callback" + # `scope` should be omitted if provided by app's TOML + scopes = ['read_products', 'read_orders'] + + newSession = shopify.Session(shop_url, api_version) + # `scope` should be omitted if provided by app's TOML + auth_url = newSession.create_permission_url(redirect_uri, scopes, state) + # redirect to auth_url ``` - Then call: +1. Once the merchant accepts, the shop redirects the owner to the `redirect_uri` of your application with a parameter named 'code'. This is a temporary token that the app can exchange for a permanent access token. You should compare the state you provided above with the one you received back to ensure the request is correct. Now we can exchange the code for an access_token when you get the request from shopify in your callback handler: ```python - scope=["write_products"] - permission_url = session.create_permission_url(scope) + session = shopify.Session(shop_url, api_version) + access_token = session.request_token(request_params) # request_token will validate hmac and timing attacks + # you should save the access token now for future use. ``` - or if you want a custom redirect_uri: +1. Now you're ready to make authorized API requests to your shop!: ```python - permission_url = session.create_permission_url(scope, "https://my_redirect_uri.com") + session = shopify.Session(shop_url, api_version, access_token) + shopify.ShopifyResource.activate_session(session) + + # Note: REST API examples will be deprecated in 2025 + shop = shopify.Shop.current() # Get the current shop + product = shopify.Product.find(179761209) # Get a specific product + + # GraphQL API example + shopify.GraphQL().execute("{ shop { name id } }") ``` -4. Once authorized, the shop redirects the owner to the return URL of your application with a parameter named 'code'. This is a temporary token that the app can exchange for a permanent access token. + Alternatively, you can use temp to initialize a Session and execute a command: - Before you proceed, make sure your application performs the following security checks. If any of the checks fails, your application must reject the request with an error, and must not proceed further. + ```python + with shopify.Session.temp(shop_url, api_version, token): + product = shopify.Product.find() + ``` - * Ensure the provided ``state`` is the same one that your application provided to Shopify during Step 3. - * Ensure the provided hmac is valid. The hmac is signed by Shopify as explained below, in the Verification section. - * Ensure the provided hostname parameter is a valid hostname, ends with myshopify.com, and does not contain characters other than letters (a-z), numbers (0-9), dots, and hyphens. +1. It is best practice to clear your session when you're done. A temporary session does this automatically: - If all security checks pass, the authorization code can be exchanged once for a permanent access token. The exchange is made with a request to the shop. + ```python + shopify.ShopifyResource.clear_session() + ``` - ``` - POST https://SHOP_NAME.myshopify.com/admin/oauth/access_token - ``` +#### Private Apps +Private apps are a bit quicker to use because OAuth is not needed. You can create the private app in the Shopify Merchant Admin. You can use the Private App password as your `access_token`: - with the following parameters: +##### With full session +```python +session = shopify.Session(shop_url, api_version, private_app_password) +shopify.ShopifyResource.activate_session(session) +# ... +shopify.ShopifyResource.clear_session() +``` - ``` - * client_id – Required – The API key for your app - * client_secret – Required – The shared secret for your app - * code – Required – The code you received in step 3 - ``` +##### With temporary session - and you'll get your permanent access token back in the response. +```python +with shopify.Session.temp(shop_url, api_version, private_app_password): + shopify.GraphQL().execute("{ shop { name id } }") +``` - There is a method to make the request and get the token for you. Pass - all the params received from the previous call (shop, code, timestamp, - signature) as a dictionary and the method will verify - the params, extract the temp code and then request your token: +### Billing +_Note: Your application must be public to test the billing process. To test on a development store use the `'test': True` flag_ +1. Create charge after session has been activated ```python - token = session.request_token(params) + application_charge = shopify.ApplicationCharge.create({ + 'name': 'My public app', + 'price': 123, + 'test': True, + 'return_url': 'https://domain.com/approve' + }) + # Redirect user to application_charge.confirmation_url so they can approve the charge ``` - - This method will save the token to the session object - and return it. For future sessions simply pass the token when - creating the session object. - +1. After approving the charge, the user is redirected to `return_url` with `charge_id` parameter (_Note: This action is no longer necessary if the charge is created with [API version 2021-01 or later](https://shopify.dev/changelog/auto-activation-of-charges-and-subscriptions)_) ```python - session = shopify.Session("SHOP_NAME.myshopify.com", token) + charge = shopify.ApplicationCharge.find(charge_id) + shopify.ApplicationCharge.activate(charge) ``` - -5. The session must be activated before use: - +1. Check that `activated_charge` status is `active` ```python - shopify.ShopifyResource.activate_session(session) + activated_charge = shopify.ApplicationCharge.find(charge_id) + has_been_billed = activated_charge.status == 'active' ``` -6. Now you're ready to make authorized API requests to your shop! - Data is returned as [ActiveResource](https://github.com/Shopify/pyactiveresource) instances: +### Advanced Usage + +> **⚠️ Note**: As of October 1, 2024, the REST Admin API is legacy: +> - Public apps must migrate to GraphQL by February 2025 +> - Custom apps must migrate to GraphQL by April 2025 +> +> For migration guidance, see [Shopify's migration guide](https://shopify.dev/docs/apps/build/graphql/migrate/new-product-model) + +It is recommended to have at least a basic grasp on the principles of the [pyactiveresource](https://github.com/Shopify/pyactiveresource) library, which is a port of rails/ActiveResource to Python and upon which this package relies heavily. + +Instances of `pyactiveresource` resources map to RESTful resources in the Shopify API. + +`pyactiveresource` exposes life cycle methods for creating, finding, updating, and deleting resources which are equivalent to the `POST`, `GET`, `PUT`, and `DELETE` HTTP verbs. + +```python +# Note: REST API examples will be deprecated in 2025 +product = shopify.Product() +product.title = "Shopify Logo T-Shirt" +product.id # => 292082188312 +product.save() # => True +shopify.Product.exists(product.id) # => True +product = shopify.Product.find(292082188312) +# Resource holding our newly created Product object +# Inspect attributes with product.attributes +product.price = 19.99 +product.save() # => True +product.destroy() +# Delete the resource from the remote server (i.e. Shopify) +``` - ```python - # Get the current shop - shop = shopify.Shop.current() - - # Get a specific product - product = shopify.Product.find(179761209) - - # Create a new product - new_product = shopify.Product() - new_product.title = "Burton Custom Freestyle 151" - new_product.product_type = "Snowboard" - new_product.vendor = "Burton" - success = new_product.save() #returns false if the record is invalid - # or - if new_product.errors: - #something went wrong, see new_product.errors.full_messages() for example - - # Update a product - product.handle = "burton-snowboard" - product.save() - - # Remove a product - product.destroy() - ``` +Here is another example to retrieve a list of open orders using certain parameters: - Alternatively, you can use temp to initialize a Session and execute a command which also handles temporarily setting ActiveResource::Base.site: +```python +new_orders = shopify.Order.find(status="open", limit="50") +``` - ```python - with shopify.Session.temp("SHOP_NAME.myshopify.com", token): - product = shopify.Product.find() - ``` +### Prefix options -7. If you want to work with another shop, you'll first need to clear the session:: +Some resources such as `Fulfillment` are prefixed by a parent resource in the Shopify API (e.g. `orders/450789469/fulfillments/255858046`). In order to interact with these resources, you must specify the identifier of the parent resource in your request. - ```python - shopify.ShopifyResource.clear_session() - ``` +```python +# Note: This REST API example will be deprecated in the future +shopify.Fulfillment.find(255858046, order_id=450789469) +``` ### Console +This package also includes the `shopify_api.py` script to make it easy to open an interactive console to use the API with a shop. +1. Obtain a private API key and password to use with your shop (step 2 in "Getting Started") +1. Save your default credentials: `shopify_api.py add yourshopname` +1. Start the console for the connection: `shopify_api.py console` +1. To see the full list of commands, type: `shopify_api.py help` -This package also includes the `shopify_api.py` script to make it easy to -open up an interactive console to use the API with a shop. +### GraphQL -1. Obtain a private API key and password to use with your shop - (step 2 in "Getting Started") +This library also supports Shopify's new [GraphQL API](https://help.shopify.com/en/api/graphql-admin-api). The authentication process is identical. Once your session is activated, simply construct a new graphql client and use `execute` to execute the query. -2. Use the `shopify_api.py` script to save the credentials for the - shop to quickly log in. The script uses [PyYAML](http://pyyaml.org/) to save - and load connection configurations in the same format as the ruby - shopify\_api. +> **Note**: Shopify recommends using GraphQL API for new development as REST API will be deprecated. +> See [Migration Guide](https://shopify.dev/docs/apps/build/graphql/migrate/new-product-model) for more details. - ```shell - shopify_api.py add yourshopname - ``` +```python +result = shopify.GraphQL().execute('{ shop { name id } }') +``` - Follow the prompts for the shop domain, API key and password. +You can perform more complex operations using the `variables` and `operation_name` parameters of `execute`. -3. Start the console for the connection. +For example, this GraphQL document uses a fragment to construct two named queries - one for a single order, and one for multiple orders: - ```shell - shopify_api.py console - ``` +```graphql + # ./order_queries.graphql -4. To see the full list of commands, type: + fragment OrderInfo on Order { + id + name + createdAt + } - ```shell - shopify_api.py help - ``` + query GetOneOrder($order_id: ID!){ + node(id: $order_id){ + ...OrderInfo + } + } -## Using Development Version + query GetManyOrders($order_ids: [ID]!){ + nodes(ids: $order_ids){ + ...OrderInfo + } + } +``` -The development version can be built using +Now you can choose which operation to execute: -```shell -python setup.py sdist +```python +# Load the document with both queries +document = Path("./order_queries.graphql").read_text() + +# Specify the named operation to execute, and the parameters for the query +result = shopify.GraphQL().execute( + query=document, + variables={"order_id": "gid://shopify/Order/12345"}, + operation_name="GetOneOrder", +) ``` -then the package can be installed using pip +## Using Development Version +#### Building and installing dev version ```shell +python setup.py sdist pip install --upgrade dist/ShopifyAPI-*.tar.gz ``` -or easy_install +**Note** Use the `bin/shopify_api.py` script when running from the source tree. It will add the lib directory to start of sys.path, so the installed version won't be used. +#### Running Tests ```shell -easy_install -U dist/ShopifyAPI-*.tar.gz +pip install setuptools --upgrade +python setup.py test ``` -Note Use the `bin/shopify_api.py` script when running from the source tree. -It will add the lib directory to start of sys.path, so the installed -version won't be used. +## Relative Cursor Pagination +Cursor based pagination support has been added in 6.0.0. -To run tests, simply open up the project directory in a terminal and run: +```python +import shopify -```shell -python setup.py test -``` - -Alternatively, use [tox](http://tox.readthedocs.org/en/latest/) to -sequentially test against different versions of Python in isolated -environments: +page1 = shopify.Product.find() +if page1.has_next_page(): + page2 = page1.next_page() -```shell -pip install tox -tox +# to persist across requests you can use next_page_url and previous_page_url +next_url = page1.next_page_url +page2 = shopify.Product.find(from_=next_url) ``` -See the tox documentation for help on running only specific environments -at a time. The related tool [detox](https://pypi.python.org/pypi/detox) -can be used to run tests in these environments in parallel: - +## Set up pre-commit locally [OPTIONAL] +[Pre-commit](https://pre-commit.com/) is set up as a GitHub action that runs on pull requests and pushes to the `main` branch. If you want to run pre-commit locally, install it and set up the git hook scripts ```shell -pip install detox -detox +pip install -r requirements.txt +pre-commit install ``` ## Limitations @@ -280,10 +301,10 @@ Currently there is no support for: * persistent connections ## Additional Resources +* [Partners Dashboard](https://partners.shopify.com) +* [developers.shopify.com](https://developers.shopify.com) +* [Shopify.dev](https://shopify.dev) +* [Ask questions on the Shopify forums](http://ecommerce.shopify.com/c/shopify-apis-and-technology) -* [Shopify API](http://api.shopify.com) <= Read the tech docs! -* [Ask questions on the Shopify forums](http://ecommerce.shopify.com/c/shopify-apis-and-technology) <= Ask questions on the forums! - -## Copyright - -Copyright (c) 2012 "Shopify inc.". See LICENSE for details. +### Sample apps built using this library +* [Sample Django app](https://github.com/shopify/sample-django-app) diff --git a/RELEASING b/RELEASING index 7be62329..a15667a0 100644 --- a/RELEASING +++ b/RELEASING @@ -1,13 +1,20 @@ Releasing shopify_python_api -1. Check the Semantic Versioning page for info on how to version the new release: http://semver.org -2. Update version in shopify/version.py -3. Update CHANGELOG entry for the release. -4. Commit the changes +1. Verify that the examples in the README are still valid against the latest stable API release. + +2. Check the Semantic Versioning page for info on how to version the new release: http://semver.org + +3. Update version in shopify/version.py + +4. Update CHANGELOG entry for the release. + +5. Commit the changes git commit -m "Release vX.Y.Z" -5. Tag the release with the version + +6. Tag the release with the version git tag -m "Release X.Y.Z" vX.Y.Z -6. Push the changes to github - git push --tags origin master -7. Upload the source package to pypi - python setup.py sdist upload + +7. Push the changes to github + git push --tags origin main + +8. Shipit! diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..2a0e9c48 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,59 @@ +# Security Policy + +## Supported versions + +### New features + +New features will only be added to the main branch and will not be made available in point releases. + +### Bug fixes + +Only the latest release series will receive bug fixes. When enough bugs are fixed and its deemed worthy to release a new gem, this is the branch it happens from. + +### Security issues + +Only the latest release series will receive patches and new versions in case of a security issue. + +### Severe security issues + +For severe security issues we will provide new versions as above, and also the last major release series will receive patches and new versions. The classification of the security issue is judged by the core team. + +### Unsupported Release Series + +When a release series is no longer supported, it's your own responsibility to deal with bugs and security issues. If you are not comfortable maintaining your own versions, you should upgrade to a supported version. + +## Reporting a bug + +All security bugs in shopify repositories should be reported to [our hackerone program](https://hackerone.com/shopify) +Shopify's whitehat program is our way to reward security researchers for finding serious security vulnerabilities in the In Scope properties listed at the bottom of this page, including our core application (all functionality associated with a Shopify store, particularly your-store.myshopify.com/admin) and certain ancillary applications. + +## Disclosure Policy + +We look forward to working with all security researchers and strive to be respectful, always assume the best and treat others as peers. We expect the same in return from all participants. To achieve this, our team strives to: + +- Reply to all reports within one business day and triage within two business days (if applicable) +- Be as transparent as possible, answering all inquires about our report decisions and adding hackers to duplicate HackerOne reports +- Award bounties within a week of resolution (excluding extenuating circumstances) +- Only close reports as N/A when the issue reported is included in Known Issues, Ineligible Vulnerabilities Types or lacks evidence of a vulnerability + +**The following rules must be followed in order for any rewards to be paid:** + +- You may only test against shops you have created which include your HackerOne YOURHANDLE @ wearehackerone.com registered email address. +- You must not attempt to gain access to, or interact with, any shops other than those created by you. +- The use of commercial scanners is prohibited (e.g., Nessus). +- Rules for reporting must be followed. +- Do not disclose any issues publicly before they have been resolved. +- Shopify reserves the right to modify the rules for this program or deem any submissions invalid at any time. Shopify may cancel the whitehat program without notice at any time. +- Contacting Shopify Support over chat, email or phone about your HackerOne report is not allowed. We may disqualify you from receiving a reward, or from participating in the program altogether. +- You are not an employee of Shopify; employees should report bugs to the internal bug bounty program. +- You hereby represent, warrant and covenant that any content you submit to Shopify is an original work of authorship and that you are legally entitled to grant the rights and privileges conveyed by these terms. You further represent, warrant and covenant that the consent of no other person or entity is or will be necessary for Shopify to use the submitted content. +- By submitting content to Shopify, you irrevocably waive all moral rights which you may have in the content. +- All content submitted by you to Shopify under this program is licensed under the MIT License. +- You must report any discovered vulnerability to Shopify as soon as you have validated the vulnerability. +- Failure to follow any of the foregoing rules will disqualify you from participating in this program. + +** Please see our [Hackerone Profile](https://hackerone.com/shopify) for full details + +## Receiving Security Updates + +To recieve all general updates to vulnerabilities, please subscribe to our hackerone [Hacktivity](https://hackerone.com/shopify/hacktivity) diff --git a/bin/shopify_api.py b/bin/shopify_api.py index e8abb535..c0930deb 100755 --- a/bin/shopify_api.py +++ b/bin/shopify_api.py @@ -8,6 +8,6 @@ project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, project_root) -with open(os.path.join(project_root, 'scripts', 'shopify_api.py')) as f: - code = compile(f.read(), f.name, 'exec') +with open(os.path.join(project_root, "scripts", "shopify_api.py")) as f: + code = compile(f.read(), f.name, "exec") exec(code) diff --git a/dev.yml b/dev.yml new file mode 100644 index 00000000..84a23200 --- /dev/null +++ b/dev.yml @@ -0,0 +1,12 @@ +--- +name: shopify-python-api + +type: python + +up: + - python: 3.8.13 + - pip: + - requirements.txt + +commands: + test: python setup.py test diff --git a/docs/api-access.md b/docs/api-access.md new file mode 100644 index 00000000..52424a24 --- /dev/null +++ b/docs/api-access.md @@ -0,0 +1,73 @@ +# Handling access scope operations + +#### Table of contents + +- [Common ApiAccess operations](#common-apiaccess-operations) +- [Using ApiAccess to handle changes in app access scopes](#using-apiaccess-to-handle-changes-in-app-access-scopes) + +There are common operations that are used for managing [access scopes](https://shopify.dev/docs/admin-api/access-scopes) in apps. Such operations include serializing, deserializing and normalizing scopes. Other operations can include checking whether two sets of scopes grant the same API access or whether one set covers the access granted by another set. + +To encapsulate the access granted by access scopes, you can use the `ApiAccess` value object. + +## Common ApiAccess operations + +### Constructing an ApiAccess + +```python +api_access = ApiAccess(["read_products", "write_orders"]) # List of access scopes +another_api_access = ApiAccess("read_products, write_products, unauthenticated_read_themes") # String of comma-delimited access scopes +``` + +### Serializing ApiAccess + +```python +api_access = ApiAccess(["read_products", "write_orders", "unauthenticated_read_themes"]) + +access_scopes_list = list(api_access) # ["read_products", "write_orders", "unauthenticated_read_themes"] +comma_delimited_access_scopes = str(api_access) # "read_products,write_orders,unauthenticated_read_themes" +``` + +### Comparing ApiAccess objects + +#### Checking for API access equality + +```python +expected_api_access = ApiAccess(["read_products", "write_orders"]) + +actual_api_access = ApiAccess(["read_products", "read_orders", "write_orders"]) +non_equal_api_access = ApiAccess(["read_products", "write_orders", "read_themes"]) + +actual_api_access == expected_api_access # True +non_equal_api_access == expected_api_access # False +``` + +#### Checking if ApiAccess covers the access of another + +```python +superset_access = ApiAccess(["write_products", "write_orders", "read_themes"]) +subset_access = ApiAccess(["read_products", "write_orders"]) + +superset_access.covers(subset_access) # True +``` + +## Using ApiAccess to handle changes in app access scopes + +If your app has changes in the access scopes it requests, you can use the `ApiAccess` object to determine whether the merchant needs to go through OAuth based on the scopes currently granted. A sample decorator shows how this can be achieved when loading an app. + +```python +from shopify import ApiAccess + + +def oauth_on_access_scopes_mismatch(func): + def wrapper(*args, **kwargs): + shop_domain = get_shop_query_parameter(request) # shop query param when loading app + current_shop_scopes = ApiAccess(ShopStore.get_record(shopify_domain = shop_domain).access_scopes) + expected_access_scopes = ApiAccess(SHOPIFY_API_SCOPES) + + if current_shop_scopes != expected_access_scopes: + return redirect_to_login() # redirect to OAuth to update access scopes granted + + return func(*args, **kwargs) + + return wrapper +``` diff --git a/docs/session-tokens.md b/docs/session-tokens.md new file mode 100644 index 00000000..7349cd3a --- /dev/null +++ b/docs/session-tokens.md @@ -0,0 +1,54 @@ +# Session tokens + +The Shopify Python API library provides helper methods to decode [session tokens](https://shopify.dev/concepts/apps/building-embedded-apps-using-session-tokens). You can use the `decode_from_header` function to extract and decode a session token from an HTTP Authorization header. + +## Basic usage + +```python +from shopify import session_token + +decoded_payload = session_token.decode_from_header( + authorization_header=your_auth_request_header, + api_key=your_api_key, + secret=your_api_secret, +) +``` + +## Create a decorator using `session_token` + +Here's a sample decorator that protects your app views/routes by requiring the presence of valid session tokens as part of a request's headers. + +```python +from shopify import session_token + + +def session_token_required(func): + def wrapper(*args, **kwargs): + request = args[0] # Or flask.request if you use Flask + try: + decoded_session_token = session_token.decode_from_header( + authorization_header = request.headers.get('Authorization'), + api_key = SHOPIFY_API_KEY, + secret = SHOPIFY_API_SECRET + ) + with shopify_session(decoded_session_token): + return func(*args, **kwargs) + except session_token.SessionTokenError as e: + # Log the error here + return unauthorized_401_response() + + return wrapper + + +def shopify_session(decoded_session_token): + shopify_domain = decoded_session_token.get("dest") + access_token = get_offline_access_token_by_shop_domain(shopify_domain) + + return shopify.Session.temp(shopify_domain, SHOPIFY_API_VERSION, access_token) + + +@session_token_required # Requests to /products require session tokens +def products(request): + products = shopify.Product.find() + ... +``` diff --git a/pylintrc b/pylintrc new file mode 100644 index 00000000..97de81bf --- /dev/null +++ b/pylintrc @@ -0,0 +1,443 @@ +# This Pylint rcfile contains a best-effort configuration to uphold the +# best-practices and style described in the Google Python style guide: +# https://google.github.io/styleguide/pyguide.html +# +# Its canonical open-source location is: +# https://google.github.io/styleguide/pylintrc + +[MASTER] + +# Files or directories to be skipped. They should be base names, not paths. +ignore=third_party + +# Files or directories matching the regex patterns are skipped. The regex +# matches against base names, not paths. +ignore-patterns= + +# Pickle collected data for later comparisons. +persistent=no + +# List of plugins (as comma separated values of python modules names) to load, +# usually to register additional checkers. +load-plugins= + +# Use multiple processes to speed up Pylint. +jobs=4 + +# Allow loading of arbitrary C extensions. Extensions are imported into the +# active Python interpreter and may run arbitrary code. +unsafe-load-any-extension=no + + +[MESSAGES CONTROL] + +# Only show warnings with the listed confidence levels. Leave empty to show +# all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED +confidence=INFERENCE + +# Enable the message, report, category or checker with the given id(s). You can +# either give multiple identifier separated by comma (,) or put this option +# multiple time (only on the command line, not in the configuration file where +# it should appear only once). See also the "--disable" option for examples. +#enable= + +# Disable the message, report, category or checker with the given id(s). You +# can either give multiple identifiers separated by comma (,) or put this +# option multiple times (only on the command line, not in the configuration +# file where it should appear only once).You can also use "--disable=all" to +# disable everything first and then reenable specific checks. For example, if +# you want to run only the similarities checker, you can use "--disable=all +# --enable=similarities". If you want to run only the classes checker, but have +# no Warning level messages displayed, use"--disable=all --enable=classes +# --disable=W" +disable=abstract-method, + apply-builtin, + arguments-differ, + attribute-defined-outside-init, + backtick, + bad-option-value, + basestring-builtin, + buffer-builtin, + c-extension-no-member, + consider-using-enumerate, + cmp-builtin, + cmp-method, + coerce-builtin, + coerce-method, + delslice-method, + div-method, + duplicate-code, + eq-without-hash, + execfile-builtin, + file-builtin, + filter-builtin-not-iterating, + fixme, + getslice-method, + global-statement, + hex-method, + idiv-method, + implicit-str-concat-in-sequence, + import-error, + import-self, + import-star-module-level, + inconsistent-return-statements, + input-builtin, + intern-builtin, + invalid-str-codec, + locally-disabled, + long-builtin, + long-suffix, + map-builtin-not-iterating, + misplaced-comparison-constant, + missing-class-docstring, + missing-function-docstring, + missing-module-docstring, + metaclass-assignment, + next-method-called, + next-method-defined, + no-absolute-import, + no-else-break, + no-else-continue, + no-else-raise, + no-else-return, + no-init, # added + no-member, + no-name-in-module, + no-self-use, + nonzero-method, + oct-method, + old-division, + old-ne-operator, + old-octal-literal, + old-raise-syntax, + parameter-unpacking, + print-statement, + raising-string, + range-builtin-not-iterating, + raw_input-builtin, + rdiv-method, + reduce-builtin, + relative-import, + reload-builtin, + round-builtin, + setslice-method, + signature-differs, + standarderror-builtin, + suppressed-message, + sys-max-int, + too-few-public-methods, + too-many-ancestors, + too-many-arguments, + too-many-boolean-expressions, + too-many-branches, + too-many-instance-attributes, + too-many-locals, + too-many-nested-blocks, + too-many-public-methods, + too-many-return-statements, + too-many-statements, + trailing-newlines, + unichr-builtin, + unicode-builtin, + unnecessary-pass, + unpacking-in-except, + useless-else-on-loop, + useless-object-inheritance, + useless-suppression, + using-cmp-argument, + wrong-import-order, + xrange-builtin, + zip-builtin-not-iterating, + + +[REPORTS] + +# Set the output format. Available formats are text, parseable, colorized, msvs +# (visual studio) and html. You can also give a reporter class, eg +# mypackage.mymodule.MyReporterClass. +output-format=text + +# Put messages in a separate file for each module / package specified on the +# command line instead of printing them on stdout. Reports (if any) will be +# written in a file name "pylint_global.[txt|html]". This option is deprecated +# and it will be removed in Pylint 2.0. +files-output=no + +# Tells whether to display a full report or only the messages +reports=no + +# Python expression which should return a note less than 10 (10 is the highest +# note). You have access to the variables errors warning, statement which +# respectively contain the number of errors / warnings messages and the total +# number of statements analyzed. This is used by the global evaluation report +# (RP0004). +evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) + +# Template used to display messages. This is a python new-style format string +# used to format the message information. See doc for all details +#msg-template= + + +[BASIC] + +# Good variable names which should always be accepted, separated by a comma +good-names=main,_ + +# Bad variable names which should always be refused, separated by a comma +bad-names= + +# Colon-delimited sets of names that determine each other's naming style when +# the name regexes allow several styles. +name-group= + +# Include a hint for the correct naming format with invalid-name +include-naming-hint=no + +# List of decorators that produce properties, such as abc.abstractproperty. Add +# to this list to register other decorators that produce valid properties. +property-classes=abc.abstractproperty,cached_property.cached_property,cached_property.threaded_cached_property,cached_property.cached_property_with_ttl,cached_property.threaded_cached_property_with_ttl + +# Regular expression matching correct function names +function-rgx=^(?:(?PsetUp|tearDown|setUpModule|tearDownModule)|(?P_?[A-Z][a-zA-Z0-9]*)|(?P_?[a-z][a-z0-9_]*))$ + +# Regular expression matching correct variable names +variable-rgx=^[a-z][a-z0-9_]*$ + +# Regular expression matching correct constant names +const-rgx=^(_?[A-Z][A-Z0-9_]*|__[a-z0-9_]+__|_?[a-z][a-z0-9_]*)$ + +# Regular expression matching correct attribute names +attr-rgx=^_{0,2}[a-z][a-z0-9_]*$ + +# Regular expression matching correct argument names +argument-rgx=^[a-z][a-z0-9_]*$ + +# Regular expression matching correct class attribute names +class-attribute-rgx=^(_?[A-Z][A-Z0-9_]*|__[a-z0-9_]+__|_?[a-z][a-z0-9_]*)$ + +# Regular expression matching correct inline iteration names +inlinevar-rgx=^[a-z][a-z0-9_]*$ + +# Regular expression matching correct class names +class-rgx=^_?[A-Z][a-zA-Z0-9]*$ + +# Regular expression matching correct module names +module-rgx=^(_?[a-z][a-z0-9_]*|__init__)$ + +# Regular expression matching correct method names +method-rgx=(?x)^(?:(?P_[a-z0-9_]+__|runTest|setUp|tearDown|setUpTestCase|tearDownTestCase|setupSelf|tearDownClass|setUpClass|(test|assert)_*[A-Z0-9][a-zA-Z0-9_]*|next)|(?P_{0,2}[A-Z][a-zA-Z0-9_]*)|(?P_{0,2}[a-z][a-z0-9_]*))$ + +# Regular expression which should only match function or class names that do +# not require a docstring. +no-docstring-rgx=(__.*__|main|test.*|.*test|.*Test)$ + +# Minimum line length for functions/classes that require docstrings, shorter +# ones are exempt. +docstring-min-length=10 + + +[TYPECHECK] + +# List of decorators that produce context managers, such as +# contextlib.contextmanager. Add to this list to register other decorators that +# produce valid context managers. +contextmanager-decorators=contextlib.contextmanager,contextlib2.contextmanager + +# Tells whether missing members accessed in mixin class should be ignored. A +# mixin class is detected if its name ends with "mixin" (case insensitive). +ignore-mixin-members=yes + +# List of module names for which member attributes should not be checked +# (useful for modules/projects where namespaces are manipulated during runtime +# and thus existing member attributes cannot be deduced by static analysis. It +# supports qualified module names, as well as Unix pattern matching. +ignored-modules= + +# List of class names for which member attributes should not be checked (useful +# for classes with dynamically set attributes). This supports the use of +# qualified names. +ignored-classes=optparse.Values,thread._local,_thread._local + +# List of members which are set dynamically and missed by pylint inference +# system, and so shouldn't trigger E1101 when accessed. Python regular +# expressions are accepted. +generated-members= + + +[FORMAT] + +# Maximum number of characters on a single line. +max-line-length=120 + +# TODO(https://github.com/PyCQA/pylint/issues/3352): Direct pylint to exempt +# lines made too long by directives to pytype. + +# Regexp for a line that is allowed to be longer than the limit. +ignore-long-lines=(?x)( + ^\s*(\#\ )??$| + ^\s*(from\s+\S+\s+)?import\s+.+$) + +# Allow the body of an if to be on the same line as the test if there is no +# else. +single-line-if-stmt=yes + +# List of optional constructs for which whitespace checking is disabled. `dict- +# separator` is used to allow tabulation in dicts, etc.: {1 : 1,\n222: 2}. +# `trailing-comma` allows a space between comma and closing bracket: (a, ). +# `empty-line` allows space-only lines. +no-space-check= + +# Maximum number of lines in a module +max-module-lines=99999 + +# String used as indentation unit. The internal Google style guide mandates 2 +# spaces. Google's externaly-published style guide says 4, consistent with +# PEP 8. Here, we use 2 spaces, for conformity with many open-sourced Google +# projects (like TensorFlow). +indent-string=' ' + +# Number of spaces of indent required inside a hanging or continued line. +indent-after-paren=4 + +# Expected format of line ending, e.g. empty (any line ending), LF or CRLF. +expected-line-ending-format= + + +[MISCELLANEOUS] + +# List of note tags to take in consideration, separated by a comma. +notes=TODO + + +[STRING] + +# This flag controls whether inconsistent-quotes generates a warning when the +# character used as a quote delimiter is used inconsistently within a module. +check-quote-consistency=yes + + +[VARIABLES] + +# Tells whether we should check for unused import in __init__ files. +init-import=no + +# A regular expression matching the name of dummy variables (i.e. expectedly +# not used). +dummy-variables-rgx=^\*{0,2}(_$|unused_|dummy_) + +# List of additional names supposed to be defined in builtins. Remember that +# you should avoid to define new builtins when possible. +additional-builtins= + +# List of strings which can identify a callback function by name. A callback +# name must start or end with one of those strings. +callbacks=cb_,_cb + +# List of qualified module names which can have objects that can redefine +# builtins. +redefining-builtins-modules=six,six.moves,past.builtins,future.builtins,functools + + +[LOGGING] + +# Logging modules to check that the string format arguments are in logging +# function parameter format +logging-modules=logging,absl.logging,tensorflow.io.logging + + +[SIMILARITIES] + +# Minimum lines number of a similarity. +min-similarity-lines=4 + +# Ignore comments when computing similarities. +ignore-comments=yes + +# Ignore docstrings when computing similarities. +ignore-docstrings=yes + +# Ignore imports when computing similarities. +ignore-imports=no + + +[SPELLING] + +# Spelling dictionary name. Available dictionaries: none. To make it working +# install python-enchant package. +spelling-dict= + +# List of comma separated words that should not be checked. +spelling-ignore-words= + +# A path to a file that contains private dictionary; one word per line. +spelling-private-dict-file= + +# Tells whether to store unknown words to indicated private dictionary in +# --spelling-private-dict-file option instead of raising a message. +spelling-store-unknown-words=no + + +[IMPORTS] + +# Deprecated modules which should not be used, separated by a comma +deprecated-modules=regsub, + TERMIOS, + Bastion, + rexec, + sets + +# Create a graph of every (i.e. internal and external) dependencies in the +# given file (report RP0402 must not be disabled) +import-graph= + +# Create a graph of external dependencies in the given file (report RP0402 must +# not be disabled) +ext-import-graph= + +# Create a graph of internal dependencies in the given file (report RP0402 must +# not be disabled) +int-import-graph= + +# Force import order to recognize a module as part of the standard +# compatibility libraries. +known-standard-library= + +# Force import order to recognize a module as part of a third party library. +known-third-party=enchant, absl + +# Analyse import fallback blocks. This can be used to support both Python 2 and +# 3 compatible code, which means that the block might have code that exists +# only in one or another interpreter, leading to false positives when analysed. +analyse-fallback-blocks=no + + +[CLASSES] + +# List of method names used to declare (i.e. assign) instance attributes. +defining-attr-methods=__init__, + __new__, + setUp + +# List of member names, which should be excluded from the protected access +# warning. +exclude-protected=_asdict, + _fields, + _replace, + _source, + _make + +# List of valid names for the first argument in a class method. +valid-classmethod-first-arg=cls, + class_ + +# List of valid names for the first argument in a metaclass class method. +valid-metaclass-classmethod-first-arg=mcs + + +[EXCEPTIONS] + +# Exceptions that will emit a warning when being caught. Defaults to +# "Exception" +overgeneral-exceptions=StandardError, + Exception, + BaseException diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..02c844ae --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,5 @@ +[tool.autopep8] +max_line_length = 120 + +[tool.black] +line-length = 120 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 00000000..49fe098d --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +setuptools diff --git a/scripts/shopify_api.py b/scripts/shopify_api.py index 737b81d8..bab35f15 100755 --- a/scripts/shopify_api.py +++ b/scripts/shopify_api.py @@ -10,22 +10,40 @@ import functools import yaml import six +from six.moves import input, map + def start_interpreter(**variables): - console = type('shopify ' + shopify.version.VERSION, (code.InteractiveConsole, object), {}) - import readline - console(variables).interact() + # add the current working directory to the sys paths + sys.path.append(os.getcwd()) + try: + from IPython import start_ipython + from traitlets.config.loader import Config + + config = Config(TerminalInteractiveShell={"banner2": "(shopify %s)" % shopify.version.VERSION}) + start_ipython(argv=[], user_ns=variables, config=config) + + except ImportError: + console = type("shopify " + shopify.version.VERSION, (code.InteractiveConsole, object), {}) + import readline + + console(variables).interact() + class ConfigFileError(Exception): pass + def usage(usage_string): """Decorator to add a usage string to a function""" + def decorate(func): func.usage = usage_string return func + return decorate + class TasksMeta(type): _prog = os.path.basename(sys.argv[0]) @@ -34,23 +52,26 @@ def __new__(mcs, name, bases, new_attrs): tasks = list(new_attrs.keys()) tasks.append("help") + def filter_func(item): return not item.startswith("_") and hasattr(getattr(cls, item), "__call__") + tasks = filter(filter_func, tasks) cls._tasks = sorted(tasks) return cls def run_task(cls, task=None, *args): - if task in [None, '-h', '--help']: + if task in [None, "-h", "--help"]: cls.help() return - # Allow unambigious abbreviations of tasks + # Allow unambiguous abbreviations of tasks if task not in cls._tasks: matches = filter(lambda item: item.startswith(task), cls._tasks) - if len(matches) == 1: - task = matches[0] + list_of_matches = list(matches) + if len(list_of_matches) == 1: + task = list_of_matches[0] else: sys.stderr.write('Could not find task "%s".\n' % (task)) @@ -75,7 +96,7 @@ def help(cls, task=None): if desc: line = "%s%s # %s" % (line, " " * (max_len - len(line)), desc) if len(line) > cols: - line = line[:cols - 3] + "..." + line = line[: cols - 3] + "..." print(line) else: task_func = getattr(cls, task) @@ -89,6 +110,7 @@ def help(cls, task=None): class Tasks(object): _shop_config_dir = os.path.join(os.environ["HOME"], ".shopify", "shops") _default_symlink = os.path.join(_shop_config_dir, "default") + _default_api_version = "unstable" @classmethod @usage("list") @@ -106,20 +128,24 @@ def add(cls, connection): if os.path.exists(filename): raise ConfigFileError("There is already a config file at " + filename) else: - config = dict(protocol='https') - domain = raw_input("Domain? (leave blank for %s.myshopify.com) " % (connection)) + config = {"protocol": "https"} + domain = input("Domain? (leave blank for %s.myshopify.com) " % (connection)) if not domain.strip(): domain = "%s.myshopify.com" % (connection) - config['domain'] = domain + config["domain"] = domain print("") print("open https://%s/admin/apps/private in your browser to generate API credentials" % (domain)) - config['api_key'] = raw_input("API key? ") - config['password'] = raw_input("Password? ") + config["api_key"] = input("API key? ") + config["password"] = input("Password? ") + config["api_version"] = input("API version? (leave blank for %s) " % (cls._default_api_version)) + if not config["api_version"].strip(): + config["api_version"] = cls._default_api_version + if not os.path.isdir(cls._shop_config_dir): os.makedirs(cls._shop_config_dir) - with open(filename, 'w') as f: + with open(filename, "w") as f: f.write(yaml.dump(config, default_flow_style=False, explicit_start="---")) - if len(cls._available_connections()) == 1: + if len(list(cls._available_connections())) == 1: cls.default(connection) @classmethod @@ -197,14 +223,16 @@ def console(cls, connection=None): @classmethod @usage("version") - def version(cls, connection=None): + def version(cls): """output the shopify library version""" print(shopify.version.VERSION) @classmethod def _available_connections(cls): - return map(lambda item: os.path.splitext(os.path.basename(item))[0], - glob.glob(os.path.join(cls._shop_config_dir, "*.yml"))) + return map( + lambda item: os.path.splitext(os.path.basename(item))[0], + glob.glob(os.path.join(cls._shop_config_dir, "*.yml")), + ) @classmethod def _default_connection_target(cls): @@ -213,7 +241,6 @@ def _default_connection_target(cls): target = os.readlink(cls._default_symlink) return os.path.join(cls._shop_config_dir, target) - @classmethod def _default_connection(cls): target = cls._default_connection_target() @@ -230,7 +257,7 @@ def _get_config_filename(cls, connection): @classmethod def _session_from_config(cls, config): - session = shopify.Session(config.get("domain")) + session = shopify.Session(config.get("domain"), config.get("api_version", cls._default_api_version)) session.protocol = config.get("protocol", "https") session.api_key = config.get("api_key") session.token = config.get("password") @@ -244,6 +271,7 @@ def _is_default(cls, connection): def _no_config_file_error(cls, filename): raise ConfigFileError("There is no config file at " + filename) + try: Tasks.run_task(*sys.argv[1:]) except ConfigFileError as e: diff --git a/service.yml b/service.yml deleted file mode 100644 index f7145cfe..00000000 --- a/service.yml +++ /dev/null @@ -1,5 +0,0 @@ -# https://services.shopify.io/services/shopify_python_api/pypi -director: jnormore -owners: -- Shopify/partnerships-eng -classification: tier3 diff --git a/setup.py b/setup.py old mode 100644 new mode 100755 index 1f41bb47..eb23ab08 --- a/setup.py +++ b/setup.py @@ -1,46 +1,53 @@ from setuptools import setup -NAME='ShopifyAPI' -exec(open('shopify/version.py').read()) -DESCRIPTION='Shopify API for Python' -LONG_DESCRIPTION="""\ +NAME = "ShopifyAPI" +exec(open("shopify/version.py").read()) +DESCRIPTION = "Shopify API for Python" +LONG_DESCRIPTION = """\ The ShopifyAPI library allows python developers to programmatically access the admin section of stores using an ActiveResource like interface similar the ruby Shopify API gem. The library makes HTTP requests to Shopify in order to list, create, update, or delete resources (e.g. Order, Product, Collection).""" -setup(name=NAME, - version=VERSION, - description=DESCRIPTION, - long_description=LONG_DESCRIPTION, - author='Shopify', - author_email='developers@shopify.com', - url='https://github.com/Shopify/shopify_python_api', - packages=['shopify', 'shopify/resources'], - scripts=['scripts/shopify_api.py'], - license='MIT License', - install_requires=[ - 'pyactiveresource>=2.1.2', - 'PyYAML', - 'six', - ], - test_suite='test', - tests_require=[ - 'mock>=1.0.1', - ], - platforms='Any', - classifiers=['Development Status :: 5 - Production/Stable', - 'Intended Audience :: Developers', - 'License :: OSI Approved :: MIT License', - 'Operating System :: OS Independent', - 'Programming Language :: Python', - 'Programming Language :: Python :: 2', - 'Programming Language :: Python :: 2.7', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.4', - 'Programming Language :: Python :: 3.5', - 'Topic :: Software Development', - 'Topic :: Software Development :: Libraries', - 'Topic :: Software Development :: Libraries :: Python Modules'] - ) +setup( + name=NAME, + version=VERSION, + description=DESCRIPTION, + long_description=LONG_DESCRIPTION, + author="Shopify", + author_email="developers@shopify.com", + url="https://github.com/Shopify/shopify_python_api", + packages=["shopify", "shopify/resources", "shopify/utils"], + scripts=["scripts/shopify_api.py"], + license="MIT License", + install_requires=[ + "pyactiveresource>=2.2.2", + "PyJWT >= 2.0.0", + "PyYAML>=6.0.1; python_version>='3.12'", + "PyYAML; python_version<'3.12'", + "six", + ], + test_suite="test", + tests_require=[ + "mock>=1.0.1", + ], + platforms="Any", + classifiers=[ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Software Development", + "Topic :: Software Development :: Libraries", + "Topic :: Software Development :: Libraries :: Python Modules", + ], +) diff --git a/shipit.pypi.yml b/shipit.pypi.yml new file mode 100644 index 00000000..e56fb968 --- /dev/null +++ b/shipit.pypi.yml @@ -0,0 +1,5 @@ +deploy: + override: + - assert-egg-version-tag setup.py + - python setup.py sdist + - twine upload --repository-url https://upload.pypi.org/legacy/ -u shopify -p $PYPI_PASSWORD_SHOPIFY dist/* diff --git a/shopify/__init__.py b/shopify/__init__.py index 5bad753c..d3f53de9 100644 --- a/shopify/__init__.py +++ b/shopify/__init__.py @@ -1,3 +1,7 @@ from shopify.version import VERSION from shopify.session import Session, ValidationException from shopify.resources import * +from shopify.limits import Limits +from shopify.api_version import * +from shopify.api_access import * +from shopify.collection import PaginatedIterator diff --git a/shopify/api_access.py b/shopify/api_access.py new file mode 100644 index 00000000..19b80671 --- /dev/null +++ b/shopify/api_access.py @@ -0,0 +1,58 @@ +import re +import sys + + +def basestring_type(): + if sys.version_info[0] < 3: # Backwards compatibility for python < v3.0.0 + return basestring + else: + return str + + +class ApiAccessError(Exception): + pass + + +class ApiAccess: + SCOPE_DELIMITER = "," + SCOPE_RE = re.compile(r"\A(?Punauthenticated_)?(write|read)_(?P.*)\Z") + IMPLIED_SCOPE_RE = re.compile(r"\A(?Punauthenticated_)?write_(?P.*)\Z") + + def __init__(self, scopes): + if isinstance(scopes, basestring_type()): + scopes = scopes.split(self.SCOPE_DELIMITER) + + self.__store_scopes(scopes) + + def covers(self, api_access): + return api_access._compressed_scopes <= self._expanded_scopes + + def __str__(self): + return self.SCOPE_DELIMITER.join(self._compressed_scopes) + + def __iter__(self): + return iter(self._compressed_scopes) + + def __eq__(self, other): + return type(self) == type(other) and self._compressed_scopes == other._compressed_scopes + + def __store_scopes(self, scopes): + sanitized_scopes = frozenset(filter(None, [scope.strip() for scope in scopes])) + self.__validate_scopes(sanitized_scopes) + implied_scopes = frozenset(self.__implied_scope(scope) for scope in sanitized_scopes) + self._compressed_scopes = sanitized_scopes - implied_scopes + self._expanded_scopes = sanitized_scopes.union(implied_scopes) + + def __validate_scopes(self, scopes): + for scope in scopes: + if not self.SCOPE_RE.match(scope): + error_message = "'{s}' is not a valid access scope".format(s=scope) + raise ApiAccessError(error_message) + + def __implied_scope(self, scope): + match = self.IMPLIED_SCOPE_RE.match(scope) + if match: + return "{unauthenticated}read_{resource}".format( + unauthenticated=match.group("unauthenticated") or "", + resource=match.group("resource"), + ) diff --git a/shopify/api_version.py b/shopify/api_version.py new file mode 100644 index 00000000..32276668 --- /dev/null +++ b/shopify/api_version.py @@ -0,0 +1,95 @@ +import re + + +class InvalidVersionError(Exception): + pass + + +class VersionNotFoundError(Exception): + pass + + +class ApiVersion(object): + versions = {} + + @classmethod + def coerce_to_version(cls, version): + try: + return cls.versions[version] + except KeyError: + # Dynamically create a new Release object if version string is not found + if Release.FORMAT.match(version): + return Release(version) + raise VersionNotFoundError + + @classmethod + def define_version(cls, version): + cls.versions[version.name] = version + return version + + @classmethod + def define_known_versions(cls): + cls.define_version(Unstable()) + cls.define_version(Release("2021-10")) + cls.define_version(Release("2022-01")) + cls.define_version(Release("2022-04")) + cls.define_version(Release("2022-07")) + cls.define_version(Release("2022-10")) + cls.define_version(Release("2023-01")) + cls.define_version(Release("2023-04")) + cls.define_version(Release("2023-07")) + cls.define_version(Release("2023-10")) + cls.define_version(Release("2024-01")) + cls.define_version(Release("2024-04")) + cls.define_version(Release("2024-07")) + cls.define_version(Release("2024-10")) + + @classmethod + def clear_defined_versions(cls): + cls.versions = {} + + @property + def numeric_version(self): + return self._numeric_version + + @property + def name(self): + return self._name + + def api_path(self, site): + return site + self._path + + def __eq__(self, other): + if not isinstance(other, type(self)): + return False + return self.numeric_version == int(other.numeric_version) + + +class Release(ApiVersion): + FORMAT = re.compile(r"^\d{4}-\d{2}$") + API_PREFIX = "/admin/api" + + def __init__(self, version_number): + if not self.FORMAT.match(version_number): + raise InvalidVersionError + self._name = version_number + self._numeric_version = int(version_number.replace("-", "")) + self._path = "%s/%s" % (self.API_PREFIX, version_number) + + @property + def stable(self): + return True + + +class Unstable(ApiVersion): + def __init__(self): + self._name = "unstable" + self._numeric_version = 9000000 + self._path = "/admin/api/unstable" + + @property + def stable(self): + return False + + +ApiVersion.define_known_versions() diff --git a/shopify/base.py b/shopify/base.py index d3539f7e..449e288b 100644 --- a/shopify/base.py +++ b/shopify/base.py @@ -8,15 +8,15 @@ from six.moves import urllib import six +from shopify.collection import PaginatedCollection +from pyactiveresource.collection import Collection # Store the response from the last request in the connection object + + class ShopifyConnection(pyactiveresource.connection.Connection): response = None - def __init__(self, site, user=None, password=None, timeout=None, - format=formats.JSONFormat): - super(ShopifyConnection, self).__init__(site, user, password, timeout, format) - def _open(self, *args, **kwargs): self.response = None try: @@ -26,14 +26,16 @@ def _open(self, *args, **kwargs): raise return self.response + # Inherit from pyactiveresource's metaclass in order to use ShopifyConnection -class ShopifyResourceMeta(ResourceMeta): + +class ShopifyResourceMeta(ResourceMeta): @property def connection(cls): """HTTP connection for the current thread""" local = cls._threadlocal - if not getattr(local, 'connection', None): + if not getattr(local, "connection", None): # Make sure these variables are no longer affected by other threads. local.user = cls.user local.password = cls.password @@ -41,85 +43,122 @@ def connection(cls): local.timeout = cls.timeout local.headers = cls.headers local.format = cls.format + local.version = cls.version + local.url = cls.url if cls.site is None: raise ValueError("No shopify session is active") - local.connection = ShopifyConnection( - cls.site, cls.user, cls.password, cls.timeout, cls.format) + local.connection = ShopifyConnection(cls.site, cls.user, cls.password, cls.timeout, cls.format) return local.connection def get_user(cls): - return getattr(cls._threadlocal, 'user', ShopifyResource._user) + return getattr(cls._threadlocal, "user", ShopifyResource._user) def set_user(cls, value): cls._threadlocal.connection = None ShopifyResource._user = cls._threadlocal.user = value - user = property(get_user, set_user, None, - "The username for HTTP Basic Auth.") + user = property(get_user, set_user, None, "The username for HTTP Basic Auth.") def get_password(cls): - return getattr(cls._threadlocal, 'password', ShopifyResource._password) + return getattr(cls._threadlocal, "password", ShopifyResource._password) def set_password(cls, value): cls._threadlocal.connection = None ShopifyResource._password = cls._threadlocal.password = value - password = property(get_password, set_password, None, - "The password for HTTP Basic Auth.") + password = property(get_password, set_password, None, "The password for HTTP Basic Auth.") def get_site(cls): - return getattr(cls._threadlocal, 'site', ShopifyResource._site) + return getattr(cls._threadlocal, "site", ShopifyResource._site) def set_site(cls, value): cls._threadlocal.connection = None ShopifyResource._site = cls._threadlocal.site = value if value is not None: parts = urllib.parse.urlparse(value) + host = parts.hostname + if parts.port: + host += ":" + str(parts.port) + new_site = urllib.parse.urlunparse((parts.scheme, host, parts.path, "", "", "")) + ShopifyResource._site = cls._threadlocal.site = new_site if parts.username: cls.user = urllib.parse.unquote(parts.username) if parts.password: cls.password = urllib.parse.unquote(parts.password) - site = property(get_site, set_site, None, - 'The base REST site to connect to.') + site = property(get_site, set_site, None, "The base REST site to connect to.") def get_timeout(cls): - return getattr(cls._threadlocal, 'timeout', ShopifyResource._timeout) + return getattr(cls._threadlocal, "timeout", ShopifyResource._timeout) def set_timeout(cls, value): cls._threadlocal.connection = None ShopifyResource._timeout = cls._threadlocal.timeout = value - timeout = property(get_timeout, set_timeout, None, - 'Socket timeout for HTTP requests') + timeout = property(get_timeout, set_timeout, None, "Socket timeout for HTTP requests") def get_headers(cls): - if not hasattr(cls._threadlocal, 'headers'): + if not hasattr(cls._threadlocal, "headers"): cls._threadlocal.headers = ShopifyResource._headers.copy() return cls._threadlocal.headers def set_headers(cls, value): cls._threadlocal.headers = value - headers = property(get_headers, set_headers, None, - 'The headers sent with HTTP requests') + headers = property(get_headers, set_headers, None, "The headers sent with HTTP requests") def get_format(cls): - return getattr(cls._threadlocal, 'format', ShopifyResource._format) + return getattr(cls._threadlocal, "format", ShopifyResource._format) def set_format(cls, value): cls._threadlocal.connection = None ShopifyResource._format = cls._threadlocal.format = value - format = property(get_format, set_format, None, - 'Encoding used for request and responses') + format = property(get_format, set_format, None, "Encoding used for request and responses") + + def get_prefix_source(cls): + """Return the prefix source, by default derived from site.""" + try: + return cls.override_prefix() + except AttributeError: + if hasattr(cls, "_prefix_source"): + return cls.site + cls._prefix_source + else: + return cls.site + + def set_prefix_source(cls, value): + """Set the prefix source, which will be rendered into the prefix.""" + cls._prefix_source = value + + prefix_source = property(get_prefix_source, set_prefix_source, None, "prefix for lookups for this type of object.") + + def get_version(cls): + if hasattr(cls._threadlocal, "version") or ShopifyResource._version: + return getattr(cls._threadlocal, "version", ShopifyResource._version) + elif ShopifyResource._site is not None: + return ShopifyResource._site.split("/")[-1] + + def set_version(cls, value): + ShopifyResource._version = cls._threadlocal.version = value + + version = property(get_version, set_version, None, "Shopify Api Version") + + def get_url(cls): + return getattr(cls._threadlocal, "url", ShopifyResource._url) + + def set_url(cls, value): + ShopifyResource._url = cls._threadlocal.url = value + + url = property(get_url, set_url, None, "Base URL including protocol and shopify domain") @six.add_metaclass(ShopifyResourceMeta) class ShopifyResource(ActiveResource, mixins.Countable): _format = formats.JSONFormat _threadlocal = threading.local() - _headers = {'User-Agent': 'ShopifyPythonAPI/%s Python/%s' % (shopify.VERSION, sys.version.split(' ', 1)[0])} + _headers = {"User-Agent": "ShopifyPythonAPI/%s Python/%s" % (shopify.VERSION, sys.version.split(" ", 1)[0])} + _version = None + _url = None def __init__(self, attributes=None, prefix_options=None): if attributes is not None and prefix_options is None: @@ -136,13 +175,25 @@ def _load_attributes_from_response(self, response): @classmethod def activate_session(cls, session): cls.site = session.site + cls.url = session.url cls.user = None cls.password = None - cls.headers['X-Shopify-Access-Token'] = session.token + cls.version = session.api_version.name + cls.headers["X-Shopify-Access-Token"] = session.token @classmethod def clear_session(cls): cls.site = None + cls.url = None cls.user = None cls.password = None - cls.headers.pop('X-Shopify-Access-Token', None) + cls.version = None + cls.headers.pop("X-Shopify-Access-Token", None) + + @classmethod + def find(cls, id_=None, from_=None, **kwargs): + """Checks the resulting collection for pagination metadata.""" + collection = super(ShopifyResource, cls).find(id_=id_, from_=from_, **kwargs) + if isinstance(collection, Collection) and "headers" in collection.metadata: + return PaginatedCollection(collection, metadata={"resource_class": cls}, **kwargs) + return collection diff --git a/shopify/collection.py b/shopify/collection.py new file mode 100644 index 00000000..62728eb9 --- /dev/null +++ b/shopify/collection.py @@ -0,0 +1,156 @@ +from pyactiveresource.collection import Collection + + +class PaginatedCollection(Collection): + """ + A subclass of Collection which allows cycling through pages of + data through cursor-based pagination. + + :next_page_url contains a url for fetching the next page + :previous_page_url contains a url for fetching the previous page + + You can use next_page_url and previous_page_url to fetch the next page + of data by calling Resource.find(from_=page.next_page_url) + """ + + def __init__(self, *args, **kwargs): + """If given a Collection object as an argument, inherit its metadata.""" + + metadata = kwargs.pop("metadata", None) + obj = args[0] + if isinstance(obj, Collection): + if metadata: + metadata.update(obj.metadata) + else: + metadata = obj.metadata + super(PaginatedCollection, self).__init__(obj, metadata=metadata) + else: + super(PaginatedCollection, self).__init__(metadata=metadata or {}, *args, **kwargs) + + if not ("resource_class" in self.metadata): + raise AttributeError('Cursor-based pagination requires a "resource_class" attribute in the metadata.') + + self.metadata["pagination"] = self.__parse_pagination() + self.next_page_url = self.metadata["pagination"].get("next", None) + self.previous_page_url = self.metadata["pagination"].get("previous", None) + + self._next = None + self._previous = None + self._current_iter = None + self._no_iter_next = kwargs.pop("no_iter_next", True) + + def __parse_pagination(self): + if "headers" not in self.metadata: + return {} + + values = self.metadata["headers"].get("Link", self.metadata["headers"].get("link", None)) + if values is None: + return {} + + result = {} + for value in values.split(", "): + link, rel = value.split("; ") + result[rel.split('"')[1]] = link[1:-1] + return result + + def has_previous_page(self): + """Returns true if the current page has any previous pages before it.""" + return bool(self.previous_page_url) + + def has_next_page(self): + """Returns true if the current page has any pages beyond the current position.""" + return bool(self.next_page_url) + + def previous_page(self, no_cache=False): + """Returns the previous page of items. + + Args: + no_cache: If true the page will not be cached. + Returns: + A PaginatedCollection object with the new data set. + """ + if self._previous: + return self._previous + elif not self.has_previous_page(): + raise IndexError("No previous page") + return self.__fetch_page(self.previous_page_url, no_cache) + + def next_page(self, no_cache=False): + """Returns the next page of items. + + Args: + no_cache: If true the page will not be cached. + Returns: + A PaginatedCollection object with the new data set. + """ + if self._next: + return self._next + elif not self.has_next_page(): + raise IndexError("No next page") + return self.__fetch_page(self.next_page_url, no_cache) + + def __fetch_page(self, url, no_cache=False): + next = self.metadata["resource_class"].find(from_=url) + if not no_cache: + self._next = next + self._next._previous = self + next._no_iter_next = self._no_iter_next + return next + + def __iter__(self): + """Iterates through all items, also fetching other pages.""" + for item in super(PaginatedCollection, self).__iter__(): + yield item + + if self._no_iter_next: + return + + try: + if not self._current_iter: + self._current_iter = self + self._current_iter = self.next_page() + + for item in self._current_iter: + yield item + except IndexError: + return + + def __len__(self): + """If fetched count all the pages.""" + + if self._next: + count = len(self._next) + else: + count = 0 + return count + super(PaginatedCollection, self).__len__() + + +class PaginatedIterator(object): + """ + This class implements an iterator over paginated collections which aims to + be more memory-efficient by not keeping more than one page in memory at a + time. + + >>> from shopify import Product, PaginatedIterator + >>> for page in PaginatedIterator(Product.find()): + ... for item in page: + ... do_something(item) + ... + # every page and the page items are iterated + """ + + def __init__(self, collection): + if not isinstance(collection, PaginatedCollection): + raise TypeError("PaginatedIterator expects a PaginatedCollection instance") + self.collection = collection + self.collection._no_iter_next = True + + def __iter__(self): + """Iterate over pages, returning one page at a time.""" + current_page = self.collection + while True: + yield current_page + try: + current_page = current_page.next_page(no_cache=True) + except IndexError: + return diff --git a/shopify/limits.py b/shopify/limits.py new file mode 100644 index 00000000..0246c793 --- /dev/null +++ b/shopify/limits.py @@ -0,0 +1,62 @@ +import shopify + + +class Limits(object): + """ + API Calls Limit + https://help.shopify.com/en/api/getting-started/api-call-limit + + Conversion of lib/shopify_api/limits.rb + """ + + # num_requests_executed/max_requests + # Eg: 1/40 + CREDIT_LIMIT_HEADER_PARAM = "X-Shopify-Shop-Api-Call-Limit" + + @classmethod + def response(cls): + if not shopify.Shop.connection.response: + shopify.Shop.current() + return shopify.Shop.connection.response + + @classmethod + def api_credit_limit_param(cls): + response = cls.response() + _safe_header = getattr(response, "headers", "") + + if not _safe_header: + raise Exception("No shopify headers found") + + if cls.CREDIT_LIMIT_HEADER_PARAM in response.headers: + credits = response.headers[cls.CREDIT_LIMIT_HEADER_PARAM] + return credits.split("/") + else: + raise Exception("No valid api call header found") + + @classmethod + def credit_left(cls): + """ + How many more API calls can I make? + """ + return int(cls.credit_limit() - cls.credit_used()) + + @classmethod + def credit_maxed(cls): + """ + Have I reached my API call limit? + """ + return bool(cls.credit_left() <= 0) + + @classmethod + def credit_limit(cls): + """ + How many total API calls can I make? + """ + return int(cls.api_credit_limit_param()[1]) + + @classmethod + def credit_used(cls): + """ + How many API calls have I made? + """ + return int(cls.api_credit_limit_param()[0]) diff --git a/shopify/mixins.py b/shopify/mixins.py index c7806a0c..5a13ca3a 100644 --- a/shopify/mixins.py +++ b/shopify/mixins.py @@ -1,7 +1,7 @@ import shopify.resources -class Countable(object): +class Countable(object): @classmethod def count(cls, _options=None, **kwargs): if _options is None: @@ -10,7 +10,6 @@ def count(cls, _options=None, **kwargs): class Metafields(object): - def metafields(self, _options=None, **kwargs): if _options is None: _options = kwargs @@ -25,12 +24,11 @@ def add_metafield(self, metafield): if self.is_new(): raise ValueError("You can only add metafields to a resource that has been saved") - metafield._prefix_options = dict(resource=self.__class__.plural, resource_id=self.id) + metafield._prefix_options = {"resource": self.__class__.plural, "resource_id": self.id} metafield.save() return metafield class Events(object): - def events(self): return shopify.resources.Event.find(resource=self.__class__.plural, resource_id=self.id) diff --git a/shopify/resources/__init__.py b/shopify/resources/__init__.py index d2ee5e02..0d420b38 100644 --- a/shopify/resources/__init__.py +++ b/shopify/resources/__init__.py @@ -16,14 +16,16 @@ from .rule import Rule from .tax_line import TaxLine from .script_tag import ScriptTag -from .product_search_engine import ProductSearchEngine from .application_charge import ApplicationCharge +from .application_credit import ApplicationCredit from .recurring_application_charge import RecurringApplicationCharge from .usage_charge import UsageCharge from .asset import Asset from .theme import Theme +from .currency import Currency from .customer_saved_search import CustomerSavedSearch from .customer_group import CustomerGroup +from .customer_invite import CustomerInvite from .customer import Customer from .event import Event from .webhook import Webhook @@ -36,21 +38,45 @@ from .page import Page from .country import Country from .refund import Refund -from .fulfillment import Fulfillment +from .fulfillment import Fulfillment, FulfillmentOrders, FulfillmentV2 +from .fulfillment_event import FulfillmentEvent from .fulfillment_service import FulfillmentService from .carrier_service import CarrierService from .transaction import Transaction +from .tender_transaction import TenderTransaction from .image import Image from .variant import Variant from .order import Order +from .balance import Balance +from .disputes import Disputes +from .payouts import Payouts +from .transactions import Transactions from .order_risk import OrderRisk from .policy import Policy from .smart_collection import SmartCollection from .gift_card import GiftCard -from .discount import Discount +from .gift_card_adjustment import GiftCardAdjustment from .shipping_zone import ShippingZone from .location import Location from .draft_order import DraftOrder from .draft_order_invoice import DraftOrderInvoice +from .report import Report +from .price_rule import PriceRule +from .discount_code import DiscountCode +from .discount_code_creation import DiscountCodeCreation +from .marketing_event import MarketingEvent +from .collection_listing import CollectionListing +from .product_listing import ProductListing +from .resource_feedback import ResourceFeedback +from .storefront_access_token import StorefrontAccessToken +from .inventory_item import InventoryItem +from .inventory_level import InventoryLevel +from .access_scope import AccessScope +from .user import User +from .api_permission import ApiPermission +from .publication import Publication +from .collection_publication import CollectionPublication +from .product_publication import ProductPublication +from .graphql import GraphQL from ..base import ShopifyResource diff --git a/shopify/resources/access_scope.py b/shopify/resources/access_scope.py new file mode 100644 index 00000000..1ded9d37 --- /dev/null +++ b/shopify/resources/access_scope.py @@ -0,0 +1,7 @@ +from ..base import ShopifyResource + + +class AccessScope(ShopifyResource): + @classmethod + def override_prefix(cls): + return "/admin/oauth" diff --git a/shopify/resources/api_permission.py b/shopify/resources/api_permission.py new file mode 100644 index 00000000..1c936451 --- /dev/null +++ b/shopify/resources/api_permission.py @@ -0,0 +1,9 @@ +from ..base import ShopifyResource + + +class ApiPermission(ShopifyResource): + @classmethod + def delete(cls): + cls.connection.delete(cls.site + "/api_permissions/current." + cls.format.extension, cls.headers) + + destroy = delete diff --git a/shopify/resources/application_charge.py b/shopify/resources/application_charge.py index df19e18c..6ed62f77 100644 --- a/shopify/resources/application_charge.py +++ b/shopify/resources/application_charge.py @@ -2,6 +2,5 @@ class ApplicationCharge(ShopifyResource): - def activate(self): self._load_attributes_from_response(self.post("activate")) diff --git a/shopify/resources/product_search_engine.py b/shopify/resources/application_credit.py similarity index 51% rename from shopify/resources/product_search_engine.py rename to shopify/resources/application_credit.py index 44976245..ecc12fa0 100644 --- a/shopify/resources/product_search_engine.py +++ b/shopify/resources/application_credit.py @@ -1,5 +1,5 @@ from ..base import ShopifyResource -class ProductSearchEngine(ShopifyResource): +class ApplicationCredit(ShopifyResource): pass diff --git a/shopify/resources/article.py b/shopify/resources/article.py index dccbe0b3..2b061a3e 100644 --- a/shopify/resources/article.py +++ b/shopify/resources/article.py @@ -4,23 +4,23 @@ class Article(ShopifyResource, mixins.Metafields, mixins.Events): - _prefix_source = "/admin/blogs/$blog_id/" + _prefix_source = "/blogs/$blog_id/" @classmethod def _prefix(cls, options={}): blog_id = options.get("blog_id") if blog_id: - return "/admin/blogs/%s" % (blog_id) + return "%s/blogs/%s" % (cls.site, blog_id) else: - return "/admin" + return cls.site def comments(self): return Comment.find(article_id=self.id) @classmethod def authors(cls, **kwargs): - return cls.get('authors', **kwargs) + return cls.get("authors", **kwargs) @classmethod def tags(cls, **kwargs): - return cls.get('tags', **kwargs) + return cls.get("tags", **kwargs) diff --git a/shopify/resources/asset.py b/shopify/resources/asset.py index 19a7bd76..d5156a5a 100644 --- a/shopify/resources/asset.py +++ b/shopify/resources/asset.py @@ -4,22 +4,26 @@ class Asset(ShopifyResource): _primary_key = "key" - _prefix_source = "/admin/themes/$theme_id/" + _prefix_source = "/themes/$theme_id/" @classmethod def _prefix(cls, options={}): theme_id = options.get("theme_id") if theme_id: - return "/admin/themes/%s" % theme_id + return "%s/themes/%s" % (cls.site, theme_id) else: - return "/admin" + return cls.site @classmethod def _element_path(cls, id, prefix_options={}, query_options=None): if query_options is None: prefix_options, query_options = cls._split_options(prefix_options) - return "%s%s.%s%s" % (cls._prefix(prefix_options)+'/', cls.plural, - cls.format.extension, cls._query_string(query_options)) + return "%s%s.%s%s" % ( + cls._prefix(prefix_options) + "/", + cls.plural, + cls.format.extension, + cls._query_string(query_options), + ) @classmethod def find(cls, key=None, **kwargs): @@ -34,7 +38,7 @@ def find(cls, key=None, **kwargs): params = {"asset[key]": key} params.update(kwargs) theme_id = params.get("theme_id") - path_prefix = "/admin/themes/%s" % (theme_id) if theme_id else "/admin" + path_prefix = "%s/themes/%s" % (cls.site, theme_id) if theme_id else cls.site resource = cls.find_one("%s/assets.%s" % (path_prefix, cls.format.extension), **params) @@ -48,7 +52,7 @@ def __get_value(self): return data data = self.attributes.get("attachment") if data: - return base64.b64decode(data) + return base64.b64decode(data).decode() def __set_value(self, data): self.__wipe_value_attributes() @@ -57,7 +61,7 @@ def __set_value(self, data): value = property(__get_value, __set_value, None, "The asset's value or attachment") def attach(self, data): - self.attachment = base64.b64encode(data) + self.attachment = base64.b64encode(data).decode() def destroy(self): options = {"asset[key]": self.key} diff --git a/shopify/resources/balance.py b/shopify/resources/balance.py new file mode 100644 index 00000000..aefa87ab --- /dev/null +++ b/shopify/resources/balance.py @@ -0,0 +1,7 @@ +from ..base import ShopifyResource +from shopify import mixins + + +class Balance(ShopifyResource, mixins.Metafields): + _prefix_source = "/shopify_payments/" + _singular = _plural = "balance" diff --git a/shopify/resources/blog.py b/shopify/resources/blog.py index d623f011..e88b26b1 100644 --- a/shopify/resources/blog.py +++ b/shopify/resources/blog.py @@ -4,6 +4,5 @@ class Blog(ShopifyResource, mixins.Metafields, mixins.Events): - def articles(self): return shopify.Article.find(blog_id=self.id) diff --git a/shopify/resources/collection_listing.py b/shopify/resources/collection_listing.py new file mode 100644 index 00000000..00567489 --- /dev/null +++ b/shopify/resources/collection_listing.py @@ -0,0 +1,8 @@ +from ..base import ShopifyResource + + +class CollectionListing(ShopifyResource): + _primary_key = "collection_id" + + def product_ids(cls, **kwargs): + return cls.get("product_ids", **kwargs) diff --git a/shopify/resources/collection_publication.py b/shopify/resources/collection_publication.py new file mode 100644 index 00000000..6805d251 --- /dev/null +++ b/shopify/resources/collection_publication.py @@ -0,0 +1,5 @@ +from ..base import ShopifyResource + + +class CollectionPublication(ShopifyResource): + _prefix_source = "/publications/$publication_id/" diff --git a/shopify/resources/comment.py b/shopify/resources/comment.py index 0015eb74..110afd61 100644 --- a/shopify/resources/comment.py +++ b/shopify/resources/comment.py @@ -2,7 +2,6 @@ class Comment(ShopifyResource): - def remove(self): self._load_attributes_from_response(self.post("remove")) diff --git a/shopify/resources/currency.py b/shopify/resources/currency.py new file mode 100644 index 00000000..6bf53b98 --- /dev/null +++ b/shopify/resources/currency.py @@ -0,0 +1,5 @@ +from ..base import ShopifyResource + + +class Currency(ShopifyResource): + pass diff --git a/shopify/resources/custom_collection.py b/shopify/resources/custom_collection.py index 87ffae1a..85bcbc4a 100644 --- a/shopify/resources/custom_collection.py +++ b/shopify/resources/custom_collection.py @@ -4,12 +4,11 @@ class CustomCollection(ShopifyResource, mixins.Metafields, mixins.Events): - def products(self): return shopify.Product.find(collection_id=self.id) def add_product(self, product): - return shopify.Collect.create({'collection_id': self.id, 'product_id': product.id}) + return shopify.Collect.create({"collection_id": self.id, "product_id": product.id}) def remove_product(self, product): collect = shopify.Collect.find_first(collection_id=self.id, product_id=product.id) diff --git a/shopify/resources/customer.py b/shopify/resources/customer.py index cb33e5c5..ab989e84 100644 --- a/shopify/resources/customer.py +++ b/shopify/resources/customer.py @@ -1,9 +1,10 @@ from ..base import ShopifyResource from shopify import mixins +from .customer_invite import CustomerInvite +from .order import Order class Customer(ShopifyResource, mixins.Metafields): - @classmethod def search(cls, **kwargs): """ @@ -14,8 +15,15 @@ def search(cls, **kwargs): query: Text to search for customers page: Page to show (default: 1) limit: Amount of results (default: 50) (maximum: 250) - fields: comma-seperated list of fields to include in the response + fields: comma-separated list of fields to include in the response Returns: - An array of customers. + A Collection of customers. """ - return cls._build_list(cls.get("search", **kwargs)) + return cls._build_collection(cls.get("search", **kwargs)) + + def send_invite(self, customer_invite=CustomerInvite()): + resource = self.post("send_invite", customer_invite.encode()) + return CustomerInvite(Customer.format.decode(resource.body)) + + def orders(self): + return Order.find(customer_id=self.id) diff --git a/shopify/resources/customer_invite.py b/shopify/resources/customer_invite.py new file mode 100644 index 00000000..cf4015b3 --- /dev/null +++ b/shopify/resources/customer_invite.py @@ -0,0 +1,5 @@ +from ..base import ShopifyResource + + +class CustomerInvite(ShopifyResource): + pass diff --git a/shopify/resources/customer_saved_search.py b/shopify/resources/customer_saved_search.py index 88c74acc..dbe97251 100644 --- a/shopify/resources/customer_saved_search.py +++ b/shopify/resources/customer_saved_search.py @@ -3,6 +3,5 @@ class CustomerSavedSearch(ShopifyResource): - def customers(cls, **kwargs): - return Customer._build_list(cls.get("customers", **kwargs)) + return Customer._build_collection(cls.get("customers", **kwargs)) diff --git a/shopify/resources/discount.py b/shopify/resources/discount.py deleted file mode 100644 index 1755ef86..00000000 --- a/shopify/resources/discount.py +++ /dev/null @@ -1,10 +0,0 @@ -from ..base import ShopifyResource - - -class Discount(ShopifyResource): - - def disable(self): - self._load_attributes_from_response(self.post("disable")) - - def enable(self): - self._load_attributes_from_response(self.post("enable")) diff --git a/shopify/resources/discount_code.py b/shopify/resources/discount_code.py new file mode 100644 index 00000000..e2559e3e --- /dev/null +++ b/shopify/resources/discount_code.py @@ -0,0 +1,5 @@ +from ..base import ShopifyResource + + +class DiscountCode(ShopifyResource): + _prefix_source = "/price_rules/$price_rule_id/" diff --git a/shopify/resources/discount_code_creation.py b/shopify/resources/discount_code_creation.py new file mode 100644 index 00000000..e72de283 --- /dev/null +++ b/shopify/resources/discount_code_creation.py @@ -0,0 +1,17 @@ +from ..base import ShopifyResource +from .discount_code import DiscountCode + + +class DiscountCodeCreation(ShopifyResource): + _prefix_source = "/price_rules/$price_rule_id/" + + def discount_codes(self): + return DiscountCode.find( + from_="%s/price_rules/%s/batch/%s/discount_codes.%s" + % ( + ShopifyResource.site, + self._prefix_options["price_rule_id"], + self.id, + DiscountCodeCreation.format.extension, + ) + ) diff --git a/shopify/resources/disputes.py b/shopify/resources/disputes.py new file mode 100644 index 00000000..f098fd0f --- /dev/null +++ b/shopify/resources/disputes.py @@ -0,0 +1,6 @@ +from ..base import ShopifyResource +from shopify import mixins + + +class Disputes(ShopifyResource, mixins.Metafields): + _prefix_source = "/shopify_payments/" diff --git a/shopify/resources/draft_order.py b/shopify/resources/draft_order.py index b8492d36..878cb7af 100644 --- a/shopify/resources/draft_order.py +++ b/shopify/resources/draft_order.py @@ -4,12 +4,12 @@ class DraftOrder(ShopifyResource, mixins.Metafields): - def send_invoice(self, draft_order_invoice = DraftOrderInvoice()): + def send_invoice(self, draft_order_invoice=DraftOrderInvoice()): resource = self.post("send_invoice", draft_order_invoice.encode()) return DraftOrderInvoice(DraftOrder.format.decode(resource.body)) - def complete(self, params = {}): - if params.get('payment_pending', False): - self._load_attributes_from_response(self.put("complete", payment_pending='true')) + def complete(self, params={}): + if params.get("payment_pending", False): + self._load_attributes_from_response(self.put("complete", payment_pending="true")) else: - self._load_attributes_from_response(self.put("complete")) + self._load_attributes_from_response(self.put("complete")) diff --git a/shopify/resources/event.py b/shopify/resources/event.py index 8b25ce38..f3268e13 100644 --- a/shopify/resources/event.py +++ b/shopify/resources/event.py @@ -1,12 +1,13 @@ from ..base import ShopifyResource + class Event(ShopifyResource): - _prefix_source = "/admin/$resource/$resource_id/" + _prefix_source = "/$resource/$resource_id/" @classmethod def _prefix(cls, options={}): resource = options.get("resource") if resource: - return "/admin/%s/%s" % (resource, options["resource_id"]) + return "%s/%s/%s" % (cls.site, resource, options["resource_id"]) else: - return "/admin" + return cls.site diff --git a/shopify/resources/fulfillment.py b/shopify/resources/fulfillment.py index c15e3d48..fcf74863 100644 --- a/shopify/resources/fulfillment.py +++ b/shopify/resources/fulfillment.py @@ -1,8 +1,9 @@ from ..base import ShopifyResource +import json class Fulfillment(ShopifyResource): - _prefix_source = "/admin/orders/$order_id/" + _prefix_source = "/orders/$order_id/" def cancel(self): self._load_attributes_from_response(self.post("cancel")) @@ -12,3 +13,21 @@ def complete(self): def open(self): self._load_attributes_from_response(self.post("open")) + + def update_tracking(self, tracking_info, notify_customer): + fulfill = FulfillmentV2() + fulfill.id = self.id + self._load_attributes_from_response(fulfill.update_tracking(tracking_info, notify_customer)) + + +class FulfillmentOrders(ShopifyResource): + _prefix_source = "/orders/$order_id/" + + +class FulfillmentV2(ShopifyResource): + _singular = "fulfillment" + _plural = "fulfillments" + + def update_tracking(self, tracking_info, notify_customer): + body = {"fulfillment": {"tracking_info": tracking_info, "notify_customer": notify_customer}} + return self.post("update_tracking", json.dumps(body).encode()) diff --git a/shopify/resources/fulfillment_event.py b/shopify/resources/fulfillment_event.py new file mode 100644 index 00000000..fbd2ece7 --- /dev/null +++ b/shopify/resources/fulfillment_event.py @@ -0,0 +1,32 @@ +from ..base import ShopifyResource + + +class FulfillmentEvent(ShopifyResource): + _prefix_source = "/orders/$order_id/fulfillments/$fulfillment_id/" + _singular = "event" + _plural = "events" + + @classmethod + def _prefix(cls, options={}): + order_id = options.get("order_id") + fulfillment_id = options.get("fulfillment_id") + event_id = options.get("event_id") + + return "%s/orders/%s/fulfillments/%s" % (cls.site, order_id, fulfillment_id) + + def save(self): + status = self.attributes["status"] + if status not in [ + "label_printed", + "label_purchased", + "attempted_delivery", + "ready_for_pickup", + "picked_up", + "confirmed", + "in_transit", + "out_for_delivery", + "delivered", + "failure", + ]: + raise AttributeError("Invalid status") + return super(ShopifyResource, self).save() diff --git a/shopify/resources/gift_card.py b/shopify/resources/gift_card.py index 9476478e..c1918c68 100644 --- a/shopify/resources/gift_card.py +++ b/shopify/resources/gift_card.py @@ -1,7 +1,30 @@ from ..base import ShopifyResource +from .gift_card_adjustment import GiftCardAdjustment class GiftCard(ShopifyResource): - def disable(self): self._load_attributes_from_response(self.post("disable")) + + @classmethod + def search(cls, **kwargs): + """ + Search for gift cards matching supplied query + + Args: + order: Field and direction to order results by (default: disabled_at DESC) + query: Text to search for gift cards + page: Page to show (default: 1) + limit: Amount of results (default: 50) (maximum: 250) + fields: comma-separated list of fields to include in the response + Returns: + An array of gift cards. + """ + return cls._build_collection(cls.get("search", **kwargs)) + + def add_adjustment(self, adjustment): + """ + Create a new Gift Card Adjustment + """ + resource = self.post("adjustments", adjustment.encode()) + return GiftCardAdjustment(GiftCard.format.decode(resource.body)) diff --git a/shopify/resources/gift_card_adjustment.py b/shopify/resources/gift_card_adjustment.py new file mode 100644 index 00000000..2314cdb6 --- /dev/null +++ b/shopify/resources/gift_card_adjustment.py @@ -0,0 +1,7 @@ +from ..base import ShopifyResource + + +class GiftCardAdjustment(ShopifyResource): + _prefix_source = "/admin/gift_cards/$gift_card_id/" + _plural = "adjustments" + _singular = "adjustment" diff --git a/shopify/resources/graphql.py b/shopify/resources/graphql.py new file mode 100644 index 00000000..33525ef1 --- /dev/null +++ b/shopify/resources/graphql.py @@ -0,0 +1,32 @@ +import shopify +from ..base import ShopifyResource +from six.moves import urllib +import json + + +class GraphQL: + def __init__(self): + self.endpoint = shopify.ShopifyResource.get_site() + "/graphql.json" + self.headers = shopify.ShopifyResource.get_headers() + + def merge_headers(self, *headers): + merged_headers = {} + for header in headers: + merged_headers.update(header) + return merged_headers + + def execute(self, query, variables=None, operation_name=None): + endpoint = self.endpoint + default_headers = {"Accept": "application/json", "Content-Type": "application/json"} + headers = self.merge_headers(default_headers, self.headers) + data = {"query": query, "variables": variables, "operationName": operation_name} + + req = urllib.request.Request(self.endpoint, json.dumps(data).encode("utf-8"), headers) + + try: + response = urllib.request.urlopen(req) + return response.read().decode("utf-8") + except urllib.error.HTTPError as e: + print((e.read())) + print("") + raise e diff --git a/shopify/resources/image.py b/shopify/resources/image.py index a0e82efe..1a4d13fb 100644 --- a/shopify/resources/image.py +++ b/shopify/resources/image.py @@ -6,15 +6,15 @@ class Image(ShopifyResource): - _prefix_source = "/admin/products/$product_id/" + _prefix_source = "/products/$product_id/" @classmethod def _prefix(cls, options={}): product_id = options.get("product_id") if product_id: - return "/admin/products/%s" % (product_id) + return "%s/products/%s" % (cls.site, product_id) else: - return "/admin" + return cls.site def __getattr__(self, name): if name in ["pico", "icon", "thumb", "small", "compact", "medium", "large", "grande", "original"]: @@ -23,17 +23,19 @@ def __getattr__(self, name): return super(Image, self).__getattr__(name) def attach_image(self, data, filename=None): - self.attributes["attachment"] = base64.b64encode(data) + self.attributes["attachment"] = base64.b64encode(data).decode() if filename: self.attributes["filename"] = filename def metafields(self): if self.is_new(): return [] - query_params = { 'metafield[owner_id]': self.id, 'metafield[owner_resource]': 'product_image' } - return Metafield.find(from_ = '/admin/metafields.json?%s' % urllib.parse.urlencode(query_params)) + query_params = {"metafield[owner_id]": self.id, "metafield[owner_resource]": "product_image"} + return Metafield.find( + from_="%s/metafields.json?%s" % (ShopifyResource.site, urllib.parse.urlencode(query_params)) + ) def save(self): - if 'product_id' not in self._prefix_options: - self._prefix_options['product_id'] = self.product_id + if "product_id" not in self._prefix_options: + self._prefix_options["product_id"] = self.product_id return super(ShopifyResource, self).save() diff --git a/shopify/resources/inventory_item.py b/shopify/resources/inventory_item.py new file mode 100644 index 00000000..8f3d39ce --- /dev/null +++ b/shopify/resources/inventory_item.py @@ -0,0 +1,5 @@ +from ..base import ShopifyResource + + +class InventoryItem(ShopifyResource): + pass diff --git a/shopify/resources/inventory_level.py b/shopify/resources/inventory_level.py new file mode 100644 index 00000000..5b7f4b0a --- /dev/null +++ b/shopify/resources/inventory_level.py @@ -0,0 +1,58 @@ +from ..base import ShopifyResource +import shopify +import json + + +class InventoryLevel(ShopifyResource): + def __repr__(self): + return "%s(inventory_item_id=%s, location_id=%s)" % (self._singular, self.inventory_item_id, self.location_id) + + @classmethod + def _element_path(cls, prefix_options={}, query_options=None): + if query_options is None: + prefix_options, query_options = cls._split_options(prefix_options) + + return "%s%s.%s%s" % ( + cls._prefix(prefix_options) + "/", + cls.plural, + cls.format.extension, + cls._query_string(query_options), + ) + + @classmethod + def adjust(cls, location_id, inventory_item_id, available_adjustment): + body = { + "inventory_item_id": inventory_item_id, + "location_id": location_id, + "available_adjustment": available_adjustment, + } + resource = cls.post("adjust", body=json.dumps(body).encode()) + return InventoryLevel(InventoryLevel.format.decode(resource.body)) + + @classmethod + def connect(cls, location_id, inventory_item_id, relocate_if_necessary=False, **kwargs): + body = { + "inventory_item_id": inventory_item_id, + "location_id": location_id, + "relocate_if_necessary": relocate_if_necessary, + } + resource = cls.post("connect", body=json.dumps(body).encode()) + return InventoryLevel(InventoryLevel.format.decode(resource.body)) + + @classmethod + def set(cls, location_id, inventory_item_id, available, disconnect_if_necessary=False, **kwargs): + body = { + "inventory_item_id": inventory_item_id, + "location_id": location_id, + "available": available, + "disconnect_if_necessary": disconnect_if_necessary, + } + resource = cls.post("set", body=json.dumps(body).encode()) + return InventoryLevel(InventoryLevel.format.decode(resource.body)) + + def is_new(self): + return False + + def destroy(self): + options = {"inventory_item_id": self.inventory_item_id, "location_id": self.location_id} + return self.__class__.connection.delete(self._element_path(query_options=options), self.__class__.headers) diff --git a/shopify/resources/line_item.py b/shopify/resources/line_item.py index cac2ceed..c701c90f 100644 --- a/shopify/resources/line_item.py +++ b/shopify/resources/line_item.py @@ -2,5 +2,5 @@ class LineItem(ShopifyResource): - class Property(ShopifyResource): - pass + class Property(ShopifyResource): + pass diff --git a/shopify/resources/location.py b/shopify/resources/location.py index 671b5b0e..51e7ecdd 100644 --- a/shopify/resources/location.py +++ b/shopify/resources/location.py @@ -1,5 +1,9 @@ from ..base import ShopifyResource +from .inventory_level import InventoryLevel class Location(ShopifyResource): - pass + def inventory_levels(self, **kwargs): + return InventoryLevel.find( + from_="%s/locations/%s/inventory_levels.json" % (ShopifyResource.site, self.id), **kwargs + ) diff --git a/shopify/resources/marketing_event.py b/shopify/resources/marketing_event.py new file mode 100644 index 00000000..6b629449 --- /dev/null +++ b/shopify/resources/marketing_event.py @@ -0,0 +1,8 @@ +import json +from ..base import ShopifyResource + + +class MarketingEvent(ShopifyResource): + def add_engagements(self, engagements): + engagements_json = json.dumps({"engagements": engagements}) + return self.post("engagements", engagements_json.encode()) diff --git a/shopify/resources/metafield.py b/shopify/resources/metafield.py index f37af161..7cba8e8e 100644 --- a/shopify/resources/metafield.py +++ b/shopify/resources/metafield.py @@ -2,12 +2,12 @@ class Metafield(ShopifyResource): - _prefix_source = "/admin/$resource/$resource_id/" + _prefix_source = "/$resource/$resource_id/" @classmethod def _prefix(cls, options={}): resource = options.get("resource") if resource: - return "/admin/%s/%s" % (resource, options["resource_id"]) + return "%s/%s/%s" % (cls.site, resource, options["resource_id"]) else: - return "/admin" + return cls.site diff --git a/shopify/resources/order.py b/shopify/resources/order.py index 4e780b0a..2e31a8c3 100644 --- a/shopify/resources/order.py +++ b/shopify/resources/order.py @@ -4,6 +4,15 @@ class Order(ShopifyResource, mixins.Metafields, mixins.Events): + _prefix_source = "/customers/$customer_id/" + + @classmethod + def _prefix(cls, options={}): + customer_id = options.get("customer_id") + if customer_id: + return "%s/customers/%s" % (cls.site, customer_id) + else: + return cls.site def close(self): self._load_attributes_from_response(self.post("close")) diff --git a/shopify/resources/order_risk.py b/shopify/resources/order_risk.py index 2cc4c268..fdcfa1f3 100644 --- a/shopify/resources/order_risk.py +++ b/shopify/resources/order_risk.py @@ -1,5 +1,7 @@ from ..base import ShopifyResource + class OrderRisk(ShopifyResource): - _prefix_source = "/admin/orders/$order_id/" - _plural = "risks" + _prefix_source = "/orders/$order_id/" + _singular = "risk" + _plural = "risks" diff --git a/shopify/resources/payouts.py b/shopify/resources/payouts.py new file mode 100644 index 00000000..dea162d8 --- /dev/null +++ b/shopify/resources/payouts.py @@ -0,0 +1,6 @@ +from ..base import ShopifyResource +from shopify import mixins + + +class Payouts(ShopifyResource, mixins.Metafields): + _prefix_source = "/shopify_payments/" diff --git a/shopify/resources/policy.py b/shopify/resources/policy.py index caac98b9..d97fcc2e 100644 --- a/shopify/resources/policy.py +++ b/shopify/resources/policy.py @@ -2,5 +2,6 @@ from shopify import mixins import shopify + class Policy(ShopifyResource, mixins.Metafields, mixins.Events): - pass + pass diff --git a/shopify/resources/price_rule.py b/shopify/resources/price_rule.py new file mode 100644 index 00000000..41fe3e04 --- /dev/null +++ b/shopify/resources/price_rule.py @@ -0,0 +1,24 @@ +import json +from ..base import ShopifyResource +from .discount_code import DiscountCode +from .discount_code_creation import DiscountCodeCreation + + +class PriceRule(ShopifyResource): + def add_discount_code(self, discount_code=DiscountCode()): + resource = self.post("discount_codes", discount_code.encode()) + return DiscountCode(PriceRule.format.decode(resource.body)) + + def discount_codes(self): + return DiscountCode.find(price_rule_id=self.id) + + def create_batch(self, codes=[]): + codes_json = json.dumps({"discount_codes": codes}) + + response = self.post("batch", codes_json.encode()) + return DiscountCodeCreation(PriceRule.format.decode(response.body)) + + def find_batch(self, batch_id): + return DiscountCodeCreation.find_one( + "%s/price_rules/%s/batch/%s.%s" % (ShopifyResource.site, self.id, batch_id, PriceRule.format.extension) + ) diff --git a/shopify/resources/product.py b/shopify/resources/product.py index e364ae07..cc16e3e3 100644 --- a/shopify/resources/product.py +++ b/shopify/resources/product.py @@ -4,7 +4,6 @@ class Product(ShopifyResource, mixins.Metafields, mixins.Events): - def price_range(self): prices = [float(variant.price) for variant in self.variants] f = "%0.2f" @@ -28,5 +27,17 @@ def remove_from_collection(self, collection): return collection.remove_product(self) def add_variant(self, variant): - variant.attributes['product_id'] = self.id + variant.attributes["product_id"] = self.id return variant.save() + + def save(self): + start_api_version = "201910" + api_version = ShopifyResource.version + if api_version and (api_version.strip("-") >= start_api_version) and api_version != "unstable": + if "variants" in self.attributes: + for variant in self.variants: + if "inventory_quantity" in variant.attributes: + del variant.attributes["inventory_quantity"] + if "old_inventory_quantity" in variant.attributes: + del variant.attributes["old_inventory_quantity"] + return super(ShopifyResource, self).save() diff --git a/shopify/resources/product_listing.py b/shopify/resources/product_listing.py new file mode 100644 index 00000000..3e59d6c1 --- /dev/null +++ b/shopify/resources/product_listing.py @@ -0,0 +1,9 @@ +from ..base import ShopifyResource + + +class ProductListing(ShopifyResource): + _primary_key = "product_id" + + @classmethod + def product_ids(cls, **kwargs): + return cls.get("product_ids", **kwargs) diff --git a/shopify/resources/product_publication.py b/shopify/resources/product_publication.py new file mode 100644 index 00000000..4ba510de --- /dev/null +++ b/shopify/resources/product_publication.py @@ -0,0 +1,5 @@ +from ..base import ShopifyResource + + +class ProductPublication(ShopifyResource): + _prefix_source = "/publications/$publication_id/" diff --git a/shopify/resources/publication.py b/shopify/resources/publication.py new file mode 100644 index 00000000..1b4079f4 --- /dev/null +++ b/shopify/resources/publication.py @@ -0,0 +1,5 @@ +from ..base import ShopifyResource + + +class Publication(ShopifyResource): + pass diff --git a/shopify/resources/recurring_application_charge.py b/shopify/resources/recurring_application_charge.py index ffda95b2..c94cac2d 100644 --- a/shopify/resources/recurring_application_charge.py +++ b/shopify/resources/recurring_application_charge.py @@ -1,6 +1,7 @@ from ..base import ShopifyResource from .usage_charge import UsageCharge + def _get_first_by_status(resources, status): for resource in resources: if resource.status == status: @@ -9,12 +10,11 @@ def _get_first_by_status(resources, status): class RecurringApplicationCharge(ShopifyResource): - def usage_charges(self): return UsageCharge.find(recurring_application_charge_id=self.id) def customize(self, **kwargs): - self._load_attributes_from_response(self.put("customize", recurring_application_charge= kwargs)) + self._load_attributes_from_response(self.put("customize", recurring_application_charge=kwargs)) @classmethod def current(cls): @@ -24,8 +24,5 @@ def current(cls): """ return _get_first_by_status(cls.find(), "active") - def cancel(self): - self._load_attributes_from_response(self.destroy) - def activate(self): self._load_attributes_from_response(self.post("activate")) diff --git a/shopify/resources/refund.py b/shopify/resources/refund.py index 394887b3..124036b3 100644 --- a/shopify/resources/refund.py +++ b/shopify/resources/refund.py @@ -1,5 +1,29 @@ +import json + from ..base import ShopifyResource class Refund(ShopifyResource): - _prefix_source = "/admin/orders/$order_id/" + _prefix_source = "/orders/$order_id/" + + @classmethod + def calculate(cls, order_id, shipping=None, refund_line_items=None): + """ + Calculates refund transactions based on line items and shipping. + When you want to create a refund, you should first use the calculate + endpoint to generate accurate refund transactions. + + Args: + order_id: Order ID for which the Refund has to created. + shipping: Specify how much shipping to refund. + refund_line_items: A list of line item IDs and quantities to refund. + Returns: + Unsaved refund record + """ + data = {} + if shipping: + data["shipping"] = shipping + data["refund_line_items"] = refund_line_items or [] + body = {"refund": data} + resource = cls.post("calculate", order_id=order_id, body=json.dumps(body).encode()) + return cls(cls.format.decode(resource.body), prefix_options={"order_id": order_id}) diff --git a/shopify/resources/report.py b/shopify/resources/report.py new file mode 100644 index 00000000..e01b4bbd --- /dev/null +++ b/shopify/resources/report.py @@ -0,0 +1,5 @@ +from ..base import ShopifyResource + + +class Report(ShopifyResource): + pass diff --git a/shopify/resources/resource_feedback.py b/shopify/resources/resource_feedback.py new file mode 100644 index 00000000..0ead8f77 --- /dev/null +++ b/shopify/resources/resource_feedback.py @@ -0,0 +1,14 @@ +from ..base import ShopifyResource + + +class ResourceFeedback(ShopifyResource): + _prefix_source = "/products/$product_id/" + _plural = "resource_feedback" + + @classmethod + def _prefix(cls, options={}): + product_id = options.get("product_id") + if product_id: + return "%s/products/%s" % (cls.site, product_id) + else: + return cls.site diff --git a/shopify/resources/shop.py b/shopify/resources/shop.py index 00a927f8..4d447366 100644 --- a/shopify/resources/shop.py +++ b/shopify/resources/shop.py @@ -4,10 +4,9 @@ class Shop(ShopifyResource): - @classmethod def current(cls): - return cls.find_one("/admin/shop." + cls.format.extension) + return cls.find_one(cls.site + "/shop." + cls.format.extension) def metafields(self): return Metafield.find() diff --git a/shopify/resources/smart_collection.py b/shopify/resources/smart_collection.py index efea6347..802c3a7e 100644 --- a/shopify/resources/smart_collection.py +++ b/shopify/resources/smart_collection.py @@ -4,6 +4,5 @@ class SmartCollection(ShopifyResource, mixins.Metafields, mixins.Events): - def products(self): return shopify.Product.find(collection_id=self.id) diff --git a/shopify/resources/storefront_access_token.py b/shopify/resources/storefront_access_token.py new file mode 100644 index 00000000..f1132f49 --- /dev/null +++ b/shopify/resources/storefront_access_token.py @@ -0,0 +1,5 @@ +from ..base import ShopifyResource + + +class StorefrontAccessToken(ShopifyResource): + pass diff --git a/shopify/resources/tender_transaction.py b/shopify/resources/tender_transaction.py new file mode 100644 index 00000000..0999ab6e --- /dev/null +++ b/shopify/resources/tender_transaction.py @@ -0,0 +1,5 @@ +from ..base import ShopifyResource + + +class TenderTransaction(ShopifyResource): + pass diff --git a/shopify/resources/transaction.py b/shopify/resources/transaction.py index aaed92b9..f465255a 100644 --- a/shopify/resources/transaction.py +++ b/shopify/resources/transaction.py @@ -2,4 +2,4 @@ class Transaction(ShopifyResource): - _prefix_source = "/admin/orders/$order_id/" + _prefix_source = "/orders/$order_id/" diff --git a/shopify/resources/transactions.py b/shopify/resources/transactions.py new file mode 100644 index 00000000..90cb884f --- /dev/null +++ b/shopify/resources/transactions.py @@ -0,0 +1,6 @@ +from ..base import ShopifyResource +from shopify import mixins + + +class Transactions(ShopifyResource, mixins.Metafields): + _prefix_source = "/shopify_payments/balance/" diff --git a/shopify/resources/usage_charge.py b/shopify/resources/usage_charge.py index 3555cfcf..bd5cd757 100644 --- a/shopify/resources/usage_charge.py +++ b/shopify/resources/usage_charge.py @@ -1,12 +1,13 @@ from ..base import ShopifyResource + class UsageCharge(ShopifyResource): - _prefix_source = "/admin/recurring_application_charge/$recurring_application_charge_id/" + _prefix_source = "/recurring_application_charge/$recurring_application_charge_id/" @classmethod def _prefix(cls, options={}): recurring_application_charge_id = options.get("recurring_application_charge_id") if recurring_application_charge_id: - return "/admin/recurring_application_charges/%s" % (recurring_application_charge_id) + return "%s/recurring_application_charges/%s" % (cls.site, recurring_application_charge_id) else: - return "/admin" + return cls.site diff --git a/shopify/resources/user.py b/shopify/resources/user.py new file mode 100644 index 00000000..a1b50cb5 --- /dev/null +++ b/shopify/resources/user.py @@ -0,0 +1,7 @@ +from ..base import ShopifyResource + + +class User(ShopifyResource): + @classmethod + def current(cls): + return User(cls.get("current")) diff --git a/shopify/resources/variant.py b/shopify/resources/variant.py index cc4f5485..743b071b 100644 --- a/shopify/resources/variant.py +++ b/shopify/resources/variant.py @@ -3,17 +3,26 @@ class Variant(ShopifyResource, mixins.Metafields): - _prefix_source = "/admin/products/$product_id/" + _prefix_source = "/products/$product_id/" @classmethod def _prefix(cls, options={}): product_id = options.get("product_id") if product_id: - return "/admin/products/%s" % (product_id) + return "%s/products/%s" % (cls.site, product_id) else: - return "/admin" + return cls.site def save(self): - if 'product_id' not in self._prefix_options: - self._prefix_options['product_id'] = self.product_id + if "product_id" not in self._prefix_options: + self._prefix_options["product_id"] = self.product_id + + start_api_version = "201910" + api_version = ShopifyResource.version + if api_version and (api_version.strip("-") >= start_api_version) and api_version != "unstable": + if "inventory_quantity" in self.attributes: + del self.attributes["inventory_quantity"] + if "old_inventory_quantity" in self.attributes: + del self.attributes["old_inventory_quantity"] + return super(ShopifyResource, self).save() diff --git a/shopify/resources/webhook.py b/shopify/resources/webhook.py index 452934f3..ba8a7f28 100644 --- a/shopify/resources/webhook.py +++ b/shopify/resources/webhook.py @@ -2,7 +2,6 @@ class Webhook(ShopifyResource): - def __get_format(self): return self.attributes.get("format") diff --git a/shopify/session.py b/shopify/session.py index 9097b8f5..561faacf 100644 --- a/shopify/session.py +++ b/shopify/session.py @@ -1,6 +1,8 @@ import time import hmac +import json from hashlib import sha256 + try: import simplejson as json except ImportError: @@ -8,16 +10,20 @@ import re from contextlib import contextmanager from six.moves import urllib +from shopify.api_access import ApiAccess +from shopify.api_version import ApiVersion, Release, Unstable import six + class ValidationException(Exception): pass + class Session(object): api_key = None secret = None - protocol = 'https' - myshopify_domain = 'myshopify.com' + protocol = "https" + myshopify_domain = "myshopify.com" port = None @classmethod @@ -27,55 +33,81 @@ def setup(cls, **kwargs): @classmethod @contextmanager - def temp(cls, domain, token): + def temp(cls, domain, version, token): import shopify - original_site = shopify.ShopifyResource.get_site() - original_token = shopify.ShopifyResource.get_headers().get('X-Shopify-Access-Token') - original_session = shopify.Session(original_site, original_token) - session = Session(domain, token) + original_domain = shopify.ShopifyResource.url + original_token = shopify.ShopifyResource.get_headers().get("X-Shopify-Access-Token") + original_version = shopify.ShopifyResource.get_version() or version + original_session = shopify.Session(original_domain, original_version, original_token) + + session = Session(domain, version, token) shopify.ShopifyResource.activate_session(session) yield shopify.ShopifyResource.activate_session(original_session) - def __init__(self, shop_url, token=None, params=None): + def __init__(self, shop_url, version=None, token=None, access_scopes=None): self.url = self.__prepare_url(shop_url) self.token = token + self.version = ApiVersion.coerce_to_version(version) + self.access_scopes = access_scopes return - def create_permission_url(self, scope, redirect_uri=None): - query_params = dict(client_id=self.api_key, scope=",".join(scope)) - if redirect_uri: query_params['redirect_uri'] = redirect_uri - return "%s/oauth/authorize?%s" % (self.site, urllib.parse.urlencode(query_params)) + def create_permission_url(self, redirect_uri, scope=None, state=None): + query_params = {"client_id": self.api_key, "redirect_uri": redirect_uri} + # `scope` should be omitted if provided by app's TOML + if scope: + query_params["scope"] = ",".join(scope) + if state: + query_params["state"] = state + return "https://%s/admin/oauth/authorize?%s" % (self.url, urllib.parse.urlencode(query_params)) def request_token(self, params): if self.token: return self.token if not self.validate_params(params): - raise ValidationException('Invalid HMAC: Possibly malicious login') + raise ValidationException("Invalid HMAC: Possibly malicious login") - code = params['code'] + code = params["code"] - url = "%s/oauth/access_token?" % self.site - query_params = dict(client_id=self.api_key, client_secret=self.secret, code=code) - request = urllib.request.Request(url, urllib.parse.urlencode(query_params).encode('utf-8')) + url = "https://%s/admin/oauth/access_token?" % self.url + query_params = {"client_id": self.api_key, "client_secret": self.secret, "code": code} + request = urllib.request.Request(url, urllib.parse.urlencode(query_params).encode("utf-8")) response = urllib.request.urlopen(request) if response.code == 200: - self.token = json.loads(response.read().decode('utf-8'))['access_token'] + json_payload = json.loads(response.read().decode("utf-8")) + self.token = json_payload["access_token"] + self.access_scopes = json_payload["scope"] + return self.token else: raise Exception(response.msg) + @property + def api_version(self): + return self.version + @property def site(self): - return "%s://%s/admin" % (self.protocol, self.url) + return self.version.api_path("%s://%s" % (self.protocol, self.url)) @property def valid(self): return self.url is not None and self.token is not None + @property + def access_scopes(self): + return self._access_scopes + + @access_scopes.setter + def access_scopes(self, scopes): + if scopes is None or type(scopes) == ApiAccess: + self._access_scopes = scopes + else: + self._access_scopes = ApiAccess(scopes) + @classmethod def __prepare_url(cls, url): if not url or (url.strip() == ""): @@ -99,18 +131,18 @@ def validate_params(cls, params): # Avoid replay attacks by making sure the request # isn't more than a day old. one_day = 24 * 60 * 60 - if int(params.get('timestamp', 0)) < time.time() - one_day: + if int(params.get("timestamp", 0)) < time.time() - one_day: return False return cls.validate_hmac(params) @classmethod def validate_hmac(cls, params): - if 'hmac' not in params: + if "hmac" not in params: return False - hmac_calculated = cls.calculate_hmac(params).encode('utf-8') - hmac_to_verify = params['hmac'].encode('utf-8') + hmac_calculated = cls.calculate_hmac(params).encode("utf-8") + hmac_to_verify = params["hmac"].encode("utf-8") # Try to use compare_digest() to reduce vulnerability to timing attacks. # If it's not available, just fall back to regular string comparison. @@ -134,12 +166,20 @@ def __encoded_params_for_signature(cls, params): """ Sort and combine query parameters into a single string, excluding those that should be removed and joining with '&' """ + def encoded_pairs(params): for k, v in six.iteritems(params): - if k != 'hmac': - # escape delimiters to avoid tampering - k = str(k).replace("%", "%25").replace("=", "%3D") - v = str(v).replace("%", "%25") - yield '{0}={1}'.format(k, v).replace("&", "%26") + if k == "hmac": + continue + + if k.endswith("[]"): + # foo[]=1&foo[]=2 has to be transformed as foo=["1", "2"] note the whitespace after comma + k = k.rstrip("[]") + v = json.dumps(list(map(str, v))) + + # escape delimiters to avoid tampering + k = str(k).replace("%", "%25").replace("=", "%3D") + v = str(v).replace("%", "%25") + yield "{0}={1}".format(k, v).replace("&", "%26") return "&".join(sorted(encoded_pairs(params))) diff --git a/shopify/session_token.py b/shopify/session_token.py new file mode 100644 index 00000000..91a4970b --- /dev/null +++ b/shopify/session_token.py @@ -0,0 +1,84 @@ +import jwt +import re +import six +import sys + +from shopify.utils import shop_url + +if sys.version_info[0] < 3: # Backwards compatibility for python < v3.0.0 + from urlparse import urljoin +else: + from urllib.parse import urljoin + + +ALGORITHM = "HS256" +PREFIX = "Bearer " +REQUIRED_FIELDS = ["iss", "dest", "sub", "jti", "sid"] +LEEWAY_SECONDS = 10 + + +class SessionTokenError(Exception): + pass + + +class InvalidIssuerError(SessionTokenError): + pass + + +class MismatchedHostsError(SessionTokenError): + pass + + +class TokenAuthenticationError(SessionTokenError): + pass + + +def decode_from_header(authorization_header, api_key, secret): + session_token = _extract_session_token(authorization_header) + decoded_payload = _decode_session_token(session_token, api_key, secret) + _validate_issuer(decoded_payload) + + return decoded_payload + + +def _extract_session_token(authorization_header): + if not authorization_header.startswith(PREFIX): + raise TokenAuthenticationError("The HTTP_AUTHORIZATION_HEADER provided does not contain a Bearer token") + + return authorization_header[len(PREFIX) :] + + +def _decode_session_token(session_token, api_key, secret): + try: + return jwt.decode( + session_token, + secret, + audience=api_key, + algorithms=[ALGORITHM], + # AppBridge frequently sends future `nbf`, and it causes `ImmatureSignatureError`. + # Accept few seconds clock skew to avoid this error. + leeway=LEEWAY_SECONDS, + options={"require": REQUIRED_FIELDS}, + ) + except jwt.exceptions.PyJWTError as exception: + six.raise_from(SessionTokenError(str(exception)), exception) + + +def _validate_issuer(decoded_payload): + _validate_issuer_hostname(decoded_payload) + _validate_issuer_and_dest_match(decoded_payload) + + +def _validate_issuer_hostname(decoded_payload): + issuer_root = urljoin(decoded_payload["iss"], "/") + + if not shop_url.sanitize_shop_domain(issuer_root): + raise InvalidIssuerError("Invalid issuer") + + +def _validate_issuer_and_dest_match(decoded_payload): + issuer_root = urljoin(decoded_payload["iss"], "/") + dest_root = urljoin(decoded_payload["dest"], "/") + + if issuer_root != dest_root: + raise MismatchedHostsError("The issuer and destination do not match") diff --git a/shopify/utils/__init__.py b/shopify/utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/shopify/utils/shop_url.py b/shopify/utils/shop_url.py new file mode 100644 index 00000000..76e088a7 --- /dev/null +++ b/shopify/utils/shop_url.py @@ -0,0 +1,20 @@ +import re +import sys + +if sys.version_info[0] < 3: # Backwards compatibility for python < v3.0.0 + from urlparse import urlparse +else: + from urllib.parse import urlparse + +HOSTNAME_PATTERN = r"[a-z0-9][a-z0-9-]*[a-z0-9]" + + +def sanitize_shop_domain(shop_domain, myshopify_domain="myshopify.com"): + name = str(shop_domain or "").lower().strip() + if myshopify_domain not in name and "." not in name: + name += ".{domain}".format(domain=myshopify_domain) + name = re.sub(r"https?://", "", name) + + uri = urlparse("http://{hostname}".format(hostname=name)) + if re.match(r"{h}\.{d}$".format(h=HOSTNAME_PATTERN, d=re.escape(myshopify_domain)), uri.netloc): + return uri.netloc diff --git a/shopify/version.py b/shopify/version.py index 8418bbc5..dfb0b4e4 100644 --- a/shopify/version.py +++ b/shopify/version.py @@ -1 +1 @@ -VERSION = '2.2.0' +VERSION = "12.7.1" diff --git a/shopify/yamlobjects.py b/shopify/yamlobjects.py index d4e7cd3a..c7438c42 100644 --- a/shopify/yamlobjects.py +++ b/shopify/yamlobjects.py @@ -8,11 +8,12 @@ import yaml class YAMLHashWithIndifferentAccess(yaml.YAMLObject): - yaml_tag = '!map:ActiveSupport::HashWithIndifferentAccess' + yaml_tag = "!map:ActiveSupport::HashWithIndifferentAccess" yaml_loader = yaml.SafeLoader @classmethod def from_yaml(cls, loader, node): return loader.construct_mapping(node, cls) + except ImportError: pass diff --git a/test/access_scope_test.py b/test/access_scope_test.py new file mode 100644 index 00000000..e931263a --- /dev/null +++ b/test/access_scope_test.py @@ -0,0 +1,10 @@ +import shopify +from test.test_helper import TestCase + + +class AccessScopeTest(TestCase): + def test_find_should_return_all_access_scopes(self): + self.fake("oauth/access_scopes", body=self.load_fixture("access_scopes"), prefix="/admin") + scopes = shopify.AccessScope.find() + self.assertEqual(3, len(scopes)) + self.assertEqual("read_products", scopes[0].handle) diff --git a/test/api_access_test.py b/test/api_access_test.py new file mode 100644 index 00000000..21866a09 --- /dev/null +++ b/test/api_access_test.py @@ -0,0 +1,153 @@ +from shopify import ApiAccess, ApiAccessError +from test.test_helper import TestCase + + +class ApiAccessTest(TestCase): + def test_creating_scopes_from_a_string_works_with_a_comma_separated_list(self): + deserialized_read_products_write_orders = ApiAccess("read_products,write_orders") + serialized_read_products_write_orders = str(deserialized_read_products_write_orders) + expected_read_products_write_orders = ApiAccess(["read_products", "write_orders"]) + + self.assertEqual(expected_read_products_write_orders, ApiAccess(serialized_read_products_write_orders)) + + def test_creating_api_access_from_invalid_scopes_raises(self): + with self.assertRaises(ApiAccessError) as cm: + api_access = ApiAccess("bad_scope, read_orders,write_orders") + + self.assertEqual("'bad_scope' is not a valid access scope", str(cm.exception)) + + def test_returns_list_of_reduced_scopes(self): + api_access = ApiAccess("read_products, read_orders,write_orders") + expected_scopes = set(["read_products", "write_orders"]) + scopes = list(api_access) + + self.assertEqual(expected_scopes, set(scopes)) + + def test_write_is_the_same_access_as_read_write_on_the_same_resource(self): + read_write_orders = ApiAccess(["read_orders", "write_orders"]) + write_orders = ApiAccess("write_orders") + + self.assertEqual(write_orders, read_write_orders) + + def test_write_is_the_same_access_as_read_write_on_the_same_unauthenticated_resource(self): + unauthenticated_read_write_orders = ApiAccess(["unauthenticated_read_orders", "unauthenticated_write_orders"]) + unauthenticated_write_orders = ApiAccess("unauthenticated_write_orders") + + self.assertEqual(unauthenticated_write_orders, unauthenticated_read_write_orders) + + def test_read_is_not_the_same_as_read_write_on_the_same_resource(self): + read_orders = ApiAccess("read_orders") + read_write_orders = ApiAccess(["write_orders", "read_orders"]) + + self.assertNotEqual(read_write_orders, read_orders) + + def test_two_different_resources_are_not_equal(self): + read_orders = ApiAccess("read_orders") + read_products = ApiAccess("read_products") + + self.assertNotEqual(read_orders, read_products) + + def test_two_identical_scopes_are_equal(self): + read_orders = ApiAccess("read_orders") + read_orders_identical = ApiAccess("read_orders") + + self.assertEqual(read_orders, read_orders_identical) + + def test_unauthenticated_is_not_implied_by_authenticated_access(self): + unauthenticated_orders = ApiAccess("unauthenticated_read_orders") + authenticated_read_orders = ApiAccess("read_orders") + authenticated_write_orders = ApiAccess("write_orders") + + self.assertNotEqual(unauthenticated_orders, authenticated_read_orders) + self.assertNotEqual(unauthenticated_orders, authenticated_write_orders) + + def test_scopes_covers_is_truthy_for_same_scopes(self): + read_orders = ApiAccess("read_orders") + read_orders_identical = ApiAccess("read_orders") + + self.assertTrue(read_orders.covers(read_orders_identical)) + + def test_covers_is_falsy_for_different_scopes(self): + read_orders = ApiAccess("read_orders") + read_products = ApiAccess("read_products") + + self.assertFalse(read_orders.covers(read_products)) + + def test_covers_is_truthy_for_read_when_the_set_has_read_write(self): + write_products = ApiAccess("write_products") + read_products = ApiAccess("read_products") + + self.assertTrue(write_products.covers(read_products)) + + def test_covers_is_truthy_for_read_when_the_set_has_read_write_for_that_resource_and_others(self): + write_products_and_orders = ApiAccess(["write_products", "write_orders"]) + read_orders = ApiAccess("read_orders") + + self.assertTrue(write_products_and_orders.covers(read_orders)) + + def test_covers_is_truthy_for_write_when_the_set_has_read_write_for_that_resource_and_others(self): + write_products_and_orders = ApiAccess(["write_products", "write_orders"]) + write_orders = ApiAccess("write_orders") + + self.assertTrue(write_products_and_orders.covers(write_orders)) + + def test_covers_is_truthy_for_subset_of_scopes(self): + write_products_orders_customers = ApiAccess(["write_products", "write_orders", "write_customers"]) + write_orders_products = ApiAccess(["write_orders", "read_products"]) + + self.assertTrue(write_products_orders_customers.covers(write_orders_products)) + + def test_covers_is_falsy_for_sets_of_scopes_that_have_no_common_elements(self): + write_products_orders_customers = ApiAccess(["write_products", "write_orders", "write_customers"]) + write_images_read_content = ApiAccess(["write_images", "read_content"]) + + self.assertFalse(write_products_orders_customers.covers(write_images_read_content)) + + def test_covers_is_falsy_for_sets_of_scopes_that_have_only_some_common_access(self): + write_products_orders_customers = ApiAccess(["write_products", "write_orders", "write_customers"]) + write_products_read_content = ApiAccess(["write_products", "read_content"]) + + self.assertFalse(write_products_orders_customers.covers(write_products_read_content)) + + def test_duplicate_scopes_resolve_to_one_scope(self): + read_orders_duplicated = ApiAccess(["read_orders", "read_orders", "read_orders", "read_orders"]) + read_orders = ApiAccess("read_orders") + + self.assertEqual(read_orders, read_orders_duplicated) + + def test_to_s_outputs_scopes_as_a_comma_separated_list_without_implied_read_scopes(self): + serialized_read_products_write_orders = "read_products,write_orders" + read_products_write_orders = ApiAccess(["read_products", "read_orders", "write_orders"]) + + self.assertIn("read_products", str(read_products_write_orders)) + self.assertIn("write_orders", str(read_products_write_orders)) + + def test_to_a_outputs_scopes_as_an_array_of_strings_without_implied_read_scopes(self): + serialized_read_products_write_orders = ["write_orders", "read_products"] + read_products_write_orders = ApiAccess(["read_products", "read_orders", "write_orders"]) + + self.assertEqual(set(serialized_read_products_write_orders), set(list(read_products_write_orders))) + + def test_creating_scopes_removes_extra_whitespace_from_scope_name_and_blank_scope_names(self): + deserialized_read_products_write_orders = ApiAccess([" read_products", " ", "write_orders "]) + serialized_read_products_write_orders = str(deserialized_read_products_write_orders) + expected_read_products_write_orders = ApiAccess(["read_products", "write_orders"]) + + self.assertEqual(expected_read_products_write_orders, ApiAccess(serialized_read_products_write_orders)) + + def test_creating_scopes_from_a_string_works_with_a_comma_separated_list(self): + deserialized_read_products_write_orders = ApiAccess("read_products,write_orders") + serialized_read_products_write_orders = str(deserialized_read_products_write_orders) + expected_read_products_write_orders = ApiAccess(["read_products", "write_orders"]) + + self.assertEqual(expected_read_products_write_orders, ApiAccess(serialized_read_products_write_orders)) + + def test_using_to_s_from_one_scopes_to_construct_another_will_be_equal(self): + read_products_write_orders = ApiAccess(["read_products", "write_orders"]) + + self.assertEqual(read_products_write_orders, ApiAccess(str(read_products_write_orders))) + + def test_using_to_a_from_one_scopes_to_construct_another_will_be_equal(self): + read_products_write_orders = ApiAccess(["read_products", "write_orders"]) + + self.assertEqual(read_products_write_orders, ApiAccess(list(read_products_write_orders))) diff --git a/test/api_permission_test.py b/test/api_permission_test.py new file mode 100644 index 00000000..1e93ee74 --- /dev/null +++ b/test/api_permission_test.py @@ -0,0 +1,9 @@ +import shopify +from test.test_helper import TestCase + + +class ApiPermissionTest(TestCase): + def test_delete_api_permission(self): + self.fake("api_permissions/current", method="DELETE", code=200, body="{}") + + shopify.ApiPermission.delete() diff --git a/test/api_version_test.py b/test/api_version_test.py new file mode 100644 index 00000000..9dce8cb2 --- /dev/null +++ b/test/api_version_test.py @@ -0,0 +1,61 @@ +import shopify +from test.test_helper import TestCase + + +class ApiVersionTest(TestCase): + """ + Api Version Tests + """ + + def tearDown(self): + shopify.ApiVersion.clear_defined_versions() + shopify.ApiVersion.define_known_versions() + + def test_unstable_api_path_returns_correct_url(self): + self.assertEqual( + "https://fakeshop.myshopify.com/admin/api/unstable", + shopify.Unstable().api_path("https://fakeshop.myshopify.com"), + ) + + def test_coerce_to_version_returns_known_versions(self): + v1 = shopify.Unstable() + v2 = shopify.ApiVersion.define_version(shopify.Release("2019-01")) + + self.assertNotEqual(v1, None) + self.assertEqual(v1, shopify.ApiVersion.coerce_to_version("unstable")) + self.assertEqual(v2, shopify.ApiVersion.coerce_to_version("2019-01")) + + def test_coerce_to_version_raises_with_string_that_does_not_match_known_version(self): + with self.assertRaises(shopify.VersionNotFoundError): + shopify.ApiVersion.coerce_to_version("crazy-name") + + def test_coerce_to_version_creates_new_release_on_the_fly(self): + new_version = "2025-01" + coerced_version = shopify.ApiVersion.coerce_to_version(new_version) + + self.assertIsInstance(coerced_version, shopify.Release) + self.assertEqual(coerced_version.name, new_version) + self.assertEqual( + coerced_version.api_path("https://test.myshopify.com"), + f"https://test.myshopify.com/admin/api/{new_version}", + ) + + # Verify that the new version is not added to the known versions + self.assertNotIn(new_version, shopify.ApiVersion.versions) + + +class ReleaseTest(TestCase): + def test_raises_if_format_invalid(self): + with self.assertRaises(shopify.InvalidVersionError): + shopify.Release("crazy-name") + + def test_release_api_path_returns_correct_url(self): + self.assertEqual( + "https://fakeshop.myshopify.com/admin/api/2019-04", + shopify.Release("2019-04").api_path("https://fakeshop.myshopify.com"), + ) + + def test_two_release_versions_with_same_number_are_equal(self): + version1 = shopify.Release("2019-01") + version2 = shopify.Release("2019-01") + self.assertEqual(version1, version2) diff --git a/test/application_credit_test.py b/test/application_credit_test.py new file mode 100644 index 00000000..23656b60 --- /dev/null +++ b/test/application_credit_test.py @@ -0,0 +1,33 @@ +import shopify +import json +from test.test_helper import TestCase + + +class ApplicationCreditTest(TestCase): + def test_get_application_credit(self): + self.fake("application_credits/445365009", method="GET", body=self.load_fixture("application_credit"), code=200) + application_credit = shopify.ApplicationCredit.find(445365009) + self.assertEqual("5.00", application_credit.amount) + + def test_get_all_application_credits(self): + self.fake("application_credits", method="GET", body=self.load_fixture("application_credits"), code=200) + application_credits = shopify.ApplicationCredit.find() + self.assertEqual(1, len(application_credits)) + self.assertEqual(445365009, application_credits[0].id) + + def test_create_application_credit(self): + self.fake( + "application_credits", + method="POST", + body=self.load_fixture("application_credit"), + headers={"Content-type": "application/json"}, + code=201, + ) + + application_credit = shopify.ApplicationCredit.create( + {"description": "application credit for refund", "amount": 5.0} + ) + + expected_body = {"application_credit": {"description": "application credit for refund", "amount": 5.0}} + + self.assertEqual(expected_body, json.loads(self.http.request.data.decode("utf-8"))) diff --git a/test/article_test.py b/test/article_test.py index 58e29b02..5ee53bbe 100644 --- a/test/article_test.py +++ b/test/article_test.py @@ -1,65 +1,75 @@ import shopify from test.test_helper import TestCase -class ArticleTest(TestCase): +class ArticleTest(TestCase): def test_create_article(self): - self.fake("blogs/1008414260/articles", method='POST', body=self.load_fixture('article'), headers={'Content-type': 'application/json'}) - article = shopify.Article({'blog_id':1008414260}) + self.fake( + "blogs/1008414260/articles", + method="POST", + body=self.load_fixture("article"), + headers={"Content-type": "application/json"}, + ) + article = shopify.Article({"blog_id": 1008414260}) article.save() self.assertEqual("First Post", article.title) def test_get_article(self): - self.fake('articles/6242736', method='GET', body=self.load_fixture('article')) + self.fake("articles/6242736", method="GET", body=self.load_fixture("article")) article = shopify.Article.find(6242736) self.assertEqual("First Post", article.title) def test_update_article(self): - self.fake('articles/6242736', method='GET', body=self.load_fixture('article')) + self.fake("articles/6242736", method="GET", body=self.load_fixture("article")) article = shopify.Article.find(6242736) - self.fake('articles/6242736', method='PUT', body=self.load_fixture('article'), headers={'Content-type': 'application/json'}) + self.fake( + "articles/6242736", + method="PUT", + body=self.load_fixture("article"), + headers={"Content-type": "application/json"}, + ) article.save() def test_get_articles(self): - self.fake("articles", method='GET', body=self.load_fixture('articles')) + self.fake("articles", method="GET", body=self.load_fixture("articles")) articles = shopify.Article.find() self.assertEqual(3, len(articles)) def test_get_articles_namespaced(self): - self.fake("blogs/1008414260/articles", method='GET', body=self.load_fixture('articles')) + self.fake("blogs/1008414260/articles", method="GET", body=self.load_fixture("articles")) articles = shopify.Article.find(blog_id=1008414260) self.assertEqual(3, len(articles)) def test_get_article_namespaced(self): - self.fake("blogs/1008414260/articles/6242736", method='GET', body=self.load_fixture('article')) + self.fake("blogs/1008414260/articles/6242736", method="GET", body=self.load_fixture("article")) article = shopify.Article.find(6242736, blog_id=1008414260) self.assertEqual("First Post", article.title) def test_get_authors(self): - self.fake("articles/authors", method='GET', body=self.load_fixture('authors')) + self.fake("articles/authors", method="GET", body=self.load_fixture("authors")) authors = shopify.Article.authors() self.assertEqual("Shopify", authors[0]) self.assertEqual("development shop", authors[-1]) def test_get_authors_for_blog_id(self): - self.fake("blogs/1008414260/articles/authors", method='GET', body=self.load_fixture('authors')) + self.fake("blogs/1008414260/articles/authors", method="GET", body=self.load_fixture("authors")) authors = shopify.Article.authors(blog_id=1008414260) self.assertEqual(3, len(authors)) def test_get_tags(self): - self.fake("articles/tags", method='GET', body=self.load_fixture('tags')) + self.fake("articles/tags", method="GET", body=self.load_fixture("tags")) tags = shopify.Article.tags() self.assertEqual("consequuntur", tags[0]) self.assertEqual("repellendus", tags[-1]) def test_get_tags_for_blog_id(self): - self.fake("blogs/1008414260/articles/tags", method='GET', body=self.load_fixture('tags')) + self.fake("blogs/1008414260/articles/tags", method="GET", body=self.load_fixture("tags")) tags = shopify.Article.tags(blog_id=1008414260) self.assertEqual("consequuntur", tags[0]) self.assertEqual("repellendus", tags[-1]) def test_get_popular_tags(self): - self.fake("articles/tags.json?limit=1&popular=1", extension=False, method='GET', body=self.load_fixture('tags')) + self.fake("articles/tags.json?limit=1&popular=1", extension=False, method="GET", body=self.load_fixture("tags")) tags = shopify.Article.tags(popular=1, limit=1) self.assertEqual(3, len(tags)) diff --git a/test/asset_test.py b/test/asset_test.py index ca9c60bb..19606a4b 100644 --- a/test/asset_test.py +++ b/test/asset_test.py @@ -1,41 +1,90 @@ +import base64 + import shopify from test.test_helper import TestCase -class AssetTest(TestCase): +class AssetTest(TestCase): def test_get_assets(self): - self.fake("assets", method='GET', body=self.load_fixture('assets')) + self.fake("assets", method="GET", body=self.load_fixture("assets")) v = shopify.Asset.find() def test_get_asset(self): - self.fake("assets.json?asset%5Bkey%5D=templates%2Findex.liquid", extension=False, method='GET', body=self.load_fixture('asset')) - v = shopify.Asset.find('templates/index.liquid') + self.fake( + "assets.json?asset%5Bkey%5D=templates%2Findex.liquid", + extension=False, + method="GET", + body=self.load_fixture("asset"), + ) + v = shopify.Asset.find("templates/index.liquid") def test_update_asset(self): - self.fake("assets.json?asset%5Bkey%5D=templates%2Findex.liquid", extension=False, method='GET', body=self.load_fixture('asset')) - v = shopify.Asset.find('templates/index.liquid') + self.fake( + "assets.json?asset%5Bkey%5D=templates%2Findex.liquid", + extension=False, + method="GET", + body=self.load_fixture("asset"), + ) + v = shopify.Asset.find("templates/index.liquid") - self.fake("assets", method='PUT', body=self.load_fixture('asset'), headers={'Content-type': 'application/json'}) + self.fake("assets", method="PUT", body=self.load_fixture("asset"), headers={"Content-type": "application/json"}) v.save() def test_get_assets_namespaced(self): - self.fake("themes/1/assets", method='GET', body=self.load_fixture('assets')) - v = shopify.Asset.find(theme_id = 1) + self.fake("themes/1/assets", method="GET", body=self.load_fixture("assets")) + v = shopify.Asset.find(theme_id=1) def test_get_asset_namespaced(self): - self.fake("themes/1/assets.json?asset%5Bkey%5D=templates%2Findex.liquid&theme_id=1", extension=False, method='GET', body=self.load_fixture('asset')) - v = shopify.Asset.find('templates/index.liquid', theme_id=1) + self.fake( + "themes/1/assets.json?asset%5Bkey%5D=templates%2Findex.liquid&theme_id=1", + extension=False, + method="GET", + body=self.load_fixture("asset"), + ) + v = shopify.Asset.find("templates/index.liquid", theme_id=1) def test_update_asset_namespaced(self): - self.fake("themes/1/assets.json?asset%5Bkey%5D=templates%2Findex.liquid&theme_id=1", extension=False, method='GET', body=self.load_fixture('asset')) - v = shopify.Asset.find('templates/index.liquid', theme_id=1) + self.fake( + "themes/1/assets.json?asset%5Bkey%5D=templates%2Findex.liquid&theme_id=1", + extension=False, + method="GET", + body=self.load_fixture("asset"), + ) + v = shopify.Asset.find("templates/index.liquid", theme_id=1) - self.fake("themes/1/assets", method='PUT', body=self.load_fixture('asset'), headers={'Content-type': 'application/json'}) + self.fake( + "themes/1/assets", + method="PUT", + body=self.load_fixture("asset"), + headers={"Content-type": "application/json"}, + ) v.save() def test_delete_asset_namespaced(self): - self.fake("themes/1/assets.json?asset%5Bkey%5D=templates%2Findex.liquid&theme_id=1", extension=False, method='GET', body=self.load_fixture('asset')) - v = shopify.Asset.find('templates/index.liquid', theme_id=1) + self.fake( + "themes/1/assets.json?asset%5Bkey%5D=templates%2Findex.liquid&theme_id=1", + extension=False, + method="GET", + body=self.load_fixture("asset"), + ) + v = shopify.Asset.find("templates/index.liquid", theme_id=1) - self.fake("themes/1/assets.json?asset%5Bkey%5D=templates%2Findex.liquid", extension=False, method='DELETE', body="{}") + self.fake( + "themes/1/assets.json?asset%5Bkey%5D=templates%2Findex.liquid", extension=False, method="DELETE", body="{}" + ) v.destroy() + + def test_attach(self): + self.fake( + "themes/1/assets", + method="PUT", + body=self.load_fixture("asset"), + headers={"Content-type": "application/json"}, + ) + attachment = b"dGVzdCBiaW5hcnkgZGF0YTogAAE=" + key = "assets/test.jpeg" + theme_id = 1 + asset = shopify.Asset({"key": key, "theme_id": theme_id}) + asset.attach(attachment) + asset.save() + self.assertEqual(base64.b64encode(attachment).decode(), asset.attributes["attachment"]) diff --git a/test/balance_test.py b/test/balance_test.py new file mode 100644 index 00000000..2cb26b08 --- /dev/null +++ b/test/balance_test.py @@ -0,0 +1,11 @@ +import shopify +from test.test_helper import TestCase + + +class BalanceTest(TestCase): + prefix = "/admin/api/unstable/shopify_payments" + + def test_get_balance(self): + self.fake("balance", method="GET", prefix=self.prefix, body=self.load_fixture("balance")) + balance = shopify.Balance.find() + self.assertGreater(len(balance), 0) diff --git a/test/base_test.py b/test/base_test.py index 86ecafe5..5cc19a60 100644 --- a/test/base_test.py +++ b/test/base_test.py @@ -4,15 +4,18 @@ from mock import patch import threading -class BaseTest(TestCase): +class BaseTest(TestCase): @classmethod def setUpClass(self): - self.session1 = shopify.Session('shop1.myshopify.com', 'token1') - self.session2 = shopify.Session('shop2.myshopify.com', 'token2') + shopify.ApiVersion.define_known_versions() + shopify.ApiVersion.define_version(shopify.Release("2019-04")) + self.session1 = shopify.Session("shop1.myshopify.com", "unstable", "token1") + self.session2 = shopify.Session("shop2.myshopify.com", "2019-04", "token2") - def setUp(self): - super(BaseTest, self).setUp() + @classmethod + def tearDownClass(self): + shopify.ApiVersion.clear_defined_versions() def tearDown(self): shopify.ShopifyResource.clear_session() @@ -21,13 +24,21 @@ def test_activate_session_should_set_site_and_headers_for_given_session(self): shopify.ShopifyResource.activate_session(self.session1) self.assertIsNone(ActiveResource.site) - self.assertEqual('https://shop1.myshopify.com/admin', shopify.ShopifyResource.site) - self.assertEqual('https://shop1.myshopify.com/admin', shopify.Shop.site) + self.assertEqual("https://shop1.myshopify.com/admin/api/unstable", shopify.ShopifyResource.site) + self.assertEqual("https://shop1.myshopify.com/admin/api/unstable", shopify.Shop.site) + self.assertIsNone(ActiveResource.headers) + self.assertEqual("token1", shopify.ShopifyResource.headers["X-Shopify-Access-Token"]) + self.assertEqual("token1", shopify.Shop.headers["X-Shopify-Access-Token"]) + + def test_activate_session_should_set_site_given_version(self): + shopify.ShopifyResource.activate_session(self.session2) + + self.assertIsNone(ActiveResource.site) + self.assertEqual("https://shop2.myshopify.com/admin/api/2019-04", shopify.ShopifyResource.site) + self.assertEqual("https://shop2.myshopify.com/admin/api/2019-04", shopify.Shop.site) self.assertIsNone(ActiveResource.headers) - self.assertEqual('token1', shopify.ShopifyResource.headers['X-Shopify-Access-Token']) - self.assertEqual('token1', shopify.Shop.headers['X-Shopify-Access-Token']) - def test_clear_session_should_clear_site_and_headers_from_Base(self): + def test_clear_session_should_clear_site_and_headers_from_base(self): shopify.ShopifyResource.activate_session(self.session1) shopify.ShopifyResource.clear_session() @@ -36,50 +47,68 @@ def test_clear_session_should_clear_site_and_headers_from_Base(self): self.assertIsNone(shopify.Shop.site) self.assertIsNone(ActiveResource.headers) - self.assertFalse('X-Shopify-Access-Token' in shopify.ShopifyResource.headers) - self.assertFalse('X-Shopify-Access-Token' in shopify.Shop.headers) + self.assertFalse("X-Shopify-Access-Token" in shopify.ShopifyResource.headers) + self.assertFalse("X-Shopify-Access-Token" in shopify.Shop.headers) - def test_activate_session_with_one_session_then_clearing_and_activating_with_another_session_shoul_request_to_correct_shop(self): + def test_activate_session_with_one_session_then_clearing_and_activating_with_another_session_shoul_request_to_correct_shop( + self, + ): shopify.ShopifyResource.activate_session(self.session1) shopify.ShopifyResource.clear_session() shopify.ShopifyResource.activate_session(self.session2) self.assertIsNone(ActiveResource.site) - self.assertEqual('https://shop2.myshopify.com/admin', shopify.ShopifyResource.site) - self.assertEqual('https://shop2.myshopify.com/admin', shopify.Shop.site) + self.assertEqual("https://shop2.myshopify.com/admin/api/2019-04", shopify.ShopifyResource.site) + self.assertEqual("https://shop2.myshopify.com/admin/api/2019-04", shopify.Shop.site) self.assertIsNone(ActiveResource.headers) - self.assertEqual('token2', shopify.ShopifyResource.headers['X-Shopify-Access-Token']) - self.assertEqual('token2', shopify.Shop.headers['X-Shopify-Access-Token']) + self.assertEqual("token2", shopify.ShopifyResource.headers["X-Shopify-Access-Token"]) + self.assertEqual("token2", shopify.Shop.headers["X-Shopify-Access-Token"]) def test_delete_should_send_custom_headers_with_request(self): shopify.ShopifyResource.activate_session(self.session1) - org_headers=shopify.ShopifyResource.headers - shopify.ShopifyResource.set_headers({'X-Custom': 'abc'}) + org_headers = shopify.ShopifyResource.headers + shopify.ShopifyResource.set_headers({"X-Custom": "abc"}) - with patch('shopify.ShopifyResource.connection.delete') as mock: - url = shopify.ShopifyResource._custom_method_collection_url('1', {}) - shopify.ShopifyResource.delete('1') - mock.assert_called_with(url, {'X-Custom': 'abc'}) + with patch("shopify.ShopifyResource.connection.delete") as mock: + url = shopify.ShopifyResource._custom_method_collection_url("1", {}) + shopify.ShopifyResource.delete("1") + mock.assert_called_with(url, {"X-Custom": "abc"}) shopify.ShopifyResource.set_headers(org_headers) def test_headers_includes_user_agent(self): - self.assertTrue('User-Agent' in shopify.ShopifyResource.headers) - t = threading.Thread(target=lambda: self.assertTrue('User-Agent' in shopify.ShopifyResource.headers)) + self.assertTrue("User-Agent" in shopify.ShopifyResource.headers) + t = threading.Thread(target=lambda: self.assertTrue("User-Agent" in shopify.ShopifyResource.headers)) t.start() t.join() def test_headers_is_thread_safe(self): def testFunc(): - shopify.ShopifyResource.headers['X-Custom'] = 'abc' - self.assertTrue('X-Custom' in shopify.ShopifyResource.headers) + shopify.ShopifyResource.headers["X-Custom"] = "abc" + self.assertTrue("X-Custom" in shopify.ShopifyResource.headers) t1 = threading.Thread(target=testFunc) t1.start() t1.join() - t2 = threading.Thread(target=lambda: self.assertFalse('X-Custom' in shopify.ShopifyResource.headers)) + t2 = threading.Thread(target=lambda: self.assertFalse("X-Custom" in shopify.ShopifyResource.headers)) t2.start() t2.join() + + def test_setting_with_user_and_pass_strips_them(self): + shopify.ShopifyResource.clear_session() + self.fake( + "shop", + url="https://this-is-my-test-show.myshopify.com/admin/shop.json", + method="GET", + body=self.load_fixture("shop"), + headers={"Authorization": "Basic dXNlcjpwYXNz"}, + ) + API_KEY = "user" + PASSWORD = "pass" + shop_url = "https://%s:%s@this-is-my-test-show.myshopify.com/admin" % (API_KEY, PASSWORD) + shopify.ShopifyResource.set_site(shop_url) + res = shopify.Shop.current() + self.assertEqual("Apple Computers", res.name) diff --git a/test/blog_test.py b/test/blog_test.py index 728f2e18..e3a912f0 100644 --- a/test/blog_test.py +++ b/test/blog_test.py @@ -1,9 +1,15 @@ import shopify from test.test_helper import TestCase + class BlogTest(TestCase): - def test_blog_creation(self): - self.fake('blogs', method='POST', code=202, body=self.load_fixture('blog'), headers={'Content-type': 'application/json'}) - blog = shopify.Blog.create({'title': "Test Blog"}) + self.fake( + "blogs", + method="POST", + code=202, + body=self.load_fixture("blog"), + headers={"Content-type": "application/json"}, + ) + blog = shopify.Blog.create({"title": "Test Blog"}) self.assertEqual("Test Blog", blog.title) diff --git a/test/carrier_service_test.py b/test/carrier_service_test.py index fbd96180..a5aea4f6 100644 --- a/test/carrier_service_test.py +++ b/test/carrier_service_test.py @@ -1,15 +1,21 @@ import shopify from test.test_helper import TestCase + class CarrierServiceTest(TestCase): def test_create_new_carrier_service(self): - self.fake("carrier_services", method='POST', body=self.load_fixture('carrier_service'), headers={'Content-type': 'application/json'}) + self.fake( + "carrier_services", + method="POST", + body=self.load_fixture("carrier_service"), + headers={"Content-type": "application/json"}, + ) - carrier_service = shopify.CarrierService.create({'name': "Some Postal Service"}) + carrier_service = shopify.CarrierService.create({"name": "Some Postal Service"}) self.assertEqual("Some Postal Service", carrier_service.name) def test_get_carrier_service(self): - self.fake("carrier_services/123456", method='GET', body=self.load_fixture('carrier_service')) + self.fake("carrier_services/123456", method="GET", body=self.load_fixture("carrier_service")) carrier_service = shopify.CarrierService.find(123456) self.assertEqual("Some Postal Service", carrier_service.name) @@ -17,4 +23,4 @@ def test_get_carrier_service(self): def test_set_format_attribute(self): carrier_service = shopify.CarrierService() carrier_service.format = "json" - self.assertEqual("json", carrier_service.attributes['format']) + self.assertEqual("json", carrier_service.attributes["format"]) diff --git a/test/cart_test.py b/test/cart_test.py index 3391dc9a..67836330 100644 --- a/test/cart_test.py +++ b/test/cart_test.py @@ -1,13 +1,13 @@ import shopify from test.test_helper import TestCase + class CartTest(TestCase): - - def test_all_should_return_all_carts(self): - self.fake('carts') - carts = shopify.Cart.find() - self.assertEqual(2, len(carts)) - self.assertEqual(2, carts[0].id) - self.assertEqual("3eed8183d4281db6ea82ee2b8f23e9cc", carts[0].token) - self.assertEqual(1, len(carts[0].line_items)) - self.assertEqual('test', carts[0].line_items[0].title) + def test_all_should_return_all_carts(self): + self.fake("carts") + carts = shopify.Cart.find() + self.assertEqual(2, len(carts)) + self.assertEqual(2, carts[0].id) + self.assertEqual("3eed8183d4281db6ea82ee2b8f23e9cc", carts[0].token) + self.assertEqual(1, len(carts[0].line_items)) + self.assertEqual("test", carts[0].line_items[0].title) diff --git a/test/checkout_test.py b/test/checkout_test.py index 508a3d9f..5b21d560 100644 --- a/test/checkout_test.py +++ b/test/checkout_test.py @@ -1,12 +1,12 @@ import shopify from test.test_helper import TestCase + class CheckoutTest(TestCase): - - def test_all_should_return_all_checkouts(self): - self.fake('checkouts') - checkouts = shopify.Checkout.find() - self.assertEqual(1, len(checkouts)) - self.assertEqual(450789469, checkouts[0].id) - self.assertEqual("2a1ace52255252df566af0faaedfbfa7", checkouts[0].token) - self.assertEqual(2, len(checkouts[0].line_items)) + def test_all_should_return_all_checkouts(self): + self.fake("checkouts") + checkouts = shopify.Checkout.find() + self.assertEqual(1, len(checkouts)) + self.assertEqual(450789469, checkouts[0].id) + self.assertEqual("2a1ace52255252df566af0faaedfbfa7", checkouts[0].token) + self.assertEqual(2, len(checkouts[0].line_items)) diff --git a/test/collection_listing_test.py b/test/collection_listing_test.py new file mode 100644 index 00000000..4bea7fac --- /dev/null +++ b/test/collection_listing_test.py @@ -0,0 +1,44 @@ +import shopify +from test.test_helper import TestCase + + +class CollectionListingTest(TestCase): + def test_get_collection_listings(self): + self.fake("collection_listings", method="GET", code=200, body=self.load_fixture("collection_listings")) + + collection_listings = shopify.CollectionListing.find() + self.assertEqual(1, len(collection_listings)) + self.assertEqual(1, collection_listings[0].collection_id) + self.assertEqual("Home page", collection_listings[0].title) + + def test_get_collection_listing(self): + self.fake("collection_listings/1", method="GET", code=200, body=self.load_fixture("collection_listing")) + + collection_listing = shopify.CollectionListing.find(1) + + self.assertEqual(1, collection_listing.collection_id) + self.assertEqual("Home page", collection_listing.title) + + def test_reload_collection_listing(self): + self.fake("collection_listings/1", method="GET", code=200, body=self.load_fixture("collection_listing")) + + collection_listing = shopify.CollectionListing() + collection_listing.collection_id = 1 + collection_listing.reload() + + self.assertEqual(1, collection_listing.collection_id) + self.assertEqual("Home page", collection_listing.title) + + def test_get_collection_listing_product_ids(self): + self.fake( + "collection_listings/1/product_ids", + method="GET", + code=200, + body=self.load_fixture("collection_listing_product_ids"), + ) + + collection_listing = shopify.CollectionListing() + collection_listing.id = 1 + product_ids = collection_listing.product_ids() + + self.assertEqual([1, 2], product_ids) diff --git a/test/collection_publication_test.py b/test/collection_publication_test.py new file mode 100644 index 00000000..4bd6dc7d --- /dev/null +++ b/test/collection_publication_test.py @@ -0,0 +1,70 @@ +import shopify +import json +from test.test_helper import TestCase + + +class CollectionPublicationTest(TestCase): + def test_find_all_collection_publications(self): + self.fake( + "publications/55650051/collection_publications", + method="GET", + body=self.load_fixture("collection_publications"), + ) + collection_publications = shopify.CollectionPublication.find(publication_id=55650051) + + self.assertEqual(96062799894, collection_publications[0].id) + self.assertEqual(60941828118, collection_publications[0].collection_id) + + def test_find_collection_publication(self): + self.fake( + "publications/55650051/collection_publications/96062799894", + method="GET", + body=self.load_fixture("collection_publication"), + code=200, + ) + collection_publication = shopify.CollectionPublication.find(96062799894, publication_id=55650051) + + self.assertEqual(96062799894, collection_publication.id) + self.assertEqual(60941828118, collection_publication.collection_id) + + def test_create_collection_publication(self): + self.fake( + "publications/55650051/collection_publications", + method="POST", + headers={"Content-type": "application/json"}, + body=self.load_fixture("collection_publication"), + code=201, + ) + + collection_publication = shopify.CollectionPublication.create( + { + "publication_id": 55650051, + "published_at": "2018-01-29T14:06:08-05:00", + "published": True, + "collection_id": 60941828118, + } + ) + + expected_body = { + "collection_publication": { + "published_at": "2018-01-29T14:06:08-05:00", + "published": True, + "collection_id": 60941828118, + } + } + + self.assertEqual(expected_body, json.loads(self.http.request.data.decode("utf-8"))) + + def test_destroy_collection_publication(self): + self.fake( + "publications/55650051/collection_publications/96062799894", + method="GET", + body=self.load_fixture("collection_publication"), + code=200, + ) + collection_publication = shopify.CollectionPublication.find(96062799894, publication_id=55650051) + + self.fake("publications/55650051/collection_publications/96062799894", method="DELETE", body="{}", code=200) + collection_publication.destroy() + + self.assertEqual("DELETE", self.http.request.get_method()) diff --git a/test/currency_test.py b/test/currency_test.py new file mode 100644 index 00000000..2d3b47c9 --- /dev/null +++ b/test/currency_test.py @@ -0,0 +1,22 @@ +import shopify +from test.test_helper import TestCase + + +class CurrencyTest(TestCase): + def test_get_currencies(self): + self.fake("currencies", method="GET", code=200, body=self.load_fixture("currencies")) + + currencies = shopify.Currency.find() + self.assertEqual(4, len(currencies)) + self.assertEqual("AUD", currencies[0].currency) + self.assertEqual("2018-10-03T14:44:08-04:00", currencies[0].rate_updated_at) + self.assertEqual(True, currencies[0].enabled) + self.assertEqual("EUR", currencies[1].currency) + self.assertEqual("2018-10-03T14:44:08-04:00", currencies[1].rate_updated_at) + self.assertEqual(True, currencies[1].enabled) + self.assertEqual("GBP", currencies[2].currency) + self.assertEqual("2018-10-03T14:44:08-04:00", currencies[2].rate_updated_at) + self.assertEqual(True, currencies[2].enabled) + self.assertEqual("HKD", currencies[3].currency) + self.assertEqual("2018-10-03T14:44:08-04:00", currencies[3].rate_updated_at) + self.assertEqual(False, currencies[3].enabled) diff --git a/test/customer_saved_search_test.py b/test/customer_saved_search_test.py index 5c775af7..48873f15 100644 --- a/test/customer_saved_search_test.py +++ b/test/customer_saved_search_test.py @@ -1,23 +1,29 @@ import shopify from test.test_helper import TestCase + class CustomerSavedSearchTest(TestCase): - def setUp(self): super(CustomerSavedSearchTest, self).setUp() self.load_customer_saved_search() def test_get_customers_from_customer_saved_search(self): - self.fake('customer_saved_searches/8899730/customers', body=self.load_fixture('customer_saved_search_customers')) + self.fake( + "customer_saved_searches/8899730/customers", body=self.load_fixture("customer_saved_search_customers") + ) self.assertEqual(1, len(self.customer_saved_search.customers())) self.assertEqual(112223902, self.customer_saved_search.customers()[0].id) def test_get_customers_from_customer_saved_search_with_params(self): - self.fake('customer_saved_searches/8899730/customers.json?limit=1', extension=False, body=self.load_fixture('customer_saved_search_customers')) - customers = self.customer_saved_search.customers(limit = 1) + self.fake( + "customer_saved_searches/8899730/customers.json?limit=1", + extension=False, + body=self.load_fixture("customer_saved_search_customers"), + ) + customers = self.customer_saved_search.customers(limit=1) self.assertEqual(1, len(customers)) self.assertEqual(112223902, customers[0].id) def load_customer_saved_search(self): - self.fake('customer_saved_searches/8899730', body=self.load_fixture('customer_saved_search')) + self.fake("customer_saved_searches/8899730", body=self.load_fixture("customer_saved_search")) self.customer_saved_search = shopify.CustomerSavedSearch.find(8899730) diff --git a/test/customer_test.py b/test/customer_test.py index baf7a055..04f2a0c8 100644 --- a/test/customer_test.py +++ b/test/customer_test.py @@ -1,29 +1,76 @@ import shopify +import json from test.test_helper import TestCase + class CustomerTest(TestCase): + def setUp(self): + super(CustomerTest, self).setUp() + self.fake("customers/207119551", method="GET", body=self.load_fixture("customer")) + self.customer = shopify.Customer.find(207119551) def test_create_customer(self): - self.fake("customers", method='POST', body=self.load_fixture('customer'), headers={'Content-type': 'application/json'}) + self.fake( + "customers", method="POST", body=self.load_fixture("customer"), headers={"Content-type": "application/json"} + ) customer = shopify.Customer() - customer.first_name = 'Bob' - customer.last_name = 'Lastnameson' - customer.email = 'steve.lastnameson@example.com' + customer.first_name = "Bob" + customer.last_name = "Lastnameson" + customer.email = "steve.lastnameson@example.com" customer.verified_email = True customer.password = "newpass" customer.password_confirmation = "newpass" - self.assertEqual("newpass", customer.attributes['password']) + self.assertEqual("newpass", customer.attributes["password"]) customer.save() self.assertEqual("Bob", customer.first_name) - self.assertEqual("newpass", customer.attributes['password']) + self.assertEqual("newpass", customer.attributes["password"]) def test_get_customer(self): - self.fake('customers/207119551', method='GET', body=self.load_fixture('customer')) - customer = shopify.Customer.find(207119551) - self.assertEqual("Bob", customer.first_name) + self.assertEqual("Bob", self.customer.first_name) def test_search(self): - self.fake("customers/search.json?query=Bob+country%3AUnited+States", extension=False, body=self.load_fixture('customers_search')) + self.fake( + "customers/search.json?query=Bob+country%3AUnited+States", + extension=False, + body=self.load_fixture("customers_search"), + ) + + results = shopify.Customer.search(query="Bob country:United States") + self.assertEqual("Bob", results[0].first_name) - results = shopify.Customer.search(query='Bob country:United States') - self.assertEqual('Bob', results[0].first_name) + def test_send_invite_with_no_params(self): + customer_invite_fixture = self.load_fixture("customer_invite") + customer_invite = json.loads(customer_invite_fixture.decode("utf-8")) + self.fake( + "customers/207119551/send_invite", + method="POST", + body=customer_invite_fixture, + headers={"Content-type": "application/json"}, + ) + customer_invite_response = self.customer.send_invite() + self.assertEqual(json.loads('{"customer_invite": {}}'), json.loads(self.http.request.data.decode("utf-8"))) + self.assertIsInstance(customer_invite_response, shopify.CustomerInvite) + self.assertEqual(customer_invite["customer_invite"]["to"], customer_invite_response.to) + + def test_send_invite_with_params(self): + customer_invite_fixture = self.load_fixture("customer_invite") + customer_invite = json.loads(customer_invite_fixture.decode("utf-8")) + self.fake( + "customers/207119551/send_invite", + method="POST", + body=customer_invite_fixture, + headers={"Content-type": "application/json"}, + ) + customer_invite_response = self.customer.send_invite(shopify.CustomerInvite(customer_invite["customer_invite"])) + self.assertEqual(customer_invite, json.loads(self.http.request.data.decode("utf-8"))) + self.assertIsInstance(customer_invite_response, shopify.CustomerInvite) + self.assertEqual(customer_invite["customer_invite"]["to"], customer_invite_response.to) + + def test_get_customer_orders(self): + self.fake("customers/207119551", method="GET", body=self.load_fixture("customer")) + customer = shopify.Customer.find(207119551) + self.fake("customers/207119551/orders", method="GET", body=self.load_fixture("orders")) + orders = customer.orders() + self.assertIsInstance(orders[0], shopify.Order) + self.assertEqual(450789469, orders[0].id) + self.assertEqual(207119551, orders[0].customer.id) diff --git a/test/discount_code_creation_test.py b/test/discount_code_creation_test.py new file mode 100644 index 00000000..ce2e3156 --- /dev/null +++ b/test/discount_code_creation_test.py @@ -0,0 +1,17 @@ +from test.test_helper import TestCase +import shopify + + +class DiscountCodeCreationTest(TestCase): + def test_find_batch_job_discount_codes(self): + self.fake("price_rules/1213131", body=self.load_fixture("price_rule")) + price_rule = shopify.PriceRule.find(1213131) + + self.fake("price_rules/1213131/batch/989355119", body=self.load_fixture("discount_code_creation")) + batch = price_rule.find_batch(989355119) + + self.fake("price_rules/1213131/batch/989355119/discount_codes", body=self.load_fixture("batch_discount_codes")) + discount_codes = batch.discount_codes() + + self.assertEqual("foo", discount_codes[0].code) + self.assertEqual("bar", discount_codes[2].code) diff --git a/test/discount_code_test.py b/test/discount_code_test.py new file mode 100644 index 00000000..44ffd55e --- /dev/null +++ b/test/discount_code_test.py @@ -0,0 +1,31 @@ +import shopify +import json +from test.test_helper import TestCase + + +class DiscountCodeTest(TestCase): + def setUp(self): + super(DiscountCodeTest, self).setUp() + self.fake("price_rules/1213131/discount_codes/34", method="GET", body=self.load_fixture("discount_code")) + self.discount_code = shopify.DiscountCode.find(34, price_rule_id=1213131) + + def test_find_a_specific_discount_code(self): + discount_code = shopify.DiscountCode.find(34, price_rule_id=1213131) + self.assertEqual("25OFF", discount_code.code) + + def test_update_a_specific_discount_code(self): + self.discount_code.code = "BOGO" + self.fake( + "price_rules/1213131/discount_codes/34", + method="PUT", + code=200, + body=self.load_fixture("discount_code"), + headers={"Content-type": "application/json"}, + ) + self.discount_code.save() + self.assertEqual("BOGO", json.loads(self.http.request.data.decode("utf-8"))["discount_code"]["code"]) + + def test_delete_a_specific_discount_code(self): + self.fake("price_rules/1213131/discount_codes/34", method="DELETE", body="destroyed") + self.discount_code.destroy() + self.assertEqual("DELETE", self.http.request.get_method()) diff --git a/test/discount_test.py b/test/discount_test.py deleted file mode 100644 index 063655c7..00000000 --- a/test/discount_test.py +++ /dev/null @@ -1,45 +0,0 @@ -import shopify -from test.test_helper import TestCase - - -class DiscountTest(TestCase): - - def test_discount_creation(self): - self.fake('discounts', - method='POST', - code=202, - body=self.load_fixture('discount'), - headers={'Content-type': 'application/json'}) - discount = shopify.Discount.create({ - "discount_type": "shipping", - "code": "quidagis?", - "starts_at": "2015-08-23T00:00:00-04:00", - "ends_at": "2015-08-27T23:59:59-04:00", - "usage_limit": 20 - }) - self.assertEqual("shipping", discount.discount_type) - self.assertEqual("quidagis?", discount.code) - - def test_fetch_discounts(self): - self.fake('discounts', - method='GET', - code=200, - body=self.load_fixture('discounts')) - discounts = shopify.Discount.find() - self.assertEqual(2, len(discounts)) - - def test_disable_discount(self): - self.fake('discounts/992807812', - method='GET', - code=200, - body=self.load_fixture('discount')) - self.fake('discounts/992807812/disable', - method='POST', - code=200, - body=self.load_fixture('discount_disabled'), - headers={'Content-length': '0', - 'Content-type': 'application/json'}) - discount = shopify.Discount.find(992807812) - self.assertEqual("enabled", discount.status) - discount.disable() - self.assertEqual("disabled", discount.status) diff --git a/test/disputes_test.py b/test/disputes_test.py new file mode 100644 index 00000000..71fd01d0 --- /dev/null +++ b/test/disputes_test.py @@ -0,0 +1,16 @@ +import shopify +from test.test_helper import TestCase + + +class DisputeTest(TestCase): + prefix = "/admin/api/unstable/shopify_payments" + + def test_get_dispute(self): + self.fake("disputes", method="GET", prefix=self.prefix, body=self.load_fixture("disputes")) + disputes = shopify.Disputes.find() + self.assertGreater(len(disputes), 0) + + def test_get_one_dispute(self): + self.fake("disputes/1052608616", method="GET", prefix=self.prefix, body=self.load_fixture("dispute")) + disputes = shopify.Disputes.find(1052608616) + self.assertEqual("won", disputes.status) diff --git a/test/draft_order_test.py b/test/draft_order_test.py index 15adbfe8..12a359bd 100644 --- a/test/draft_order_test.py +++ b/test/draft_order_test.py @@ -6,97 +6,145 @@ class DraftOrderTest(TestCase): def setUp(self): super(DraftOrderTest, self).setUp() - self.fake('draft_orders/517119332', body=self.load_fixture('draft_order')) + self.fake("draft_orders/517119332", body=self.load_fixture("draft_order")) self.draft_order = shopify.DraftOrder.find(517119332) def test_get_draft_order(self): - self.fake('draft_orders/517119332', method='GET', status=200, body=self.load_fixture('draft_order')) + self.fake("draft_orders/517119332", method="GET", code=200, body=self.load_fixture("draft_order")) draft_order = shopify.DraftOrder.find(517119332) self.assertEqual(517119332, draft_order.id) def test_get_all_draft_orders(self): - self.fake('draft_orders', method='GET', status=200, body=self.load_fixture('draft_orders')) + self.fake("draft_orders", method="GET", code=200, body=self.load_fixture("draft_orders")) draft_orders = shopify.DraftOrder.find() self.assertEqual(1, len(draft_orders)) self.assertEqual(517119332, draft_orders[0].id) def test_get_count_draft_orders(self): - self.fake('draft_orders/count', method='GET', status=200, body='{"count": 16}') + self.fake("draft_orders/count", method="GET", code=200, body='{"count": 16}') draft_orders_count = shopify.DraftOrder.count() self.assertEqual(16, draft_orders_count) def test_create_draft_order(self): - self.fake('draft_orders', method='POST', status=201, body=self.load_fixture('draft_order'), headers={'Content-type': 'application/json'}) - draft_order = shopify.DraftOrder.create({"line_items": [{ "quantity": 1, "variant_id": 39072856 }]}) - self.assertEqual(json.loads('{"draft_order": {"line_items": [{"quantity": 1, "variant_id": 39072856}]}}'), json.loads(self.http.request.data.decode("utf-8"))) + self.fake( + "draft_orders", + method="POST", + code=201, + body=self.load_fixture("draft_order"), + headers={"Content-type": "application/json"}, + ) + draft_order = shopify.DraftOrder.create({"line_items": [{"quantity": 1, "variant_id": 39072856}]}) + self.assertEqual( + json.loads('{"draft_order": {"line_items": [{"quantity": 1, "variant_id": 39072856}]}}'), + json.loads(self.http.request.data.decode("utf-8")), + ) def test_create_draft_order_202(self): - self.fake('draft_orders', method='POST', status=202, body=self.load_fixture('draft_order'), headers={'Content-type': 'application/json'}) - draft_order = shopify.DraftOrder.create({"line_items": [{ "quantity": 1, "variant_id": 39072856 }]}) + self.fake( + "draft_orders", + method="POST", + code=202, + body=self.load_fixture("draft_order"), + headers={"Content-type": "application/json"}, + ) + draft_order = shopify.DraftOrder.create({"line_items": [{"quantity": 1, "variant_id": 39072856}]}) self.assertEqual(39072856, draft_order.line_items[0].variant_id) def test_update_draft_order(self): - self.draft_order.note = 'Test new note' - self.fake('draft_orders/517119332', method='PUT', status=200, body=self.load_fixture('draft_order'), headers={'Content-type': 'application/json'}) + self.draft_order.note = "Test new note" + self.fake( + "draft_orders/517119332", + method="PUT", + code=200, + body=self.load_fixture("draft_order"), + headers={"Content-type": "application/json"}, + ) self.draft_order.save() - self.assertEqual('Test new note', json.loads(self.http.request.data.decode("utf-8"))['draft_order']['note']) + self.assertEqual("Test new note", json.loads(self.http.request.data.decode("utf-8"))["draft_order"]["note"]) def test_send_invoice_with_no_params(self): - draft_order_invoice_fixture = self.load_fixture('draft_order_invoice') + draft_order_invoice_fixture = self.load_fixture("draft_order_invoice") draft_order_invoice = json.loads(draft_order_invoice_fixture.decode("utf-8")) - self.fake('draft_orders/517119332/send_invoice', method='POST', body=draft_order_invoice_fixture, headers={'Content-type': 'application/json'}) + self.fake( + "draft_orders/517119332/send_invoice", + method="POST", + body=draft_order_invoice_fixture, + headers={"Content-type": "application/json"}, + ) draft_order_invoice_response = self.draft_order.send_invoice() self.assertEqual(json.loads('{"draft_order_invoice": {}}'), json.loads(self.http.request.data.decode("utf-8"))) self.assertIsInstance(draft_order_invoice_response, shopify.DraftOrderInvoice) - self.assertEqual(draft_order_invoice['draft_order_invoice']['to'], draft_order_invoice_response.to) + self.assertEqual(draft_order_invoice["draft_order_invoice"]["to"], draft_order_invoice_response.to) def test_send_invoice_with_params(self): - draft_order_invoice_fixture = self.load_fixture('draft_order_invoice') + draft_order_invoice_fixture = self.load_fixture("draft_order_invoice") draft_order_invoice = json.loads(draft_order_invoice_fixture.decode("utf-8")) - self.fake('draft_orders/517119332/send_invoice', method='POST', body=draft_order_invoice_fixture, headers={'Content-type': 'application/json'}) - draft_order_invoice_response = self.draft_order.send_invoice(shopify.DraftOrderInvoice(draft_order_invoice['draft_order_invoice'])) + self.fake( + "draft_orders/517119332/send_invoice", + method="POST", + body=draft_order_invoice_fixture, + headers={"Content-type": "application/json"}, + ) + draft_order_invoice_response = self.draft_order.send_invoice( + shopify.DraftOrderInvoice(draft_order_invoice["draft_order_invoice"]) + ) self.assertEqual(draft_order_invoice, json.loads(self.http.request.data.decode("utf-8"))) self.assertIsInstance(draft_order_invoice_response, shopify.DraftOrderInvoice) - self.assertEqual(draft_order_invoice['draft_order_invoice']['to'], draft_order_invoice_response.to) + self.assertEqual(draft_order_invoice["draft_order_invoice"]["to"], draft_order_invoice_response.to) def test_delete_draft_order(self): - self.fake('draft_orders/517119332', method='DELETE', body='destroyed') + self.fake("draft_orders/517119332", method="DELETE", body="destroyed") self.draft_order.destroy() - self.assertEqual('DELETE', self.http.request.get_method()) + self.assertEqual("DELETE", self.http.request.get_method()) def test_add_metafields_to_draft_order(self): - self.fake('draft_orders/517119332/metafields', method='POST', status=201, body=self.load_fixture('metafield'), headers={'Content-type': 'application/json'}) - field = self.draft_order.add_metafield(shopify.Metafield({'namespace': 'contact', 'key': 'email', 'value': '123@example.com', 'value_type': 'string'})) - self.assertEqual(json.loads('{"metafield":{"namespace":"contact","key":"email","value":"123@example.com","value_type":"string"}}'), json.loads(self.http.request.data.decode("utf-8"))) - self.assertFalse (field.is_new()) - self.assertEqual('contact', field.namespace) - self.assertEqual('email', field.key) - self.assertEqual('123@example.com', field.value) + self.fake( + "draft_orders/517119332/metafields", + method="POST", + code=201, + body=self.load_fixture("metafield"), + headers={"Content-type": "application/json"}, + ) + field = self.draft_order.add_metafield( + shopify.Metafield( + {"namespace": "contact", "key": "email", "value": "123@example.com", "value_type": "string"} + ) + ) + self.assertEqual( + json.loads( + '{"metafield":{"namespace":"contact","key":"email","value":"123@example.com","value_type":"string"}}' + ), + json.loads(self.http.request.data.decode("utf-8")), + ) + self.assertFalse(field.is_new()) + self.assertEqual("contact", field.namespace) + self.assertEqual("email", field.key) + self.assertEqual("123@example.com", field.value) def test_get_metafields_for_draft_order(self): - self.fake('draft_orders/517119332/metafields', body=self.load_fixture('metafields')) + self.fake("draft_orders/517119332/metafields", body=self.load_fixture("metafields")) metafields = self.draft_order.metafields() self.assertEqual(2, len(metafields)) self.assertIsInstance(metafields[0], shopify.Metafield) self.assertIsInstance(metafields[1], shopify.Metafield) def test_complete_draft_order_with_no_params(self): - completed_fixture = self.load_fixture('draft_order_completed') - completed_draft = json.loads(completed_fixture.decode("utf-8"))['draft_order'] - headers={'Content-type': 'application/json', 'Content-length': '0'} - self.fake('draft_orders/517119332/complete', method='PUT', body=completed_fixture, headers=headers) + completed_fixture = self.load_fixture("draft_order_completed") + completed_draft = json.loads(completed_fixture.decode("utf-8"))["draft_order"] + headers = {"Content-type": "application/json", "Content-length": "0"} + self.fake("draft_orders/517119332/complete", method="PUT", body=completed_fixture, headers=headers) self.draft_order.complete() - self.assertEqual(completed_draft['status'], self.draft_order.status) - self.assertEqual(completed_draft['order_id'], self.draft_order.order_id) + self.assertEqual(completed_draft["status"], self.draft_order.status) + self.assertEqual(completed_draft["order_id"], self.draft_order.order_id) self.assertIsNotNone(self.draft_order.completed_at) def test_complete_draft_order_with_params(self): - completed_fixture = self.load_fixture('draft_order_completed') - completed_draft = json.loads(completed_fixture.decode("utf-8"))['draft_order'] - headers = {'Content-type': 'application/json', 'Content-length': '0'} - url = 'draft_orders/517119332/complete.json?payment_pending=true' - self.fake(url, extension=False, method='PUT', body=completed_fixture, headers=headers) - self.draft_order.complete({'payment_pending': True}) - self.assertEqual(completed_draft['status'], self.draft_order.status) - self.assertEqual(completed_draft['order_id'], self.draft_order.order_id) + completed_fixture = self.load_fixture("draft_order_completed") + completed_draft = json.loads(completed_fixture.decode("utf-8"))["draft_order"] + headers = {"Content-type": "application/json", "Content-length": "0"} + url = "draft_orders/517119332/complete.json?payment_pending=true" + self.fake(url, extension=False, method="PUT", body=completed_fixture, headers=headers) + self.draft_order.complete({"payment_pending": True}) + self.assertEqual(completed_draft["status"], self.draft_order.status) + self.assertEqual(completed_draft["order_id"], self.draft_order.order_id) self.assertIsNotNone(self.draft_order.completed_at) diff --git a/test/event_test.py b/test/event_test.py new file mode 100644 index 00000000..cbc28802 --- /dev/null +++ b/test/event_test.py @@ -0,0 +1,12 @@ +import shopify +from test.test_helper import TestCase + + +class EventTest(TestCase): + def test_prefix_uses_resource(self): + prefix = shopify.Event._prefix(options={"resource": "orders", "resource_id": 42}) + self.assertEqual("https://this-is-my-test-show.myshopify.com/admin/api/unstable/orders/42", prefix) + + def test_prefix_doesnt_need_resource(self): + prefix = shopify.Event._prefix() + self.assertEqual("https://this-is-my-test-show.myshopify.com/admin/api/unstable", prefix) diff --git a/test/fixtures/access_scopes.json b/test/fixtures/access_scopes.json new file mode 100644 index 00000000..6f4fb0c2 --- /dev/null +++ b/test/fixtures/access_scopes.json @@ -0,0 +1,13 @@ +{ + "access_scopes": [ + { + "handle": "read_products" + }, + { + "handle": "write_orders" + }, + { + "handle": "read_orders" + } + ] +} diff --git a/test/fixtures/application_credit.json b/test/fixtures/application_credit.json new file mode 100644 index 00000000..4d6c3de0 --- /dev/null +++ b/test/fixtures/application_credit.json @@ -0,0 +1,8 @@ +{ + "application_credit": { + "id": 445365009, + "amount": "5.00", + "description": "credit for application refund", + "test": null + } +} diff --git a/test/fixtures/application_credits.json b/test/fixtures/application_credits.json new file mode 100644 index 00000000..b487a523 --- /dev/null +++ b/test/fixtures/application_credits.json @@ -0,0 +1,10 @@ +{ + "application_credits": [ + { + "id": 445365009, + "amount": "5.00", + "description": "credit for application refund", + "test": null + } + ] +} diff --git a/test/fixtures/article.json b/test/fixtures/article.json index 65ac8c6d..9910f0eb 100644 --- a/test/fixtures/article.json +++ b/test/fixtures/article.json @@ -12,4 +12,4 @@ "user_id": null, "tags": "consequuntur, cupiditate, repellendus" } -} \ No newline at end of file +} diff --git a/test/fixtures/articles.json b/test/fixtures/articles.json index df83a9cb..5954b09f 100644 --- a/test/fixtures/articles.json +++ b/test/fixtures/articles.json @@ -36,4 +36,4 @@ "user_id": 2221540, "tags": "" }] -} \ No newline at end of file +} diff --git a/test/fixtures/balance.json b/test/fixtures/balance.json new file mode 100644 index 00000000..851a2d76 --- /dev/null +++ b/test/fixtures/balance.json @@ -0,0 +1,8 @@ +{ + "balance": [ + { + "currency": "USD", + "amount": "53.99" + } + ] +} diff --git a/test/fixtures/batch_discount_codes.json b/test/fixtures/batch_discount_codes.json new file mode 100644 index 00000000..f63d29db --- /dev/null +++ b/test/fixtures/batch_discount_codes.json @@ -0,0 +1,19 @@ +{ + "discount_codes": [ + { + "id": null, + "code": "foo", + "errors": {} + }, + { + "id": null, + "code": "", + "errors": {} + }, + { + "id": null, + "code": "bar", + "errors": {} + } + ] +} diff --git a/test/fixtures/blog.json b/test/fixtures/blog.json index df94412c..2c92f6d7 100644 --- a/test/fixtures/blog.json +++ b/test/fixtures/blog.json @@ -10,4 +10,4 @@ "feedburner": null, "commentable": "no" } -} \ No newline at end of file +} diff --git a/test/fixtures/blogs.json b/test/fixtures/blogs.json index 3f779b25..0749df28 100644 --- a/test/fixtures/blogs.json +++ b/test/fixtures/blogs.json @@ -10,4 +10,4 @@ "feedburner": null, "commentable": "no" }] -} \ No newline at end of file +} diff --git a/test/fixtures/carts.json b/test/fixtures/carts.json index 64d51246..4238684f 100644 --- a/test/fixtures/carts.json +++ b/test/fixtures/carts.json @@ -5,7 +5,7 @@ "note": null, "token": "3eed8183d4281db6ea82ee2b8f23e9cc", "updated_at": "2012-02-13T14:39:37-05:00", - "line_items": + "line_items": [ { "id": 1, @@ -40,4 +40,4 @@ ] } ] -} \ No newline at end of file +} diff --git a/test/fixtures/collection_listing.json b/test/fixtures/collection_listing.json new file mode 100644 index 00000000..00c72093 --- /dev/null +++ b/test/fixtures/collection_listing.json @@ -0,0 +1,11 @@ +{ + "collection_id": 1, + "updated_at": "2017-01-09T13:59:09-05:00", + "body_html": null, + "default_product_image": null, + "handle": "frontpage", + "image": null, + "title": "Home page", + "sort_order": "alpha-asc", + "published_at": "2017-01-09T13:59:09-05:00" +} diff --git a/test/fixtures/collection_listing_product_ids.json b/test/fixtures/collection_listing_product_ids.json new file mode 100644 index 00000000..192c2445 --- /dev/null +++ b/test/fixtures/collection_listing_product_ids.json @@ -0,0 +1,4 @@ +[ + 1, + 2 +] diff --git a/test/fixtures/collection_listings.json b/test/fixtures/collection_listings.json new file mode 100644 index 00000000..be841820 --- /dev/null +++ b/test/fixtures/collection_listings.json @@ -0,0 +1,13 @@ +[ + { + "collection_id": 1, + "updated_at": "2017-01-09T13:59:09-05:00", + "body_html": null, + "default_product_image": null, + "handle": "frontpage", + "image": null, + "title": "Home page", + "sort_order": "alpha-asc", + "published_at": "2017-01-09T13:59:09-05:00" + } +] diff --git a/test/fixtures/collection_publication.json b/test/fixtures/collection_publication.json new file mode 100644 index 00000000..e284ea53 --- /dev/null +++ b/test/fixtures/collection_publication.json @@ -0,0 +1,11 @@ +{ + "collection_publication": { + "id": 96062799894, + "publication_id": 55650051, + "published_at": "2018-09-05T17:22:31-04:00", + "published": true, + "created_at": "2018-09-05T17:22:31-04:00", + "updated_at": "2018-09-14T14:31:19-04:00", + "collection_id": 60941828118 + } +} diff --git a/test/fixtures/collection_publications.json b/test/fixtures/collection_publications.json new file mode 100644 index 00000000..93ccec46 --- /dev/null +++ b/test/fixtures/collection_publications.json @@ -0,0 +1,13 @@ +{ + "collection_publications": [ + { + "id": 96062799894, + "publication_id": 55650051, + "published_at": "2018-09-05T17:22:31-04:00", + "published": true, + "created_at": "2018-09-05T17:22:31-04:00", + "updated_at": "2018-09-14T14:31:19-04:00", + "collection_id": 60941828118 + } + ] +} diff --git a/test/fixtures/currencies.json b/test/fixtures/currencies.json new file mode 100644 index 00000000..29da014e --- /dev/null +++ b/test/fixtures/currencies.json @@ -0,0 +1,24 @@ +{ + "currencies": [ + { + "currency": "AUD", + "rate_updated_at": "2018-10-03T14:44:08-04:00", + "enabled": true + }, + { + "currency": "EUR", + "rate_updated_at": "2018-10-03T14:44:08-04:00", + "enabled": true + }, + { + "currency": "GBP", + "rate_updated_at": "2018-10-03T14:44:08-04:00", + "enabled": true + }, + { + "currency": "HKD", + "rate_updated_at": "2018-10-03T14:44:08-04:00", + "enabled": false + } + ] +} diff --git a/test/fixtures/customer_invite.json b/test/fixtures/customer_invite.json new file mode 100644 index 00000000..4f8f947c --- /dev/null +++ b/test/fixtures/customer_invite.json @@ -0,0 +1,9 @@ +{ + "customer_invite": { + "to": "paul.norman@example.com", + "from": "steve@apple.com", + "subject": "Welcome to my new store!", + "custom_message": "This is a test custom message.", + "bcc": [ ] + } +} diff --git a/test/fixtures/discount.json b/test/fixtures/discount.json deleted file mode 100644 index e7b587be..00000000 --- a/test/fixtures/discount.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "discount": { - "id": 992807812, - "code": "quidagis?", - "value": "9999999.00", - "ends_at": "2015-08-27T23:59:59-04:00", - "starts_at": "2015-08-23T00:00:00-04:00", - "status": "enabled", - "usage_limit": 20, - "minimum_order_amount": "0.00", - "applies_to_id": null, - "applies_once": false, - "discount_type": "shipping", - "applies_to_resource": null, - "times_used": 0 - } -} diff --git a/test/fixtures/discount_code.json b/test/fixtures/discount_code.json new file mode 100644 index 00000000..4f3da0f1 --- /dev/null +++ b/test/fixtures/discount_code.json @@ -0,0 +1,9 @@ +{ + "discount_code": { + "code": "25OFF", + "id": 34, + "usage_count": 3, + "created_at": "2016-09-11T09:00:00-04:00", + "updated_at": "2016-09-11T09:30:00-04:00" + } +} diff --git a/test/fixtures/discount_code_creation.json b/test/fixtures/discount_code_creation.json new file mode 100644 index 00000000..27f3a973 --- /dev/null +++ b/test/fixtures/discount_code_creation.json @@ -0,0 +1,14 @@ +{ + "discount_code_creation": { + "id": 989355119, + "price_rule_id": 1213131, + "started_at": null, + "completed_at": null, + "created_at": "2018-07-05T13:04:29-04:00", + "updated_at": "2018-07-05T13:04:29-04:00", + "status": "queued", + "codes_count": 3, + "imported_count": 0, + "failed_count": 0 + } +} diff --git a/test/fixtures/discount_codes.json b/test/fixtures/discount_codes.json new file mode 100644 index 00000000..453e384c --- /dev/null +++ b/test/fixtures/discount_codes.json @@ -0,0 +1,11 @@ +{ + "discount_codes": [ + { + "code": "25OFF", + "id": 34, + "usage_count": 3, + "created_at": "2016-09-11T09:00:00-04:00", + "updated_at": "2016-09-11T09:30:00-04:00" + } + ] +} diff --git a/test/fixtures/discounts.json b/test/fixtures/discounts.json deleted file mode 100644 index b796fdb5..00000000 --- a/test/fixtures/discounts.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "discounts": [ - { - "id": 680866, - "code": "TENOFF", - "value": "10.0", - "ends_at": null, - "starts_at": null, - "status": "enabled", - "usage_limit": null, - "minimum_order_amount": "0.00", - "applies_to_id": null, - "applies_once": false, - "discount_type": "percentage", - "applies_to_resource": null, - "times_used": 1 - }, - { - "id": 949676421, - "code": "xyz", - "value": "10.00", - "ends_at": null, - "starts_at": null, - "status": "disabled", - "usage_limit": null, - "minimum_order_amount": "0.00", - "applies_to_id": null, - "applies_once": false, - "discount_type": "fixed_amount", - "applies_to_resource": null, - "times_used": 0 - } - ] -} diff --git a/test/fixtures/dispute.json b/test/fixtures/dispute.json new file mode 100644 index 00000000..e88489a8 --- /dev/null +++ b/test/fixtures/dispute.json @@ -0,0 +1,16 @@ +{ + "dispute": { + "id": 1052608616, + "order_id": null, + "type": "chargeback", + "amount": "100.00", + "currency": "USD", + "reason": "fraudulent", + "network_reason_code": "4827", + "status": "won", + "evidence_due_by": "2013-07-03T19:00:00-04:00", + "evidence_sent_on": "2013-07-04T07:00:00-04:00", + "finalized_on": null, + "initiated_at": "2013-05-03T20:00:00-04:00" + } +} diff --git a/test/fixtures/disputes.json b/test/fixtures/disputes.json new file mode 100644 index 00000000..867c7519 --- /dev/null +++ b/test/fixtures/disputes.json @@ -0,0 +1,102 @@ +{ + "disputes": [ + { + "id": 1052608616, + "order_id": null, + "type": "chargeback", + "amount": "100.00", + "currency": "USD", + "reason": "fraudulent", + "network_reason_code": "4827", + "status": "won", + "evidence_due_by": "2013-07-03T19:00:00-04:00", + "evidence_sent_on": "2013-07-04T07:00:00-04:00", + "finalized_on": null, + "initiated_at": "2013-05-03T20:00:00-04:00" + }, + { + "id": 815713555, + "order_id": 625362839, + "type": "chargeback", + "amount": "11.50", + "currency": "USD", + "reason": "credit_not_processed", + "network_reason_code": "4827", + "status": "needs_response", + "evidence_due_by": "2020-11-17T19:00:00-05:00", + "evidence_sent_on": null, + "finalized_on": null, + "initiated_at": "2013-05-03T20:00:00-04:00" + }, + { + "id": 782360659, + "order_id": 625362839, + "type": "chargeback", + "amount": "11.50", + "currency": "USD", + "reason": "fraudulent", + "network_reason_code": "4827", + "status": "won", + "evidence_due_by": "2013-07-03T19:00:00-04:00", + "evidence_sent_on": "2013-07-04T07:00:00-04:00", + "finalized_on": null, + "initiated_at": "2013-05-03T20:00:00-04:00" + }, + { + "id": 670893524, + "order_id": 625362839, + "type": "inquiry", + "amount": "11.50", + "currency": "USD", + "reason": "fraudulent", + "network_reason_code": "4827", + "status": "needs_response", + "evidence_due_by": "2020-11-17T19:00:00-05:00", + "evidence_sent_on": null, + "finalized_on": null, + "initiated_at": "2013-05-03T20:00:00-04:00" + }, + { + "id": 598735659, + "order_id": 625362839, + "type": "chargeback", + "amount": "11.50", + "currency": "USD", + "reason": "fraudulent", + "network_reason_code": "4827", + "status": "needs_response", + "evidence_due_by": "2020-11-17T19:00:00-05:00", + "evidence_sent_on": null, + "finalized_on": null, + "initiated_at": "2013-05-03T20:00:00-04:00" + }, + { + "id": 85190714, + "order_id": 625362839, + "type": "chargeback", + "amount": "11.50", + "currency": "USD", + "reason": "fraudulent", + "network_reason_code": "4827", + "status": "under_review", + "evidence_due_by": "2020-11-17T19:00:00-05:00", + "evidence_sent_on": "2020-11-04T19:00:00-05:00", + "finalized_on": null, + "initiated_at": "2013-05-03T20:00:00-04:00" + }, + { + "id": 35982383, + "order_id": 625362839, + "type": "chargeback", + "amount": "11.50", + "currency": "USD", + "reason": "subscription_canceled", + "network_reason_code": "4827", + "status": "needs_response", + "evidence_due_by": "2020-11-17T19:00:00-05:00", + "evidence_sent_on": null, + "finalized_on": null, + "initiated_at": "2013-05-03T20:00:00-04:00" + } + ] +} diff --git a/test/fixtures/engagement.json b/test/fixtures/engagement.json new file mode 100644 index 00000000..4f0c9c4d --- /dev/null +++ b/test/fixtures/engagement.json @@ -0,0 +1,15 @@ +{ + "engagements": [ + { + "occurred_on": "2017-04-20", + "impressions_count": null, + "views_count": null, + "clicks_count": 10, + "shares_count": null, + "favorites_count": null, + "comments_count": null, + "ad_spend": null, + "is_cumulative": true + } + ] +} diff --git a/test/fixtures/fulfillment.json b/test/fixtures/fulfillment.json index 9d7e04d3..6d8bd9e8 100644 --- a/test/fixtures/fulfillment.json +++ b/test/fixtures/fulfillment.json @@ -5,7 +5,7 @@ "order_id": 450789469, "service": "manual", "status": "pending", - "tracking_company": null, + "tracking_company": "null-company", "updated_at": "2013-11-01T16:06:08-04:00", "tracking_number": "1Z2345", "tracking_numbers": [ @@ -46,4 +46,4 @@ } ] } -} \ No newline at end of file +} diff --git a/test/fixtures/fulfillment_event.json b/test/fixtures/fulfillment_event.json new file mode 100644 index 00000000..7115a158 --- /dev/null +++ b/test/fixtures/fulfillment_event.json @@ -0,0 +1,22 @@ +{ + "fulfillment_event": { + "id": 12584341209251, + "fulfillment_id": 2608403447971, + "status": "label_printed", + "message": null, + "happened_at": "2021-01-25T16:32:23-05:00", + "city": null, + "province": null, + "country": null, + "zip": null, + "address1": null, + "latitude": null, + "longitude": null, + "shop_id": 49144037539, + "created_at": "2021-01-25T16:32:23-05:00", + "updated_at": "2021-01-25T16:32:23-05:00", + "estimated_delivery_at": null, + "order_id": 2776493818019, + "admin_graphql_api_id": "gid://shopify/FulfillmentEvent/12584341209251" + } +} diff --git a/test/fixtures/gift_card_adjustment.json b/test/fixtures/gift_card_adjustment.json new file mode 100644 index 00000000..299c028a --- /dev/null +++ b/test/fixtures/gift_card_adjustment.json @@ -0,0 +1,17 @@ +{ + "adjustment": { + "remote_transaction_url": null, + "user_id": 0, + "created_at": "2018-04-02T16:45:14-04:00", + "updated_at": "2018-04-02T16:45:14-04:00", + "number": 3, + "note": null, + "amount": "100.00", + "gift_card_id": 4208208, + "order_transaction_id": null, + "processed_at": "2018-04-02T16:45:14-04:00", + "api_client_id": 2349816, + "remote_transaction_ref": null, + "id": 1796440070 + } +} diff --git a/test/fixtures/gift_cards_search.json b/test/fixtures/gift_cards_search.json new file mode 100644 index 00000000..ebdc4adc --- /dev/null +++ b/test/fixtures/gift_cards_search.json @@ -0,0 +1,20 @@ +{ + "gift_cards":[{ + "api_client_id": null, + "balance": "10.00", + "created_at": "2020-05-11T10:16:40+10:00", + "currency": "USD", + "customer_id": null, + "disabled_at": null, + "expires_on": null, + "id": 4208209, + "initial_value": "25.00", + "line_item_id": null, + "note": "balance10", + "template_suffix": null, + "updated_at": "2020-05-11T15:13:15+10:00", + "user_id": 123456, + "last_characters":"c294", + "order_id":null + }] +} diff --git a/test/fixtures/graphql.json b/test/fixtures/graphql.json new file mode 100644 index 00000000..ee925166 --- /dev/null +++ b/test/fixtures/graphql.json @@ -0,0 +1,26 @@ +{ + "shop": { + "name": "Apple Computers", + "city": "Cupertino", + "address1": "1 Infinite Loop", + "zip": "95014", + "created_at": "2007-12-31T19:00:00-05:00", + "shop_owner": "Steve Jobs", + "plan_name": "enterprise", + "public": false, + "country": "US", + "money_with_currency_format": "$ {{amount}} USD", + "money_format": "$ {{amount}}", + "domain": "shop.apple.com", + "taxes_included": null, + "id": 690933842, + "timezone": "(GMT-05:00) Eastern Time (US & Canada)", + "tax_shipping": null, + "phone": null, + "currency": "USD", + "myshopify_domain": "apple.myshopify.com", + "source": null, + "province": "CA", + "email": "steve@apple.com" + } +} diff --git a/test/fixtures/inventory_item.json b/test/fixtures/inventory_item.json new file mode 100644 index 00000000..97235987 --- /dev/null +++ b/test/fixtures/inventory_item.json @@ -0,0 +1,9 @@ +{ + "inventory_item": { + "id": 808950810, + "sku": "IPOD2008PINK", + "created_at": "2018-05-07T15:33:38-04:00", + "updated_at": "2018-05-07T15:33:38-04:00", + "tracked": true + } +} diff --git a/test/fixtures/inventory_items.json b/test/fixtures/inventory_items.json new file mode 100644 index 00000000..01a69ec4 --- /dev/null +++ b/test/fixtures/inventory_items.json @@ -0,0 +1,25 @@ +{ + "inventory_items": [ + { + "id": 39072856, + "sku": "IPOD2008GREEN", + "created_at": "2018-05-07T15:33:38-04:00", + "updated_at": "2018-05-07T15:33:38-04:00", + "tracked": true + }, + { + "id": 457924702, + "sku": "IPOD2008BLACK", + "created_at": "2018-05-07T15:33:38-04:00", + "updated_at": "2018-05-07T15:33:38-04:00", + "tracked": true + }, + { + "id": 808950810, + "sku": "IPOD2008PINK", + "created_at": "2018-05-07T15:33:38-04:00", + "updated_at": "2018-05-07T15:33:38-04:00", + "tracked": true + } + ] +} diff --git a/test/fixtures/inventory_level.json b/test/fixtures/inventory_level.json new file mode 100644 index 00000000..72bd930a --- /dev/null +++ b/test/fixtures/inventory_level.json @@ -0,0 +1,8 @@ +{ + "inventory_level": { + "inventory_item_id": 808950810, + "location_id": 905684977, + "available": 6, + "updated_at": "2018-05-07T15:51:26-04:00" + } +} diff --git a/test/fixtures/inventory_levels.json b/test/fixtures/inventory_levels.json new file mode 100644 index 00000000..f238820c --- /dev/null +++ b/test/fixtures/inventory_levels.json @@ -0,0 +1,28 @@ +{ + "inventory_levels": [ + { + "inventory_item_id": 39072856, + "location_id": 487838322, + "available": 27, + "updated_at": "2018-05-07T15:33:38-04:00" + }, + { + "inventory_item_id": 808950810, + "location_id": 905684977, + "available": 1, + "updated_at": "2018-05-07T15:33:38-04:00" + }, + { + "inventory_item_id": 808950810, + "location_id": 487838322, + "available": 9, + "updated_at": "2018-05-07T15:33:38-04:00" + }, + { + "inventory_item_id": 39072856, + "location_id": 905684977, + "available": 3, + "updated_at": "2018-05-07T15:33:38-04:00" + } + ] +} diff --git a/test/fixtures/location_inventory_levels.json b/test/fixtures/location_inventory_levels.json new file mode 100644 index 00000000..85eb684a --- /dev/null +++ b/test/fixtures/location_inventory_levels.json @@ -0,0 +1,16 @@ +{ + "inventory_levels": [ + { + "inventory_item_id": 39072856, + "location_id": 487838322, + "available": 27, + "updated_at": "2018-05-07T15:33:38-04:00" + }, + { + "inventory_item_id": 808950810, + "location_id": 487838322, + "available": 9, + "updated_at": "2018-05-07T15:33:38-04:00" + } + ] +} diff --git a/test/fixtures/marketing_event.json b/test/fixtures/marketing_event.json new file mode 100644 index 00000000..eedc2a5d --- /dev/null +++ b/test/fixtures/marketing_event.json @@ -0,0 +1,28 @@ +{ + "marketing_event": { + "id": 1, + "started_at": "2011-12-31T19:00:00-05:00", + "ended_at": null, + "event_target": "facebook", + "event_type": "post", + "scheduled_to_end_at": null, + "budget": "10.11", + "budget_type": "daily", + "currency": "GBP", + "utm_campaign": "1234567890", + "utm_source": "facebook", + "utm_medium": "facebook-post", + "utm_content": null, + "utm_term": null, + "manage_url": null, + "preview_url": null, + "description": null, + "marketing_channel": "social", + "paid": false, + "referring_domain": "facebook.com", + "breadcrumb_id": null, + "marketed_resources": [ + { "type": "product", "id": 1 } + ] + } +} diff --git a/test/fixtures/marketing_events.json b/test/fixtures/marketing_events.json new file mode 100644 index 00000000..f99778af --- /dev/null +++ b/test/fixtures/marketing_events.json @@ -0,0 +1,54 @@ +{ + "marketing_event": [{ + "id": 1, + "started_at": "2011-12-31T19:00:00-05:00", + "ended_at": null, + "event_target": "facebook", + "event_type": "post", + "scheduled_to_end_at": null, + "budget": "10.11", + "budget_type": "daily", + "currency": "GBP", + "utm_campaign": "1234567890", + "utm_source": "facebook", + "utm_medium": "facebook-post", + "utm_content": null, + "utm_term": null, + "manage_url": null, + "preview_url": null, + "description": null, + "marketing_channel": "social", + "paid": false, + "referring_domain": "facebook.com", + "breadcrumb_id": null, + "marketed_resources": [ + { "type": "product", "id": 1 } + ] + }, + { + "id": 2, + "started_at": "2011-12-31T19:00:00-05:00", + "ended_at": null, + "event_target": "facebook", + "event_type": "post", + "scheduled_to_end_at": null, + "budget": "10.11", + "budget_type": "daily", + "currency": "USD", + "utm_campaign": "1234567891", + "utm_source": "facebook", + "utm_medium": "facebook-post", + "utm_content": null, + "utm_term": null, + "manage_url": null, + "preview_url": null, + "description": null, + "marketing_channel": "social", + "paid": false, + "referring_domain": "facebook.com", + "breadcrumb_id": null, + "marketed_resources": [ + { "type": "product", "id": 2 } + ] + }] +} diff --git a/test/fixtures/orders.json b/test/fixtures/orders.json new file mode 100644 index 00000000..925a4a5d --- /dev/null +++ b/test/fixtures/orders.json @@ -0,0 +1,206 @@ +{ + "orders": [ + { + "id": 450789469, + "email": "bob.norman@hostmail.com", + "closed_at": null, + "created_at": "2008-01-10T11:00:00-05:00", + "updated_at": "2008-01-10T11:00:00-05:00", + "number": 1, + "note": null, + "token": "b1946ac92492d2347c6235b4d2611184", + "gateway": "authorize_net", + "test": false, + "total_price": "598.94", + "subtotal_price": "597.00", + "total_weight": 0, + "total_tax": "11.94", + "taxes_included": false, + "currency": "USD", + "financial_status": "partially_refunded", + "confirmed": true, + "total_discounts": "10.00", + "total_line_items_price": "597.00", + "cart_token": "68778783ad298f1c80c3bafcddeea02f", + "buyer_accepts_marketing": false, + "name": "#1001", + "referring_site": "http://www.otherexample.com", + "landing_site": "http://www.example.com?source=abc", + "cancelled_at": null, + "cancel_reason": null, + "total_price_usd": "598.94", + "checkout_token": "bd5a8aa1ecd019dd3520ff791ee3a24c", + "reference": "fhwdgads", + "user_id": null, + "location_id": null, + "source_identifier": "fhwdgads", + "source_url": null, + "processed_at": "2008-01-10T11:00:00-05:00", + "device_id": null, + "phone": "+557734881234", + "customer_locale": null, + "app_id": null, + "browser_ip": "0.0.0.0", + "client_details": { + "accept_language": null, + "browser_height": null, + "browser_ip": "0.0.0.0", + "browser_width": null, + "session_hash": null, + "user_agent": null + }, + "landing_site_ref": "abc", + "order_number": 1001, + "payment_details": { + "credit_card_number": "•••• •••• •••• 4242", + "credit_card_company": "Visa" + }, + "payment_gateway_names": [ + "bogus" + ], + "tags": "", + "contact_email": "bob.norman@hostmail.com", + "order_status_url": "https://apple.myshopify.com/690933842/orders/b1946ac92492d2347c6235b4d2611184/authenticate?key=ccde591a93123786bd8d257abd970200", + "presentment_currency": "USD", + "total_line_items_price_set": { + "shop_money": { + "amount": "597.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "597.00", + "currency_code": "USD" + } + }, + "total_discounts_set": { + "shop_money": { + "amount": "10.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "10.00", + "currency_code": "USD" + } + }, + "total_shipping_price_set": { + "shop_money": { + "amount": "0.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "0.00", + "currency_code": "USD" + } + }, + "subtotal_price_set": { + "shop_money": { + "amount": "597.00", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "597.00", + "currency_code": "USD" + } + }, + "total_price_set": { + "shop_money": { + "amount": "598.94", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "598.94", + "currency_code": "USD" + } + }, + "total_tax_set": { + "shop_money": { + "amount": "11.94", + "currency_code": "USD" + }, + "presentment_money": { + "amount": "11.94", + "currency_code": "USD" + } + }, + "admin_graphql_api_id": "gid://shopify/Order/450789469", + "billing_address": { + "first_name": "Bob", + "address1": "Chestnut Street 92", + "phone": "555-625-1199", + "city": "Louisville", + "zip": "40202", + "province": "Kentucky", + "country": "United States", + "last_name": "Norman", + "address2": "", + "company": null, + "latitude": 45.41634, + "longitude": -75.6868, + "name": "Bob Norman", + "country_code": "US", + "province_code": "KY" + }, + "shipping_address": { + "first_name": "Bob", + "address1": "Chestnut Street 92", + "phone": "555-625-1199", + "city": "Louisville", + "zip": "40202", + "province": "Kentucky", + "country": "United States", + "last_name": "Norman", + "address2": "", + "company": null, + "latitude": 45.41634, + "longitude": -75.6868, + "name": "Bob Norman", + "country_code": "US", + "province_code": "KY" + }, + "customer": { + "id": 207119551, + "email": "bob.norman@hostmail.com", + "accepts_marketing": false, + "created_at": "2021-02-12T13:51:00-05:00", + "updated_at": "2021-02-12T13:51:00-05:00", + "first_name": "Bob", + "last_name": "Norman", + "orders_count": 1, + "state": "disabled", + "total_spent": "199.65", + "last_order_id": 450789469, + "note": null, + "verified_email": true, + "multipass_identifier": null, + "tax_exempt": false, + "phone": "+16136120707", + "tags": "", + "last_order_name": "#1001", + "currency": "USD", + "accepts_marketing_updated_at": "2005-06-12T11:57:11-04:00", + "marketing_opt_in_level": null, + "tax_exemptions": [], + "admin_graphql_api_id": "gid://shopify/Customer/207119551", + "default_address": { + "id": 207119551, + "customer_id": 207119551, + "first_name": null, + "last_name": null, + "company": null, + "address1": "Chestnut Street 92", + "address2": "", + "city": "Louisville", + "province": "Kentucky", + "country": "United States", + "zip": "40202", + "phone": "555-625-1199", + "name": "", + "province_code": "KY", + "country_code": "US", + "country_name": "United States", + "default": true + } + } + } + ] +} diff --git a/test/fixtures/payout.json b/test/fixtures/payout.json new file mode 100644 index 00000000..e11a8f47 --- /dev/null +++ b/test/fixtures/payout.json @@ -0,0 +1,21 @@ +{ + "payout": { + "id": 623721858, + "status": "paid", + "date": "2019-11-12", + "currency": "USD", + "amount": "41.90", + "summary": { + "adjustments_fee_amount": "0.12", + "adjustments_gross_amount": "2.13", + "charges_fee_amount": "1.32", + "charges_gross_amount": "44.52", + "refunds_fee_amount": "-0.23", + "refunds_gross_amount": "-3.54", + "reserved_funds_fee_amount": "0.00", + "reserved_funds_gross_amount": "0.00", + "retried_payouts_fee_amount": "0.00", + "retried_payouts_gross_amount": "0.00" + } + } +} diff --git a/test/fixtures/payouts.json b/test/fixtures/payouts.json new file mode 100644 index 00000000..26ad9b3a --- /dev/null +++ b/test/fixtures/payouts.json @@ -0,0 +1,118 @@ +{ + "payouts": [ + { + "id": 854088011, + "status": "scheduled", + "date": "2019-11-01", + "currency": "USD", + "amount": "43.12", + "summary": { + "adjustments_fee_amount": "0.12", + "adjustments_gross_amount": "2.13", + "charges_fee_amount": "1.32", + "charges_gross_amount": "45.52", + "refunds_fee_amount": "-0.23", + "refunds_gross_amount": "-3.54", + "reserved_funds_fee_amount": "0.00", + "reserved_funds_gross_amount": "0.00", + "retried_payouts_fee_amount": "0.00", + "retried_payouts_gross_amount": "0.00" + } + }, + { + "id": 512467833, + "status": "failed", + "date": "2019-11-01", + "currency": "USD", + "amount": "43.12", + "summary": { + "adjustments_fee_amount": "0.12", + "adjustments_gross_amount": "2.13", + "charges_fee_amount": "1.32", + "charges_gross_amount": "45.52", + "refunds_fee_amount": "-0.23", + "refunds_gross_amount": "-3.54", + "reserved_funds_fee_amount": "0.00", + "reserved_funds_gross_amount": "0.00", + "retried_payouts_fee_amount": "0.00", + "retried_payouts_gross_amount": "0.00" + } + }, + { + "id": 39438702, + "status": "in_transit", + "date": "2019-11-01", + "currency": "USD", + "amount": "43.12", + "summary": { + "adjustments_fee_amount": "0.12", + "adjustments_gross_amount": "2.13", + "charges_fee_amount": "1.32", + "charges_gross_amount": "45.52", + "refunds_fee_amount": "-0.23", + "refunds_gross_amount": "-3.54", + "reserved_funds_fee_amount": "0.00", + "reserved_funds_gross_amount": "0.00", + "retried_payouts_fee_amount": "0.00", + "retried_payouts_gross_amount": "0.00" + } + }, + { + "id": 710174591, + "status": "paid", + "date": "2019-12-12", + "currency": "USD", + "amount": "41.90", + "summary": { + "adjustments_fee_amount": "0.12", + "adjustments_gross_amount": "2.13", + "charges_fee_amount": "1.32", + "charges_gross_amount": "44.52", + "refunds_fee_amount": "-0.23", + "refunds_gross_amount": "-3.54", + "reserved_funds_fee_amount": "0.00", + "reserved_funds_gross_amount": "0.00", + "retried_payouts_fee_amount": "0.00", + "retried_payouts_gross_amount": "0.00" + } + }, + { + "id": 974708905, + "status": "paid", + "date": "2019-11-13", + "currency": "CAD", + "amount": "51.69", + "summary": { + "adjustments_fee_amount": "0.12", + "adjustments_gross_amount": "2.13", + "charges_fee_amount": "6.46", + "charges_gross_amount": "58.15", + "refunds_fee_amount": "-0.23", + "refunds_gross_amount": "-3.54", + "reserved_funds_fee_amount": "0.00", + "reserved_funds_gross_amount": "0.00", + "retried_payouts_fee_amount": "0.00", + "retried_payouts_gross_amount": "0.00" + } + }, + { + "id": 623721858, + "status": "paid", + "date": "2019-11-12", + "currency": "USD", + "amount": "41.90", + "summary": { + "adjustments_fee_amount": "0.12", + "adjustments_gross_amount": "2.13", + "charges_fee_amount": "1.32", + "charges_gross_amount": "44.52", + "refunds_fee_amount": "-0.23", + "refunds_gross_amount": "-3.54", + "reserved_funds_fee_amount": "0.00", + "reserved_funds_gross_amount": "0.00", + "retried_payouts_fee_amount": "0.00", + "retried_payouts_gross_amount": "0.00" + } + } + ] +} diff --git a/test/fixtures/payouts_transactions.json b/test/fixtures/payouts_transactions.json new file mode 100644 index 00000000..74220886 --- /dev/null +++ b/test/fixtures/payouts_transactions.json @@ -0,0 +1,404 @@ +{ + "transactions": [ + { + "id": 699519475, + "type": "debit", + "test": false, + "payout_id": 623721858, + "payout_status": "paid", + "currency": "USD", + "amount": "-50.00", + "fee": "0.00", + "net": "-50.00", + "source_id": 460709370, + "source_type": "adjustment", + "source_order_id": null, + "source_order_transaction_id": null, + "processed_at": "2020-11-05T19:52:08-05:00" + }, + { + "id": 77412310, + "type": "credit", + "test": false, + "payout_id": 623721858, + "payout_status": "paid", + "currency": "USD", + "amount": "50.00", + "fee": "0.00", + "net": "50.00", + "source_id": 374511569, + "source_type": "Payments::Balance::AdjustmentReversal", + "source_order_id": null, + "source_order_transaction_id": null, + "processed_at": "2020-11-05T19:52:08-05:00" + }, + { + "id": 1006917261, + "type": "refund", + "test": false, + "payout_id": 623721858, + "payout_status": "paid", + "currency": "USD", + "amount": "-3.45", + "fee": "0.00", + "net": "-3.45", + "source_id": 1006917261, + "source_type": "Payments::Refund", + "source_order_id": 217130470, + "source_order_transaction_id": 1006917261, + "processed_at": "2020-11-04T19:52:08-05:00" + }, + { + "id": 777128868, + "type": "refund", + "test": false, + "payout_id": 623721858, + "payout_status": "paid", + "currency": "USD", + "amount": "-8.05", + "fee": "0.00", + "net": "-8.05", + "source_id": 777128868, + "source_type": "Payments::Refund", + "source_order_id": null, + "source_order_transaction_id": null, + "processed_at": "2020-11-04T19:52:08-05:00" + }, + { + "id": 758509248, + "type": "adjustment", + "test": false, + "payout_id": 623721858, + "payout_status": "paid", + "currency": "USD", + "amount": "-1.50", + "fee": "-0.25", + "net": "-1.75", + "source_id": 764194150, + "source_type": "charge", + "source_order_id": null, + "source_order_transaction_id": null, + "processed_at": "2020-11-04T19:52:08-05:00" + }, + { + "id": 746296004, + "type": "charge", + "test": false, + "payout_id": 623721858, + "payout_status": "paid", + "currency": "USD", + "amount": "10.00", + "fee": "2.00", + "net": "8.00", + "source_id": 746296004, + "source_type": "charge", + "source_order_id": null, + "source_order_transaction_id": null, + "processed_at": "2020-11-04T19:52:08-05:00" + }, + { + "id": 515523000, + "type": "charge", + "test": false, + "payout_id": 623721858, + "payout_status": "paid", + "currency": "USD", + "amount": "11.50", + "fee": "0.65", + "net": "10.85", + "source_id": 1006917261, + "source_type": "Payments::Refund", + "source_order_id": 217130470, + "source_order_transaction_id": 1006917261, + "processed_at": "2020-11-04T19:52:08-05:00" + }, + { + "id": 482793472, + "type": "adjustment", + "test": false, + "payout_id": 623721858, + "payout_status": "paid", + "currency": "USD", + "amount": "0.45", + "fee": "0.00", + "net": "0.45", + "source_id": 204289877, + "source_type": "charge", + "source_order_id": 217130470, + "source_order_transaction_id": 567994517, + "processed_at": "2020-11-04T19:52:08-05:00" + }, + { + "id": 382557793, + "type": "adjustment", + "test": false, + "payout_id": 623721858, + "payout_status": "paid", + "currency": "USD", + "amount": "0.20", + "fee": "0.00", + "net": "0.20", + "source_id": 204289877, + "source_type": "charge", + "source_order_id": 217130470, + "source_order_transaction_id": 567994517, + "processed_at": "2020-11-04T19:52:08-05:00" + }, + { + "id": 201521674, + "type": "refund", + "test": false, + "payout_id": 623721858, + "payout_status": "paid", + "currency": "USD", + "amount": "-2.00", + "fee": "0.00", + "net": "-2.00", + "source_id": 971443537, + "source_type": "charge", + "source_order_id": 625362839, + "source_order_transaction_id": 461790020, + "processed_at": "2020-11-04T19:52:08-05:00" + }, + { + "id": 620327031, + "type": "charge", + "test": false, + "payout_id": 623721858, + "payout_status": "paid", + "currency": "USD", + "amount": "11.50", + "fee": "0.63", + "net": "10.87", + "source_id": 620327031, + "source_type": "charge", + "source_order_id": null, + "source_order_transaction_id": null, + "processed_at": "2016-08-04T18:07:57-04:00" + }, + { + "id": 726130462, + "type": "dispute", + "test": false, + "payout_id": 623721858, + "payout_status": "paid", + "currency": "USD", + "amount": "-11.50", + "fee": "15.00", + "net": "-26.50", + "source_id": 598735659, + "source_type": "Payments::Dispute", + "source_order_id": 625362839, + "source_order_transaction_id": 897736458, + "processed_at": "2020-10-25T20:52:08-04:00" + }, + { + "id": 996672915, + "type": "debit", + "test": false, + "payout_id": 623721858, + "payout_status": "paid", + "currency": "USD", + "amount": "-100.00", + "fee": "0.00", + "net": "-100.00", + "source_id": 996672915, + "source_type": "Payments::Balance::AdjustmentReversal", + "source_order_id": null, + "source_order_transaction_id": null, + "processed_at": "2016-08-04T18:07:57-04:00" + }, + { + "id": 843310825, + "type": "charge", + "test": false, + "payout_id": 623721858, + "payout_status": "paid", + "currency": "USD", + "amount": "11.50", + "fee": "0.63", + "net": "10.87", + "source_id": 843310825, + "source_type": "charge", + "source_order_id": 625362839, + "source_order_transaction_id": 897736458, + "processed_at": "2016-08-04T18:07:57-04:00" + }, + { + "id": 841651232, + "type": "debit", + "test": false, + "payout_id": 623721858, + "payout_status": "paid", + "currency": "USD", + "amount": "-100.00", + "fee": "0.00", + "net": "-100.00", + "source_id": 841651232, + "source_type": "Payments::Balance::AdjustmentReversal", + "source_order_id": null, + "source_order_transaction_id": null, + "processed_at": "2016-08-04T18:07:57-04:00" + }, + { + "id": 717600021, + "type": "credit", + "test": false, + "payout_id": 623721858, + "payout_status": "paid", + "currency": "USD", + "amount": "100.00", + "fee": "0.00", + "net": "100.00", + "source_id": 717600021, + "source_type": "adjustment", + "source_order_id": null, + "source_order_transaction_id": null, + "processed_at": "2016-08-04T18:07:57-04:00" + }, + { + "id": 427940661, + "type": "credit", + "test": false, + "payout_id": 623721858, + "payout_status": "paid", + "currency": "USD", + "amount": "150.00", + "fee": "0.00", + "net": "150.00", + "source_id": 427940661, + "source_type": "Payments::Balance::AdjustmentReversal", + "source_order_id": null, + "source_order_transaction_id": null, + "processed_at": "2016-08-04T18:07:57-04:00" + }, + { + "id": 400852343, + "type": "reserve", + "test": false, + "payout_id": 623721858, + "payout_status": "paid", + "currency": "USD", + "amount": "-42.00", + "fee": "0.00", + "net": "-42.00", + "source_id": null, + "source_type": null, + "source_order_id": null, + "source_order_transaction_id": null, + "processed_at": "2016-08-04T18:07:57-04:00" + }, + { + "id": 381560291, + "type": "debit", + "test": false, + "payout_id": 623721858, + "payout_status": "paid", + "currency": "USD", + "amount": "-150.00", + "fee": "0.00", + "net": "-150.00", + "source_id": 381560291, + "source_type": "adjustment", + "source_order_id": null, + "source_order_transaction_id": null, + "processed_at": "2016-08-04T18:07:57-04:00" + }, + { + "id": 357948134, + "type": "charge", + "test": false, + "payout_id": 623721858, + "payout_status": "paid", + "currency": "USD", + "amount": "10.00", + "fee": "0.46", + "net": "9.54", + "source_id": 971443537, + "source_type": "charge", + "source_order_id": 625362839, + "source_order_transaction_id": 461790020, + "processed_at": "2016-08-04T18:07:57-04:00" + }, + { + "id": 250467535, + "type": "reserve", + "test": false, + "payout_id": 623721858, + "payout_status": "paid", + "currency": "USD", + "amount": "42.00", + "fee": "0.00", + "net": "42.00", + "source_id": null, + "source_type": null, + "source_order_id": null, + "source_order_transaction_id": null, + "processed_at": "2016-08-04T18:07:57-04:00" + }, + { + "id": 217609728, + "type": "charge", + "test": false, + "payout_id": 623721858, + "payout_status": "paid", + "currency": "USD", + "amount": "11.50", + "fee": "0.00", + "net": "11.50", + "source_id": 930299385, + "source_type": "charge", + "source_order_id": 625362839, + "source_order_transaction_id": 348327371, + "processed_at": "2016-08-04T18:07:57-04:00" + }, + { + "id": 138130604, + "type": "credit", + "test": false, + "payout_id": 623721858, + "payout_status": "paid", + "currency": "USD", + "amount": "150.00", + "fee": "0.00", + "net": "150.00", + "source_id": 138130604, + "source_type": "Payments::Balance::AdjustmentReversal", + "source_order_id": null, + "source_order_transaction_id": null, + "processed_at": "2016-08-04T18:07:57-04:00" + }, + { + "id": 567994517, + "type": "charge", + "test": false, + "payout_id": 623721858, + "payout_status": "paid", + "currency": "USD", + "amount": "11.50", + "fee": "0.65", + "net": "10.85", + "source_id": 204289877, + "source_type": "charge", + "source_order_id": 217130470, + "source_order_transaction_id": 567994517, + "processed_at": "2014-01-21T13:05:38-05:00" + }, + { + "id": 854848137, + "type": "payout", + "test": false, + "payout_id": 623721858, + "payout_status": "paid", + "currency": "USD", + "amount": "-41.90", + "fee": "0.00", + "net": "-41.90", + "source_id": 623721858, + "source_type": "payout", + "source_order_id": null, + "source_order_transaction_id": null, + "processed_at": "2012-11-11T19:00:00-05:00" + } + ] +} diff --git a/test/fixtures/price_rule.json b/test/fixtures/price_rule.json new file mode 100644 index 00000000..dfeb626e --- /dev/null +++ b/test/fixtures/price_rule.json @@ -0,0 +1,18 @@ +{ + "price_rule": { + "id": 1213131, + "title": "BOGO", + "target_type": "line_item", + "target_selection": "all", + "allocation_method": "across", + "value_type": "percentage", + "value": -100, + "once_per_customer": true, + "usage_limit": null, + "customer_selection": "all", + "prerequisite_subtotal_range": null, + "prerequisite_shipping_price_range": null, + "starts_at": "2017-05-30T04:13:56Z", + "ends_at": null + } +} diff --git a/test/fixtures/price_rules.json b/test/fixtures/price_rules.json new file mode 100644 index 00000000..aef51c8e --- /dev/null +++ b/test/fixtures/price_rules.json @@ -0,0 +1,36 @@ +{ + "price_rules": [ + { + "id": 1213131, + "title": "BOGO", + "target_type": "line_item", + "target_selection": "all", + "allocation_method": "across", + "value_type": "percentage", + "value": -100, + "once_per_customer": true, + "usage_limit": null, + "customer_selection": "all", + "prerequisite_subtotal_range": null, + "prerequisite_shipping_price_range": null, + "starts_at": "2017-05-30T04:13:56Z", + "ends_at": null + }, + { + "id": 1213132, + "title": "TENOFF", + "target_type": "line_item", + "target_selection": "all", + "allocation_method": "each", + "value_type": "percentage", + "value": -10, + "once_per_customer": true, + "usage_limit": null, + "customer_selection": "all", + "prerequisite_subtotal_range": null, + "prerequisite_shipping_price_range": null, + "starts_at": "2017-05-30T04:13:56Z", + "ends_at": null + } + ] +} diff --git a/test/fixtures/product_listing.json b/test/fixtures/product_listing.json new file mode 100644 index 00000000..85b64324 --- /dev/null +++ b/test/fixtures/product_listing.json @@ -0,0 +1,86 @@ +{ + "product_id": 2, + "created_at": "2017-01-06T14:52:56-05:00", + "updated_at": "2017-01-06T14:52:56-05:00", + "body_html": null, + "handle": "synergistic-silk-chair", + "product_type": "morph magnetic solutions", + "title": "Synergistic Silk Chair", + "vendor": "O'Hara, Fritsch and Hudson", + "available": true, + "tags": "", + "published_at": "2017-01-06T14:52:53-05:00", + "images": [ + + ], + "options": [ + { + "id": 2, + "name": "Color or something", + "product_id": 2, + "position": 1 + } + ], + "variants": [ + { + "id": 3, + "title": "Aerodynamic Copper Clock", + "option_values": [ + { + "option_id": 2, + "name": "Color or something", + "value": "Aerodynamic Copper Clock" + } + ], + "price": "179.99", + "formatted_price": "$179.99", + "compare_at_price": null, + "grams": 8400, + "requires_shipping": true, + "sku": "", + "barcode": null, + "taxable": true, + "position": 1, + "available": true, + "inventory_policy": "deny", + "inventory_quantity": 810, + "inventory_management": "shopify", + "fulfillment_service": "manual", + "weight": 8.4, + "weight_unit": "kg", + "image_id": null, + "created_at": "2017-01-04T17:07:47-05:00", + "updated_at": "2017-01-04T17:07:47-05:00" + }, + { + "id": 4, + "title": "Awesome Concrete Knife", + "option_values": [ + { + "option_id": 2, + "name": "Color or something", + "value": "Awesome Concrete Knife" + } + ], + "price": "179.99", + "formatted_price": "$179.99", + "compare_at_price": null, + "grams": 8400, + "requires_shipping": true, + "sku": "", + "barcode": null, + "taxable": true, + "position": 2, + "available": true, + "inventory_policy": "deny", + "inventory_quantity": 1, + "inventory_management": null, + "fulfillment_service": "manual", + "weight": 8.4, + "weight_unit": "kg", + "image_id": null, + "created_at": "2017-01-04T17:07:47-05:00", + "updated_at": "2017-01-04T17:07:47-05:00" + } + ] +} diff --git a/test/fixtures/product_listing_product_ids.json b/test/fixtures/product_listing_product_ids.json new file mode 100644 index 00000000..208d98ed --- /dev/null +++ b/test/fixtures/product_listing_product_ids.json @@ -0,0 +1,4 @@ +[ + 2, + 1 +] diff --git a/test/fixtures/product_listings.json b/test/fixtures/product_listings.json new file mode 100644 index 00000000..f3961722 --- /dev/null +++ b/test/fixtures/product_listings.json @@ -0,0 +1,174 @@ +[ + { + "product_id": 2, + "created_at": "2017-01-06T14:52:56-05:00", + "updated_at": "2017-01-06T14:52:56-05:00", + "body_html": null, + "handle": "synergistic-silk-chair", + "product_type": "morph magnetic solutions", + "title": "Synergistic Silk Chair", + "vendor": "O'Hara, Fritsch and Hudson", + "available": true, + "tags": "", + "published_at": "2017-01-06T14:52:53-05:00", + "images": [ + + ], + "options": [ + { + "id": 2, + "name": "Color or something", + "product_id": 2, + "position": 1 + } + ], + "variants": [ + { + "id": 3, + "title": "Aerodynamic Copper Clock", + "option_values": [ + { + "option_id": 2, + "name": "Color or something", + "value": "Aerodynamic Copper Clock" + } + ], + "price": "179.99", + "formatted_price": "$179.99", + "compare_at_price": null, + "grams": 8400, + "requires_shipping": true, + "sku": "", + "barcode": null, + "taxable": true, + "position": 1, + "available": true, + "inventory_policy": "deny", + "inventory_quantity": 810, + "inventory_management": "shopify", + "fulfillment_service": "manual", + "weight": 8.4, + "weight_unit": "kg", + "image_id": null, + "created_at": "2017-01-04T17:07:47-05:00", + "updated_at": "2017-01-04T17:07:47-05:00" + }, + { + "id": 4, + "title": "Awesome Concrete Knife", + "option_values": [ + { + "option_id": 2, + "name": "Color or something", + "value": "Awesome Concrete Knife" + } + ], + "price": "179.99", + "formatted_price": "$179.99", + "compare_at_price": null, + "grams": 8400, + "requires_shipping": true, + "sku": "", + "barcode": null, + "taxable": true, + "position": 2, + "available": true, + "inventory_policy": "deny", + "inventory_quantity": 1, + "inventory_management": null, + "fulfillment_service": "manual", + "weight": 8.4, + "weight_unit": "kg", + "image_id": null, + "created_at": "2017-01-04T17:07:47-05:00", + "updated_at": "2017-01-04T17:07:47-05:00" + } + ] + }, + { + "product_id": 1, + "created_at": "2017-01-06T14:52:54-05:00", + "updated_at": "2017-01-06T14:52:54-05:00", + "body_html": null, + "handle": "rustic-copper-bottle", + "product_type": "maximize viral channels", + "title": "Rustic Copper Bottle", + "vendor": "Kuphal and Sons", + "available": true, + "tags": "", + "published_at": "2017-01-06T14:52:52-05:00", + "images": [ + + ], + "options": [ + { + "id": 1, + "name": "Color or something", + "product_id": 1, + "position": 1 + } + ], + "variants": [ + { + "id": 1, + "title": "Awesome Bronze Hat", + "option_values": [ + { + "option_id": 1, + "name": "Color or something", + "value": "Awesome Bronze Hat" + } + ], + "price": "111.99", + "formatted_price": "$111.99", + "compare_at_price": null, + "grams": 1800, + "requires_shipping": true, + "sku": "", + "barcode": null, + "taxable": true, + "position": 1, + "available": true, + "inventory_policy": "deny", + "inventory_quantity": 65, + "inventory_management": "shopify", + "fulfillment_service": "manual", + "weight": 1.8, + "weight_unit": "kg", + "image_id": null, + "created_at": "2017-01-04T17:07:07-05:00", + "updated_at": "2017-01-04T17:07:07-05:00" + }, + { + "id": 2, + "title": "Rustic Marble Bottle", + "option_values": [ + { + "option_id": 1, + "name": "Color or something", + "value": "Rustic Marble Bottle" + } + ], + "price": "111.99", + "formatted_price": "$111.99", + "compare_at_price": null, + "grams": 1800, + "requires_shipping": true, + "sku": "", + "barcode": null, + "taxable": true, + "position": 2, + "available": true, + "inventory_policy": "deny", + "inventory_quantity": 1, + "inventory_management": null, + "fulfillment_service": "manual", + "weight": 1.8, + "weight_unit": "kg", + "image_id": null, + "created_at": "2017-01-04T17:07:07-05:00", + "updated_at": "2017-01-04T17:07:07-05:00" + } + ] + } +] diff --git a/test/fixtures/product_publication.json b/test/fixtures/product_publication.json new file mode 100644 index 00000000..593388c5 --- /dev/null +++ b/test/fixtures/product_publication.json @@ -0,0 +1,11 @@ +{ + "product_publication": { + "id": 647162527768, + "publication_id": 55650051, + "published_at": "2018-01-29T14:06:08-05:00", + "published": true, + "created_at": "2018-01-29T14:06:08-05:00", + "updated_at": "2018-09-26T15:39:05-04:00", + "product_id": 8267093571 + } +} diff --git a/test/fixtures/product_publications.json b/test/fixtures/product_publications.json new file mode 100644 index 00000000..761f61ae --- /dev/null +++ b/test/fixtures/product_publications.json @@ -0,0 +1,13 @@ +{ + "product_publications": [ + { + "id": 647162527768, + "publication_id": 55650051, + "published_at": "2018-01-29T14:06:08-05:00", + "published": true, + "created_at": "2018-01-29T14:06:08-05:00", + "updated_at": "2018-09-26T15:39:05-04:00", + "product_id": 8267093571 + } + ] +} diff --git a/test/fixtures/products.json b/test/fixtures/products.json new file mode 100644 index 00000000..4258cf6a --- /dev/null +++ b/test/fixtures/products.json @@ -0,0 +1,206 @@ +[ + { + "product_type": "Cult Products", + "handle": "ipod-nano", + "created_at": "2011-10-20T14:05:13-04:00", + "body_html": "

It's the small iPod with one very big idea: Video. Now the world's most popular music player, available in 4GB and 8GB models, lets you enjoy TV shows, movies, video podcasts, and more. The larger, brighter display means amazing picture quality. In six eye-catching colors, iPod nano is stunning all around. And with models starting at just $149, little speaks volumes.

", + "title": "IPod Nano - 8GB", + "template_suffix": null, + "updated_at": "2011-10-20T14:05:13-04:00", + "id": 1, + "tags": "Emotive, Flash Memory, MP3, Music", + "images": [ + { + "position": 1, + "created_at": "2011-10-20T14:05:13-04:00", + "product_id": 1, + "updated_at": "2011-10-20T14:05:13-04:00", + "src": "http://static.shopify.com/s/files/1/6909/3384/products/ipod-nano.png?0", + "id": 850703190 + } + ], + "variants": [ + { + "position": 1, + "price": "199.00", + "product_id": 1, + "created_at": "2011-10-20T14:05:13-04:00", + "requires_shipping": true, + "title": "Pink", + "inventory_quantity": 10, + "compare_at_price": null, + "inventory_policy": "continue", + "updated_at": "2011-10-20T14:05:13-04:00", + "inventory_management": "shopify", + "id": 808950810, + "taxable": true, + "grams": 200, + "sku": "IPOD2008PINK", + "option1": "Pink", + "fulfillment_service": "manual", + "option2": null, + "option3": null + } + ], + "vendor": "Apple", + "published_at": "2007-12-31T19:00:00-05:00", + "options": [ + { + "name": "Title" + } + ] + }, + { + "product_type": "Cult Products", + "handle": "ipod-nano", + "created_at": "2011-10-20T14:05:13-04:00", + "body_html": "

It's the small iPod with one very big idea: Video. Now the world's most popular music player, available in 4GB and 8GB models, lets you enjoy TV shows, movies, video podcasts, and more. The larger, brighter display means amazing picture quality. In six eye-catching colors, iPod nano is stunning all around. And with models starting at just $149, little speaks volumes.

", + "title": "IPod Nano - 8GB", + "template_suffix": null, + "updated_at": "2011-10-20T14:05:13-04:00", + "id": 2, + "tags": "Emotive, Flash Memory, MP3, Music", + "images": [ + { + "position": 1, + "created_at": "2011-10-20T14:05:13-04:00", + "product_id": 2, + "updated_at": "2011-10-20T14:05:13-04:00", + "src": "http://static.shopify.com/s/files/1/6909/3384/products/ipod-nano.png?0", + "id": 850703190 + } + ], + "variants": [ + { + "position": 1, + "price": "199.00", + "product_id": 2, + "created_at": "2011-10-20T14:05:13-04:00", + "requires_shipping": true, + "title": "Pink", + "inventory_quantity": 10, + "compare_at_price": null, + "inventory_policy": "continue", + "updated_at": "2011-10-20T14:05:13-04:00", + "inventory_management": "shopify", + "id": 808950810, + "taxable": true, + "grams": 200, + "sku": "IPOD2008PINK", + "option1": "Pink", + "fulfillment_service": "manual", + "option2": null, + "option3": null + } + ], + "vendor": "Apple", + "published_at": "2007-12-31T19:00:00-05:00", + "options": [ + { + "name": "Title" + } + ] + }, + { + "product_type": "Cult Products", + "handle": "ipod-nano", + "created_at": "2011-10-20T14:05:13-04:00", + "body_html": "

It's the small iPod with one very big idea: Video. Now the world's most popular music player, available in 4GB and 8GB models, lets you enjoy TV shows, movies, video podcasts, and more. The larger, brighter display means amazing picture quality. In six eye-catching colors, iPod nano is stunning all around. And with models starting at just $149, little speaks volumes.

", + "title": "IPod Nano - 8GB", + "template_suffix": null, + "updated_at": "2011-10-20T14:05:13-04:00", + "id": 3, + "tags": "Emotive, Flash Memory, MP3, Music", + "images": [ + { + "position": 1, + "created_at": "2011-10-20T14:05:13-04:00", + "product_id": 2, + "updated_at": "2011-10-20T14:05:13-04:00", + "src": "http://static.shopify.com/s/files/1/6909/3384/products/ipod-nano.png?0", + "id": 850703190 + } + ], + "variants": [ + { + "position": 1, + "price": "199.00", + "product_id": 2, + "created_at": "2011-10-20T14:05:13-04:00", + "requires_shipping": true, + "title": "Pink", + "inventory_quantity": 10, + "compare_at_price": null, + "inventory_policy": "continue", + "updated_at": "2011-10-20T14:05:13-04:00", + "inventory_management": "shopify", + "id": 808950810, + "taxable": true, + "grams": 200, + "sku": "IPOD2008PINK", + "option1": "Pink", + "fulfillment_service": "manual", + "option2": null, + "option3": null + } + ], + "vendor": "Apple", + "published_at": "2007-12-31T19:00:00-05:00", + "options": [ + { + "name": "Title" + } + ] + }, + { + "product_type": "Cult Products", + "handle": "ipod-nano", + "created_at": "2011-10-20T14:05:13-04:00", + "body_html": "

It's the small iPod with one very big idea: Video. Now the world's most popular music player, available in 4GB and 8GB models, lets you enjoy TV shows, movies, video podcasts, and more. The larger, brighter display means amazing picture quality. In six eye-catching colors, iPod nano is stunning all around. And with models starting at just $149, little speaks volumes.

", + "title": "IPod Nano - 8GB", + "template_suffix": null, + "updated_at": "2011-10-20T14:05:13-04:00", + "id": 4, + "tags": "Emotive, Flash Memory, MP3, Music", + "images": [ + { + "position": 1, + "created_at": "2011-10-20T14:05:13-04:00", + "product_id": 4, + "updated_at": "2011-10-20T14:05:13-04:00", + "src": "http://static.shopify.com/s/files/1/6909/3384/products/ipod-nano.png?0", + "id": 850703190 + } + ], + "variants": [ + { + "position": 1, + "price": "199.00", + "product_id": 4, + "created_at": "2011-10-20T14:05:13-04:00", + "requires_shipping": true, + "title": "Pink", + "inventory_quantity": 10, + "compare_at_price": null, + "inventory_policy": "continue", + "updated_at": "2011-10-20T14:05:13-04:00", + "inventory_management": "shopify", + "id": 808950810, + "taxable": true, + "grams": 200, + "sku": "IPOD2008PINK", + "option1": "Pink", + "fulfillment_service": "manual", + "option2": null, + "option3": null + } + ], + "vendor": "Apple", + "published_at": "2007-12-31T19:00:00-05:00", + "options": [ + { + "name": "Title" + } + ] + } +] diff --git a/test/fixtures/publications.json b/test/fixtures/publications.json new file mode 100644 index 00000000..ab9e67fb --- /dev/null +++ b/test/fixtures/publications.json @@ -0,0 +1,9 @@ +{ + "publications": [ + { + "id": 55650051, + "created_at": "2016-05-20T13:12:10-04:00", + "name": "Buy Button" + } + ] +} diff --git a/test/fixtures/recurring_application_charges_no_active.json b/test/fixtures/recurring_application_charges_no_active.json index c806aa37..f3812604 100644 --- a/test/fixtures/recurring_application_charges_no_active.json +++ b/test/fixtures/recurring_application_charges_no_active.json @@ -35,4 +35,4 @@ "decorated_return_url": "http://yourapp.com?charge_id=455696195" } ] -} \ No newline at end of file +} diff --git a/test/fixtures/refund_calculate.json b/test/fixtures/refund_calculate.json new file mode 100644 index 00000000..dbd06213 --- /dev/null +++ b/test/fixtures/refund_calculate.json @@ -0,0 +1,31 @@ +{ + "refund": { + "shipping": { + "amount": "5.00", + "tax": "0.00", + "maximum_refundable": "5.00" + }, + "refund_line_items": [ + { + "quantity": 1, + "line_item_id": 518995019, + "price": "199.00", + "subtotal": "195.67", + "total_tax": "3.98", + "discounted_price": "199.00", + "discounted_total_price": "199.00", + "total_cart_discount_amount": "3.33" + } + ], + "transactions": [ + { + "order_id": 450789469, + "amount": "41.94", + "kind": "suggested_refund", + "gateway": "bogus", + "parent_id": 801038806, + "maximum_refundable": "41.94" + } + ] + } +} diff --git a/test/fixtures/report.json b/test/fixtures/report.json new file mode 100644 index 00000000..683b97e9 --- /dev/null +++ b/test/fixtures/report.json @@ -0,0 +1,9 @@ +{ + "report": { + "id": 987, + "name": "Custom App Report", + "shopify_ql": "SHOW quantity_count, total_sales BY product_type, vendor, product_title FROM products SINCE -1m UNTIL -0m ORDER BY total_sales DESC", + "updated_at": "2017-04-12T14:00:54-04:00", + "category": "custom_app_reports" + } +} diff --git a/test/fixtures/reports.json b/test/fixtures/reports.json new file mode 100644 index 00000000..bd829a6f --- /dev/null +++ b/test/fixtures/reports.json @@ -0,0 +1,11 @@ +{ + "reports": [ + { + "id": 987, + "name": "Custom App Report", + "shopify_ql": "SHOW quantity_count, total_sales BY product_type, vendor, product_title FROM products SINCE -1m UNTIL -0m ORDER BY total_sales DESC", + "updated_at": "2017-04-12T14:00:54-04:00", + "category": "custom_app_reports" + } + ] +} diff --git a/test/fixtures/shipping_zones.json b/test/fixtures/shipping_zones.json index f07b8ff2..877534a7 100644 --- a/test/fixtures/shipping_zones.json +++ b/test/fixtures/shipping_zones.json @@ -111,4 +111,4 @@ ] } ] -} \ No newline at end of file +} diff --git a/test/fixtures/storefront_access_token.json b/test/fixtures/storefront_access_token.json new file mode 100644 index 00000000..2b87ca0d --- /dev/null +++ b/test/fixtures/storefront_access_token.json @@ -0,0 +1,9 @@ +{ + "storefront_access_token": { + "id": 1, + "access_token": "477697f16c722efd66918cff7b3657a7", + "access_scope": "unauthenticated_read_product_listings", + "created_at": "2016-11-15T14:15:10-05:00", + "title": "Test" + } +} diff --git a/test/fixtures/storefront_access_tokens.json b/test/fixtures/storefront_access_tokens.json new file mode 100644 index 00000000..cad2bc54 --- /dev/null +++ b/test/fixtures/storefront_access_tokens.json @@ -0,0 +1,18 @@ +{ + "storefront_access_tokens": [ + { + "id": 1, + "access_token": "477697f16c722efd66918cff7b3657a7", + "access_scope": "unauthenticated_read_product_listings", + "created_at": "2016-11-15T14:15:10-05:00", + "title": "Test 1" + }, + { + "id": 2, + "access_token": "477697f16c722efd66918cff7b3657a7", + "access_scope": "unauthenticated_read_product_listings", + "created_at": "2016-11-15T14:15:10-05:00", + "title": "Test 2" + } + ] +} diff --git a/test/fixtures/tender_transactions.json b/test/fixtures/tender_transactions.json new file mode 100644 index 00000000..83e6d567 --- /dev/null +++ b/test/fixtures/tender_transactions.json @@ -0,0 +1,52 @@ +{ + "tender_transactions": [ + { + "id": 1, + "order_id": 450789469, + "amount": "138.46", + "currency": "CAD", + "user_id": null, + "test": true, + "processed_at": "2018-08-09T15:43:39-04:00", + "updated_at": "2018-08-09T15:43:41-04:00", + "remote_reference": "1118366", + "payment_method": "credit_card", + "payment_details": { + "credit_card_number": "•••• •••• •••• 1", + "credit_card_company": "Bogus" + } + }, + { + "id": 2, + "order_id": 450789469, + "amount": "128.16", + "currency": "CAD", + "user_id": null, + "test": true, + "processed_at": "2018-08-11T15:43:39-04:00", + "updated_at": "2018-08-09T15:43:41-04:00", + "remote_reference": "1118367", + "payment_method": "credit_card", + "payment_details": { + "credit_card_number": "•••• •••• •••• 2", + "credit_card_company": "Bogus" + } + }, + { + "id": 3, + "order_id": 450789469, + "amount": "28.16", + "currency": "CAD", + "user_id": null, + "test": true, + "processed_at": "2018-08-12T15:43:39-04:00", + "updated_at": "2018-08-09T15:43:41-04:00", + "remote_reference": "1118368", + "payment_method": "credit_card", + "payment_details": { + "credit_card_number": "•••• •••• •••• 3", + "credit_card_company": "Bogus" + } + } + ] +} diff --git a/test/fixtures/transaction.json b/test/fixtures/transaction.json index 33a70e9d..61ae6f98 100644 --- a/test/fixtures/transaction.json +++ b/test/fixtures/transaction.json @@ -26,4 +26,4 @@ "credit_card_company": "Visa" } } -} \ No newline at end of file +} diff --git a/test/fixtures/transactions.json b/test/fixtures/transactions.json new file mode 100644 index 00000000..b712d649 --- /dev/null +++ b/test/fixtures/transactions.json @@ -0,0 +1,29 @@ +[ + { + "amount": "409.94", + "authorization": "authorization-key", + "created_at": "2005-08-01T11:57:11-04:00", + "gateway": "bogus", + "id": 389404469, + "kind": "authorization", + "location_id": null, + "message": null, + "order_id": 450789469, + "parent_id": null, + "status": "success", + "test": false, + "user_id": null, + "device_id": null, + "receipt": { + "testcase": true, + "authorization": "123456" + }, + "payment_details": { + "avs_result_code": null, + "credit_card_bin": null, + "cvv_result_code": null, + "credit_card_number": "XXXX-XXXX-XXXX-4242", + "credit_card_company": "Visa" + } + } +] diff --git a/test/fixtures/user.json b/test/fixtures/user.json new file mode 100644 index 00000000..d0c5890a --- /dev/null +++ b/test/fixtures/user.json @@ -0,0 +1,23 @@ +{ + "user": { + "id": 799407056, + "first_name": "Steve", + "email": "steve@apple.com", + "url": "www.apple.com", + "im": null, + "screen_name": null, + "phone": null, + "last_name": "Jobs", + "account_owner": true, + "receive_announcements": 1, + "bio": null, + "permissions": [ + "full" + ], + "locale": "en", + "user_type": "regular", + "phone_validated?": false, + "tfa_enabled?": false, + "admin_graphql_api_id": "gid://shopify/StaffMember/799407056" + } +} diff --git a/test/fixtures/users.json b/test/fixtures/users.json new file mode 100644 index 00000000..83cb01eb --- /dev/null +++ b/test/fixtures/users.json @@ -0,0 +1,44 @@ +{ + "users": [ + { + "id": 799407056, + "first_name": "Steve", + "email": "steve@apple.com", + "url": "www.apple.com", + "im": null, + "screen_name": null, + "phone": null, + "last_name": "Jobs", + "account_owner": true, + "receive_announcements": 1, + "bio": null, + "permissions": [ + "full" + ], + "locale": "en", + "user_type": "regular", + "phone_validated?": false, + "tfa_enabled?": false, + "admin_graphql_api_id": "gid://shopify/StaffMember/799407056" + }, + { + "id": 930143300, + "first_name": "noaccesssteve", + "email": "noaccesssteve@jobs.com", + "url": "www.apple.com", + "im": null, + "screen_name": null, + "phone": null, + "last_name": "Jobs", + "account_owner": false, + "receive_announcements": 1, + "bio": null, + "permissions": [], + "locale": "en", + "user_type": "regular", + "phone_validated?": false, + "tfa_enabled?": false, + "admin_graphql_api_id": "gid://shopify/StaffMember/930143300" + } + ] +} diff --git a/test/fulfillment_event_test.py b/test/fulfillment_event_test.py new file mode 100644 index 00000000..df92c3b6 --- /dev/null +++ b/test/fulfillment_event_test.py @@ -0,0 +1,40 @@ +import shopify +from test.test_helper import TestCase + + +class FulFillmentEventTest(TestCase): + def test_get_fulfillment_event(self): + self.fake( + "orders/2776493818019/fulfillments/2608403447971/events", + method="GET", + body=self.load_fixture("fulfillment_event"), + ) + fulfillment_event = shopify.FulfillmentEvent.find(order_id=2776493818019, fulfillment_id=2608403447971) + self.assertEqual(1, len(fulfillment_event)) + + def test_create_fulfillment_event(self): + self.fake( + "orders/2776493818019/fulfillments/2608403447971/events", + method="POST", + body=self.load_fixture("fulfillment_event"), + headers={"Content-type": "application/json"}, + ) + new_fulfillment_event = shopify.FulfillmentEvent( + {"order_id": "2776493818019", "fulfillment_id": "2608403447971"} + ) + new_fulfillment_event.status = "ready_for_pickup" + new_fulfillment_event.save() + + def test_error_on_incorrect_status(self): + with self.assertRaises(AttributeError): + self.fake( + "orders/2776493818019/fulfillments/2608403447971/events/12584341209251", + method="GET", + body=self.load_fixture("fulfillment_event"), + ) + incorrect_status = "asdf" + fulfillment_event = shopify.FulfillmentEvent.find( + 12584341209251, order_id="2776493818019", fulfillment_id="2608403447971" + ) + fulfillment_event.status = incorrect_status + fulfillment_event.save() diff --git a/test/fulfillment_service_test.py b/test/fulfillment_service_test.py index 81296698..7519b3b9 100644 --- a/test/fulfillment_service_test.py +++ b/test/fulfillment_service_test.py @@ -1,15 +1,21 @@ import shopify from test.test_helper import TestCase + class FulfillmentServiceTest(TestCase): def test_create_new_fulfillment_service(self): - self.fake("fulfillment_services", method='POST', body=self.load_fixture('fulfillment_service'), headers={'Content-type': 'application/json'}) + self.fake( + "fulfillment_services", + method="POST", + body=self.load_fixture("fulfillment_service"), + headers={"Content-type": "application/json"}, + ) - fulfillment_service = shopify.FulfillmentService.create({'name': "SomeService"}) + fulfillment_service = shopify.FulfillmentService.create({"name": "SomeService"}) self.assertEqual("SomeService", fulfillment_service.name) def test_get_fulfillment_service(self): - self.fake("fulfillment_services/123456", method='GET', body=self.load_fixture('fulfillment_service')) + self.fake("fulfillment_services/123456", method="GET", body=self.load_fixture("fulfillment_service")) fulfillment_service = shopify.FulfillmentService.find(123456) self.assertEqual("SomeService", fulfillment_service.name) @@ -17,4 +23,4 @@ def test_get_fulfillment_service(self): def test_set_format_attribute(self): fulfillment_service = shopify.FulfillmentService() fulfillment_service.format = "json" - self.assertEqual("json", fulfillment_service.attributes['format']) + self.assertEqual("json", fulfillment_service.attributes["format"]) diff --git a/test/fulfillment_test.py b/test/fulfillment_test.py index 20034fb1..8bb84efb 100644 --- a/test/fulfillment_test.py +++ b/test/fulfillment_test.py @@ -2,41 +2,82 @@ from test.test_helper import TestCase from pyactiveresource.activeresource import ActiveResource + class FulFillmentTest(TestCase): - def setUp(self): super(FulFillmentTest, self).setUp() - self.fake("orders/450789469/fulfillments/255858046", method='GET', body=self.load_fixture('fulfillment')) + self.fake("orders/450789469/fulfillments/255858046", method="GET", body=self.load_fixture("fulfillment")) def test_able_to_open_fulfillment(self): fulfillment = shopify.Fulfillment.find(255858046, order_id=450789469) - success = self.load_fixture('fulfillment') - success = success.replace(b'pending',b'open') - self.fake("orders/450789469/fulfillments/255858046/open", method='POST', headers={'Content-length':'0', 'Content-type': 'application/json'}, body=success) + success = self.load_fixture("fulfillment") + success = success.replace(b"pending", b"open") + self.fake( + "orders/450789469/fulfillments/255858046/open", + method="POST", + headers={"Content-length": "0", "Content-type": "application/json"}, + body=success, + ) - self.assertEqual('pending', fulfillment.status) + self.assertEqual("pending", fulfillment.status) fulfillment.open() - self.assertEqual('open', fulfillment.status) + self.assertEqual("open", fulfillment.status) def test_able_to_complete_fulfillment(self): fulfillment = shopify.Fulfillment.find(255858046, order_id=450789469) - success = self.load_fixture('fulfillment') - success = success.replace(b'pending',b'success') - self.fake("orders/450789469/fulfillments/255858046/complete", method='POST', headers={'Content-length':'0', 'Content-type': 'application/json'}, body=success) + success = self.load_fixture("fulfillment") + success = success.replace(b"pending", b"success") + self.fake( + "orders/450789469/fulfillments/255858046/complete", + method="POST", + headers={"Content-length": "0", "Content-type": "application/json"}, + body=success, + ) - self.assertEqual('pending', fulfillment.status) + self.assertEqual("pending", fulfillment.status) fulfillment.complete() - self.assertEqual('success', fulfillment.status) - + self.assertEqual("success", fulfillment.status) + def test_able_to_cancel_fulfillment(self): fulfillment = shopify.Fulfillment.find(255858046, order_id=450789469) - cancelled = self.load_fixture('fulfillment') - cancelled = cancelled.replace(b'pending', b'cancelled') - self.fake("orders/450789469/fulfillments/255858046/cancel", method='POST', headers={'Content-length':'0', 'Content-type': 'application/json'}, body=cancelled) + cancelled = self.load_fixture("fulfillment") + cancelled = cancelled.replace(b"pending", b"cancelled") + self.fake( + "orders/450789469/fulfillments/255858046/cancel", + method="POST", + headers={"Content-length": "0", "Content-type": "application/json"}, + body=cancelled, + ) - self.assertEqual('pending', fulfillment.status) + self.assertEqual("pending", fulfillment.status) fulfillment.cancel() - self.assertEqual('cancelled', fulfillment.status) + self.assertEqual("cancelled", fulfillment.status) + + def test_update_tracking(self): + fulfillment = shopify.Fulfillment.find(255858046, order_id=450789469) + + tracking_info = {"number": 1111, "url": "http://www.my-url.com", "company": "my-company"} + notify_customer = False + + update_tracking = self.load_fixture("fulfillment") + update_tracking = update_tracking.replace(b"null-company", b"my-company") + update_tracking = update_tracking.replace(b"http://www.google.com/search?q=1Z2345", b"http://www.my-url.com") + update_tracking = update_tracking.replace(b"1Z2345", b"1111") + + self.fake( + "fulfillments/255858046/update_tracking", + method="POST", + headers={"Content-type": "application/json"}, + body=update_tracking, + ) + + self.assertEqual("null-company", fulfillment.tracking_company) + self.assertEqual("1Z2345", fulfillment.tracking_number) + self.assertEqual("http://www.google.com/search?q=1Z2345", fulfillment.tracking_url) + fulfillment.update_tracking(tracking_info, notify_customer) + self.assertEqual("my-company", fulfillment.tracking_company) + self.assertEqual("1111", fulfillment.tracking_number) + self.assertEqual("http://www.my-url.com", fulfillment.tracking_url) diff --git a/test/gift_card_test.py b/test/gift_card_test.py index 0414cf61..2b5e6055 100644 --- a/test/gift_card_test.py +++ b/test/gift_card_test.py @@ -1,23 +1,65 @@ +from decimal import Decimal import shopify from test.test_helper import TestCase -class GiftCardTest(TestCase): +class GiftCardTest(TestCase): def test_gift_card_creation(self): - self.fake('gift_cards', method='POST', code=202, body=self.load_fixture('gift_card'), headers={'Content-type': 'application/json'}) - gift_card = shopify.GiftCard.create({'code': 'd7a2bcggda89c293', 'note': "Gift card note."}) + self.fake( + "gift_cards", + method="POST", + code=202, + body=self.load_fixture("gift_card"), + headers={"Content-type": "application/json"}, + ) + gift_card = shopify.GiftCard.create({"code": "d7a2bcggda89c293", "note": "Gift card note."}) self.assertEqual("Gift card note.", gift_card.note) self.assertEqual("c293", gift_card.last_characters) def test_fetch_gift_cards(self): - self.fake('gift_cards', method='GET', code=200, body=self.load_fixture('gift_cards')) + self.fake("gift_cards", method="GET", code=200, body=self.load_fixture("gift_cards")) gift_cards = shopify.GiftCard.find() self.assertEqual(1, len(gift_cards)) def test_disable_gift_card(self): - self.fake('gift_cards/4208208', method='GET', code=200, body=self.load_fixture('gift_card')) - self.fake('gift_cards/4208208/disable', method='POST', code=200, body=self.load_fixture('gift_card_disabled'), headers={'Content-length': '0', 'Content-type': 'application/json'}) + self.fake("gift_cards/4208208", method="GET", code=200, body=self.load_fixture("gift_card")) + self.fake( + "gift_cards/4208208/disable", + method="POST", + code=200, + body=self.load_fixture("gift_card_disabled"), + headers={"Content-length": "0", "Content-type": "application/json"}, + ) gift_card = shopify.GiftCard.find(4208208) self.assertFalse(gift_card.disabled_at) gift_card.disable() self.assertTrue(gift_card.disabled_at) + + def test_adjust_gift_card(self): + self.fake("gift_cards/4208208", method="GET", code=200, body=self.load_fixture("gift_card")) + self.fake( + "gift_cards/4208208/adjustments", + method="POST", + code=201, + body=self.load_fixture("gift_card_adjustment"), + headers={"Content-type": "application/json"}, + ) + gift_card = shopify.GiftCard.find(4208208) + self.assertEqual(gift_card.balance, "25.00") + adjustment = gift_card.add_adjustment( + shopify.GiftCardAdjustment( + { + "amount": 100, + } + ) + ) + self.assertIsInstance(adjustment, shopify.GiftCardAdjustment) + self.assertEqual(Decimal(adjustment.amount), Decimal("100")) + + def test_search(self): + self.fake( + "gift_cards/search.json?query=balance%3A10", extension=False, body=self.load_fixture("gift_cards_search") + ) + + results = shopify.GiftCard.search(query="balance:10") + self.assertEqual(results[0].balance, "10.00") diff --git a/test/graphql_test.py b/test/graphql_test.py new file mode 100644 index 00000000..dc32b935 --- /dev/null +++ b/test/graphql_test.py @@ -0,0 +1,46 @@ +import shopify +import json +from test.test_helper import TestCase + + +class GraphQLTest(TestCase): + def setUp(self): + super(GraphQLTest, self).setUp() + shopify.ApiVersion.define_known_versions() + shopify_session = shopify.Session("this-is-my-test-show.myshopify.com", "unstable", "token") + shopify.ShopifyResource.activate_session(shopify_session) + self.client = shopify.GraphQL() + self.fake( + "graphql", + method="POST", + code=201, + headers={ + "X-Shopify-Access-Token": "token", + "Accept": "application/json", + "Content-Type": "application/json", + }, + ) + + def test_fetch_shop_with_graphql(self): + query = """ + { + shop { + name + id + } + } + """ + result = self.client.execute(query) + self.assertTrue(json.loads(result)["shop"]["name"] == "Apple Computers") + + def test_specify_operation_name(self): + query = """ + query GetShop{ + shop { + name + id + } + } + """ + result = self.client.execute(query, operation_name="GetShop") + self.assertTrue(json.loads(result)["shop"]["name"] == "Apple Computers") diff --git a/test/image_test.py b/test/image_test.py index 1234898d..3dad3817 100644 --- a/test/image_test.py +++ b/test/image_test.py @@ -1,44 +1,81 @@ import shopify from test.test_helper import TestCase +import base64 -class ImageTest(TestCase): +class ImageTest(TestCase): def test_create_image(self): - self.fake("products/632910392/images", method='POST', body=self.load_fixture('image'), headers={'Content-type': 'application/json'}) - image = shopify.Image({'product_id':632910392}) + self.fake( + "products/632910392/images", + method="POST", + body=self.load_fixture("image"), + headers={"Content-type": "application/json"}, + ) + image = shopify.Image({"product_id": 632910392}) image.position = 1 image.attachment = "R0lGODlhbgCMAPf/APbr48VySrxTO7IgKt2qmKQdJeK8lsFjROG5p/nz7Zg3MNmnd7Q1MLNVS9GId71hSJMZIuzTu4UtKbeEeakhKMl8U8WYjfr18YQaIbAf==" image.save() - self.assertEqual('http://cdn.shopify.com/s/files/1/0006/9093/3842/products/ipod-nano.png?v=1389388540', image.src) + self.assertEqual( + "http://cdn.shopify.com/s/files/1/0006/9093/3842/products/ipod-nano.png?v=1389388540", image.src + ) + self.assertEqual(850703190, image.id) + + def test_attach_image(self): + self.fake( + "products/632910392/images", + method="POST", + body=self.load_fixture("image"), + headers={"Content-type": "application/json"}, + ) + image = shopify.Image({"product_id": 632910392}) + image.position = 1 + binary_in = base64.b64decode( + "R0lGODlhbgCMAPf/APbr48VySrxTO7IgKt2qmKQdJeK8lsFjROG5p/nz7Zg3MNmnd7Q1MLNVS9GId71hSJMZIuzTu4UtKbeEeakhKMl8U8WYjfr18YQaIbAf==" + ) + image.attach_image(data=binary_in, filename="ipod-nano.png") + image.save() + binary_out = base64.b64decode(image.attachment) + + self.assertEqual( + "http://cdn.shopify.com/s/files/1/0006/9093/3842/products/ipod-nano.png?v=1389388540", image.src + ) self.assertEqual(850703190, image.id) + self.assertEqual(binary_in, binary_out) def test_create_image_then_add_parent_id(self): - self.fake("products/632910392/images", method='POST', body=self.load_fixture('image'), headers={'Content-type': 'application/json'}) + self.fake( + "products/632910392/images", + method="POST", + body=self.load_fixture("image"), + headers={"Content-type": "application/json"}, + ) image = shopify.Image() image.position = 1 image.product_id = 632910392 image.attachment = "R0lGODlhbgCMAPf/APbr48VySrxTO7IgKt2qmKQdJeK8lsFjROG5p/nz7Zg3MNmnd7Q1MLNVS9GId71hSJMZIuzTu4UtKbeEeakhKMl8U8WYjfr18YQaIbAf==" image.save() - self.assertEqual('http://cdn.shopify.com/s/files/1/0006/9093/3842/products/ipod-nano.png?v=1389388540', image.src) + self.assertEqual( + "http://cdn.shopify.com/s/files/1/0006/9093/3842/products/ipod-nano.png?v=1389388540", image.src + ) self.assertEqual(850703190, image.id) def test_get_images(self): - self.fake("products/632910392/images", method='GET', body=self.load_fixture('images')) + self.fake("products/632910392/images", method="GET", body=self.load_fixture("images")) image = shopify.Image.find(product_id=632910392) self.assertEqual(2, len(image)) def test_get_image(self): - self.fake("products/632910392/images/850703190", method='GET', body=self.load_fixture('image')) + self.fake("products/632910392/images/850703190", method="GET", body=self.load_fixture("image")) image = shopify.Image.find(850703190, product_id=632910392) self.assertEqual(850703190, image.id) def test_get_metafields_for_image(self): - fake_extension = 'json?metafield[owner_id]=850703190&metafield[owner_resource]=product_image' - self.fake("metafields", method='GET', extension=fake_extension, body=self.load_fixture('image_metafields')) + fake_extension = "json?metafield[owner_id]=850703190&metafield[owner_resource]=product_image" + self.fake("metafields", method="GET", extension=fake_extension, body=self.load_fixture("image_metafields")) - image = shopify.Image(attributes = { 'id': 850703190, 'product_id': 632910392 }) + image = shopify.Image(attributes={"id": 850703190, "product_id": 632910392}) metafields = image.metafields() self.assertEqual(1, len(metafields)) diff --git a/test/inventory_item_test.py b/test/inventory_item_test.py new file mode 100644 index 00000000..61c66e60 --- /dev/null +++ b/test/inventory_item_test.py @@ -0,0 +1,19 @@ +import shopify +from test.test_helper import TestCase + + +class InventoryItemTest(TestCase): + def test_fetch_inventory_item(self): + self.fake("inventory_items/123456789", method="GET", body=self.load_fixture("inventory_item")) + inventory_item = shopify.InventoryItem.find(123456789) + self.assertEqual(inventory_item.sku, "IPOD2008PINK") + + def test_fetch_inventory_item_ids(self): + self.fake( + "inventory_items.json?ids=123456789%2C234567891", + extension="", + method="GET", + body=self.load_fixture("inventory_items"), + ) + inventory_items = shopify.InventoryItem.find(ids="123456789,234567891") + self.assertEqual(3, len(inventory_items)) diff --git a/test/inventory_level_test.py b/test/inventory_level_test.py new file mode 100644 index 00000000..cf621b7c --- /dev/null +++ b/test/inventory_level_test.py @@ -0,0 +1,68 @@ +import shopify +import json +from six.moves.urllib.parse import urlencode +from test.test_helper import TestCase + + +class InventoryLevelTest(TestCase): + def test_fetch_inventory_level(self): + params = {"inventory_item_ids": [808950810, 39072856], "location_ids": [905684977, 487838322]} + + self.fake( + "inventory_levels.json?location_ids=905684977%2C487838322&inventory_item_ids=808950810%2C39072856", + method="GET", + extension="", + body=self.load_fixture("inventory_levels"), + ) + inventory_levels = shopify.InventoryLevel.find( + inventory_item_ids="808950810,39072856", location_ids="905684977,487838322" + ) + self.assertTrue( + all( + item.location_id in params["location_ids"] and item.inventory_item_id in params["inventory_item_ids"] + for item in inventory_levels + ) + ) + + def test_inventory_level_adjust(self): + self.fake( + "inventory_levels/adjust", + method="POST", + body=self.load_fixture("inventory_level"), + headers={"Content-type": "application/json"}, + ) + inventory_level = shopify.InventoryLevel.adjust(905684977, 808950810, 5) + self.assertEqual(inventory_level.available, 6) + + def test_inventory_level_connect(self): + self.fake( + "inventory_levels/connect", + method="POST", + body=self.load_fixture("inventory_level"), + headers={"Content-type": "application/json"}, + code=201, + ) + inventory_level = shopify.InventoryLevel.connect(905684977, 808950810) + self.assertEqual(inventory_level.available, 6) + + def test_inventory_level_set(self): + self.fake( + "inventory_levels/set", + method="POST", + body=self.load_fixture("inventory_level"), + headers={"Content-type": "application/json"}, + ) + inventory_level = shopify.InventoryLevel.set(905684977, 808950810, 6) + self.assertEqual(inventory_level.available, 6) + + def test_destroy_inventory_level(self): + inventory_level_response = json.loads(self.load_fixture("inventory_level").decode()) + inventory_level = shopify.InventoryLevel(inventory_level_response["inventory_level"]) + + query_params = urlencode( + {"inventory_item_id": inventory_level.inventory_item_id, "location_id": inventory_level.location_id} + ) + path = "inventory_levels.json?" + query_params + + self.fake(path, extension=False, method="DELETE", code=204, body="{}") + inventory_level.destroy() diff --git a/test/limits_test.py b/test/limits_test.py new file mode 100644 index 00000000..0d705abb --- /dev/null +++ b/test/limits_test.py @@ -0,0 +1,65 @@ +import shopify +from mock import patch +from test.test_helper import TestCase + + +class LimitsTest(TestCase): + """ + API Calls Limit Tests + + Conversion of test/limits_test.rb + """ + + @classmethod + def setUpClass(self): + self.original_headers = None + + def setUp(self): + super(LimitsTest, self).setUp() + self.fake("shop") + shopify.Shop.current() + # TODO: Fake not support Headers + self.original_headers = shopify.Shop.connection.response.headers + + def tearDown(self): + super(LimitsTest, self).tearDown() + shopify.Shop.connection.response.headers = self.original_headers + + def test_raise_error_no_header(self): + with self.assertRaises(Exception): + shopify.Limits.credit_left() + + def test_raise_error_invalid_header(self): + with patch.dict(shopify.Shop.connection.response.headers, {"bad": "value"}, clear=True): + with self.assertRaises(Exception): + shopify.Limits.credit_left() + + def test_fetch_limits_total(self): + with patch.dict( + shopify.Shop.connection.response.headers, {"X-Shopify-Shop-Api-Call-Limit": "40/40"}, clear=True + ): + self.assertEqual(40, shopify.Limits.credit_limit()) + + def test_fetch_used_calls(self): + with patch.dict( + shopify.Shop.connection.response.headers, {"X-Shopify-Shop-Api-Call-Limit": "1/40"}, clear=True + ): + self.assertEqual(1, shopify.Limits.credit_used()) + + def test_calculate_remaining_calls(self): + with patch.dict( + shopify.Shop.connection.response.headers, {"X-Shopify-Shop-Api-Call-Limit": "292/300"}, clear=True + ): + self.assertEqual(8, shopify.Limits.credit_left()) + + def test_maxed_credits_false(self): + with patch.dict( + shopify.Shop.connection.response.headers, {"X-Shopify-Shop-Api-Call-Limit": "125/300"}, clear=True + ): + self.assertFalse(shopify.Limits.credit_maxed()) + + def test_maxed_credits_true(self): + with patch.dict( + shopify.Shop.connection.response.headers, {"X-Shopify-Shop-Api-Call-Limit": "40/40"}, clear=True + ): + self.assertTrue(shopify.Limits.credit_maxed()) diff --git a/test/location_test.py b/test/location_test.py new file mode 100644 index 00000000..8550b1af --- /dev/null +++ b/test/location_test.py @@ -0,0 +1,31 @@ +import shopify +import json +from test.test_helper import TestCase + + +class LocationTest(TestCase): + def test_fetch_locations(self): + self.fake("locations", method="GET", body=self.load_fixture("locations")) + locations = shopify.Location.find() + self.assertEqual(2, len(locations)) + + def test_fetch_location(self): + self.fake("locations/487838322", method="GET", body=self.load_fixture("location")) + location = shopify.Location.find(487838322) + self.assertEqual(location.id, 487838322) + self.assertEqual(location.name, "Fifth Avenue AppleStore") + + def test_inventory_levels_returns_all_inventory_levels(self): + location = shopify.Location({"id": 487838322}) + + self.fake( + "locations/%s/inventory_levels" % location.id, + method="GET", + code=200, + body=self.load_fixture("location_inventory_levels"), + ) + inventory_levels = location.inventory_levels() + + self.assertEqual(location.id, inventory_levels[0].location_id) + self.assertEqual(27, inventory_levels[0].available) + self.assertEqual(9, inventory_levels[1].available) diff --git a/test/locations_test.py b/test/locations_test.py deleted file mode 100644 index 44a6768c..00000000 --- a/test/locations_test.py +++ /dev/null @@ -1,14 +0,0 @@ -import shopify -from test.test_helper import TestCase - -class LocationsTest(TestCase): - def test_fetch_locations(self): - self.fake("locations", method='GET', body=self.load_fixture('locations')) - locations = shopify.Location.find() - self.assertEqual(2,len(locations)) - - def test_fetch_location(self): - self.fake("locations/487838322", method='GET', body=self.load_fixture('location')) - location = shopify.Location.find(487838322) - self.assertEqual(location.id,487838322) - self.assertEqual(location.name,"Fifth Avenue AppleStore") diff --git a/test/marketing_event_test.py b/test/marketing_event_test.py new file mode 100644 index 00000000..2f73029d --- /dev/null +++ b/test/marketing_event_test.py @@ -0,0 +1,97 @@ +import shopify +import json +from test.test_helper import TestCase + + +class MarketingEventTest(TestCase): + def test_get_marketing_event(self): + self.fake("marketing_events/1", method="GET", body=self.load_fixture("marketing_event")) + marketing_event = shopify.MarketingEvent.find(1) + self.assertEqual(marketing_event.id, 1) + + def test_get_marketing_events(self): + self.fake("marketing_events", method="GET", body=self.load_fixture("marketing_events")) + marketing_events = shopify.MarketingEvent.find() + self.assertEqual(len(marketing_events), 2) + + def test_create_marketing_event(self): + self.fake( + "marketing_events", + method="POST", + body=self.load_fixture("marketing_event"), + headers={"Content-type": "application/json"}, + ) + + marketing_event = shopify.MarketingEvent() + marketing_event.currency_code = "GBP" + marketing_event.event_target = "facebook" + marketing_event.event_type = "post" + marketing_event.save() + + self.assertEqual(marketing_event.event_target, "facebook") + self.assertEqual(marketing_event.currency_code, "GBP") + self.assertEqual(marketing_event.event_type, "post") + + def test_delete_marketing_event(self): + self.fake("marketing_events/1", method="GET", body=self.load_fixture("marketing_event")) + self.fake("marketing_events/1", method="DELETE", body="destroyed") + + marketing_event = shopify.MarketingEvent.find(1) + marketing_event.destroy() + + self.assertEqual("DELETE", self.http.request.get_method()) + + def test_update_marketing_event(self): + self.fake("marketing_events/1", method="GET", code=200, body=self.load_fixture("marketing_event")) + self.fake( + "marketing_events/1", + method="PUT", + code=200, + body=self.load_fixture("marketing_event"), + headers={"Content-type": "application/json"}, + ) + + marketing_event = shopify.MarketingEvent.find(1) + marketing_event.currency = "USD" + + self.assertTrue(marketing_event.save()) + + def test_count_marketing_events(self): + self.fake("marketing_events/count", method="GET", body='{"count": 2}') + marketing_events_count = shopify.MarketingEvent.count() + self.assertEqual(marketing_events_count, 2) + + def test_add_engagements(self): + self.fake("marketing_events/1", method="GET", body=self.load_fixture("marketing_event")) + self.fake( + "marketing_events/1/engagements", + method="POST", + code=201, + body=self.load_fixture("engagement"), + headers={"Content-type": "application/json"}, + ) + + marketing_event = shopify.MarketingEvent.find(1) + response = marketing_event.add_engagements( + [ + { + "occurred_on": "2017-04-20", + "impressions_count": None, + "views_count": None, + "clicks_count": 10, + "shares_count": None, + "favorites_count": None, + "comments_count": None, + "ad_spend": None, + "is_cumulative": True, + } + ] + ) + + request_data = json.loads(self.http.request.data.decode("utf-8"))["engagements"] + self.assertEqual(len(request_data), 1) + self.assertEqual(request_data[0]["occurred_on"], "2017-04-20") + + response_data = json.loads(response.body.decode("utf-8"))["engagements"] + self.assertEqual(len(response_data), 1) + self.assertEqual(response_data[0]["occurred_on"], "2017-04-20") diff --git a/test/order_risk_test.py b/test/order_risk_test.py index fb8b09b8..4ca6dbff 100644 --- a/test/order_risk_test.py +++ b/test/order_risk_test.py @@ -1,42 +1,52 @@ import shopify from test.test_helper import TestCase -class OrderRiskTest(TestCase): - def test_create_order_risk(self): - self.fake("orders/450789469/risks", method='POST', body= self.load_fixture('order_risk'), headers={'Content-type': 'application/json'}) - v = shopify.OrderRisk({'order_id':450789469}) - v.message = "This order was placed from a proxy IP" - v.recommendation = "cancel" - v.score = "1.0" - v.source = "External" - v.merchant_message = "This order was placed from a proxy IP" - v.display = True - v.cause_cancel = True - v.save() - - self.assertEqual(284138680, v.id) - - def test_get_order_risks(self): - self.fake("orders/450789469/risks", method='GET', body= self.load_fixture('order_risks')) - v = shopify.OrderRisk.find(order_id=450789469) - self.assertEqual(2, len(v)) - - def test_get_order_risk(self): - self.fake("orders/450789469/risks/284138680", method='GET', body= self.load_fixture('order_risk')) - v = shopify.OrderRisk.find(284138680, order_id=450789469) - self.assertEqual(284138680, v.id) - - def test_delete_order_risk(self): - self.fake("orders/450789469/risks/284138680", method='GET', body= self.load_fixture('order_risk')) - self.fake("orders/450789469/risks/284138680", method='DELETE', body="destroyed") - v = shopify.OrderRisk.find(284138680, order_id=450789469) - v.destroy() - - def test_delete_order_risk(self): - self.fake("orders/450789469/risks/284138680", method='GET', body= self.load_fixture('order_risk')) - self.fake("orders/450789469/risks/284138680", method='PUT', body= self.load_fixture('order_risk'), headers={'Content-type': 'application/json'}) - - v = shopify.OrderRisk.find(284138680, order_id=450789469) - v.position = 3 - v.save() +class OrderRiskTest(TestCase): + def test_create_order_risk(self): + self.fake( + "orders/450789469/risks", + method="POST", + body=self.load_fixture("order_risk"), + headers={"Content-type": "application/json"}, + ) + v = shopify.OrderRisk({"order_id": 450789469}) + v.message = "This order was placed from a proxy IP" + v.recommendation = "cancel" + v.score = "1.0" + v.source = "External" + v.merchant_message = "This order was placed from a proxy IP" + v.display = True + v.cause_cancel = True + v.save() + + self.assertEqual(284138680, v.id) + + def test_get_order_risks(self): + self.fake("orders/450789469/risks", method="GET", body=self.load_fixture("order_risks")) + v = shopify.OrderRisk.find(order_id=450789469) + self.assertEqual(2, len(v)) + + def test_get_order_risk(self): + self.fake("orders/450789469/risks/284138680", method="GET", body=self.load_fixture("order_risk")) + v = shopify.OrderRisk.find(284138680, order_id=450789469) + self.assertEqual(284138680, v.id) + + def test_delete_order_risk(self): + self.fake("orders/450789469/risks/284138680", method="GET", body=self.load_fixture("order_risk")) + self.fake("orders/450789469/risks/284138680", method="DELETE", body="destroyed") + v = shopify.OrderRisk.find(284138680, order_id=450789469) + v.destroy() + + def test_delete_order_risk(self): + self.fake("orders/450789469/risks/284138680", method="GET", body=self.load_fixture("order_risk")) + self.fake( + "orders/450789469/risks/284138680", + method="PUT", + body=self.load_fixture("order_risk"), + headers={"Content-type": "application/json"}, + ) + + v = shopify.OrderRisk.find(284138680, order_id=450789469) + v.position = 3 + v.save() diff --git a/test/order_test.py b/test/order_test.py index d70645c4..257ab08a 100644 --- a/test/order_test.py +++ b/test/order_test.py @@ -3,8 +3,8 @@ from pyactiveresource.activeresource import ActiveResource from pyactiveresource.util import xml_to_dict -class OrderTest(TestCase): +class OrderTest(TestCase): def test_should_be_loaded_correctly_from_order_xml(self): order_xml = """ @@ -26,7 +26,7 @@ def test_should_be_loaded_correctly_from_order_xml(self): def test_should_be_able_to_add_note_attributes_to_an_order(self): order = shopify.Order() order.note_attributes = [] - order.note_attributes.append(shopify.NoteAttribute({'name': "color", 'value': "blue"})) + order.note_attributes.append(shopify.NoteAttribute({"name": "color", "value": "blue"})) order_xml = xml_to_dict(order.to_xml()) note_attributes = order_xml["order"]["note_attributes"] @@ -37,13 +37,20 @@ def test_should_be_able_to_add_note_attributes_to_an_order(self): self.assertEqual("blue", attribute["value"]) def test_get_order(self): - self.fake('orders/450789469', method='GET', body=self.load_fixture('order')) + self.fake("orders/450789469", method="GET", body=self.load_fixture("order")) order = shopify.Order.find(450789469) - self.assertEqual('bob.norman@hostmail.com', order.email) + self.assertEqual("bob.norman@hostmail.com", order.email) def test_get_order_transaction(self): - self.fake('orders/450789469', method='GET', body=self.load_fixture('order')) + self.fake("orders/450789469", method="GET", body=self.load_fixture("order")) order = shopify.Order.find(450789469) - self.fake('orders/450789469/transactions', method='GET', body=self.load_fixture('transaction')) + self.fake("orders/450789469/transactions", method="GET", body=self.load_fixture("transactions")) transactions = order.transactions() self.assertEqual("409.94", transactions[0].amount) + + def test_get_customer_orders(self): + self.fake("customers/207119551/orders", method="GET", body=self.load_fixture("orders"), code=200) + orders = shopify.Order.find(customer_id=207119551) + self.assertIsInstance(orders[0], shopify.Order) + self.assertEqual(450789469, orders[0].id) + self.assertEqual(207119551, orders[0].customer.id) diff --git a/test/pagination_test.py b/test/pagination_test.py new file mode 100644 index 00000000..72fc78e9 --- /dev/null +++ b/test/pagination_test.py @@ -0,0 +1,124 @@ +import shopify +import json +from test.test_helper import TestCase + + +class PaginationTest(TestCase): + def setUp(self): + super(PaginationTest, self).setUp() + prefix = self.http.site + "/admin/api/unstable" + fixture = json.loads(self.load_fixture("products").decode()) + + self.next_page_url = prefix + "/products.json?limit=2&page_info=FOOBAR" + self.prev_page_url = prefix + "/products.json?limit=2&page_info=BAZQUUX" + + next_headers = {"Link": "<" + self.next_page_url + '>; rel="next"'} + prev_headers = {"Link": "<" + self.prev_page_url + '>; rel="previous"'} + + self.fake( + "products", + url=prefix + "/products.json?limit=2", + body=json.dumps({"products": fixture[:2]}), + response_headers=next_headers, + ) + self.fake( + "products", + url=prefix + "/products.json?limit=2&page_info=FOOBAR", + body=json.dumps({"products": fixture[2:4]}), + response_headers=prev_headers, + ) + self.fake( + "products", + url=prefix + "/products.json?limit=2&page_info=BAZQUUX", + body=json.dumps({"products": fixture[:2]}), + response_headers=next_headers, + ) + + def test_nonpaginates_collection(self): + self.fake("draft_orders", method="GET", code=200, body=self.load_fixture("draft_orders")) + draft_orders = shopify.DraftOrder.find() + self.assertEqual(1, len(draft_orders)) + self.assertEqual(517119332, draft_orders[0].id) + self.assertIsInstance( + draft_orders, shopify.collection.PaginatedCollection, "find() result is not PaginatedCollection" + ) + + def test_paginated_collection(self): + items = shopify.Product.find(limit=2) + self.assertIsInstance(items, shopify.collection.PaginatedCollection, "find() result is not PaginatedCollection") + self.assertEqual(len(items), 2, "find() result has incorrect length") + + def test_pagination_next_page(self): + c = shopify.Product.find(limit=2) + self.assertEqual(c.next_page_url, self.next_page_url, "next url is incorrect") + n = c.next_page() + self.assertEqual(n.previous_page_url, self.prev_page_url, "prev url is incorrect") + self.assertIsInstance( + n, shopify.collection.PaginatedCollection, "next_page() result is not PaginatedCollection" + ) + self.assertEqual(len(n), 2, "next_page() collection has incorrect length") + self.assertIn("pagination", n.metadata) + self.assertIn("previous", n.metadata["pagination"], "next_page() collection doesn't have a previous page") + + with self.assertRaises(IndexError, msg="next_page() did not raise with no next page"): + n.next_page() + + def test_pagination_previous(self): + c = shopify.Product.find(limit=2) + self.assertEqual(c.next_page_url, self.next_page_url, "next url is incorrect") + self.assertTrue(c.has_next_page()) + n = c.next_page() + self.assertEqual(n.previous_page_url, self.prev_page_url, "prev url is incorrect") + self.assertTrue(n.has_previous_page()) + + p = n.previous_page() + + self.assertIsInstance( + p, shopify.collection.PaginatedCollection, "previous_page() result is not PaginatedCollection" + ) + self.assertEqual(len(p), 4, "previous_page() collection has incorrect length") # cached + self.assertIn("pagination", p.metadata) + self.assertIn("next", p.metadata["pagination"], "previous_page() collection doesn't have a next page") + + with self.assertRaises(IndexError, msg="previous_page() did not raise with no previous page"): + p.previous_page() + + def test_paginated_collection_iterator(self): + c = shopify.Product.find(limit=2) + + i = iter(c) + self.assertEqual(next(i).id, 1) + self.assertEqual(next(i).id, 2) + with self.assertRaises(StopIteration): + next(i) + + def test_paginated_collection_no_cache(self): + c = shopify.Product.find(limit=2) + + n = c.next_page(no_cache=True) + self.assertIsNone(c._next, "no_cache=True still caches") + self.assertIsNone(n._previous, "no_cache=True still caches") + + p = n.previous_page(no_cache=True) + self.assertIsNone(p._next, "no_cache=True still caches") + self.assertIsNone(n._previous, "no_cache=True still caches") + + def test_paginated_iterator(self): + c = shopify.Product.find(limit=2) + + i = iter(shopify.PaginatedIterator(c)) + + first_page = iter(next(i)) + self.assertEqual(next(first_page).id, 1) + self.assertEqual(next(first_page).id, 2) + with self.assertRaises(StopIteration): + next(first_page) + + second_page = iter(next(i)) + self.assertEqual(next(second_page).id, 3) + self.assertEqual(next(second_page).id, 4) + with self.assertRaises(StopIteration): + next(second_page) + + with self.assertRaises(StopIteration): + next(i) diff --git a/test/payouts_test.py b/test/payouts_test.py new file mode 100644 index 00000000..f82851c2 --- /dev/null +++ b/test/payouts_test.py @@ -0,0 +1,17 @@ +import shopify +from test.test_helper import TestCase + + +class PayoutsTest(TestCase): + prefix = "/admin/api/unstable/shopify_payments" + + def test_get_payouts(self): + self.fake("payouts", method="GET", prefix=self.prefix, body=self.load_fixture("payouts")) + payouts = shopify.Payouts.find() + self.assertGreater(len(payouts), 0) + + def test_get_one_payout(self): + self.fake("payouts/623721858", method="GET", prefix=self.prefix, body=self.load_fixture("payout")) + payouts = shopify.Payouts.find(623721858) + self.assertEqual("paid", payouts.status) + self.assertEqual("41.90", payouts.amount) diff --git a/test/price_rules_test.py b/test/price_rules_test.py new file mode 100644 index 00000000..a28b15de --- /dev/null +++ b/test/price_rules_test.py @@ -0,0 +1,109 @@ +import json +from test.test_helper import TestCase + +import shopify + + +class PriceRuleTest(TestCase): + def setUp(self): + super(PriceRuleTest, self).setUp() + self.fake("price_rules/1213131", body=self.load_fixture("price_rule")) + self.price_rule = shopify.PriceRule.find(1213131) + + def test_get_price_rule(self): + self.fake("price_rule/1213131", method="GET", code=200, body=self.load_fixture("price_rule")) + price_rule = shopify.PriceRule.find(1213131) + self.assertEqual(1213131, price_rule.id) + + def test_get_all_price_rules(self): + self.fake("price_rules", method="GET", code=200, body=self.load_fixture("price_rules")) + price_rules = shopify.PriceRule.find() + self.assertEqual(2, len(price_rules)) + + def test_update_price_rule(self): + self.price_rule.title = "Buy One Get One" + self.fake( + "price_rules/1213131", + method="PUT", + code=200, + body=self.load_fixture("price_rule"), + headers={"Content-type": "application/json"}, + ) + self.price_rule.save() + self.assertEqual("Buy One Get One", json.loads(self.http.request.data.decode("utf-8"))["price_rule"]["title"]) + + def test_delete_price_rule(self): + self.fake("price_rules/1213131", method="DELETE", body="destroyed") + self.price_rule.destroy() + self.assertEqual("DELETE", self.http.request.get_method()) + + def test_price_rule_creation(self): + self.fake( + "price_rules", + method="POST", + code=202, + body=self.load_fixture("price_rule"), + headers={"Content-type": "application/json"}, + ) + price_rule = shopify.PriceRule.create( + { + "title": "BOGO", + "target_type": "line_item", + "target_selection": "all", + "allocation_method": "across", + "value_type": "percentage", + "value": -100, + "once_per_customer": "true", + "customer_selection": "all", + } + ) + self.assertEqual("BOGO", price_rule.title) + self.assertEqual("line_item", price_rule.target_type) + + def test_get_discount_codes(self): + self.fake( + "price_rules/1213131/discount_codes", method="GET", code=200, body=self.load_fixture("discount_codes") + ) + discount_codes = self.price_rule.discount_codes() + self.assertEqual(1, len(discount_codes)) + + def test_add_discount_code(self): + price_rule_discount_fixture = self.load_fixture("discount_code") + discount_code = json.loads(price_rule_discount_fixture.decode("utf-8")) + self.fake( + "price_rules/1213131/discount_codes", + method="POST", + body=price_rule_discount_fixture, + headers={"Content-type": "application/json"}, + ) + price_rule_discount_response = self.price_rule.add_discount_code( + shopify.DiscountCode(discount_code["discount_code"]) + ) + self.assertEqual(discount_code, json.loads(self.http.request.data.decode("utf-8"))) + self.assertIsInstance(price_rule_discount_response, shopify.DiscountCode) + self.assertEqual(discount_code["discount_code"]["code"], price_rule_discount_response.code) + + def test_create_batch_discount_codes(self): + self.fake( + "price_rules/1213131/batch", + method="POST", + code=201, + body=self.load_fixture("discount_code_creation"), + headers={"Content-type": "application/json"}, + ) + batch = self.price_rule.create_batch([{"code": "SUMMER1"}, {"code": "SUMMER2"}, {"code": "SUMMER3"}]) + + self.assertEqual(3, batch.codes_count) + self.assertEqual("queued", batch.status) + + def test_find_batch_job(self): + self.fake( + "price_rules/1213131/batch/989355119", + method="GET", + code=200, + body=self.load_fixture("discount_code_creation"), + ) + batch = self.price_rule.find_batch(989355119) + + self.assertEqual(3, batch.codes_count) + self.assertEqual("queued", batch.status) diff --git a/test/product_listing_test.py b/test/product_listing_test.py new file mode 100644 index 00000000..dcdc048d --- /dev/null +++ b/test/product_listing_test.py @@ -0,0 +1,43 @@ +import shopify +from test.test_helper import TestCase + + +class ProductListingTest(TestCase): + def test_get_product_listings(self): + self.fake("product_listings", method="GET", code=200, body=self.load_fixture("product_listings")) + + product_listings = shopify.ProductListing.find() + self.assertEqual(2, len(product_listings)) + self.assertEqual(2, product_listings[0].product_id) + self.assertEqual(1, product_listings[1].product_id) + self.assertEqual("Synergistic Silk Chair", product_listings[0].title) + self.assertEqual("Rustic Copper Bottle", product_listings[1].title) + + def test_get_product_listing(self): + self.fake("product_listings/2", method="GET", code=200, body=self.load_fixture("product_listing")) + + product_listing = shopify.ProductListing.find(2) + self.assertEqual("Synergistic Silk Chair", product_listing.title) + + def test_reload_product_listing(self): + self.fake("product_listings/2", method="GET", code=200, body=self.load_fixture("product_listing")) + + product_listing = shopify.ProductListing() + product_listing.product_id = 2 + product_listing.reload() + + self.assertEqual("Synergistic Silk Chair", product_listing.title) + + def test_get_product_listing_product_ids(self): + self.fake( + "product_listings/product_ids", + method="GET", + status=200, + body=self.load_fixture("product_listing_product_ids"), + ) + + product_ids = shopify.ProductListing.product_ids() + + self.assertEqual(2, len(product_ids)) + self.assertEqual(2, product_ids[0]) + self.assertEqual(1, product_ids[1]) diff --git a/test/product_publication_test.py b/test/product_publication_test.py new file mode 100644 index 00000000..671e7786 --- /dev/null +++ b/test/product_publication_test.py @@ -0,0 +1,68 @@ +import shopify +import json +from test.test_helper import TestCase + + +class ProductPublicationTest(TestCase): + def test_find_all_product_publications(self): + self.fake( + "publications/55650051/product_publications", method="GET", body=self.load_fixture("product_publications") + ) + product_publications = shopify.ProductPublication.find(publication_id=55650051) + + self.assertEqual(647162527768, product_publications[0].id) + self.assertEqual(8267093571, product_publications[0].product_id) + + def test_find_product_publication(self): + self.fake( + "publications/55650051/product_publications/647162527768", + method="GET", + body=self.load_fixture("product_publication"), + code=200, + ) + product_publication = shopify.ProductPublication.find(647162527768, publication_id=55650051) + + self.assertEqual(647162527768, product_publication.id) + self.assertEqual(8267093571, product_publication.product_id) + + def test_create_product_publication(self): + self.fake( + "publications/55650051/product_publications", + method="POST", + headers={"Content-type": "application/json"}, + body=self.load_fixture("product_publication"), + code=201, + ) + + product_publication = shopify.ProductPublication.create( + { + "publication_id": 55650051, + "published_at": "2018-01-29T14:06:08-05:00", + "published": True, + "product_id": 8267093571, + } + ) + + expected_body = { + "product_publication": { + "published_at": "2018-01-29T14:06:08-05:00", + "published": True, + "product_id": 8267093571, + } + } + + self.assertEqual(expected_body, json.loads(self.http.request.data.decode("utf-8"))) + + def test_destroy_product_publication(self): + self.fake( + "publications/55650051/product_publications/647162527768", + method="GET", + body=self.load_fixture("product_publication"), + code=200, + ) + product_publication = shopify.ProductPublication.find(647162527768, publication_id=55650051) + + self.fake("publications/55650051/product_publications/647162527768", method="DELETE", body="{}", code=200) + product_publication.destroy() + + self.assertEqual("DELETE", self.http.request.get_method()) diff --git a/test/product_test.py b/test/product_test.py index dcc9ae72..de183691 100644 --- a/test/product_test.py +++ b/test/product_test.py @@ -1,18 +1,28 @@ import shopify from test.test_helper import TestCase -class ProductTest(TestCase): +class ProductTest(TestCase): def setUp(self): super(ProductTest, self).setUp() - self.fake("products/632910392", body=self.load_fixture('product')) + self.fake("products/632910392", body=self.load_fixture("product")) self.product = shopify.Product.find(632910392) def test_add_metafields_to_product(self): - self.fake("products/632910392/metafields", method='POST', code=201, body=self.load_fixture('metafield'), headers={'Content-type': 'application/json'}) - - field = self.product.add_metafield(shopify.Metafield({'namespace': "contact", 'key': "email", 'value': "123@example.com", 'value_type': "string"})) + self.fake( + "products/632910392/metafields", + method="POST", + code=201, + body=self.load_fixture("metafield"), + headers={"Content-type": "application/json"}, + ) + + field = self.product.add_metafield( + shopify.Metafield( + {"namespace": "contact", "key": "email", "value": "123@example.com", "value_type": "string"} + ) + ) self.assertFalse(field.is_new()) self.assertEqual("contact", field.namespace) @@ -20,7 +30,7 @@ def test_add_metafields_to_product(self): self.assertEqual("123@example.com", field.value) def test_get_metafields_for_product(self): - self.fake("products/632910392/metafields", body=self.load_fixture('metafields')) + self.fake("products/632910392/metafields", body=self.load_fixture("metafields")) metafields = self.product.metafields() @@ -29,7 +39,7 @@ def test_get_metafields_for_product(self): self.assertTrue(isinstance(field, shopify.Metafield)) def test_get_metafields_for_product_with_params(self): - self.fake("products/632910392/metafields.json?limit=2", extension=False, body=self.load_fixture('metafields')) + self.fake("products/632910392/metafields.json?limit=2", extension=False, body=self.load_fixture("metafields")) metafields = self.product.metafields(limit=2) self.assertEqual(2, len(metafields)) @@ -37,26 +47,41 @@ def test_get_metafields_for_product_with_params(self): self.assertTrue(isinstance(field, shopify.Metafield)) def test_get_metafields_for_product_count(self): - self.fake("products/632910392/metafields/count", body=self.load_fixture('metafields_count')) + self.fake("products/632910392/metafields/count", body=self.load_fixture("metafields_count")) metafields_count = self.product.metafields_count() self.assertEqual(2, metafields_count) def test_get_metafields_for_product_count_with_params(self): - self.fake("products/632910392/metafields/count.json?value_type=string", extension=False, body=self.load_fixture('metafields_count')) + self.fake( + "products/632910392/metafields/count.json?value_type=string", + extension=False, + body=self.load_fixture("metafields_count"), + ) metafields_count = self.product.metafields_count(value_type="string") self.assertEqual(2, metafields_count) def test_update_loaded_variant(self): - self.fake("products/632910392/variants/808950810", method='PUT', code=200, body=self.load_fixture('variant')) + self.fake("products/632910392/variants/808950810", method="PUT", code=200, body=self.load_fixture("variant")) variant = self.product.variants[0] variant.price = "0.50" variant.save def test_add_variant_to_product(self): - self.fake("products/632910392/variants", method='POST', body=self.load_fixture('variant'), headers={'Content-type': 'application/json'}) - self.fake("products/632910392/variants/808950810", method='PUT', code=200, body=self.load_fixture('variant'), headers={'Content-type': 'application/json'}) + self.fake( + "products/632910392/variants", + method="POST", + body=self.load_fixture("variant"), + headers={"Content-type": "application/json"}, + ) + self.fake( + "products/632910392/variants/808950810", + method="PUT", + code=200, + body=self.load_fixture("variant"), + headers={"Content-type": "application/json"}, + ) v = shopify.Variant() self.assertTrue(self.product.add_variant(v)) diff --git a/test/publication_test.py b/test/publication_test.py new file mode 100644 index 00000000..dab26fc5 --- /dev/null +++ b/test/publication_test.py @@ -0,0 +1,11 @@ +import shopify +from test.test_helper import TestCase + + +class PublicationTest(TestCase): + def test_find_all_publications(self): + self.fake("publications") + publications = shopify.Publication.find() + + self.assertEqual(55650051, publications[0].id) + self.assertEqual("Buy Button", publications[0].name) diff --git a/test/recurring_charge_test.py b/test/recurring_charge_test.py index af5fcfb2..c785388e 100644 --- a/test/recurring_charge_test.py +++ b/test/recurring_charge_test.py @@ -1,11 +1,17 @@ import shopify from test.test_helper import TestCase + class RecurringApplicationChargeTest(TestCase): def test_activate_charge(self): # Just check that calling activate doesn't raise an exception. - self.fake("recurring_application_charges/35463/activate", method='POST',headers={'Content-length':'0', 'Content-type': 'application/json'}, body=" ") - charge = shopify.RecurringApplicationCharge({'id': 35463}) + self.fake( + "recurring_application_charges/35463/activate", + method="POST", + headers={"Content-length": "0", "Content-type": "application/json"}, + body=" ", + ) + charge = shopify.RecurringApplicationCharge({"id": 35463}) charge.activate() def test_current_method_returns_active_charge(self): @@ -26,7 +32,11 @@ def test_usage_charges_method_returns_associated_usage_charges(self): self.fake("recurring_application_charges") charge = shopify.RecurringApplicationCharge.current() - self.fake("recurring_application_charges/455696195/usage_charges", method='GET', body=self.load_fixture('usage_charges')) + self.fake( + "recurring_application_charges/455696195/usage_charges", + method="GET", + body=self.load_fixture("usage_charges"), + ) usage_charges = charge.usage_charges() self.assertEqual(len(usage_charges), 2) @@ -35,6 +45,19 @@ def test_customize_method_increases_capped_amount(self): charge = shopify.RecurringApplicationCharge.current() self.assertEqual(charge.capped_amount, 100) - self.fake("recurring_application_charges/455696195/customize.json?recurring_application_charge%5Bcapped_amount%5D=200", extension=False, method='PUT', headers={'Content-length':'0', 'Content-type': 'application/json'}, body=self.load_fixture('recurring_application_charge_adjustment')) - charge.customize(capped_amount= 200) + self.fake( + "recurring_application_charges/455696195/customize.json?recurring_application_charge%5Bcapped_amount%5D=200", + extension=False, + method="PUT", + headers={"Content-length": "0", "Content-type": "application/json"}, + body=self.load_fixture("recurring_application_charge_adjustment"), + ) + charge.customize(capped_amount=200) self.assertTrue(charge.update_capped_amount_url) + + def test_destroy_recurring_application_charge(self): + self.fake("recurring_application_charges") + charge = shopify.RecurringApplicationCharge.current() + + self.fake("recurring_application_charges/455696195", method="DELETE", body="{}") + charge.destroy() diff --git a/test/refund_test.py b/test/refund_test.py index f7a47851..905bbabc 100644 --- a/test/refund_test.py +++ b/test/refund_test.py @@ -1,11 +1,28 @@ import shopify from test.test_helper import TestCase + class RefundTest(TestCase): def setUp(self): super(RefundTest, self).setUp() - self.fake("orders/450789469/refunds/509562969", method='GET', body=self.load_fixture('refund')) + self.fake("orders/450789469/refunds/509562969", method="GET", body=self.load_fixture("refund")) def test_should_find_a_specific_refund(self): refund = shopify.Refund.find(509562969, order_id=450789469) self.assertEqual("209.00", refund.transactions[0].amount) + + def test_calculate_refund_for_order(self): + self.fake( + "orders/450789469/refunds/calculate", + method="POST", + code=201, + body=self.load_fixture("refund_calculate"), + headers={"Content-type": "application/json"}, + ) + refund = shopify.Refund.calculate( + order_id=450789469, refund_line_items=[{"line_item_id": 518995019, "quantity": 1}] + ) + + self.assertEqual("suggested_refund", refund.transactions[0].kind) + self.assertEqual("41.94", refund.transactions[0].amount) + self.assertEqual(518995019, refund.refund_line_items[0].line_item_id) diff --git a/test/report_test.py b/test/report_test.py new file mode 100644 index 00000000..bfb66935 --- /dev/null +++ b/test/report_test.py @@ -0,0 +1,36 @@ +import shopify +from test.test_helper import TestCase + + +class CustomerSavedSearchTest(TestCase): + def test_get_report(self): + self.fake("reports/987", method="GET", code=200, body=self.load_fixture("report")) + report = shopify.Report.find(987) + self.assertEqual(987, report.id) + + def test_get_reports(self): + self.fake("reports", method="GET", code=200, body=self.load_fixture("reports")) + reports = shopify.Report.find() + self.assertEqual("custom_app_reports", reports[0].category) + + def test_create_report(self): + self.fake( + "reports", + method="POST", + code=201, + body=self.load_fixture("report"), + headers={"Content-type": "application/json"}, + ) + report = shopify.Report.create( + { + "name": "Custom App Report", + "shopify_ql": "SHOW quantity_count, total_sales BY product_type, vendor, product_title FROM products SINCE -1m UNTIL -0m ORDER BY total_sales DESC", + } + ) + self.assertEqual("custom_app_reports", report.category) + + def test_delete_report(self): + self.fake("reports/987", method="GET", code=200, body=self.load_fixture("report")) + self.fake("reports", method="DELETE", code=200, body="[]") + report = shopify.Report.find(987) + self.assertTrue(report.destroy) diff --git a/test/resource_feedback_test.py b/test/resource_feedback_test.py new file mode 100644 index 00000000..47ee9f92 --- /dev/null +++ b/test/resource_feedback_test.py @@ -0,0 +1,40 @@ +import json +import shopify +from test.test_helper import TestCase + + +class ResourceFeedbackTest(TestCase): + def test_get_resource_feedback(self): + body = json.dumps({"resource_feedback": [{"resource_type": "Shop"}]}) + self.fake("resource_feedback", method="GET", body=body) + + feedback = shopify.ResourceFeedback.find() + + self.assertEqual("Shop", feedback[0].resource_type) + + def test_save_with_resource_feedback_endpoint(self): + body = json.dumps({"resource_feedback": {}}) + self.fake("resource_feedback", method="POST", body=body, headers={"Content-Type": "application/json"}) + + shopify.ResourceFeedback().save() + + self.assertEqual(body, self.http.request.data.decode("utf-8")) + + def test_get_resource_feedback_with_product_id(self): + body = json.dumps({"resource_feedback": [{"resource_type": "Product"}]}) + self.fake("products/42/resource_feedback", method="GET", body=body) + + feedback = shopify.ResourceFeedback.find(product_id=42) + + self.assertEqual("Product", feedback[0].resource_type) + + def test_save_with_product_id_resource_feedback_endpoint(self): + body = json.dumps({"resource_feedback": {}}) + self.fake( + "products/42/resource_feedback", method="POST", body=body, headers={"Content-Type": "application/json"} + ) + + feedback = shopify.ResourceFeedback({"product_id": 42}) + feedback.save() + + self.assertEqual(body, self.http.request.data.decode("utf-8")) diff --git a/test/session_test.py b/test/session_test.py index 11843810..8d73e293 100644 --- a/test/session_test.py +++ b/test/session_test.py @@ -6,35 +6,41 @@ from six.moves import urllib from six import u + class SessionTest(TestCase): + @classmethod + def setUpClass(self): + shopify.ApiVersion.define_known_versions() + shopify.ApiVersion.define_version(shopify.Release("2019-04")) + + @classmethod + def tearDownClass(self): + shopify.ApiVersion.clear_defined_versions() def test_not_be_valid_without_a_url(self): - session = shopify.Session("", "any-token") + session = shopify.Session("", "unstable", "any-token") self.assertFalse(session.valid) def test_not_be_valid_without_token(self): - session = shopify.Session("testshop.myshopify.com") + session = shopify.Session("testshop.myshopify.com", "unstable") self.assertFalse(session.valid) def test_be_valid_with_any_token_and_any_url(self): - session = shopify.Session("testshop.myshopify.com", "any-token") + session = shopify.Session("testshop.myshopify.com", "unstable", "any-token") self.assertTrue(session.valid) def test_ignore_everything_but_the_subdomain_in_the_shop(self): - session = shopify.Session("http://user:pass@testshop.notshopify.net/path", "any-token") - self.assertEqual("https://testshop.myshopify.com/admin", session.site) + session = shopify.Session("http://user:pass@testshop.notshopify.net/path", "unstable", "any-token") + self.assertEqual("https://testshop.myshopify.com/admin/api/unstable", session.site) def test_append_the_myshopify_domain_if_not_given(self): - session = shopify.Session("testshop", "any-token") - self.assertEqual("https://testshop.myshopify.com/admin", session.site) - - def test_not_raise_error_without_params(self): - session = shopify.Session("testshop.myshopify.com", "any-token") + session = shopify.Session("testshop", "unstable", "any-token") + self.assertEqual("https://testshop.myshopify.com/admin/api/unstable", session.site) def test_raise_error_if_params_passed_but_signature_omitted(self): with self.assertRaises(shopify.ValidationException): - session = shopify.Session("testshop.myshopify.com") - token = session.request_token({'code':'any_code', 'foo': 'bar', 'timestamp':'1234'}) + session = shopify.Session("testshop.myshopify.com", "unstable") + token = session.request_token({"code": "any_code", "foo": "bar", "timestamp": "1234"}) def test_setup_api_key_and_secret_for_all_sessions(self): shopify.Session.setup(api_key="My test key", secret="My test secret") @@ -42,31 +48,31 @@ def test_setup_api_key_and_secret_for_all_sessions(self): self.assertEqual("My test secret", shopify.Session.secret) def test_use_https_protocol_by_default_for_all_sessions(self): - self.assertEqual('https', shopify.Session.protocol) + self.assertEqual("https", shopify.Session.protocol) - def test_temp_reset_shopify_ShopifyResource_site_to_original_value(self): + def test_temp_reset_shopify_shopify_resource_site_to_original_value(self): shopify.Session.setup(api_key="key", secret="secret") - session1 = shopify.Session('fakeshop.myshopify.com', 'token1') + session1 = shopify.Session("fakeshop.myshopify.com", "2019-04", "token1") shopify.ShopifyResource.activate_session(session1) assigned_site = "" - with shopify.Session.temp("testshop.myshopify.com", "any-token"): + with shopify.Session.temp("testshop.myshopify.com", "unstable", "any-token"): assigned_site = shopify.ShopifyResource.site - self.assertEqual('https://testshop.myshopify.com/admin', assigned_site) - self.assertEqual('https://fakeshop.myshopify.com/admin', shopify.ShopifyResource.site) + self.assertEqual("https://testshop.myshopify.com/admin/api/unstable", assigned_site) + self.assertEqual("https://fakeshop.myshopify.com/admin/api/2019-04", shopify.ShopifyResource.site) def test_myshopify_domain_supports_non_standard_ports(self): try: shopify.Session.setup(api_key="key", secret="secret", myshopify_domain="localhost", port=3000) - session = shopify.Session('fakeshop.localhost:3000', 'token1') + session = shopify.Session("fakeshop.localhost:3000", "unstable", "token1") shopify.ShopifyResource.activate_session(session) - self.assertEqual('https://fakeshop.localhost:3000/admin', shopify.ShopifyResource.site) + self.assertEqual("https://fakeshop.localhost:3000/admin/api/unstable", shopify.ShopifyResource.site) - session = shopify.Session('fakeshop', 'token1') + session = shopify.Session("fakeshop", "unstable", "token1") shopify.ShopifyResource.activate_session(session) - self.assertEqual('https://fakeshop.localhost:3000/admin', shopify.ShopifyResource.site) + self.assertEqual("https://fakeshop.localhost:3000/admin/api/unstable", shopify.ShopifyResource.site) finally: shopify.Session.setup(myshopify_domain="myshopify.com", port=None) @@ -74,137 +80,242 @@ def test_temp_works_without_currently_active_session(self): shopify.ShopifyResource.clear_session() assigned_site = "" - with shopify.Session.temp("testshop.myshopify.com", "any-token"): + with shopify.Session.temp("testshop.myshopify.com", "unstable", "any-token"): assigned_site = shopify.ShopifyResource.site - self.assertEqual('https://testshop.myshopify.com/admin', assigned_site) - self.assertEqual('https://None/admin', shopify.ShopifyResource.site) + self.assertEqual("https://testshop.myshopify.com/admin/api/unstable", assigned_site) + self.assertEqual("https://none/admin/api/unstable", shopify.ShopifyResource.site) - def test_create_permission_url_returns_correct_url_with_single_scope_no_redirect_uri(self): + def test_create_permission_url_returns_correct_url_with_redirect_uri(self): shopify.Session.setup(api_key="My_test_key", secret="My test secret") - session = shopify.Session('http://localhost.myshopify.com') - scope = ["write_products"] - permission_url = session.create_permission_url(scope) - self.assertEqual("https://localhost.myshopify.com/admin/oauth/authorize?client_id=My_test_key&scope=write_products", self.normalize_url(permission_url)) - - def test_create_permission_url_returns_correct_url_with_single_scope_and_redirect_uri(self): + session = shopify.Session("http://localhost.myshopify.com", "unstable") + permission_url = session.create_permission_url("my_redirect_uri.com") + self.assertEqual( + "https://localhost.myshopify.com/admin/oauth/authorize?client_id=My_test_key&redirect_uri=my_redirect_uri.com", + self.normalize_url(permission_url), + ) + + def test_create_permission_url_returns_correct_url_with_redirect_uri_and_single_scope(self): shopify.Session.setup(api_key="My_test_key", secret="My test secret") - session = shopify.Session('http://localhost.myshopify.com') + session = shopify.Session("http://localhost.myshopify.com", "unstable") scope = ["write_products"] - permission_url = session.create_permission_url(scope, "my_redirect_uri.com") - self.assertEqual("https://localhost.myshopify.com/admin/oauth/authorize?client_id=My_test_key&redirect_uri=my_redirect_uri.com&scope=write_products", self.normalize_url(permission_url)) + permission_url = session.create_permission_url("my_redirect_uri.com", scope=scope) + self.assertEqual( + "https://localhost.myshopify.com/admin/oauth/authorize?client_id=My_test_key&redirect_uri=my_redirect_uri.com&scope=write_products", + self.normalize_url(permission_url), + ) - def test_create_permission_url_returns_correct_url_with_dual_scope_no_redirect_uri(self): + def test_create_permission_url_returns_correct_url_with_redirect_uri_and_dual_scope(self): shopify.Session.setup(api_key="My_test_key", secret="My test secret") - session = shopify.Session('http://localhost.myshopify.com') - scope = ["write_products","write_customers"] - permission_url = session.create_permission_url(scope) - self.assertEqual("https://localhost.myshopify.com/admin/oauth/authorize?client_id=My_test_key&scope=write_products%2Cwrite_customers", self.normalize_url(permission_url)) + session = shopify.Session("http://localhost.myshopify.com", "unstable") + scope = ["write_products", "write_customers"] + permission_url = session.create_permission_url("my_redirect_uri.com", scope=scope) + self.assertEqual( + "https://localhost.myshopify.com/admin/oauth/authorize?client_id=My_test_key&redirect_uri=my_redirect_uri.com&scope=write_products%2Cwrite_customers", + self.normalize_url(permission_url), + ) + + def test_create_permission_url_returns_correct_url_with_redirect_uri_and_empty_scope(self): + shopify.Session.setup(api_key="My_test_key", secret="My test secret") + session = shopify.Session("http://localhost.myshopify.com", "unstable") + scope = [] + permission_url = session.create_permission_url("my_redirect_uri.com", scope=scope) + self.assertEqual( + "https://localhost.myshopify.com/admin/oauth/authorize?client_id=My_test_key&redirect_uri=my_redirect_uri.com", + self.normalize_url(permission_url), + ) - def test_create_permission_url_returns_correct_url_with_no_scope_no_redirect_uri(self): + def test_create_permission_url_returns_correct_url_with_redirect_uri_and_state(self): + shopify.Session.setup(api_key="My_test_key", secret="My test secret") + session = shopify.Session("http://localhost.myshopify.com", "unstable") + permission_url = session.create_permission_url("my_redirect_uri.com", state="mystate") + self.assertEqual( + "https://localhost.myshopify.com/admin/oauth/authorize?client_id=My_test_key&redirect_uri=my_redirect_uri.com&state=mystate", + self.normalize_url(permission_url), + ) + + def test_create_permission_url_returns_correct_url_with_redirect_uri_empty_scope_and_state(self): shopify.Session.setup(api_key="My_test_key", secret="My test secret") - session = shopify.Session('http://localhost.myshopify.com') + session = shopify.Session("http://localhost.myshopify.com", "unstable") scope = [] - permission_url = session.create_permission_url(scope) - self.assertEqual("https://localhost.myshopify.com/admin/oauth/authorize?client_id=My_test_key&scope=", self.normalize_url(permission_url)) + permission_url = session.create_permission_url("my_redirect_uri.com", scope=scope, state="mystate") + self.assertEqual( + "https://localhost.myshopify.com/admin/oauth/authorize?client_id=My_test_key&redirect_uri=my_redirect_uri.com&state=mystate", + self.normalize_url(permission_url), + ) + + def test_create_permission_url_returns_correct_url_with_redirect_uri_and_single_scope_and_state(self): + shopify.Session.setup(api_key="My_test_key", secret="My test secret") + session = shopify.Session("http://localhost.myshopify.com", "unstable") + scope = ["write_customers"] + permission_url = session.create_permission_url("my_redirect_uri.com", scope=scope, state="mystate") + self.assertEqual( + "https://localhost.myshopify.com/admin/oauth/authorize?client_id=My_test_key&redirect_uri=my_redirect_uri.com&scope=write_customers&state=mystate", + self.normalize_url(permission_url), + ) def test_raise_exception_if_code_invalid_in_request_token(self): shopify.Session.setup(api_key="My test key", secret="My test secret") - session = shopify.Session('http://localhost.myshopify.com') - self.fake(None, url='https://localhost.myshopify.com/admin/oauth/access_token', method='POST', code=404, body='{"error" : "invalid_request"}', has_user_agent=False) + session = shopify.Session("http://localhost.myshopify.com", "unstable") + self.fake( + None, + url="https://localhost.myshopify.com/admin/oauth/access_token", + method="POST", + code=404, + body='{"error" : "invalid_request"}', + has_user_agent=False, + ) with self.assertRaises(shopify.ValidationException): - session.request_token({'code':'any-code', 'timestamp':'1234'}) + session.request_token({"code": "any-code", "timestamp": "1234"}) self.assertFalse(session.valid) def test_return_site_for_session(self): - session = shopify.Session("testshop.myshopify.com", "any-token") - self.assertEqual("https://testshop.myshopify.com/admin", session.site) + session = shopify.Session("testshop.myshopify.com", "unstable", "any-token") + self.assertEqual("https://testshop.myshopify.com/admin/api/unstable", session.site) def test_hmac_calculation(self): # Test using the secret and parameter examples given in the Shopify API documentation. - shopify.Session.secret='hush' + shopify.Session.secret = "hush" params = { - 'shop': 'some-shop.myshopify.com', - 'code': 'a94a110d86d2452eb3e2af4cfb8a3828', - 'timestamp': '1337178173', - 'hmac': '2cb1a277650a659f1b11e92a4a64275b128e037f2c3390e3c8fd2d8721dac9e2', + "shop": "some-shop.myshopify.com", + "code": "a94a110d86d2452eb3e2af4cfb8a3828", + "timestamp": "1337178173", + "hmac": "2cb1a277650a659f1b11e92a4a64275b128e037f2c3390e3c8fd2d8721dac9e2", } - self.assertEqual(shopify.Session.calculate_hmac(params), params['hmac']) + self.assertEqual(shopify.Session.calculate_hmac(params), params["hmac"]) def test_hmac_calculation_with_ampersand_and_equal_sign_characters(self): - shopify.Session.secret='secret' - params = { 'a': '1&b=2', 'c=3&d': '4' } + shopify.Session.secret = "secret" + params = {"a": "1&b=2", "c=3&d": "4"} to_sign = "a=1%26b=2&c%3D3%26d=4" - expected_hmac = hmac.new('secret'.encode(), to_sign.encode(), sha256).hexdigest() + expected_hmac = hmac.new("secret".encode(), to_sign.encode(), sha256).hexdigest() self.assertEqual(shopify.Session.calculate_hmac(params), expected_hmac) def test_hmac_validation(self): # Test using the secret and parameter examples given in the Shopify API documentation. - shopify.Session.secret='hush' + shopify.Session.secret = "hush" params = { - 'shop': 'some-shop.myshopify.com', - 'code': 'a94a110d86d2452eb3e2af4cfb8a3828', - 'timestamp': '1337178173', - 'hmac': u('2cb1a277650a659f1b11e92a4a64275b128e037f2c3390e3c8fd2d8721dac9e2'), + "shop": "some-shop.myshopify.com", + "code": "a94a110d86d2452eb3e2af4cfb8a3828", + "timestamp": "1337178173", + "hmac": u("2cb1a277650a659f1b11e92a4a64275b128e037f2c3390e3c8fd2d8721dac9e2"), } self.assertTrue(shopify.Session.validate_hmac(params)) def test_parameter_validation_handles_missing_params(self): # Test using the secret and parameter examples given in the Shopify API documentation. - shopify.Session.secret='hush' + shopify.Session.secret = "hush" params = { - 'shop': 'some-shop.myshopify.com', - 'code': 'a94a110d86d2452eb3e2af4cfb8a3828', - 'hmac': u('2cb1a277650a659f1b11e92a4a64275b128e037f2c3390e3c8fd2d8721dac9e2'), + "shop": "some-shop.myshopify.com", + "code": "a94a110d86d2452eb3e2af4cfb8a3828", + "hmac": u("2cb1a277650a659f1b11e92a4a64275b128e037f2c3390e3c8fd2d8721dac9e2"), } self.assertFalse(shopify.Session.validate_params(params)) - def test_return_token_if_hmac_is_valid(self): - shopify.Session.secret='secret' - params = {'code': 'any-code', 'timestamp': time.time()} - hmac = shopify.Session.calculate_hmac(params) - params['hmac'] = hmac + def test_param_validation_of_param_values_with_lists(self): + shopify.Session.secret = "hush" + params = { + "shop": "some-shop.myshopify.com", + "ids[]": [ + 2, + 1, + ], + "hmac": u("b93b9f82996f6f8bf9f1b7bbddec284c8fabacdc4e12dc80550b4705f3003b1e"), + } + self.assertEqual(True, shopify.Session.validate_hmac(params)) - self.fake(None, url='https://localhost.myshopify.com/admin/oauth/access_token', method='POST', body='{"access_token" : "token"}', has_user_agent=False) - session = shopify.Session('http://localhost.myshopify.com') + def test_return_token_and_scope_if_hmac_is_valid(self): + shopify.Session.secret = "secret" + params = {"code": "any-code", "timestamp": time.time()} + hmac = shopify.Session.calculate_hmac(params) + params["hmac"] = hmac + + self.fake( + None, + url="https://localhost.myshopify.com/admin/oauth/access_token", + method="POST", + body='{"access_token" : "token", "scope": "read_products,write_orders"}', + has_user_agent=False, + ) + session = shopify.Session("http://localhost.myshopify.com", "unstable") token = session.request_token(params) self.assertEqual("token", token) + self.assertEqual(shopify.ApiAccess("read_products,write_orders"), session.access_scopes) def test_raise_error_if_hmac_is_invalid(self): - shopify.Session.secret='secret' - params = {'code': 'any-code', 'timestamp': time.time()} - params['hmac'] = 'a94a110d86d2452e92a4a64275b128e9273be3037f2c339eb3e2af4cfb8a3828' + shopify.Session.secret = "secret" + params = {"code": "any-code", "timestamp": time.time()} + params["hmac"] = "a94a110d86d2452e92a4a64275b128e9273be3037f2c339eb3e2af4cfb8a3828" with self.assertRaises(shopify.ValidationException): - session = shopify.Session('http://localhost.myshopify.com') + session = shopify.Session("http://localhost.myshopify.com", "unstable") session = session.request_token(params) def test_raise_error_if_hmac_does_not_match_expected(self): - shopify.Session.secret='secret' - params = {'foo': 'hello', 'timestamp': time.time()} + shopify.Session.secret = "secret" + params = {"foo": "hello", "timestamp": time.time()} hmac = shopify.Session.calculate_hmac(params) - params['hmac'] = hmac - params['bar'] = 'world' - params['code'] = 'code' + params["hmac"] = hmac + params["bar"] = "world" + params["code"] = "code" with self.assertRaises(shopify.ValidationException): - session = shopify.Session('http://localhost.myshopify.com') + session = shopify.Session("http://localhost.myshopify.com", "unstable") session = session.request_token(params) def test_raise_error_if_timestamp_is_too_old(self): - shopify.Session.secret='secret' + shopify.Session.secret = "secret" one_day = 24 * 60 * 60 - params = {'code': 'any-code', 'timestamp': time.time()-(2*one_day)} + params = {"code": "any-code", "timestamp": time.time() - (2 * one_day)} hmac = shopify.Session.calculate_hmac(params) - params['hmac'] = hmac + params["hmac"] = hmac with self.assertRaises(shopify.ValidationException): - session = shopify.Session('http://localhost.myshopify.com') + session = shopify.Session("http://localhost.myshopify.com", "unstable") session = session.request_token(params) + def test_access_scopes_are_nil_by_default(self): + session = shopify.Session("testshop.myshopify.com", "unstable", "any-token") + self.assertIsNone(session.access_scopes) + + def test_access_scopes_when_valid_scopes_passed_in(self): + session = shopify.Session( + shop_url="testshop.myshopify.com", + version="unstable", + token="any-token", + access_scopes="read_products, write_orders", + ) + + expected_access_scopes = shopify.ApiAccess("read_products, write_orders") + self.assertEqual(expected_access_scopes, session.access_scopes) + + def test_access_scopes_set_with_api_access_object_passed_in(self): + session = shopify.Session( + shop_url="testshop.myshopify.com", + version="unstable", + token="any-token", + access_scopes=shopify.ApiAccess("read_products, write_orders"), + ) + + expected_access_scopes = shopify.ApiAccess("read_products, write_orders") + self.assertEqual(expected_access_scopes, session.access_scopes) + def normalize_url(self, url): scheme, netloc, path, query, fragment = urllib.parse.urlsplit(url) query = "&".join(sorted(query.split("&"))) return urllib.parse.urlunsplit((scheme, netloc, path, query, fragment)) + + def test_session_with_coerced_version(self): + future_version = "2030-01" + session = shopify.Session("test.myshopify.com", future_version, "token") + self.assertEqual(session.api_version.name, future_version) + self.assertEqual( + session.api_version.api_path("https://test.myshopify.com"), + f"https://test.myshopify.com/admin/api/{future_version}", + ) + + def test_session_with_invalid_version(self): + with self.assertRaises(shopify.VersionNotFoundError): + shopify.Session("test.myshopify.com", "invalid-version", "token") diff --git a/test/session_token_test.py b/test/session_token_test.py new file mode 100644 index 00000000..0df7147f --- /dev/null +++ b/test/session_token_test.py @@ -0,0 +1,110 @@ +from shopify import session_token +from test.test_helper import TestCase +from datetime import datetime, timedelta + +import jwt +import sys + +if sys.version_info[0] < 3: # Backwards compatibility for python < v3.0.0 + import time + + +def timestamp(date): + return time.mktime(date.timetuple()) if sys.version_info[0] < 3 else date.timestamp() + + +class TestSessionTokenGetDecodedSessionToken(TestCase): + @classmethod + def setUpClass(self): + self.secret = "API Secret" + self.api_key = "API key" + + @classmethod + def setUp(self): + current_time = datetime.now() + self.payload = { + "iss": "https://test-shop.myshopify.com/admin", + "dest": "https://test-shop.myshopify.com", + "aud": self.api_key, + "sub": "1", + "exp": timestamp((current_time + timedelta(0, 60))), + "nbf": timestamp(current_time), + "iat": timestamp(current_time), + "jti": "4321", + "sid": "abc123", + } + + @classmethod + def build_auth_header(self): + mock_session_token = jwt.encode(self.payload, self.secret, algorithm="HS256") + return "Bearer {session_token}".format(session_token=mock_session_token) + + def test_raises_if_token_authentication_header_is_not_bearer(self): + authorization_header = "Bad auth header" + + with self.assertRaises(session_token.TokenAuthenticationError) as cm: + session_token.decode_from_header(authorization_header, api_key=self.api_key, secret=self.secret) + + self.assertEqual("The HTTP_AUTHORIZATION_HEADER provided does not contain a Bearer token", str(cm.exception)) + + def test_raises_jwt_error_if_session_token_is_expired(self): + self.payload["exp"] = timestamp((datetime.now() + timedelta(0, -11))) + + with self.assertRaises(session_token.SessionTokenError) as cm: + session_token.decode_from_header(self.build_auth_header(), api_key=self.api_key, secret=self.secret) + + self.assertEqual("Signature has expired", str(cm.exception)) + + def test_raises_jwt_error_if_invalid_alg(self): + bad_session_token = jwt.encode(self.payload, None, algorithm="none") + invalid_header = "Bearer {session_token}".format(session_token=bad_session_token) + + with self.assertRaises(session_token.SessionTokenError) as cm: + session_token.decode_from_header(invalid_header, api_key=self.api_key, secret=self.secret) + + self.assertEqual("The specified alg value is not allowed", str(cm.exception)) + + def test_raises_jwt_error_if_invalid_signature(self): + bad_session_token = jwt.encode(self.payload, "bad_secret", algorithm="HS256") + invalid_header = "Bearer {session_token}".format(session_token=bad_session_token) + + with self.assertRaises(session_token.SessionTokenError) as cm: + session_token.decode_from_header(invalid_header, api_key=self.api_key, secret=self.secret) + + self.assertEqual("Signature verification failed", str(cm.exception)) + + def test_raises_if_aud_doesnt_match_api_key(self): + self.payload["aud"] = "bad audience" + + with self.assertRaises(session_token.SessionTokenError) as cm: + session_token.decode_from_header(self.build_auth_header(), api_key=self.api_key, secret=self.secret) + + self.assertEqual("Audience doesn't match", str(cm.exception)) + + def test_raises_if_issuer_hostname_is_invalid(self): + self.payload["iss"] = "bad_shop_hostname" + + with self.assertRaises(session_token.InvalidIssuerError) as cm: + session_token.decode_from_header(self.build_auth_header(), api_key=self.api_key, secret=self.secret) + + self.assertEqual("Invalid issuer", str(cm.exception)) + + def test_raises_if_iss_and_dest_dont_match(self): + self.payload["dest"] = "bad_shop.myshopify.com" + + with self.assertRaises(session_token.MismatchedHostsError) as cm: + session_token.decode_from_header(self.build_auth_header(), api_key=self.api_key, secret=self.secret) + + self.assertEqual("The issuer and destination do not match", str(cm.exception)) + + def test_returns_decoded_payload(self): + decoded_payload = session_token.decode_from_header( + self.build_auth_header(), api_key=self.api_key, secret=self.secret + ) + + self.assertEqual(self.payload, decoded_payload) + + def test_allow_10_seconds_clock_skew_in_nbf(self): + self.payload["nbf"] = timestamp((datetime.now() + timedelta(seconds=10))) + + session_token.decode_from_header(self.build_auth_header(), api_key=self.api_key, secret=self.secret) diff --git a/test/shipping_zone_test.py b/test/shipping_zone_test.py index e81cfe6a..3d1e1e4d 100644 --- a/test/shipping_zone_test.py +++ b/test/shipping_zone_test.py @@ -1,11 +1,11 @@ import shopify from test.test_helper import TestCase + class ShippingZoneTest(TestCase): def test_get_shipping_zones(self): - self.fake("shipping_zones", method='GET', body=self.load_fixture('shipping_zones')) + self.fake("shipping_zones", method="GET", body=self.load_fixture("shipping_zones")) shipping_zones = shopify.ShippingZone.find() - self.assertEqual(1,len(shipping_zones)) - self.assertEqual(shipping_zones[0].name,"Some zone") - self.assertEqual(3,len(shipping_zones[0].countries)) - + self.assertEqual(1, len(shipping_zones)) + self.assertEqual(shipping_zones[0].name, "Some zone") + self.assertEqual(3, len(shipping_zones[0].countries)) diff --git a/test/shop_test.py b/test/shop_test.py index 2f02a632..3a88a2a6 100644 --- a/test/shop_test.py +++ b/test/shop_test.py @@ -1,6 +1,7 @@ import shopify from test.test_helper import TestCase + class ShopTest(TestCase): def setUp(self): super(ShopTest, self).setUp() @@ -8,7 +9,7 @@ def setUp(self): self.shop = shopify.Shop.current() def test_current_should_return_current_shop(self): - self.assertTrue(isinstance(self.shop,shopify.Shop)) + self.assertTrue(isinstance(self.shop, shopify.Shop)) self.assertEqual("Apple Computers", self.shop.name) self.assertEqual("apple.myshopify.com", self.shop.myshopify_domain) self.assertEqual(690933842, self.shop.id) @@ -25,9 +26,19 @@ def test_get_metafields_for_shop(self): self.assertTrue(isinstance(field, shopify.Metafield)) def test_add_metafield(self): - self.fake("metafields", method='POST', code=201, body=self.load_fixture('metafield'), headers={'Content-type': 'application/json'}) - - field = self.shop.add_metafield( shopify.Metafield({'namespace': "contact", 'key': "email", 'value': "123@example.com", 'value_type': "string"})) + self.fake( + "metafields", + method="POST", + code=201, + body=self.load_fixture("metafield"), + headers={"Content-type": "application/json"}, + ) + + field = self.shop.add_metafield( + shopify.Metafield( + {"namespace": "contact", "key": "email", "value": "123@example.com", "value_type": "string"} + ) + ) self.assertFalse(field.is_new()) self.assertEqual("contact", field.namespace) diff --git a/test/storefront_access_token_test.py b/test/storefront_access_token_test.py new file mode 100644 index 00000000..ce5ef805 --- /dev/null +++ b/test/storefront_access_token_test.py @@ -0,0 +1,37 @@ +import shopify +from test.test_helper import TestCase + + +class StorefrontAccessTokenTest(TestCase): + def test_create_storefront_access_token(self): + self.fake( + "storefront_access_tokens", + method="POST", + body=self.load_fixture("storefront_access_token"), + headers={"Content-type": "application/json"}, + ) + storefront_access_token = shopify.StorefrontAccessToken.create({"title": "Test"}) + self.assertEqual(1, storefront_access_token.id) + self.assertEqual("Test", storefront_access_token.title) + + def test_get_and_delete_storefront_access_token(self): + self.fake( + "storefront_access_tokens/1", method="GET", code=200, body=self.load_fixture("storefront_access_token") + ) + storefront_access_token = shopify.StorefrontAccessToken.find(1) + + self.fake("storefront_access_tokens/1", method="DELETE", code=200, body="destroyed") + storefront_access_token.destroy() + self.assertEqual("DELETE", self.http.request.get_method()) + + def test_get_storefront_access_tokens(self): + self.fake( + "storefront_access_tokens", method="GET", code=200, body=self.load_fixture("storefront_access_tokens") + ) + tokens = shopify.StorefrontAccessToken.find() + + self.assertEqual(2, len(tokens)) + self.assertEqual(1, tokens[0].id) + self.assertEqual(2, tokens[1].id) + self.assertEqual("Test 1", tokens[0].title) + self.assertEqual("Test 2", tokens[1].title) diff --git a/test/tender_transaction_test.py b/test/tender_transaction_test.py new file mode 100644 index 00000000..fe73c633 --- /dev/null +++ b/test/tender_transaction_test.py @@ -0,0 +1,13 @@ +import shopify +from test.test_helper import TestCase + + +class TenderTransactionTest(TestCase): + def setUp(self): + super(TenderTransactionTest, self).setUp() + self.fake("tender_transactions", method="GET", body=self.load_fixture("tender_transactions")) + + def test_should_load_all_tender_transactions(self): + tender_transactions = shopify.TenderTransaction.find() + self.assertEqual(3, len(tender_transactions)) + self.assertEqual([1, 2, 3], list(map(lambda t: t.id, tender_transactions))) diff --git a/test/test_helper.py b/test/test_helper.py index 2d9db66d..666ac792 100644 --- a/test/test_helper.py +++ b/test/test_helper.py @@ -5,53 +5,55 @@ from pyactiveresource.testing import http_fake import shopify -class TestCase(unittest.TestCase): +class TestCase(unittest.TestCase): def setUp(self): ActiveResource.site = None - ActiveResource.headers=None + ActiveResource.headers = None shopify.ShopifyResource.clear_session() - shopify.ShopifyResource.site = "https://this-is-my-test-show.myshopify.com/admin" + shopify.ShopifyResource.site = "https://this-is-my-test-show.myshopify.com/admin/api/unstable" shopify.ShopifyResource.password = None shopify.ShopifyResource.user = None http_fake.initialize() self.http = http_fake.TestHandler - self.http.set_response(Exception('Bad request')) - self.http.site = 'https://this-is-my-test-show.myshopify.com' + self.http.set_response(Exception("Bad request")) + self.http.site = "https://this-is-my-test-show.myshopify.com" - def load_fixture(self, name, format='json'): - with open(os.path.dirname(__file__)+'/fixtures/%s.%s' % (name, format), 'rb') as f: + def load_fixture(self, name, format="json"): + with open(os.path.dirname(__file__) + "/fixtures/%s.%s" % (name, format), "rb") as f: return f.read() def fake(self, endpoint, **kwargs): - body = kwargs.pop('body', None) or self.load_fixture(endpoint) - format = kwargs.pop('format','json') - method = kwargs.pop('method','GET') + body = kwargs.pop("body", None) or self.load_fixture(endpoint) + format = kwargs.pop("format", "json") + method = kwargs.pop("method", "GET") + prefix = kwargs.pop("prefix", "/admin/api/unstable") - if ('extension' in kwargs and not kwargs['extension']): + if "extension" in kwargs and not kwargs["extension"]: extension = "" else: - extension = ".%s" % (kwargs.pop('extension', 'json')) + extension = ".%s" % (kwargs.pop("extension", "json")) - url = "https://this-is-my-test-show.myshopify.com/admin/%s%s" % (endpoint, extension) + url = "https://this-is-my-test-show.myshopify.com%s/%s%s" % (prefix, endpoint, extension) try: - url = kwargs['url'] + url = kwargs["url"] except KeyError: - pass + pass headers = {} - if kwargs.pop('has_user_agent', True): - userAgent = 'ShopifyPythonAPI/%s Python/%s' % (shopify.VERSION, sys.version.split(' ', 1)[0]) - headers['User-agent'] = userAgent + if kwargs.pop("has_user_agent", True): + userAgent = "ShopifyPythonAPI/%s Python/%s" % (shopify.VERSION, sys.version.split(" ", 1)[0]) + headers["User-agent"] = userAgent try: - headers.update(kwargs['headers']) + headers.update(kwargs["headers"]) except KeyError: - pass + pass - code = kwargs.pop('code', 200) + code = kwargs.pop("code", 200) self.http.respond_to( - method, url, headers, body=body, code=code) + method, url, headers, body=body, code=code, response_headers=kwargs.pop("response_headers", None) + ) diff --git a/test/transaction_test.py b/test/transaction_test.py index 79335eee..e02fae00 100644 --- a/test/transaction_test.py +++ b/test/transaction_test.py @@ -1,10 +1,11 @@ import shopify from test.test_helper import TestCase + class TransactionTest(TestCase): def setUp(self): super(TransactionTest, self).setUp() - self.fake("orders/450789469/transactions/389404469", method='GET', body=self.load_fixture('transaction')) + self.fake("orders/450789469/transactions/389404469", method="GET", body=self.load_fixture("transaction")) def test_should_find_a_specific_transaction(self): transaction = shopify.Transaction.find(389404469, order_id=450789469) diff --git a/test/transactions_test.py b/test/transactions_test.py new file mode 100644 index 00000000..9b0d54b7 --- /dev/null +++ b/test/transactions_test.py @@ -0,0 +1,11 @@ +import shopify +from test.test_helper import TestCase + + +class TransactionsTest(TestCase): + prefix = "/admin/api/unstable/shopify_payments/balance" + + def test_get_payouts_transactions(self): + self.fake("transactions", method="GET", prefix=self.prefix, body=self.load_fixture("payouts_transactions")) + transactions = shopify.Transactions.find() + self.assertGreater(len(transactions), 0) diff --git a/test/usage_charge_test.py b/test/usage_charge_test.py index ab341fb3..a816636b 100644 --- a/test/usage_charge_test.py +++ b/test/usage_charge_test.py @@ -1,16 +1,28 @@ import shopify from test.test_helper import TestCase + class UsageChargeTest(TestCase): def test_create_usage_charge(self): - self.fake("recurring_application_charges/654381177/usage_charges", method='POST', body=self.load_fixture('usage_charge'), headers={'Content-type': 'application/json'}) + self.fake( + "recurring_application_charges/654381177/usage_charges", + method="POST", + body=self.load_fixture("usage_charge"), + headers={"Content-type": "application/json"}, + ) - charge = shopify.UsageCharge({'price': 9.0, 'description': '1000 emails', 'recurring_application_charge_id': 654381177}) + charge = shopify.UsageCharge( + {"price": 9.0, "description": "1000 emails", "recurring_application_charge_id": 654381177} + ) charge.save() - self.assertEqual('1000 emails', charge.description) + self.assertEqual("1000 emails", charge.description) def test_get_usage_charge(self): - self.fake("recurring_application_charges/654381177/usage_charges/359376002", method='GET', body=self.load_fixture('usage_charge')) + self.fake( + "recurring_application_charges/654381177/usage_charges/359376002", + method="GET", + body=self.load_fixture("usage_charge"), + ) - charge = shopify.UsageCharge.find(359376002, recurring_application_charge_id= 654381177) - self.assertEqual('1000 emails', charge.description) + charge = shopify.UsageCharge.find(359376002, recurring_application_charge_id=654381177) + self.assertEqual("1000 emails", charge.description) diff --git a/test/user_test.py b/test/user_test.py new file mode 100644 index 00000000..efb90d30 --- /dev/null +++ b/test/user_test.py @@ -0,0 +1,26 @@ +import shopify +from test.test_helper import TestCase + + +class UserTest(TestCase): + def test_get_all_users(self): + self.fake("users", body=self.load_fixture("users")) + users = shopify.User.find() + + self.assertEqual(2, len(users)) + self.assertEqual("Steve", users[0].first_name) + self.assertEqual("Jobs", users[0].last_name) + + def test_get_user(self): + self.fake("users/799407056", body=self.load_fixture("user")) + user = shopify.User.find(799407056) + + self.assertEqual("Steve", user.first_name) + self.assertEqual("Jobs", user.last_name) + + def test_get_current_user(self): + self.fake("users/current", body=self.load_fixture("user")) + user = shopify.User.current() + + self.assertEqual("Steve", user.first_name) + self.assertEqual("Jobs", user.last_name) diff --git a/test/utils/shop_url_test.py b/test/utils/shop_url_test.py new file mode 100644 index 00000000..ea7a77e9 --- /dev/null +++ b/test/utils/shop_url_test.py @@ -0,0 +1,47 @@ +from shopify.utils import shop_url +from test.test_helper import TestCase + + +class TestSanitizeShopDomain(TestCase): + def test_returns_hostname_for_good_shop_domains(self): + good_shop_domains = [ + "my-shop", + "my-shop.myshopify.com", + "http://my-shop.myshopify.com", + "https://my-shop.myshopify.com", + ] + sanitized_shops = [shop_url.sanitize_shop_domain(shop_domain) for shop_domain in good_shop_domains] + + self.assertTrue(all(shop == "my-shop.myshopify.com" for shop in sanitized_shops)) + + def test_returns_none_for_bad_shop_domains(self): + bad_shop_domains = [ + "myshop.com", + "myshopify.com", + "shopify.com", + "two words", + "store.myshopify.com.evil.com", + "/foo/bar", + "/foo.myshopify.io.evil.ru", + "%0a123.myshopify.io ", + "foo.bar.myshopify.io", + ] + sanitized_shops = [shop_url.sanitize_shop_domain(shop_domain) for shop_domain in bad_shop_domains] + + self.assertTrue(all(shop_domain is None for shop_domain in sanitized_shops)) + + def test_returns_hostname_for_custom_shop_domains(self): + custom_shop_domains = [ + "my-shop", + "my-shop.myshopify.io", + "http://my-shop.myshopify.io", + "https://my-shop.myshopify.io", + ] + sanitized_shops = [ + shop_url.sanitize_shop_domain(shop_domain, "myshopify.io") for shop_domain in custom_shop_domains + ] + + self.assertTrue(all(shop == "my-shop.myshopify.io" for shop in sanitized_shops)) + + def test_returns_none_for_none_type(self): + self.assertIsNone(shop_url.sanitize_shop_domain(None)) diff --git a/test/variant_test.py b/test/variant_test.py index ebfdd8c0..63ecb639 100644 --- a/test/variant_test.py +++ b/test/variant_test.py @@ -1,34 +1,49 @@ import shopify from test.test_helper import TestCase -class VariantTest(TestCase): +class VariantTest(TestCase): def test_get_variants(self): - self.fake("products/632910392/variants", method='GET', body=self.load_fixture('variants')) - v = shopify.Variant.find(product_id = 632910392) + self.fake("products/632910392/variants", method="GET", body=self.load_fixture("variants")) + v = shopify.Variant.find(product_id=632910392) def test_get_variant_namespaced(self): - self.fake("products/632910392/variants/808950810", method='GET', body=self.load_fixture('variant')) - v = shopify.Variant.find(808950810, product_id = 632910392) + self.fake("products/632910392/variants/808950810", method="GET", body=self.load_fixture("variant")) + v = shopify.Variant.find(808950810, product_id=632910392) def test_update_variant_namespace(self): - self.fake("products/632910392/variants/808950810", method='GET', body=self.load_fixture('variant')) - v = shopify.Variant.find(808950810, product_id = 632910392) + self.fake("products/632910392/variants/808950810", method="GET", body=self.load_fixture("variant")) + v = shopify.Variant.find(808950810, product_id=632910392) - self.fake("products/632910392/variants/808950810", method='PUT', body=self.load_fixture('variant'), headers={'Content-type': 'application/json'}) + self.fake( + "products/632910392/variants/808950810", + method="PUT", + body=self.load_fixture("variant"), + headers={"Content-type": "application/json"}, + ) v.save() def test_create_variant(self): - self.fake("products/632910392/variants", method='POST', body=self.load_fixture('variant'), headers={'Content-type': 'application/json'}) - v = shopify.Variant({'product_id':632910392}) + self.fake( + "products/632910392/variants", + method="POST", + body=self.load_fixture("variant"), + headers={"Content-type": "application/json"}, + ) + v = shopify.Variant({"product_id": 632910392}) v.save() def test_create_variant_then_add_parent_id(self): - self.fake("products/632910392/variants", method='POST', body=self.load_fixture('variant'), headers={'Content-type': 'application/json'}) + self.fake( + "products/632910392/variants", + method="POST", + body=self.load_fixture("variant"), + headers={"Content-type": "application/json"}, + ) v = shopify.Variant() v.product_id = 632910392 v.save() - + def test_get_variant(self): - self.fake("variants/808950810", method='GET', body=self.load_fixture('variant')) + self.fake("variants/808950810", method="GET", body=self.load_fixture("variant")) v = shopify.Variant.find(808950810) diff --git a/tox.ini b/tox.ini index a2f77bd6..1523475c 100644 --- a/tox.ini +++ b/tox.ini @@ -1,10 +1,12 @@ [tox] -envlist = py27, py34, py35 +envlist = py27, py34, py35, py36, py38, py39 +skip_missing_interpreters = true [testenv] setenv = PYTHONPATH = {toxinidir}:{toxinidir}/shopify -commands = python setup.py test +commands= + python setup.py test [testenv:flake8] basepython=python