├── .changeset ├── README.md └── config.json ├── .editorconfig ├── .gitattributes ├── .github ├── CODEOWNERS └── workflows │ └── ci-cd.yaml ├── .gitignore ├── CHANGELOG.md ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── biome.json ├── package.json ├── pnpm-lock.yaml ├── src ├── .env ├── config.ts ├── constants.ts ├── ctMock.test.ts ├── ctMock.ts ├── exceptions.ts ├── helpers.ts ├── index.test.ts ├── index.ts ├── lib │ ├── expandParser.ts │ ├── haversine.test.ts │ ├── haversine.ts │ ├── masking.ts │ ├── parser.ts │ ├── password.ts │ ├── predicateParser.test.ts │ ├── predicateParser.ts │ ├── productSearchFilter.test.ts │ ├── productSearchFilter.ts │ ├── projectionSearchFilter.test.ts │ ├── projectionSearchFilter.ts │ ├── proxy.ts │ ├── searchQueryTypeChecker.test.ts │ └── searchQueryTypeChecker.ts ├── oauth │ ├── errors.ts │ ├── helpers.ts │ ├── server.test.ts │ ├── server.ts │ └── store.ts ├── priceSelector.test.ts ├── priceSelector.ts ├── product-projection-search.ts ├── product-search.ts ├── projectAPI.test.ts ├── projectAPI.ts ├── repositories │ ├── abstract.ts │ ├── as-associate.ts │ ├── associate-role.ts │ ├── attribute-group.ts │ ├── business-unit.ts │ ├── cart-discount │ │ ├── actions.ts │ │ └── index.ts │ ├── cart │ │ ├── actions.ts │ │ ├── helpers.ts │ │ ├── index.test.ts │ │ └── index.ts │ ├── category │ │ ├── actions.ts │ │ ├── index.test.ts │ │ └── index.ts │ ├── channel.ts │ ├── custom-object.ts │ ├── customer-group.ts │ ├── customer │ │ ├── actions.ts │ │ ├── index.test.ts │ │ └── index.ts │ ├── discount-code │ │ ├── actions.ts │ │ └── index.ts │ ├── errors.ts │ ├── extension.ts │ ├── helpers.ts │ ├── index.ts │ ├── inventory-entry │ │ ├── actions.ts │ │ └── index.ts │ ├── my-customer.ts │ ├── my-order.ts │ ├── my-quote-request.ts │ ├── order-edit.ts │ ├── order │ │ ├── actions.ts │ │ ├── index.test.ts │ │ └── index.ts │ ├── payment │ │ ├── actions.ts │ │ ├── helpers.ts │ │ └── index.ts │ ├── product-discount.ts │ ├── product-projection.ts │ ├── product-selection.ts │ ├── product-tailoring.ts │ ├── product-type.ts │ ├── product │ │ ├── actions.ts │ │ ├── helpers.ts │ │ └── index.ts │ ├── project.ts │ ├── quote-request │ │ ├── actions.ts │ │ ├── index.test.ts │ │ └── index.ts │ ├── quote-staged │ │ ├── actions.ts │ │ └── index.ts │ ├── quote │ │ ├── actions.ts │ │ └── index.ts │ ├── review.ts │ ├── shipping-method │ │ ├── actions.ts │ │ ├── helpers.ts │ │ └── index.ts │ ├── shopping-list │ │ ├── actions.ts │ │ └── index.ts │ ├── standalone-price.ts │ ├── state.ts │ ├── store.ts │ ├── subscription.ts │ ├── tax-category │ │ ├── actions.ts │ │ ├── helpers.ts │ │ └── index.ts │ ├── type │ │ ├── actions.ts │ │ └── index.ts │ └── zone.ts ├── schemas │ └── update-request.ts ├── server.ts ├── services │ ├── abstract.ts │ ├── as-associate-cart.ts │ ├── as-associate-order.test.ts │ ├── as-associate-order.ts │ ├── as-associate-quote-request.ts │ ├── as-associate.ts │ ├── associate-roles.test.ts │ ├── associate-roles.ts │ ├── attribute-group.ts │ ├── business-units.test.ts │ ├── business-units.ts │ ├── cart-discount.test.ts │ ├── cart-discount.ts │ ├── cart.test.ts │ ├── cart.ts │ ├── category.test.ts │ ├── category.ts │ ├── channel.ts │ ├── custom-object.test.ts │ ├── custom-object.ts │ ├── customer-group.ts │ ├── customer.test.ts │ ├── customer.ts │ ├── discount-code.ts │ ├── extension.ts │ ├── index.ts │ ├── inventory-entry.test.ts │ ├── inventory-entry.ts │ ├── my-business-unit.ts │ ├── my-cart.test.ts │ ├── my-cart.ts │ ├── my-customer.test.ts │ ├── my-customer.ts │ ├── my-order.ts │ ├── my-payment.test.ts │ ├── my-payment.ts │ ├── my-shopping-list.ts │ ├── order.test.ts │ ├── order.ts │ ├── payment.test.ts │ ├── payment.ts │ ├── product-discount.ts │ ├── product-projection.test.ts │ ├── product-projection.ts │ ├── product-selection.test.ts │ ├── product-selection.ts │ ├── product-type.test.ts │ ├── product-type.ts │ ├── product.test.ts │ ├── product.ts │ ├── project.test.ts │ ├── project.ts │ ├── quote-request.test.ts │ ├── quote-request.ts │ ├── quote-staged.ts │ ├── quote.ts │ ├── reviews.ts │ ├── shipping-method.test.ts │ ├── shipping-method.ts │ ├── shopping-list.test.ts │ ├── shopping-list.ts │ ├── standalone-price.test.ts │ ├── standalone-price.ts │ ├── state.test.ts │ ├── state.ts │ ├── store.test.ts │ ├── store.ts │ ├── subscription.ts │ ├── tax-category.test.ts │ ├── tax-category.ts │ ├── type.ts │ └── zone.ts ├── shipping.test.ts ├── shipping.ts ├── storage │ ├── abstract.ts │ ├── in-memory.ts │ └── index.ts ├── testing │ └── customer.ts ├── types.ts └── validate.ts ├── tsconfig.json ├── tsdown.config.js ├── vendor ├── perplex │ ├── README.md │ ├── lexer-state.ts │ ├── lexer.ts │ ├── token-types.ts │ └── token.ts └── pratt │ ├── README.md │ └── index.ts └── vitest.config.ts /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@2.3.0/schema.json", 3 | "changelog": [ 4 | "@changesets/changelog-github", 5 | { 6 | "repo": "labd/commercetools-node-mock" 7 | } 8 | ], 9 | "commit": false, 10 | "fixed": [], 11 | "linked": [], 12 | "access": "public", 13 | "baseBranch": "main", 14 | "updateInternalDependencies": "patch", 15 | "ignore": [] 16 | } 17 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | # Set up tabs as indent style for a11y reasons 4 | # This will be the default for prettier in >=v3 5 | [*.{js,jsx,ts,tsx,json}] 6 | indent_style = tab 7 | indent_size = 2 8 | 9 | 10 | # Yaml does not support tabs 11 | [*.{yml,yaml}] 12 | indent_style = space 13 | indent_size = 2 14 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | src/ctMock.ts @labd/commercetools-sdks 2 | src/storage/* @labd/commercetools-sdks 3 | src/oauth/* @labd/commercetools-sdks 4 | -------------------------------------------------------------------------------- /.github/workflows/ci-cd.yaml: -------------------------------------------------------------------------------- 1 | name: validate 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | types: 9 | - opened 10 | - synchronize 11 | - reopened 12 | 13 | jobs: 14 | validate: 15 | name: Validate on Node ${{ matrix.node }} and ${{ matrix.os }} 16 | if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'pull_request' 17 | strategy: 18 | matrix: 19 | node: ["18.x", "20.x", "22.x"] 20 | os: [ubuntu-latest, windows-latest, macOS-latest] 21 | runs-on: ${{ matrix.os }} 22 | env: 23 | CI: true 24 | steps: 25 | - name: Checkout repo 26 | uses: actions/checkout@v4 27 | 28 | - name: Set up Node.js 29 | uses: labd/gh-actions-typescript/pnpm-install@main 30 | with: 31 | node-version: ${{ matrix.node }} 32 | 33 | - name: Check formatting and typing 34 | run: pnpm check 35 | 36 | - name: Run tests 37 | run: pnpm test:ci 38 | 39 | - name: Run build 40 | run: pnpm build 41 | 42 | docker: 43 | runs-on: ubuntu-latest 44 | if: github.event_name == 'push' && github.ref == 'refs/heads/main' 45 | needs: validate 46 | steps: 47 | - name: Set up QEMU 48 | uses: docker/setup-qemu-action@v3 49 | 50 | - name: Set up Docker Buildx 51 | uses: docker/setup-buildx-action@v3 52 | 53 | - name: Login to DockerHub 54 | uses: docker/login-action@v3 55 | with: 56 | username: ${{ secrets.DOCKERHUB_USERNAME }} 57 | password: ${{ secrets.DOCKERHUB_TOKEN }} 58 | 59 | # Build only if not main branch 60 | - name: Build 61 | uses: docker/build-push-action@v5 62 | if: github.ref != 'refs/heads/main' 63 | with: 64 | push: false 65 | tags: labdigital/commercetools-mock-server:latest 66 | 67 | # Build and push if main branch 68 | - name: Build and push 69 | uses: docker/build-push-action@v5 70 | if: github.ref == 'refs/heads/main' 71 | with: 72 | push: true 73 | tags: labdigital/commercetools-mock-server:latest 74 | 75 | release: 76 | timeout-minutes: 15 77 | runs-on: ubuntu-latest 78 | if: github.event_name == 'push' && github.ref == 'refs/heads/main' 79 | needs: validate 80 | steps: 81 | - name: Checkout repo 82 | uses: actions/checkout@v4 83 | 84 | - name: Set up Node.js 85 | uses: labd/gh-actions-typescript/pnpm-install@main 86 | 87 | - name: Run build 88 | run: pnpm build 89 | 90 | - name: Create and publish versions 91 | uses: changesets/action@v1 92 | with: 93 | title: "Release new version" 94 | commit: "update version" 95 | publish: pnpm publish:ci 96 | version: pnpm publish:version 97 | env: 98 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 99 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 100 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 101 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .yalc/ 3 | .vscode/ 4 | .DS_Store 5 | node_modules 6 | dist 7 | coverage/ 8 | .idea 9 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:18-alpine AS builder 2 | ENV PNPM_VERSION=9.0.2 3 | 4 | RUN corepack enable && \ 5 | corepack prepare pnpm@${PNPM_VERSION} --activate && \ 6 | pnpm config set store-dir /pnpm-store 7 | 8 | WORKDIR /app 9 | 10 | # Files required by pnpm install 11 | COPY package.json pnpm-lock.yaml tsdown.config.js tsconfig.json /app/ 12 | 13 | RUN pnpm install --frozen-lockfile 14 | 15 | # Bundle app source 16 | COPY src src 17 | COPY vendor vendor 18 | 19 | RUN pnpm build:server 20 | 21 | 22 | FROM node:18-alpine 23 | WORKDIR /app 24 | 25 | RUN adduser -D -u 8000 commercetools 26 | 27 | COPY --from=builder /app/dist /app 28 | 29 | EXPOSE 8989 30 | ENV HTTP_SERVER_PORT 8989 31 | 32 | CMD ["node", "./server.js"] 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Michael van Tellingen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | docker-build: 2 | docker build --no-cache -t labdigital/commercetools-mock-server:latest . 3 | 4 | docker-release: 5 | docker push labdigital/commercetools-mock-server:latest 6 | 7 | test: 8 | pnpm test 9 | 10 | 11 | check: 12 | node_modules/typescript/bin/tsc 13 | pnpm run test 14 | pnpm run lint 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Commercetools Mocking library for Node 2 | 3 | [](https://www.npmjs.com/package/@labdigital/commercetools-mock) 4 | [![codecov](https://codecov.io/gh/labd/commercetools-node-mock/branch/main/graph/badge.svg?token=muKkNunJ95)](https://codecov.io/gh/labd/commercetools-node-mock) 5 | 6 | This library mocks the Commercetools rest API to ease testing of your typescript 7 | codebases interacting with the commercetools api. It uses the same proven 8 | approach as our testing module in the 9 | [commercetools Python SDK](https://github.com/labd/commercetools-python-sdk/tree/main/src/commercetools/testing). 10 | 11 | Since version 2 of this library it is based on [msw](https://mswjs.io/) instead 12 | of nock. It is now therefore als recommended to manage the msw server yourself 13 | and use the `registerHandlers` method to register the handlers on this server. 14 | 15 | This allows you to use the same server for mocking other API's as well. 16 | 17 | ## Installation 18 | 19 | ```bash 20 | yarn add --dev @labdigital/commercetools-mock 21 | ``` 22 | 23 | ## Docker image 24 | 25 | This codebase is also available as a docker image where it provides a runnable 26 | http server exposing the mocked endpoints. See 27 | https://hub.docker.com/r/labdigital/commercetools-mock-server 28 | 29 | ## Example 30 | 31 | ```typescript 32 | import { CommercetoolsMock, getBaseResourceProperties } from '@labdigital/commercetools-mock' 33 | import { setupServer } from 'msw/node' 34 | 35 | const ctMock = new CommercetoolsMock({ 36 | apiHost: 'https://localhost', 37 | authHost: 'https://localhost', 38 | enableAuthentication: false, 39 | validateCredentials: false, 40 | defaultProjectKey: 'my-project', 41 | silent: true, 42 | }) 43 | 44 | describe('A module', () => { 45 | const mswServer = setupServer() 46 | 47 | beforeAll(() => { 48 | mswServer.listen({ onUnhandledRequest: "error" }) 49 | }) 50 | 51 | beforeEach(() => { 52 | ctMock.registerHandlers(mswServer) 53 | 54 | ctMock.project().add('type', { 55 | ...getBaseResourceProperties() 56 | key: 'my-customt-type', 57 | fieldDefinitions: [], 58 | }) 59 | }) 60 | 61 | afterAll(() => { 62 | mswServer.close() 63 | }) 64 | 65 | afterEach(() => { 66 | server.clearHandlers() 67 | ctMock.clear() 68 | }) 69 | 70 | test('my function', async () => { 71 | ctMock.project().add('customer', customerFixture) 72 | 73 | const res = await myFunction() 74 | 75 | expect(res).toEqual(true) 76 | }) 77 | }) 78 | ``` 79 | 80 | ## Contributing 81 | 82 | This codebases use [@changesets](https://github.com/changesets/changesets) for release and version management 83 | 84 | - Create a feature branch with new features / fixes (see [Adding a new service](#adding-a-service)) 85 | - When your code changes are complete, add a changeset file to your feature branch using `pnpm changeset` 86 | - Create a PR to request your changes to be merged to main 87 | - After your PR is merged, GitHub actions will create a release PR or add your changeset to an existing release PR 88 | - When the release is ready merge the release branch. A new version will be released 89 | 90 | ### Adding a new service {#adding-a-service} 91 | 92 | Implement the following: 93 | 94 | - New repository in src/repositories 95 | - New service in src/services 96 | - Add new service to src/ctMock.ts ctMock.\_services 97 | - Add new service to src/storage.ts InMemoryStorage 98 | - Adjust src/types.ts RepositoryMap and possibly serviceTypes 99 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", 3 | "vcs": { 4 | "enabled": false, 5 | "clientKind": "git", 6 | "useIgnoreFile": false 7 | }, 8 | "files": { 9 | "ignoreUnknown": false, 10 | "ignore": ["vendor/*", "dist/*"], 11 | "include": ["src/**/*"] 12 | }, 13 | "formatter": { 14 | "enabled": true, 15 | "indentStyle": "tab" 16 | }, 17 | "organizeImports": { 18 | "enabled": true 19 | }, 20 | "linter": { 21 | "enabled": true, 22 | "rules": { 23 | "recommended": true, 24 | "complexity": { 25 | "noForEach": "off" 26 | }, 27 | "style": { 28 | "noNonNullAssertion": "off", 29 | "noParameterAssign": "off" 30 | }, 31 | "suspicious": { 32 | "noConsoleLog": "error", 33 | "noImplicitAnyLet": "off", 34 | "noExplicitAny": "off", 35 | "noExportsInTest": "off" 36 | } 37 | } 38 | }, 39 | "javascript": { 40 | "formatter": { 41 | "quoteStyle": "double" 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@labdigital/commercetools-mock", 3 | "version": "2.51.0", 4 | "license": "MIT", 5 | "author": "Michael van Tellingen", 6 | "type": "module", 7 | "exports": { 8 | ".": "./dist/index.js" 9 | }, 10 | "main": "dist/index.js", 11 | "module": "dist/index.js", 12 | "typings": "dist/index.d.ts", 13 | "files": [ 14 | "dist", 15 | "src" 16 | ], 17 | "scripts": { 18 | "build": "tsdown", 19 | "build:server": "esbuild src/server.ts --bundle --outfile=dist/server.js --platform=node", 20 | "check": "biome check && tsc", 21 | "format": "biome check --fix", 22 | "lint": "biome check", 23 | "publish:ci": "pnpm build && pnpm changeset publish", 24 | "publish:version": "pnpm changeset version && pnpm format", 25 | "start": "tsdown src/server.ts --watch --onSuccess 'node dist/server'", 26 | "test": "vitest run", 27 | "test:ci": "vitest run --coverage" 28 | }, 29 | "dependencies": { 30 | "basic-auth": "2.0.1", 31 | "body-parser": "2.2.0", 32 | "decimal.js": "10.5.0", 33 | "express": "5.1.0", 34 | "light-my-request": "6.6.0", 35 | "morgan": "1.10.0", 36 | "msw": "2.7.3", 37 | "uuid": "11.1.0", 38 | "zod": "3.24.2", 39 | "zod-validation-error": "3.4.0" 40 | }, 41 | "devDependencies": { 42 | "@biomejs/biome": "1.9.4", 43 | "@types/express": "^5.0.1", 44 | "@changesets/changelog-github": "0.5.1", 45 | "@changesets/cli": "2.28.1", 46 | "@commercetools/platform-sdk": "8.8.0", 47 | "@types/basic-auth": "1.1.8", 48 | "@types/body-parser": "1.19.5", 49 | "@types/express-serve-static-core": "^5.0.6", 50 | "@types/morgan": "1.9.9", 51 | "@types/node": "20.16.14", 52 | "@types/qs": "6.9.11", 53 | "@types/supertest": "6.0.2", 54 | "@types/uuid": "9.0.8", 55 | "@vitest/coverage-v8": "3.1.1", 56 | "esbuild": "0.25.2", 57 | "fishery": "2.2.3", 58 | "supertest": "7.1.0", 59 | "timekeeper": "2.3.1", 60 | "tsdown": "^0.9.9", 61 | "typescript": "5.8.3", 62 | "vitest": "3.1.1" 63 | }, 64 | "packageManager": "pnpm@10.8.0", 65 | "engines": { 66 | "node": ">=18", 67 | "pnpm": ">=9.0.2" 68 | }, 69 | "publishConfig": { 70 | "access": "public" 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/.env: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/labd/commercetools-node-mock/ff3eb59f6afe73f8ac7aed596163ace99d644f9e/src/.env -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | import type { AbstractStorage } from "./storage"; 2 | 3 | export type Config = { 4 | strict: boolean; 5 | storage: AbstractStorage; 6 | }; 7 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export const DEFAULT_API_HOSTNAME = "https://api.*.commercetools.com"; 2 | export const DEFAULT_AUTH_HOSTNAME = "https://auth.*.commercetools.com"; 3 | -------------------------------------------------------------------------------- /src/ctMock.test.ts: -------------------------------------------------------------------------------- 1 | import { test } from "vitest"; 2 | import { CommercetoolsMock } from "./index"; 3 | 4 | test("ctMock.authServer", async () => { 5 | const ctMock = new CommercetoolsMock({ 6 | enableAuthentication: false, 7 | validateCredentials: false, 8 | apiHost: "http://api.localhost", 9 | }); 10 | 11 | ctMock.authStore().addToken({ 12 | token_type: "Bearer", 13 | access_token: "foobar", 14 | expires_in: 172800, 15 | scope: "my-project", 16 | refresh_token: "foobar", 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/exceptions.ts: -------------------------------------------------------------------------------- 1 | export abstract class BaseError { 2 | abstract message: string; 3 | 4 | abstract errors?: BaseError[]; 5 | } 6 | 7 | export class CommercetoolsError extends Error { 8 | info: T; 9 | 10 | statusCode: number; 11 | 12 | errors: BaseError[]; 13 | 14 | constructor(info: T, statusCode = 400) { 15 | super(info.message); 16 | this.info = info; 17 | this.statusCode = statusCode || 500; 18 | this.errors = info.errors ?? []; 19 | } 20 | } 21 | 22 | export interface InvalidRequestError { 23 | readonly code: "invalid_request"; 24 | readonly message: string; 25 | } 26 | 27 | export interface AuthError { 28 | readonly statusCode: number; 29 | readonly message: string; 30 | readonly error: string; 31 | readonly error_description: string; 32 | } 33 | -------------------------------------------------------------------------------- /src/helpers.ts: -------------------------------------------------------------------------------- 1 | import type { OutgoingHttpHeaders } from "node:http"; 2 | import type { ParsedQs } from "qs"; 3 | import { v4 as uuidv4 } from "uuid"; 4 | 5 | export const getBaseResourceProperties = () => ({ 6 | id: uuidv4(), 7 | createdAt: new Date().toISOString(), 8 | lastModifiedAt: new Date().toISOString(), 9 | version: 0, 10 | }); 11 | 12 | /** 13 | * Do a nested lookup by using a path. For example `foo.bar.value` will 14 | * return obj['foo']['bar']['value'] 15 | */ 16 | export const nestedLookup = (obj: any, path: string): any => { 17 | if (!path || path === "") { 18 | return obj; 19 | } 20 | 21 | const parts = path.split("."); 22 | let val = obj; 23 | 24 | for (let i = 0; i < parts.length; i++) { 25 | const part = parts[i]; 26 | if (val === undefined) { 27 | return undefined; 28 | } 29 | 30 | val = val[part]; 31 | } 32 | 33 | return val; 34 | }; 35 | 36 | export const queryParamsArray = ( 37 | input: string | ParsedQs | string[] | ParsedQs[] | undefined, 38 | ): string[] | undefined => { 39 | if (input === undefined) { 40 | return undefined; 41 | } 42 | 43 | const values: string[] = Array.isArray(input) 44 | ? (input as string[]) 45 | : ([input] as string[]); 46 | if (values.length < 1) { 47 | return undefined; 48 | } 49 | return values; 50 | }; 51 | 52 | export const queryParamsValue = ( 53 | value: string | ParsedQs | string[] | ParsedQs[] | undefined, 54 | ): string | undefined => { 55 | const values = queryParamsArray(value); 56 | if (values && values.length > 0) { 57 | return values[0]; 58 | } 59 | return undefined; 60 | }; 61 | 62 | export const cloneObject = (o: T): T => JSON.parse(JSON.stringify(o)); 63 | 64 | export const mapHeaderType = ( 65 | outgoingHttpHeaders: OutgoingHttpHeaders, 66 | ): HeadersInit => { 67 | const headersInit: HeadersInit = {}; 68 | for (const key in outgoingHttpHeaders) { 69 | const value = outgoingHttpHeaders[key]; 70 | if (Array.isArray(value)) { 71 | // Join multiple values for the same header with a comma 72 | headersInit[key] = value.join(", "); 73 | } else if (value !== undefined) { 74 | // Single value or undefined 75 | headersInit[key] = value.toString(); 76 | } 77 | } 78 | return headersInit; 79 | }; 80 | 81 | export const generateRandomString = (length: number) => { 82 | const characters = 83 | "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; 84 | let result = ""; 85 | for (let i = 0; i < length; i++) { 86 | const randomIndex = Math.floor(Math.random() * characters.length); 87 | result += characters[randomIndex]; 88 | } 89 | return result; 90 | }; 91 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import type { CommercetoolsMockOptions } from "./ctMock"; 2 | import { CommercetoolsMock } from "./ctMock"; 3 | import { getBaseResourceProperties } from "./helpers"; 4 | 5 | export { 6 | CommercetoolsMock, 7 | getBaseResourceProperties, 8 | type CommercetoolsMockOptions, 9 | }; 10 | -------------------------------------------------------------------------------- /src/lib/expandParser.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This module implements the reference expansion as imeplemented by 3 | * commercetools. 4 | * 5 | * See https://docs.commercetools.com/api/general-concepts#reference-expansion 6 | * 7 | * TODO: implement support for multi-dimensional array 8 | */ 9 | type ExpandResult = { 10 | element: string; 11 | index?: string | number; 12 | rest?: string; 13 | }; 14 | 15 | export const parseExpandClause = (clause: string): ExpandResult => { 16 | const result: ExpandResult = { 17 | element: clause, 18 | index: undefined, 19 | rest: undefined, 20 | }; 21 | 22 | const pos = clause.indexOf("."); 23 | if (pos > 0) { 24 | result.element = clause.substring(0, pos); 25 | result.rest = clause.substring(pos + 1); 26 | } 27 | 28 | const match = result.element.match(/\[([^\]+])]/); 29 | if (match) { 30 | result.index = match[1] === "*" ? "*" : Number.parseInt(match[1], 10); 31 | result.element = result.element.substring(0, match.index); 32 | } 33 | return result; 34 | }; 35 | -------------------------------------------------------------------------------- /src/lib/haversine.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "vitest"; 2 | import type { Location } from "./haversine"; 3 | import { haversineDistance } from "./haversine"; 4 | 5 | test("haversine", () => { 6 | // Lab Digital 7 | const src: Location = { 8 | latitude: 5.110230209615395, 9 | longitude: 52.06969591642097, 10 | }; 11 | 12 | // Dom Tower 13 | const dst: Location = { 14 | latitude: 5.121310867198959, 15 | longitude: 52.09068804569714, 16 | }; 17 | 18 | const dist = haversineDistance(src, dst); 19 | expect(dist).toBeGreaterThan(2631); 20 | expect(dist).toBeLessThan(2632); 21 | }); 22 | -------------------------------------------------------------------------------- /src/lib/haversine.ts: -------------------------------------------------------------------------------- 1 | export type Location = { 2 | latitude: number; 3 | longitude: number; 4 | }; 5 | 6 | /** 7 | * Returns the distance between src and dst as meters 8 | */ 9 | export const haversineDistance = (src: Location, dst: Location) => { 10 | const RADIUS_OF_EARTH_IN_KM = 6371; 11 | const toRadian = (deg: number) => deg * (Math.PI / 180); 12 | 13 | const dLat = toRadian(dst.latitude - src.latitude); 14 | const dLon = toRadian(dst.longitude - src.longitude); 15 | 16 | const a = 17 | Math.sin(dLat / 2) * Math.sin(dLat / 2) + 18 | Math.cos(toRadian(src.latitude)) * 19 | Math.cos(toRadian(dst.latitude)) * 20 | Math.sin(dLon / 2) * 21 | Math.sin(dLon / 2); 22 | const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); 23 | return RADIUS_OF_EARTH_IN_KM * c * 1000; 24 | }; 25 | -------------------------------------------------------------------------------- /src/lib/masking.ts: -------------------------------------------------------------------------------- 1 | import { cloneObject } from "../helpers"; 2 | 3 | export const maskSecretValue = (resource: T, path: string): T => { 4 | const parts = path.split("."); 5 | const clone = cloneObject(resource) as any; 6 | let val = clone; 7 | 8 | const target = parts.pop(); 9 | for (let i = 0; i < parts.length; i++) { 10 | const part = parts[i]; 11 | val = val[part]; 12 | 13 | if (val === undefined) { 14 | return resource; 15 | } 16 | } 17 | 18 | if (val && target && val[target]) { 19 | val[target] = "****"; 20 | } 21 | return clone; 22 | }; 23 | -------------------------------------------------------------------------------- /src/lib/parser.ts: -------------------------------------------------------------------------------- 1 | export { Lexer } from "~vendor/perplex/lexer"; 2 | export { Parser, type ITokenPosition } from "~vendor/pratt"; 3 | -------------------------------------------------------------------------------- /src/lib/password.ts: -------------------------------------------------------------------------------- 1 | import type { Customer } from "@commercetools/platform-sdk"; 2 | import { v4 as uuidv4 } from "uuid"; 3 | 4 | const PWRESET_SECRET = "pwreset"; 5 | const EMAIL_VERIFY_SECRET = "emailverifysecret"; 6 | 7 | export const validatePassword = ( 8 | clearPassword: string, 9 | hashedPassword: string, 10 | ) => hashPassword(clearPassword) === hashedPassword; 11 | 12 | export const hashPassword = (clearPassword: string) => 13 | Buffer.from(clearPassword).toString("base64"); 14 | 15 | export const createPasswordResetToken = (customer: Customer, expiresAt: Date) => 16 | Buffer.from( 17 | `${customer.id}:${PWRESET_SECRET}:${expiresAt.getTime()}`, 18 | ).toString("base64"); 19 | 20 | export const createEmailVerifyToken = (customer: Customer) => 21 | Buffer.from(`${customer.id}:${EMAIL_VERIFY_SECRET}:${uuidv4()}`).toString( 22 | "base64", 23 | ); 24 | 25 | export const validatePasswordResetToken = (token: string) => { 26 | const items = Buffer.from(token, "base64").toString("utf-8").split(":"); 27 | const [customerId, secret, time] = items; 28 | 29 | if (secret !== PWRESET_SECRET) { 30 | return undefined; 31 | } 32 | 33 | // Check if the token is expired 34 | if (Number.parseInt(time) < new Date().getTime()) { 35 | return undefined; 36 | } 37 | 38 | return customerId; 39 | }; 40 | 41 | export const validateEmailVerifyToken = (token: string) => { 42 | const items = Buffer.from(token, "base64").toString("utf-8").split(":"); 43 | const [customerId, secret] = items; 44 | if (secret !== EMAIL_VERIFY_SECRET) { 45 | return undefined; 46 | } 47 | 48 | return customerId; 49 | }; 50 | -------------------------------------------------------------------------------- /src/lib/proxy.ts: -------------------------------------------------------------------------------- 1 | export const copyHeaders = (headers: Headers) => { 2 | const validHeaders = ["accept", "host", "authorization", "content-type"]; 3 | const result: Record = {}; 4 | 5 | for (const [key, value] of headers.entries()) { 6 | if (validHeaders.includes(key.toLowerCase())) { 7 | result[key] = value; 8 | } 9 | } 10 | 11 | return result; 12 | }; 13 | -------------------------------------------------------------------------------- /src/lib/searchQueryTypeChecker.test.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | SearchAndExpression, 3 | SearchOrExpression, 4 | _SearchQuery, 5 | _SearchQueryExpression, 6 | } from "@commercetools/platform-sdk"; 7 | import { describe, expect, it } from "vitest"; 8 | import { 9 | isSearchAndExpression, 10 | isSearchAnyValue, 11 | isSearchExactExpression, 12 | isSearchExistsExpression, 13 | isSearchFilterExpression, 14 | isSearchFullTextExpression, 15 | isSearchFullTextPrefixExpression, 16 | isSearchNotExpression, 17 | isSearchOrExpression, 18 | isSearchPrefixExpression, 19 | isSearchRangeExpression, 20 | isSearchWildCardExpression, 21 | validateSearchQuery, 22 | } from "./searchQueryTypeChecker"; 23 | 24 | describe("searchQueryTypeChecker", () => { 25 | it("should validate SearchAndExpression", () => { 26 | const query: SearchAndExpression = { and: [] }; 27 | expect(isSearchAndExpression(query)).toBe(true); 28 | }); 29 | 30 | it("should validate SearchOrExpression", () => { 31 | const query: SearchOrExpression = { or: [] }; 32 | expect(isSearchOrExpression(query)).toBe(true); 33 | }); 34 | 35 | it("should validate SearchNotExpression", () => { 36 | const query: _SearchQueryExpression = { not: {} }; 37 | expect(isSearchNotExpression(query)).toBe(true); 38 | }); 39 | 40 | it("should validate SearchFilterExpression", () => { 41 | const query: _SearchQueryExpression = { filter: [] }; 42 | expect(isSearchFilterExpression(query)).toBe(true); 43 | }); 44 | 45 | it("should validate SearchRangeExpression", () => { 46 | const query: _SearchQueryExpression = { range: {} }; 47 | expect(isSearchRangeExpression(query)).toBe(true); 48 | }); 49 | 50 | it("should validate SearchExactExpression", () => { 51 | const query: _SearchQueryExpression = { exact: "some-exact" }; 52 | expect(isSearchExactExpression(query)).toBe(true); 53 | }); 54 | 55 | it("should validate SearchExistsExpression", () => { 56 | const query: _SearchQueryExpression = { exists: true }; 57 | expect(isSearchExistsExpression(query)).toBe(true); 58 | }); 59 | 60 | it("should validate SearchFullTextExpression", () => { 61 | const query: _SearchQueryExpression = { fullText: "some-text" }; 62 | expect(isSearchFullTextExpression(query)).toBe(true); 63 | }); 64 | 65 | it("should validate SearchFullTextPrefixExpression", () => { 66 | const query: _SearchQueryExpression = { fullTextPrefix: "some-prefix" }; 67 | expect(isSearchFullTextPrefixExpression(query)).toBe(true); 68 | }); 69 | 70 | it("should validate SearchPrefixExpression", () => { 71 | const query: _SearchQueryExpression = { prefix: "some-prefix" }; 72 | expect(isSearchPrefixExpression(query)).toBe(true); 73 | }); 74 | 75 | it("should validate SearchWildCardExpression", () => { 76 | const query: _SearchQueryExpression = { wildcard: "some-wildcard" }; 77 | expect(isSearchWildCardExpression(query)).toBe(true); 78 | }); 79 | 80 | it("should validate SearchAnyValue", () => { 81 | const query: _SearchQueryExpression = { value: "some-value" }; 82 | expect(isSearchAnyValue(query)).toBe(true); 83 | }); 84 | 85 | it("should throw an error for unsupported query", () => { 86 | const query = { unsupported: "unsupported" } as _SearchQuery; 87 | expect(() => validateSearchQuery(query)).toThrow( 88 | "Unsupported search query expression", 89 | ); 90 | }); 91 | 92 | it("should not throw an error for supported query", () => { 93 | const query: SearchAndExpression = { and: [] }; 94 | expect(() => validateSearchQuery(query)).not.toThrow(); 95 | }); 96 | }); 97 | -------------------------------------------------------------------------------- /src/lib/searchQueryTypeChecker.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | SearchAndExpression, 3 | SearchAnyValue, 4 | SearchDateRangeExpression, 5 | SearchDateTimeRangeExpression, 6 | SearchExactExpression, 7 | SearchExistsExpression, 8 | SearchFilterExpression, 9 | SearchFullTextExpression, 10 | SearchFullTextPrefixExpression, 11 | SearchLongRangeExpression, 12 | SearchNotExpression, 13 | SearchNumberRangeExpression, 14 | SearchOrExpression, 15 | SearchPrefixExpression, 16 | SearchTimeRangeExpression, 17 | SearchWildCardExpression, 18 | _SearchQuery, 19 | _SearchQueryExpression, 20 | } from "@commercetools/platform-sdk"; 21 | 22 | export const validateSearchQuery = (query: _SearchQuery): void => { 23 | if (isSearchAndExpression(query)) { 24 | query.and.forEach((expr) => validateSearchQuery(expr)); 25 | } else if (isSearchOrExpression(query)) { 26 | query.or.forEach((expr) => validateSearchQuery(expr)); 27 | } else if (isSearchNotExpression(query)) { 28 | validateSearchQuery(query.not); 29 | } else if ( 30 | isSearchFilterExpression(query) || 31 | isSearchRangeExpression(query) || 32 | isSearchExactExpression(query) || 33 | isSearchExistsExpression(query) || 34 | isSearchFullTextExpression(query) || 35 | isSearchFullTextPrefixExpression(query) || 36 | isSearchPrefixExpression(query) || 37 | isSearchWildCardExpression(query) || 38 | isSearchAnyValue(query) 39 | ) { 40 | return; 41 | } else { 42 | throw new Error("Unsupported search query expression"); 43 | } 44 | }; 45 | 46 | // Type guards 47 | export const isSearchAndExpression = ( 48 | expr: _SearchQuery, 49 | ): expr is SearchAndExpression => 50 | (expr as SearchAndExpression).and !== undefined; 51 | 52 | export const isSearchOrExpression = ( 53 | expr: _SearchQuery, 54 | ): expr is SearchOrExpression => (expr as SearchOrExpression).or !== undefined; 55 | 56 | export type SearchRangeExpression = 57 | | SearchDateRangeExpression 58 | | SearchDateTimeRangeExpression 59 | | SearchLongRangeExpression 60 | | SearchNumberRangeExpression 61 | | SearchTimeRangeExpression; 62 | 63 | // Type guard for SearchNotExpression 64 | export const isSearchNotExpression = ( 65 | expr: _SearchQueryExpression, 66 | ): expr is SearchNotExpression => 67 | (expr as SearchNotExpression).not !== undefined; 68 | 69 | // Type guard for SearchFilterExpression 70 | export const isSearchFilterExpression = ( 71 | expr: _SearchQueryExpression, 72 | ): expr is SearchFilterExpression => 73 | (expr as SearchFilterExpression).filter !== undefined; 74 | 75 | // Type guard for SearchDateRangeExpression 76 | export const isSearchRangeExpression = ( 77 | expr: _SearchQueryExpression, 78 | ): expr is SearchRangeExpression => 79 | (expr as SearchRangeExpression).range !== undefined; 80 | 81 | // Type guard for SearchExactExpression 82 | export const isSearchExactExpression = ( 83 | expr: _SearchQueryExpression, 84 | ): expr is SearchExactExpression => 85 | (expr as SearchExactExpression).exact !== undefined; 86 | 87 | // Type guard for SearchExistsExpression 88 | export const isSearchExistsExpression = ( 89 | expr: _SearchQueryExpression, 90 | ): expr is SearchExistsExpression => 91 | (expr as SearchExistsExpression).exists !== undefined; 92 | 93 | // Type guard for SearchFullTextExpression 94 | export const isSearchFullTextExpression = ( 95 | expr: _SearchQueryExpression, 96 | ): expr is SearchFullTextExpression => 97 | (expr as SearchFullTextExpression).fullText !== undefined; 98 | 99 | // Type guard for SearchFullTextPrefixExpression 100 | export const isSearchFullTextPrefixExpression = ( 101 | expr: _SearchQueryExpression, 102 | ): expr is SearchFullTextPrefixExpression => 103 | (expr as SearchFullTextPrefixExpression).fullTextPrefix !== undefined; 104 | 105 | // Type guard for SearchPrefixExpression 106 | export const isSearchPrefixExpression = ( 107 | expr: _SearchQueryExpression, 108 | ): expr is SearchPrefixExpression => 109 | (expr as SearchPrefixExpression).prefix !== undefined; 110 | 111 | // Type guard for SearchWildCardExpression 112 | export const isSearchWildCardExpression = ( 113 | expr: _SearchQueryExpression, 114 | ): expr is SearchWildCardExpression => 115 | (expr as SearchWildCardExpression).wildcard !== undefined; 116 | 117 | // Type guard for SearchAnyValue 118 | export const isSearchAnyValue = ( 119 | expr: _SearchQueryExpression, 120 | ): expr is SearchAnyValue => (expr as SearchAnyValue).value !== undefined; 121 | -------------------------------------------------------------------------------- /src/oauth/errors.ts: -------------------------------------------------------------------------------- 1 | export interface InvalidClientError { 2 | readonly code: "invalid_client"; 3 | readonly message: string; 4 | } 5 | 6 | export interface UnsupportedGrantType { 7 | readonly code: "unsupported_grant_type"; 8 | readonly message: string; 9 | } 10 | -------------------------------------------------------------------------------- /src/oauth/helpers.ts: -------------------------------------------------------------------------------- 1 | import type { Request } from "express"; 2 | 3 | export const getBearerToken = (request: Request): string | undefined => { 4 | const authHeader = request.header("Authorization"); 5 | const match = authHeader?.match(/^Bearer\s(?[^\s]+)$/); 6 | if (match) { 7 | return match.groups?.token; 8 | } 9 | return undefined; 10 | }; 11 | -------------------------------------------------------------------------------- /src/oauth/store.ts: -------------------------------------------------------------------------------- 1 | import { randomBytes } from "node:crypto"; 2 | import { v4 as uuidv4 } from "uuid"; 3 | 4 | type Token = { 5 | access_token: string; 6 | token_type: "Bearer"; 7 | expires_in: number; 8 | scope: string; 9 | refresh_token?: string; 10 | }; 11 | 12 | export class OAuth2Store { 13 | tokens: Token[] = []; 14 | 15 | validate = true; 16 | 17 | constructor(validate = true) { 18 | this.validate = validate; 19 | } 20 | 21 | addToken(token: Token) { 22 | this.tokens.push(token); 23 | } 24 | 25 | getClientToken(clientId: string, clientSecret: string, scope?: string) { 26 | const token: Token = { 27 | access_token: randomBytes(16).toString("base64"), 28 | token_type: "Bearer", 29 | expires_in: 172800, 30 | scope: scope || "todo", 31 | refresh_token: `my-project-${randomBytes(16).toString("base64")}`, 32 | }; 33 | this.addToken(token); 34 | return token; 35 | } 36 | 37 | getAnonymousToken( 38 | projectKey: string, 39 | anonymousId: string | undefined, 40 | scope: string, 41 | ) { 42 | if (!anonymousId) { 43 | anonymousId = uuidv4(); 44 | } 45 | const token: Token = { 46 | access_token: randomBytes(16).toString("base64"), 47 | token_type: "Bearer", 48 | expires_in: 172800, 49 | scope: scope 50 | ? `${scope} anonymous_id:${anonymousId}` 51 | : `anonymous_id:${anonymousId}`, 52 | refresh_token: `${projectKey}:${randomBytes(16).toString("base64")}`, 53 | }; 54 | this.addToken(token); 55 | return token; 56 | } 57 | 58 | getCustomerToken(projectKey: string, customerId: string, scope: string) { 59 | const token: Token = { 60 | access_token: randomBytes(16).toString("base64"), 61 | token_type: "Bearer", 62 | expires_in: 172800, 63 | scope: scope 64 | ? `${scope} customer_id:${customerId}` 65 | : `customer_id:${customerId}`, 66 | refresh_token: `${projectKey}:${randomBytes(16).toString("base64")}`, 67 | }; 68 | this.addToken(token); 69 | return token; 70 | } 71 | 72 | refreshToken(clientId: string, clientSecret: string, refreshToken: string) { 73 | const existing = this.tokens.find((t) => t.refresh_token === refreshToken); 74 | if (!existing) { 75 | return undefined; 76 | } 77 | const token: Token = { 78 | ...existing, 79 | access_token: randomBytes(16).toString("base64"), 80 | }; 81 | this.addToken(token); 82 | 83 | // We don't want to return the refresh_token again 84 | return { 85 | access_token: token.access_token, 86 | token_type: token.token_type, 87 | expires_in: token.expires_in, 88 | scope: token.scope, 89 | }; 90 | } 91 | 92 | validateToken(token: string) { 93 | if (!this.validate) return true; 94 | 95 | const foundToken = this.tokens.find((t) => t.access_token === token); 96 | if (foundToken) { 97 | return true; 98 | } 99 | return false; 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/priceSelector.test.ts: -------------------------------------------------------------------------------- 1 | import type { ProductProjection } from "@commercetools/platform-sdk"; 2 | import { beforeEach, describe, expect, test } from "vitest"; 3 | import { applyPriceSelector } from "./priceSelector"; 4 | 5 | describe("priceSelector", () => { 6 | let product: ProductProjection; 7 | 8 | beforeEach(() => { 9 | product = { 10 | id: "7401d82f-1378-47ba-996a-85beeb87ac87", 11 | version: 2, 12 | createdAt: "2022-07-22T10:02:40.851Z", 13 | lastModifiedAt: "2022-07-22T10:02:44.427Z", 14 | productType: { 15 | typeId: "product-type", 16 | id: "b9b4b426-938b-4ccb-9f36-c6f933e8446e", 17 | }, 18 | name: { 19 | "nl-NL": "test", 20 | }, 21 | slug: { 22 | "nl-NL": "test", 23 | }, 24 | variants: [], 25 | searchKeywords: {}, 26 | categories: [], 27 | masterVariant: { 28 | id: 1, 29 | sku: "MYSKU", 30 | attributes: [ 31 | { 32 | name: "Country", 33 | value: { 34 | key: "NL", 35 | label: { 36 | de: "niederlande", 37 | en: "netherlands", 38 | nl: "nederland", 39 | }, 40 | }, 41 | }, 42 | { 43 | name: "number", 44 | value: 4, 45 | }, 46 | ], 47 | prices: [ 48 | { 49 | id: "dummy-uuid", 50 | value: { 51 | type: "centPrecision", 52 | currencyCode: "EUR", 53 | centAmount: 1789, 54 | fractionDigits: 2, 55 | }, 56 | }, 57 | ], 58 | }, 59 | }; 60 | }); 61 | 62 | test("currency (match)", async () => { 63 | applyPriceSelector([product], { currency: "EUR" }); 64 | 65 | expect(product).toMatchObject({ 66 | masterVariant: { 67 | sku: "MYSKU", 68 | scopedPrice: { value: { centAmount: 1789 } }, 69 | }, 70 | }); 71 | }); 72 | 73 | test("currency, country (no match)", async () => { 74 | applyPriceSelector([product], { currency: "EUR", country: "US" }); 75 | expect(product.masterVariant.scopedPrice).toBeUndefined(); 76 | expect(product.masterVariant.scopedPrice).toBeUndefined(); 77 | }); 78 | }); 79 | -------------------------------------------------------------------------------- /src/priceSelector.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | InvalidInputError, 3 | Price, 4 | ProductProjection, 5 | ProductVariant, 6 | } from "@commercetools/platform-sdk"; 7 | import { CommercetoolsError } from "./exceptions"; 8 | import type { Writable } from "./types"; 9 | 10 | export type PriceSelector = { 11 | currency?: string; 12 | country?: string; 13 | customerGroup?: string; 14 | channel?: string; 15 | }; 16 | 17 | /** 18 | * Apply the price selector on all the variants. The price selector is applied 19 | * on all the prices per variant and the first match per variant is stored in 20 | * the scopedPrice attribute 21 | */ 22 | export const applyPriceSelector = ( 23 | products: ProductProjection[], 24 | selector: PriceSelector, 25 | noScopedPrice = false, 26 | ) => { 27 | validatePriceSelector(selector); 28 | 29 | for (const product of products) { 30 | // Get list of all variants (master + variants) 31 | const variants: Writable[] = [ 32 | product.masterVariant, 33 | ...(product.variants ?? []), 34 | ].filter((x) => x !== undefined); 35 | 36 | for (const variant of variants) { 37 | const scopedPrices = 38 | variant.prices?.filter((p) => priceSelectorFilter(p, selector)) ?? []; 39 | 40 | if (scopedPrices.length > 0) { 41 | const price = scopedPrices[0]; 42 | 43 | variant.price = scopedPrices[0]; 44 | if (!noScopedPrice) { 45 | variant.scopedPriceDiscounted = false; 46 | variant.scopedPrice = { 47 | ...price, 48 | currentValue: price.value, 49 | }; 50 | } 51 | } 52 | } 53 | } 54 | }; 55 | 56 | const validatePriceSelector = (selector: PriceSelector) => { 57 | if ( 58 | (selector.country || selector.channel || selector.customerGroup) && 59 | !selector.currency 60 | ) { 61 | throw new CommercetoolsError( 62 | { 63 | code: "InvalidInput", 64 | message: 65 | "The price selecting parameters country, channel and customerGroup " + 66 | "cannot be used without the currency.", 67 | }, 68 | 400, 69 | ); 70 | } 71 | }; 72 | 73 | /** 74 | * Return a boolean to indicate if the price matches the selector. Price 75 | * selection requires that if the selector or the price has a specific value 76 | * then it should match. 77 | */ 78 | export const priceSelectorFilter = ( 79 | price: Price, 80 | selector: PriceSelector, 81 | ): boolean => { 82 | if ( 83 | (selector.country || price.country) && 84 | selector.country !== price.country 85 | ) { 86 | return false; 87 | } 88 | 89 | if ( 90 | (selector.currency || price.value.currencyCode) && 91 | selector.currency !== price.value.currencyCode 92 | ) { 93 | return false; 94 | } 95 | 96 | if ( 97 | (selector.channel || price.channel?.id) && 98 | selector.channel !== price.channel?.id 99 | ) { 100 | return false; 101 | } 102 | 103 | if ( 104 | (selector.customerGroup || price.customerGroup?.id) && 105 | selector.customerGroup !== price.customerGroup?.id 106 | ) { 107 | return false; 108 | } 109 | 110 | return true; 111 | }; 112 | -------------------------------------------------------------------------------- /src/projectAPI.test.ts: -------------------------------------------------------------------------------- 1 | import { test } from "vitest"; 2 | import { CommercetoolsMock } from "./index"; 3 | 4 | test("getRepository", async () => { 5 | const ctMock = new CommercetoolsMock(); 6 | const repo = ctMock.project("my-project-key").getRepository("order"); 7 | repo.get({ projectKey: "unittest" }, "1234"); 8 | }); 9 | -------------------------------------------------------------------------------- /src/projectAPI.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "./config"; 2 | import { getBaseResourceProperties } from "./helpers"; 3 | import type { RepositoryMap } from "./repositories"; 4 | import type { GetParams } from "./repositories/abstract"; 5 | import type { AbstractStorage } from "./storage"; 6 | import type { ResourceMap, ResourceType } from "./types"; 7 | 8 | export class ProjectAPI { 9 | private projectKey: string; 10 | 11 | private _storage: AbstractStorage; 12 | 13 | private _repositories: RepositoryMap; 14 | 15 | private config: Config; 16 | 17 | constructor(projectKey: string, repositories: RepositoryMap, config: Config) { 18 | this.projectKey = projectKey; 19 | this.config = config; 20 | this._storage = config.storage; 21 | this._repositories = repositories; 22 | } 23 | 24 | add( 25 | typeId: T, 26 | resource: ResourceMap[T], 27 | ) { 28 | process.emitWarning( 29 | "ctMock.add() is deprecated, create resources via regular create endpoints " + 30 | "or if you are really sure, use unsafeAdd() (but be aware of potential state issues)", 31 | "DeprecationWarning", 32 | ); 33 | this.unsafeAdd(typeId, resource); 34 | } 35 | 36 | unsafeAdd( 37 | typeId: T, 38 | resource: ResourceMap[T], 39 | ) { 40 | const repository = this._repositories[typeId]; 41 | if (repository) { 42 | this._storage.add(this.projectKey, typeId, { 43 | ...getBaseResourceProperties(), 44 | ...resource, 45 | }); 46 | } else { 47 | throw new Error(`Service for ${typeId} not implemented yet`); 48 | } 49 | } 50 | 51 | get( 52 | typeId: RT, 53 | id: string, 54 | params?: GetParams, 55 | ): ResourceMap[RT] { 56 | return this._storage.get( 57 | this.projectKey, 58 | typeId, 59 | id, 60 | params, 61 | ) as ResourceMap[RT]; 62 | } 63 | 64 | // TODO: Not sure if we want to expose this... 65 | getRepository(typeId: RT): RepositoryMap[RT] { 66 | const repository = this._repositories[typeId]; 67 | if (repository !== undefined) { 68 | return repository as RepositoryMap[RT]; 69 | } 70 | throw new Error("No such repository"); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/repositories/as-associate.ts: -------------------------------------------------------------------------------- 1 | import { CartRepository } from "./cart"; 2 | import { OrderRepository } from "./order"; 3 | import { QuoteRequestRepository } from "./quote-request"; 4 | 5 | export class AsAssociateOrderRepository extends OrderRepository {} 6 | export class AsAssociateCartRepository extends CartRepository {} 7 | export class AsAssociateQuoteRequestRepository extends QuoteRequestRepository {} 8 | -------------------------------------------------------------------------------- /src/repositories/associate-role.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | AssociateRole, 3 | AssociateRoleAddPermissionAction, 4 | AssociateRoleChangeBuyerAssignableAction, 5 | AssociateRoleDraft, 6 | AssociateRoleRemovePermissionAction, 7 | AssociateRoleSetCustomFieldAction, 8 | AssociateRoleSetCustomTypeAction, 9 | AssociateRoleSetNameAction, 10 | AssociateRoleSetPermissionsAction, 11 | AssociateRoleUpdateAction, 12 | } from "@commercetools/platform-sdk"; 13 | import type { Config } from "~src/config"; 14 | import { getBaseResourceProperties } from "../helpers"; 15 | import type { Writable } from "../types"; 16 | import type { UpdateHandlerInterface } from "./abstract"; 17 | import { 18 | AbstractResourceRepository, 19 | AbstractUpdateHandler, 20 | type RepositoryContext, 21 | } from "./abstract"; 22 | import { createCustomFields } from "./helpers"; 23 | 24 | export class AssociateRoleRepository extends AbstractResourceRepository<"associate-role"> { 25 | constructor(config: Config) { 26 | super("associate-role", config); 27 | this.actions = new AssociateRoleUpdateHandler(this._storage); 28 | } 29 | 30 | create(context: RepositoryContext, draft: AssociateRoleDraft): AssociateRole { 31 | const resource: AssociateRole = { 32 | ...getBaseResourceProperties(), 33 | key: draft.key, 34 | name: draft.name, 35 | buyerAssignable: draft.buyerAssignable || false, 36 | permissions: draft.permissions || [], 37 | custom: createCustomFields( 38 | draft.custom, 39 | context.projectKey, 40 | this._storage, 41 | ), 42 | }; 43 | 44 | return this.saveNew(context, resource); 45 | } 46 | } 47 | 48 | class AssociateRoleUpdateHandler 49 | extends AbstractUpdateHandler 50 | implements 51 | Partial> 52 | { 53 | addPermission( 54 | context: RepositoryContext, 55 | resource: Writable, 56 | { permission }: AssociateRoleAddPermissionAction, 57 | ) { 58 | if (!resource.permissions) { 59 | resource.permissions = [permission]; 60 | } else { 61 | resource.permissions.push(permission); 62 | } 63 | } 64 | 65 | changeBuyerAssignable( 66 | context: RepositoryContext, 67 | resource: Writable, 68 | { buyerAssignable }: AssociateRoleChangeBuyerAssignableAction, 69 | ) { 70 | resource.buyerAssignable = buyerAssignable; 71 | } 72 | 73 | removePermission( 74 | context: RepositoryContext, 75 | resource: Writable, 76 | { permission }: AssociateRoleRemovePermissionAction, 77 | ) { 78 | if (!resource.permissions) { 79 | return; 80 | } 81 | 82 | resource.permissions = resource.permissions.filter((p) => { 83 | p !== permission; 84 | }); 85 | } 86 | 87 | setBuyerAssignable( 88 | context: RepositoryContext, 89 | resource: Writable, 90 | { buyerAssignable }: AssociateRoleChangeBuyerAssignableAction, 91 | ) { 92 | resource.buyerAssignable = buyerAssignable; 93 | } 94 | 95 | setCustomFields( 96 | context: RepositoryContext, 97 | resource: Writable, 98 | { name, value }: AssociateRoleSetCustomFieldAction, 99 | ) { 100 | if (!resource.custom) { 101 | return; 102 | } 103 | 104 | if (value === null) { 105 | delete resource.custom.fields[name]; 106 | } else { 107 | resource.custom.fields[name] = value; 108 | } 109 | } 110 | 111 | setCustomType( 112 | context: RepositoryContext, 113 | resource: Writable, 114 | { type, fields }: AssociateRoleSetCustomTypeAction, 115 | ) { 116 | if (type) { 117 | resource.custom = createCustomFields( 118 | { type, fields }, 119 | context.projectKey, 120 | this._storage, 121 | ); 122 | } else { 123 | resource.custom = undefined; 124 | } 125 | } 126 | 127 | setName( 128 | context: RepositoryContext, 129 | resource: Writable, 130 | { name }: AssociateRoleSetNameAction, 131 | ) { 132 | resource.name = name; 133 | } 134 | 135 | setPermissions( 136 | context: RepositoryContext, 137 | resource: Writable, 138 | { permissions }: AssociateRoleSetPermissionsAction, 139 | ) { 140 | resource.permissions = permissions || []; 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /src/repositories/attribute-group.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | AttributeGroup, 3 | AttributeGroupChangeNameAction, 4 | AttributeGroupDraft, 5 | AttributeGroupSetAttributesAction, 6 | AttributeGroupSetDescriptionAction, 7 | AttributeGroupSetKeyAction, 8 | AttributeGroupUpdateAction, 9 | } from "@commercetools/platform-sdk"; 10 | import type { Config } from "~src/config"; 11 | import { getBaseResourceProperties } from "../helpers"; 12 | import type { Writable } from "../types"; 13 | import type { UpdateHandlerInterface } from "./abstract"; 14 | import { 15 | AbstractResourceRepository, 16 | AbstractUpdateHandler, 17 | type RepositoryContext, 18 | } from "./abstract"; 19 | 20 | export class AttributeGroupRepository extends AbstractResourceRepository<"attribute-group"> { 21 | constructor(config: Config) { 22 | super("attribute-group", config); 23 | this.actions = new AttributeGroupUpdateHandler(this._storage); 24 | } 25 | 26 | create( 27 | context: RepositoryContext, 28 | draft: AttributeGroupDraft, 29 | ): AttributeGroup { 30 | const resource: AttributeGroup = { 31 | ...getBaseResourceProperties(), 32 | name: draft.name, 33 | description: draft.description, 34 | key: draft.key, 35 | attributes: draft.attributes, 36 | }; 37 | return this.saveNew(context, resource); 38 | } 39 | } 40 | 41 | class AttributeGroupUpdateHandler 42 | extends AbstractUpdateHandler 43 | implements 44 | Partial> 45 | { 46 | changeName( 47 | _context: RepositoryContext, 48 | resource: Writable, 49 | { name }: AttributeGroupChangeNameAction, 50 | ) { 51 | resource.name = name; 52 | } 53 | 54 | setAttributes( 55 | _context: RepositoryContext, 56 | resource: Writable, 57 | { attributes }: AttributeGroupSetAttributesAction, 58 | ) { 59 | resource.attributes = attributes; 60 | } 61 | 62 | setDescription( 63 | _context: RepositoryContext, 64 | resource: Writable, 65 | { description }: AttributeGroupSetDescriptionAction, 66 | ) { 67 | resource.description = description; 68 | } 69 | 70 | setKey( 71 | _context: RepositoryContext, 72 | resource: Writable, 73 | { key }: AttributeGroupSetKeyAction, 74 | ) { 75 | resource.key = key; 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/repositories/cart-discount/index.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | CartDiscount, 3 | CartDiscountDraft, 4 | CartDiscountValueAbsolute, 5 | CartDiscountValueDraft, 6 | CartDiscountValueFixed, 7 | CartDiscountValueGiftLineItem, 8 | CartDiscountValueRelative, 9 | } from "@commercetools/platform-sdk"; 10 | import type { Config } from "~src/config"; 11 | import { getBaseResourceProperties } from "~src/helpers"; 12 | import { 13 | AbstractResourceRepository, 14 | type RepositoryContext, 15 | } from "../abstract"; 16 | import { 17 | createCustomFields, 18 | createTypedMoney, 19 | getStoreKeyReference, 20 | } from "../helpers"; 21 | import { CartDiscountUpdateHandler } from "./actions"; 22 | 23 | export class CartDiscountRepository extends AbstractResourceRepository<"cart-discount"> { 24 | constructor(config: Config) { 25 | super("cart-discount", config); 26 | this.actions = new CartDiscountUpdateHandler(config.storage); 27 | } 28 | 29 | create(context: RepositoryContext, draft: CartDiscountDraft): CartDiscount { 30 | const resource: CartDiscount = { 31 | ...getBaseResourceProperties(), 32 | key: draft.key, 33 | description: draft.description, 34 | cartPredicate: draft.cartPredicate, 35 | isActive: draft.isActive || false, 36 | name: draft.name, 37 | stores: 38 | draft.stores?.map((s) => 39 | getStoreKeyReference(s, context.projectKey, this._storage), 40 | ) ?? [], 41 | references: [], 42 | target: draft.target, 43 | requiresDiscountCode: draft.requiresDiscountCode || false, 44 | sortOrder: draft.sortOrder, 45 | stackingMode: draft.stackingMode || "Stacking", 46 | validFrom: draft.validFrom, 47 | validUntil: draft.validUntil, 48 | value: this.transformValueDraft(draft.value), 49 | custom: createCustomFields( 50 | draft.custom, 51 | context.projectKey, 52 | this._storage, 53 | ), 54 | }; 55 | return this.saveNew(context, resource); 56 | } 57 | 58 | private transformValueDraft(value: CartDiscountValueDraft) { 59 | switch (value.type) { 60 | case "absolute": { 61 | return { 62 | type: "absolute", 63 | money: value.money.map(createTypedMoney), 64 | } as CartDiscountValueAbsolute; 65 | } 66 | case "fixed": { 67 | return { 68 | type: "fixed", 69 | money: value.money.map(createTypedMoney), 70 | } as CartDiscountValueFixed; 71 | } 72 | case "giftLineItem": { 73 | return { 74 | ...value, 75 | } as CartDiscountValueGiftLineItem; 76 | } 77 | case "relative": { 78 | return { 79 | ...value, 80 | } as CartDiscountValueRelative; 81 | } 82 | } 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/repositories/cart/helpers.ts: -------------------------------------------------------------------------------- 1 | import type { Cart, LineItem, Price } from "@commercetools/platform-sdk"; 2 | 3 | export const selectPrice = ({ 4 | prices, 5 | currency, 6 | country, 7 | }: { 8 | prices: Price[] | undefined; 9 | currency: string; 10 | country: string | undefined; 11 | }): Price | undefined => { 12 | if (!prices) { 13 | return undefined; 14 | } 15 | 16 | // Quick-and-dirty way of selecting price based on the given currency and country. 17 | // Can be improved later to give more priority to exact matches over 18 | // 'all country' matches, and include customer groups in the mix as well 19 | return prices.find((price) => { 20 | const countryMatch = !price.country || price.country === country; 21 | const currencyMatch = price.value.currencyCode === currency; 22 | return countryMatch && currencyMatch; 23 | }); 24 | }; 25 | 26 | export const calculateLineItemTotalPrice = (lineItem: LineItem): number => 27 | lineItem.price?.value.centAmount * lineItem.quantity; 28 | 29 | export const calculateCartTotalPrice = (cart: Cart): number => 30 | cart.lineItems.reduce((cur, item) => cur + item.totalPrice.centAmount, 0); 31 | -------------------------------------------------------------------------------- /src/repositories/category/index.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "vitest"; 2 | import type { Config } from "~src/config"; 3 | import { InMemoryStorage } from "~src/storage"; 4 | import { CategoryRepository } from "./index"; 5 | 6 | describe("Order repository", () => { 7 | const storage = new InMemoryStorage(); 8 | const config: Config = { storage, strict: false }; 9 | const repository = new CategoryRepository(config); 10 | 11 | test("valid ancestors", async () => { 12 | const root = repository.create( 13 | { projectKey: "dummy" }, 14 | { 15 | key: "root", 16 | slug: { 17 | en: "root", 18 | }, 19 | name: { 20 | en: "root", 21 | }, 22 | }, 23 | ); 24 | 25 | const level1 = repository.create( 26 | { projectKey: "dummy" }, 27 | { 28 | key: "level-1", 29 | slug: { 30 | en: "level-1", 31 | }, 32 | name: { 33 | en: "level-1", 34 | }, 35 | parent: { 36 | id: root.id, 37 | typeId: "category", 38 | }, 39 | }, 40 | ); 41 | 42 | const level2 = repository.create( 43 | { projectKey: "dummy" }, 44 | { 45 | key: "level-2", 46 | slug: { 47 | en: "level-2", 48 | }, 49 | name: { 50 | en: "level-2", 51 | }, 52 | parent: { 53 | id: level1.id, 54 | typeId: "category", 55 | }, 56 | }, 57 | ); 58 | 59 | const level3 = repository.create( 60 | { projectKey: "dummy" }, 61 | { 62 | key: "level-3", 63 | slug: { 64 | en: "level-3", 65 | }, 66 | name: { 67 | en: "level-3", 68 | }, 69 | parent: { 70 | id: level2.id, 71 | typeId: "category", 72 | }, 73 | }, 74 | ); 75 | 76 | const result = repository.get({ projectKey: "dummy" }, level3.id); 77 | expect(result?.ancestors).toHaveLength(3); 78 | expect(result?.ancestors).toEqual([ 79 | { id: level2.id, typeId: "category" }, 80 | { id: level1.id, typeId: "category" }, 81 | { id: root.id, typeId: "category" }, 82 | ]); 83 | 84 | const expandResult = repository.get({ projectKey: "dummy" }, level3.id, { 85 | expand: ["ancestors[*]"], 86 | }); 87 | expect(expandResult?.ancestors).toHaveLength(3); 88 | expect(expandResult?.ancestors).toEqual([ 89 | { id: level2.id, typeId: "category", obj: level2 }, 90 | { id: level1.id, typeId: "category", obj: level1 }, 91 | { id: root.id, typeId: "category", obj: root }, 92 | ]); 93 | 94 | const queryResult = repository.query( 95 | { projectKey: "dummy" }, 96 | { 97 | where: [`id="${level3.id}"`], 98 | expand: ["ancestors[*]"], 99 | }, 100 | ); 101 | expect(queryResult.results[0].ancestors).toHaveLength(3); 102 | expect(queryResult.results[0].ancestors).toEqual([ 103 | { id: level2.id, typeId: "category", obj: level2 }, 104 | { id: level1.id, typeId: "category", obj: level1 }, 105 | { id: root.id, typeId: "category", obj: root }, 106 | ]); 107 | }); 108 | }); 109 | -------------------------------------------------------------------------------- /src/repositories/category/index.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | Category, 3 | CategoryDraft, 4 | CategoryReference, 5 | } from "@commercetools/platform-sdk"; 6 | import { v4 as uuidv4 } from "uuid"; 7 | import type { Config } from "~src/config"; 8 | import { getBaseResourceProperties } from "~src/helpers"; 9 | import { parseExpandClause } from "~src/lib/expandParser"; 10 | import type { Writable } from "~src/types"; 11 | import type { GetParams } from "../abstract"; 12 | import { 13 | AbstractResourceRepository, 14 | type RepositoryContext, 15 | } from "../abstract"; 16 | import { createCustomFields } from "../helpers"; 17 | import { CategoryUpdateHandler } from "./actions"; 18 | 19 | export class CategoryRepository extends AbstractResourceRepository<"category"> { 20 | constructor(config: Config) { 21 | super("category", config); 22 | this.actions = new CategoryUpdateHandler(this._storage); 23 | } 24 | 25 | create(context: RepositoryContext, draft: CategoryDraft): Category { 26 | const resource: Category = { 27 | ...getBaseResourceProperties(), 28 | key: draft.key, 29 | name: draft.name, 30 | slug: draft.slug, 31 | description: draft.description, 32 | metaDescription: draft.metaDescription, 33 | metaKeywords: draft.metaKeywords, 34 | orderHint: draft.orderHint || "", 35 | externalId: draft.externalId || "", 36 | parent: draft.parent 37 | ? { typeId: "category", id: draft.parent.id! } 38 | : undefined, 39 | ancestors: [], // Resolved at runtime 40 | assets: 41 | draft.assets?.map((d) => ({ 42 | id: uuidv4(), 43 | name: d.name, 44 | description: d.description, 45 | sources: d.sources, 46 | tags: d.tags, 47 | key: d.key, 48 | custom: createCustomFields( 49 | draft.custom, 50 | context.projectKey, 51 | this._storage, 52 | ), 53 | })) || [], 54 | custom: createCustomFields( 55 | draft.custom, 56 | context.projectKey, 57 | this._storage, 58 | ), 59 | }; 60 | return this.saveNew(context, resource); 61 | } 62 | 63 | postProcessResource( 64 | context: RepositoryContext, 65 | resource: Writable, 66 | params?: GetParams, 67 | ): Category { 68 | let node: Category = resource; 69 | const ancestors: CategoryReference[] = []; 70 | 71 | // TODO: The expand clause here is a hack, the current expand architecture 72 | // is not able to handle the case for 'dynamic' fields like ancestors which 73 | // are resolved at runtime. We should do the expand resolution post query 74 | // execution for all resources 75 | 76 | const expandClauses = params?.expand?.map(parseExpandClause) ?? []; 77 | const addExpand = expandClauses?.find( 78 | (c) => c.element === "ancestors" && c.index === "*", 79 | ); 80 | 81 | while (node.parent) { 82 | node = this._storage.getByResourceIdentifier<"category">( 83 | context.projectKey, 84 | node.parent, 85 | ); 86 | ancestors.push({ 87 | typeId: "category", 88 | id: node.id, 89 | obj: addExpand ? node : undefined, 90 | }); 91 | } 92 | 93 | resource.ancestors = ancestors; 94 | return resource; 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/repositories/channel.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | Channel, 3 | ChannelChangeDescriptionAction, 4 | ChannelChangeKeyAction, 5 | ChannelChangeNameAction, 6 | ChannelDraft, 7 | ChannelSetAddressAction, 8 | ChannelSetCustomFieldAction, 9 | ChannelSetCustomTypeAction, 10 | ChannelSetGeoLocationAction, 11 | ChannelUpdateAction, 12 | } from "@commercetools/platform-sdk"; 13 | import type { Config } from "~src/config"; 14 | import { getBaseResourceProperties } from "../helpers"; 15 | import type { Writable } from "../types"; 16 | import type { UpdateHandlerInterface } from "./abstract"; 17 | import { 18 | AbstractResourceRepository, 19 | AbstractUpdateHandler, 20 | type RepositoryContext, 21 | } from "./abstract"; 22 | import { createAddress, createCustomFields } from "./helpers"; 23 | 24 | export class ChannelRepository extends AbstractResourceRepository<"channel"> { 25 | constructor(config: Config) { 26 | super("channel", config); 27 | this.actions = new ChannelUpdateHandler(this._storage); 28 | } 29 | 30 | create(context: RepositoryContext, draft: ChannelDraft): Channel { 31 | const resource: Channel = { 32 | ...getBaseResourceProperties(), 33 | key: draft.key, 34 | name: draft.name, 35 | description: draft.description, 36 | roles: draft.roles || [], 37 | geoLocation: draft.geoLocation, 38 | address: createAddress(draft.address, context.projectKey, this._storage), 39 | custom: createCustomFields( 40 | draft.custom, 41 | context.projectKey, 42 | this._storage, 43 | ), 44 | }; 45 | return this.saveNew(context, resource); 46 | } 47 | } 48 | 49 | class ChannelUpdateHandler 50 | extends AbstractUpdateHandler 51 | implements Partial> 52 | { 53 | changeDescription( 54 | context: RepositoryContext, 55 | resource: Writable, 56 | { description }: ChannelChangeDescriptionAction, 57 | ) { 58 | resource.description = description; 59 | } 60 | 61 | changeKey( 62 | context: RepositoryContext, 63 | resource: Writable, 64 | { key }: ChannelChangeKeyAction, 65 | ) { 66 | resource.key = key; 67 | } 68 | 69 | changeName( 70 | context: RepositoryContext, 71 | resource: Writable, 72 | { name }: ChannelChangeNameAction, 73 | ) { 74 | resource.name = name; 75 | } 76 | 77 | setAddress( 78 | context: RepositoryContext, 79 | resource: Writable, 80 | { address }: ChannelSetAddressAction, 81 | ) { 82 | resource.address = createAddress( 83 | address, 84 | context.projectKey, 85 | this._storage, 86 | ); 87 | } 88 | 89 | setCustomField( 90 | context: RepositoryContext, 91 | resource: Writable, 92 | { name, value }: ChannelSetCustomFieldAction, 93 | ) { 94 | if (!resource.custom) { 95 | return; 96 | } 97 | if (value === null) { 98 | delete resource.custom.fields[name]; 99 | } else { 100 | resource.custom.fields[name] = value; 101 | } 102 | } 103 | 104 | setCustomType( 105 | context: RepositoryContext, 106 | resource: Writable, 107 | { type, fields }: ChannelSetCustomTypeAction, 108 | ) { 109 | if (type) { 110 | resource.custom = createCustomFields( 111 | { type, fields }, 112 | context.projectKey, 113 | this._storage, 114 | ); 115 | } else { 116 | resource.custom = undefined; 117 | } 118 | } 119 | 120 | setGeoLocation( 121 | context: RepositoryContext, 122 | resource: Writable, 123 | { geoLocation }: ChannelSetGeoLocationAction, 124 | ) { 125 | resource.geoLocation = geoLocation; 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/repositories/custom-object.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | CustomObject, 3 | CustomObjectDraft, 4 | InvalidOperationError, 5 | } from "@commercetools/platform-sdk"; 6 | import type { Config } from "~src/config"; 7 | import { CommercetoolsError } from "~src/exceptions"; 8 | import { cloneObject, getBaseResourceProperties } from "../helpers"; 9 | import type { Writable } from "../types"; 10 | import type { QueryParams } from "./abstract"; 11 | import { AbstractResourceRepository, type RepositoryContext } from "./abstract"; 12 | import { checkConcurrentModification } from "./errors"; 13 | 14 | export class CustomObjectRepository extends AbstractResourceRepository<"key-value-document"> { 15 | constructor(config: Config) { 16 | super("key-value-document", config); 17 | } 18 | 19 | create( 20 | context: RepositoryContext, 21 | draft: Writable, 22 | ): CustomObject { 23 | const current = this.getWithContainerAndKey( 24 | context, 25 | draft.container, 26 | draft.key, 27 | ) as Writable; 28 | 29 | if (current) { 30 | // Only check version if it is passed in the draft 31 | if (draft.version) { 32 | checkConcurrentModification(current.version, draft.version, current.id); 33 | } else { 34 | draft.version = current.version; 35 | } 36 | 37 | if (draft.value !== current.value) { 38 | const updated = cloneObject(current) as Writable; 39 | updated.value = draft.value; 40 | updated.version += 1; 41 | this.saveUpdate(context, draft.version, updated); 42 | return updated; 43 | } 44 | return current; 45 | } 46 | // If the resource is new the only valid version is 0 47 | if (draft.version) { 48 | throw new CommercetoolsError( 49 | { 50 | code: "InvalidOperation", 51 | message: "version on create must be 0", 52 | }, 53 | 400, 54 | ); 55 | } 56 | const baseProperties = getBaseResourceProperties(); 57 | const resource: CustomObject = { 58 | ...baseProperties, 59 | container: draft.container, 60 | key: draft.key, 61 | value: draft.value, 62 | }; 63 | 64 | this.saveNew(context, resource); 65 | return resource; 66 | } 67 | 68 | getWithContainerAndKey( 69 | context: RepositoryContext, 70 | container: string, 71 | key: string, 72 | ) { 73 | const items = this._storage.all(context.projectKey, this.getTypeId()); 74 | return items.find( 75 | (item) => item.container === container && item.key === key, 76 | ); 77 | } 78 | 79 | queryWithContainer( 80 | context: RepositoryContext, 81 | container: string, 82 | params: QueryParams = {}, 83 | ) { 84 | const whereClause = params.where || []; 85 | whereClause.push(`container="${container}"`); 86 | const result = this._storage.query(context.projectKey, this.getTypeId(), { 87 | ...params, 88 | where: whereClause, 89 | }); 90 | 91 | // @ts-ignore 92 | result.results = result.results.map((r) => 93 | this.postProcessResource(context, r as CustomObject, { 94 | expand: params.expand, 95 | }), 96 | ); 97 | return result; 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/repositories/customer-group.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | CustomerGroup, 3 | CustomerGroupChangeNameAction, 4 | CustomerGroupDraft, 5 | CustomerGroupSetCustomFieldAction, 6 | CustomerGroupSetCustomTypeAction, 7 | CustomerGroupSetKeyAction, 8 | CustomerGroupUpdateAction, 9 | } from "@commercetools/platform-sdk"; 10 | import type { Config } from "~src/config"; 11 | import { getBaseResourceProperties } from "../helpers"; 12 | import type { Writable } from "../types"; 13 | import type { UpdateHandlerInterface } from "./abstract"; 14 | import { 15 | AbstractResourceRepository, 16 | AbstractUpdateHandler, 17 | type RepositoryContext, 18 | } from "./abstract"; 19 | import { createCustomFields } from "./helpers"; 20 | 21 | export class CustomerGroupRepository extends AbstractResourceRepository<"customer-group"> { 22 | constructor(config: Config) { 23 | super("customer-group", config); 24 | this.actions = new CustomerGroupUpdateHandler(config.storage); 25 | } 26 | 27 | create(context: RepositoryContext, draft: CustomerGroupDraft): CustomerGroup { 28 | const resource: CustomerGroup = { 29 | ...getBaseResourceProperties(), 30 | key: draft.key, 31 | name: draft.groupName, 32 | custom: createCustomFields( 33 | draft.custom, 34 | context.projectKey, 35 | this._storage, 36 | ), 37 | }; 38 | return this.saveNew(context, resource); 39 | } 40 | } 41 | 42 | class CustomerGroupUpdateHandler 43 | extends AbstractUpdateHandler 44 | implements UpdateHandlerInterface 45 | { 46 | changeName( 47 | context: RepositoryContext, 48 | resource: Writable, 49 | { name }: CustomerGroupChangeNameAction, 50 | ) { 51 | resource.name = name; 52 | } 53 | 54 | setCustomField( 55 | context: RepositoryContext, 56 | resource: Writable, 57 | { name, value }: CustomerGroupSetCustomFieldAction, 58 | ) { 59 | if (!resource.custom) { 60 | return; 61 | } 62 | if (value === null) { 63 | delete resource.custom.fields[name]; 64 | } else { 65 | resource.custom.fields[name] = value; 66 | } 67 | } 68 | 69 | setCustomType( 70 | context: RepositoryContext, 71 | resource: Writable, 72 | { type, fields }: CustomerGroupSetCustomTypeAction, 73 | ) { 74 | if (type) { 75 | resource.custom = createCustomFields( 76 | { type, fields }, 77 | context.projectKey, 78 | this._storage, 79 | ); 80 | } else { 81 | resource.custom = undefined; 82 | } 83 | } 84 | 85 | setKey( 86 | context: RepositoryContext, 87 | resource: Writable, 88 | { key }: CustomerGroupSetKeyAction, 89 | ) { 90 | resource.key = key; 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/repositories/discount-code/actions.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | CartDiscountReference, 3 | DiscountCode, 4 | DiscountCodeChangeCartDiscountsAction, 5 | DiscountCodeChangeIsActiveAction, 6 | DiscountCodeSetCartPredicateAction, 7 | DiscountCodeSetCustomFieldAction, 8 | DiscountCodeSetCustomTypeAction, 9 | DiscountCodeSetDescriptionAction, 10 | DiscountCodeSetMaxApplicationsAction, 11 | DiscountCodeSetMaxApplicationsPerCustomerAction, 12 | DiscountCodeSetNameAction, 13 | DiscountCodeSetValidFromAction, 14 | DiscountCodeSetValidFromAndUntilAction, 15 | DiscountCodeSetValidUntilAction, 16 | DiscountCodeUpdateAction, 17 | } from "@commercetools/platform-sdk"; 18 | import type { Writable } from "~src/types"; 19 | import type { UpdateHandlerInterface } from "../abstract"; 20 | import { AbstractUpdateHandler, type RepositoryContext } from "../abstract"; 21 | import { createCustomFields } from "../helpers"; 22 | 23 | export class DiscountCodeUpdateHandler 24 | extends AbstractUpdateHandler 25 | implements 26 | Partial> 27 | { 28 | changeCartDiscounts( 29 | context: RepositoryContext, 30 | resource: Writable, 31 | { cartDiscounts }: DiscountCodeChangeCartDiscountsAction, 32 | ) { 33 | resource.cartDiscounts = cartDiscounts.map( 34 | (obj): CartDiscountReference => ({ 35 | typeId: "cart-discount", 36 | id: obj.id!, 37 | }), 38 | ); 39 | } 40 | 41 | changeIsActive( 42 | context: RepositoryContext, 43 | resource: Writable, 44 | { isActive }: DiscountCodeChangeIsActiveAction, 45 | ) { 46 | resource.isActive = isActive; 47 | } 48 | 49 | setCartPredicate( 50 | context: RepositoryContext, 51 | resource: Writable, 52 | { cartPredicate }: DiscountCodeSetCartPredicateAction, 53 | ) { 54 | resource.cartPredicate = cartPredicate; 55 | } 56 | 57 | setCustomField( 58 | context: RepositoryContext, 59 | resource: Writable, 60 | { name, value }: DiscountCodeSetCustomFieldAction, 61 | ) { 62 | if (!resource.custom) { 63 | return; 64 | } 65 | if (value === null) { 66 | delete resource.custom.fields[name]; 67 | } else { 68 | resource.custom.fields[name] = value; 69 | } 70 | } 71 | 72 | setCustomType( 73 | context: RepositoryContext, 74 | resource: Writable, 75 | { type, fields }: DiscountCodeSetCustomTypeAction, 76 | ) { 77 | if (type) { 78 | resource.custom = createCustomFields( 79 | { type, fields }, 80 | context.projectKey, 81 | this._storage, 82 | ); 83 | } else { 84 | resource.custom = undefined; 85 | } 86 | } 87 | 88 | setDescription( 89 | context: RepositoryContext, 90 | resource: Writable, 91 | { description }: DiscountCodeSetDescriptionAction, 92 | ) { 93 | resource.description = description; 94 | } 95 | 96 | setMaxApplications( 97 | context: RepositoryContext, 98 | resource: Writable, 99 | { maxApplications }: DiscountCodeSetMaxApplicationsAction, 100 | ) { 101 | resource.maxApplications = maxApplications; 102 | } 103 | 104 | setMaxApplicationsPerCustomer( 105 | context: RepositoryContext, 106 | resource: Writable, 107 | { 108 | maxApplicationsPerCustomer, 109 | }: DiscountCodeSetMaxApplicationsPerCustomerAction, 110 | ) { 111 | resource.maxApplicationsPerCustomer = maxApplicationsPerCustomer; 112 | } 113 | 114 | setName( 115 | context: RepositoryContext, 116 | resource: Writable, 117 | { name }: DiscountCodeSetNameAction, 118 | ) { 119 | resource.name = name; 120 | } 121 | 122 | setValidFrom( 123 | context: RepositoryContext, 124 | resource: Writable, 125 | { validFrom }: DiscountCodeSetValidFromAction, 126 | ) { 127 | resource.validFrom = validFrom; 128 | } 129 | 130 | setValidFromAndUntil( 131 | context: RepositoryContext, 132 | resource: Writable, 133 | { validFrom, validUntil }: DiscountCodeSetValidFromAndUntilAction, 134 | ) { 135 | resource.validFrom = validFrom; 136 | resource.validUntil = validUntil; 137 | } 138 | 139 | setValidUntil( 140 | context: RepositoryContext, 141 | resource: Writable, 142 | { validUntil }: DiscountCodeSetValidUntilAction, 143 | ) { 144 | resource.validUntil = validUntil; 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /src/repositories/discount-code/index.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | CartDiscountReference, 3 | DiscountCode, 4 | DiscountCodeDraft, 5 | } from "@commercetools/platform-sdk"; 6 | import type { Config } from "~src/config"; 7 | import { getBaseResourceProperties } from "~src/helpers"; 8 | import { 9 | AbstractResourceRepository, 10 | type RepositoryContext, 11 | } from "../abstract"; 12 | import { createCustomFields } from "../helpers"; 13 | import { DiscountCodeUpdateHandler } from "./actions"; 14 | 15 | export class DiscountCodeRepository extends AbstractResourceRepository<"discount-code"> { 16 | constructor(config: Config) { 17 | super("discount-code", config); 18 | this.actions = new DiscountCodeUpdateHandler(config.storage); 19 | } 20 | 21 | create(context: RepositoryContext, draft: DiscountCodeDraft): DiscountCode { 22 | const resource: DiscountCode = { 23 | ...getBaseResourceProperties(), 24 | applicationVersion: 1, 25 | cartDiscounts: draft.cartDiscounts.map( 26 | (obj): CartDiscountReference => ({ 27 | typeId: "cart-discount", 28 | id: obj.id!, 29 | }), 30 | ), 31 | cartPredicate: draft.cartPredicate, 32 | code: draft.code, 33 | description: draft.description, 34 | groups: draft.groups || [], 35 | isActive: draft.isActive || true, 36 | name: draft.name, 37 | references: [], 38 | validFrom: draft.validFrom, 39 | validUntil: draft.validUntil, 40 | maxApplications: draft.maxApplications, 41 | maxApplicationsPerCustomer: draft.maxApplicationsPerCustomer, 42 | custom: createCustomFields( 43 | draft.custom, 44 | context.projectKey, 45 | this._storage, 46 | ), 47 | }; 48 | return this.saveNew(context, resource); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/repositories/errors.ts: -------------------------------------------------------------------------------- 1 | import type { ConcurrentModificationError } from "@commercetools/platform-sdk"; 2 | import { CommercetoolsError } from "~src/exceptions"; 3 | 4 | export const checkConcurrentModification = ( 5 | currentVersion: number, 6 | expectedVersion: number, 7 | identifier: string, 8 | ) => { 9 | if (currentVersion === expectedVersion) return; 10 | throw new CommercetoolsError( 11 | { 12 | message: `Object ${identifier} has a different version than expected. Expected: ${expectedVersion} - Actual: ${currentVersion}.`, 13 | currentVersion: currentVersion, 14 | code: "ConcurrentModification", 15 | }, 16 | 409, 17 | ); 18 | }; 19 | -------------------------------------------------------------------------------- /src/repositories/extension.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | Extension, 3 | ExtensionChangeDestinationAction, 4 | ExtensionChangeTriggersAction, 5 | ExtensionDraft, 6 | ExtensionSetKeyAction, 7 | ExtensionSetTimeoutInMsAction, 8 | ExtensionUpdateAction, 9 | } from "@commercetools/platform-sdk"; 10 | import type { Config } from "~src/config"; 11 | import { getBaseResourceProperties } from "../helpers"; 12 | import { maskSecretValue } from "../lib/masking"; 13 | import type { Writable } from "../types"; 14 | import type { UpdateHandlerInterface } from "./abstract"; 15 | import { 16 | AbstractResourceRepository, 17 | AbstractUpdateHandler, 18 | type RepositoryContext, 19 | } from "./abstract"; 20 | 21 | export class ExtensionRepository extends AbstractResourceRepository<"extension"> { 22 | constructor(config: Config) { 23 | super("extension", config); 24 | this.actions = new ExtensionUpdateHandler(config.storage); 25 | } 26 | 27 | create(context: RepositoryContext, draft: ExtensionDraft): Extension { 28 | const resource: Extension = { 29 | ...getBaseResourceProperties(), 30 | key: draft.key, 31 | timeoutInMs: draft.timeoutInMs, 32 | destination: draft.destination, 33 | triggers: draft.triggers, 34 | }; 35 | return this.saveNew(context, resource); 36 | } 37 | 38 | postProcessResource( 39 | context: RepositoryContext, 40 | resource: Extension, 41 | ): Extension { 42 | if (resource) { 43 | const extension = resource as Extension; 44 | if ( 45 | extension.destination.type === "HTTP" && 46 | extension.destination.authentication?.type === "AuthorizationHeader" 47 | ) { 48 | return maskSecretValue( 49 | extension, 50 | "destination.authentication.headerValue", 51 | ); 52 | } 53 | if (extension.destination.type === "AWSLambda") { 54 | return maskSecretValue(resource, "destination.accessSecret"); 55 | } 56 | } 57 | return resource; 58 | } 59 | } 60 | 61 | class ExtensionUpdateHandler 62 | extends AbstractUpdateHandler 63 | implements UpdateHandlerInterface 64 | { 65 | changeDestination( 66 | context: RepositoryContext, 67 | resource: Writable, 68 | action: ExtensionChangeDestinationAction, 69 | ): void { 70 | resource.destination = action.destination; 71 | } 72 | 73 | changeTriggers( 74 | context: RepositoryContext, 75 | resource: Writable, 76 | action: ExtensionChangeTriggersAction, 77 | ): void { 78 | resource.triggers = action.triggers; 79 | } 80 | 81 | setKey( 82 | context: RepositoryContext, 83 | resource: Writable, 84 | action: ExtensionSetKeyAction, 85 | ): void { 86 | resource.key = action.key; 87 | } 88 | 89 | setTimeoutInMs( 90 | context: RepositoryContext, 91 | resource: Writable, 92 | action: ExtensionSetTimeoutInMsAction, 93 | ): void { 94 | resource.timeoutInMs = action.timeoutInMs; 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/repositories/inventory-entry/actions.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | InventoryEntry, 3 | InventoryEntryChangeQuantityAction, 4 | InventoryEntrySetCustomFieldAction, 5 | InventoryEntrySetCustomTypeAction, 6 | InventoryEntrySetExpectedDeliveryAction, 7 | InventoryEntrySetRestockableInDaysAction, 8 | InventoryEntryUpdateAction, 9 | } from "@commercetools/platform-sdk"; 10 | import type { Writable } from "~src/types"; 11 | import type { UpdateHandlerInterface } from "../abstract"; 12 | import { AbstractUpdateHandler, type RepositoryContext } from "../abstract"; 13 | 14 | export class InventoryEntryUpdateHandler 15 | extends AbstractUpdateHandler 16 | implements 17 | Partial> 18 | { 19 | changeQuantity( 20 | context: RepositoryContext, 21 | resource: Writable, 22 | { quantity }: InventoryEntryChangeQuantityAction, 23 | ) { 24 | resource.quantityOnStock = quantity; 25 | // don't know active reservations so just set to same value 26 | resource.availableQuantity = quantity; 27 | } 28 | 29 | setCustomField( 30 | context: RepositoryContext, 31 | resource: InventoryEntry, 32 | { name, value }: InventoryEntrySetCustomFieldAction, 33 | ) { 34 | if (!resource.custom) { 35 | throw new Error("Resource has no custom field"); 36 | } 37 | resource.custom.fields[name] = value; 38 | } 39 | 40 | setCustomType( 41 | context: RepositoryContext, 42 | resource: Writable, 43 | { type, fields }: InventoryEntrySetCustomTypeAction, 44 | ) { 45 | if (!type) { 46 | resource.custom = undefined; 47 | } else { 48 | const resolvedType = this._storage.getByResourceIdentifier( 49 | context.projectKey, 50 | type, 51 | ); 52 | if (!resolvedType) { 53 | throw new Error(`Type ${type} not found`); 54 | } 55 | 56 | resource.custom = { 57 | type: { 58 | typeId: "type", 59 | id: resolvedType.id, 60 | }, 61 | fields: fields || {}, 62 | }; 63 | } 64 | } 65 | 66 | setExpectedDelivery( 67 | context: RepositoryContext, 68 | resource: Writable, 69 | { expectedDelivery }: InventoryEntrySetExpectedDeliveryAction, 70 | ) { 71 | resource.expectedDelivery = new Date(expectedDelivery!).toISOString(); 72 | } 73 | 74 | setRestockableInDays( 75 | context: RepositoryContext, 76 | resource: Writable, 77 | { restockableInDays }: InventoryEntrySetRestockableInDaysAction, 78 | ) { 79 | resource.restockableInDays = restockableInDays; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/repositories/inventory-entry/index.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | InventoryEntry, 3 | InventoryEntryDraft, 4 | } from "@commercetools/platform-sdk"; 5 | import type { Config } from "~src/config"; 6 | import { getBaseResourceProperties } from "~src/helpers"; 7 | import { 8 | AbstractResourceRepository, 9 | type RepositoryContext, 10 | } from "../abstract"; 11 | import { createCustomFields } from "../helpers"; 12 | import { InventoryEntryUpdateHandler } from "./actions"; 13 | 14 | export class InventoryEntryRepository extends AbstractResourceRepository<"inventory-entry"> { 15 | constructor(config: Config) { 16 | super("inventory-entry", config); 17 | this.actions = new InventoryEntryUpdateHandler(config.storage); 18 | } 19 | 20 | create( 21 | context: RepositoryContext, 22 | draft: InventoryEntryDraft, 23 | ): InventoryEntry { 24 | const resource: InventoryEntry = { 25 | ...getBaseResourceProperties(), 26 | sku: draft.sku, 27 | quantityOnStock: draft.quantityOnStock, 28 | availableQuantity: draft.quantityOnStock, 29 | expectedDelivery: draft.expectedDelivery, 30 | restockableInDays: draft.restockableInDays, 31 | supplyChannel: { 32 | ...draft.supplyChannel, 33 | typeId: "channel", 34 | id: draft.supplyChannel?.id ?? "", 35 | }, 36 | custom: createCustomFields( 37 | draft.custom, 38 | context.projectKey, 39 | this._storage, 40 | ), 41 | }; 42 | return this.saveNew(context, resource); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/repositories/my-customer.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | Customer, 3 | InvalidCurrentPasswordError, 4 | MyCustomerChangePassword, 5 | MyCustomerEmailVerify, 6 | ResourceNotFoundError, 7 | } from "@commercetools/platform-sdk"; 8 | import { CommercetoolsError } from "~src/exceptions"; 9 | import { hashPassword, validateEmailVerifyToken } from "../lib/password"; 10 | import type { Writable } from "../types"; 11 | import type { RepositoryContext } from "./abstract"; 12 | import { CustomerRepository } from "./customer"; 13 | 14 | export class MyCustomerRepository extends CustomerRepository { 15 | changePassword( 16 | context: RepositoryContext, 17 | changePassword: MyCustomerChangePassword, 18 | ) { 19 | const { currentPassword, newPassword } = changePassword; 20 | const encodedPassword = hashPassword(currentPassword); 21 | 22 | const result = this._storage.query(context.projectKey, "customer", { 23 | where: [`password = "${encodedPassword}"`], 24 | }); 25 | if (result.count === 0) { 26 | throw new CommercetoolsError({ 27 | code: "InvalidCurrentPassword", 28 | message: "Account with the given credentials not found.", 29 | }); 30 | } 31 | 32 | const customer = result.results[0] as Writable; 33 | if (customer.password !== hashPassword(currentPassword)) { 34 | throw new CommercetoolsError({ 35 | code: "InvalidCurrentPassword", 36 | message: "The current password is invalid.", 37 | }); 38 | } 39 | 40 | customer.password = hashPassword(newPassword); 41 | customer.version += 1; 42 | 43 | // Update storage 44 | this._storage.add(context.projectKey, "customer", customer); 45 | return customer; 46 | } 47 | 48 | confirmEmail( 49 | context: RepositoryContext, 50 | resetPassword: MyCustomerEmailVerify, 51 | ) { 52 | const { tokenValue } = resetPassword; 53 | 54 | const customerId = validateEmailVerifyToken(tokenValue); 55 | if (!customerId) { 56 | throw new CommercetoolsError({ 57 | code: "ResourceNotFound", 58 | message: `The Customer with ID 'Token(${tokenValue})' was not found.`, 59 | }); 60 | } 61 | 62 | const customer = this._storage.get( 63 | context.projectKey, 64 | "customer", 65 | customerId, 66 | ) as Writable | undefined; 67 | 68 | if (!customer) { 69 | throw new CommercetoolsError({ 70 | code: "ResourceNotFound", 71 | message: `The Customer with ID 'Token(${tokenValue})' was not found.`, 72 | }); 73 | } 74 | 75 | customer.isEmailVerified = true; 76 | customer.version += 1; 77 | 78 | // Update storage 79 | this._storage.add(context.projectKey, "customer", customer); 80 | return customer; 81 | } 82 | 83 | deleteMe(context: RepositoryContext): Customer | undefined { 84 | // grab the first customer you can find for now. In the future we should 85 | // use the customer id from the scope of the token 86 | const results = this._storage.query( 87 | context.projectKey, 88 | this.getTypeId(), 89 | {}, 90 | ); 91 | 92 | if (results.count > 0) { 93 | return this.delete(context, results.results[0].id) as Customer; 94 | } 95 | 96 | return; 97 | } 98 | 99 | getMe(context: RepositoryContext): Customer | undefined { 100 | // grab the first customer you can find for now. In the future we should 101 | // use the customer id from the scope of the token 102 | const results = this._storage.query( 103 | context.projectKey, 104 | this.getTypeId(), 105 | {}, 106 | ); 107 | 108 | if (results.count > 0) { 109 | return results.results[0] as Customer; 110 | } 111 | 112 | return; 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/repositories/my-order.ts: -------------------------------------------------------------------------------- 1 | import assert from "node:assert"; 2 | import type { 3 | CartReference, 4 | MyOrderFromCartDraft, 5 | Order, 6 | } from "@commercetools/platform-sdk"; 7 | import type { RepositoryContext } from "./abstract"; 8 | import { OrderRepository } from "./order"; 9 | 10 | export class MyOrderRepository extends OrderRepository { 11 | create(context: RepositoryContext, draft: MyOrderFromCartDraft): Order { 12 | assert(draft.id, "draft.id is missing"); 13 | const cartIdentifier = { 14 | id: draft.id, 15 | typeId: "cart", 16 | } as CartReference; 17 | return this.createFromCart(context, cartIdentifier); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/repositories/my-quote-request.ts: -------------------------------------------------------------------------------- 1 | import { QuoteRequestRepository } from "./quote-request"; 2 | 3 | export class MyQuoteRequestRepository extends QuoteRequestRepository {} 4 | -------------------------------------------------------------------------------- /src/repositories/order-edit.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | OrderEdit, 3 | OrderEditDraft, 4 | OrderEditResult, 5 | } from "@commercetools/platform-sdk"; 6 | import type { Config } from "~src/config"; 7 | import { getBaseResourceProperties } from "../helpers"; 8 | import type { RepositoryContext } from "./abstract"; 9 | import { AbstractResourceRepository } from "./abstract"; 10 | 11 | export class OrderEditRepository extends AbstractResourceRepository<"order-edit"> { 12 | constructor(config: Config) { 13 | super("order-edit", config); 14 | } 15 | 16 | create(context: RepositoryContext, draft: OrderEditDraft): OrderEdit { 17 | const resource: OrderEdit = { 18 | ...getBaseResourceProperties(), 19 | stagedActions: draft.stagedActions ?? [], 20 | resource: draft.resource, 21 | result: { 22 | type: "NotProcessed", 23 | } as OrderEditResult, 24 | }; 25 | return this.saveNew(context, resource); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/repositories/payment/helpers.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | Transaction, 3 | TransactionDraft, 4 | } from "@commercetools/platform-sdk"; 5 | import { v4 as uuidv4 } from "uuid"; 6 | import type { AbstractStorage } from "~src/storage"; 7 | import type { RepositoryContext } from "../abstract"; 8 | import { createCentPrecisionMoney, createCustomFields } from "../helpers"; 9 | 10 | export const transactionFromTransactionDraft = ( 11 | context: RepositoryContext, 12 | storage: AbstractStorage, 13 | draft: TransactionDraft, 14 | ): Transaction => ({ 15 | ...draft, 16 | id: uuidv4(), 17 | amount: createCentPrecisionMoney(draft.amount), 18 | custom: createCustomFields(draft.custom, context.projectKey, storage), 19 | state: draft.state ?? "Initial", // Documented as default 20 | }); 21 | -------------------------------------------------------------------------------- /src/repositories/payment/index.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | Payment, 3 | PaymentDraft, 4 | StateReference, 5 | } from "@commercetools/platform-sdk"; 6 | import type { Config } from "~src/config"; 7 | import { getBaseResourceProperties } from "~src/helpers"; 8 | import type { RepositoryContext } from "../abstract"; 9 | import { AbstractResourceRepository } from "../abstract"; 10 | import { 11 | createCentPrecisionMoney, 12 | createCustomFields, 13 | getReferenceFromResourceIdentifier, 14 | } from "../helpers"; 15 | import { PaymentUpdateHandler } from "./actions"; 16 | import { transactionFromTransactionDraft } from "./helpers"; 17 | 18 | export class PaymentRepository extends AbstractResourceRepository<"payment"> { 19 | constructor(config: Config) { 20 | super("payment", config); 21 | this.actions = new PaymentUpdateHandler(this._storage); 22 | } 23 | 24 | create(context: RepositoryContext, draft: PaymentDraft): Payment { 25 | const resource: Payment = { 26 | ...getBaseResourceProperties(), 27 | key: draft.key, 28 | amountPlanned: createCentPrecisionMoney(draft.amountPlanned), 29 | paymentMethodInfo: draft.paymentMethodInfo!, 30 | paymentStatus: draft.paymentStatus 31 | ? { 32 | ...draft.paymentStatus, 33 | state: draft.paymentStatus.state 34 | ? getReferenceFromResourceIdentifier( 35 | draft.paymentStatus.state, 36 | context.projectKey, 37 | this._storage, 38 | ) 39 | : undefined, 40 | } 41 | : {}, 42 | transactions: (draft.transactions || []).map((t) => 43 | transactionFromTransactionDraft(context, this._storage, t), 44 | ), 45 | interfaceInteractions: (draft.interfaceInteractions || []).map( 46 | (interaction) => 47 | createCustomFields(interaction, context.projectKey, this._storage)!, 48 | ), 49 | custom: createCustomFields( 50 | draft.custom, 51 | context.projectKey, 52 | this._storage, 53 | ), 54 | }; 55 | 56 | return this.saveNew(context, resource); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/repositories/product-selection.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ProductSelection, 3 | ProductSelectionChangeNameAction, 4 | ProductSelectionDraft, 5 | ProductSelectionSetCustomTypeAction, 6 | ProductSelectionUpdateAction, 7 | } from "@commercetools/platform-sdk"; 8 | import type { Config } from "~src/config"; 9 | import { createCustomFields } from "~src/repositories/helpers"; 10 | import { getBaseResourceProperties } from "../helpers"; 11 | import type { Writable } from "../types"; 12 | import type { RepositoryContext, UpdateHandlerInterface } from "./abstract"; 13 | import { AbstractResourceRepository, AbstractUpdateHandler } from "./abstract"; 14 | 15 | export class ProductSelectionRepository extends AbstractResourceRepository<"product-selection"> { 16 | constructor(config: Config) { 17 | super("product-selection", config); 18 | this.actions = new ProductSelectionUpdateHandler(this._storage); 19 | } 20 | 21 | create( 22 | context: RepositoryContext, 23 | draft: ProductSelectionDraft, 24 | ): ProductSelection { 25 | const resource: ProductSelection = { 26 | ...getBaseResourceProperties(), 27 | productCount: 0, 28 | key: draft.key, 29 | name: draft.name, 30 | mode: "Individual", 31 | }; 32 | return this.saveNew(context, resource); 33 | } 34 | } 35 | 36 | class ProductSelectionUpdateHandler 37 | extends AbstractUpdateHandler 38 | implements 39 | Partial< 40 | UpdateHandlerInterface 41 | > 42 | { 43 | changeName( 44 | context: RepositoryContext, 45 | resource: Writable, 46 | { name }: ProductSelectionChangeNameAction, 47 | ) { 48 | resource.name = name; 49 | } 50 | 51 | setCustomType( 52 | context: RepositoryContext, 53 | resource: Writable, 54 | { type, fields }: ProductSelectionSetCustomTypeAction, 55 | ) { 56 | if (type) { 57 | resource.custom = createCustomFields( 58 | { type, fields }, 59 | context.projectKey, 60 | this._storage, 61 | ); 62 | } else { 63 | resource.custom = undefined; 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/repositories/product-tailoring.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ProductTailoring, 3 | ProductTailoringUpdateAction, 4 | } from "@commercetools/platform-sdk"; 5 | import type { Config } from "~src/config"; 6 | import type { RepositoryContext, UpdateHandlerInterface } from "./abstract"; 7 | import { AbstractResourceRepository, AbstractUpdateHandler } from "./abstract"; 8 | 9 | export class ProductTailoringRepository extends AbstractResourceRepository<"product-tailoring"> { 10 | constructor(config: Config) { 11 | super("product-tailoring", config); 12 | this.actions = new ProductTailoringUpdateHandler(this._storage); 13 | } 14 | 15 | create(context: RepositoryContext, draft: any): ProductTailoring { 16 | throw new Error("Create method for product-tailoring not implemented."); 17 | } 18 | } 19 | 20 | class ProductTailoringUpdateHandler 21 | extends AbstractUpdateHandler 22 | implements 23 | Partial< 24 | UpdateHandlerInterface 25 | > 26 | { 27 | setSlug() { 28 | throw new Error("SetSlug method for product-tailoring not implemented."); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/repositories/product/helpers.ts: -------------------------------------------------------------------------------- 1 | import { isDeepStrictEqual } from "node:util"; 2 | import type { 3 | Asset, 4 | AssetDraft, 5 | ChannelReference, 6 | Price, 7 | PriceDraft, 8 | Product, 9 | ProductData, 10 | ProductVariant, 11 | ProductVariantDraft, 12 | } from "@commercetools/platform-sdk"; 13 | import { v4 as uuidv4 } from "uuid"; 14 | import type { AbstractStorage } from "~src/storage"; 15 | import type { Writable } from "~src/types"; 16 | import type { RepositoryContext } from "../abstract"; 17 | import { 18 | createCustomFields, 19 | createTypedMoney, 20 | getReferenceFromResourceIdentifier, 21 | } from "../helpers"; 22 | 23 | interface VariantResult { 24 | variant: Writable | undefined; 25 | isMasterVariant: boolean; 26 | variantIndex: number; 27 | } 28 | 29 | export const getVariant = ( 30 | productData: ProductData, 31 | variantId?: number, 32 | sku?: string, 33 | ): VariantResult => { 34 | const variants = [productData.masterVariant, ...productData.variants]; 35 | const foundVariant = variants.find((variant: ProductVariant) => { 36 | if (variantId) { 37 | return variant.id === variantId; 38 | } 39 | if (sku) { 40 | return variant.sku === sku; 41 | } 42 | return false; 43 | }); 44 | 45 | const isMasterVariant = foundVariant === productData.masterVariant; 46 | return { 47 | variant: foundVariant, 48 | isMasterVariant, 49 | variantIndex: 50 | !isMasterVariant && foundVariant 51 | ? productData.variants.indexOf(foundVariant) 52 | : -1, 53 | }; 54 | }; 55 | 56 | // Check if the product still has staged data that is different from the 57 | // current data. 58 | export const checkForStagedChanges = (product: Writable) => { 59 | if (!product.masterData.staged) { 60 | product.masterData.staged = product.masterData.current; 61 | } 62 | 63 | if ( 64 | isDeepStrictEqual(product.masterData.current, product.masterData.staged) 65 | ) { 66 | product.masterData.hasStagedChanges = false; 67 | } else { 68 | product.masterData.hasStagedChanges = true; 69 | } 70 | }; 71 | 72 | export const variantFromDraft = ( 73 | context: RepositoryContext, 74 | storage: AbstractStorage, 75 | variantId: number, 76 | variant: ProductVariantDraft, 77 | ): ProductVariant => ({ 78 | id: variantId, 79 | sku: variant?.sku, 80 | key: variant?.key, 81 | attributes: variant?.attributes ?? [], 82 | prices: variant?.prices?.map((p) => priceFromDraft(context, storage, p)), 83 | assets: variant.assets?.map((a) => assetFromDraft(context, storage, a)) ?? [], 84 | images: variant.images ?? [], 85 | }); 86 | 87 | export const assetFromDraft = ( 88 | context: RepositoryContext, 89 | storage: AbstractStorage, 90 | draft: AssetDraft, 91 | ): Asset => { 92 | const asset: Asset = { 93 | ...draft, 94 | id: uuidv4(), 95 | custom: createCustomFields(draft.custom, context.projectKey, storage), 96 | }; 97 | return asset; 98 | }; 99 | 100 | export const priceFromDraft = ( 101 | context: RepositoryContext, 102 | storage: AbstractStorage, 103 | draft: PriceDraft, 104 | ): Price => ({ 105 | id: uuidv4(), 106 | key: draft.key, 107 | country: draft.country, 108 | value: createTypedMoney(draft.value), 109 | channel: draft.channel 110 | ? getReferenceFromResourceIdentifier( 111 | draft.channel, 112 | context.projectKey, 113 | storage, 114 | ) 115 | : undefined, 116 | }); 117 | -------------------------------------------------------------------------------- /src/repositories/quote-request/actions.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | InvalidJsonInputError, 3 | QuoteRequest, 4 | QuoteRequestSetCustomFieldAction, 5 | QuoteRequestSetCustomTypeAction, 6 | QuoteRequestTransitionStateAction, 7 | QuoteRequestUpdateAction, 8 | StateReference, 9 | } from "@commercetools/platform-sdk"; 10 | import { CommercetoolsError } from "~src/exceptions"; 11 | import type { Writable } from "~src/types"; 12 | import type { RepositoryContext, UpdateHandlerInterface } from "../abstract"; 13 | import { AbstractUpdateHandler } from "../abstract"; 14 | import { getReferenceFromResourceIdentifier } from "../helpers"; 15 | 16 | export class QuoteRequestUpdateHandler 17 | extends AbstractUpdateHandler 18 | implements 19 | Partial> 20 | { 21 | setCustomField( 22 | context: RepositoryContext, 23 | resource: QuoteRequest, 24 | { name, value }: QuoteRequestSetCustomFieldAction, 25 | ) { 26 | if (!resource.custom) { 27 | throw new Error("Resource has no custom field"); 28 | } 29 | resource.custom.fields[name] = value; 30 | } 31 | 32 | setCustomType( 33 | context: RepositoryContext, 34 | resource: Writable, 35 | { type, fields }: QuoteRequestSetCustomTypeAction, 36 | ) { 37 | if (!type) { 38 | resource.custom = undefined; 39 | } else { 40 | const resolvedType = this._storage.getByResourceIdentifier( 41 | context.projectKey, 42 | type, 43 | ); 44 | if (!resolvedType) { 45 | throw new Error(`Type ${type} not found`); 46 | } 47 | 48 | resource.custom = { 49 | type: { 50 | typeId: "type", 51 | id: resolvedType.id, 52 | }, 53 | fields: fields || {}, 54 | }; 55 | } 56 | } 57 | 58 | transitionState( 59 | context: RepositoryContext, 60 | resource: Writable, 61 | { state, force }: QuoteRequestTransitionStateAction, 62 | ) { 63 | let stateReference: StateReference | undefined = undefined; 64 | if (state) { 65 | stateReference = getReferenceFromResourceIdentifier( 66 | state, 67 | context.projectKey, 68 | this._storage, 69 | ); 70 | resource.state = stateReference; 71 | } else { 72 | throw new CommercetoolsError( 73 | { 74 | code: "InvalidJsonInput", 75 | message: "Request body does not contain valid JSON.", 76 | detailedErrorMessage: "actions -> state: Missing required value", 77 | }, 78 | 400, 79 | ); 80 | } 81 | 82 | return resource; 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/repositories/quote-request/index.ts: -------------------------------------------------------------------------------- 1 | import assert from "node:assert"; 2 | import type { 3 | Cart, 4 | CartReference, 5 | MyQuoteRequestDraft, 6 | QuoteRequest, 7 | QuoteRequestDraft, 8 | } from "@commercetools/platform-sdk"; 9 | import type { Config } from "~src/config"; 10 | import { getBaseResourceProperties } from "~src/helpers"; 11 | import type { RepositoryContext } from "../abstract"; 12 | import { AbstractResourceRepository } from "../abstract"; 13 | import { QuoteRequestUpdateHandler } from "./actions"; 14 | 15 | export class QuoteRequestRepository extends AbstractResourceRepository<"quote-request"> { 16 | constructor(config: Config) { 17 | super("quote-request", config); 18 | this.actions = new QuoteRequestUpdateHandler(config.storage); 19 | } 20 | 21 | create( 22 | context: RepositoryContext, 23 | draft: QuoteRequestDraft | MyQuoteRequestDraft, 24 | ): QuoteRequest { 25 | // Handle the 'my' version of the draft 26 | if ("cartId" in draft) { 27 | return this.createFromCart(context, { 28 | id: draft.cartId, 29 | typeId: "cart", 30 | }); 31 | } 32 | 33 | assert(draft.cart, "draft.cart is missing"); 34 | return this.createFromCart(context, { 35 | id: draft.cart.id!, 36 | typeId: "cart", 37 | }); 38 | } 39 | 40 | createFromCart(context: RepositoryContext, cartReference: CartReference) { 41 | const cart = this._storage.getByResourceIdentifier( 42 | context.projectKey, 43 | cartReference, 44 | ) as Cart | null; 45 | if (!cart) { 46 | throw new Error("Cannot find cart"); 47 | } 48 | 49 | if (!cart.customerId) { 50 | throw new Error("Cart does not have a customer"); 51 | } 52 | 53 | const resource: QuoteRequest = { 54 | ...getBaseResourceProperties(), 55 | billingAddress: cart.billingAddress, 56 | cart: cartReference, 57 | country: cart.country, 58 | custom: cart.custom, 59 | customer: { 60 | typeId: "customer", 61 | id: cart.customerId, 62 | }, 63 | customerGroup: cart.customerGroup, 64 | customLineItems: [], 65 | directDiscounts: cart.directDiscounts, 66 | lineItems: cart.lineItems, 67 | paymentInfo: cart.paymentInfo, 68 | quoteRequestState: "Submitted", 69 | shippingAddress: cart.shippingAddress, 70 | taxCalculationMode: cart.taxCalculationMode, 71 | taxedPrice: cart.taxedPrice, 72 | taxMode: cart.taxMode, 73 | taxRoundingMode: cart.taxRoundingMode, 74 | totalPrice: cart.totalPrice, 75 | store: cart.store, 76 | }; 77 | return this.saveNew(context, resource); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/repositories/quote-staged/actions.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | InvalidJsonInputError, 3 | StagedQuote, 4 | StagedQuoteSetCustomFieldAction, 5 | StagedQuoteSetCustomTypeAction, 6 | StagedQuoteTransitionStateAction, 7 | StagedQuoteUpdateAction, 8 | StateReference, 9 | } from "@commercetools/platform-sdk"; 10 | import { CommercetoolsError } from "~src/exceptions"; 11 | import type { Writable } from "~src/types"; 12 | import type { RepositoryContext, UpdateHandlerInterface } from "../abstract"; 13 | import { AbstractUpdateHandler } from "../abstract"; 14 | import { getReferenceFromResourceIdentifier } from "../helpers"; 15 | 16 | export class StagedQuoteUpdateHandler 17 | extends AbstractUpdateHandler 18 | implements 19 | Partial> 20 | { 21 | setCustomField( 22 | context: RepositoryContext, 23 | resource: StagedQuote, 24 | { name, value }: StagedQuoteSetCustomFieldAction, 25 | ) { 26 | if (!resource.custom) { 27 | throw new Error("Resource has no custom field"); 28 | } 29 | resource.custom.fields[name] = value; 30 | } 31 | 32 | setCustomType( 33 | context: RepositoryContext, 34 | resource: Writable, 35 | { type, fields }: StagedQuoteSetCustomTypeAction, 36 | ) { 37 | if (!type) { 38 | resource.custom = undefined; 39 | } else { 40 | const resolvedType = this._storage.getByResourceIdentifier( 41 | context.projectKey, 42 | type, 43 | ); 44 | if (!resolvedType) { 45 | throw new Error(`Type ${type} not found`); 46 | } 47 | 48 | resource.custom = { 49 | type: { 50 | typeId: "type", 51 | id: resolvedType.id, 52 | }, 53 | fields: fields || {}, 54 | }; 55 | } 56 | } 57 | 58 | transitionState( 59 | context: RepositoryContext, 60 | resource: Writable, 61 | { state, force }: StagedQuoteTransitionStateAction, 62 | ) { 63 | let stateReference: StateReference | undefined = undefined; 64 | if (state) { 65 | stateReference = getReferenceFromResourceIdentifier( 66 | state, 67 | context.projectKey, 68 | this._storage, 69 | ); 70 | resource.state = stateReference; 71 | } else { 72 | throw new CommercetoolsError( 73 | { 74 | code: "InvalidJsonInput", 75 | message: "Request body does not contain valid JSON.", 76 | detailedErrorMessage: "actions -> state: Missing required value", 77 | }, 78 | 400, 79 | ); 80 | } 81 | 82 | return resource; 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/repositories/quote-staged/index.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | StagedQuote, 3 | StagedQuoteDraft, 4 | } from "@commercetools/platform-sdk"; 5 | import type { Config } from "~src/config"; 6 | import { getBaseResourceProperties } from "~src/helpers"; 7 | import type { RepositoryContext } from "../abstract"; 8 | import { AbstractResourceRepository } from "../abstract"; 9 | import { StagedQuoteUpdateHandler } from "./actions"; 10 | 11 | export class StagedQuoteRepository extends AbstractResourceRepository<"staged-quote"> { 12 | constructor(config: Config) { 13 | super("staged-quote", config); 14 | this.actions = new StagedQuoteUpdateHandler(config.storage); 15 | } 16 | 17 | create(context: RepositoryContext, draft: StagedQuoteDraft): StagedQuote { 18 | const quoteRequest = this._storage.getByResourceIdentifier<"quote-request">( 19 | context.projectKey, 20 | draft.quoteRequest, 21 | ); 22 | 23 | if (!quoteRequest.cart) { 24 | throw new Error("Cannot find quote request"); 25 | } 26 | 27 | const cart = this._storage.getByResourceIdentifier<"cart">( 28 | context.projectKey, 29 | quoteRequest.cart, 30 | ); 31 | 32 | const resource: StagedQuote = { 33 | ...getBaseResourceProperties(), 34 | stagedQuoteState: "InProgress", 35 | quoteRequest: { 36 | typeId: "quote-request", 37 | id: quoteRequest.id, 38 | }, 39 | quotationCart: { 40 | typeId: "cart", 41 | id: cart.id, 42 | }, 43 | }; 44 | 45 | return resource; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/repositories/quote/actions.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | InvalidJsonInputError, 3 | Quote, 4 | QuoteSetCustomFieldAction, 5 | QuoteSetCustomTypeAction, 6 | QuoteTransitionStateAction, 7 | QuoteUpdateAction, 8 | StateReference, 9 | } from "@commercetools/platform-sdk"; 10 | import { CommercetoolsError } from "~src/exceptions"; 11 | import type { Writable } from "~src/types"; 12 | import type { RepositoryContext, UpdateHandlerInterface } from "../abstract"; 13 | import { AbstractUpdateHandler } from "../abstract"; 14 | import { getReferenceFromResourceIdentifier } from "../helpers"; 15 | 16 | export class QuoteUpdateHandler 17 | extends AbstractUpdateHandler 18 | implements Partial> 19 | { 20 | setCustomField( 21 | context: RepositoryContext, 22 | resource: Quote, 23 | { name, value }: QuoteSetCustomFieldAction, 24 | ) { 25 | if (!resource.custom) { 26 | throw new Error("Resource has no custom field"); 27 | } 28 | resource.custom.fields[name] = value; 29 | } 30 | 31 | setCustomType( 32 | context: RepositoryContext, 33 | resource: Writable, 34 | { type, fields }: QuoteSetCustomTypeAction, 35 | ) { 36 | if (!type) { 37 | resource.custom = undefined; 38 | } else { 39 | const resolvedType = this._storage.getByResourceIdentifier( 40 | context.projectKey, 41 | type, 42 | ); 43 | if (!resolvedType) { 44 | throw new Error(`Type ${type} not found`); 45 | } 46 | 47 | resource.custom = { 48 | type: { 49 | typeId: "type", 50 | id: resolvedType.id, 51 | }, 52 | fields: fields || {}, 53 | }; 54 | } 55 | } 56 | 57 | transitionState( 58 | context: RepositoryContext, 59 | resource: Writable, 60 | { state, force }: QuoteTransitionStateAction, 61 | ) { 62 | let stateReference: StateReference | undefined = undefined; 63 | if (state) { 64 | stateReference = getReferenceFromResourceIdentifier( 65 | state, 66 | context.projectKey, 67 | this._storage, 68 | ); 69 | resource.state = stateReference; 70 | } else { 71 | throw new CommercetoolsError( 72 | { 73 | code: "InvalidJsonInput", 74 | message: "Request body does not contain valid JSON.", 75 | detailedErrorMessage: "actions -> state: Missing required value", 76 | }, 77 | 400, 78 | ); 79 | } 80 | 81 | return resource; 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/repositories/quote/index.ts: -------------------------------------------------------------------------------- 1 | import type { Quote, QuoteDraft } from "@commercetools/platform-sdk"; 2 | import type { Config } from "~src/config"; 3 | import { getBaseResourceProperties } from "~src/helpers"; 4 | import type { RepositoryContext } from "../abstract"; 5 | import { AbstractResourceRepository } from "../abstract"; 6 | import { QuoteUpdateHandler } from "./actions"; 7 | 8 | export class QuoteRepository extends AbstractResourceRepository<"quote"> { 9 | constructor(config: Config) { 10 | super("quote", config); 11 | this.actions = new QuoteUpdateHandler(config.storage); 12 | } 13 | 14 | create(context: RepositoryContext, draft: QuoteDraft): Quote { 15 | const staged = this._storage.getByResourceIdentifier<"staged-quote">( 16 | context.projectKey, 17 | draft.stagedQuote, 18 | ); 19 | 20 | const cart = this._storage.getByResourceIdentifier<"cart">( 21 | context.projectKey, 22 | staged.quotationCart, 23 | ); 24 | 25 | if (!cart.customerId) { 26 | throw new Error("Cart does not have a customer"); 27 | } 28 | 29 | const resource: Quote = { 30 | ...getBaseResourceProperties(), 31 | quoteState: "Accepted", 32 | quoteRequest: staged.quoteRequest, 33 | lineItems: cart.lineItems, 34 | customLineItems: cart.customLineItems, 35 | customer: { 36 | typeId: "customer", 37 | id: cart.customerId, 38 | }, 39 | stagedQuote: { 40 | typeId: "staged-quote", 41 | id: staged.id, 42 | }, 43 | totalPrice: cart.totalPrice, 44 | taxedPrice: cart.taxedPrice, 45 | taxMode: cart.taxMode, 46 | taxRoundingMode: cart.taxRoundingMode, 47 | taxCalculationMode: cart.taxCalculationMode, 48 | billingAddress: cart.billingAddress, 49 | shippingAddress: cart.shippingAddress, 50 | }; 51 | 52 | return resource; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/repositories/review.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ChannelReference, 3 | ProductReference, 4 | } from "@commercetools/platform-sdk"; 5 | import type { 6 | Review, 7 | ReviewDraft, 8 | StateReference, 9 | } from "@commercetools/platform-sdk"; 10 | import type { Config } from "~src/config"; 11 | import { getBaseResourceProperties } from "../helpers"; 12 | import type { RepositoryContext } from "./abstract"; 13 | import { AbstractResourceRepository } from "./abstract"; 14 | import { 15 | createCustomFields, 16 | getReferenceFromResourceIdentifier, 17 | } from "./helpers"; 18 | 19 | export class ReviewRepository extends AbstractResourceRepository<"review"> { 20 | constructor(config: Config) { 21 | super("review", config); 22 | } 23 | 24 | create(context: RepositoryContext, draft: ReviewDraft): Review { 25 | if (!draft.target) throw new Error("Missing target"); 26 | const resource: Review = { 27 | ...getBaseResourceProperties(), 28 | 29 | locale: draft.locale, 30 | authorName: draft.authorName, 31 | title: draft.title, 32 | text: draft.text, 33 | rating: draft.rating, 34 | uniquenessValue: draft.uniquenessValue, 35 | state: draft.state 36 | ? getReferenceFromResourceIdentifier( 37 | draft.state, 38 | context.projectKey, 39 | this._storage, 40 | ) 41 | : undefined, 42 | target: draft.target 43 | ? getReferenceFromResourceIdentifier< 44 | ProductReference | ChannelReference 45 | >(draft.target, context.projectKey, this._storage) 46 | : undefined, 47 | includedInStatistics: false, 48 | custom: createCustomFields( 49 | draft.custom, 50 | context.projectKey, 51 | this._storage, 52 | ), 53 | }; 54 | return this.saveNew(context, resource); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/repositories/shipping-method/helpers.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ShippingRate, 3 | ShippingRateDraft, 4 | } from "@commercetools/platform-sdk"; 5 | import { createTypedMoney } from "../helpers"; 6 | 7 | export const transformShippingRate = ( 8 | rate: ShippingRateDraft, 9 | ): ShippingRate => ({ 10 | price: createTypedMoney(rate.price), 11 | freeAbove: rate.freeAbove && createTypedMoney(rate.freeAbove), 12 | tiers: rate.tiers || [], 13 | }); 14 | -------------------------------------------------------------------------------- /src/repositories/shipping-method/index.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ShippingMethod, 3 | ShippingMethodDraft, 4 | ZoneRate, 5 | ZoneRateDraft, 6 | ZoneReference, 7 | } from "@commercetools/platform-sdk"; 8 | import type { Config } from "~src/config"; 9 | import { getBaseResourceProperties } from "../../helpers"; 10 | import { getShippingMethodsMatchingCart } from "../../shipping"; 11 | import type { GetParams, RepositoryContext } from "../abstract"; 12 | import { AbstractResourceRepository } from "../abstract"; 13 | import { 14 | createCustomFields, 15 | getReferenceFromResourceIdentifier, 16 | } from "../helpers"; 17 | import { ShippingMethodUpdateHandler } from "./actions"; 18 | import { transformShippingRate } from "./helpers"; 19 | 20 | export class ShippingMethodRepository extends AbstractResourceRepository<"shipping-method"> { 21 | constructor(config: Config) { 22 | super("shipping-method", config); 23 | this.actions = new ShippingMethodUpdateHandler(config.storage); 24 | } 25 | 26 | create( 27 | context: RepositoryContext, 28 | draft: ShippingMethodDraft, 29 | ): ShippingMethod { 30 | const resource: ShippingMethod = { 31 | ...getBaseResourceProperties(), 32 | ...draft, 33 | active: draft.active ?? true, 34 | taxCategory: getReferenceFromResourceIdentifier( 35 | draft.taxCategory, 36 | context.projectKey, 37 | this._storage, 38 | ), 39 | zoneRates: draft.zoneRates?.map((z) => 40 | this._transformZoneRateDraft(context, z), 41 | ), 42 | custom: createCustomFields( 43 | draft.custom, 44 | context.projectKey, 45 | this._storage, 46 | ), 47 | }; 48 | return this.saveNew(context, resource); 49 | } 50 | 51 | /* 52 | * Retrieves all the ShippingMethods that can ship to the shipping address of 53 | * the given Cart. Each ShippingMethod contains exactly one ShippingRate with 54 | * the flag isMatching set to true. This ShippingRate is used when the 55 | * ShippingMethod is added to the Cart. 56 | */ 57 | public matchingCart( 58 | context: RepositoryContext, 59 | cartId: string, 60 | params: GetParams = {}, 61 | ) { 62 | const cart = this._storage.get(context.projectKey, "cart", cartId); 63 | if (!cart) { 64 | return undefined; 65 | } 66 | 67 | return getShippingMethodsMatchingCart(context, this._storage, cart, params); 68 | } 69 | 70 | private _transformZoneRateDraft( 71 | context: RepositoryContext, 72 | draft: ZoneRateDraft, 73 | ): ZoneRate { 74 | return { 75 | ...draft, 76 | zone: getReferenceFromResourceIdentifier( 77 | draft.zone, 78 | context.projectKey, 79 | this._storage, 80 | ), 81 | shippingRates: draft.shippingRates?.map(transformShippingRate), 82 | }; 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/repositories/shopping-list/index.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | CustomerReference, 3 | LineItemDraft, 4 | ProductPagedQueryResponse, 5 | ShoppingList, 6 | ShoppingListDraft, 7 | ShoppingListLineItem, 8 | } from "@commercetools/platform-sdk"; 9 | import type { Config } from "~src/config"; 10 | import { getBaseResourceProperties } from "../../helpers"; 11 | import type { Writable } from "../../types"; 12 | import type { RepositoryContext } from "../abstract"; 13 | import { AbstractResourceRepository } from "../abstract"; 14 | import { 15 | createCustomFields, 16 | getBusinessUnitKeyReference, 17 | getReferenceFromResourceIdentifier, 18 | getStoreKeyReference, 19 | } from "../helpers"; 20 | import { ShoppingListUpdateHandler } from "./actions"; 21 | 22 | export class ShoppingListRepository extends AbstractResourceRepository<"shopping-list"> { 23 | constructor(config: Config) { 24 | super("shopping-list", config); 25 | this.actions = new ShoppingListUpdateHandler(config.storage); 26 | } 27 | 28 | create(context: RepositoryContext, draft: ShoppingListDraft): ShoppingList { 29 | const lineItems = 30 | draft.lineItems?.map((draftLineItem) => 31 | this.draftLineItemtoLineItem(context.projectKey, draftLineItem), 32 | ) ?? []; 33 | 34 | const resource: ShoppingList = { 35 | ...getBaseResourceProperties(), 36 | ...draft, 37 | custom: createCustomFields( 38 | draft.custom, 39 | context.projectKey, 40 | this._storage, 41 | ), 42 | textLineItems: [], 43 | lineItems, 44 | customer: draft.customer 45 | ? getReferenceFromResourceIdentifier( 46 | draft.customer, 47 | context.projectKey, 48 | this._storage, 49 | ) 50 | : undefined, 51 | store: draft.store 52 | ? getStoreKeyReference(draft.store, context.projectKey, this._storage) 53 | : undefined, 54 | businessUnit: draft.businessUnit 55 | ? getBusinessUnitKeyReference( 56 | draft.businessUnit, 57 | context.projectKey, 58 | this._storage, 59 | ) 60 | : undefined, 61 | }; 62 | return this.saveNew(context, resource); 63 | } 64 | 65 | draftLineItemtoLineItem = ( 66 | projectKey: string, 67 | draftLineItem: LineItemDraft, 68 | ): ShoppingListLineItem => { 69 | const { sku, productId, variantId } = draftLineItem; 70 | 71 | const lineItem: Writable = { 72 | ...getBaseResourceProperties(), 73 | ...draftLineItem, 74 | addedAt: draftLineItem.addedAt ?? "", 75 | productId: draftLineItem.productId ?? "", 76 | name: {}, 77 | variantId, 78 | quantity: draftLineItem.quantity ?? 1, 79 | productType: { typeId: "product-type", id: "" }, 80 | custom: createCustomFields( 81 | draftLineItem.custom, 82 | projectKey, 83 | this._storage, 84 | ), 85 | }; 86 | 87 | if (productId && variantId) { 88 | return lineItem; 89 | } 90 | 91 | if (sku) { 92 | const items = this._storage.query(projectKey, "product", { 93 | where: [ 94 | `masterData(current(masterVariant(sku="${sku}"))) or masterData(current(variants(sku="${sku}")))`, 95 | ], 96 | }) as ProductPagedQueryResponse; 97 | 98 | if (items.count === 0) { 99 | throw new Error(`Product with sku ${sku} not found`); 100 | } 101 | 102 | const product = items.results[0]; 103 | const allVariants = [ 104 | product.masterData.current.masterVariant, 105 | ...product.masterData.current.variants, 106 | ]; 107 | const variantId = allVariants.find((e) => e.sku === sku)?.id; 108 | lineItem.variantId = variantId; 109 | lineItem.productId = product.id; 110 | return lineItem; 111 | } 112 | 113 | if (productId) { 114 | const items = this._storage.query(projectKey, "product", { 115 | where: [`id="${productId}"`], 116 | }) as ProductPagedQueryResponse; 117 | 118 | if (items.count === 0) { 119 | throw new Error(`Product with id ${productId} not found`); 120 | } 121 | 122 | const variantId = items.results[0].masterData.current.masterVariant.id; 123 | lineItem.variantId = variantId; 124 | return lineItem; 125 | } 126 | 127 | throw new Error( 128 | "must provide either sku, productId or variantId for ShoppingListLineItem", 129 | ); 130 | }; 131 | } 132 | -------------------------------------------------------------------------------- /src/repositories/standalone-price.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ChannelReference, 3 | ChannelResourceIdentifier, 4 | DiscountedPriceDraft, 5 | StandalonePrice, 6 | StandalonePriceChangeActiveAction, 7 | StandalonePriceChangeValueAction, 8 | StandalonePriceDraft, 9 | StandalonePriceSetDiscountedPriceAction, 10 | StandalonePriceUpdateAction, 11 | } from "@commercetools/platform-sdk"; 12 | import type { Config } from "~src/config"; 13 | import { getBaseResourceProperties } from "../helpers"; 14 | import type { Writable } from "../types"; 15 | import type { RepositoryContext, UpdateHandlerInterface } from "./abstract"; 16 | import { AbstractResourceRepository, AbstractUpdateHandler } from "./abstract"; 17 | import { createTypedMoney } from "./helpers"; 18 | 19 | export class StandAlonePriceRepository extends AbstractResourceRepository<"standalone-price"> { 20 | constructor(config: Config) { 21 | super("standalone-price", config); 22 | this.actions = new StandalonePriceUpdateHandler(this._storage); 23 | } 24 | 25 | create( 26 | context: RepositoryContext, 27 | draft: StandalonePriceDraft, 28 | ): StandalonePrice { 29 | const resource: StandalonePrice = { 30 | ...getBaseResourceProperties(), 31 | active: draft.active ? draft.active : false, 32 | sku: draft.sku, 33 | value: createTypedMoney(draft.value), 34 | country: draft.country, 35 | discounted: draft.discounted 36 | ? transformDiscountDraft(draft.discounted) 37 | : undefined, 38 | channel: draft.channel?.id 39 | ? this.transformChannelReferenceDraft(draft.channel) 40 | : undefined, 41 | validFrom: draft.validFrom, 42 | validUntil: draft.validUntil, 43 | }; 44 | return this.saveNew(context, resource); 45 | } 46 | 47 | transformChannelReferenceDraft( 48 | channel: ChannelResourceIdentifier, 49 | ): ChannelReference { 50 | return { 51 | typeId: channel.typeId, 52 | id: channel.id as string, 53 | }; 54 | } 55 | } 56 | 57 | const transformDiscountDraft = (discounted: DiscountedPriceDraft) => ({ 58 | value: createTypedMoney(discounted.value), 59 | discount: discounted.discount, 60 | }); 61 | 62 | class StandalonePriceUpdateHandler 63 | extends AbstractUpdateHandler 64 | implements 65 | Partial< 66 | UpdateHandlerInterface 67 | > 68 | { 69 | changeValue( 70 | context: RepositoryContext, 71 | resource: Writable, 72 | action: StandalonePriceChangeValueAction, 73 | ) { 74 | resource.value = createTypedMoney(action.value); 75 | } 76 | 77 | setActive( 78 | context: RepositoryContext, 79 | resource: Writable, 80 | action: StandalonePriceChangeActiveAction, 81 | ) { 82 | resource.active = action.active; 83 | } 84 | 85 | setDiscountedPrice( 86 | context: RepositoryContext, 87 | resource: Writable, 88 | action: StandalonePriceSetDiscountedPriceAction, 89 | ) { 90 | resource.discounted = action.discounted 91 | ? transformDiscountDraft(action.discounted) 92 | : undefined; 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/repositories/state.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | State, 3 | StateAddRolesAction, 4 | StateChangeInitialAction, 5 | StateChangeKeyAction, 6 | StateChangeTypeAction, 7 | StateDraft, 8 | StateReference, 9 | StateRemoveRolesAction, 10 | StateSetDescriptionAction, 11 | StateSetNameAction, 12 | StateSetRolesAction, 13 | StateSetTransitionsAction, 14 | StateUpdateAction, 15 | } from "@commercetools/platform-sdk"; 16 | import type { Config } from "~src/config"; 17 | import { getBaseResourceProperties } from "../helpers"; 18 | import type { Writable } from "../types"; 19 | import type { RepositoryContext, UpdateHandlerInterface } from "./abstract"; 20 | import { AbstractResourceRepository, AbstractUpdateHandler } from "./abstract"; 21 | import { getReferenceFromResourceIdentifier } from "./helpers"; 22 | 23 | export class StateRepository extends AbstractResourceRepository<"state"> { 24 | constructor(config: Config) { 25 | super("state", config); 26 | this.actions = new StateUpdateHandler(config.storage); 27 | } 28 | 29 | create(context: RepositoryContext, draft: StateDraft): State { 30 | const resource: State = { 31 | ...getBaseResourceProperties(), 32 | ...draft, 33 | builtIn: false, 34 | initial: draft.initial || false, 35 | transitions: (draft.transitions || []).map((t) => 36 | getReferenceFromResourceIdentifier( 37 | t, 38 | context.projectKey, 39 | this._storage, 40 | ), 41 | ), 42 | }; 43 | 44 | return this.saveNew(context, resource); 45 | } 46 | } 47 | 48 | class StateUpdateHandler 49 | extends AbstractUpdateHandler 50 | implements UpdateHandlerInterface 51 | { 52 | addRoles( 53 | context: RepositoryContext, 54 | resource: Writable, 55 | action: StateAddRolesAction, 56 | ) { 57 | if (!resource.roles) { 58 | resource.roles = []; 59 | } 60 | for (const role of action.roles) { 61 | if (!resource.roles.includes(role)) { 62 | resource.roles.push(role); 63 | } 64 | } 65 | } 66 | 67 | changeInitial( 68 | context: RepositoryContext, 69 | resource: Writable, 70 | { initial }: StateChangeInitialAction, 71 | ) { 72 | resource.initial = initial; 73 | } 74 | 75 | changeKey( 76 | context: RepositoryContext, 77 | resource: Writable, 78 | { key }: StateChangeKeyAction, 79 | ) { 80 | resource.key = key; 81 | } 82 | 83 | changeType( 84 | context: RepositoryContext, 85 | resource: Writable, 86 | action: StateChangeTypeAction, 87 | ) { 88 | resource.type = action.type; 89 | } 90 | 91 | removeRoles( 92 | context: RepositoryContext, 93 | resource: Writable, 94 | action: StateRemoveRolesAction, 95 | ) { 96 | resource.roles = resource.roles?.filter( 97 | (role) => !action.roles.includes(role), 98 | ); 99 | } 100 | 101 | setDescription( 102 | context: RepositoryContext, 103 | resource: Writable, 104 | { description }: StateSetDescriptionAction, 105 | ) { 106 | resource.description = description; 107 | } 108 | 109 | setName( 110 | context: RepositoryContext, 111 | resource: Writable, 112 | { name }: StateSetNameAction, 113 | ) { 114 | resource.name = name; 115 | } 116 | 117 | setRoles( 118 | context: RepositoryContext, 119 | resource: Writable, 120 | { roles }: StateSetRolesAction, 121 | ) { 122 | resource.roles = roles; 123 | } 124 | 125 | setTransitions( 126 | context: RepositoryContext, 127 | resource: Writable, 128 | { transitions }: StateSetTransitionsAction, 129 | ) { 130 | resource.transitions = transitions?.map( 131 | (resourceId): StateReference => ({ 132 | id: resourceId.id || "", 133 | typeId: "state", 134 | }), 135 | ); 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /src/repositories/store.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ChannelReference, 3 | ChannelResourceIdentifier, 4 | Store, 5 | StoreDraft, 6 | StoreSetCountriesAction, 7 | StoreSetCustomFieldAction, 8 | StoreSetCustomTypeAction, 9 | StoreSetDistributionChannelsAction, 10 | StoreSetLanguagesAction, 11 | StoreSetNameAction, 12 | StoreUpdateAction, 13 | } from "@commercetools/platform-sdk"; 14 | import type { Config } from "~src/config"; 15 | import { getBaseResourceProperties } from "../helpers"; 16 | import type { AbstractStorage } from "../storage/abstract"; 17 | import type { Writable } from "../types"; 18 | import type { RepositoryContext, UpdateHandlerInterface } from "./abstract"; 19 | import { AbstractResourceRepository, AbstractUpdateHandler } from "./abstract"; 20 | import { 21 | createCustomFields, 22 | getReferenceFromResourceIdentifier, 23 | } from "./helpers"; 24 | 25 | export class StoreRepository extends AbstractResourceRepository<"store"> { 26 | constructor(config: Config) { 27 | super("store", config); 28 | this.actions = new StoreUpdateHandler(this._storage); 29 | } 30 | 31 | create(context: RepositoryContext, draft: StoreDraft): Store { 32 | const resource: Store = { 33 | ...getBaseResourceProperties(), 34 | key: draft.key, 35 | name: draft.name, 36 | languages: draft.languages ?? [], 37 | countries: draft.countries ?? [], 38 | distributionChannels: transformChannels( 39 | context, 40 | this._storage, 41 | draft.distributionChannels, 42 | ), 43 | supplyChannels: transformChannels( 44 | context, 45 | this._storage, 46 | draft.supplyChannels, 47 | ), 48 | productSelections: [], 49 | custom: createCustomFields( 50 | draft.custom, 51 | context.projectKey, 52 | this._storage, 53 | ), 54 | }; 55 | return this.saveNew(context, resource); 56 | } 57 | } 58 | 59 | const transformChannels = ( 60 | context: RepositoryContext, 61 | storage: AbstractStorage, 62 | channels?: ChannelResourceIdentifier[], 63 | ) => { 64 | if (!channels) return []; 65 | 66 | return channels.map((ref) => 67 | getReferenceFromResourceIdentifier( 68 | ref, 69 | context.projectKey, 70 | storage, 71 | ), 72 | ); 73 | }; 74 | 75 | class StoreUpdateHandler 76 | extends AbstractUpdateHandler 77 | implements Partial> 78 | { 79 | setCountries( 80 | context: RepositoryContext, 81 | resource: Writable, 82 | { countries }: StoreSetCountriesAction, 83 | ) { 84 | resource.countries = countries ?? []; 85 | } 86 | 87 | setCustomField( 88 | context: RepositoryContext, 89 | resource: Writable, 90 | { name, value }: StoreSetCustomFieldAction, 91 | ) { 92 | if (!resource.custom) { 93 | return; 94 | } 95 | if (value === null) { 96 | delete resource.custom.fields[name]; 97 | } else { 98 | resource.custom.fields[name] = value; 99 | } 100 | } 101 | 102 | setCustomType( 103 | context: RepositoryContext, 104 | resource: Writable, 105 | { type, fields }: StoreSetCustomTypeAction, 106 | ) { 107 | if (type) { 108 | resource.custom = createCustomFields( 109 | { type, fields }, 110 | context.projectKey, 111 | this._storage, 112 | ); 113 | } else { 114 | resource.custom = undefined; 115 | } 116 | } 117 | 118 | setDistributionChannels( 119 | context: RepositoryContext, 120 | resource: Writable, 121 | { distributionChannels }: StoreSetDistributionChannelsAction, 122 | ) { 123 | resource.distributionChannels = transformChannels( 124 | context, 125 | this._storage, 126 | distributionChannels, 127 | ); 128 | } 129 | 130 | setLanguages( 131 | context: RepositoryContext, 132 | resource: Writable, 133 | { languages }: StoreSetLanguagesAction, 134 | ) { 135 | resource.languages = languages ?? []; 136 | } 137 | 138 | setName( 139 | context: RepositoryContext, 140 | resource: Writable, 141 | { name }: StoreSetNameAction, 142 | ) { 143 | resource.name = name; 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /src/repositories/subscription.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | InvalidInputError, 3 | Subscription, 4 | SubscriptionDraft, 5 | SubscriptionSetKeyAction, 6 | SubscriptionUpdateAction, 7 | } from "@commercetools/platform-sdk"; 8 | import type { Config } from "~src/config"; 9 | import { CommercetoolsError } from "~src/exceptions"; 10 | import { getBaseResourceProperties } from "../helpers"; 11 | import type { Writable } from "../types"; 12 | import type { RepositoryContext, UpdateHandlerInterface } from "./abstract"; 13 | import { AbstractResourceRepository, AbstractUpdateHandler } from "./abstract"; 14 | 15 | export class SubscriptionRepository extends AbstractResourceRepository<"subscription"> { 16 | constructor(config: Config) { 17 | super("subscription", config); 18 | this.actions = new SubscriptionUpdateHandler(config.storage); 19 | } 20 | 21 | create(context: RepositoryContext, draft: SubscriptionDraft): Subscription { 22 | // TODO: We could actually test this here by using the aws sdk. For now 23 | // hardcode a failed check when account id is 0000000000 24 | if (draft.destination.type === "SQS") { 25 | const queueURL = new URL(draft.destination.queueUrl); 26 | const accountId = queueURL.pathname.split("/")[1]; 27 | if (accountId === "0000000000") { 28 | const dest = draft.destination; 29 | throw new CommercetoolsError( 30 | { 31 | code: "InvalidInput", 32 | message: `A test message could not be delivered to this destination: SQS ${dest.queueUrl} in ${dest.region} for ${dest.accessKey}. Please make sure your destination is correctly configured.`, 33 | }, 34 | 400, 35 | ); 36 | } 37 | } 38 | 39 | const resource: Subscription = { 40 | ...getBaseResourceProperties(), 41 | changes: draft.changes || [], 42 | destination: draft.destination, 43 | format: draft.format || { 44 | type: "Platform", 45 | }, 46 | key: draft.key, 47 | messages: draft.messages || [], 48 | status: "Healthy", 49 | events: draft.events || [], 50 | }; 51 | return this.saveNew(context, resource); 52 | } 53 | } 54 | 55 | class SubscriptionUpdateHandler 56 | extends AbstractUpdateHandler 57 | implements 58 | Partial> 59 | { 60 | setKey( 61 | _context: RepositoryContext, 62 | resource: Writable, 63 | { key }: SubscriptionSetKeyAction, 64 | ) { 65 | resource.key = key; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/repositories/tax-category/actions.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | TaxCategory, 3 | TaxCategoryAddTaxRateAction, 4 | TaxCategoryChangeNameAction, 5 | TaxCategoryRemoveTaxRateAction, 6 | TaxCategoryReplaceTaxRateAction, 7 | TaxCategorySetDescriptionAction, 8 | TaxCategorySetKeyAction, 9 | TaxCategoryUpdateAction, 10 | } from "@commercetools/platform-sdk"; 11 | import type { Writable } from "~src/types"; 12 | import type { RepositoryContext } from "../abstract"; 13 | import { AbstractUpdateHandler } from "../abstract"; 14 | import { taxRateFromTaxRateDraft } from "./helpers"; 15 | 16 | type TaxCategoryUpdateHandlerMethod = ( 17 | context: RepositoryContext, 18 | resource: Writable, 19 | action: T, 20 | ) => void; 21 | 22 | type TaxCategoryUpdateActions = { 23 | [P in TaxCategoryUpdateAction as P["action"]]: TaxCategoryUpdateHandlerMethod

; 24 | }; 25 | 26 | export class TaxCategoryUpdateHandler 27 | extends AbstractUpdateHandler 28 | implements TaxCategoryUpdateActions 29 | { 30 | addTaxRate( 31 | context: RepositoryContext, 32 | resource: Writable, 33 | { taxRate }: TaxCategoryAddTaxRateAction, 34 | ) { 35 | if (resource.rates === undefined) { 36 | resource.rates = []; 37 | } 38 | resource.rates.push(taxRateFromTaxRateDraft(taxRate)); 39 | } 40 | 41 | changeName( 42 | context: RepositoryContext, 43 | resource: Writable, 44 | { name }: TaxCategoryChangeNameAction, 45 | ) { 46 | resource.name = name; 47 | } 48 | 49 | removeTaxRate( 50 | context: RepositoryContext, 51 | resource: Writable, 52 | { taxRateId }: TaxCategoryRemoveTaxRateAction, 53 | ) { 54 | if (resource.rates === undefined) { 55 | resource.rates = []; 56 | } 57 | resource.rates = resource.rates.filter( 58 | (taxRate) => taxRate.id !== taxRateId, 59 | ); 60 | } 61 | 62 | replaceTaxRate( 63 | context: RepositoryContext, 64 | resource: Writable, 65 | { taxRateId, taxRate }: TaxCategoryReplaceTaxRateAction, 66 | ) { 67 | if (resource.rates === undefined) { 68 | resource.rates = []; 69 | } 70 | 71 | const taxRateObj = taxRateFromTaxRateDraft(taxRate); 72 | for (let i = 0; i < resource.rates.length; i++) { 73 | const rate = resource.rates[i]; 74 | if (rate.id === taxRateId) { 75 | resource.rates[i] = taxRateObj; 76 | } 77 | } 78 | } 79 | 80 | setDescription( 81 | context: RepositoryContext, 82 | resource: Writable, 83 | { description }: TaxCategorySetDescriptionAction, 84 | ) { 85 | resource.description = description; 86 | } 87 | 88 | setKey( 89 | context: RepositoryContext, 90 | resource: Writable, 91 | { key }: TaxCategorySetKeyAction, 92 | ) { 93 | resource.key = key; 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/repositories/tax-category/helpers.ts: -------------------------------------------------------------------------------- 1 | import type { TaxRate, TaxRateDraft } from "@commercetools/platform-sdk"; 2 | import { v4 as uuidv4 } from "uuid"; 3 | 4 | export const taxRateFromTaxRateDraft = (draft: TaxRateDraft): TaxRate => ({ 5 | ...draft, 6 | id: uuidv4(), 7 | amount: draft.amount || 0, 8 | }); 9 | -------------------------------------------------------------------------------- /src/repositories/tax-category/index.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | TaxCategory, 3 | TaxCategoryDraft, 4 | } from "@commercetools/platform-sdk"; 5 | import type { Config } from "~src/config"; 6 | import { getBaseResourceProperties } from "~src/helpers"; 7 | import type { RepositoryContext } from "../abstract"; 8 | import { AbstractResourceRepository } from "../abstract"; 9 | import { TaxCategoryUpdateHandler } from "./actions"; 10 | import { taxRateFromTaxRateDraft } from "./helpers"; 11 | 12 | export class TaxCategoryRepository extends AbstractResourceRepository<"tax-category"> { 13 | constructor(config: Config) { 14 | super("tax-category", config); 15 | this.actions = new TaxCategoryUpdateHandler(this._storage); 16 | } 17 | 18 | create(context: RepositoryContext, draft: TaxCategoryDraft): TaxCategory { 19 | const resource: TaxCategory = { 20 | ...getBaseResourceProperties(), 21 | ...draft, 22 | rates: draft.rates?.map(taxRateFromTaxRateDraft) || [], 23 | }; 24 | return this.saveNew(context, resource); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/repositories/type/index.ts: -------------------------------------------------------------------------------- 1 | import type { Type, TypeDraft } from "@commercetools/platform-sdk"; 2 | import type { Config } from "~src/config"; 3 | import { getBaseResourceProperties } from "~src/helpers"; 4 | import type { RepositoryContext } from "../abstract"; 5 | import { AbstractResourceRepository } from "../abstract"; 6 | import { TypeUpdateHandler } from "./actions"; 7 | 8 | export class TypeRepository extends AbstractResourceRepository<"type"> { 9 | constructor(config: Config) { 10 | super("type", config); 11 | this.actions = new TypeUpdateHandler(config.storage); 12 | } 13 | 14 | create(context: RepositoryContext, draft: TypeDraft): Type { 15 | const resource: Type = { 16 | ...getBaseResourceProperties(), 17 | key: draft.key, 18 | name: draft.name, 19 | resourceTypeIds: draft.resourceTypeIds, 20 | fieldDefinitions: draft.fieldDefinitions || [], 21 | description: draft.description, 22 | }; 23 | return this.saveNew(context, resource); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/repositories/zone.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | Zone, 3 | ZoneAddLocationAction, 4 | ZoneChangeNameAction, 5 | ZoneDraft, 6 | ZoneRemoveLocationAction, 7 | ZoneSetDescriptionAction, 8 | ZoneSetKeyAction, 9 | ZoneUpdateAction, 10 | } from "@commercetools/platform-sdk"; 11 | import type { Config } from "~src/config"; 12 | import { getBaseResourceProperties } from "../helpers"; 13 | import type { Writable } from "../types"; 14 | import type { RepositoryContext, UpdateHandlerInterface } from "./abstract"; 15 | import { AbstractResourceRepository, AbstractUpdateHandler } from "./abstract"; 16 | 17 | export class ZoneRepository extends AbstractResourceRepository<"zone"> { 18 | constructor(config: Config) { 19 | super("zone", config); 20 | this.actions = new ZoneUpdateHandler(config.storage); 21 | } 22 | 23 | create(context: RepositoryContext, draft: ZoneDraft): Zone { 24 | const resource: Zone = { 25 | ...getBaseResourceProperties(), 26 | key: draft.key, 27 | locations: draft.locations || [], 28 | name: draft.name, 29 | description: draft.description, 30 | }; 31 | return this.saveNew(context, resource); 32 | } 33 | } 34 | 35 | class ZoneUpdateHandler 36 | extends AbstractUpdateHandler 37 | implements Partial> 38 | { 39 | addLocation( 40 | context: RepositoryContext, 41 | resource: Writable, 42 | { location }: ZoneAddLocationAction, 43 | ) { 44 | resource.locations.push(location); 45 | } 46 | 47 | changeName( 48 | context: RepositoryContext, 49 | resource: Writable, 50 | { name }: ZoneChangeNameAction, 51 | ) { 52 | resource.name = name; 53 | } 54 | 55 | removeLocation( 56 | context: RepositoryContext, 57 | resource: Writable, 58 | { location }: ZoneRemoveLocationAction, 59 | ) { 60 | resource.locations = resource.locations.filter( 61 | (loc) => 62 | !(loc.country === location.country && loc.state === location.state), 63 | ); 64 | } 65 | 66 | setDescription( 67 | context: RepositoryContext, 68 | resource: Writable, 69 | { description }: ZoneSetDescriptionAction, 70 | ) { 71 | resource.description = description; 72 | } 73 | 74 | setKey( 75 | context: RepositoryContext, 76 | resource: Writable, 77 | { key }: ZoneSetKeyAction, 78 | ) { 79 | resource.key = key; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/schemas/update-request.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | const UpdateActionSchema = z 4 | .object({ 5 | action: z.string(), 6 | }) 7 | .passthrough(); 8 | 9 | export const updateRequestSchema = z.object({ 10 | version: z.number(), 11 | actions: z.array(UpdateActionSchema), 12 | }); 13 | -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- 1 | import { CommercetoolsMock } from "./index"; 2 | 3 | process.on("SIGINT", () => { 4 | console.info("Stopping server..."); 5 | process.exit(); 6 | }); 7 | 8 | const instance = new CommercetoolsMock(); 9 | 10 | let port = 3000; 11 | 12 | if (process.env.HTTP_SERVER_PORT) 13 | port = Number.parseInt(process.env.HTTP_SERVER_PORT); 14 | 15 | instance.runServer(port); 16 | -------------------------------------------------------------------------------- /src/services/as-associate-cart.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import type { CartRepository } from "../repositories/cart"; 3 | import AbstractService from "./abstract"; 4 | 5 | export class AsAssociateCartService extends AbstractService { 6 | public repository: CartRepository; 7 | 8 | constructor(parent: Router, repository: CartRepository) { 9 | super(parent); 10 | this.repository = repository; 11 | } 12 | 13 | getBasePath() { 14 | return "carts"; 15 | } 16 | 17 | registerRoutes(parent: Router) { 18 | const basePath = this.getBasePath(); 19 | const router = Router({ mergeParams: true }); 20 | 21 | this.extraRoutes(router); 22 | 23 | router.get("/", this.get.bind(this)); 24 | router.get("/:id", this.getWithId.bind(this)); 25 | 26 | router.delete("/:id", this.deleteWithId.bind(this)); 27 | 28 | router.post("/", this.post.bind(this)); 29 | router.post("/:id", this.postWithId.bind(this)); 30 | 31 | parent.use(`/${basePath}`, router); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/services/as-associate-order.test.ts: -------------------------------------------------------------------------------- 1 | import assert from "node:assert"; 2 | import type { Order } from "@commercetools/platform-sdk"; 3 | import supertest from "supertest"; 4 | import { afterEach, beforeEach, describe, expect, test } from "vitest"; 5 | import { CommercetoolsMock } from "../index"; 6 | 7 | describe("Order Query", () => { 8 | const ctMock = new CommercetoolsMock(); 9 | let order: Order | undefined; 10 | const projectKey = "dummy"; 11 | const customerId = "5fac8fca-2484-4b14-a1d1-cfdce2f8d3c4"; 12 | const businessUnitKey = "business-unit"; 13 | 14 | beforeEach(async () => { 15 | let response = await supertest(ctMock.app) 16 | .post( 17 | `/${projectKey}/as-associate/${customerId}/in-business-unit/key=${businessUnitKey}/carts`, 18 | ) 19 | .send({ 20 | currency: "EUR", 21 | custom: { 22 | type: { 23 | key: "my-cart", 24 | }, 25 | fields: { 26 | description: "example description", 27 | }, 28 | }, 29 | }); 30 | expect(response.status).toBe(201); 31 | const cart = response.body; 32 | 33 | response = await supertest(ctMock.app) 34 | .post( 35 | `/${projectKey}/as-associate/${customerId}/in-business-unit/key=${businessUnitKey}/orders`, 36 | ) 37 | .send({ 38 | cart: { 39 | typeId: "cart", 40 | id: cart.id, 41 | }, 42 | orderNumber: "foobar", 43 | }); 44 | expect(response.status).toBe(201); 45 | order = response.body; 46 | }); 47 | 48 | afterEach(() => { 49 | ctMock.clear(); 50 | }); 51 | 52 | test("no filter", async () => { 53 | assert(order, "order not created"); 54 | 55 | const response = await supertest(ctMock.app).get( 56 | `/${projectKey}/as-associate/${customerId}/in-business-unit/key=${businessUnitKey}/orders`, 57 | ); 58 | expect(response.status).toBe(200); 59 | expect(response.body.count).toBe(1); 60 | expect(response.body.total).toBe(1); 61 | expect(response.body.offset).toBe(0); 62 | expect(response.body.limit).toBe(20); 63 | }); 64 | }); 65 | -------------------------------------------------------------------------------- /src/services/as-associate-order.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import type { MyOrderRepository } from "../repositories/my-order"; 3 | import AbstractService from "./abstract"; 4 | 5 | export class AsAssociateOrderService extends AbstractService { 6 | public repository: MyOrderRepository; 7 | 8 | constructor(parent: Router, repository: MyOrderRepository) { 9 | super(parent); 10 | this.repository = repository; 11 | } 12 | 13 | getBasePath() { 14 | return "orders"; 15 | } 16 | 17 | registerRoutes(parent: Router) { 18 | const basePath = this.getBasePath(); 19 | const router = Router({ mergeParams: true }); 20 | 21 | this.extraRoutes(router); 22 | 23 | router.get("/", this.get.bind(this)); 24 | router.get("/:id", this.getWithId.bind(this)); 25 | 26 | router.delete("/:id", this.deleteWithId.bind(this)); 27 | 28 | router.post("/", this.post.bind(this)); 29 | router.post("/:id", this.postWithId.bind(this)); 30 | 31 | parent.use(`/${basePath}`, router); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/services/as-associate-quote-request.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import type { MyQuoteRequestRepository } from "~src/repositories/my-quote-request"; 3 | import type { MyOrderRepository } from "../repositories/my-order"; 4 | import AbstractService from "./abstract"; 5 | 6 | export class AsAssociateQuoteRequestService extends AbstractService { 7 | public repository: MyQuoteRequestRepository; 8 | 9 | constructor(parent: Router, repository: MyQuoteRequestRepository) { 10 | super(parent); 11 | this.repository = repository; 12 | } 13 | 14 | getBasePath() { 15 | return "quote-requests"; 16 | } 17 | 18 | registerRoutes(parent: Router) { 19 | const basePath = this.getBasePath(); 20 | const router = Router({ mergeParams: true }); 21 | 22 | this.extraRoutes(router); 23 | 24 | router.get("/", this.get.bind(this)); 25 | router.get("/:id", this.getWithId.bind(this)); 26 | 27 | router.delete("/:id", this.deleteWithId.bind(this)); 28 | 29 | router.post("/", this.post.bind(this)); 30 | router.post("/:id", this.postWithId.bind(this)); 31 | 32 | parent.use(`/${basePath}`, router); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/services/as-associate.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import type { 3 | AsAssociateCartRepository, 4 | AsAssociateOrderRepository, 5 | AsAssociateQuoteRequestRepository, 6 | } from "~src/repositories/as-associate"; 7 | import { AsAssociateCartService } from "./as-associate-cart"; 8 | import { AsAssociateOrderService } from "./as-associate-order"; 9 | import { AsAssociateQuoteRequestService } from "./as-associate-quote-request"; 10 | 11 | type Repositories = { 12 | cart: AsAssociateCartRepository; 13 | order: AsAssociateOrderRepository; 14 | "quote-request": AsAssociateQuoteRequestRepository; 15 | }; 16 | 17 | export class AsAssociateService { 18 | router: Router; 19 | 20 | subServices: { 21 | cart: AsAssociateCartService; 22 | order: AsAssociateOrderService; 23 | "quote-request": AsAssociateQuoteRequestService; 24 | }; 25 | 26 | constructor(parent: Router, repositories: Repositories) { 27 | this.router = Router({ mergeParams: true }); 28 | 29 | this.subServices = { 30 | order: new AsAssociateOrderService(this.router, repositories.order), 31 | cart: new AsAssociateCartService(this.router, repositories.cart), 32 | "quote-request": new AsAssociateQuoteRequestService( 33 | this.router, 34 | repositories["quote-request"], 35 | ), 36 | }; 37 | parent.use( 38 | "/as-associate/:associateId/in-business-unit/key=:businessUnitId", 39 | this.router, 40 | ); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/services/associate-roles.test.ts: -------------------------------------------------------------------------------- 1 | import type { AssociateRole } from "@commercetools/platform-sdk"; 2 | import supertest from "supertest"; 3 | import { afterEach, beforeEach, describe, expect, test } from "vitest"; 4 | import { CommercetoolsMock } from "../ctMock"; 5 | 6 | describe("Associate roles query", () => { 7 | const ctMock = new CommercetoolsMock(); 8 | let associateRole: AssociateRole | undefined; 9 | 10 | beforeEach(async () => { 11 | const response = await supertest(ctMock.app) 12 | .post("/dummy/associate-roles") 13 | .send({ 14 | name: "example-role", 15 | buyerAssignable: false, 16 | key: "example-role-associate-role", 17 | permissions: ["ViewMyQuotes", "ViewMyOrders", "ViewMyCarts"], 18 | }); 19 | 20 | expect(response.status).toBe(201); 21 | 22 | associateRole = response.body as AssociateRole; 23 | }); 24 | 25 | afterEach(() => { 26 | ctMock.clear(); 27 | }); 28 | 29 | test("no filter", async () => { 30 | const response = await supertest(ctMock.app) 31 | .get("/dummy/associate-roles") 32 | .query("{}") 33 | .send(); 34 | 35 | expect(response.status).toBe(200); 36 | expect(response.body.count).toBe(1); 37 | 38 | associateRole = response.body.results[0] as AssociateRole; 39 | 40 | expect(associateRole.key).toBe("example-role-associate-role"); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /src/services/associate-roles.ts: -------------------------------------------------------------------------------- 1 | import type { Router } from "express"; 2 | import type { AssociateRoleRepository } from "../repositories/associate-role"; 3 | import AbstractService from "./abstract"; 4 | 5 | export class AssociateRoleServices extends AbstractService { 6 | public repository: AssociateRoleRepository; 7 | 8 | constructor(parent: Router, repository: AssociateRoleRepository) { 9 | super(parent); 10 | 11 | this.repository = repository; 12 | } 13 | 14 | protected getBasePath(): string { 15 | return "associate-roles"; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/services/attribute-group.ts: -------------------------------------------------------------------------------- 1 | import type { Router } from "express"; 2 | import type { AttributeGroupRepository } from "../repositories/attribute-group"; 3 | import AbstractService from "./abstract"; 4 | 5 | export class AttributeGroupService extends AbstractService { 6 | public repository: AttributeGroupRepository; 7 | 8 | constructor(parent: Router, repository: AttributeGroupRepository) { 9 | super(parent); 10 | this.repository = repository; 11 | } 12 | 13 | getBasePath() { 14 | return "attribute-groups"; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/services/business-units.test.ts: -------------------------------------------------------------------------------- 1 | import type { BusinessUnit } from "@commercetools/platform-sdk"; 2 | import supertest from "supertest"; 3 | import { afterEach, beforeEach, describe, expect, test } from "vitest"; 4 | import { CommercetoolsMock } from "../ctMock"; 5 | 6 | describe("Business units query", () => { 7 | const ctMock = new CommercetoolsMock(); 8 | let businessUnit: BusinessUnit | undefined; 9 | 10 | beforeEach(async () => { 11 | const response = await supertest(ctMock.app) 12 | .post("/dummy/business-units") 13 | .send({ 14 | key: "example-business-unit", 15 | status: "Active", 16 | name: "Example Business Unit", 17 | unitType: "Company", 18 | }); 19 | 20 | expect(response.status).toBe(201); 21 | 22 | businessUnit = response.body as BusinessUnit; 23 | }); 24 | 25 | afterEach(() => { 26 | ctMock.clear(); 27 | }); 28 | 29 | test("no filter", async () => { 30 | const response = await supertest(ctMock.app) 31 | .get("/dummy/business-units") 32 | .query("{}") 33 | .send(); 34 | 35 | expect(response.status).toBe(200); 36 | expect(response.body.count).toBe(1); 37 | 38 | businessUnit = response.body.results[0] as BusinessUnit; 39 | 40 | expect(businessUnit.key).toBe("example-business-unit"); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /src/services/business-units.ts: -------------------------------------------------------------------------------- 1 | import type { Router } from "express"; 2 | import type { BusinessUnitRepository } from "../repositories/business-unit"; 3 | import AbstractService from "./abstract"; 4 | 5 | export class BusinessUnitServices extends AbstractService { 6 | public repository: BusinessUnitRepository; 7 | 8 | constructor(parent: Router, repository: BusinessUnitRepository) { 9 | super(parent); 10 | 11 | this.repository = repository; 12 | } 13 | 14 | protected getBasePath(): string { 15 | return "business-units"; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/services/cart-discount.ts: -------------------------------------------------------------------------------- 1 | import type { Router } from "express"; 2 | import type { CartDiscountRepository } from "../repositories/cart-discount"; 3 | import AbstractService from "./abstract"; 4 | 5 | export class CartDiscountService extends AbstractService { 6 | public repository: CartDiscountRepository; 7 | 8 | constructor(parent: Router, repository: CartDiscountRepository) { 9 | super(parent); 10 | this.repository = repository; 11 | } 12 | 13 | getBasePath() { 14 | return "cart-discounts"; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/services/cart.ts: -------------------------------------------------------------------------------- 1 | import type { Cart, CartDraft, Order } from "@commercetools/platform-sdk"; 2 | import type { Request, Response, Router } from "express"; 3 | import type { CartRepository } from "../repositories/cart"; 4 | import { getRepositoryContext } from "../repositories/helpers"; 5 | import type { OrderRepository } from "../repositories/order"; 6 | import AbstractService from "./abstract"; 7 | 8 | export class CartService extends AbstractService { 9 | public repository: CartRepository; 10 | 11 | public orderRepository: OrderRepository; 12 | 13 | constructor( 14 | parent: Router, 15 | cartRepository: CartRepository, 16 | orderRepository: OrderRepository, 17 | ) { 18 | super(parent); 19 | this.repository = cartRepository; 20 | this.orderRepository = orderRepository; 21 | } 22 | 23 | getBasePath() { 24 | return "carts"; 25 | } 26 | 27 | extraRoutes(parent: Router) { 28 | parent.post("/replicate", this.replicate.bind(this)); 29 | } 30 | 31 | replicate(request: Request, response: Response) { 32 | const context = getRepositoryContext(request); 33 | 34 | // @ts-ignore 35 | const cartOrOrder: Cart | Order | null = 36 | request.body.reference.typeId === "order" 37 | ? this.orderRepository.get(context, request.body.reference.id) 38 | : this.repository.get(context, request.body.reference.id); 39 | 40 | if (!cartOrOrder) { 41 | response.status(400).send(); 42 | return; 43 | } 44 | 45 | const cartDraft: CartDraft = { 46 | ...cartOrOrder, 47 | currency: cartOrOrder.totalPrice.currencyCode, 48 | discountCodes: [], 49 | shipping: [], // TODO: cartOrOrder.shipping, 50 | lineItems: cartOrOrder.lineItems.map((lineItem) => ({ 51 | ...lineItem, 52 | variantId: lineItem.variant.id, 53 | sku: lineItem.variant.sku, 54 | })), 55 | }; 56 | 57 | const newCart = this.repository.create(context, cartDraft); 58 | 59 | response.status(200).send(newCart); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/services/category.ts: -------------------------------------------------------------------------------- 1 | import type { Router } from "express"; 2 | import type { CategoryRepository } from "../repositories/category/index"; 3 | import AbstractService from "./abstract"; 4 | 5 | export class CategoryServices extends AbstractService { 6 | public repository: CategoryRepository; 7 | 8 | constructor(parent: Router, repository: CategoryRepository) { 9 | super(parent); 10 | this.repository = repository; 11 | } 12 | 13 | getBasePath() { 14 | return "categories"; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/services/channel.ts: -------------------------------------------------------------------------------- 1 | import type { Router } from "express"; 2 | import type { ChannelRepository } from "../repositories/channel"; 3 | import AbstractService from "./abstract"; 4 | 5 | export class ChannelService extends AbstractService { 6 | public repository: ChannelRepository; 7 | 8 | constructor(parent: Router, repository: ChannelRepository) { 9 | super(parent); 10 | this.repository = repository; 11 | } 12 | 13 | getBasePath() { 14 | return "channels"; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/services/custom-object.ts: -------------------------------------------------------------------------------- 1 | import type { CustomObjectDraft } from "@commercetools/platform-sdk"; 2 | import type { Request, Response, Router } from "express"; 3 | import type { CustomObjectRepository } from "../repositories/custom-object"; 4 | import { getRepositoryContext } from "../repositories/helpers"; 5 | import AbstractService from "./abstract"; 6 | 7 | export class CustomObjectService extends AbstractService { 8 | public repository: CustomObjectRepository; 9 | 10 | constructor(parent: Router, repository: CustomObjectRepository) { 11 | super(parent); 12 | this.repository = repository; 13 | } 14 | 15 | getBasePath() { 16 | return "custom-objects"; 17 | } 18 | 19 | extraRoutes(router: Router) { 20 | router.get("/:container", this.getWithContainer.bind(this)); 21 | router.get("/:container/:key", this.getWithContainerAndKey.bind(this)); 22 | router.post("/:container/:key", this.createWithContainerAndKey.bind(this)); 23 | router.delete( 24 | "/:container/:key", 25 | this.deleteWithContainerAndKey.bind(this), 26 | ); 27 | } 28 | 29 | getWithContainer(request: Request, response: Response) { 30 | const limit = this._parseParam(request.query.limit); 31 | const offset = this._parseParam(request.query.offset); 32 | 33 | const result = this.repository.queryWithContainer( 34 | getRepositoryContext(request), 35 | request.params.container, 36 | { 37 | expand: this._parseParam(request.query.expand), 38 | where: this._parseParam(request.query.where), 39 | limit: limit !== undefined ? Number(limit) : undefined, 40 | offset: offset !== undefined ? Number(offset) : undefined, 41 | }, 42 | ); 43 | 44 | response.status(200).send(result); 45 | } 46 | 47 | getWithContainerAndKey(request: Request, response: Response) { 48 | const result = this.repository.getWithContainerAndKey( 49 | getRepositoryContext(request), 50 | request.params.container, 51 | request.params.key, 52 | ); 53 | 54 | if (!result) { 55 | response.status(404).send({ statusCode: 404 }); 56 | return; 57 | } 58 | response.status(200).send(result); 59 | } 60 | 61 | createWithContainerAndKey(request: Request, response: Response) { 62 | const draft: CustomObjectDraft = { 63 | ...request.body, 64 | key: request.params.key, 65 | container: request.params.container, 66 | }; 67 | 68 | const result = this.repository.create(getRepositoryContext(request), draft); 69 | response.status(200).send(result); 70 | } 71 | 72 | deleteWithContainerAndKey(request: Request, response: Response) { 73 | const current = this.repository.getWithContainerAndKey( 74 | getRepositoryContext(request), 75 | request.params.container, 76 | request.params.key, 77 | ); 78 | 79 | if (!current) { 80 | response.status(404).send({ statusCode: 404 }); 81 | return; 82 | } 83 | 84 | const result = this.repository.delete( 85 | getRepositoryContext(request), 86 | current.id, 87 | ); 88 | 89 | response.status(200).send(result); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/services/customer-group.ts: -------------------------------------------------------------------------------- 1 | import type { Router } from "express"; 2 | import type { CustomerGroupRepository } from "../repositories/customer-group"; 3 | import AbstractService from "./abstract"; 4 | 5 | export class CustomerGroupService extends AbstractService { 6 | public repository: CustomerGroupRepository; 7 | 8 | constructor(parent: Router, repository: CustomerGroupRepository) { 9 | super(parent); 10 | this.repository = repository; 11 | } 12 | 13 | getBasePath() { 14 | return "customer-groups"; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/services/customer.ts: -------------------------------------------------------------------------------- 1 | import type { CustomerSignInResult } from "@commercetools/platform-sdk"; 2 | import type { Router } from "express"; 3 | import type { Request, Response } from "express"; 4 | import type { CustomerRepository } from "../repositories/customer"; 5 | import { getRepositoryContext } from "../repositories/helpers"; 6 | import AbstractService from "./abstract"; 7 | 8 | export class CustomerService extends AbstractService { 9 | public repository: CustomerRepository; 10 | 11 | constructor(parent: Router, repository: CustomerRepository) { 12 | super(parent); 13 | this.repository = repository; 14 | } 15 | 16 | getBasePath() { 17 | return "customers"; 18 | } 19 | 20 | extraRoutes(parent: Router) { 21 | parent.post("/password-token", this.passwordResetToken.bind(this)); 22 | parent.post("/password/reset", this.passwordReset.bind(this)); 23 | parent.post("/email-token", this.confirmEmailToken.bind(this)); 24 | } 25 | 26 | post(request: Request, response: Response) { 27 | const draft = request.body; 28 | const resource = this.repository.create( 29 | getRepositoryContext(request), 30 | draft, 31 | ); 32 | const expanded = this._expandWithId(request, resource.id); 33 | 34 | const result: CustomerSignInResult = { 35 | customer: expanded, 36 | }; 37 | response.status(this.createStatusCode).send(result); 38 | } 39 | 40 | passwordResetToken(request: Request, response: Response) { 41 | const customer = this.repository.passwordResetToken( 42 | getRepositoryContext(request), 43 | request.body, 44 | ); 45 | 46 | response.status(200).send(customer); 47 | } 48 | 49 | passwordReset(request: Request, response: Response) { 50 | const customer = this.repository.passwordReset( 51 | getRepositoryContext(request), 52 | request.body, 53 | ); 54 | 55 | response.status(200).send(customer); 56 | } 57 | 58 | confirmEmailToken(request: Request, response: Response) { 59 | const id = request.body.id; 60 | const token = this.repository.verifyEmailToken( 61 | getRepositoryContext(request), 62 | id, 63 | ); 64 | response.status(200).send(token); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/services/discount-code.ts: -------------------------------------------------------------------------------- 1 | import type { Router } from "express"; 2 | import type { DiscountCodeRepository } from "../repositories/discount-code/index"; 3 | import AbstractService from "./abstract"; 4 | 5 | export class DiscountCodeService extends AbstractService { 6 | public repository: DiscountCodeRepository; 7 | 8 | constructor(parent: Router, repository: DiscountCodeRepository) { 9 | super(parent); 10 | this.repository = repository; 11 | } 12 | 13 | getBasePath() { 14 | return "discount-codes"; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/services/extension.ts: -------------------------------------------------------------------------------- 1 | import type { Router } from "express"; 2 | import type { ExtensionRepository } from "../repositories/extension"; 3 | import AbstractService from "./abstract"; 4 | 5 | export class ExtensionServices extends AbstractService { 6 | public repository: ExtensionRepository; 7 | 8 | constructor(parent: Router, repository: ExtensionRepository) { 9 | super(parent); 10 | this.repository = repository; 11 | } 12 | 13 | getBasePath() { 14 | return "extensions"; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/services/inventory-entry.ts: -------------------------------------------------------------------------------- 1 | import type { Router } from "express"; 2 | import type { InventoryEntryRepository } from "../repositories/inventory-entry"; 3 | import AbstractService from "./abstract"; 4 | 5 | export class InventoryEntryService extends AbstractService { 6 | public repository: InventoryEntryRepository; 7 | 8 | constructor(parent: Router, repository: InventoryEntryRepository) { 9 | super(parent); 10 | this.repository = repository; 11 | } 12 | 13 | getBasePath() { 14 | return "inventory"; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/services/my-business-unit.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import type { BusinessUnitRepository } from "~src/repositories/business-unit"; 3 | import AbstractService from "./abstract"; 4 | 5 | export class MyBusinessUnitService extends AbstractService { 6 | public repository: BusinessUnitRepository; 7 | 8 | constructor(parent: Router, repository: BusinessUnitRepository) { 9 | super(parent); 10 | this.repository = repository; 11 | } 12 | 13 | getBasePath() { 14 | return "me"; 15 | } 16 | 17 | registerRoutes(parent: Router) { 18 | // Overwrite this function to be able to handle /me/business-units path. 19 | const basePath = this.getBasePath(); 20 | const router = Router({ mergeParams: true }); 21 | 22 | this.extraRoutes(router); 23 | 24 | router.get("/business-units/", this.get.bind(this)); 25 | 26 | parent.use(`/${basePath}`, router); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/services/my-cart.test.ts: -------------------------------------------------------------------------------- 1 | import type { Cart, MyCartDraft } from "@commercetools/platform-sdk"; 2 | import supertest from "supertest"; 3 | import { afterEach, beforeEach, describe, expect, test } from "vitest"; 4 | import { CommercetoolsMock } from "../index"; 5 | 6 | const ctMock = new CommercetoolsMock(); 7 | 8 | describe("MyCart", () => { 9 | beforeEach(async () => { 10 | const response = await supertest(ctMock.app) 11 | .post("/dummy/types") 12 | .send({ 13 | key: "custom-payment", 14 | name: { 15 | "nl-NL": "custom-payment", 16 | }, 17 | resourceTypeIds: ["payment"], 18 | }); 19 | expect(response.status).toBe(201); 20 | }); 21 | 22 | afterEach(() => { 23 | ctMock.clear(); 24 | }); 25 | 26 | test("Create my cart", async () => { 27 | const draft: MyCartDraft = { 28 | currency: "EUR", 29 | }; 30 | 31 | const response = await supertest(ctMock.app) 32 | .post("/dummy/me/carts") 33 | .send(draft); 34 | 35 | expect(response.status).toBe(201); 36 | expect(response.body).toEqual({ 37 | id: expect.anything(), 38 | createdAt: expect.anything(), 39 | lastModifiedAt: expect.anything(), 40 | version: 1, 41 | cartState: "Active", 42 | discountCodes: [], 43 | directDiscounts: [], 44 | inventoryMode: "None", 45 | itemShippingAddresses: [], 46 | lineItems: [], 47 | customLineItems: [], 48 | shipping: [], 49 | shippingMode: "Single", 50 | totalPrice: { 51 | type: "centPrecision", 52 | centAmount: 0, 53 | currencyCode: "EUR", 54 | fractionDigits: 0, 55 | }, 56 | taxMode: "Platform", 57 | taxRoundingMode: "HalfEven", 58 | taxCalculationMode: "LineItemLevel", 59 | refusedGifts: [], 60 | origin: "Customer", 61 | } as Cart); 62 | }); 63 | 64 | test("Get my cart by ID", async () => { 65 | const draft: MyCartDraft = { 66 | currency: "EUR", 67 | }; 68 | const createResponse = await supertest(ctMock.app) 69 | .post("/dummy/me/carts") 70 | .send(draft); 71 | 72 | const response = await supertest(ctMock.app).get( 73 | `/dummy/me/carts/${createResponse.body.id}`, 74 | ); 75 | 76 | expect(response.status).toBe(200); 77 | expect(response.body).toEqual(createResponse.body); 78 | }); 79 | 80 | test("Get my active cart", async () => { 81 | const draft: MyCartDraft = { 82 | currency: "EUR", 83 | }; 84 | const createResponse = await supertest(ctMock.app) 85 | .post("/dummy/me/carts") 86 | .send(draft); 87 | 88 | const response = await supertest(ctMock.app).get("/dummy/me/active-cart"); 89 | 90 | expect(response.status).toBe(200); 91 | expect(response.body).toEqual(createResponse.body); 92 | }); 93 | 94 | test("Get my active cart which doesnt exists", async () => { 95 | const response = await supertest(ctMock.app).get("/dummy/me/active-cart"); 96 | 97 | expect(response.status).toBe(404); 98 | }); 99 | }); 100 | -------------------------------------------------------------------------------- /src/services/my-cart.ts: -------------------------------------------------------------------------------- 1 | import type { Request, Response } from "express"; 2 | import { Router } from "express"; 3 | import type { CartRepository } from "../repositories/cart"; 4 | import AbstractService from "./abstract"; 5 | 6 | export class MyCartService extends AbstractService { 7 | public repository: CartRepository; 8 | 9 | constructor(parent: Router, repository: CartRepository) { 10 | super(parent); 11 | this.repository = repository; 12 | } 13 | 14 | getBasePath() { 15 | return "me"; 16 | } 17 | 18 | registerRoutes(parent: Router) { 19 | // Overwrite this function to be able to handle /me/active-cart path. 20 | const basePath = this.getBasePath(); 21 | const router = Router({ mergeParams: true }); 22 | 23 | this.extraRoutes(router); 24 | 25 | router.get("/active-cart", this.activeCart.bind(this)); 26 | router.get("/carts/", this.get.bind(this)); 27 | router.get("/carts/:id", this.getWithId.bind(this)); 28 | 29 | router.delete("/carts/:id", this.deleteWithId.bind(this)); 30 | 31 | router.post("/carts/", this.post.bind(this)); 32 | router.post("/carts/:id", this.postWithId.bind(this)); 33 | 34 | parent.use(`/${basePath}`, router); 35 | } 36 | 37 | activeCart(request: Request, response: Response) { 38 | const resource = this.repository.getActiveCart(request.params.projectKey); 39 | if (!resource) { 40 | response.status(404).send({ 41 | statusCode: 404, 42 | message: "No active cart exists.", 43 | errors: [ 44 | { 45 | code: "ResourceNotFound", 46 | message: "No active cart exists.", 47 | }, 48 | ], 49 | }); 50 | return; 51 | } 52 | response.status(200).send(resource); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/services/my-order.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import type { MyOrderRepository } from "../repositories/my-order"; 3 | import AbstractService from "./abstract"; 4 | 5 | export class MyOrderService extends AbstractService { 6 | public repository: MyOrderRepository; 7 | 8 | constructor(parent: Router, repository: MyOrderRepository) { 9 | super(parent); 10 | this.repository = repository; 11 | } 12 | 13 | getBasePath() { 14 | return "me"; 15 | } 16 | 17 | registerRoutes(parent: Router) { 18 | // Overwrite this function to be able to handle /me/active-cart path. 19 | const basePath = this.getBasePath(); 20 | const router = Router({ mergeParams: true }); 21 | 22 | this.extraRoutes(router); 23 | 24 | router.get("/orders/", this.get.bind(this)); 25 | router.get("/orders/:id", this.getWithId.bind(this)); 26 | 27 | router.delete("/orders/:id", this.deleteWithId.bind(this)); 28 | 29 | router.post("/orders/", this.post.bind(this)); 30 | router.post("/orders/:id", this.postWithId.bind(this)); 31 | 32 | parent.use(`/${basePath}`, router); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/services/my-payment.test.ts: -------------------------------------------------------------------------------- 1 | import type { MyPaymentDraft } from "@commercetools/platform-sdk"; 2 | import supertest from "supertest"; 3 | import { beforeEach, describe, expect, test } from "vitest"; 4 | import { CommercetoolsMock } from "../index"; 5 | 6 | const ctMock = new CommercetoolsMock(); 7 | 8 | describe("MyPayment", () => { 9 | beforeEach(async () => { 10 | const response = await supertest(ctMock.app) 11 | .post("/dummy/types") 12 | .send({ 13 | key: "custom-payment", 14 | name: { 15 | "nl-NL": "custom-payment", 16 | }, 17 | resourceTypeIds: ["payment"], 18 | }); 19 | expect(response.status).toBe(201); 20 | }); 21 | 22 | test("Create payment", async () => { 23 | const draft: MyPaymentDraft = { 24 | amountPlanned: { currencyCode: "EUR", centAmount: 1337 }, 25 | custom: { 26 | type: { typeId: "type", key: "custom-payment" }, 27 | fields: { 28 | foo: "bar", 29 | }, 30 | }, 31 | }; 32 | const response = await supertest(ctMock.app) 33 | .post("/dummy/me/payments") 34 | .send(draft); 35 | 36 | expect(response.status).toBe(201); 37 | expect(response.body).toEqual({ 38 | id: expect.anything(), 39 | createdAt: expect.anything(), 40 | lastModifiedAt: expect.anything(), 41 | version: 1, 42 | amountPlanned: { 43 | type: "centPrecision", 44 | fractionDigits: 2, 45 | currencyCode: "EUR", 46 | centAmount: 1337, 47 | }, 48 | paymentStatus: {}, 49 | transactions: [], 50 | interfaceInteractions: [], 51 | custom: { 52 | type: { typeId: "type", id: expect.anything() }, 53 | fields: { foo: "bar" }, 54 | }, 55 | }); 56 | }); 57 | test("Get payment", async () => { 58 | const draft: MyPaymentDraft = { 59 | amountPlanned: { currencyCode: "EUR", centAmount: 1337 }, 60 | custom: { 61 | type: { typeId: "type", key: "custom-payment" }, 62 | fields: { 63 | foo: "bar", 64 | }, 65 | }, 66 | }; 67 | const createResponse = await supertest(ctMock.app) 68 | .post("/dummy/me/payments") 69 | .send(draft); 70 | 71 | const response = await supertest(ctMock.app).get( 72 | `/dummy/me/payments/${createResponse.body.id}`, 73 | ); 74 | 75 | expect(response.status).toBe(200); 76 | expect(response.body).toEqual(createResponse.body); 77 | }); 78 | }); 79 | -------------------------------------------------------------------------------- /src/services/my-payment.ts: -------------------------------------------------------------------------------- 1 | import type { Router } from "express"; 2 | import type { PaymentRepository } from "../repositories/payment"; 3 | import AbstractService from "./abstract"; 4 | 5 | export class MyPaymentService extends AbstractService { 6 | public repository: PaymentRepository; 7 | 8 | constructor(parent: Router, repository: PaymentRepository) { 9 | super(parent); 10 | this.repository = repository; 11 | } 12 | 13 | getBasePath() { 14 | return "me/payments"; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/services/my-shopping-list.ts: -------------------------------------------------------------------------------- 1 | import type { Router } from "express"; 2 | import type { ShoppingListRepository } from "../repositories/shopping-list"; 3 | import AbstractService from "./abstract"; 4 | 5 | export class MyShoppingListService extends AbstractService { 6 | public repository: ShoppingListRepository; 7 | 8 | constructor(parent: Router, repository: ShoppingListRepository) { 9 | super(parent); 10 | this.repository = repository; 11 | } 12 | 13 | getBasePath() { 14 | return "me/shopping-lists"; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/services/order.ts: -------------------------------------------------------------------------------- 1 | import type { Request, Response, Router } from "express"; 2 | import { getRepositoryContext } from "../repositories/helpers"; 3 | import type { OrderRepository } from "../repositories/order/index"; 4 | import AbstractService from "./abstract"; 5 | 6 | export class OrderService extends AbstractService { 7 | public repository: OrderRepository; 8 | 9 | constructor(parent: Router, repository: OrderRepository) { 10 | super(parent); 11 | this.repository = repository; 12 | } 13 | 14 | getBasePath() { 15 | return "orders"; 16 | } 17 | 18 | extraRoutes(router: Router) { 19 | router.post("/import", this.import.bind(this)); 20 | router.get( 21 | "/order-number=:orderNumber", 22 | this.getWithOrderNumber.bind(this), 23 | ); 24 | } 25 | 26 | import(request: Request, response: Response) { 27 | const importDraft = request.body; 28 | const resource = this.repository.import( 29 | getRepositoryContext(request), 30 | importDraft, 31 | ); 32 | response.status(200).send(resource); 33 | } 34 | 35 | getWithOrderNumber(request: Request, response: Response) { 36 | const orderNumber = request.params.orderNumber; 37 | const resource = this.repository.getWithOrderNumber( 38 | getRepositoryContext(request), 39 | orderNumber, 40 | 41 | // @ts-ignore 42 | request.query, 43 | ); 44 | if (resource) { 45 | response.status(200).send(resource); 46 | return; 47 | } 48 | response.status(404).send({ 49 | statusCode: 404, 50 | message: `The Resource with key '${orderNumber}' was not found.`, 51 | errors: [ 52 | { 53 | code: "ResourceNotFound", 54 | message: `The Resource with key '${orderNumber}' was not found.`, 55 | }, 56 | ], 57 | }); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/services/payment.test.ts: -------------------------------------------------------------------------------- 1 | import type { PaymentDraft } from "@commercetools/platform-sdk"; 2 | import supertest from "supertest"; 3 | import { beforeEach, describe, expect, test } from "vitest"; 4 | import { CommercetoolsMock } from "../index"; 5 | 6 | const ctMock = new CommercetoolsMock(); 7 | 8 | describe("Payment", () => { 9 | beforeEach(async () => { 10 | const response = await supertest(ctMock.app) 11 | .post("/dummy/types") 12 | .send({ 13 | key: "custom-payment", 14 | name: { 15 | "nl-NL": "custom-payment", 16 | }, 17 | resourceTypeIds: ["payment"], 18 | }); 19 | expect(response.status).toBe(201); 20 | }); 21 | 22 | test("Create payment", async () => { 23 | const draft: PaymentDraft = { 24 | amountPlanned: { currencyCode: "EUR", centAmount: 1337 }, 25 | custom: { 26 | type: { typeId: "type", key: "custom-payment" }, 27 | fields: { 28 | foo: "bar", 29 | }, 30 | }, 31 | }; 32 | const response = await supertest(ctMock.app) 33 | .post("/dummy/payments") 34 | .send(draft); 35 | 36 | expect(response.status).toBe(201); 37 | expect(response.body).toEqual({ 38 | id: expect.anything(), 39 | createdAt: expect.anything(), 40 | lastModifiedAt: expect.anything(), 41 | version: 1, 42 | amountPlanned: { 43 | type: "centPrecision", 44 | fractionDigits: 2, 45 | currencyCode: "EUR", 46 | centAmount: 1337, 47 | }, 48 | paymentStatus: {}, 49 | transactions: [], 50 | interfaceInteractions: [], 51 | custom: { 52 | type: { typeId: "type", id: expect.anything() }, 53 | fields: { foo: "bar" }, 54 | }, 55 | }); 56 | }); 57 | test("Get payment", async () => { 58 | const draft: PaymentDraft = { 59 | amountPlanned: { currencyCode: "EUR", centAmount: 1337 }, 60 | custom: { 61 | type: { typeId: "type", key: "custom-payment" }, 62 | fields: { 63 | foo: "bar", 64 | }, 65 | }, 66 | }; 67 | const createResponse = await supertest(ctMock.app) 68 | .post("/dummy/payments") 69 | .send(draft); 70 | 71 | const response = await supertest(ctMock.app).get( 72 | `/dummy/payments/${createResponse.body.id}`, 73 | ); 74 | 75 | expect(response.status).toBe(200); 76 | expect(response.body).toEqual(createResponse.body); 77 | }); 78 | }); 79 | -------------------------------------------------------------------------------- /src/services/payment.ts: -------------------------------------------------------------------------------- 1 | import type { Router } from "express"; 2 | import type { PaymentRepository } from "../repositories/payment"; 3 | import AbstractService from "./abstract"; 4 | 5 | export class PaymentService extends AbstractService { 6 | public repository: PaymentRepository; 7 | 8 | constructor(parent: Router, repository: PaymentRepository) { 9 | super(parent); 10 | this.repository = repository; 11 | } 12 | 13 | getBasePath() { 14 | return "payments"; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/services/product-discount.ts: -------------------------------------------------------------------------------- 1 | import type { Router } from "express"; 2 | import type { ProductDiscountRepository } from "../repositories/product-discount"; 3 | import AbstractService from "./abstract"; 4 | 5 | export class ProductDiscountService extends AbstractService { 6 | public repository: ProductDiscountRepository; 7 | 8 | constructor(parent: Router, repository: ProductDiscountRepository) { 9 | super(parent); 10 | this.repository = repository; 11 | } 12 | 13 | getBasePath() { 14 | return "product-discounts"; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/services/product-projection.ts: -------------------------------------------------------------------------------- 1 | import type { Request, Response, Router } from "express"; 2 | import { queryParamsArray, queryParamsValue } from "../helpers"; 3 | import { getRepositoryContext } from "../repositories/helpers"; 4 | import type { 5 | ProductProjectionQueryParams, 6 | ProductProjectionRepository, 7 | } from "./../repositories/product-projection"; 8 | import AbstractService from "./abstract"; 9 | 10 | export class ProductProjectionService extends AbstractService { 11 | public repository: ProductProjectionRepository; 12 | 13 | constructor(parent: Router, repository: ProductProjectionRepository) { 14 | super(parent); 15 | this.repository = repository; 16 | } 17 | 18 | getBasePath() { 19 | return "product-projections"; 20 | } 21 | 22 | extraRoutes(router: Router) { 23 | router.get("/search", this.search.bind(this)); 24 | } 25 | 26 | get(request: Request, response: Response) { 27 | const limit = this._parseParam(request.query.limit); 28 | const offset = this._parseParam(request.query.offset); 29 | 30 | const result = this.repository.query(getRepositoryContext(request), { 31 | ...request.query, 32 | expand: this._parseParam(request.query.expand), 33 | where: this._parseParam(request.query.where), 34 | limit: limit !== undefined ? Number(limit) : undefined, 35 | offset: offset !== undefined ? Number(offset) : undefined, 36 | }); 37 | response.status(200).send(result); 38 | } 39 | 40 | search(request: Request, response: Response) { 41 | const query = request.query; 42 | const searchParams: ProductProjectionQueryParams = { 43 | filter: queryParamsArray(query.filter), 44 | "filter.query": queryParamsArray(query["filter.query"]), 45 | facet: queryParamsArray(query.facet), 46 | expand: queryParamsArray(query.expand), 47 | staged: queryParamsValue(query.staged) === "true", 48 | localeProjection: queryParamsValue(query.localeProjection), 49 | storeProjection: queryParamsValue(query.storeProjection), 50 | priceChannel: queryParamsValue(query.priceChannel), 51 | priceCountry: queryParamsValue(query.priceCountry), 52 | priceCurrency: queryParamsValue(query.priceCurrency), 53 | priceCustomerGroup: queryParamsValue(query.priceCustomerGroup), 54 | offset: query.offset ? Number(queryParamsValue(query.offset)) : undefined, 55 | limit: query.limit ? Number(queryParamsValue(query.limit)) : undefined, 56 | }; 57 | const resource = this.repository.search( 58 | getRepositoryContext(request), 59 | searchParams, 60 | ); 61 | response.status(200).send(resource); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/services/product-selection.test.ts: -------------------------------------------------------------------------------- 1 | import type { ProductSelectionDraft } from "@commercetools/platform-sdk"; 2 | import supertest from "supertest"; 3 | import { describe, expect, test } from "vitest"; 4 | import { CommercetoolsMock } from "../index"; 5 | 6 | const ctMock = new CommercetoolsMock(); 7 | 8 | describe("product-selection", () => { 9 | test("Create product selection", async () => { 10 | const draft: ProductSelectionDraft = { 11 | name: { 12 | en: "foo", 13 | }, 14 | key: "foo", 15 | }; 16 | const response = await supertest(ctMock.app) 17 | .post("/dummy/product-selections") 18 | .send(draft); 19 | 20 | expect(response.status).toBe(201); 21 | 22 | expect(response.body).toEqual({ 23 | createdAt: expect.anything(), 24 | id: expect.anything(), 25 | lastModifiedAt: expect.anything(), 26 | name: { 27 | en: "foo", 28 | }, 29 | key: "foo", 30 | version: 1, 31 | productCount: 0, 32 | mode: "Individual", 33 | }); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /src/services/product-selection.ts: -------------------------------------------------------------------------------- 1 | import type { Router } from "express"; 2 | import type { ProductSelectionRepository } from "../repositories/product-selection"; 3 | import AbstractService from "./abstract"; 4 | 5 | export class ProductSelectionService extends AbstractService { 6 | public repository: ProductSelectionRepository; 7 | 8 | constructor(parent: Router, repository: ProductSelectionRepository) { 9 | super(parent); 10 | this.repository = repository; 11 | } 12 | 13 | getBasePath() { 14 | return "product-selections"; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/services/product-type.test.ts: -------------------------------------------------------------------------------- 1 | import type { ProductTypeDraft } from "@commercetools/platform-sdk"; 2 | import supertest from "supertest"; 3 | import { describe, expect, test } from "vitest"; 4 | import { CommercetoolsMock } from "../index"; 5 | 6 | const ctMock = new CommercetoolsMock(); 7 | 8 | describe("Product type", () => { 9 | test("Create product type", async () => { 10 | const draft: ProductTypeDraft = { 11 | name: "foo", 12 | description: "bar", 13 | attributes: [ 14 | { 15 | name: "name", 16 | type: { name: "boolean" }, 17 | label: { "nl-NL": "bar" }, 18 | isRequired: false, 19 | }, 20 | ], 21 | }; 22 | const response = await supertest(ctMock.app) 23 | .post("/dummy/product-types") 24 | .send(draft); 25 | 26 | expect(response.status).toBe(201); 27 | 28 | expect(response.body).toEqual({ 29 | attributes: [ 30 | { 31 | attributeConstraint: "None", 32 | inputHint: "SingleLine", 33 | isRequired: false, 34 | isSearchable: true, 35 | label: { 36 | "nl-NL": "bar", 37 | }, 38 | name: "name", 39 | type: { 40 | name: "boolean", 41 | }, 42 | }, 43 | ], 44 | createdAt: expect.anything(), 45 | description: "bar", 46 | id: expect.anything(), 47 | lastModifiedAt: expect.anything(), 48 | name: "foo", 49 | version: 1, 50 | }); 51 | }); 52 | 53 | test("Get product type", async () => { 54 | const draft: ProductTypeDraft = { 55 | name: "foo", 56 | description: "bar", 57 | }; 58 | const createResponse = await supertest(ctMock.app) 59 | .post("/dummy/product-types") 60 | .send(draft); 61 | 62 | expect(createResponse.status).toBe(201); 63 | 64 | const response = await supertest(ctMock.app).get( 65 | `/dummy/product-types/${createResponse.body.id}`, 66 | ); 67 | 68 | expect(response.status).toBe(200); 69 | expect(response.body).toEqual(createResponse.body); 70 | }); 71 | }); 72 | -------------------------------------------------------------------------------- /src/services/product-type.ts: -------------------------------------------------------------------------------- 1 | import type { Router } from "express"; 2 | import type { ProductTypeRepository } from "../repositories/product-type"; 3 | import AbstractService from "./abstract"; 4 | 5 | export class ProductTypeService extends AbstractService { 6 | public repository: ProductTypeRepository; 7 | 8 | constructor(parent: Router, repository: ProductTypeRepository) { 9 | super(parent); 10 | this.repository = repository; 11 | } 12 | 13 | getBasePath() { 14 | return "product-types"; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/services/product.ts: -------------------------------------------------------------------------------- 1 | import type { Request, Response, Router } from "express"; 2 | import { getRepositoryContext } from "~src/repositories/helpers"; 3 | import type { ProductRepository } from "../repositories/product"; 4 | import AbstractService from "./abstract"; 5 | 6 | export class ProductService extends AbstractService { 7 | public repository: ProductRepository; 8 | 9 | constructor(parent: Router, repository: ProductRepository) { 10 | super(parent); 11 | this.repository = repository; 12 | } 13 | 14 | getBasePath() { 15 | return "products"; 16 | } 17 | 18 | extraRoutes(router: Router) { 19 | router.post("/search", this.search.bind(this)); 20 | } 21 | 22 | search(request: Request, response: Response) { 23 | const searchBody = request.body; 24 | const resource = this.repository.search( 25 | getRepositoryContext(request), 26 | searchBody, 27 | ); 28 | response.status(200).send(resource); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/services/project.test.ts: -------------------------------------------------------------------------------- 1 | import type { Project } from "@commercetools/platform-sdk"; 2 | import supertest from "supertest"; 3 | import { describe, expect, test } from "vitest"; 4 | import { CommercetoolsMock } from "../index"; 5 | 6 | const ctMock = new CommercetoolsMock(); 7 | 8 | describe("Project", () => { 9 | test("Get project by key", async () => { 10 | const response = await supertest(ctMock.app).get("/dummy/"); 11 | 12 | expect(response.status).toBe(200); 13 | expect(response.body).toEqual({ 14 | version: 1, 15 | carts: { 16 | countryTaxRateFallbackEnabled: false, 17 | deleteDaysAfterLastModification: 90, 18 | }, 19 | countries: [], 20 | createdAt: "2018-10-04T11:32:12.603Z", 21 | currencies: [], 22 | key: "dummy", 23 | languages: [], 24 | messages: { 25 | deleteDaysAfterCreation: 15, 26 | enabled: false, 27 | }, 28 | name: "", 29 | searchIndexing: { 30 | customers: { 31 | status: "Deactivated", 32 | }, 33 | orders: { 34 | status: "Deactivated", 35 | }, 36 | products: { 37 | status: "Deactivated", 38 | }, 39 | productsSearch: { 40 | status: "Deactivated", 41 | }, 42 | }, 43 | trialUntil: "2018-12", 44 | } as Project); 45 | }); 46 | 47 | test("Post empty update ", async () => { 48 | const response = await supertest(ctMock.app).post("/dummy/"); 49 | expect(response.statusCode).toBe(400); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /src/services/project.ts: -------------------------------------------------------------------------------- 1 | import type { Update } from "@commercetools/platform-sdk"; 2 | import type { Request, Response, Router } from "express"; 3 | import { updateRequestSchema } from "~src/schemas/update-request"; 4 | import { validateData } from "~src/validate"; 5 | import { getRepositoryContext } from "../repositories/helpers"; 6 | import type { ProjectRepository } from "../repositories/project"; 7 | 8 | export class ProjectService { 9 | public repository: ProjectRepository; 10 | 11 | constructor(parent: Router, repository: ProjectRepository) { 12 | this.repository = repository; 13 | this.registerRoutes(parent); 14 | } 15 | 16 | registerRoutes(parent: Router) { 17 | parent.get("", this.get.bind(this)); 18 | parent.post("", this.post.bind(this)); 19 | } 20 | 21 | get(request: Request, response: Response) { 22 | const project = this.repository.get(getRepositoryContext(request)); 23 | response.status(200).send(project); 24 | } 25 | 26 | post(request: Request, response: Response) { 27 | const updateRequest = validateData( 28 | request.body, 29 | updateRequestSchema, 30 | ); 31 | const project = this.repository.get(getRepositoryContext(request)); 32 | 33 | if (!project) { 34 | response.status(404).send({ statusCode: 404 }); 35 | return; 36 | } 37 | 38 | const updatedResource = this.repository.processUpdateActions( 39 | getRepositoryContext(request), 40 | project, 41 | updateRequest.version, 42 | updateRequest.actions, 43 | ); 44 | 45 | response.status(200).send(updatedResource); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/services/quote-request.test.ts: -------------------------------------------------------------------------------- 1 | import supertest from "supertest"; 2 | import { afterEach, describe, expect, it } from "vitest"; 3 | import { customerDraftFactory } from "~src/testing/customer"; 4 | import { CommercetoolsMock } from "../index"; 5 | 6 | describe("Quote Request Create", () => { 7 | const ctMock = new CommercetoolsMock(); 8 | 9 | afterEach(() => { 10 | ctMock.clear(); 11 | }); 12 | 13 | it("should create a quote request", async () => { 14 | const customer = await customerDraftFactory(ctMock).create(); 15 | let response = await supertest(ctMock.app) 16 | .post("/dummy/carts") 17 | .send({ 18 | currency: "EUR", 19 | customerId: customer.id, 20 | custom: { 21 | type: { 22 | key: "my-cart", 23 | }, 24 | fields: { 25 | description: "example description", 26 | }, 27 | }, 28 | }); 29 | expect(response.status).toBe(201); 30 | const cart = response.body; 31 | 32 | response = await supertest(ctMock.app) 33 | .post("/dummy/quote-requests") 34 | .send({ 35 | cart: { 36 | typeId: "cart", 37 | id: cart.id, 38 | }, 39 | cartVersion: cart.version, 40 | }); 41 | expect(response.status).toBe(201); 42 | const quote = response.body; 43 | 44 | expect(quote.cart).toEqual({ 45 | typeId: "cart", 46 | id: cart.id, 47 | }); 48 | 49 | response = await supertest(ctMock.app) 50 | .get(`/dummy/quote-requests/${quote.id}`) 51 | .send(); 52 | 53 | const quoteResult = response.body; 54 | expect(quoteResult.cart).toEqual({ 55 | typeId: "cart", 56 | id: cart.id, 57 | }); 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /src/services/quote-request.ts: -------------------------------------------------------------------------------- 1 | import type { Router } from "express"; 2 | import type { QuoteRequestRepository } from "~src/repositories/quote-request"; 3 | import AbstractService from "./abstract"; 4 | 5 | export class QuoteRequestService extends AbstractService { 6 | public repository: QuoteRequestRepository; 7 | 8 | constructor(parent: Router, repository: QuoteRequestRepository) { 9 | super(parent); 10 | this.repository = repository; 11 | } 12 | 13 | getBasePath() { 14 | return "quote-requests"; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/services/quote-staged.ts: -------------------------------------------------------------------------------- 1 | import type { Router } from "express"; 2 | import type { StagedQuoteRepository } from "~src/repositories/quote-staged"; 3 | import AbstractService from "./abstract"; 4 | 5 | export class StagedQuoteService extends AbstractService { 6 | public repository: StagedQuoteRepository; 7 | 8 | constructor(parent: Router, repository: StagedQuoteRepository) { 9 | super(parent); 10 | this.repository = repository; 11 | } 12 | 13 | getBasePath() { 14 | return "staged-quotes"; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/services/quote.ts: -------------------------------------------------------------------------------- 1 | import type { Router } from "express"; 2 | import type { QuoteRepository } from "~src/repositories/quote"; 3 | import AbstractService from "./abstract"; 4 | 5 | export class QuoteService extends AbstractService { 6 | public repository: QuoteRepository; 7 | 8 | constructor(parent: Router, repository: QuoteRepository) { 9 | super(parent); 10 | this.repository = repository; 11 | } 12 | 13 | getBasePath() { 14 | return "quotes"; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/services/reviews.ts: -------------------------------------------------------------------------------- 1 | import type { Router } from "express"; 2 | import type { ReviewRepository } from "../repositories/review"; 3 | import AbstractService from "./abstract"; 4 | 5 | export class ReviewService extends AbstractService { 6 | public repository: ReviewRepository; 7 | 8 | constructor(parent: Router, repository: ReviewRepository) { 9 | super(parent); 10 | this.repository = repository; 11 | } 12 | 13 | getBasePath() { 14 | return "reviews"; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/services/shipping-method.ts: -------------------------------------------------------------------------------- 1 | import type { Request, Response, Router } from "express"; 2 | import { queryParamsValue } from "../helpers"; 3 | import { getRepositoryContext } from "../repositories/helpers"; 4 | import type { ShippingMethodRepository } from "../repositories/shipping-method"; 5 | import AbstractService from "./abstract"; 6 | 7 | export class ShippingMethodService extends AbstractService { 8 | public repository: ShippingMethodRepository; 9 | 10 | constructor(parent: Router, repository: ShippingMethodRepository) { 11 | super(parent); 12 | this.repository = repository; 13 | this.registerRoutes(parent); 14 | } 15 | 16 | getBasePath() { 17 | return "shipping-methods"; 18 | } 19 | 20 | extraRoutes(parent: Router) { 21 | parent.get("/matching-cart", this.matchingCart.bind(this)); 22 | } 23 | 24 | matchingCart(request: Request, response: Response) { 25 | const cartId = queryParamsValue(request.query.cartId); 26 | if (!cartId) { 27 | response.status(400).send(); 28 | return; 29 | } 30 | const result = this.repository.matchingCart( 31 | getRepositoryContext(request), 32 | cartId, 33 | { 34 | expand: this._parseParam(request.query.expand), 35 | }, 36 | ); 37 | response.status(200).send(result); 38 | return; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/services/shopping-list.ts: -------------------------------------------------------------------------------- 1 | import type { Router } from "express"; 2 | import type { ShoppingListRepository } from "../repositories/shopping-list"; 3 | import AbstractService from "./abstract"; 4 | 5 | export class ShoppingListService extends AbstractService { 6 | public repository: ShoppingListRepository; 7 | 8 | constructor(parent: Router, repository: ShoppingListRepository) { 9 | super(parent); 10 | this.repository = repository; 11 | } 12 | 13 | getBasePath() { 14 | return "shopping-lists"; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/services/standalone-price.ts: -------------------------------------------------------------------------------- 1 | import type { Router } from "express"; 2 | import type { StandAlonePriceRepository } from "../repositories/standalone-price"; 3 | import AbstractService from "./abstract"; 4 | 5 | export class StandAlonePriceService extends AbstractService { 6 | public repository: StandAlonePriceRepository; 7 | 8 | constructor(parent: Router, repository: StandAlonePriceRepository) { 9 | super(parent); 10 | this.repository = repository; 11 | } 12 | 13 | getBasePath() { 14 | return "standalone-prices"; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/services/state.test.ts: -------------------------------------------------------------------------------- 1 | import type { StateDraft } from "@commercetools/platform-sdk"; 2 | import supertest from "supertest"; 3 | import { describe, expect, test } from "vitest"; 4 | import { CommercetoolsMock } from "../index"; 5 | 6 | const ctMock = new CommercetoolsMock(); 7 | 8 | describe("State", () => { 9 | test("Create state", async () => { 10 | const draft: StateDraft = { 11 | key: "foo", 12 | type: "PaymentState", 13 | }; 14 | const response = await supertest(ctMock.app) 15 | .post("/dummy/states") 16 | .send(draft); 17 | 18 | expect(response.status).toBe(201); 19 | 20 | expect(response.body).toEqual({ 21 | builtIn: false, 22 | createdAt: expect.anything(), 23 | id: expect.anything(), 24 | initial: false, 25 | key: "foo", 26 | lastModifiedAt: expect.anything(), 27 | transitions: [], 28 | type: "PaymentState", 29 | version: 1, 30 | }); 31 | }); 32 | 33 | test("Get state", async () => { 34 | const draft: StateDraft = { 35 | key: "foo", 36 | type: "PaymentState", 37 | }; 38 | const createResponse = await supertest(ctMock.app) 39 | .post("/dummy/states") 40 | .send(draft); 41 | 42 | expect(createResponse.status).toBe(201); 43 | 44 | const response = await supertest(ctMock.app).get( 45 | `/dummy/states/${createResponse.body.id}`, 46 | ); 47 | 48 | expect(response.status).toBe(200); 49 | expect(response.body).toEqual(createResponse.body); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /src/services/state.ts: -------------------------------------------------------------------------------- 1 | import type { Router } from "express"; 2 | import type { StateRepository } from "../repositories/state"; 3 | import AbstractService from "./abstract"; 4 | 5 | export class StateService extends AbstractService { 6 | public repository: StateRepository; 7 | 8 | constructor(parent: Router, repository: StateRepository) { 9 | super(parent); 10 | this.repository = repository; 11 | } 12 | 13 | getBasePath() { 14 | return "states"; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/services/store.test.ts: -------------------------------------------------------------------------------- 1 | import type { Store } from "@commercetools/platform-sdk"; 2 | import supertest from "supertest"; 3 | import { describe, expect, test } from "vitest"; 4 | import { CommercetoolsMock } from "../index"; 5 | 6 | const ctMock = new CommercetoolsMock(); 7 | 8 | describe("Store", () => { 9 | test("Get store by key", async () => { 10 | ctMock.project("dummy").add("store", { 11 | id: "fake-store", 12 | version: 1, 13 | createdAt: "", 14 | lastModifiedAt: "", 15 | key: "STOREKEY", 16 | countries: [], 17 | languages: [], 18 | distributionChannels: [], 19 | supplyChannels: [], 20 | productSelections: [], 21 | }); 22 | 23 | const response = await supertest(ctMock.app).get( 24 | "/dummy/stores/key=STOREKEY", 25 | ); 26 | 27 | expect(response.status).toBe(200); 28 | expect(response.body).toEqual({ 29 | version: 1, 30 | createdAt: "", 31 | id: "fake-store", 32 | key: "STOREKEY", 33 | lastModifiedAt: "", 34 | countries: [], 35 | languages: [], 36 | distributionChannels: [], 37 | supplyChannels: [], 38 | productSelections: [], 39 | } as Store); 40 | }); 41 | 42 | test("Get store by 404 when not found by key", async () => { 43 | ctMock.project("dummy").add("store", { 44 | id: "fake-store", 45 | version: 1, 46 | createdAt: "", 47 | lastModifiedAt: "", 48 | key: "STOREKEY", 49 | countries: [], 50 | languages: [], 51 | distributionChannels: [], 52 | supplyChannels: [], 53 | productSelections: [], 54 | }); 55 | 56 | const response = await supertest(ctMock.app).get( 57 | "/dummy/stores/key=DOESNOTEXIST", 58 | ); 59 | 60 | expect(response.status).toBe(404); 61 | }); 62 | }); 63 | -------------------------------------------------------------------------------- /src/services/store.ts: -------------------------------------------------------------------------------- 1 | import type { Router } from "express"; 2 | import type { StoreRepository } from "../repositories/store"; 3 | import AbstractService from "./abstract"; 4 | 5 | export class StoreService extends AbstractService { 6 | public repository: StoreRepository; 7 | 8 | constructor(parent: Router, repository: StoreRepository) { 9 | super(parent); 10 | this.repository = repository; 11 | } 12 | 13 | getBasePath() { 14 | return "stores"; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/services/subscription.ts: -------------------------------------------------------------------------------- 1 | import type { Router } from "express"; 2 | import type { SubscriptionRepository } from "../repositories/subscription"; 3 | import AbstractService from "./abstract"; 4 | 5 | export class SubscriptionService extends AbstractService { 6 | public repository: SubscriptionRepository; 7 | 8 | constructor(parent: Router, repository: SubscriptionRepository) { 9 | super(parent); 10 | this.repository = repository; 11 | } 12 | 13 | getBasePath() { 14 | return "subscriptions"; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/services/tax-category.test.ts: -------------------------------------------------------------------------------- 1 | import type { TaxCategoryDraft } from "@commercetools/platform-sdk"; 2 | import supertest from "supertest"; 3 | import { afterEach, describe, expect, test } from "vitest"; 4 | import { CommercetoolsMock } from "../index"; 5 | 6 | const ctMock = new CommercetoolsMock(); 7 | 8 | describe("Tax Category", () => { 9 | afterEach(() => { 10 | ctMock.clear(); 11 | }); 12 | test("Create tax category", async () => { 13 | const draft: TaxCategoryDraft = { 14 | name: "foo", 15 | key: "standard", 16 | rates: [], 17 | }; 18 | const response = await supertest(ctMock.app) 19 | .post("/dummy/tax-categories") 20 | .send(draft); 21 | 22 | expect(response.status).toBe(201); 23 | 24 | expect(response.body).toEqual({ 25 | createdAt: expect.anything(), 26 | id: expect.anything(), 27 | lastModifiedAt: expect.anything(), 28 | name: "foo", 29 | rates: [], 30 | key: "standard", 31 | version: 1, 32 | }); 33 | }); 34 | 35 | test("Get tax category", async () => { 36 | const draft: TaxCategoryDraft = { 37 | name: "foo", 38 | key: "standard", 39 | rates: [], 40 | }; 41 | const createResponse = await supertest(ctMock.app) 42 | .post("/dummy/tax-categories") 43 | .send(draft); 44 | 45 | expect(createResponse.status).toBe(201); 46 | 47 | const response = await supertest(ctMock.app).get( 48 | `/dummy/tax-categories/${createResponse.body.id}`, 49 | ); 50 | 51 | expect(response.status).toBe(200); 52 | expect(response.body).toEqual(createResponse.body); 53 | }); 54 | 55 | test("Get tax category with key", async () => { 56 | const draft: TaxCategoryDraft = { 57 | name: "foo", 58 | key: "standard", 59 | rates: [], 60 | }; 61 | const createResponse = await supertest(ctMock.app) 62 | .post("/dummy/tax-categories") 63 | .send(draft); 64 | 65 | expect(createResponse.status).toBe(201); 66 | 67 | const response = await supertest(ctMock.app) 68 | .get("/dummy/tax-categories/") 69 | .query({ where: `key="${createResponse.body.key}"` }); 70 | 71 | expect(response.status).toBe(200); 72 | expect(response.body).toEqual({ 73 | count: 1, 74 | limit: 20, 75 | offset: 0, 76 | total: 1, 77 | results: [createResponse.body], 78 | }); 79 | }); 80 | }); 81 | -------------------------------------------------------------------------------- /src/services/tax-category.ts: -------------------------------------------------------------------------------- 1 | import type { Router } from "express"; 2 | import type { TaxCategoryRepository } from "../repositories/tax-category"; 3 | import AbstractService from "./abstract"; 4 | 5 | export class TaxCategoryService extends AbstractService { 6 | public repository: TaxCategoryRepository; 7 | 8 | constructor(parent: Router, repository: TaxCategoryRepository) { 9 | super(parent); 10 | this.repository = repository; 11 | } 12 | 13 | getBasePath() { 14 | return "tax-categories"; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/services/type.ts: -------------------------------------------------------------------------------- 1 | import type { Router } from "express"; 2 | import type { TypeRepository } from "../repositories/type"; 3 | import AbstractService from "./abstract"; 4 | 5 | export class TypeService extends AbstractService { 6 | public repository: TypeRepository; 7 | 8 | constructor(parent: Router, repository: TypeRepository) { 9 | super(parent); 10 | this.repository = repository; 11 | } 12 | 13 | getBasePath() { 14 | return "types"; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/services/zone.ts: -------------------------------------------------------------------------------- 1 | import type { Router } from "express"; 2 | import type { ZoneRepository } from "../repositories/zone"; 3 | import AbstractService from "./abstract"; 4 | 5 | export class ZoneService extends AbstractService { 6 | public repository: ZoneRepository; 7 | 8 | constructor(parent: Router, repository: ZoneRepository) { 9 | super(parent); 10 | this.repository = repository; 11 | } 12 | 13 | getBasePath() { 14 | return "zones"; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/storage/abstract.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | BaseResource, 3 | Project, 4 | QueryParam, 5 | ResourceIdentifier, 6 | } from "@commercetools/platform-sdk"; 7 | import type { 8 | PagedQueryResponseMap, 9 | ResourceMap, 10 | ResourceType, 11 | } from "../types"; 12 | 13 | export type GetParams = { 14 | expand?: string[]; 15 | }; 16 | 17 | export type QueryParams = { 18 | expand?: string | string[]; 19 | sort?: string | string[]; 20 | limit?: number; 21 | offset?: number; 22 | withTotal?: boolean; 23 | where?: string | string[]; 24 | [key: string]: QueryParam; 25 | }; 26 | 27 | export abstract class AbstractStorage { 28 | abstract clear(): void; 29 | 30 | abstract all( 31 | projectKey: string, 32 | typeId: RT, 33 | ): Array; 34 | 35 | abstract add( 36 | projectKey: string, 37 | typeId: RT, 38 | obj: ResourceMap[RT], 39 | ): ResourceMap[RT]; 40 | 41 | abstract get( 42 | projectKey: string, 43 | typeId: RT, 44 | id: string, 45 | params?: GetParams, 46 | ): ResourceMap[RT] | null; 47 | 48 | abstract getByKey( 49 | projectKey: string, 50 | typeId: RT, 51 | key: string, 52 | params: GetParams, 53 | ): ResourceMap[RT] | null; 54 | 55 | abstract addProject(projectKey: string): Project; 56 | 57 | abstract getProject(projectKey: string): Project; 58 | 59 | abstract saveProject(project: Project): Project; 60 | 61 | abstract delete( 62 | projectKey: string, 63 | typeId: RT, 64 | id: string, 65 | params: GetParams, 66 | ): ResourceMap[RT] | null; 67 | 68 | abstract query( 69 | projectKey: string, 70 | typeId: RT, 71 | params: QueryParams, 72 | ): PagedQueryResponseMap[RT]; 73 | 74 | abstract getByResourceIdentifier( 75 | projectKey: string, 76 | identifier: ResourceIdentifier, 77 | ): ResourceMap[RT]; 78 | 79 | abstract expand( 80 | projectKey: string, 81 | obj: T, 82 | clause: undefined | string | string[], 83 | ): T; 84 | } 85 | 86 | export type ProjectStorage = { 87 | [index in ResourceType]: Map; 88 | }; 89 | -------------------------------------------------------------------------------- /src/storage/index.ts: -------------------------------------------------------------------------------- 1 | export { AbstractStorage } from "./abstract"; 2 | export { InMemoryStorage } from "./in-memory"; 3 | -------------------------------------------------------------------------------- /src/testing/customer.ts: -------------------------------------------------------------------------------- 1 | import type { Customer, CustomerDraft } from "@commercetools/platform-sdk"; 2 | import { Factory } from "fishery"; 3 | import supertest from "supertest"; 4 | import type { CommercetoolsMock } from "~src/ctMock"; 5 | 6 | export const customerDraftFactory = (m: CommercetoolsMock) => 7 | Factory.define(({ onCreate }) => { 8 | onCreate(async (draft) => { 9 | const response = await supertest(m.app) 10 | .post("/dummy/customers") 11 | .send(draft); 12 | 13 | return response.body.customer; 14 | }); 15 | 16 | return { 17 | email: "customer@example.com", 18 | firstName: "John", 19 | lastName: "Doe", 20 | locale: "nl-NL", 21 | password: "my-secret-pw", 22 | addresses: [ 23 | { 24 | firstName: "John", 25 | lastName: "Doe", 26 | streetName: "Street name", 27 | streetNumber: "42", 28 | postalCode: "1234 AB", 29 | city: "Utrecht", 30 | country: "NL", 31 | company: "Lab Digital", 32 | phone: "+31612345678", 33 | email: "customer@example.com", 34 | }, 35 | ], 36 | isEmailVerified: false, 37 | stores: [], 38 | authenticationMode: "Password", 39 | }; 40 | }); 41 | -------------------------------------------------------------------------------- /src/validate.ts: -------------------------------------------------------------------------------- 1 | import type { InvalidJsonInputError } from "@commercetools/platform-sdk"; 2 | import type { z } from "zod"; 3 | import { fromZodError } from "zod-validation-error"; 4 | import { CommercetoolsError } from "./exceptions"; 5 | 6 | export const validateData = (data: any, schema: z.AnyZodObject) => { 7 | try { 8 | schema.parse(data); 9 | return data as T; 10 | } catch (err: any) { 11 | const validationError = fromZodError(err); 12 | throw new CommercetoolsError({ 13 | code: "InvalidJsonInput", 14 | message: "Request body does not contain valid JSON.", 15 | detailedErrorMessage: validationError.toString(), 16 | }); 17 | } 18 | }; 19 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "compilerOptions": { 4 | "allowJs": true, 5 | "baseUrl": ".", 6 | "esModuleInterop": true, 7 | "isolatedModules": true, 8 | "module": "ES2022", 9 | "moduleResolution": "bundler", 10 | "outDir": "dist", 11 | "preserveWatchOutput": true, 12 | "resolveJsonModule": true, 13 | "sourceMap": true, 14 | "skipLibCheck": true, 15 | "strict": true, 16 | "strictPropertyInitialization": false, 17 | "target": "ES2022", 18 | "paths": { 19 | "~src/*": ["./src/*"], 20 | "~vendor/*": ["./vendor/*"] 21 | } 22 | }, 23 | "include": ["./src/**/*.ts"] 24 | } 25 | -------------------------------------------------------------------------------- /tsdown.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "tsdown"; 2 | 3 | export default defineConfig([ 4 | { 5 | entry: ["src/index.ts"], 6 | clean: true, 7 | splitting: false, 8 | dts: true, 9 | sourcemap: true, 10 | format: ["esm"], 11 | outDir: "dist", 12 | }, 13 | ]); 14 | -------------------------------------------------------------------------------- /vendor/perplex/README.md: -------------------------------------------------------------------------------- 1 | # Perplex 2 | 3 | This is vendored from https://github.com/jrop/perplex 4 | Reason is that the original code is hard/impossible to import into ESM based 5 | projects 6 | -------------------------------------------------------------------------------- /vendor/perplex/lexer-state.ts: -------------------------------------------------------------------------------- 1 | import TokenTypes from "./token-types"; 2 | 3 | /** 4 | * @private 5 | */ 6 | export default class LexerState { 7 | public source: string; 8 | public position: number; 9 | public tokenTypes: TokenTypes; 10 | 11 | constructor(source: string, position: number = 0) { 12 | this.source = source; 13 | this.position = position; 14 | } 15 | 16 | copy() { 17 | return new LexerState(this.source, this.position); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /vendor/perplex/token-types.ts: -------------------------------------------------------------------------------- 1 | // Thank you, http://stackoverflow.com/a/6969486 2 | function toRegExp(str: string): RegExp { 3 | return new RegExp(str.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, "\\$&")); 4 | } 5 | 6 | function normalize(regex: RegExp | string): RegExp { 7 | if (typeof regex === "string") regex = toRegExp(regex); 8 | if (!regex.source.startsWith("^")) 9 | return new RegExp(`^${regex.source}`, regex.flags); 10 | else return regex; 11 | } 12 | 13 | function first( 14 | arr: T[], 15 | predicate: (item: T, i: number) => U, 16 | ): { item: T; result: U } | undefined { 17 | let i = 0; 18 | for (const item of arr) { 19 | const result = predicate(item, i++); 20 | if (result) return { item, result }; 21 | } 22 | } 23 | 24 | /** 25 | * @private 26 | */ 27 | export default class TokenTypes { 28 | public tokenTypes: { 29 | type: T; 30 | regex: RegExp; 31 | enabled: boolean; 32 | skip: boolean; 33 | }[]; 34 | 35 | constructor() { 36 | this.tokenTypes = []; 37 | } 38 | 39 | disable(type: T): TokenTypes { 40 | return this.enable(type, false); 41 | } 42 | 43 | enable(type: T, enabled: boolean = true): TokenTypes { 44 | this.tokenTypes 45 | .filter((t) => t.type == type) 46 | .forEach((t) => (t.enabled = enabled)); 47 | return this; 48 | } 49 | 50 | isEnabled(type: T) { 51 | const ttypes = this.tokenTypes.filter((tt) => tt.type == type); 52 | if (ttypes.length == 0) 53 | throw new Error(`Token of type ${type} does not exists`); 54 | return ttypes[0].enabled; 55 | } 56 | 57 | peek(source: string, position: number) { 58 | const s = source.substr(position); 59 | return first( 60 | this.tokenTypes.filter((tt) => tt.enabled), 61 | (tt) => { 62 | tt.regex.lastIndex = 0; 63 | return tt.regex.exec(s); 64 | }, 65 | ); 66 | } 67 | 68 | token( 69 | type: T, 70 | pattern: RegExp | string, 71 | skip: boolean = false, 72 | ): TokenTypes { 73 | this.tokenTypes.push({ 74 | type, 75 | regex: normalize(pattern), 76 | enabled: true, 77 | skip, 78 | }); 79 | return this; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /vendor/perplex/token.ts: -------------------------------------------------------------------------------- 1 | import Lexer from "./lexer"; 2 | 3 | /** 4 | * @typedef {{ 5 | * start: Position, 6 | * end: Position, 7 | * }} TokenPosition 8 | */ 9 | 10 | /** 11 | * Represents a token instance 12 | */ 13 | class Token { 14 | type: T; 15 | 16 | match: string; 17 | 18 | groups: string[]; 19 | 20 | start: number; 21 | 22 | end: number; 23 | 24 | lexer: Lexer; 25 | 26 | /* tslint:disable:indent */ 27 | /** 28 | * Constructs a token 29 | * @param {T} type The token type 30 | * @param {string} match The string that the lexer consumed to create this token 31 | * @param {string[]} groups Any RegExp groups that accrued during the match 32 | * @param {number} start The string position where this match started 33 | * @param {number} end The string position where this match ends 34 | * @param {Lexer} lexer The parent {@link Lexer} 35 | */ 36 | constructor( 37 | type: T, 38 | match: string, 39 | groups: string[], 40 | start: number, 41 | end: number, 42 | lexer: Lexer, 43 | ) { 44 | /* tslint:enable */ 45 | /** 46 | * The token type 47 | * @type {T} 48 | */ 49 | this.type = type; 50 | 51 | /** 52 | * The string that the lexer consumed to create this token 53 | * @type {string} 54 | */ 55 | this.match = match; 56 | 57 | /** 58 | * Any RegExp groups that accrued during the match 59 | * @type {string[]} 60 | */ 61 | this.groups = groups; 62 | 63 | /** 64 | * The string position where this match started 65 | * @type {number} 66 | */ 67 | this.start = start; 68 | 69 | /** 70 | * The string position where this match ends 71 | * @type {number} 72 | */ 73 | this.end = end; 74 | 75 | /** 76 | * The parent {@link Lexer} 77 | * @type {Lexer} 78 | */ 79 | this.lexer = lexer; 80 | } 81 | 82 | /** 83 | * Returns the bounds of this token, each in `{line, column}` format 84 | * @return {TokenPosition} 85 | */ 86 | strpos() { 87 | const start = this.lexer.strpos(this.start); 88 | const end = this.lexer.strpos(this.end); 89 | return { start, end }; 90 | } 91 | 92 | // tslint:disable-next-line prefer-function-over-method 93 | isEof() { 94 | return false; 95 | } 96 | } 97 | 98 | export default Token; 99 | 100 | export class EOFToken extends Token { 101 | constructor(lexer: Lexer) { 102 | const end = lexer.source.length; 103 | super(null as T, "(eof)", [], end, end, lexer); 104 | } 105 | 106 | // tslint:disable-next-line prefer-function-over-method 107 | isEof() { 108 | return true; 109 | } 110 | } 111 | 112 | /** 113 | * @private 114 | */ 115 | export const EOF = (lexer: Lexer) => new EOFToken(lexer); 116 | -------------------------------------------------------------------------------- /vendor/pratt/README.md: -------------------------------------------------------------------------------- 1 | # Pratt 2 | 3 | This is vendored from https://github.com/jrop/pratt 4 | Reason is that the original code is hard/impossible to import into ESM based 5 | projects 6 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import path from "node:path"; 2 | import { defineConfig } from "vitest/config"; 3 | 4 | export default defineConfig({ 5 | test: { 6 | testTimeout: 5000, 7 | coverage: { 8 | provider: "v8", 9 | all: true, 10 | include: ["src/**/*.ts", "vendor/**/*.ts"], 11 | }, 12 | passWithNoTests: true, 13 | }, 14 | resolve: { 15 | alias: { 16 | "~src": path.join(__dirname, "src"), 17 | "~vendor": path.join(__dirname, "vendor"), 18 | }, 19 | }, 20 | }); 21 | --------------------------------------------------------------------------------